class RequestHandler: exposed = True traversed = Event(doc=""" An event triggered when the L{dispatcher<cocktail.controllers.dispatcher.Dispatcher>} passes through or reaches the controller while looking for a request handler. @ivar path: A list-like object containing the remaining path components that haven't been consumed by the dispatcher yet. @type path: L{PathProcessor <cocktail.controllers.dispatcher.PathProcessor>} @ivar config: The configuration dictionary with all entries that apply to the controller. Note that this dictionary can change as the dispatcher traverses through the handler hierarchy. Event handlers are free to modify it to change configuration as required. @type config: dict """) before_request = Event(doc=""" An event triggered before the controller (or one of its nested handlers) has been executed. """) after_request = Event(doc=""" An event triggered after the controller (or one of its nested handlers) has been executed. This is guaranteed to run, regardless of errors. """) exception_raised = Event(doc=""" An event triggered when an exception is raised by the controller or one of its descendants. The event is propagated up through the handler chain, until it reaches the top or it is stoped by setting its L{handled} attribute to True. @param exception: The exception being reported. @type exception: L{Exception} @param handled: A boolean indicating if the exception has been dealt with. Setting this to True will stop the propagation of the event up the handler chain, while a False value will cause the exception to continue to be bubbled up towards the application root and, eventually, the user. @type handled: bool """) @cherrypy.expose def default(self, *args, **kwargs): # Compatibility with the standard CherryPy dispatcher return self.__call__(*args, **kwargs)
class StatusTracker(object): """An object that allows clients to be kept up to date of the progress of an L{export operation<StaticSiteDestination.export>}. """ __metaclass__ = EventHub beginning = Event( """An event triggered after an exporter has finished its preparations and is ready to start the export operation. @ivar context: The L{context<StaticSiteDestination.setup.context>} used by the export operation to maintain its state. @type context: dict """) file_processed = Event("""An event triggered after file has been processed. This event will be triggered at least once for each generated file on the exportation process. The event will be triggered regardless of the outcome of the exporting process, and even if the item is discarded and not exported. Clients can differentiate between these cases using the L{status} attribute. @ivar file: The generated file path. @type file: str @ivar status: The status for the processed file. Will be one of the following: - ignored: The file is not published or marked as not able to be exported, and has been skipped. - not-modified: The file hasn't been modified since the last time it was exported, and the operation has been flagged as "L{update only<StaticSiteDestination.export.update_only>}". - exported: The file has been correctly exported. - failed: The export attempt raised an exception. @type status: str @ivar error: Only relevant if L{status} is 'failed'. A reference to the exception raised while trying to export the item. @type error: L{Exception} @ivar error_handled: Only relevant if L{status} is 'failed'. Allows the event response code to capture an export error. If set to True, the exporter will continue @type error_handled: bool @ivar context: The L{context<StaticSiteDestination.setup.context>} used by the export operation to maintain its state. @type context: dict """)
class Foo: spammed = Event() @event_handler def handle_spammed(e): pass
class Node(Handler): """Base class for nodes in a RESTful API. .. attribute:: children The child nodes of this node. .. attribute:: wildcards The list of wildcard nodes (anonymous, argument based nodes) of this node. .. attribute:: arguments The positional arguments expected by the node. """ children: Mapping[str, Type["Node"]] = {} wildcards: Sequence[Type["Node"]] = [] arguments: Sequence[schema.Member] = [] response_type: str = None private: bool = False open_api: yaml_template = None traversed = Event(doc = """ An event triggered when the CherryPy dispatcher passes through or reaches this node while dispatching a request. .. attribute:: path A list-like object containing the remaining path components that haven't been consumed by the dispatcher yet. """) def __init__(self): super().__init__() children = self.children wildcards = self.wildcards arguments = self.arguments self.__name = None self.__parent = None self.children = {} self.wildcards = [] self.arguments = [] self.__methods = {} # Instantiate child nodes for name, node_cls in children.items(): self.add(node_cls(), name) # Instantiate wildcard nodes for node_cls in wildcards: self.add(node_cls()) # Add positional arguments for arg in arguments: self.add_argument(arg) # Instantiate methods for key in dir(self): value = getattr(self, key, None) if isinstance(value, type) and issubclass(value, Method): assert key == key.upper(), \ "HTTP methods must be all in uppercase" method = self.create_method(value, key) self.__methods[key] = method def __repr__(self): return f"{self.__class__.__name__} {self.get_path()}" def get_path(self, arguments: Mapping[str, Any] = None) -> str: """Gets the string path for the node""" return "/" + "/".join(self.get_path_components(arguments)) def get_path_components( self, arguments: Mapping[str, Any] = None) -> List[str]: """Gets a list containing the distinct components of the node's path. """ if self.__parent: components = self.__parent.get_path_components() else: components = [] if self.__name: components.append(self.__name) if self.arguments: for arg in self.arguments: if arguments: try: value = arguments[arg.name] except KeyError: if arg.required: raise else: pass else: if arg.serialize_request_value: value = arg.serialize_request_value(value) components.append(value) elif arg.required: components.append("{%s}" % arg.name) return components def add(self, node: "Node", name: str = None): """Add a nested node.""" if node.__parent: raise NodeRelocationError(node, node.__parent, self, name) node.__parent = self if name: node.__name = name self.children[node.__name] = node else: self.wildcards.append(node) def add_argument(self, argument: schema.Member): """Declare a position argument on the node.""" self.arguments.append(argument) @property def name(self) -> Optional[str]: """The name of the node.""" return self.__name @property def parent(self) -> Optional["Node"]: """The parent node that contains the node.""" return self.__parent def iter_nodes( self, include_self: bool = False, recursive: bool = False) -> Iterable["Node"]: """Iterates over the node contained within the node.""" if include_self: yield self if recursive: for child in self.children.values(): yield from child.iter_nodes( include_self=True, recursive=True ) for wildcard in self.wildcards: yield from wildcard.iter_nodes( include_self=True, recursive=True ) else: yield from self.children.values() @property def methods(self) -> Dict[str, "Method"]: """The methods implemented by this node.""" return self.__methods def create_method( self, method_class: Type[Method], method_name: str) -> Method: """Creates the implementation of a given HTTP method for this node. This method allows nodes to apply common initialization to their methods. """ return method_class(self, method_name) def _cp_dispatch(self, vpath): # Override the default CherryPy dispatching algorithm self.traversed(path = vpath) if vpath: child = self.children.get(vpath[0]) if child: vpath.pop(0) return child if ( self.arguments and getattr(cherrypy.request, "wildcard", None) is not self and not self.process_arguments(vpath) ): return None if vpath: for wildcard in self.wildcards: if wildcard.process_arguments(vpath): cherrypy.request.wildcard = wildcard return wildcard return None return self def process_arguments( self, path: Sequence[str]) -> Optional[Dict[str, Any]]: """Extract the positional arguments for the node for the current request. If the path for the current request doesn't match the expected arguments (either not enough path components are supplied, or one of the extracted values doesn't pass validation) the method will leave `path` untouched and return 'None'. If all expected values can be correctly extracted and validated, the resulting values are merged into the 'cherrypy.request.arguments' mapping, along with any preceding arguments previously loaded by an ancestor node. The method will pop all extracted values from `path` (to indicate the matching path components have been consumed), and return a mapping with the values extracted by this node. """ arg_values = {} for n, arg in enumerate(self.arguments): try: value = path[n] except IndexError: if arg.required: return None value = arg.produce_default() else: try: value = arg.parse(value) except ValueError: return None if not arg.validate(value): return None arg_values[arg.name] = value for v in arg_values: path.pop(0) # Store values in cherrypy.request.arguments try: request_arguments = cherrypy.request.arguments except AttributeError: cherrypy.request.arguments = arg_values else: request_arguments.update(arg_values) return arg_values def handle_request(self): try: method = self.__methods[cherrypy.request.method] except KeyError: raise cherrypy.HTTPError(405, "Not Implemented") from None return method() class OPTIONS(Method): """Default response for CORS pre-flight requests. See `cocktail.web.Handler.handle_cors` for the actual implementation of CORS headers. """ responses = { 204: {} } def respond(self): cherrypy.response.status = 204
class SASSCompilation(object): validating_theme = Event() custom_functions = {} class ResolvingImportEventInfo(EventInfo): uri = None path = None code = None source_map = None def add_code(self, code): if self.code is None: self.code = [code] else: self.code.append(code) @property def resolution(self): if self.path: resolution = [self.path] elif self.code: resolution = [self.uri] else: return None if self.code is not None: resolution.append("".join(self.code)) if self.source_map: resolution.append(self.source_map) return [tuple(resolution)] resolving_import = Event(event_info_class=ResolvingImportEventInfo) def __init__(self, theme=None): if theme is None: theme = get_theme() if theme != "default": theme_is_valid = self.validating_theme(theme=theme, valid=False).valid if not theme_is_valid: raise InvalidSASSTheme(theme) self.__imported_uris = set() self.__theme = theme @property def theme(self): return self.__theme def compile(self, **kwargs): if sass is None: raise sass_import_error importers = kwargs.get("importers") if importers is None: importers = [] kwargs["importers"] = importers importers.insert(0, (0, self.resolve_import)) custom_functions = self.custom_functions extra_custom_functions = kwargs.get("custom_functions") if extra_custom_functions is not None: custom_functions = custom_functions.copy() custom_functions.update(extra_custom_functions) kwargs["custom_functions"] = custom_functions return sass.compile(**kwargs) def resolve_import(self, uri): # Prevent importing dependencies more than once if uri in self.__imported_uris: return ((uri, ""), ) else: self.__imported_uris.add(uri) e = self.resolving_import(uri=uri, theme=self.__theme) resolution = e.resolution if resolution is None: if resource_repositories.is_repository_uri(uri): if not uri.endswith(".scss"): uri += ".scss" location = resource_repositories.locate(uri) if os.path.exists(location): resolution = ((location, ), ) else: path, file_name = os.path.split(location) resolution = ((os.path.join(path, "_" + file_name), ), ) return resolution
class Controller(RequestHandler): context = context # Default configuration #------------------------------------------------------------------------------ default_rendering_engine = "cocktail" default_rendering_format = "html" # Execution and lifecycle #------------------------------------------------------------------------------ exposed = True def __call__(self, *args, **kwargs): if args: raise cherrypy.NotFound() try: if self.ready: self.submit() self.successful = True self.processed() except Exception as ex: self.handle_error(ex) return self.render() def submit(self): pass @cached_getter def submitted(self): return True @cached_getter def valid(self): return True @cached_getter def ready(self): return self.submitted and self.valid successful = False handled_errors = () def handle_error(self, error): if isinstance(error, self.handled_errors): self.output["error"] = error else: raise processed = Event(doc=""" An event triggered after the controller's logic has been invoked. """) # Input / Output #------------------------------------------------------------------------------ @cached_getter def params(self): return FormSchemaReader() @cached_getter def output(self): return {} # Rendering #------------------------------------------------------------------------------ rendering_format_param = "format" allowed_rendering_formats = frozenset( ["html", "html4", "html5", "xhtml", "json"]) def _get_rendering_engine(self, engine_name): warn( "Controller._get_rendering_engine() is deprecated, use " "cocktail.controllers.renderingengines.get_rendering_engine() instead", DeprecationWarning, stacklevel=2) return get_rendering_engine( engine_name, cherrypy.request.config.get("rendering.engine_options")) @cached_getter def rendering_format(self): format = None if self.rendering_format_param: format = cherrypy.request.params.get(self.rendering_format_param) if format is None: format = cherrypy.request.config.get("rendering.format", self.default_rendering_format) return format @cached_getter def rendering_engine(self): engine_name = cherrypy.request.config.get( "rendering.engine", self.default_rendering_engine) return get_rendering_engine( engine_name, cherrypy.request.config.get("rendering.engine_options")) @cached_getter def view_class(self): return None def render(self): format = self.rendering_format if format and format in self.allowed_rendering_formats: renderer = getattr(self, "render_" + format, None) else: renderer = None if renderer is None: raise ValueError("%s can't render its response in '%s' format" % (self, format)) return renderer() render_as_fragment = False def _render_template(self): view_class = self.view_class if view_class: output = self.output output["submitted"] = self.submitted output["successful"] = self.successful return self.rendering_engine.render( output, format=self.rendering_format, fragment=self.render_as_fragment, template=view_class) else: return "" def render_html(self): return self._render_template() def render_html4(self): return self._render_template() def render_html5(self): return self._render_template() def render_xhtml(self): return self._render_template() def render_json(self): cherrypy.response.headers["Content-Type"] = "text/plain" return dumps(self.output) @classmethod def copy_config(cls, **kwargs): config = getattr(cls, "_cp_config", None) config = {} if config is None else config.copy() config.update(kwargs) return config
class CMSController(BaseCMSController): application_settings = None # Application events item_saved = Event(doc=""" An event triggered after an item is inserted or modified. @ivar item: The saved item. @type item: L{Item<woost.models.Item>} @ivar user: The user who saved the item. @type user: L{User<woost.models.user.User>} @ivar is_new: True for an insertion, False for a modification. @type is_new: bool @ivar change: The revision describing the changes to the item. @type change: L{Change<woost.models.Change>} """) item_deleted = Event(doc=""" An event triggered after an item is deleted. @ivar item: The deleted item. @type item: L{Item<woost.models.Item>} @ivar user: The user who deleted the item. @type user: L{User<woost.models.user.User>} @ivar change: The revision describing the changes to the item. @type change: L{Change<woost.models.Change>} """) producing_output = Event(doc=""" An event triggered to allow setting site-wide output for controllers. @ivar controller: The controller that is producing the output. @type controller: L{BaseCMSController <woost.controllers.basecmscontroller .BaseCMSController>} @ivar output: The output for the controller. Event handlers can modify it as required. @type output: dict """) # Application modules LanguageModule = None AuthenticationModule = None # Webserver configuration virtual_path = "/" # Enable / disable confirmation dialogs when closing an edit session. This # setting exists mainly to disable the dialogs on selenium test runs. closing_item_requires_confirmation = True # A dummy controller for CherryPy, that triggers the cocktail dispatcher. # This is done so dynamic dispatching (using the resolve() method of # request handlers) can depend on session setup and other requirements # being available beforehand. class ApplicationContainer(object): def __init__(self, cms): self.__cms = cms self.__dispatcher = Dispatcher() # Set the default location for file-based sessions sconf = session.config using_file_sessions = (sconf.get("session.type") == "file") using_dbm_sessions = (sconf.get("session.type") == "dbm") lock_dir_missing = (not using_file_sessions and not sconf.get("session.lock_dir")) data_dir_missing = ( (using_file_sessions or (using_dbm_sessions and not sconf.get("session.dbm_dir"))) and not sconf.get("session.data_dir")) if lock_dir_missing or data_dir_missing: session_path = app.path("sessions") if not os.path.exists(session_path): os.mkdir(session_path) if lock_dir_missing: sconf["session.lock_dir"] = session_path if data_dir_missing: sconf["session.data_dir"] = session_path if not sconf.get("session.secret"): session_key_path = app.path(".session_key") if os.path.exists(session_key_path): with open(session_key_path, "r") as session_key_file: session_key = session_key_file.readline() else: with open(session_key_path, "w") as session_key_file: session_key = sha("".join( choice(ascii_letters) for i in range(10))).hexdigest() session_key_file.write(session_key) sconf["session.secret"] = session_key # Create the folders for uploaded files upload_path = app.path("upload") if not os.path.exists(upload_path): os.mkdir(upload_path) temp_path = app.path("upload", "temp") if not os.path.exists(temp_path): os.mkdir(temp_path) async_uploader.temp_folder = temp_path @cherrypy.expose def default(self, *args, **kwargs): # All requests are forwarded to the nested dispatcher: return self.__dispatcher.respond(args, self.__cms) # Static resources resources = folder_publisher( resource_filename("woost.views", "resources")) cocktail = folder_publisher( resource_filename("cocktail.html", "resources")) def session_middleware(self, app): return SessionMiddleware(app, session.config) @property def language(self): warn("CMSController.language is deprecated, use app.language instead", DeprecationWarning, stacklevel=2) return app.language @property def authentication(self): warn( "CMSController.authentication is deprecated, use " "app.authentication instead", DeprecationWarning, stacklevel=2) return app.authentication def __init__(self, *args, **kwargs): BaseCMSController.__init__(self, *args, **kwargs) if self.LanguageModule: warn( "CMSController.LanguageModule is deprecated, use " "app.language instead", DeprecationWarning) app.language = self.LanguageModule() if self.AuthenticationModule: warn( "CMSController.AuthenticationModule is deprecated, use " "app.authentication instead", DeprecationWarning) app.authentication = self.AuthenticationModule() def run(self, block=True): self.mount() if hasattr(cherrypy.engine, "signal_handler"): cherrypy.engine.signal_handler.subscribe() if hasattr(cherrypy.engine, "console_control_handler"): cherrypy.engine.console_control_handler.subscribe() cherrypy.engine.start() if block: cherrypy.engine.block() else: cherrypy.engine.wait(cherrypy.engine.states.STARTED) def mount(self): app = cherrypy.Application(self.ApplicationContainer(self)) # Session middleware app.wsgiapp.pipeline.append( ("beaker_session", self.session_middleware)) return cherrypy.tree.mount(app, self.virtual_path, self.application_settings) def resolve(self, path): # Allow application modules (ie. language) to process the URI before # resolving the requested publishable item self._process_path(path) request = cherrypy.request # Item resolution publishable = self._resolve_path(path) self.context["publishable"] = publishable # HTTP/HTTPS check self._apply_https_policy(publishable) # Check maintenance mode self._maintenance_check(publishable) # Controller resolution controller = publishable.resolve_controller() if controller is None: raise cherrypy.NotFound() # Add the selected language to the current URI if publishable.per_language_publication: if not request.language_specified: location = Location.get_current() location.path_info = app.language.translate_uri() location.go() # Remove the language selection from the current URI elif request.language_specified: location = Location.get_current() location.path_info = \ "/" + "/".join(location.path_info.strip("/").split("/")[1:]) location.go() return controller def canonical_redirection(self, path_resolution): """Redirect the current request to the canonical URL for the selected publishable element. """ publishable = path_resolution.item # Find the canonical path for the element canonical_path = app.url_resolver.get_path(publishable) if canonical_path is None: return canonical_path = canonical_path.strip("/") canonical_path = (canonical_path.split("/") if canonical_path else []) # The current request matches the canonical path, do nothing if canonical_path == path_resolution.matching_path: return canonical_uri = "".join( percent_encode(c) for c in "/" + "/".join( step for step in (canonical_path + path_resolution.extra_path))) if publishable.per_language_publication: canonical_uri = \ app.language.translate_uri(canonical_uri) if cherrypy.request.query_string: canonical_uri = canonical_uri + \ "?" + cherrypy.request.query_string raise cherrypy.HTTPRedirect(canonical_uri) def _process_path(self, path): # Invoke the language module to set the active language app.language.process_request(path) def _maintenance_check(self, publishable): if isinstance(publishable, File): return config = Configuration.instance website = get_current_website() if (config.down_for_maintenance or (website and website.down_for_maintenance)): headers = cherrypy.request.headers client_ip = headers.get("X-Forwarded-For") \ or headers.get("Remote-Addr") if client_ip not in config.maintenance_addresses: raise cherrypy.HTTPError(503, "Site down for maintenance") def _resolve_path(self, path): unicode_path = [try_decode(step) for step in path] path_resolution = app.url_resolver.resolve_path(unicode_path) if path_resolution: publishable = path_resolution.item for step in path_resolution.matching_path: path.pop(0) self.canonical_redirection(path_resolution) else: website = get_current_website() publishable = website.home if website else None return publishable def uri(self, publishable, *args, **kwargs): """Obtains the canonical absolute URI for the given item. @param publishable: The item to obtain the canonical URI for. @type publishable: L{Publishable<woost.models.publishable.Publishable>} @param args: Additional path components to append to the produced URI. @type args: unicode @param kwargs: Key/value pairs to append to the produced URI as query string parameters. @type kwargs: (unicode, unicode) @return: The item's canonical URI, or None if no matching URI can be constructed. @rtype: unicode """ warn("CMS.uri() is deprecated, use Publishable.get_uri() instead", DeprecationWarning, stacklevel=2) # User defined URIs if isinstance(publishable, URI): uri = publishable.uri # Regular elements else: uri = app.url_resolver.get_path(publishable) if uri is not None: if publishable.per_language_publication: uri = app.language.translate_uri(uri) uri = self.application_uri(uri, *args, **kwargs) if uri: uri = "".join(percent_encode(c) for c in uri) return uri def translate_uri(self, path=None, language=None): return self.application_uri( app.language.translate_uri(path=path, language=language)) def image_uri(self, element, factory="default"): if not isinstance(element, (int, basestring)): if isinstance(element, type) \ or not getattr(element, "is_inserted", False): element = element.full_name elif hasattr(element, "id"): element = element.id return self.application_uri("images", element, factory) def validate_publishable(self, publishable): if not publishable.is_published(): raise cherrypy.NotFound() user = get_current_user() user.require_permission(ReadPermission, target=publishable) user.require_permission(ReadTranslationPermission, language=get_language()) def _establish_active_website(self): location = Location.get_current_host() website = Configuration.instance.get_website_by_host(location.host) set_current_website(website) if website is None: raise cherrypy.HTTPError(404, "Unknown hostname: " + location.host) @event_handler def handle_traversed(cls, event): datastore.sync() cms = event.source cms.context.update(cms=cms, publishable=None) # Determine the active website cms._establish_active_website() # Set the default language language = app.language.infer_language() set_language(language) # Invoke the authentication module app.authentication.process_request() @event_handler def handle_before_request(cls, event): cms = event.source publishable = cms.context.get("publishable") if publishable is not None: # Possibly redirect to another website, if the selected publishable is # specific to another website cms._apply_website_exclusiveness(publishable) # Validate access to the requested item cms.validate_publishable(publishable) # Set the content type and encoding content_type = publishable.mime_type if content_type: encoding = publishable.encoding if encoding: content_type += ";charset=" + encoding cherrypy.response.headers["Content-Type"] = content_type # TODO: encode / decode the request based on the 'encoding' member? @event_handler def handle_producing_output(cls, event): # Set application wide output parameters cms = event.source event.output.update( cms=cms, user=get_current_user(), publishable=event.controller.context.get("publishable")) @event_handler def handle_exception_raised(cls, event): # Couldn't establish the active website: show a generic error if get_current_website() is None: return error = event.exception controller = event.source content_type = cherrypy.response.headers.get("Content-Type") pos = content_type.find(";") if pos != -1: content_type = content_type[:pos] if content_type in ("text/html", "text/xhtml"): error_page, status = event.source.get_error_page(error) response = cherrypy.response if status: response.status = status if error_page: event.handled = True # HTTP/HTTPS check controller._apply_https_policy(error_page) controller.context.update( original_publishable=controller.context["publishable"], publishable=error_page) error_controller = error_page.resolve_controller() # Instantiate class based controllers if isinstance(error_controller, type): error_controller = error_controller() error_controller._rendering_format = "html" response.body = error_controller() def get_error_page(self, error): """Produces a custom error page for the indicated exception. @param error: The exception to describe. @type error: Exception @return: A tuple comprised of a publishable item to delegate to and an HTTP status to set on the response. Either component can be None, so that no custom error page is shown, or that the status is not changed, respectively. @rtype: (L{Document<woost.models.Document>}, int) """ is_http_error = isinstance(error, cherrypy.HTTPError) config = Configuration.instance page = None page_name = None status = None # Page not found if is_http_error and error.status == 404: return config.get_setting("not_found_error_page"), 404 # Service unavailable elif is_http_error and error.status == 503: return config.get_setting("maintenance_page"), 503 # Access forbidden: # The default behavior is to show a login page for anonymous users, and # a 403 error message for authenticated users. elif ((is_http_error and error.status == 403) or isinstance(error, (AuthorizationError, AuthenticationFailedError))): if get_current_user().anonymous: publishable = self.context["publishable"] while publishable is not None: login_page = publishable.login_page if login_page is not None: return login_page, 403 publishable = publishable.parent return config.get_setting("login_page"), 403 else: return config.get_setting("forbidden_error_page"), 403 # Generic error elif (is_http_error and error.status == 500) or not is_http_error: return config.get_setting("generic_error_page"), 500 return None, None def _apply_website_exclusiveness(self, publishable): if (publishable.websites and get_current_website() not in publishable.websites): raise cherrypy.HTTPRedirect( publishable.get_uri(host=publishable.websites[0].hosts[0])) def _apply_https_policy(self, publishable): policy = Configuration.instance.get_setting("https_policy") website = get_current_website() if policy == "always": Location.require_https() elif policy == "never": Location.require_http() elif policy == "per_page": if publishable.requires_https or not get_current_user().anonymous: Location.require_https() elif not website.https_persistence: Location.require_http() @event_handler def handle_after_request(cls, event): datastore.abort() images = ImagesController async_upload = AsyncUploadController() async_upload.uploader = async_uploader @cherrypy.expose def current_user(self): cherrypy.response.headers["Content-Type"] = "text/javascript" user = get_current_user() return "cocktail.declare('woost'); woost.user = %s;" % dumps( { "id": user.id, "label": translations(user), "identifier": user.get(app.authentication.identifier_field), "anonymous": user.anonymous } if user else None)
class Tracker: declaring_tracker = Event() inclusion_code = """ <script type="text/javascript"> (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){ (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o), m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m) })(window,document,'script','//www.google-analytics.com/analytics.js','ga'); %(create_tracker_command)s %(initialization)s %(commands)s </script> """ @classmethod def get_analytics_script(cls, publishable=None, commands=None): config = Configuration.instance if publishable is None: publishable = app.publishable event = cls.declaring_tracker( publishable=publishable, account=get_setting("x_googleanalytics_account"), tracker_parameters={}, domain=get_setting("x_googleanalytics_domain"), template=cls.inclusion_code, values={}, commands=commands or []) if not event.account: return "" commands = event.commands parameters = {} if event.domain: event.tracker_parameters["cookieDomain"] = event.domain parameters["create_tracker_command"] = \ cls._serialize_commands([( "create", event.account, event.tracker_parameters )]) if event.values: commands.insert(0, ("set", event.values)) parameters["initialization"] = ( "woost.ga.setCustomDefinitions(%s);\n" "ga('set', woost.ga.getEventData(document.documentElement));" % (json.dumps( dict((custom_def.identifier, { "index": i + 1, "type": custom_def.definition_type }) for i, custom_def in enumerate( config.x_googleanalytics_custom_definitions) if custom_def.identifier)))) parameters["commands"] = cls._serialize_commands(commands) return event.template % parameters @classmethod def get_analytics_page_hit_script(cls, publishable=None): return cls.get_analytics_script(publishable=publishable, commands=[("send", "pageview")]) @classmethod def _serialize_commands(cls, commands): return "\n".join("ga(%s);" % (", ".join(json.dumps(arg) for arg in cmd)) for cmd in commands)
class Foo(object): spammed = Event()
class Basket(object): session_key = "woost.extensions.ecommerce.basket" created = Event(doc=""" An event triggered on the class when a new instance is created. @ivar basket: A reference to the new instance. @type basket: L{Basket} """) @classmethod def get(cls, create_new=True): """Obtains the shop order for the current user session. If the user had not started an order yet, a new one is created. :rtype: `~woost.extensions.ecommerce.ecommerceorder.ECommerceOrder` """ order = getattr(cherrypy.request, "woost_ecommerce_order", None) if order is None: order = cls.restore() if order is None and create_new: order = ECommerceOrder() cls.created(basket=order) cherrypy.request.woost_ecommerce_order = order return order @classmethod def pop(cls): """Remove the current shop order from the session.""" order = cls.get(create_new=False) if order is not None: session.pop(cls.session_key, None) del cherrypy.request.woost_ecommerce_order return order @classmethod def empty(cls): """Removes all products from the basket.""" order = cls.get(create_new=False) if order is not None: for purchase in list(order.purchases): purchase.delete() datastore.commit() @classmethod def store(cls): order = cls.get() order.insert() for purchase in order.purchases: purchase.insert() datastore.commit() session[cls.session_key] = order.id @classmethod def restore(cls): session_data = session.get(cls.session_key) if session_data is None: return None else: return ECommerceOrder.get_instance(session_data)
class Form: """A description of a form based on a schema.""" controller = None actions = (None, ) process_after = () declared = Event() def __init__(self, controller): self.controller = controller def __str__(self): return "%s in %s" % (self.form_id, self.controller) @cached_getter def model(self): source_instance = self.source_instance if source_instance is not None \ and isinstance(source_instance, schema.SchemaObject): return source_instance.__class__ return schema.Schema(get_full_name(self.__class__)) @cached_getter def form_id(self): """The name given to the form by its `controller`.""" return camel_to_underscore(self.__class__.__name__) @cached_getter def submitted(self): """Indicates if this particular form has been submitted in the current request. """ return self.controller.submitted \ and self.controller.action in self.actions def submit(self): """Load and handle form data when the form is submitted.""" self.apply_form_data() def after_submit(self): """Perform additional actions after all forms have been submitted.""" pass @cached_getter def valid(self): """Indicates if the form is valid and can be submitted.""" return not self.errors @cached_getter def errors(self): """The `list of validation errors<cocktail.schema.errorlist.ErrorList>` for the current form state. """ return schema.ErrorList( self.schema.get_errors(self.data, **self.validation_parameters ) if self.submitted else ()) @cached_getter def adapter(self): """The adapter used to obtain the form's customized `schema` from the form's `model`. """ return schema.Adapter() @cached_getter def schema(self): """A schema describing the fields and validation logic that make up the form. """ if self.model is None: raise ValueError("No form model specified for %s" % self) adapted_schema = schema.Schema( name=self.get_schema_name(), schema_aliases=self.get_schema_aliases()) adapted_schema.is_form = True return self.adapter.export_schema(self.model, adapted_schema) def get_schema_name(self): if self.controller: return (get_full_name(self.controller.__class__) + "." + self.__class__.__name__) else: return get_full_name(self.__class__) def get_schema_aliases(self): if not self.controller: return [] return [ get_full_name(cls) + "." + self.__class__.__name__ for cls in self.controller.__class__.__mro__ if cls is not FormProcessor and issubclass(cls, FormProcessor) ] @cached_getter def data(self): """A dictionary containing the user's input.""" data = {} # First load: set the initial state for the form if not self.submitted: self.init_data(data) # Form submitted: read request data # - Start by exporting data from the source instance; this takes care # of read only members # - Then, overwrite all editable parameters by reading from the request else: self.apply_instance_data(data) self.read_data(data) return data def init_data(self, data): """Set the form state on first load. :param data: The dictionary representing the form state. :type data: dict """ if self.source_instance is None: # First, apply defaults specified by the form schema self.apply_defaults(data) # Then, selectively override these with any pre-supplied parameter # (useful to pass parameters to a form page) get_parameter(self.schema, target=data, undefined="skip", errors="ignore") else: self.apply_instance_data(data) def apply_defaults(self, data): """Initialize the form state with defaults supplied by the form's schema. :param data: The dictionary representing the form state. :type data: dict """ self.schema.init_instance(data) def apply_instance_data(self, data): """Initialize the form state with data from the edited instance. :param data: The dictionary representing the form state. The method should update it with data from the source instance. :type data: dict """ self.adapter.export_object(self.instance, data, source_schema=self.model, target_schema=self.schema) def read_data(self, data): """Update the form state with data read from the request. :param data: The dictionary that request data is read into. :type data: dict """ get_parameter(self.schema, target=data, **self.reader_params) @cached_getter def reader_params(self): """The set of parameters to pass to `get_parameter <cocktail.controllers.parameters.get_parameter>` when reading data from the form. """ return {"errors": "ignore", "undefined": "set_none"} @cached_getter def instance(self): """The instance produced by the form. Depending on the form's model, it will be a dictionary or a `SchemaObject <cocktail.schema.schemaobject.SchemaObject>` instance. """ return self.source_instance or self.create_instance() @cached_getter def source_instance(self): """An existing instance that should be edited by the form.""" return None def create_instance(self): if isinstance(self.model, type): return self.model() else: return {} def apply_form_data(self): """Fill the edited instance with data from the form.""" self.adapter.import_object(self.data, self.instance, source_schema=self.schema, target_schema=self.model) @cached_getter def validation_parameters(self): """Context supplied to the form validation process.""" context = {} if isinstance(self.model, PersistentClass): context["persistent_object"] = self.instance return context
class Extension(Item): """Base model for Woost extensions.""" instantiable = False type_group = "setup" collapsed_backoffice_menu = True edit_node_class = "woost.controllers.backoffice.extensioneditnode." \ "ExtensionEditNode" installed = False @property def loaded(self): return self.__class__ in _loaded_extensions member_order = ("extension_author", "license", "web_page", "description", "enabled") extension_author = schema.String(editable=False, listed_by_default=False) license = schema.String(editable=False, listed_by_default=False) web_page = schema.String(editable=False) description = schema.String(editable=False, translated=True) enabled = schema.Boolean(required=True, default=False) loading = Event("""An event triggered during application start up.""") installing = Event( """An event triggered when an extension creates its assets, the first time it is loaded. """) @classgetter def instance(cls): return first(cls.select()) def __translate__(self, language, **kwargs): return translations(self.__class__.name) def load(self): with _extensions_lock: if self.__class__ not in _loaded_extensions: _loaded_extensions.add(self.__class__) self._load() self.loading() def _load(self): pass def install(self): if not self.installed: def install_extension(): self._install() self.installing() self.installed = True transaction(install_extension, desist=lambda: self.installed) def _install(self): pass def _create_asset(self, cls, id, **values): """Convenience method for creating content when installing an extension. """ asset = cls() asset.qname = qname = self.full_name.rsplit(".", 1)[0] + "." + id if values: for key, value in values.iteritems(): if value is extension_translations: for language in Configuration.instance.languages: value = translations(qname + "." + key, language) if value: asset.set(key, value, language) else: asset.set(key, value) asset.insert() return asset
class CSRFProtection(object): """A class that provides protection against CSRF attacks. For background on Cross Site Request Forgery attacks, see the `corresponding entry on the OWASP website <https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)>`. The class implements a Synchronizer Token pattern to disallow POST requests that don't include a previously supplied token. Tokens are generated on a per session basis, and can also be explicitly renewed by calling the `renew_session_token` method. Tokens are sent to the client using a cookie and injected into form submissions and Ajax requests via Javascript. This approach prevents interference with response caching, at the cost of preventing POST requests from user agents without javascript enabled. Only a single instance of this class can be enabled at any one time. To install the protection scheme, create an instance of the class and pass it to `set_csrf_protection`. Note that the module automatically installs an instance of the class by default, so unless the default behavior needs to be changed, users don't need to do anything to enable the protection. """ session_key = "cocktail.csrf_protection_token" cookie_name = session_key token_characters = ascii_letters + digits token_length = 40 field = "__cocktail_csrf_token" header = "X-Cocktail-CSRF-Token" deciding_injection = Event() deciding_validation = Event() def should_inject(self): """Specifies wether the protection scheme should be applied to the current request. All requests are protected by default. :return: True if the scheme should be enabled, False otherwise. :rtype: True """ if not getattr(cherrypy.request, "csrf_token_injection", True): return False try: self.deciding_injection() except CSRFProtectionExemption: return False else: return True def should_require_token(self): """Specifies wether the current request should be protected. By default, all POST, PUT and DELETE requests are protected. :return: True if the request should be protected, False otherwise. :rtype: True """ try: self.deciding_validation() except CSRFProtectionExemption: return False else: return cherrypy.request.method in ("POST", "PUT", "DELETE") def generate_token(self): """Generate a Synchronizer Token. The default implementation generates a random string containing as many characters from `token_characters` as indicated by `token_length`. :return: The generated token. :rtype: str """ return random_string(self.token_length, self.token_characters) def renew_session_token(self): """Generate a new token for the active session.""" session.pop(self.session_key, None) return self.get_session_token() def get_session_token(self): """Get (and set, if new) the token for the active session. :return: The token assigned to the active session. :rtype: str """ token = session.get(self.session_key) if not token: token = self.generate_token() session[self.session_key] = token return token def get_request_token(self): """Get the token included in the present request. The method will check for either an HTTP header (as specified by the `header` property) and a POST data field (as specified by the `field` property). :return: The token included in the request, or None if the request contains no Synchronizer token. :rtype: str """ return (cherrypy.request.headers.get(self.header) or cherrypy.request.params.get(self.field)) def handle_token_error(self, session_token, received_token): """Specifies what should happen when a non authenticated request is received. Note that a request without a valid token doesn't necessarily equate with an attack: - Any user agent without javascript enabled will produce this. - If the session is lost, existing clients may send tokens that are no longer valid. The default behavior will raise a `CSRFTokenError` exception (which will typically end up producing a 403 HTTP error). :param session_token: The token that should have been included in the request for it to be considered valid. :type session_token: str :param received_token: The invalid token that was received instead, or None if no token was included. :type received_token: str """ raise CSRFTokenError()
class Handler(metaclass=ABCMeta): """Base class for request handlers. This is an abstract class providing common functionality to `~cocktail.web.node.Node` and `~cocktail.web.method.Method`. """ error_responses: ErrorResponseMap = default_error_responses allowed_origins: Union[Inherit, AnyOrigin, Set[str]] = inherit allowed_headers: Set[str] = {"Content-Type", "Authorization"} def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) # Subclasses create their own Handler.error_responses, chained to the # one on its parent custom_responses = cls.__dict__.get("error_responses", None) if custom_responses is not None: del cls.error_responses cls.error_responses = cls.error_responses.new_child( custom_responses) else: cls.error_responses = cls.error_responses.new_child() before_request = Event(doc=""" An event triggered before the handler (or one of its nested handlers) has been executed. """) before_reading_input = Event(doc=""" An event triggered just before a method starts parsing request parameters. Arguments, parameters and request bodies are not yet available at this point this point. """) responding = Event(doc=""" An event triggered just before a method starts producing the response for the current HTTP request. Arguments, parameters and request bodies have already been processed at this point. """) after_request = Event(doc=""" An event triggered after the controller (or one of its nested handlers) has been executed. This is guaranteed to run, regardless of errors. """) exception_raised = Event(doc=""" An event triggered when an exception is raised by the handler or one of its descendants. The event is propagated up through the handler chain, until it reaches the top or it is stoped by setting its `handled` attribute to True. .. attribute:: exception The exception being reported. .. attribute:: handled A boolean indicating if the exception has been dealt with. Setting this to True will stop the propagation of the event up the handler chain, while a False value will cause the exception to continue to be bubbled up towards the application root and, eventually, the user. """) def ascend_handlers(self) -> Iterable["Handler"]: """Produces the iterable sequence of `Handler` objects for this handler and its ancestors. The method yields the handler itself first, and iterates upwards towards the root handler, which is returned in last place. """ handler = self while handler: yield handler handler = handler.parent def iter_handlers_from_root(self) -> Iterable["Handler"]: """Produces the iterable sequence of `Handler` objects for this handler and its ancestors, starting from the root. The method yields the root handler first, and iterates inwards towards the handler itself, which is returned in last place. """ parent = self.parent if parent: yield from parent.iter_handlers_from_root() yield self @property @abstractmethod def parent(self) -> "Handler": """Returns the parent handler for this handler.""" pass def allows_origin(self, origin: str) -> bool: """Determine if the given origin is acceptable.""" return origin in self.resolve_allowed_origins() def resolve_allowed_origins(self) -> Union[AnyOrigin, Set[str]]: """Resolves the value of the `allowed_origins` property, recursing to parent handlers when the value is set to `inherit`. If the root of the handler tree is reached and the value is still `inherit`, the method returns an empty set. """ if self.allowed_origins is inherit: parent = self.parent return (parent and parent.resolve_allowed_origins() or set()) return self.allowed_origins def __call__(self) -> Any: """Handle the current request, producing the appropiate response.""" try: for handler in self.iter_handlers_from_root(): handler.before_request(handler=self) return self.handle_request() except Exception as error: # Redirections skip error handling if (isinstance(error, cherrypy.HTTPError) and error.status >= 300 <= 400): raise return self.handle_error(error) finally: for handler in self.iter_handlers_from_root(): handler.after_request(handler=self) @cherrypy.expose def index(self, **kwargs): return self() @abstractmethod def handle_request(self): pass def handle_error(self, error: Exception) -> ErrorResponse: """Handle an exception raised during the invocation of this handler or one of its nested handlers. """ # Fire an event on the handler and each of its ancestors for handler in self.ascend_handlers(): handler.exception_raised(error=error) # Find a suitable response for the exception error_response = self.get_error_response(error) return error_response.respond(error) def get_error_response(self, error: Exception) -> ErrorResponse: """Gets the error response that should handle the given exception.""" for handler in self.ascend_handlers(): error_response = self.error_responses.get(error.__class__) if error_response is not None: return error_response return None def apply_error_response(self, error: Exception, error_response: ErrorResponse) -> Any: """Applies the given response to an exception.""" error_response.applying(error=error) cherrypy.response.status = error_response.status
class PaymentGateway(Item): instantiable = False visible_from_root = False payment_gateway_controller_class = \ "woost.extensions.payments.paymentgatewaycontroller.PaymentGatewayController" transaction_notified = Event( """An event triggered when the payment gateway notifies the application of the outcome of a payment transaction. @param payment: The payment that the notification is sent for. @type payment: L{Payment<tpv.payment.Payment>} """) members_order = ["label", "test_mode", "currency"] label = schema.String(required=True, translated=True) test_mode = schema.Boolean(required=True, default=True) currency = schema.String(required=True, enumeration=currency_alpha, translatable_enumeration=False, text_search=False) def __translate__(self, language, **kwargs): return translations(self.__class__.name, language) def initiate_payment(self, payment_id): """Begin a payment transaction, redirecting the user to the payment gateway. @param payment_id: The identifier of the payment to execute. """ url, params = self.get_payment_form_data(payment_id, get_language()) location = Location(url) location.method = "POST" location.form_data = OrderedDict(params) location.go() def get_payment_url(self, *args, **kwargs): website = get_current_website() location = Location() location.relative = False if website.https_policy != "never": location.scheme = "https" location.host = website.hosts[0] location.path_info = "payments/" + str(self.id) location.join_path(*args) location.query_string.update(kwargs) return unicode(location) @property def handshake_url(self): return self.get_payment_url("handshake") @property def notification_url(self): return self.get_payment_url("notification")
class Adapter(object): member_created = Event( """An event triggered when the adapter generates a new member on a target schema. :param member: The new member. :type member: `~cocktail.schema.Member` """) def __init__(self, source_accessor=None, target_accessor=None, implicit_copy=IMPLICIT_COPY_DEFAULT, preserve_order=PRESERVE_ORDER_DEFAULT, copy_validations=COPY_VALIDATIONS_DEFAULT, copy_mode=COPY_MODE_DEFAULT, collection_copy_mode=COLLECTION_COPY_MODE_DEFAULT): self.import_rules = RuleSet() self.import_rules.adapter = self self.export_rules = RuleSet() self.export_rules.adapter = self self.source_accessor = source_accessor self.target_accessor = target_accessor self.implicit_copy = implicit_copy self.preserve_order = preserve_order self.copy_validations = copy_validations self.copy_mode = copy_mode self.collection_copy_mode = collection_copy_mode def import_schema(self, source_schema, target_schema=None, parent_context=None): if target_schema is None: target_schema = Schema() self.import_rules.adapt_schema(source_schema, target_schema, parent_context=parent_context) return target_schema def export_schema(self, source_schema, target_schema=None, parent_context=None): if target_schema is None: target_schema = Schema() self.export_rules.adapt_schema(source_schema, target_schema, parent_context=parent_context) return target_schema def import_object(self, source_object, target_object, source_schema=None, target_schema=None, source_accessor=None, target_accessor=None, languages=None, parent_context=None): self.import_rules.adapt_object(source_object, target_object, source_accessor or self.source_accessor, target_accessor or self.target_accessor, source_schema, target_schema, languages=languages, parent_context=parent_context) def export_object(self, source_object, target_object, source_schema=None, target_schema=None, source_accessor=None, target_accessor=None, languages=None, parent_context=None): self.export_rules.adapt_object(source_object, target_object, source_accessor or self.source_accessor, target_accessor or self.target_accessor, source_schema, target_schema, languages=languages, parent_context=parent_context) def _get_implicit_copy(self): return self.__implicit_copy def _set_implicit_copy(self, value): self.__implicit_copy = value self.import_rules.implicit_copy = value self.export_rules.implicit_copy = value implicit_copy = property( _get_implicit_copy, _set_implicit_copy, doc="""Indicates if members of the source schema that are not covered by any adaptation rule should be implicitly copied. Note that setting this property will alter the analogous attribute on both of the adapter's import and export rule sets (but the opposite isn't true; setting X{collection_copy_mode} on either rule set won't affect the adapter). @type: bool """) def _get_preserve_order(self): return self.__preserve_order def _set_preserve_order(self, value): self.__preserve_order = value self.import_rules.preserve_order = value self.export_rules.preserve_order = value preserve_order = property( _get_preserve_order, _set_preserve_order, doc="""Indicates if the schemas exported by the adapter attempt to preserve the same relative ordering for their members and groups of members defined by the source schema. Note that setting this property will alter the analogous attribute on both of the adapter's import and export rule sets (but the opposite isn't true; setting X{preserve_order} on either rule set won't affect the adapter). @type: bool """) def _get_copy_validations(self): return self.__copy_validations def _set_copy_validations(self, copy_validations): self.__copy_validations = copy_validations self.import_rules.copy_validations = copy_validations self.export_rules.copy_validations = copy_validations copy_validations = property( _get_copy_validations, _set_copy_validations, doc="""Indicates if validations from the source schema and members should be added to adapted schemas and members. Note that setting this property will alter the analogous attribute on both of the adapter's import and export rule sets (but the opposite isn't true; setting X{copy_validations} on either rule set won't affect the adapter). @type: bool """) def _get_copy_mode(self): return self.__copy_mode def _set_copy_mode(self, copy_mode): self.__copy_mode = copy_mode self.import_rules.copy_mode = copy_mode self.export_rules.copy_mode = copy_mode copy_mode = property(_get_copy_mode, _set_copy_mode, doc=""" Indicates the way in which values are copied between objects. This should be a function, taking a single parameter (the input value) and returning the resulting copy of the value. For convenience, the module provides the following functions: * L{reference}: This is the default copy mode. It doesn't actually perform a copy of the provided value, but rather returns the same value unmodified. * L{shallow}: Creates a shallow copy of the provided value. * L{deep}: Creates a deep copy of the provided value. Note that setting this property will alter the analogous attribute on both of the adapter's import and export rule sets (but the opposite isn't true; setting X{copy_mode} on either rule set won't affect the adapter). @type: function """) def _get_collection_copy_mode(self): return self.__collection_copy_mode def _set_collection_copy_mode(self, copy_mode): self.__collection_copy_mode = copy_mode self.import_rules.collection_copy_mode = copy_mode self.export_rules.collection_copy_mode = copy_mode collection_copy_mode = property( _get_collection_copy_mode, _set_collection_copy_mode, doc=""" Indicates the way in which collections are copied between objects. This should be a function, taking a single parameter (the input collection) and returning the resulting copy of the collection. For convenience, the module provides the following functions: * L{reference}: This is the default copy mode. It doesn't actually perform a copy of the provided collection, but rather returns a reference to it. Beware that by using this copy mode, an adapted object will share the collection with its source object, which may result in unexpected behavior in some cases. * L{shallow}: Creates a shallow copy of the collection (a copy of the collection itself is made, but its members are copied by reference). * L{deep}: Creates a deep copy of the collection and its members. Note that setting this property will alter the analogous attribute on both of the adapter's import and export rule sets (but the opposite isn't true; setting X{collection_copy_mode} on either rule set won't affect the adapter). @type: function """) def has_rules(self): """Indicates if the adapter defines one or more import or export rules. @rtype: bool """ return self.export_rules.rules or self.import_rules.rules def copy(self, mapping, export_transform=None, import_transform=None, import_condition=None, export_condition=None, rule_position=None, properties=None): export_rule = Copy(mapping, transform=export_transform, condition=export_condition, properties=properties) import_rule = Copy(dict( (value, key) for key, value in export_rule.mapping.items()), transform=import_transform, condition=import_condition) self.export_rules.add_rule(export_rule, rule_position) self.import_rules.add_rule(import_rule, rule_position) def exclude(self, members, rule_position=None): if isinstance(members, str): members = [members] exclusion = Exclusion(members) self.import_rules.add_rule(exclusion, rule_position) self.export_rules.add_rule(exclusion, rule_position) def export_read_only(self, mapping, transform=None, condition=None, properties=None, rule_position=None): properties = {} if properties is None else properties.copy() properties["editable"] = READ_ONLY self.export_rules.add_rule( Copy(mapping, transform=transform, condition=condition, properties=properties), rule_position) def expand(self, member, related_object, adapter, rule_position=None): self.export_rules.add_rule(Expansion(member, related_object, adapter), rule_position) self.import_rules.add_rule( Contraction(member, related_object, adapter), rule_position) def split(self, source_member, separator, target_members, rule_position=None): self.export_rules.add_rule( Split(source_member, separator, target_members), rule_position) self.import_rules.add_rule( Join(target_members, separator, source_member), rule_position) def join(self, source_members, glue, target_member, rule_position=None): self.export_rules.add_rule(Join(source_members, glue, target_member), rule_position) self.import_rules.add_rule(Split(target_member, glue, source_members), rule_position)
class Member(Variable): """A member describes the properties and metadata of a unit of data. Although not strictly an abstract class, the class is very generic in nature, and is inherited by a wealth of other classes that add the necessary features to describe a variety of more concrete data types (`String`, `Integer`, `Boolean`, etc.). Members can also be composited, in order to describe parts of a more complex data set, by embedding them within a `Collection` or `Schema`. .. attribute:: default The default value for the member. .. attribute:: required Determines if the field requires a value. When set to true, a value of None for this member will trigger a validation error of type `exceptions.ValueRequiredError`. .. attribute:: require_none Determines if the field disallows any value other than None. When set to true, a value different than None for this member will trigger a validation error of type `exceptions.NoneRequiredError`. .. attribute:: enumeration Establishes a limited set of acceptable values for the member. If a member with this constraint is given a value not found inside the set, an `exceptions.EnumerationError` error will be triggered. .. attribute:: translated Indicates if the member accepts multiple values, each in a different language. .. attribute:: expression An expression used by computed fields to produce their value. """ attached = Event( doc="""An event triggered when the member is added to a schema.""") validating = Event( doc="""An event triggered when a validation of the member begins. The main purpose of this event is to modify the parameters of its validation context. To implement actual validation rules, check out the `_default_validation` and `add_validation` methods. :param context: The validation context representing the validation process that is about to start. """) # Groupping and sorting member_group = None before_member = None after_member = None # Constraints and behavior primary = False descriptive = False default = None required = False require_none = False enumeration = None normalization = None expression = None # Instance data layout accessor = None # Copying and adaptation _copy_class = None source_member = None original_member = None copy_mode = None # Translation translated = False translation = None translation_source = None translatable_values = False translatable_enumeration = False custom_translation_key = None # Wether the member is included in full text searches text_search = False __language_dependant = None # Attributes that deserve special treatment when performing a deep copy _special_copy_keys = set([ "__module__", "__class__", "_schema", "_validations_wrapper", "_validations", "translation", "translation_source", "original_member", "source_member" ]) def __init__(self, name=None, doc=None, **kwargs): """Initializes a member, optionally setting its name, docstring and a set of arbitrary attributes. :param name: The `name` given to the member. :type name: str :param doc: The docstring to assign to the member. :type doc: unicode :param kwargs: A set of key/value pairs to set as attributes of the member. """ self._name = None self._schema = None self._validations = OrderedSet() self._validations_wrapper = ListWrapper(self._validations) self.original_member = self self.validation_parameters = {} Variable.__init__(self, None) self.__type = None self.name = name self.__doc__ = doc validations = kwargs.pop("validations", None) for key, value in kwargs.items(): setattr(self, key, value) if validations is not None: for validation in validations: self.add_validation(validation) def __hash__(self): return object.__hash__(self) def __eq__(self, other): return self is other def __repr__(self): member_desc = self.__class__.__name__ if (self.__class__.__module__ and not self.__class__.__module__.startswith("cocktail.schema.")): member_desc = self.__class__.__module__ + "." + member_desc return member_desc + " <%s>" % self.get_qualified_name() def _get_name(self): return self._name def _set_name(self, value): if self._schema is not None: raise exceptions.MemberRenamedError(self, value) self._name = value name = property( _get_name, _set_name, doc="""The name that uniquely identifies the member on the schema it is bound to. Once set it can't be changed (trying to do so will raise a `exceptions.MemberRenamedError` exception). """) def get_qualified_name(self, delimiter=".", include_ns=False): member = self names = [] while True: if member._name: names.append(member._name) owner = member._schema if owner is None: if include_ns: namespace = getattr(member, "__module__", None) if (namespace and not namespace.startswith("cocktail.schema.")): names.append(namespace) break else: member = owner names.reverse() return delimiter.join(names) def _get_schema(self): return self._schema def _set_schema(self, value): if self._schema is not None: raise exceptions.MemberReacquiredError(self, value) self._schema = value schema = property( _get_schema, _set_schema, doc= """The `schema <Schema>` that the member is bound to. Once set it can't be changed (doing so will raise a `exceptions.MemberReacquiredError` exception). """) def _get_type(self): # Resolve string references if isinstance(self.__type, str): self.__type = import_type(self.__type) return self.__type def _set_type(self, type): self.__type = type type = property( _get_type, _set_type, doc= """Imposes a data type constraint on the member. All values assigned to this member must be instances of the specified data type. Breaking this restriction will produce a validation error of type `exceptions.TypeCheckError`. The value for this constraint can take either a reference to a type object or a fully qualified python name. When set using a name, the indicated type will be imported lazily, the first time the value for this constraint is requested. This can be helpful in avoiding circular references between schemas. """) def _set_exclusive(self, expr): self.required = expr if isinstance(expr, Expression): self.require_none = expr.not_() else: self.require_none = lambda ctx: not expr(ctx) exclusive = property( None, _set_exclusive, doc="""A write only property that eases the definition of members that should be required or empty depending on a condition and its negation, respectively. Should be set to a dynamic expression (a callable object, or an instance of `expressions.Expression`). """) def produce_default(self, instance=None): """Generates a default value for the member. Can be overridden (ie. to produce dynamic default values). :param instance: The instance that the default is produced for. :return: The resulting default value. """ if instance is not None and self.name: default = getattr(instance, "default_" + self.name, self.default) else: default = self.default if isinstance(default, DynamicDefault): default = default() return self.normalization(default) if self.normalization else default def copy(self, **kwargs): """Creates a deep, unbound copy of the member. Keyword arguments are assigned as attributes of the new member. It is possible to use dotted names to set attributes of nested objects. """ member_copy = self.__deepcopy__({}) # Set 'primary' before any other property, to avoid constraint # violations (f. eg. setting required = False, primary = False would # fail if the 'required' property was set first) primary = kwargs.pop("primary", None) if primary is not None: member_copy.primary = primary for key, value in kwargs.items(): obj = member_copy name_parts = key.split(".") for name in name_parts[:-1]: obj = getattr(obj, name) setattr(obj, name_parts[-1], value) return member_copy def __deepcopy__(self, memo): # Custom class if self._copy_class: copy = self._copy_class() # Copying a SchemaObject elif issubclass(self.__class__, type): members = self.members() copy = self.__class__( self.name, self.__bases__, dict((key, value) for key, value in self.__dict__.items() if key not in self._special_copy_keys and key not in members)) # Regular copy else: copy = self.__class__() memo[id(self)] = copy if not isinstance(copy, type): for key, value in self.__dict__.items(): if key not in self._special_copy_keys: copy.__dict__[key] = deepcopy(value, memo) copy._validations = list(self._validations) memo[id(self._validations)] = copy._validations copy._validations_wrapper = ListWrapper(copy._validations) memo[id(copy._validations_wrapper)] = copy._validations_wrapper copy.source_member = self copy.original_member = self.original_member return copy def detach_from_source_member(self): self.source_member = None self.original_member = self def add_validation(self, validation): """Adds a validation function to the member. :param validation: A callable that will be added as a validation rule for the member. Takes a single positional parameter (a reference to a `ValidationContext` object). The callable should produce a sequence of `exceptions.ValidationError` instances. :type validation: callable :return: The validation rule, as provided. :rtype: callable """ self._validations.append(validation) return validation def remove_validation(self, validation): """Removes one of the validation rules previously added to a member. :param validation: The validation to remove, as previously passed to `add_validation`. :type validation: callable :raise ValueError: Raised if the member doesn't have the indicated validation. """ self._validations.remove(validation) def validations(self, recursive=True, **validation_parameters): """Iterates over all the validation rules that apply to the member. :param recursive: Indicates if the produced set of validations should include those declared on members contained within the member. This parameter is only meaningful on compound members, but is made available globally in order to allow the method to be called polymorphically using a consistent signature. :type recursive: bool :return: The sequence of validation rules for the member. :rtype: callable iterable """ return self._validations_wrapper def validate(self, value, **validation_parameters): """Indicates if the given value fulfills all the validation rules imposed by the member. :param value: The value to validate. :param validation_parameters: Additional parameters used to initialize the `ValidationContext` instance generated by this validation process. :type validation_parameters: `ValidationContext` :return: True if the given value satisfies the validations imposed by this member, False otherwise. :rtype: bool """ for error in self.get_errors(value, **validation_parameters): return False return True def get_errors(self, value, **validation_parameters): """Tests the given value with all the validation rules declared by the member, iterating over the resulting set of errors. :param value: The value to evaluate. :param validation_parameters: Additional parameters used to initialize the `ValidationContext` instance generated by this validation process. :type validation_parameters: `ValidationContext` :return: An iterable sequence of validation errors. :rtype: `exceptions.ValidationError` iterable """ context = ValidationContext(self, value, **validation_parameters) for error in self._default_validation(context): yield error for validation in self.validations(**validation_parameters): for error in validation(context): yield error def coerce(self, value: Any, coercion: Coercion, **validation_parameters) -> Any: """Coerces the given value to conform to the member definition. The method applies the behavior indicated by the `coercion` parameter, either accepting or rejecting the given value. Depending on the selected coercion strategy, rejected values may be transformed into a new value or raise an exception. New values are both modified in place (when possible) and returned. """ if coercion is Coercion.NONE: return value errors = self.get_errors(value, **validation_parameters) if coercion is Coercion.FAIL: errors = list(errors) if errors: raise InputError(self, value, errors) else: for error in errors: if coercion is Coercion.FAIL_IMMEDIATELY: raise InputError(self, value, [error]) elif coercion is Coercion.SET_NONE: return None elif coercion is Coercion.SET_DEFAULT: return self.produce_default() return value @classmethod def resolve_constraint(cls, expr, context): """Resolves a constraint expression for the given context. Most constraints can be specified using dynamic expressions instead of static values, allowing them to adapt to different validation contexts. For example, a field may state that it should be required only if another field is set to a certain value. This method normalizes any constraint (either static or dynamic) to a static value, given a certain validation context. Dynamic expressions are formed by assigning a callable object or an `expressions.Expression` instance to a constraint value. :param expr: The constraint expression to resolve. :param context: The validation context that will be made available to dynamic constraint expressions. :type context: `ValidationContext` :return: The normalized expression value. """ if not isinstance(expr, type): if isinstance(expr, Expression): return expr.eval( context.get_object(-2) if context.parent_context else None) elif callable(expr): return expr(context) return expr def _default_validation(self, context): """A method implementing the intrinsic validation rules for a member. Each member subtype should extend this method to implement its constraints and validation rules. :param context: The validation context to evaluate. :type context: `ValidationContext` :return: An iterable sequence of validation errors. :rtype: `exceptions.ValidationError` iterable """ # Value required if context.value is None: if self.resolve_constraint(self.required, context): yield exceptions.ValueRequiredError(context) # None required elif self.resolve_constraint(self.require_none, context): yield exceptions.NoneRequiredError(context) else: # Enumeration enumeration = self.resolve_constraint(self.enumeration, context) if enumeration is not None and context.value not in enumeration: yield exceptions.EnumerationError(context, enumeration) # Type check type = self.resolve_constraint(self.type, context) if type and not isinstance(context.value, type): yield exceptions.TypeCheckError(context, type) def get_possible_values(self, context=None): if self.enumeration is not None: if context is None: context = ValidationContext(self, None) enumeration = self.resolve_constraint(self.enumeration, context) if enumeration: return enumeration def translate_value(self, value, language=None, **kwargs): if value is None: return translations(self, language=language, suffix=".none", **kwargs) elif ((value or value == 0) and self.translatable_values or (self.translatable_enumeration and self.enumeration)): return translations(self, language=language, suffix=".values.%s" % value) else: return str(value) def translate_error(self, error, language=None, **kwargs): for error_class in error.__class__.__mro__: try: error_class_name = get_full_name(error.__class__) except Exception as ex: error_class_name = error.__class__.__name__ error_translation = translations(self, suffix=".errors." + error_class_name, error=error, language=language) if error_translation: return error_translation # No custom translation for the error, return the stock description return translations(error, language=language, **kwargs) def get_member_explanation(self, language=None, **kwargs): return translations(self, language=language, suffix=".explanation", **kwargs) def _get_language_dependant(self): explicit_state = self.__language_dependant return (self._infer_is_language_dependant() if explicit_state is None else explicit_state) def _set_language_dependant(self, value): self.__language_dependant = value def _infer_is_language_dependant(self): return (self.translated or (self.translatable_enumeration and self.enumeration is not None)) language_dependant = property(_get_language_dependant, _set_language_dependant) def extract_searchable_text(self, extractor): extractor.feed( self.translate_value(extractor.current.value, language=extractor.current.language)) __editable = None def _get_editable(self): if self.__editable is None: if self.expression is None: return EDITABLE else: return READ_ONLY return self.__editable def _set_editable(self, editable): if isinstance(editable, bool): warn( "Setting Member.editable to a bool is deprecated, use one of " "schema.EDITABLE, schema.NOT_EDITABLE or schema.READ_ONLY", DeprecationWarning, stacklevel=2) editable = EDITABLE if editable else NOT_EDITABLE self.__editable = editable editable = property(_get_editable, _set_editable) def to_json_value(self, value, **options): return value def from_json_value(self, value, **options): return value def serialize(self, value: Any, **options) -> str: return str(value) def parse(self, value: str, **options) -> Any: if not value.strip(): return None if self.type: return self.type(value) else: return value def resolve_expression(self, obj, language=None): expression = self.expression if getattr(expression, "__self__", None) is obj: if self.translated: return expression(obj, language) else: return expression(obj) else: if self.translated: return expression(self, obj, language) else: return expression(self, obj)
class IdentityProvider(Item): instantiable = False user_authenticated = Event( """An event triggered when the provider resolves a user. .. attribute:: user The `user <woost.models.User>` resolved by the provider. .. attribute:: data The user's profile data supplied by the provider (a dictionary with provider specific keys). .. attribute:: first_login Indicates if this is the first time that this user has logged in using this provider. """ ) members_order = [ "title", "hidden", "debug_mode" ] title = schema.String( descriptive = True ) hidden = schema.Boolean( required = True, default = False ) debug_mode = schema.Boolean( required = True, default = False ) provider_name = None user_data_id_field = "id" user_data_email_field = "email" user_identifier = None def get_auth_url(self, target_url = None): raise ValueError( "%s doesn't implement the get_auth_url() method" % self ) def login(self, data): user = transaction(self.process_user_data, (data,)) app.authentication.set_user_session(user) return user def process_user_data(self, data): id = data[self.user_data_id_field] email = data.get(self.user_data_email_field) user = ( User.get_instance(**{self.user_identifier: id}) or ( email and User.get_instance(email = email) ) ) if user is None: user = User() if not user.get(self.user_identifier): user.set(self.user_identifier, id) if not user.email: user.email = email first_login = True else: first_login = False self.user_authenticated( user = user, data = data, first_login = first_login ) user.insert() return user
class RelationMember(Member): """Base class for all members that describe a single end of a relation between two or more schemas. This is an abstract class; Noteworthy concrete subclasses include L{Reference<cocktail.schema.schemareference.Reference>} and L{Collection<cocktail.schema.schemacollections.Collection>}. @ivar relation_constraints: A collection of constraints imposed on related items during validation. Constraints can be specified using schema expressions or a callable. Expressions are evaluated normally using the related object as the context. Callables receive the relation owner and a related object as a parameter. Both types of constraints should evaluate to True if the related object satisfies their requirements, or False otherwise. During validation, a failed relation constraint will produce a L{RelationConstraintError<cocktail.schemas.exceptions.RelationConstraintError>} exception. @type relation_constraints: collection of callables or L{Expression<cocktail.schema.expressions.Expression>} instances """ bidirectional = False related_key = None relation_constraints = None _integral = None _many = False __related_end = None __anonymous = False attached_as_orphan = Event( doc="""An event triggered when the relation end is attached to a schema after being declared using a self-contained bidirectional relation. @ivar anonymous: Indicates if the relation end had no name of its own. @type anonymous: bool """) def __init__(self, *args, **kwargs): Member.__init__(self, *args, **kwargs) if kwargs.get("integral", False) and not self.bidirectional: raise SchemaIntegrityError( "%s can't be declared 'integral' without setting " "'bidirectional' to True" % self) def _get_related_end(self): """Gets the opposite end of a bidirectional relation. @type: L{Member<member.Member>} """ if self.__related_end: return self.__related_end if self.source_member: return self.source_member.related_end related_end = None related_type = self.related_type if self.bidirectional and related_type is not None: if self.related_key: related_end = related_type.get_member(self.related_key) if not getattr(related_end, "bidirectional", False) \ or ( related_end.related_key and related_end.related_key != self.name ): related_end = None else: for member in related_type.members().values(): if getattr(member, "bidirectional", False): if member.related_key: if self.name == member.related_key: related_end = member break elif self.schema is member.related_type \ and member is not self: related_end = member break # Related end missing if related_end is None: raise SchemaIntegrityError( "Couldn't find the related end for %s" % self) # Disallow relations were both ends are declared as integral elif self.integral and related_end.integral: raise SchemaIntegrityError( "Can't declare both ends of a relation as integral (%s <-> %s)" % (self, related_end)) # Disallow integral many to many relations elif (self.integral or related_end.integral) \ and (self._many and related_end._many): raise SchemaIntegrityError( "Can't declare a many to many relation as integral (%s <-> %s)" % (self, related_end)) self.__related_end = related_end related_end.__related_end = self return related_end def _set_related_end(self, related_end): if related_end is not None: self.bidirectional = True self.__related_end = related_end self._bind_orphan_related_end() related_end = property(_get_related_end, _set_related_end, doc="""The member at the other end of the relation. @type: L{RelationMember} """) @event_handler def handle_attached(event): event.source._bind_orphan_related_end() def _bind_orphan_related_end(self): related_end = self.__related_end if related_end \ and related_end.schema is None \ and self.schema is not None \ and self.source_member is None: if related_end.name is None: if self.schema.name: related_end.name = self.schema.name + "_" + self.name anonymous = True else: anonymous = False if related_end.name \ and self.related_type.get_member(related_end.name) is None: self.bidirectional = True related_end.bidirectional = True self.__related_end = related_end related_end.__related_end = self related_end.attached_as_orphan(anonymous=anonymous) related_end.__anonymous = anonymous self.related_type.add_member(related_end) @property def anonymous(self): return self.__anonymous @property @abstractmethod def related_type(self): pass def _get_integral(self): integral = self._integral if integral is None: related_type = self.related_type integral = \ related_type and getattr(related_type, "integral", False) \ or False return integral def _set_integral(self, value): self._integral = value integral = property( _get_integral, _set_integral, doc="""Gets or sets wether the member defines an integral relation. Items related through an integral relation belong exclusively to its parent. @type: bool """) def add_relation(self, obj, related_obj): if _push("relate", obj, related_obj, self): try: self._add_relation(obj, related_obj) finally: _pop() def remove_relation(self, obj, related_obj): if _push("unrelate", obj, related_obj, self): try: self._remove_relation(obj, related_obj) finally: _pop() def validate_relation_constraint(self, constraint, owner, related): # Expression-based constraint if isinstance(constraint, Expression): return constraint.eval(related) # Callable-based constraint elif callable(constraint): return constraint(owner, related) # Unknown constraint type else: raise TypeError("%r is not a valid relation constraint for %r; " "expected a callable or an Expression instance" % (constraint, self))
class GoogleAnalyticsExtension(Extension): def __init__(self, **values): Extension.__init__(self, **values) self.extension_author = u"Whads/Accent SL" self.set("description", u"""Integra el lloc web amb Google Analytics.""", "ca") self.set("description", u"""Integra el sitio web con Google Analytics.""", "es") self.set("description", u"""Integrates the site with Google Analytics.""", "en") def _load(self): from woost.extensions.googleanalytics import (strings, configuration, website, document, eventredirection) from cocktail.events import when from woost.controllers import CMSController @when(CMSController.producing_output) def handle_producing_output(e): publishable = e.output.get("publishable") if (publishable is None or getattr( publishable, "is_ga_tracking_enabled", lambda: True)()): html = e.output.get("head_end_html", "") if html: html += " " html += self.get_analytics_page_hit_script(publishable) e.output["head_end_html"] = html inclusion_code = { "ga.js-async": """ <script type="text/javascript"> var _gaq = _gaq || []; _gaq.push(['_setAccount', '%(account)s']); %(commands)s (function() { var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true; ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js'; var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s); })(); </script> """, "ga.js-sync": """ <script type="text/javascript" src="http://www.google-analytics.com/ga.js"></script> <script type="text/javascript"> _gaq.push(['_setAccount', '%(account)s']); %(commands)s </script> """, "universal-async": """ <script type="text/javascript"> (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){ (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o), m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m) })(window,document,'script','//www.google-analytics.com/analytics.js','ga'); ga("create", "%(account)s"); %(commands)s </script> """, "universal-sync": """ <script type="text/javascript"> (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){ (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o), m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m) })(window,document,'script','//www.google-analytics.com/analytics.js','ga'); ga("create", "%(account)s"); ga(function () { %(commands)s }); </script> """, } declaring_tracker = Event() def get_analytics_script(self, publishable=None, commands=None, async=True): config = Configuration.instance api = config.google_analytics_api classic_api = (api == "ga.js") if publishable is None: publishable = context.get("publishable") event = self.declaring_tracker( api=api, publishable=publishable, account=config.get_setting("google_analytics_account"), template=self.inclusion_code[api + ("-async" if async else "-sync")], values=None if classic_api else self.get_analytics_values(publishable), commands=commands or []) parameters = {"account": event.account} if classic_api: parameters["commands"] = \ self._serialize_ga_commands(event.commands) else: commands = event.commands if event.values: commands.insert(0, ("set", event.values)) parameters["commands"] = \ self._serialize_universal_commands(commands) return event.template % parameters
class Foo(object): spammed = Event(event_info_class = SpammedEventInfo)
class EditNode(StackNode): """An L{edit stack<EditStack>} node, used to maintain a set of changes for an edited item before they are finally committed. @ivar translations: The list of translations defined by the item (note that these can change during the course of an edit operation, so that's why they are stored in here). @type translations: str list """ _persistent_keys = frozenset([ "_stack", "_parent_node", "_index", "_item", "_form_data", "translations", "section", "tab" ]) _item = None translations = None section = "fields" tab = None saving = Event(""" An event triggered when saving the changes contained within the node, after loading form data to the modified item and before the transaction is committed. @ivar user: The user that makes the changes. @type user: L{User<woost.models.User>} @ivar changeset: The change set describing the changes. @type changeset: L{ChangeSet<woost.models.ChangeSet>} """) committed = Event(""" An event triggered after the changes contained within the node have been successfully committed to the data store. @ivar user: The user that makes the changes. @type user: L{User<woost.models.User>} @ivar changeset: The change set describing the changes. @type changeset: L{ChangeSet<woost.models.ChangeSet>} """) def __init__(self, item): assert item is not None self._item = item def __translate__(self, language, **kwargs): if self.item.is_inserted: return translations(self.item) else: return translations("creating", content_type=self.content_type) def uri(self, **params): if "edit_stack" not in params: params["edit_stack"] = self.stack.to_param(self.index) uri = context["cms"].contextual_uri( "content", str(self.item.id) if self.item.is_inserted or self.item.is_deleted else "new", self.section, **params) uri += "#" + (self.tab or "") return uri def __getstate__(self): state = {} for key, value in self.__dict__.iteritems(): if key in self._persistent_keys: if key == "_item" and not value.is_inserted: value = None state[key] = value state["content_type"] = self._item.__class__ if self._item.__class__.translated: state["item_translations"] = self._item.translations.keys() return state def __setstate__(self, state): content_type = state.pop("content_type", None) item_translations = state.pop("item_translations", None) for key, value in state.iteritems(): if key in self._persistent_keys: if key == "_item": if content_type is None: value = None elif value is None: value = content_type() self.initialize_new_item(value, item_translations) setattr(self, key, value) @getter def content_type(self): """The edited content type. @type: L{Item<woost.models.Item>} subclass """ return self._item.__class__ @getter def item(self): """The edited item. @type: L{Item<woost.models.Item>} """ return self._item def initialize_new_item(self, item, languages=None): if item.__class__.translated: for language in (languages or [ Configuration.instance.get_setting("default_language") ]): item.new_translation(language) def import_form_data(self, form_data, item): """Update the edited item with data gathered from the form.""" self.form_adapter.import_object(form_data, item, self.form_schema, self.content_type) # Drop deleted translations if item.__class__.translated: user = get_current_user() deleted_translations = ( set(language for language in item.translations if user.has_permission( ReadTranslationPermission, language=language)) - set(self.translations)) for language in deleted_translations: del item.translations[language] def export_form_data(self, item, form_data): """Update the edit form with the data contained on the edited item.""" self.form_adapter.export_object(item, form_data, self.content_type, self.form_schema) # Default translations if self.content_type.translated: user = get_current_user() available_languages = set( language for language in item.translations if user.has_permission(ReadTranslationPermission, language=language)) if not self._item.translations: default_language = \ Configuration.instance.get_setting("default_language") if default_language in available_languages: self._item.new_translation(default_language) self.translations = [ language for language in self._item.translations.keys() if language in available_languages ] else: self.translations = [] @cached_getter def form_adapter(self): """The data adapter used to pass data between the edited item and the edit form. @type: L{Adapter<cocktail.schema.Adapter>} """ adapter = schema.Adapter() adapter.collection_copy_mode = self._adapt_collection adapter.exclude([ member.name for member in self.content_type.members().itervalues() if self.should_exclude_member(member) ]) return adapter def should_exclude_member(self, member): if not member.editable: return True if not member.visible: return True # Hide relations with relation nodes in the stack relation_node = self.get_ancestor_node(RelationNode) if relation_node and member is relation_node.member.related_end: return True user = get_current_user() if not user.has_permission(ReadMemberPermission, member=member): return True if not user.has_permission(ModifyMemberPermission, member=member): return True if isinstance(member, schema.RelationMember) \ and member.is_persistent_relation: # Hide relations to invisible types if not member.related_type.visible: return True # Hide empty collections with the exclude_when_empty flag # set if (isinstance(member, schema.Collection) and member.exclude_when_empty and not member.select_constraint_instances(parent=self.item)): return True def class_family_permission(root, permission_type): return any( user.has_permission(permission_type, target=cls) for cls in root.schema_tree()) # Require read permission for related types if not class_family_permission(member.related_type, ReadPermission): return True # Integral relation if (isinstance(member, schema.Reference) and member.integral and self.item): # Empty: require create permission # Has an item: require edit or delete permission if self.item.get(member) is None: if not class_family_permission(member.type, CreatePermission): return True elif not (class_family_permission(member.type, ModifyPermission) and class_family_permission(member.type, DeletePermission)): return True # Remove all relations to blocks from the edit view if (isinstance(member, schema.RelationMember) and member.related_type and issubclass(member.related_type, Block)): return True return False def _adapt_collection(self, context, key, value): return self._copy_collection(value) def _copy_collection(self, collection): if isinstance(collection, (PersistentList, PersistentRelationList)): return list(collection) elif isinstance(collection, (PersistentMapping, PersistentRelationMapping)): return dict(collection.iteritems()) elif isinstance(collection, (PersistentOrderedSet, PersistentRelationOrderedSet)): return OrderedSet(collection) elif isinstance(collection, (PersistentSet, PersistentRelationSet)): return set(collection) else: return copy(collection) @cached_getter def form_schema(self): """The schema that describes the edit form for the edited item. @type: L{Schema<cocktail.schema.Schema>} """ form_schema = self.form_adapter.export_schema(self.content_type) form_schema.name = "BackOfficeEditForm" return form_schema @cached_getter def form_data(self): """The data entered into the edit form.""" form_data = {} # First load: fill the form with data from the edited item if self is self.stack[-1]: self.export_form_data(self.item, form_data) return form_data def iter_errors(self): """Iterates over the sequence of validation errors produced by the current edited state. @type: L{ValidationError<cocktail.schema.exceptions.ValidationError>} sequence """ return self.form_schema.get_errors(self.form_data, languages=self.translations, persistent_object=self.item) def iter_changes(self, source=None): """Iterates over the set of members that the current edit state has modified. Each change is expressed as a tuple containing the affected member and language. @type: (L{Member<cocktail.schema.Member>}, str) sequence """ source_form_data = {} self.form_adapter.export_object(source or self._item, source_form_data, self.content_type, self.form_schema) return schema.diff(source_form_data, self.form_data, self.form_schema, exclude=lambda member: not member.editable) def relate(self, member, item): """Adds a relation between the edited item and another item. @param member: The member describing the relation between the two items. It should be the end nearer to the edited item. @type member: L{RelationMember<cocktail.schema.RelationMember>} @param item: The item to relate. @type item: L{Item<woost.models.item.Item>} """ if isinstance(member, schema.Collection): collection = schema.get(self.form_data, member) # Editing collections with duplicate entries is not allowed if item in collection: raise ValueError( "Collections with duplicate entries are not allowed") schema.add(collection, item) else: schema.set(self.form_data, member, item) def unrelate(self, member, item): """Breaks the relation between the edited item and one of its related items. @param member: The member describing the relation between the two items. It should be the end nearer to the edited item. @type member: L{RelationMember<cocktail.schema.RelationMember>} @param item: The item to unrelate. @type item: L{Item<woost.models.item.Item>} """ if isinstance(member, schema.Collection): collection = schema.get(self.form_data, member) schema.remove(collection, item) else: schema.set(self.form_data, member, None) def item_saved_notification(self, is_new, change): """Notifies the user that the node's item has been saved. @param is_new: Indicates if the save operation consisted of an insertion (True) or an update of an existing item (False). @type is_new: bool @param change: A change object describing the save operation. @type change: L{Change<woost.models.changeset.Change>} """ item = self.item msg = translations("woost.views.BackOfficeEditView Changes saved", item=item, is_new=is_new) transient = True if is_new and self.parent_node is None: controller = cherrypy.request.handler_chain[-1] msg += '. <a href="%s">%s</a>.' % ( controller.edit_uri(item.__class__, edit_stack=None), translations("woost.views.BackOfficeEditView Create another")) transient = False notify_user(msg, "success", transient)
class DataStore(object): """A thread safe wrapper over the application's object database. Normally used through its global L{datastore} instance. The class expects an X{storage} setting, containing a L{storage<ZODB.BaseStorage.BaseStorage>} instance pointing to the physical location of the database (see the ZODB documentation for more details). """ automatic_migration = False def __init__(self, storage=None): self._thread_data = local() self.__storage_lock = RLock() self.__db = None self.__storage = None self.storage = storage storage_changed = Event(""" An event triggered when changing which storage is used by the datastore. """) cleared = Event(""" An event triggered when all data in the storage is completely cleared. """) connection_opened = Event(""" An event triggered when the datastore spawns a new thread-bound connection. @ivar connection: The new connection. @type connection: L{Connection<>} """) def _get_storage(self): if isinstance(self.__storage, FunctionType): self.__storage_lock.acquire() try: if isinstance(self.__storage, FunctionType): self.__storage = self.__storage() self.__possibly_migrate() finally: self.__storage_lock.release() return self.__storage def _set_storage(self, storage): self.__storage_lock.acquire() try: self.__storage = storage self.__db = None self.__possibly_migrate() self.storage_changed() finally: self.__storage_lock.release() storage = property(_get_storage, _set_storage, doc=""" Gets or sets the underlying ZODB storage for the data store. @type: L{ZODB.Storage} """) def __possibly_migrate(self): if self.automatic_migration \ and self.__storage is not None \ and not isinstance(self.__storage, FunctionType): from cocktail.persistence.migration import migrate migrate() @property def db(self): if self.__db is None: self.__db = DB(self.storage) return self.__db @property def root(self): """Gives access to the root container of the database. The property is thread safe; accessing it on different threads will produce different containers, each bound to a separate database connection. @type: mapping """ root = getattr(self._thread_data, "root", None) if root is None: root = self.connection.root() self._thread_data.root = root return root @property def connection(self): """Returns the database connection for the current thread. The property is thread safe; accessing it on different threads will produce different connections. Once called, each connection remains bound to a single thread until the thread finishes or the datastore's L{close} method is called. @type: L{Connection<ZODB.Connection.Connection>} """ connection = getattr(self._thread_data, "connection", None) if connection is None: connection = self.db.open() self._thread_data.connection = connection self.connection_opened(connection=connection) return connection commit = transaction.commit abort = transaction.abort def close(self): """Closes the connection to the database for the current thread. Accessing the L{root} or L{connection} properties after this method is called will spawn a new database connection. """ if hasattr(self._thread_data, "root"): self._thread_data.root = None connection = getattr(self._thread_data, "connection", None) if connection is not None: self._thread_data.connection.close() self._thread_data.connection = None def clear(self): """Clears all the data in the storage.""" self.root.clear() self.cleared() self.commit() def sync(self): self._thread_data.root = None self.connection.sync() def _get_transaction_data(self, create_if_missing=False): thread_transaction_data = getattr(self._thread_data, "transaction_data", None) if thread_transaction_data is None: thread_transaction_data = WeakKeyDictionary() self._thread_data.transaction_data = thread_transaction_data transaction = getattr(self._thread_data, "transaction", None) if transaction is None: transaction = self.connection.transaction_manager.get() transaction_data = thread_transaction_data.get(transaction) if transaction_data is None and create_if_missing: transaction_data = {} thread_transaction_data[transaction] = transaction_data return transaction_data def get_transaction_value(self, key, default=None): transaction_data = self._get_transaction_data() if transaction_data is None: return default return transaction_data.get(key, default) def set_transaction_value(self, key, value): transaction_data = self._get_transaction_data(True) transaction_data[key] = value def unique_after_commit_hook(self, id, callback, *args, **kwargs): key = "cocktail.persistence.unique_after_commit_hooks" unique_after_commit_hooks = self.get_transaction_value(key) if unique_after_commit_hooks is None: unique_after_commit_hooks = set([id]) self.set_transaction_value(key, unique_after_commit_hooks) elif id in unique_after_commit_hooks: return False else: unique_after_commit_hooks.add(id) def callback_wrapper(success, *args, **kwargs): try: self._thread_data.transaction = transaction return callback(success, *args, **kwargs) finally: self._thread_data.transaction = None transaction = self.connection.transaction_manager.get() transaction.addAfterCommitHook(callback_wrapper, args, kwargs) return True def process_items_after_commit(self, callback, item, item_data=None, mapping_type=dict): id = get_full_name(callback) items_key = id + ".items" items = self.get_transaction_value(items_key) if items is None: items = mapping_type() self.set_transaction_value(items_key, items) items[item] = item_data self.unique_after_commit_hook(id, callback, items)
class Translations(object): bundle_loaded = Event() verbose = False def __init__(self, *args, **kwargs): self.definitions = {} self.__loaded_bundles = set() self.__bundle_overrides = defaultdict(list) def _get_key(self, key, verbose): if verbose: print((verbose - 1) * 2 * " " + styled("Key", "slate_blue"), key) return self.definitions.get(key) def define(self, key, value=None, **per_language_values): if value is not None and per_language_values: raise ValueError( "Can't specify both 'value' and 'per_language_values'") self.definitions[key] = per_language_values or value def set(self, key, language, value): per_language_values = self.definitions.get(key) if per_language_values is None: per_language_values = {} self.definitions[key] = per_language_values per_language_values[language] = value def instances_of(self, cls): def decorator(func): try: class_name = get_full_name(cls) except: class_name = cls.__name__ self.definitions[class_name + ".instance"] = func return func return decorator def load_bundle(self, bundle_path, callback=None, reload=False, **kwargs): if reload or bundle_path not in self.__loaded_bundles: self.__loaded_bundles.add(bundle_path) pkg_name, file_name = bundle_path.rsplit(".", 1) file_path = resource_filename(pkg_name, file_name + ".strings") file_error = None try: with open(file_path, "r") as bundle_file: parser = TranslationsFileParser(bundle_file, file_path=file_path, **kwargs) for key, language, value in parser: if callback: callback(key, language, value) if language is None: self.definitions[key] = value else: self.set(key, language, value) except IOError as e: file_error = e else: self.bundle_loaded(file_path=file_path) overrides = self.__bundle_overrides.get(bundle_path) if overrides: for overrides_path, overrides_kwargs in overrides: self.load_bundle(overrides_path, **kwargs) elif file_error: raise file_error def request_bundle(self, bundle_path, **kwargs): try: self.load_bundle(bundle_path, **kwargs) except IOError: pass def override_bundle(self, original_bundle_path, overrides_bundle_path, **kwargs): self.__bundle_overrides[original_bundle_path].append( (overrides_bundle_path, kwargs)) if original_bundle_path in self.__loaded_bundles: self.request_bundle(overrides_bundle_path) def __iter_class_names(self, cls): mro = getattr(cls, "__mro__", None) if mro is None: mro = (cls, ) for cls in mro: try: class_name = get_full_name(cls) except: class_name = cls.__name__ yield class_name def __call__(self, obj, language=None, default="", chain=None, **kwargs): translation = None verbose = kwargs.get("verbose", None) if verbose is None: if callable(self.verbose): verbose = self.verbose(obj, **kwargs) else: verbose = self.verbose verbose = 1 if verbose else 0 if verbose == 1: print(styled("TRANSLATION", "white", "blue"), end=' ') print(styled(obj, style="bold")) if verbose: kwargs["verbose"] = verbose + 1 # Look for a explicit definition for the given value if isinstance(obj, str): translation = self._get_key(obj, verbose) else: # Translation of class instances for class_name in self.__iter_class_names(obj.__class__): translation = self._get_key(class_name + ".instance", verbose) if translation: break # Translation of class references if not translation and isinstance(obj, type): for class_name in self.__iter_class_names(obj): translation = self(class_name, language=language, **kwargs) if translation: break # Resolve the obtained translation if translation: if callable(translation): with language_context(language): if verbose: print(styled("Function", "slate_blue"), end=' ') print(translation) if isinstance(obj, str): translation = translation(**kwargs) else: translation = translation(obj, **kwargs) elif isinstance(translation, Mapping): definitions = translation translation = None prev_lang = language or get_language() for lang in iter_language_chain(language): translation = definitions.get(lang) if translation: if callable(translation): with language_context(prev_lang): if isinstance(obj, str): translation = translation(**kwargs) else: translation = translation(obj, **kwargs) if translation: break else: if kwargs: translation = translation % kwargs break elif kwargs: translation = translation % kwargs # Custom translation chain if not translation and chain is not None: translation = self(chain, language=language, **kwargs) if not translation: translation = str(default) return translation or ""
class ECommerceOrder(Item): payment_types_completed_status = { "payment_gateway": "accepted", "transfer": "payment_pending", "cash_on_delivery": "payment_pending" } incoming = Event(doc=""" An event triggered when a new order is received. """) completed = Event(doc=""" An event triggered when an order is completed. """) groups_order = ["shipping_info", "billing"] members_order = [ "customer", "address", "town", "region", "country", "postal_code", "language", "status", "purchases", "payment_type", "total_price", "pricing", "total_shipping_costs", "shipping_costs", "total_taxes", "taxes", "total" ] customer = schema.Reference( type=User, related_end=schema.Collection(), required=True, default=schema.DynamicDefault(get_current_user)) address = schema.String(member_group="shipping_info", required=True, listed_by_default=False) town = schema.String(member_group="shipping_info", required=True, listed_by_default=False) region = schema.String(member_group="shipping_info", required=True, listed_by_default=False) country = schema.Reference( member_group="shipping_info", type=Location, relation_constraints=[Location.location_type.equal("country")], default_order="location_name", related_end=schema.Collection(), required=True, listed_by_default=False, user_filter="cocktail.controllers.userfilter.MultipleChoiceFilter") postal_code = schema.String(member_group="shipping_info", required=True, listed_by_default=False) language = schema.String( required=True, format="^[a-z]{2}$", editable=False, default=schema.DynamicDefault(get_language), text_search=False, translate_value=lambda value, language=None, **kwargs: u"" if not value else translations(value, language, **kwargs)) status = schema.String( required=True, indexed=True, enumeration=[ "shopping", "payment_pending", "accepted", "failed", "refund" ], default="shopping", text_search=False, translate_value=lambda value, language=None, **kwargs: u"" if not value else translations("ECommerceOrder.status-" + value, language, **kwargs)) purchases = schema.Collection( items="woost.extensions.ecommerce.ecommercepurchase." "ECommercePurchase", integral=True, bidirectional=True, min=1) payment_type = schema.String( member_group="billing", required=True, translate_value=lambda value, language=None, **kwargs: translations( "ECommerceOrder.payment_type-%s" % value, language=language), default=schema.DynamicDefault(_get_default_payment_type), text_search=False, edit_control="cocktail.html.RadioSelector", listed_by_default=False) total_price = schema.Decimal(member_group="billing", editable=False, listed_by_default=False, translate_value=_translate_amount) pricing = schema.Collection( member_group="billing", items=schema.Reference(type=ECommerceBillingConcept), related_end=schema.Collection(block_delete=True), editable=False) total_shipping_costs = schema.Decimal(member_group="billing", editable=False, listed_by_default=False, translate_value=_translate_amount) shipping_costs = schema.Collection( member_group="billing", items=schema.Reference(type=ECommerceBillingConcept), related_end=schema.Collection(block_delete=True), editable=False) total_taxes = schema.Decimal(member_group="billing", editable=False, listed_by_default=False, translate_value=_translate_amount) taxes = schema.Collection( member_group="billing", items=schema.Reference(type=ECommerceBillingConcept), related_end=schema.Collection(block_delete=True), editable=False) total = schema.Decimal(member_group="billing", editable=False, translate_value=_translate_amount) def calculate_cost(self, apply_pricing=True, apply_shipping_costs=True, apply_taxes=True): """Calculates the costs for the order. :rtype: dict """ from woost.extensions.ecommerce import ECommerceExtension extension = ECommerceExtension.instance order_costs = { "price": { "cost": Decimal("0.00"), "percentage": Decimal("0.00"), "concepts": [] }, "shipping_costs": { "cost": Decimal("0.00"), "percentage": Decimal("0.00"), "concepts": [] }, "taxes": { "cost": Decimal("0.00"), "percentage": Decimal("0.00"), "concepts": [] }, "purchases": {} } # Per purchase costs: for purchase in self.purchases: purchase_costs = purchase.calculate_costs( apply_pricing=apply_pricing, apply_shipping_costs=apply_shipping_costs, apply_taxes=apply_taxes) order_costs["purchases"][purchase] = purchase_costs order_costs["price"]["cost"] += purchase_costs["price"]["total"] order_costs["shipping_costs"]["cost"] += \ purchase_costs["shipping_costs"]["total"] order_costs["taxes"]["cost"] += purchase_costs["taxes"]["total"] # Order price order_price = order_costs["price"] if apply_pricing: for pricing in extension.pricing: if pricing.applies_to(self): pricing.apply(self, order_price) order_price["cost"] += \ order_price["cost"] * order_price["percentage"] / 100 order_price["total"] = order_price["cost"] # Order shipping costs order_shipping_costs = order_costs["shipping_costs"] if apply_shipping_costs: for shipping_cost in extension.shipping_costs: if shipping_cost.applies_to(self): shipping_cost.apply(self, order_shipping_costs) order_shipping_costs["total"] = ( order_shipping_costs["cost"] + order_price["total"] * order_shipping_costs["percentage"] / 100) # Order taxes order_taxes = order_costs["taxes"] if apply_taxes: for tax in extension.taxes: if tax.applies_to(self): tax.apply(self, order_taxes) order_taxes["total"] = ( order_taxes["cost"] + order_price["total"] * order_taxes["percentage"] / 100) # Total order_costs["total"] = (order_price["total"] + order_shipping_costs["total"] + order_taxes["total"]) return order_costs def update_cost(self, apply_pricing=True, apply_shipping_costs=True, apply_taxes=True): costs = self.calculate_cost(apply_pricing=apply_pricing, apply_shipping_costs=apply_shipping_costs, apply_taxes=apply_taxes) self.total_price = costs["price"]["total"] self.pricing = list(costs["price"]["concepts"]) self.total_shipping_costs = costs["shipping_costs"]["total"] self.shipping_costs = list(costs["shipping_costs"]["concepts"]) self.total_taxes = costs["taxes"]["total"] self.taxes = list(costs["taxes"]["concepts"]) self.total = costs["total"] for purchase, purchase_costs in costs["purchases"].iteritems(): purchase.total_price = purchase_costs["price"]["total"] purchase.pricing = list(purchase_costs["price"]["concepts"]) self.pricing.extend(purchase.pricing) purchase.total_shipping_costs = \ purchase_costs["shipping_costs"]["total"] purchase.shipping_costs = \ list(purchase_costs["shipping_costs"]["concepts"]) self.shipping_costs.extend(purchase.shipping_costs) purchase.total_taxes = purchase_costs["taxes"]["total"] purchase.taxes = list(purchase_costs["taxes"]["concepts"]) self.taxes.extend(purchase.taxes) purchase.total = purchase_costs["total"] def count_units(self): return sum(purchase.quantity for purchase in self.purchases) def get_weight(self): return sum(purchase.get_weight() for purchase in self.purchases) def add_purchase(self, purchase): for order_purchase in self.purchases: if order_purchase.__class__ is purchase.__class__ \ and order_purchase.product is purchase.product \ and all( order_purchase.get(option) == purchase.get(option) for option in purchase.get_options() if option.name != "quantity" ): order_purchase.quantity += purchase.quantity purchase.product = None if purchase.is_inserted: purchase.delete() break else: self.purchases.append(purchase) @classmethod def get_public_schema(cls): public_schema = schema.Schema("OrderCheckoutSummary") cls.get_public_adapter().export_schema(cls, public_schema) payment_type = public_schema.get_member("payment_type") if payment_type: payments = PaymentsExtension.instance if payments.enabled and payments.payment_gateway: translate_value = payment_type.translate_value def payment_type_translate_value(value, language=None, **kwargs): if value == "payment_gateway": return payments.payment_gateway.label else: return translate_value(value, language=language, **kwargs) payment_type.translate_value = payment_type_translate_value return public_schema @classmethod def get_public_adapter(cls): from woost.extensions.ecommerce import ECommerceExtension user = get_current_user() adapter = schema.Adapter() adapter.exclude(["customer", "status", "purchases"]) adapter.exclude([ member.name for member in cls.members().itervalues() if not member.visible or not member.editable or not issubclass(member.schema, ECommerceOrder) or not user.has_permission(ModifyMemberPermission, member=member) ]) if len(ECommerceExtension.instance.payment_types) == 1: adapter.exclude(["payment_type"]) return adapter @property def is_completed(self): return self.status \ and self.status == self.payment_types_completed_status.get( self.payment_type ) @event_handler def handle_changed(cls, event): item = event.source member = event.member if member.name == "status": if event.previous_value == "shopping" \ and event.value in ("payment_pending", "accepted"): item.incoming() if item.is_completed: item.completed() def get_description_for_gateway(self): site_name = Configuration.instance.get_setting("site_name") if site_name: return translations( "woost.extensions.ECommerceOrder description for gateway" ) % site_name else: return translations(self)
for member in cls.members(False).values(): if member.indexed and not member.primary: if verbose: print("Rebuilding index for %s" % member) member.rebuild_index() if recursive: for subclass in cls.derived_schemas(): subclass.rebuild_indexes(True) cls.rebuilding_indexes(recursive=recursive, verbose=verbose) PersistentClass.rebuild_indexes = _rebuild_indexes PersistentClass.rebuilding_indexes = Event() @when(PersistentObject.declared) def _handle_declared(event): cls = event.source # Add 'id' as an alias for custom primary members if cls.primary_member: if cls.primary_member.schema is cls \ and cls.primary_member.name != "id": cls.id = cls.__dict__[cls.primary_member.name] # Add an 'id' field to all indexed schemas that don't define their # own primary member explicitly. Will be initialized to an
class Schema(Member): """A data structure, made up of one or more L{members<member.Member>}. Schemas are themselves members, which allows them to be nested arbitrarely (ie. in other schemas or L{collections<schemacollections.Collection>} to assemble more complex compound types. Schemas support inheritance. All members defined by a base schema will be reflected on their derived schemas. This is done dynamically: new members added to base schemas automatically appear as members of any derived schema, recursively. Derived schemas can override member definitions with their own, simply adding a new member matching the name of a existing one. Schemas can use multiple inheritance; in case of conflicting member definitions, the one defined by the foremost base schema (as passed to the L{inherit} method) will take precedence. @ivar bases: The list of base schemas that the schema inherits from. This is a shallow list; to obtain the full inheritance tree, use the L{ascend_inheritance} method instead. """ schema_aliases = () primary_member = None descriptive_member = None members_order = None groups_order = [] integral = False text_search = True member_added = Event(""" An event triggered when a member is added to the schema. @ivar member: The added member. @type member: L{Member<cocktail.schema.Member>} """) inherited = Event(""" An event triggered when the schema is extended by another schema. @ivar schema: The derived schema that extends this schema. @type schema: L{Schema} """) _special_copy_keys = Member._special_copy_keys | set( ["_Schema__bases", "_Schema__members", "_declared"]) def __init__(self, *args, **kwargs): members = kwargs.pop("members", None) bases = kwargs.pop("bases", None) Member.__init__(self, *args, **kwargs) self.__bases = None self.bases = ListWrapper(empty_list) self.__members = None if members: if isinstance(members, (list, tuple)) and not self.members_order: self.members_order = [member.name for member in members] self.expand(members) if bases: self.inherit(*bases) def __deepcopy__(self, memo): schema_copy = Member.__deepcopy__(self, memo) if not isinstance(schema_copy, type): if self.__bases: for base in self.__bases: schema_copy.inherit(base) if self.__members: for member in self.__members.values(): schema_copy.add_member(deepcopy(member)) return schema_copy def init_instance(self, instance, values=None, accessor=None, excluded_members=None): if accessor is None: accessor = get_accessor(instance) # Set the value of all object members, either from a parameter or from # a default value definition for name, member in self.members().items(): if excluded_members is not None and member in excluded_members: continue value = default if values is None else values.get(name, default) if value is undefined: continue if value is default: if member.translated: continue value = member.produce_default(instance) accessor.set(instance, name, value) def produce_default(self, instance=None): default = Member.produce_default(self, instance) if default is None: default = self._create_default_instance() return default def _create_default_instance(self): if self.type: default = self.type() elif isinstance(self, type): default = self() else: default = {} self.init_instance(default) return default def inherit(self, *bases): """Declare an inheritance relationship towards one or more base schemas. @param bases: The list of base schemas to inherit from. @type bases: L{Schema} """ def prevent_cycle(bases): for base in bases: if base is self: raise SchemaInheritanceCycleError(self) if base.__bases: prevent_cycle(base.__bases) prevent_cycle(bases) if self.__bases is None: self.__bases = [] self.bases = ListWrapper(self.__bases) for base in bases: self.__bases.append(base) for ancestor in reversed(list(base.ascend_inheritance(True))): ancestor.inherited(schema=self) def ascend_inheritance(self, include_self=False): if include_self: yield self if self.__bases: for base in self.__bases: for ascendant in base.ascend_inheritance(True): yield ascendant def descend_inheritance(self, include_self=False): if self.__bases: for base in self.__bases: for ascendant in base.descend_inheritance(True): yield ascendant if include_self: yield self def add_member(self, member, append=False, after=None, before=None): """Adds a new member to the schema. @param member: The member to add. @type member: L{Member<member.Member>} @raise SchemaIntegrityError: Raised when trying to add an anonymous member to the schema. All members must have a unique name. """ self._check_member(member) if append or after or before: if ((1 if append else 0) + (1 if after else 0) + (1 if before else 0) > 1): raise ValueError( "Can't combine the 'append', 'after' or 'before' " "parameters when calling Schema.add_member()") if self.members_order is None: self.members_order = [] elif not isinstance(self.members_order, list): self.members_order = list(self.members_order) if append: self.members_order.append(member.name) elif after: pos = self.members_order.index(after) self.members_order.insert(pos + 1, member.name) else: pos = self.members_order.index(before) self.members_order.insert(pos, member.name) self._add_member(member) member.attached() self.member_added(member=member) def _check_member(self, member): if member.name is None: raise SchemaIntegrityError("Can't add an anonymous member to %s" % self) def _add_member(self, member): if self.__members is None: self.__members = {} if member.primary: self.primary_member = member if member.descriptive: self.descriptive_member = member self.__members[member.name] = member member._schema = self def expand(self, members): """Adds several members to the schema. @param members: A list or mapping of additional members to add to the copy. When given as a mapping, the keys will be used for the member names. @type members: L{Member<member.Member>} list or (str, L{Member<member.Member>}) dict """ # From a dictionary if isinstance(members, dict): for name, member in members.items(): if isinstance(member, type): member = member() member.name = name self.add_member(member) # From a list else: # Use the provided list as an implicit order sequence for the # schema members if not self.members_order: self.members_order = [member.name for member in members] for member in members: self.add_member(member) def remove_member(self, member): """Removes a member from the schema. @param member: The member to remove. Can be specified using a reference to the member object itself, or giving its name. @type member: L{Member<member.Member>} or str @raise L{SchemaIntegrityError<exceptions.SchemaIntegrityError>}: Raised if the member doesn't belong to the schema. """ # Normalize string references to member objects if isinstance(member, str): member = self[member] if member._schema is not self: raise SchemaIntegrityError( "Trying to remove %s from a schema it doesn't belong to (%s)" % (member, self)) member._schema = None del self.__members[member.name] def members(self, recursive=True): """A dictionary with all the members defined by the schema and its bases. @param recursive: Indicates if the returned dictionary should contain members defined by the schema's bases. This is the default behavior; Setting this parameter to False will exclude all inherited members. @type recursive: False @return: A mapping containing the members for the schema, indexed by member name. @rtype: (str, L{Member<members.Member>}) read only dict """ if recursive and self.__bases: return dict( (member.name, member) for member in self.iter_members()) else: return DictWrapper(self.__members or empty_dict) def iter_members(self, recursive=True): """Iterates over all the members defined by the schema and its bases. @param recursive: Indicates if the returned dictionary should contain members defined by the schema's bases. This is the default behavior; Setting this parameter to False will exclude all inherited members. @type recursive: False @return: An iterable sequence containing the members for the schema and its bases. No guarantees are made about their order. @rtype: L{Member<members.Member>} iterator """ if recursive and self.__bases: for base in self.__bases: for member in base.iter_members(): yield member if self.__members: for member in self.__members.values(): yield member def get_member(self, name): """Obtains one of the schema's members given its name. @param name: The name of the member to look for. @type name: str @return: The requested member, or None if the schema doesn't contain a member with the indicated name. @rtype: L{Member<member.Member>} """ member = self.__members and self.__members.get(name) if member is None and self.__bases: for base in self.__bases: member = base.get_member(name) if member: break return member def __getitem__(self, name): """Overrides the indexing operator to retrieve members by name. @param name: The name of the member to retrieve. @rtype name: str @return: A reference to the requested member. @rtype: L{Member<member.Member>} @raise KeyError: Raised if neither the schema or its bases possess a member with the specified name. """ member = self.get_member(name) if member is None: raise KeyError("%s doesn't define a '%s' member" % (self, name)) return member def __setitem__(self, name, member): """Overrides the indexing operator to bind members to the schema under the specified name. @param name: The name to assign to the member. @type name: str @param member: The member to add to the schema. @type member: L{Member<member.Member>} """ member.name = name self.add_member(member) def __contains__(self, name): """Indicates if the schema contains a member with the given name. @param name: The name of the member to search for. @type name: str @return: True if the schema contains a member by the given name, False otherwise. @rtype: bool """ return self.get_member(name) is not None def validations(self, recursive=True, **validation_parameters): """Iterates over all the validation rules that apply to the schema. @param recursive: Indicates if validations inherited from base schemas should be included. This is the default behavior. @return: The sequence of validation rules for the member. @rtype: callable iterable """ if self.__bases: validations = OrderedSet() def descend(schema): if schema.__bases: for base in schema.__bases: descend(base) if schema._validations: validations.extend(schema._validations) descend(self) return ListWrapper(validations) elif self._validations: return ListWrapper(self._validations) else: return empty_list def _default_validation(self, context): """Validation rule for schemas. Applies the validation rules defined by all members in the schema, propagating their errors.""" for error in Member._default_validation(self, context): yield error accessor = (self.accessor or context.get("accessor", None) or get_accessor(context.value)) languages = context.get("languages") for member in self.ordered_members(): key = member.name if member.translated: for language in (languages or accessor.languages(context.value, key)): value = accessor.get(context.value, key, language=language) for error in member.get_errors(value, parent_context=context, language=language): yield error else: value = accessor.get(context.value, key, default=None) for error in member.get_errors(value, parent_context=context): yield error def coerce(self, value: Any, coercion: Coercion, **validation_parameters) -> Any: """Coerces the given value to conform to the member definition. The method applies the behavior indicated by the `coercion` parameter to each member of the scheam, either accepting or rejecting its value. Depending on the selected coercion strategy, rejected values may be transformed into a new value or raise an exception. New values are modified in place. """ if coercion is Coercion.NONE: return value if coercion is Coercion.FAIL: errors = list(self.get_errors(value, **validation_parameters)) if errors: raise InputError(self, value, errors) else: # Coercion of members affected by schema wide validation rules accessor = get_accessor(value) schema_level_errors = self.get_errors(value, recursive=False, **validation_parameters) for error in schema_level_errors: if coercion is Coercion.FAIL_IMMEDIATELY: raise InputError(self, value, [error]) elif coercion is Coercion.SET_NONE: for member in error.invalid_members: if member.schema is self: accessor.set(value, member.name, None, error.language) elif coercion is Coercion.SET_DEFAULT: for member in error.invalid_members: if member.schema is self: accessor.set(value, member.name, member.produce_default(value), error.language) # Per member coercion if value is not None: for member in self.iter_members(): if member.translated: for language in accessor.languages(value, member.name): lang_value = accessor.get(value, member.name, language) coerced_lang_value = member.coerce( lang_value, coercion, **validation_parameters) if lang_value != coerced_lang_value: accessor.set(value, member.name, coerced_lang_value, language) else: member_value = accessor.get(value, member.name) coerced_member_value = member.coerce( member_value, coercion, **validation_parameters) if member_value != coerced_member_value: accessor.set(value, member.name, coerced_member_value) return value def ordered_members(self, recursive=True): """Gets a list containing all the members defined by the schema, in order. Schemas can define the ordering for their members by supplying a L{members_order} attribute, which should contain a series of object or string references to members defined by the schema. Members not in that list will be appended at the end, sorted by name. Inherited members will be prepended, in the order defined by their parent schema. The L{Member.before_member} and L{after_member} attributes can also be used to alter the position of the member they are defined in. Alternatively, schema subclasses can override this method to allow for more involved sorting logic. @param recursive: Indicates if the returned list should contain members inherited from base schemas (True) or if only members directly defined by the schema should be included. @type recursive: bool @return: The list of members in the schema, in order. @rtype: L{Member<member.Member>} list """ ordered = [] relative = list() schemas = self.descend_inheritance(True) if recursive else (self, ) for schema in schemas: if schema.__members: remaining = set(schema.__members.values()) if schema.members_order: for member in schema.members_order: if isinstance(member, str): member = schema[member] if not (member.before_member or member.after_member): remaining.remove(member) ordered.append(member) for member in list(remaining): if member.before_member or member.after_member: relative.append(member) remaining.remove(member) ordered.extend(sorted(remaining, key=lambda m: m.name)) if relative: def insert_relative(member, visited): # Disallow conflicting positions if member.before_member and member.after_member: raise ValueError( "Can't decide the proper order for %s, it defines " "both 'before_member' and 'after_member'" % member) # Prevent cycles if member not in visited: visited.add(member) else: raise ValueError( "Cycle detected in the relative position for %s" % member) # Locate the 'anchor' member pos = -1 anchor = member.before_member or member.after_member if isinstance(anchor, str): if recursive: anchor = self.get_member(anchor) else: anchor = self.__members.get(anchor) if anchor: # If the anchor member is also relatively positioned, # fix down its position (this works recursively) if (anchor.before_member or anchor.after_member) \ and anchor in relative: relative.remove(anchor) pos = insert_relative(anchor, visited) else: pos = ordered.index(anchor) # Insert the member if pos == -1: pos = len(ordered) elif member.after_member: pos += 1 ordered.insert(pos, member) return pos while relative: insert_relative(relative.pop(0), set()) return ordered def ordered_groups(self, recursive=True): """Gets a list containing all the member groups defined by the schema, in order. @param recursive: Indicates if the returned list should contain groups defined by base schemas. @type recursive: bool @return: The list of groups defined by the schema, in order. @rtype: str list """ ordered_groups = [] visited = set() def collect(schema): if schema.groups_order: for group in schema.groups_order: if group not in visited: visited.add(group) ordered_groups.append(group) for member in schema.ordered_members(recursive=False): group = member.member_group if group and group not in visited: visited.add(group) ordered_groups.append(group) if recursive: if schema.__bases: for base in schema.__bases: collect(base) collect(self) return ordered_groups def grouped_members(self, recursive=True): """Returns the groups of members defined by the schema. @param recursive: Indicates if the returned list should contain members inherited from base schemas (True) or if only members directly defined by the schema should be included. @type recursive: bool @return: A list of groups in the schema and their members, in order. Each group is represented with a tuple containing its name and the list of its members. @rtype: list(tuple(str, L{Member<cocktail.schema.member.Member>} sequence)) """ members_by_group = {} for member in self.ordered_members(recursive): group_members = members_by_group.get(member.member_group) if group_members is None: group_members = [] members_by_group[member.member_group] = group_members group_members.append(member) groups = [] for group_name in self.ordered_groups(recursive): group_members = members_by_group.get(group_name) if group_members: groups.append((group_name, group_members)) ungroupped_members = members_by_group.get(None) if ungroupped_members: groups.insert(0, (None, ungroupped_members)) return groups def translate_group(self, group, suffix=None): if group: suffix_str = ".groups." + group + (suffix or "") else: suffix_str = ".generic_group" + (suffix or "") def get_label(schema): if schema.name: label = translations( getattr(schema, "full_name", schema.name) + suffix_str) if label: return label for alias in schema.schema_aliases: label = translations(alias + suffix_str) if label: return label for base in schema.bases: label = get_label(base) if label: return label label = get_label(self) if label: return label source_schema = self.source_member if source_schema and hasattr(source_schema, "translate_group"): return source_schema.translate_group(group, suffix=suffix) return None def insert_group(self, group, before=None, after=None): if before is None and after is None: raise ValueError( "insert_group() requires a value for either the 'after' or " "'before' parameters") elif before is not None and after is not None: raise ValueError( "insert_group() can't take both 'after' and 'before' " "parameters at the same time") anchor = before or after if not isinstance(self.groups_order, list): self.groups_order = list(self.groups_order) try: pos = self.groups_order.index(anchor) except ValueError: self.groups_order.append(group) else: if after: pos += 1 self.groups_order.insert(pos, group) def extract_searchable_text(self, extractor): obj = extractor.current.value for member in self.iter_members(): if member.text_search: if member.language_dependant: for language in extractor.iter_node_languages(): if language is not None: value = get(obj, member, language=language) extractor.extract(member, value, language) else: value = get(obj, member) extractor.extract(member, value) def to_json_value(self, value, **options): languages = options.get("languages", None) if languages is None: languages = get_language() if value is None: return None record = {} accessor = get_accessor(value) for member in self.iter_members(): if member.translated and not isinstance(languages, str): member_value = dict( (lang, member.to_json_value(accessor.get(value, language=lang), **options)) for lang in accessor.languages(value, member.name)) else: member_value = member.to_json_value( accessor.get(value, member.name), **options) record[member.name] = member_value return record def from_json_value(self, value, **options): if value is None: return None record = (self.type or dict)() accessor = get_accessor(record) for key, member_value in value.items(): member = self.get_member(key) if member is None: continue if member.translated and isinstance(member_value, Mapping): if member_value: for lang, lang_value in member_value.items(): accessor.set(record, member.name, member.from_json_value( lang_value, **options), language=lang) else: accessor.set(record, member.name, member.from_json_value(member_value, **options)) return record
class SchemaObject(object, metaclass=SchemaClass): _generates_translation_schema = False _translation_schema_base = None bidirectional = True declared = Event(doc=""" An event triggered on the class after the declaration of its schema has finished. """) instantiated = Event(doc=""" An event triggered on the class when a new instance is created. @ivar instance: A reference to the new instance. @type instance: L{SchemaObject} @ivar values: The parameters passed to the constructor. @type values: dict """) changing = Event(doc=""" An event triggered before the value of a member is changed. @ivar member: The member that is being changed. @type member: L{Member<cocktail.schema.member.Member>} @ivar language: Only for translated members, indicates the language affected by the change. @type language: str @ivar value: The new value assigned to the member. Modifying this attribute *will* change the value which is finally assigned to the member. Will be None for collections. @ivar previous_value: The current value for the affected member, before any change takes place. Will be None for collections. @ivar translation_changes: A dictionary indicating the translations that will be affected by this change, and their respective values before the change takes effect. Will be None if the changed member is not translatable. @ivar added: Only for collections. A collection of all the items added to the collection. @ivar removed: Only for collections. A collection of all the items removed from the collection. @ivar change_context: A dictionary containing arbitrary key / value pairs. It is shared with the L{changed} event, which makes it possible to pass information between both events; the typical use case for this involves storing object state during the L{changing} event so it can be read by L{changed} event handlers. """) changed = Event(doc=""" An event triggered after the value of a member has changed. @ivar member: The member that has been changed. @type member: L{Member<cocktail.schema.member.Member>} @ivar language: Only for translated members, indicates the language affected by the change. @type language: str @ivar value: The new value assigned to the member. Will be None for collections. @ivar previous_value: The value that the member had before the current change. Will be None for collections. @ivar added: Only for collections. A collection of all the items added to the collection. @ivar removed: Only for collections. A collection of all the items removed from the collection. @ivar translation_changes: A dictionary indicating the translations that will be affected by this change, and their respective values before the change took effect. Will be None if the changed member is not translatable. @ivar change_context: A dictionary containing arbitrary key / value pairs. It is shared with the L{changing} event, which makes it possible to pass information between both events; the typical use case for this involves storing object state during the L{changing} event so it can be read by L{changed} event handlers. """) collection_item_added = Event(doc=""" An event triggered after an item is added to one of the object's collections. @ivar member: The collection that the item has been added to. @type member: L{Collection<cocktail.schema.schemacollections.Collection>} @ivar item: The item added to the collection. @type item: obj """) collection_item_removed = Event(doc=""" An event triggered after an item is removed from one of the object's collections. @ivar member: The collection that the item has been removed from. @type member: L{Collection<cocktail.schema.schemacollections.Collection>} @ivar item: The item removed from the collection. @type item: obj """) related = Event(doc=""" An event triggered after establishing a relationship between objects. If both ends of the relation are instances of L{SchemaObject}, this event will be triggered twice. @ivar member: The schema member that describes the relationship. @type member: L{Relation<cocktail.schema.schemarelations>} @ivar related_object: The object that has been related to the target. """) unrelated = Event(doc=""" An event triggered after clearing a relationship between objects. @ivar member: The schema member that describes the relationship. @type member: L{Relation<cocktail.schema.schemarelations>} @ivar related_object: The object that is no longer related to the target. """) adding_translation = Event(doc=""" An event triggered when a new translation is being added. @ivar language: The language of the added translation @type language: str @ivar translation: The new value assigned to the translation. """) removing_translation = Event(doc=""" An event triggered when a translation is being removed. @ivar language: The language of the removed translation @type language: str @ivar translation: The value of the translation. """) def __init__(self, **values): # If supplied, the "bidirectional" attribute must be set before # setting any other attribute, otherwise bidirectional relations may # still be set! bidirectional = values.pop("bidirectional", None) if bidirectional is not None: self.bidirectional = bidirectional self.__class__.init_instance( self, values, SchemaObjectAccessor, excluded_members={self.__class__.translations} if self.__class__.translated else None) self.__class__.instantiated(instance=self, values=values) def __repr__(self): label = self.__class__.__name__ primary_member = self.__class__.primary_member if primary_member: id = getattr(self, primary_member.name, None) if id is not None: label += " #%s" % id try: trans = translations(self, discard_generic_translation=True) if trans: label += " (%s)" % trans except NoActiveLanguageError: pass return label def get(self, member, language=None): # Normalize the member argument to a member reference if not isinstance(member, Member): if isinstance(member, str): member = self.__class__[member] else: raise TypeError("Expected a string or a member reference") getter = member.schema.__dict__[member.name].__get__ return getter(self, None, language) def set(self, member, value, language=None): # Normalize the member argument to a member reference if not isinstance(member, Member): if isinstance(member, str): member = self.__class__[member] else: raise TypeError("Expected a string or a member reference") setter = member.schema.__dict__[member.name].__set__ setter(self, value, language) def new_translation(self, language): return self.translation(translated_object=self, language=language) def iter_derived_translations(self, language=None, include_self=False): language = require_language(language) for derived_lang in descend_language_tree(language, include_self=include_self): for chain_lang in iter_language_chain(derived_lang): if chain_lang == language: yield derived_lang break elif chain_lang in self.translations: break def iter_translations(self, include_derived=True, languages=None): for language in self.translations: if languages is not None and language not in languages: continue yield language if include_derived: for derived_language in descend_language_tree( language, include_self=False): if derived_language not in self.translations: yield derived_language def consolidate_translations(self, root_language=None, members=None): redundant_languages = [] # If no language is given, apply the process to all the root languages # defined by the translated object if root_language is None: processed_root_languages = set() for language in list(self.translations): root_language = get_root_language(language) if root_language not in processed_root_languages: processed_root_languages.add(root_language) redundant_languages += \ self.consolidate_translations(root_language) return redundant_languages # Find all present translations for languages derived from the given # root language affected_langs = [ language for language in descend_language_tree(root_language, include_self=False) if language in self.translations ] if not affected_langs: return redundant_languages # Find translatable members if members is None: members = [ member for member in self.__class__.iter_members() if member.translated ] def get_translated_values(language): values = [] for member in members: if member.translated: value = self.get(member, language) values.append((member, value)) return tuple(values) # Translation for the root language present; use it if root_language in self.translations: common_values = get_translated_values(root_language) # Otherwise, find the most common translation and use it as the root # translation else: count = Counter( get_translated_values(language) for language in affected_langs) common_values = count.most_common()[0][0] for member, value in common_values: self.set(member, value, root_language) # Delete redundant translations for language in affected_langs: if get_translated_values(language) == common_values: del self.translations[language] redundant_languages.append(language) return redundant_languages def get_source_locale(self, locale): translations = self.translations for locale in iter_language_chain(locale): if locale in translations: return locale def create_text_extractor(self, **kwargs): extractor = TextExtractor(**kwargs) extractor.extract(self.__class__, self) return extractor def get_searchable_text(self, languages=None, **kwargs): return str(self.create_text_extractor(languages=languages, **kwargs)) copy_excluded_members = frozenset() def create_copy(self, member_copy_modes=None): clone = self.__class__() self.init_copy(clone, member_copy_modes=member_copy_modes) return clone def init_copy(self, copy, member_copy_modes=None): for member in self.__class__.members().values(): copy_mode = self.get_member_copy_mode( member, member_copy_modes=member_copy_modes) if copy_mode == DO_NOT_COPY: continue if not member.translated: languages = (None, ) else: languages = list(self.translations) for language in languages: value = self.copy_value(member, language, mode=copy_mode, receiver=copy, member_copy_modes=member_copy_modes) copy.set(member, value, language) def get_member_copy_mode(self, member, member_copy_modes=None): # Explicit mode set by an override if member_copy_modes: copy_mode = member_copy_modes.get(member) if copy_mode is not None: return copy_mode # Explicit mode set by the member if member.copy_mode is not None: return member.copy_mode # Implicit mode, based on member properties if (member.name == "translations" or member.primary or member in self.copy_excluded_members): return DO_NOT_COPY if isinstance(member, (Reference, Collection)): if member.anonymous: return DO_NOT_COPY if member.integral: return DEEP_COPY return SHALLOW_COPY def copy_value(self, member, language, mode=SHALLOW_COPY, receiver=None, member_copy_modes=None): value = self.get(member, language) if value is not None and mode != SHALLOW_COPY: if isinstance(member, Reference): if mode == DEEP_COPY \ or (callable(mode) and mode(self, member, value)): value = value.create_copy( member_copy_modes=None if member.related_end is None else {member.related_end: DO_NOT_COPY}) else: items = [] for item in value: if item is not None and (mode == DEEP_COPY or (callable(mode) and mode(self, member, item))): item = item.create_copy( member_copy_modes=None if member.related_end is None else {member.related_end: DO_NOT_COPY}) items.append(item) value = items return value @classmethod def observe_relation_changes(cls, relation_path, members=None): def decorator(change_handler): # Observe relate / unrelate operations at any point of the relation # chain. The RelationObserver class is used to propagate them # upwards towards the root of the chain and finally trigger the # decorated callback. model = cls current_path = [] for rel_name in relation_path.split("."): relation = model.get_member(rel_name) if relation is None: raise ValueError( "Can't observe the relation path %s: %s doesn't contain a " "%s relation" % (relation_path, model, rel_name)) current_path.append(relation) observer = RelationObserver(list(current_path), change_handler) model.related.append(observer) model.unrelated.append(observer) model = relation.related_type # Observe changes in certain fields of the objects at the end of the # relation chain (optional). if members: # Normalize the subset of members to observe (if any) member_subset = set() for member in members: if isinstance(member, str): member_name = member member = model.get_member(member_name) else: member_name = member.name if member is None or not issubclass(model, member.schema): raise ValueError( "Error while setting up a relation observer " "for %s: can't find member '%s' in %s" % (relation_path, member_name, model)) member_subset.add(member) # Observe changes to the member subset observer = RelationObserver(current_path, change_handler, member_subset) model.changed.append(observer) return change_handler return decorator def get_translated_value(self, member, language=None, **kwargs): if isinstance(member, str): member = self.__class__.get_member(member) value = self.get(member, language) return member.translate_value(value, **kwargs) def get_translated_values(self, member, languages=None): return TranslatedValues( (language, self.get(member, language)) for language in (languages or self.translations))
class ExportJob: export = None exporter = None dependencies = None reset = False errors = "resume" # "resume" or "raise" encoding = "utf-8" selecting_export_urls = Event() export_starting = Event() export_failed = Event() export_completed = Event() export_ended = Event() task_starting = Event() task_executed = Event() task_successful = Event() task_failed = Event() dependency_transfers_starting = Event() dependency_transfer_starting = Event() dependency_transfer_successful = Event() dependency_transfer_failed = Event() css_url_regexp = re.compile(r"""url\(([^)]+)\)""") js_string_regexp = re.compile( r""" (?P<delim>['"]) (?P<value>.*) \1 # Same string delimiter used in the opening (?!\\) # Not preceded by a escape character """, re.VERBOSE) js_url_regexp = re.compile( r""" (?P<head> (\s+src\s*=|\s+href\s*=|\Wurl\() \s* \\? ['"]? ) (?P<url>.*?) (?P<tail>['")\\]) """, re.VERBOSE) def __init__(self, export): self.export = export self.exporter = self.create_exporter() self.document_urls = set() self.dependencies = set() self.pending_dependencies = set() self.__url_resolutions = {} def create_exporter(self, **kwargs) -> Exporter: return self.export.destination.create_exporter(**kwargs) def get_export_urls(self, item: PublishableObject, language: str) -> Sequence[URL]: e = self.selecting_export_urls( item=item, language=language, urls=[item.get_uri(host="!", language=language)]) return e.urls def get_source_url(self, item: PublishableObject, language: str, path: Sequence[str] = None, parameters: Dict[str, str] = None) -> URL: return item.get_uri(language=language, path=path, parameters=parameters, host="!") def execute(self): @transaction def begin(): self.export.state = "running" if self.reset: for task in self.export.tasks.itervalues(): task["state"] = "pending" with self.exporter: self.export_starting() try: for task in self.export.tasks.itervalues(): # Ignore completed / failed tasks if task["state"] != "pending": continue # Give other scripts a chance to abort the export operation datastore.sync() if self.export.state != "running": raise Halt() self.execute_task(task) if self.dependencies: self.export_dependencies() except Halt: pass except Exception as error: @transaction def complete(): self.export.state = "idle" self.export_failed(error=error) if self.errors == "raise": raise else: @transaction def complete(): self.export.state = "completed" self.export_completed() finally: self.export_ended() self.exporter.close() def execute_task(self, task: dict): self.task_starting(task=task) action = task["action"] item = task["item"] language = task["language"] try: tags = set() for source_url in self.get_export_urls(item, language): if action == "post": resource = ExportedResource(self, source_url) resource.language = language resource.open(**self.get_request_parameters(resource)) url_tags = resource.headers.get("X-Woost-Cache-Tags") if url_tags: tags.update(url_tags.split()) self.process_resource(resource) # Prevent documents from also being downloaded as # dependencies self.document_urls.add(resource.source_url) self.dependencies.discard(resource.source_url) self.pending_dependencies.discard(resource.source_url) self.exporter.write_file( resource.export_path, resource.content, content_type=resource.content_type) elif action == "delete": export_path = \ self.export.destination.get_export_path(source_url) self.exporter.remove_file(export_path) self.task_executed(task=task) except Exception as export_error: if self.errors == "raise": raise else: export_error = None @transaction def update_task(): if export_error: task["state"] = "failed" task["error_message"] = repr(export_error) else: task["state"] = "success" publishable = task["item"] self.export.destination.set_pending_task( publishable, language, None) self.export.destination.set_exported_content_tags( publishable, language, tags) if export_error: self.task_failed(task=task, error=export_error) else: self.task_successful(task=task) def get_request_parameters(self, resource: 'ExportedResource') -> Dict[str, str]: headers = { "User-agent": USER_AGENT, EXPORT_HEADER: str(self.export.id), } if self.export.auth_token: headers[app.authentication.AUTH_TOKEN_HEADER] = \ self.export.auth_token return {"headers": headers} def process_resource(self, resource: 'ExportedResource'): if resource.content_type == "text/html": document = BeautifulSoup(resource.content, features="lxml") self.process_html(document, resource) resource.content = str(document) elif resource.content_type == "text/css": css = resource.content.decode(self.encoding) css = self.process_css(css, resource) resource.content = css.encode(self.encoding) def process_html(self, document: BeautifulSoup, resource: 'ExportedResource'): # Process embedded styles for element in document.find_all("style"): content_type = element.get("type") if ((not content_type or content_type == "text/css") and element.string): element.string = self.process_css(element.string, resource) # Process embdded scripts for element in document.find_all("script"): content_type = element.get("type") if ((not content_type or content_type == "text/javascript") and element.string): element.string = self.process_embedded_javascript( element.string, resource) # Transform URLs for element, attr, url, content_type \ in self.iter_urls_in_html(document, resource): self.process_html_url(element, attr, url, content_type, resource) def iter_urls_in_html( self, document: BeautifulSoup, resource: 'ExportedResource') -> Iterable[ResourceWithinDocument]: for link in document.find_all("link"): href = link.get("href") if href: ctype = link.get("type") if not ctype: rel = link.get("rel") if rel and str(rel).lower() == "stylesheet": ctype = "text/css" yield link, "href", URL(href), ctype for script in document.find_all("script"): src = script.get("src") if src: yield (script, "src", URL(src), script.get("type") or "application/javascript") for img in document.find_all("img"): src = img.get("src") if src: yield img, "src", URL(src), None for video in document.find_all("video"): src = video.get("src") if src: yield video, "src", URL(src), video.get("type") for audio in document.find_all("audio"): src = audio.get("src") if src: yield audio, "src", URL(src), audio.get("type") for source in document.find_all("source"): src = source.get("src") if src: yield source, "src", URL(src), source.get("type") for a in document.find_all("a"): href = a.get("href") if href and not href.startswith("#"): yield a, "href", URL(href), a.get("type") for iframe in document.find_all("iframe"): src = iframe.get("src") if src: yield iframe, "src", URL(src), None def process_html_url(self, element: Tag, attr: str, url: URL, content_type: str, resource: 'ExportedResource'): if url.scheme in ("javascript", "mailto"): return url url = self.normalize_href(url, resource) # Collect dependencies self.add_dependency(url, content_type=content_type) # Transform the resource URL into a relative path url = self.transform_href(url, resource, content_type=content_type) element[attr] = url def process_css(self, content: str, resource: 'ExportedResource') -> str: def replace_url(match): value = match.group(1).strip("'").strip('"') if value.startswith("javascript:"): return value url = URL(value) url = self.normalize_href(url, resource) self.add_dependency(url, content_type="text/css") url = self.transform_href(url, resource, content_type="text/css") return f"url('{url}')" return self.css_url_regexp.sub(replace_url, content) def process_embedded_javascript(self, content: str, resource: 'ExportedResource') -> str: def process_strings(match): c = match.group("delim") return (c + self.js_url_regexp.sub(replace_url, match.group("value")) + c) def replace_url(match): value = match.group("url") if value.startswith("javascript:"): return match.group(0) url = URL(value) url = self.normalize_href(url, resource) self.add_dependency(url, content_type="application/javascript") url = self.transform_href(url, resource, content_type="application/javascript") return match.group("head") + url + match.group("tail") return self.js_string_regexp.sub(process_strings, content) def get_base_url(self, url: URL) -> URL: if len(url.path) > 1: return url.copy(path=url.path.pop(-1)) else: return url def normalize_href(self, url: URL, resource: 'ExportedResource') -> URL: # Normalize relative URLs using the source URL for the processed # document if not url.hostname: url = resource.base_url.copy(path=resource.base_url.path.merge( url.path), query=url.query, fragment=url.fragment) # Normalize URLs to their canonical form if not self.url_is_external(url): resolution = self.resolve_url(url) if resolution and resolution.publishable: url = app.url_mapping.get_canonical_url( url, language=resource.language, preserve_extra_path=True) return url def transform_href(self, url: URL, resource: "ExportedResource", content_type: str = None) -> URL: if self.url_is_external(url): return url else: if self.should_make_url_absolute(url, resource): return self.export.destination.get_export_url( url, content_type=content_type) else: return self.get_relative_url(url, resource, content_type=content_type) def should_make_url_absolute(self, url: URL, resource: 'ExportedResource') -> bool: return False def get_relative_url(self, url: URL, resource: "ExportedResource", content_type: str = None) -> URL: # Express URLs as paths relative to the exported document url_export_path = self.export.destination.get_export_path( url, content_type=content_type) i = 0 for a, b in zip_longest(resource.export_folder, url_export_path): if a != b: break i += 1 url_export_path = url_export_path[i:] for n in range(len(resource.export_folder) - i): url_export_path.insert(0, u"..") return URL(path=url_export_path, query=url.query, fragment=url.fragment) def url_is_external(self, url: URL) -> bool: if not url.hostname: return False config = Configuration.instance return config.get_website_by_host(url.hostname) is None def url_is_exportable_dependency(self, url: URL, content_type: str = None) -> bool: # Ignore external URLs if self.url_is_external(url): return False # Ignore publishable elements resolution = self.resolve_url(url) return ( not resolution or not resolution.publishable or (content_type or resolution.publishable.mime_type) != "text/html") def resolve_url(self, url: URL) -> URLResolution: try: return self.__url_resolutions[url] except KeyError: resolution = app.url_mapping.resolve(url) self.__url_resolutions[url] = resolution return resolution def add_dependency(self, url: URL, content_type: str = None): if url not in self.dependencies and url not in self.document_urls: if self.url_is_exportable_dependency(url, content_type): self.dependencies.add(url) self.pending_dependencies.add(url) def export_dependencies(self): if self.dependencies: self.dependency_transfers_starting() while self.pending_dependencies: source_url = self.pending_dependencies.pop() resource = ExportedResource(self, source_url) self.dependency_transfer_starting(resource=resource) try: resource.open(**self.get_request_parameters(resource)) self.process_resource(resource) self.exporter.write_file(resource.export_path, resource.content, content_type=resource.content_type) except Exception as export_error: self.dependency_transfer_failed(resource=resource, error=export_error) if self.errors == "raise": raise else: self.dependency_transfer_successful(resource=resource)
class Destination(Item): type_group = "staticpub" export_file_extension = ".html" export_job_class = ExportJob exporter_class = None instantiable = False state_ui_component = ( "woost.extensions.staticpub.admin.ui." "PublicationState" ) resolving_export_path = Event() members_order = [ "title", "url", "website_prefixes", "exports" ] title = schema.String( required=True, unique=True, indexed=True, translated=True, descriptive=True ) url = schema.URL() website_prefixes = schema.Mapping( keys=schema.Reference( type=Website, ui_form_control="cocktail.ui.DropdownSelector" ), values=schema.String() ) exports = schema.Collection( items="woost.extensions.staticpub.export.Export", bidirectional=True, integral=True, editable=schema.NOT_EDITABLE ) def __init__(self, *args, **kwargs): Item.__init__(self, *args, **kwargs) self._pending_tasks = IOBTree() self._entries_by_tag = OOBTree() self._entry_tags = OOBTree() def create_exporter(self, **kwargs): if self.exporter_class is None: raise ValueError(f"No exporter class defined for {self}") return self.exporter_class(**kwargs) def get_export_url( self, url: URL, resolution: URLResolution = None, content_type: str = None) -> URL: root_url = URL(self.url) return root_url.copy( path=root_url.path.append( self.get_export_path( url, resolution=resolution, content_type=content_type ) ) ) def get_export_path( self, url: Union[URL, str], resolution: URLResolution = None, content_type: str = None, add_file_extension: bool = True) -> Sequence[str]: url = URL(url) if resolution is None: resolution = app.url_mapping.resolve(url) export_path = [] # Add per-website prefixes if self.website_prefixes: website = ( resolution and resolution.publishable and resolution.publishable.websites and len(resolution.publishable.websites) == 1 and iter(resolution.publishable.websites).next() ) if website: prefix = self.website_prefixes.get(website) if prefix: export_path.extend(prefix.split("/")) # Path export_path.extend(url.path.segments) # Query string if url.query: export_path.append(url.query.replace("=", "-").replace("&", ".")) # File extension if ( add_file_extension and url.path.segments and "." not in url.path.segments[-1] ): ext = self.get_export_file_extension( url, content_type ) if ext: export_path[-1] += ext # Customization e = self.resolving_export_path( url=url, resolution=resolution, export_path=export_path ) return e.export_path def get_export_file_extension( self, url: URL, content_type: str = None) -> str: if content_type == "text/html" or not content_type: return self.export_file_extension return guess_extension(content_type) def iter_pending_tasks(self, publishable=None, languages=None): if publishable: pub_tasks = self._pending_tasks.get(publishable.id) if pub_tasks: for lang, action in pub_tasks.iteritems(): if languages is None or lang in languages: yield action, publishable.id, lang else: for pub_id, pub_tasks in self._pending_tasks.iteritems(): for lang, action in pub_tasks.iteritems(): if languages is None or lang in languages: yield action, pub_id, lang def has_pending_tasks(self, publishable=None, languages=None): for task in self.iter_pending_tasks(publishable, languages): return True else: return False def clear_pending_tasks(self, publishable=None, languages=None): if publishable: if languages: pub_tasks = self._pending_tasks.get(publishable.id) for lang in languages: try: del pub_tasks[language] except KeyError: pass if not pub_tasks: del self._pending_tasks[publishable.id] else: del self._pending_tasks[publishable.id] else: for pub_id, pub_tasks in list(self._pending_tasks.iteritems()): if languages: pub_tasks = self._pending_tasks.get(pub_id) for lang in languages: try: del pub_tasks[language] except KeyError: pass if not pub_tasks: del self._pending_tasks[pub_id] else: del self._pending_tasks[pub_id] def get_pending_task(self, publishable, language): pub_tasks = self._pending_tasks.get(publishable.id) if pub_tasks is not None: return pub_tasks.get(language) return None def set_pending_task(self, publishable, language, task): if task is None: pub_tasks = self._pending_tasks.get(publishable.id) if pub_tasks is not None: try: del pub_tasks[language] except KeyError: pass else: if not pub_tasks: del self._pending_tasks[publishable.id] else: pub_tasks = self._require_pub_tasks(publishable.id) pub_tasks[language] = task def _require_pub_tasks(self, publishable_id): pub_tasks = self._pending_tasks.get(publishable_id) if pub_tasks is None: pub_tasks = OOBucket() self._pending_tasks[publishable_id] = pub_tasks return pub_tasks def set_exported_content_tags(self, item, language, tags): entry = (item.id, language) prev_tags = self._entry_tags.get(entry) self._entry_tags[entry] = tags if prev_tags: for tag in prev_tags: tag_entries = self._entries_by_tag.get(tag) if tag_entries: tag_entries.remove(entry) for tag in tags: tag_entries = self._entries_by_tag.get(tag) if tag_entries is None: tag_entries = OOTreeSet() self._entries_by_tag[tag] = tag_entries tag_entries.insert(entry) def invalidate_exported_content( self, item, language = None, cache_part = None ): scope = normalize_scope( item.get_cache_invalidation_scope( language=language, cache_part=cache_part ) ) self._invalidate_exported_scope(scope) def _invalidate_exported_scope(self, scope, task="mod"): # Invalidate everything if scope is whole_cache: for publishable, language in iter_all_exportable_content(): pub_tasks = self._require_pub_tasks(publishable.id) pub_tasks.setdefault(language, task) # Invalidate a single tag elif isinstance(scope, basestring): for publishable_id, language in self._entries_by_tag.get(scope, ()): pub_tasks = self._require_pub_tasks(publishable_id) pub_tasks.setdefault(language, task) # Invalidate an intersection of tags elif isinstance(scope, tuple): matching_entries = None for tag in scope: tagged_entries = self._entries_by_tag.get(tag, ()) if matching_entries is None: matching_entries = set(tagged_entries) else: matching_entries.intersection_update(tagged_entries) if not matching_entries: break for publishable_id, language in matching_entries: pub_tasks = self._require_pub_tasks(publishable_id) pub_tasks.setdefault(language, task) # Invalidate a collection of scopes elif isinstance(scope, Iterable): for subscope in scope: self._invalidate_exported_scope(subscope) # Invalid scope else: raise TypeError( f"Invalid scope ({scope}). " "Expected whole_cache, a string, a tuple of strings or a " "collection of any of those elements." )