OpenStack Dashboard (Horizon)
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

base.py 35KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944
  1. # Copyright 2012 Nebula, Inc.
  2. #
  3. # Licensed under the Apache License, Version 2.0 (the "License"); you may
  4. # not use this file except in compliance with the License. You may obtain
  5. # a copy of the License at
  6. #
  7. # http://www.apache.org/licenses/LICENSE-2.0
  8. #
  9. # Unless required by applicable law or agreed to in writing, software
  10. # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
  11. # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
  12. # License for the specific language governing permissions and limitations
  13. # under the License.
  14. import collections
  15. import copy
  16. from importlib import import_module
  17. import inspect
  18. import logging
  19. from django.conf import settings
  20. from django import forms
  21. from django.forms.forms import NON_FIELD_ERRORS
  22. from django import template
  23. from django.template.defaultfilters import linebreaks
  24. from django.template.defaultfilters import safe
  25. from django.template.defaultfilters import slugify
  26. from django import urls
  27. from django.utils.encoding import force_text
  28. from django.utils import module_loading
  29. from django.utils.translation import ugettext_lazy as _
  30. from openstack_auth import policy
  31. import six
  32. from horizon import base
  33. from horizon import exceptions
  34. from horizon.templatetags.horizon import has_permissions
  35. from horizon.utils import html
  36. LOG = logging.getLogger(__name__)
  37. class WorkflowContext(dict):
  38. def __init__(self, workflow, *args, **kwargs):
  39. super(WorkflowContext, self).__init__(*args, **kwargs)
  40. self._workflow = workflow
  41. def __setitem__(self, key, val):
  42. super(WorkflowContext, self).__setitem__(key, val)
  43. return self._workflow._trigger_handlers(key)
  44. def __delitem__(self, key):
  45. return self.__setitem__(key, None)
  46. def set(self, key, val):
  47. return self.__setitem__(key, val)
  48. def unset(self, key):
  49. return self.__delitem__(key)
  50. class ActionMetaclass(forms.forms.DeclarativeFieldsMetaclass):
  51. def __new__(mcs, name, bases, attrs):
  52. # Pop Meta for later processing
  53. opts = attrs.pop("Meta", None)
  54. # Create our new class
  55. cls = super(ActionMetaclass, mcs).__new__(mcs, name, bases, attrs)
  56. # Process options from Meta
  57. cls.name = getattr(opts, "name", name)
  58. cls.slug = getattr(opts, "slug", slugify(name))
  59. cls.permissions = getattr(opts, "permissions", ())
  60. cls.policy_rules = getattr(opts, "policy_rules", ())
  61. cls.progress_message = getattr(opts,
  62. "progress_message",
  63. _("Processing..."))
  64. cls.help_text = getattr(opts, "help_text", "")
  65. cls.help_text_template = getattr(opts, "help_text_template", None)
  66. return cls
  67. @six.python_2_unicode_compatible
  68. @six.add_metaclass(ActionMetaclass)
  69. class Action(forms.Form):
  70. """An ``Action`` represents an atomic logical interaction with the system.
  71. This is easier to understand with a conceptual example: in the context of
  72. a "launch instance" workflow, actions would include "naming the instance",
  73. "selecting an image", and ultimately "launching the instance".
  74. Because ``Actions`` are always interactive, they always provide form
  75. controls, and thus inherit from Django's ``Form`` class. However, they
  76. have some additional intelligence added to them:
  77. * ``Actions`` are aware of the permissions required to complete them.
  78. * ``Actions`` have a meta-level concept of "help text" which is meant to be
  79. displayed in such a way as to give context to the action regardless of
  80. where the action is presented in a site or workflow.
  81. * ``Actions`` understand how to handle their inputs and produce outputs,
  82. much like :class:`~horizon.forms.SelfHandlingForm` does now.
  83. ``Action`` classes may define the following attributes in a ``Meta``
  84. class within them:
  85. .. attribute:: name
  86. The verbose name for this action. Defaults to the name of the class.
  87. .. attribute:: slug
  88. A semi-unique slug for this action. Defaults to the "slugified" name
  89. of the class.
  90. .. attribute:: permissions
  91. A list of permission names which this action requires in order to be
  92. completed. Defaults to an empty list (``[]``).
  93. .. attribute:: policy_rules
  94. list of scope and rule tuples to do policy checks on, the
  95. composition of which is (scope, rule)
  96. * scope: service type managing the policy for action
  97. * rule: string representing the action to be checked
  98. for a policy that requires a single rule check::
  99. policy_rules should look like
  100. "(("compute", "compute:create_instance"),)"
  101. for a policy that requires multiple rule checks::
  102. rules should look like
  103. "(("identity", "identity:list_users"),
  104. ("identity", "identity:list_roles"))"
  105. where two service-rule clauses are OR-ed.
  106. .. attribute:: help_text
  107. A string of simple help text to be displayed alongside the Action's
  108. fields.
  109. .. attribute:: help_text_template
  110. A path to a template which contains more complex help text to be
  111. displayed alongside the Action's fields. In conjunction with
  112. :meth:`~horizon.workflows.Action.get_help_text` method you can
  113. customize your help text template to display practically anything.
  114. """
  115. def __init__(self, request, context, *args, **kwargs):
  116. if request.method == "POST":
  117. super(Action, self).__init__(request.POST, initial=context)
  118. else:
  119. super(Action, self).__init__(initial=context)
  120. if not hasattr(self, "handle"):
  121. raise AttributeError("The action %s must define a handle method."
  122. % self.__class__.__name__)
  123. self.request = request
  124. self._populate_choices(request, context)
  125. self.required_css_class = 'required'
  126. def __str__(self):
  127. return force_text(self.name)
  128. def __repr__(self):
  129. return "<%s: %s>" % (self.__class__.__name__, self.slug)
  130. def _populate_choices(self, request, context):
  131. for field_name, bound_field in self.fields.items():
  132. meth = getattr(self, "populate_%s_choices" % field_name, None)
  133. if meth is not None and callable(meth):
  134. bound_field.choices = meth(request, context)
  135. def get_help_text(self, extra_context=None):
  136. """Returns the help text for this step."""
  137. text = ""
  138. extra_context = extra_context or {}
  139. if self.help_text_template:
  140. tmpl = template.loader.get_template(self.help_text_template)
  141. text += tmpl.render(extra_context, self.request)
  142. else:
  143. text += linebreaks(force_text(self.help_text))
  144. return safe(text)
  145. def add_action_error(self, message):
  146. """Adds an error to the Action's Step based on API issues."""
  147. self.errors[NON_FIELD_ERRORS] = self.error_class([message])
  148. def handle(self, request, context):
  149. """Handles any requisite processing for this action.
  150. The method should return either ``None`` or a dictionary of data
  151. to be passed to :meth:`~horizon.workflows.Step.contribute`.
  152. Returns ``None`` by default, effectively making it a no-op.
  153. """
  154. return None
  155. class MembershipAction(Action):
  156. """An action that allows a user to add/remove members from a group.
  157. Extend the Action class with additional helper method for membership
  158. management.
  159. """
  160. def get_default_role_field_name(self):
  161. return "default_" + self.slug + "_role"
  162. def get_member_field_name(self, role_id):
  163. return self.slug + "_role_" + role_id
  164. @six.python_2_unicode_compatible
  165. class Step(object):
  166. """A wrapper around an action which defines its context in a workflow.
  167. It knows about details such as:
  168. * The workflow's context data (data passed from step to step).
  169. * The data which must be present in the context to begin this step (the
  170. step's dependencies).
  171. * The keys which will be added to the context data upon completion of the
  172. step.
  173. * The connections between this step's fields and changes in the context
  174. data (e.g. if that piece of data changes, what needs to be updated in
  175. this step).
  176. A ``Step`` class has the following attributes:
  177. .. attribute:: action_class
  178. The :class:`~horizon.workflows.Action` class which this step wraps.
  179. .. attribute:: depends_on
  180. A list of context data keys which this step requires in order to
  181. begin interaction.
  182. .. attribute:: contributes
  183. A list of keys which this step will contribute to the workflow's
  184. context data. Optional keys should still be listed, even if their
  185. values may be set to ``None``.
  186. .. attribute:: connections
  187. A dictionary which maps context data key names to lists of callbacks.
  188. The callbacks may be functions, dotted python paths to functions
  189. which may be imported, or dotted strings beginning with ``"self"``
  190. to indicate methods on the current ``Step`` instance.
  191. .. attribute:: before
  192. Another ``Step`` class. This optional attribute is used to provide
  193. control over workflow ordering when steps are dynamically added to
  194. workflows. The workflow mechanism will attempt to place the current
  195. step before the step specified in the attribute.
  196. .. attribute:: after
  197. Another ``Step`` class. This attribute has the same purpose as
  198. :meth:`~horizon.workflows.Step.before` except that it will instead
  199. attempt to place the current step after the given step.
  200. .. attribute:: help_text
  201. A string of simple help text which will be prepended to the ``Action``
  202. class' help text if desired.
  203. .. attribute:: template_name
  204. A path to a template which will be used to render this step. In
  205. general the default common template should be used. Default:
  206. ``"horizon/common/_workflow_step.html"``.
  207. .. attribute:: has_errors
  208. A boolean value which indicates whether or not this step has any
  209. errors on the action within it or in the scope of the workflow. This
  210. attribute will only accurately reflect this status after validation
  211. has occurred.
  212. .. attribute:: slug
  213. Inherited from the ``Action`` class.
  214. .. attribute:: name
  215. Inherited from the ``Action`` class.
  216. .. attribute:: permissions
  217. Inherited from the ``Action`` class.
  218. """
  219. action_class = None
  220. depends_on = ()
  221. contributes = ()
  222. connections = None
  223. before = None
  224. after = None
  225. help_text = ""
  226. template_name = "horizon/common/_workflow_step.html"
  227. def __repr__(self):
  228. return "<%s: %s>" % (self.__class__.__name__, self.slug)
  229. def __str__(self):
  230. return force_text(self.name)
  231. def __init__(self, workflow):
  232. super(Step, self).__init__()
  233. self.workflow = workflow
  234. cls = self.__class__.__name__
  235. if not (self.action_class and issubclass(self.action_class, Action)):
  236. raise AttributeError("action_class not specified for %s." % cls)
  237. self.slug = self.action_class.slug
  238. self.name = self.action_class.name
  239. self.permissions = self.action_class.permissions
  240. self.policy_rules = self.action_class.policy_rules
  241. self.has_errors = False
  242. self._handlers = {}
  243. if self.connections is None:
  244. # We want a dict, but don't want to declare a mutable type on the
  245. # class directly.
  246. self.connections = {}
  247. # Gather our connection handlers and make sure they exist.
  248. for key, handlers in self.connections.items():
  249. self._handlers[key] = []
  250. # TODO(gabriel): This is a poor substitute for broader handling
  251. if not isinstance(handlers, (list, tuple)):
  252. raise TypeError("The connection handlers for %s must be a "
  253. "list or tuple." % cls)
  254. for possible_handler in handlers:
  255. if callable(possible_handler):
  256. # If it's callable we know the function exists and is valid
  257. self._handlers[key].append(possible_handler)
  258. continue
  259. elif not isinstance(possible_handler, six.string_types):
  260. raise TypeError("Connection handlers must be either "
  261. "callables or strings.")
  262. bits = possible_handler.split(".")
  263. if bits[0] == "self":
  264. root = self
  265. for bit in bits[1:]:
  266. try:
  267. root = getattr(root, bit)
  268. except AttributeError:
  269. raise AttributeError("The connection handler %s "
  270. "could not be found on %s."
  271. % (possible_handler, cls))
  272. handler = root
  273. elif len(bits) == 1:
  274. # Import by name from local module not supported
  275. raise ValueError("Importing a local function as a string "
  276. "is not supported for the connection "
  277. "handler %s on %s."
  278. % (possible_handler, cls))
  279. else:
  280. # Try a general import
  281. module_name = ".".join(bits[:-1])
  282. try:
  283. mod = import_module(module_name)
  284. handler = getattr(mod, bits[-1])
  285. except ImportError:
  286. raise ImportError("Could not import %s from the "
  287. "module %s as a connection "
  288. "handler on %s."
  289. % (bits[-1], module_name, cls))
  290. except AttributeError:
  291. raise AttributeError("Could not import %s from the "
  292. "module %s as a connection "
  293. "handler on %s."
  294. % (bits[-1], module_name, cls))
  295. self._handlers[key].append(handler)
  296. @property
  297. def action(self):
  298. if not getattr(self, "_action", None):
  299. try:
  300. # Hook in the action context customization.
  301. workflow_context = dict(self.workflow.context)
  302. context = self.prepare_action_context(self.workflow.request,
  303. workflow_context)
  304. self._action = self.action_class(self.workflow.request,
  305. context)
  306. except Exception:
  307. LOG.exception("Problem instantiating action class.")
  308. raise
  309. return self._action
  310. def prepare_action_context(self, request, context):
  311. """Hook to customize how the workflow context is passed to the action.
  312. This is the reverse of what "contribute" does to make the
  313. action outputs sane for the workflow. Changes to the context are not
  314. saved globally here. They are localized to the action.
  315. Simply returns the unaltered context by default.
  316. """
  317. return context
  318. def get_id(self):
  319. """Returns the ID for this step. Suitable for use in HTML markup."""
  320. return "%s__%s" % (self.workflow.slug, self.slug)
  321. def _verify_contributions(self, context):
  322. for key in self.contributes:
  323. # Make sure we don't skip steps based on weird behavior of
  324. # POST query dicts.
  325. field = self.action.fields.get(key, None)
  326. if field and field.required and not context.get(key):
  327. context.pop(key, None)
  328. failed_to_contribute = set(self.contributes)
  329. failed_to_contribute -= set(context.keys())
  330. if failed_to_contribute:
  331. raise exceptions.WorkflowError("The following expected data was "
  332. "not added to the workflow context "
  333. "by the step %s: %s."
  334. % (self.__class__,
  335. failed_to_contribute))
  336. return True
  337. def contribute(self, data, context):
  338. """Adds the data listed in ``contributes`` to the workflow's context.
  339. By default, the context is simply updated with all the data
  340. returned by the action.
  341. Note that even if the value of one of the ``contributes`` keys is
  342. not present (e.g. optional) the key should still be added to the
  343. context with a value of ``None``.
  344. """
  345. if data:
  346. for key in self.contributes:
  347. context[key] = data.get(key, None)
  348. return context
  349. def render(self):
  350. """Renders the step."""
  351. step_template = template.loader.get_template(self.template_name)
  352. extra_context = {"form": self.action,
  353. "step": self}
  354. return step_template.render(extra_context, self.workflow.request)
  355. def get_help_text(self):
  356. """Returns the help text for this step."""
  357. text = linebreaks(force_text(self.help_text))
  358. text += self.action.get_help_text()
  359. return safe(text)
  360. def add_step_error(self, message):
  361. """Adds an error to the Step based on API issues."""
  362. self.action.add_action_error(message)
  363. def has_required_fields(self):
  364. """Returns True if action contains any required fields."""
  365. return any(field.required for field in self.action.fields.values())
  366. def allowed(self, request):
  367. """Determines whether or not the step is displayed.
  368. Step instances can override this method to specify conditions under
  369. which this tab should not be shown at all by returning ``False``.
  370. The default behavior is to return ``True`` for all cases.
  371. """
  372. return True
  373. class WorkflowMetaclass(type):
  374. def __new__(mcs, name, bases, attrs):
  375. super(WorkflowMetaclass, mcs).__new__(mcs, name, bases, attrs)
  376. attrs["_cls_registry"] = []
  377. return type.__new__(mcs, name, bases, attrs)
  378. class UpdateMembersStep(Step):
  379. """A step that allows a user to add/remove members from a group.
  380. .. attribute:: show_roles
  381. Set to False to disable the display of the roles dropdown.
  382. .. attribute:: available_list_title
  383. The title used for the available list column.
  384. .. attribute:: members_list_title
  385. The title used for the members list column.
  386. .. attribute:: no_available_text
  387. The placeholder text used when the available list is empty.
  388. .. attribute:: no_members_text
  389. The placeholder text used when the members list is empty.
  390. """
  391. template_name = "horizon/common/_workflow_step_update_members.html"
  392. show_roles = True
  393. available_list_title = _("All available")
  394. members_list_title = _("Members")
  395. no_available_text = _("None available.")
  396. no_members_text = _("No members.")
  397. def get_member_field_name(self, role_id):
  398. if issubclass(self.action_class, MembershipAction):
  399. return self.action.get_member_field_name(role_id)
  400. else:
  401. return self.slug + "_role_" + role_id
  402. @six.python_2_unicode_compatible
  403. @six.add_metaclass(WorkflowMetaclass)
  404. class Workflow(html.HTMLElement):
  405. """A Workflow is a collection of Steps.
  406. Its interface is very straightforward, but it is responsible for handling
  407. some very important tasks such as:
  408. * Handling the injection, removal, and ordering of arbitrary steps.
  409. * Determining if the workflow can be completed by a given user at runtime
  410. based on all available information.
  411. * Dispatching connections between steps to ensure that when context data
  412. changes all the applicable callback functions are executed.
  413. * Verifying/validating the overall data integrity and subsequently
  414. triggering the final method to complete the workflow.
  415. The ``Workflow`` class has the following attributes:
  416. .. attribute:: name
  417. The verbose name for this workflow which will be displayed to the user.
  418. Defaults to the class name.
  419. .. attribute:: slug
  420. The unique slug for this workflow. Required.
  421. .. attribute:: steps
  422. Read-only access to the final ordered set of step instances for
  423. this workflow.
  424. .. attribute:: default_steps
  425. A list of :class:`~horizon.workflows.Step` classes which serve as the
  426. starting point for this workflow's ordered steps. Defaults to an empty
  427. list (``[]``).
  428. .. attribute:: finalize_button_name
  429. The name which will appear on the submit button for the workflow's
  430. form. Defaults to ``"Save"``.
  431. .. attribute:: success_message
  432. A string which will be displayed to the user upon successful completion
  433. of the workflow. Defaults to
  434. ``"{{ workflow.name }} completed successfully."``
  435. .. attribute:: failure_message
  436. A string which will be displayed to the user upon failure to complete
  437. the workflow. Defaults to ``"{{ workflow.name }} did not complete."``
  438. .. attribute:: depends_on
  439. A roll-up list of all the ``depends_on`` values compiled from the
  440. workflow's steps.
  441. .. attribute:: contributions
  442. A roll-up list of all the ``contributes`` values compiled from the
  443. workflow's steps.
  444. .. attribute:: template_name
  445. Path to the template which should be used to render this workflow.
  446. In general the default common template should be used. Default:
  447. ``"horizon/common/_workflow.html"``.
  448. .. attribute:: entry_point
  449. The slug of the step which should initially be active when the
  450. workflow is rendered. This can be passed in upon initialization of
  451. the workflow, or set anytime after initialization but before calling
  452. either ``get_entry_point`` or ``render``.
  453. .. attribute:: redirect_param_name
  454. The name of a parameter used for tracking the URL to redirect to upon
  455. completion of the workflow. Defaults to ``"next"``.
  456. .. attribute:: object
  457. The object (if any) which this workflow relates to. In the case of
  458. a workflow which creates a new resource the object would be the created
  459. resource after the relevant creation steps have been undertaken. In
  460. the case of a workflow which updates a resource it would be the
  461. resource being updated after it has been retrieved.
  462. .. attribute:: wizard
  463. Whether to present the workflow as a wizard, with "prev" and "next"
  464. buttons and validation after every step.
  465. """
  466. slug = None
  467. default_steps = ()
  468. template_name = "horizon/common/_workflow.html"
  469. finalize_button_name = _("Save")
  470. success_message = _("%s completed successfully.")
  471. failure_message = _("%s did not complete.")
  472. redirect_param_name = "next"
  473. multipart = False
  474. wizard = False
  475. _registerable_class = Step
  476. def __str__(self):
  477. return self.name
  478. def __repr__(self):
  479. return "<%s: %s>" % (self.__class__.__name__, self.slug)
  480. def __init__(self, request=None, context_seed=None, entry_point=None,
  481. *args, **kwargs):
  482. super(Workflow, self).__init__(*args, **kwargs)
  483. if self.slug is None:
  484. raise AttributeError("The workflow %s must have a slug."
  485. % self.__class__.__name__)
  486. self.name = getattr(self, "name", self.__class__.__name__)
  487. self.request = request
  488. self.depends_on = set([])
  489. self.contributions = set([])
  490. self.entry_point = entry_point
  491. self.object = None
  492. self._register_steps_from_config()
  493. # Put together our steps in order. Note that we pre-register
  494. # non-default steps so that we can identify them and subsequently
  495. # insert them in order correctly.
  496. self._registry = collections.OrderedDict(
  497. [(step_class, step_class(self)) for step_class
  498. in self.__class__._cls_registry
  499. if step_class not in self.default_steps])
  500. self._gather_steps()
  501. # Determine all the context data we need to end up with.
  502. for step in self.steps:
  503. self.depends_on = self.depends_on | set(step.depends_on)
  504. self.contributions = self.contributions | set(step.contributes)
  505. # Initialize our context. For ease we can preseed it with a
  506. # regular dictionary. This should happen after steps have been
  507. # registered and ordered.
  508. self.context = WorkflowContext(self)
  509. context_seed = context_seed or {}
  510. clean_seed = dict((key, val)
  511. for key, val in context_seed.items()
  512. if key in self.contributions | self.depends_on)
  513. self.context_seed = clean_seed
  514. self.context.update(clean_seed)
  515. if request and request.method == "POST":
  516. for step in self.steps:
  517. valid = step.action.is_valid()
  518. # Be sure to use the CLEANED data if the workflow is valid.
  519. if valid:
  520. data = step.action.cleaned_data
  521. else:
  522. data = request.POST
  523. self.context = step.contribute(data, self.context)
  524. @property
  525. def steps(self):
  526. if getattr(self, "_ordered_steps", None) is None:
  527. self._gather_steps()
  528. return self._ordered_steps
  529. def get_step(self, slug):
  530. """Returns the instantiated step matching the given slug."""
  531. for step in self.steps:
  532. if step.slug == slug:
  533. return step
  534. def _register_steps_from_config(self):
  535. my_name = '.'.join([self.__class__.__module__,
  536. self.__class__.__name__])
  537. horizon_config = settings.HORIZON_CONFIG.get('extra_steps', {})
  538. extra_steps = horizon_config.get(my_name, [])
  539. for step in extra_steps:
  540. self._register_step_from_config(step, my_name)
  541. def _register_step_from_config(self, step_config, my_name):
  542. if not isinstance(step_config, str):
  543. LOG.error('Extra step definition must be a string '
  544. '(workflow "%s"', my_name)
  545. return
  546. try:
  547. class_ = module_loading.import_string(step_config)
  548. except ImportError:
  549. LOG.error('Step class "%s" is not found (workflow "%s")',
  550. step_config, my_name)
  551. return
  552. self.register(class_)
  553. def _gather_steps(self):
  554. ordered_step_classes = self._order_steps()
  555. for default_step in self.default_steps:
  556. self.register(default_step)
  557. self._registry[default_step] = default_step(self)
  558. self._ordered_steps = []
  559. for step_class in ordered_step_classes:
  560. cls = self._registry[step_class]
  561. if (has_permissions(self.request.user, cls) and
  562. policy.check(cls.policy_rules, self.request) and
  563. cls.allowed(self.request)):
  564. self._ordered_steps.append(cls)
  565. def _order_steps(self):
  566. steps = list(copy.copy(self.default_steps))
  567. additional = self._registry.keys()
  568. for step in additional:
  569. try:
  570. min_pos = steps.index(step.after)
  571. except ValueError:
  572. min_pos = 0
  573. try:
  574. max_pos = steps.index(step.before)
  575. except ValueError:
  576. max_pos = len(steps)
  577. if min_pos > max_pos:
  578. raise exceptions.WorkflowError("The step %(new)s can't be "
  579. "placed between the steps "
  580. "%(after)s and %(before)s; the "
  581. "step %(before)s comes before "
  582. "%(after)s."
  583. % {"new": additional,
  584. "after": step.after,
  585. "before": step.before})
  586. steps.insert(max_pos, step)
  587. return steps
  588. def get_entry_point(self):
  589. """Returns the slug of the step which the workflow should begin on.
  590. This method takes into account both already-available data and errors
  591. within the steps.
  592. """
  593. # If we have a valid specified entry point, use it.
  594. if self.entry_point:
  595. if self.get_step(self.entry_point):
  596. return self.entry_point
  597. # Otherwise fall back to calculating the appropriate entry point.
  598. for step in self.steps:
  599. if step.has_errors:
  600. return step.slug
  601. try:
  602. step._verify_contributions(self.context)
  603. except exceptions.WorkflowError:
  604. return step.slug
  605. # If nothing else, just return the first step.
  606. return self.steps[0].slug
  607. def _trigger_handlers(self, key):
  608. responses = []
  609. handlers = [(step.slug, f) for step in self.steps
  610. for f in step._handlers.get(key, [])]
  611. for slug, handler in handlers:
  612. responses.append((slug, handler(self.request, self.context)))
  613. return responses
  614. @classmethod
  615. def register(cls, step_class):
  616. """Registers a :class:`~horizon.workflows.Step` with the workflow."""
  617. if not inspect.isclass(step_class):
  618. raise ValueError('Only classes may be registered.')
  619. elif not issubclass(step_class, cls._registerable_class):
  620. raise ValueError('Only %s classes or subclasses may be registered.'
  621. % cls._registerable_class.__name__)
  622. if step_class in cls._cls_registry:
  623. return False
  624. else:
  625. cls._cls_registry.append(step_class)
  626. return True
  627. @classmethod
  628. def unregister(cls, step_class):
  629. """Unregisters a :class:`~horizon.workflows.Step` from the workflow."""
  630. try:
  631. cls._cls_registry.remove(step_class)
  632. except ValueError:
  633. raise base.NotRegistered('%s is not registered' % cls)
  634. return cls._unregister(step_class)
  635. def validate(self, context):
  636. """Hook for custom context data validation.
  637. Should return a booleanvalue or
  638. raise :class:`~horizon.exceptions.WorkflowValidationError`.
  639. """
  640. return True
  641. def is_valid(self):
  642. """Verifies that all required data is present in the context.
  643. It also calls the ``validate`` method to allow for finer-grained checks
  644. on the context data.
  645. """
  646. missing = self.depends_on - set(self.context.keys())
  647. if missing:
  648. raise exceptions.WorkflowValidationError(
  649. "Unable to complete the workflow. The values %s are "
  650. "required but not present." % ", ".join(missing))
  651. # Validate each step. Cycle through all of them to catch all errors
  652. # in one pass before returning.
  653. steps_valid = True
  654. for step in self.steps:
  655. if not step.action.is_valid():
  656. steps_valid = False
  657. step.has_errors = True
  658. if not steps_valid:
  659. return steps_valid
  660. return self.validate(self.context)
  661. def finalize(self):
  662. """Finalizes a workflow by running through all the actions.
  663. It runs all the actions in order and calling their ``handle`` methods.
  664. Returns ``True`` on full success, or ``False`` for a partial success,
  665. e.g. there were non-critical errors.
  666. (If it failed completely the function wouldn't return.)
  667. """
  668. partial = False
  669. for step in self.steps:
  670. try:
  671. data = step.action.handle(self.request, self.context)
  672. if data is True or data is None:
  673. continue
  674. elif data is False:
  675. partial = True
  676. else:
  677. self.context = step.contribute(data or {}, self.context)
  678. except Exception:
  679. partial = True
  680. exceptions.handle(self.request)
  681. if not self.handle(self.request, self.context):
  682. partial = True
  683. return not partial
  684. def handle(self, request, context):
  685. """Handles any final processing for this workflow.
  686. Should return a boolean value indicating success.
  687. """
  688. return True
  689. def get_success_url(self):
  690. """Returns a URL to redirect the user to upon completion.
  691. By default it will attempt to parse a ``success_url`` attribute on the
  692. workflow, which can take the form of a reversible URL pattern name,
  693. or a standard HTTP URL.
  694. """
  695. try:
  696. return urls.reverse(self.success_url)
  697. except urls.NoReverseMatch:
  698. return self.success_url
  699. def format_status_message(self, message):
  700. """Hook to allow customization of the message returned to the user.
  701. This is called upon both successful or unsuccessful completion of
  702. the workflow.
  703. By default it simply inserts the workflow's name into the message
  704. string.
  705. """
  706. if "%s" in message:
  707. return message % self.name
  708. else:
  709. return message
  710. def verify_integrity(self):
  711. provided_keys = self.contributions | set(self.context_seed.keys())
  712. if len(self.depends_on - provided_keys):
  713. raise exceptions.NotAvailable(
  714. _("The current user has insufficient permission to complete "
  715. "the requested task."))
  716. def render(self):
  717. """Renders the workflow."""
  718. workflow_template = template.loader.get_template(self.template_name)
  719. extra_context = {"workflow": self}
  720. if self.request.is_ajax():
  721. extra_context['modal'] = True
  722. return workflow_template.render(extra_context, self.request)
  723. def get_absolute_url(self):
  724. """Returns the canonical URL for this workflow.
  725. This is used for the POST action attribute on the form element
  726. wrapping the workflow.
  727. For convenience it defaults to the value of
  728. ``request.get_full_path()`` with any query string stripped off,
  729. e.g. the path at which the workflow was requested.
  730. """
  731. return self.request.get_full_path().partition('?')[0]
  732. def add_error_to_step(self, message, slug):
  733. """Adds an error message to the workflow's Step.
  734. This is useful when you wish for API errors to appear as errors
  735. on the form rather than using the messages framework.
  736. The workflow's Step is specified by its slug.
  737. """
  738. step = self.get_step(slug)
  739. if step:
  740. step.add_step_error(message)