Source code for univention.management.console.module

# -*- coding: utf-8 -*-
#
# Univention Management Console
#  next generation of UMC modules
#
# Copyright 2011-2022 Univention GmbH
#
# https://www.univention.de/
#
# All rights reserved.
#
# The source code of this program is made available
# under the terms of the GNU Affero General Public License version 3
# (GNU AGPL V3) as published by the Free Software Foundation.
#
# Binary versions of this program provided by Univention to you as
# well as other copyrighted, protected or trademarked materials like
# Logos, graphics, fonts, specific documentations and configurations,
# cryptographic keys etc. are subject to a license agreement between
# you and Univention and not subject to the GNU AGPL V3.
#
# In the case you use this program under the terms of the GNU AGPL V3,
# the program is provided in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public
# License with the Debian GNU/Linux or Univention distribution in file
# /usr/share/common-licenses/AGPL-3; if not, see
# <https://www.gnu.org/licenses/>.

"""
.. _sec-module-definitions:

Module definitions
==================

The UMC server does not load the python modules to get the details about
the modules name, description and functionality. Therefore each UMC
module must provide an XML file containing this kind of information.

The following example defines a module with the id `udm`:

.. code-block:: xml

	<?xml version="1.0" encoding="UTF-8"?>
	<umc version="2.0">
		<module id="udm" icon="udm-module" version="1.0" python="3">
			<name>Univention Directory Manager</name>
			<description>Manages all UDM modules</description>
			<flavor icon="udm-users" id="users/user">
				<name>Users</name>
				<description>Managing users</description>
			</flavor>
			<categories>
				<category name="domain" />
			</categories>
			<requiredCommands>
				<requiredCommand name="udm/query" />
			</requiredCommands>
			<command name="udm/query" function="query" />
			<command name="udm/containers" function="containers" />
		</module>
	</umc>

The *module* tag defines the basic details of a UMC module

id
	This identifier must be unique among the modules of an UMC server. Other
	files may extend the definition of a module by adding more flavors
	or categories.

icon
	The value of this attribute defines an identifier for the icon that
	should be used for the module. Details for installing icons can be
	found in the section :ref:`chapter-packaging`

The child elements *name* and *description* define the English human
readable name and description of the module. For other translations the
build tools will create translation files. Details can be found in the
section :ref:`chapter-packaging`.

This example defines a so called flavor. A flavor defines a new name,
description and icon for the same UMC module. This can be used to show
several"virtual" modules in the overview of the web frontend. Additionally the flavor is passed to the UMC server with each request i.e. the UMC module has the possibility to act differently for a specific flavor.

As the next element *categories* is defined in the example. The child
elements *category* set the categories wthin the overview where the
module should be shown. Each module can be more than one category. The
attribute name is to identify the category internally. The UMC server
brings a set of pre-defined categories:

favorites
	This category is intended to be filled by the user herself.

system
	Tools manipulating the system itself (e.g. software installation)
	should go in here.

At the end of the definition file a list of commands is specified. The
UMC server only passes commands to a UMC module that are defined. A
command definition has two attributes:

name
	is the name of the command that is passed to the UMC module. Within
	the UMCP message it is the first argument after the UMCP COMMAND.

function
	defines the method to be invoked within the python module when the
	command is called.

keywords
	defined keywords for the module to ensure searchability

The translations are stored in extra po files that are generated by the
UMC build tools.
"""

import copy
import os
import sys
import re
import xml.parsers.expat
import xml.etree.cElementTree as ET

from .tools import JSON_Object, JSON_List
from .log import RESOURCES
from .config import ucr

KEYWORD_PATTERN = re.compile(r'\s*,\s*')


[docs]class Command(JSON_Object): '''Represents a UMCP command handled by a module''' SEPARATOR = '/' def __init__(self, name='', method=None, allow_anonymous=False): self.name = name if method: self.method = method else: self.method = self.name.replace(Command.SEPARATOR, '_') self.allow_anonymous = allow_anonymous
[docs] def fromJSON(self, json): for attr in ('name', 'method'): setattr(self, attr, json[attr]) setattr(self, 'allow_anonymous', json.get('allow_anonymous', False))
[docs]class Flavor(JSON_Object): '''Defines a flavor of a module. This provides another name and icon in the overview and may influence the behavior of the module.''' def __init__(self, id='', icon='', name='', description='', overwrites=None, deactivated=False, priority=-1, translationId=None, keywords=None, categories=None, required_commands=None, version=None, hidden=False): self.id = id self.name = name self.description = description self.icon = icon self.overwrites = overwrites or [] self.keywords = keywords or [] self.deactivated = deactivated self.priority = priority self.translationId = translationId self.categories = categories or [] self.required_commands = required_commands or [] self.version = version self.hidden = hidden
[docs] def merge(self, other): self.id = self.id or other.id self.version = self.version or other.version self.name = self.name or other.name self.description = self.description or other.description self.icon = self.icon or other.icon self.overwrites = list(set(self.overwrites + other.overwrites)) self.keywords = list(set(self.keywords + other.keywords)) self.deactivated = self.deactivated or other.deactivated self.priority = self.priority or other.priority self.translationId = self.translationId or other.translationId self.categories = list(set(self.categories + other.categories)) self.required_commands = list(set(self.required_commands + other.required_commands)) self.hidden = self.hidden or other.hidden
def __repr__(self): return '<%s %r>' % (type(self).__name__, self.json())
[docs]class Module(JSON_Object): '''Represents a command attribute''' def __init__(self, id='', name='', url='', description='', icon='', categories=None, flavors=None, commands=None, priority=-1, keywords=None, translationId=None, required_commands=None, version=None): self.id = id self.name = name self.url = url self.description = description self.keywords = keywords or [] self.icon = icon self.priority = priority self.flavors = JSON_List() self.translationId = translationId self.required_commands = required_commands or [] self.version = version if flavors is not None: self.append_flavors(flavors) if categories is None: self.categories = JSON_List() else: self.categories = categories if commands is None: self.commands = JSON_List() else: self.commands = commands
[docs] def fromJSON(self, json): if isinstance(json, dict): for attr in ('id', 'name', 'description', 'icon', 'categories', 'keywords'): setattr(self, attr, json[attr]) commands = json['commands'] else: commands = json for cmd in commands: command = Command() command.fromJSON(cmd) self.commands.append(command)
[docs] def append_flavors(self, flavors): for flavor in flavors: # remove duplicated flavors if flavor.id not in [iflavor.id for iflavor in self.flavors] or flavor.deactivated: self.flavors.append(flavor) else: RESOURCES.warn('Duplicated flavor for module %s: %s' % (self.id, flavor.id))
[docs] def merge_flavors(self, other_flavors): for other_flavor in other_flavors: try: # merge other_flavor into self_flavor self_flavor = [iflavor for iflavor in self.flavors if iflavor.id == other_flavor.id][0] self_flavor.merge(other_flavor) except IndexError: # add if other_flavor does not exist RESOURCES.info('Add flavor: %s' % other_flavor.name) self.flavors.append(other_flavor)
[docs] def merge(self, other): ''' merge another Module object into current one ''' if not self.name: self.name = other.name if not self.icon: self.icon = other.icon if not self.description: self.description = other.description self.version = self.version or other.version self.keywords = list(set(self.keywords + other.keywords)) self.merge_flavors(other.flavors) self.categories = JSON_List(set(self.categories + other.categories)) self.commands = JSON_List(set(self.commands + other.commands)) self.required_commands = JSON_List(set(self.required_commands + other.required_commands))
def __repr__(self): return '<%s %r>' % (type(self).__name__, self.json())
[docs]class XML_Definition(ET.ElementTree): '''container for the interface description of a module''' def __init__(self, root=None, filename=None): ET.ElementTree.__init__(self, element=root, file=filename) self.root = self.getroot() @property def name(self): return self.findtext('name') @property def version(self): return self.root.findtext('version') @property def url(self): return self.findtext('url') @property def description(self): return self.findtext('description') @property def keywords(self): return KEYWORD_PATTERN.split(self.findtext('keywords', '')) + [self.name] @property def id(self): return self.root.get('id') @property def priority(self): try: return float(self.root.get('priority', -1)) except ValueError: RESOURCES.warn('No valid number type for property "priority": %s' % self.root.get('priority')) return None @property def translationId(self): return self.root.get('translationId', '') @property def notifier(self): return self.root.get('notifier') @property def python_version(self): try: return int(float(self.root.get('python', 2))) except ValueError: return 2 @property def icon(self): return self.root.get('icon') @property def deactivated(self): return self.root.get('deactivated', 'no').lower() in ('yes', 'true', '1') @property def flavors(self): '''Retrieve list of flavor objects''' for elem in self.findall('flavor'): name = elem.findtext('name') priority = None try: priority = float(elem.get('priority', -1)) except ValueError: RESOURCES.warn('No valid number type for property "priority": %s' % elem.get('priority')) categories = [cat.get('name') for cat in elem.findall('categories/category')] # a empty <categories/> causes the module to be hidden! while a not existing <category> element causes that the categories from the module are used hidden = elem.find('categories') is not None and not categories yield Flavor( id=elem.get('id'), icon=elem.get('icon'), name=name, overwrites=elem.get('overwrites', '').split(','), deactivated=(elem.get('deactivated', 'no').lower() in ('yes', 'true', '1')), translationId=self.translationId, description=elem.findtext('description'), keywords=re.split(KEYWORD_PATTERN, elem.findtext('keywords', '')) + [name], priority=priority, categories=categories, required_commands=[cmd.get('name') for cmd in elem.findall('requiredCommands/requiredCommand')], version=self.version, hidden=hidden, ) @property def categories(self): return [elem.get('name') for elem in self.findall('categories/category')]
[docs] def commands(self): '''Generator to iterate over the commands''' for command in self.findall('command'): yield command.get('name')
[docs] def get_module(self): cls = { 'link': Link, 'module': Module }.get(self.root.tag, Module) return cls( self.id, self.name, self.url, self.description, self.icon, self.categories, self.flavors, priority=self.priority, keywords=self.keywords, translationId=self.translationId, required_commands=[cat.get('name') for cat in self.findall('requiredCommands/requiredCommand')], version=self.version, )
[docs] def get_flavor(self, name): '''Retrieves details of a flavor''' for flavor in self.flavors: if flavor.name == name: return flavor
[docs] def get_command(self, name): '''Retrieves details of a command''' for command in self.findall('command'): if command.get('name') == name: return Command(name, command.get('function'), command.get('allow_anonymous', '0').lower() in ('yes', 'true', '1'))
def __bool__(self): module = self.find('module') return module is not None and len(module) != 0 __nonzero__ = __bool__ def __repr__(self): return '<XML_Definition %s (%r)>' % (self.id, self.name)
_manager = None
[docs]class Manager(dict): '''Manager of all available modules''' DIRECTORY = os.path.join(sys.prefix, 'share/univention-management-console/modules') def __init__(self): dict.__init__(self)
[docs] def modules(self): '''Returns list of module names''' return list(self.keys())
[docs] def load(self): '''Loads the list of available modules. As the list is cleared before, the method can also be used for reloading''' RESOURCES.info('Loading modules ...') self.clear() for filename in os.listdir(Manager.DIRECTORY): if not filename.endswith('.xml'): continue try: parsed_xml = ET.parse(os.path.join(Manager.DIRECTORY, filename)) RESOURCES.info('Loaded module %s' % filename) for mod_tree in parsed_xml.getroot(): mod = XML_Definition(root=mod_tree) if mod.deactivated: RESOURCES.info('Module is deactivated: %s' % filename) continue # save list of definitions in self self.setdefault(mod.id, []).append(mod) except (xml.parsers.expat.ExpatError, ET.ParseError) as exc: RESOURCES.warn('Failed to load module %s: %s' % (filename, exc)) continue
[docs] def is_command_allowed(self, acls, command, hostname=None, options={}, flavor=None): for module_xmls in self.values(): for module_xml in module_xmls: cmd = module_xml.get_command(command) if cmd and cmd.allow_anonymous: return True return acls.is_command_allowed(command, hostname, options, flavor)
[docs] def permitted_commands(self, hostname, acls): '''Retrieves a list of all modules and commands available according to the ACLs (instance of LDAP_ACLs) { id : Module, ... } ''' RESOURCES.info('Retrieving list of permitted commands') modules = {} for module_id in self: # get first Module and merge all subsequent Module objects into it mod = None for module_xml in self[module_id]: nextmod = module_xml.get_module() if mod: mod.merge(nextmod) else: mod = nextmod if ucr.is_true('umc/module/%s/disabled' % (module_id)): RESOURCES.info('module %s is deactivated by UCR' % (module_id)) continue if isinstance(mod, Link): if mod.url: modules[module_id] = mod else: RESOURCES.info('invalid link %s: no url element' % (module_id)) continue if not mod.flavors: flavors = [Flavor(id=None, required_commands=mod.required_commands)] else: flavors = copy.copy(mod.flavors) deactivated_flavors = set() for flavor in flavors: if ucr.is_true('umc/module/%s/%s/disabled' % (module_id, flavor.id)): RESOURCES.info('flavor %s (module=%s) is deactivated by UCR' % (flavor.id, module_id)) # flavor is deactivated by UCR variable flavor.deactivated = True RESOURCES.info('mod=%r flavor=%r deactivated=%r hidden=%r' % (module_id, flavor.id, flavor.deactivated, flavor.hidden)) if flavor.deactivated: deactivated_flavors.add(flavor.id) continue required_commands = [module_xml.get_command(command) for module_xml in self[module_id] for command in module_xml.commands() if command in flavor.required_commands] apply_function = all if not required_commands: # backwards compatibility. if any of the commands defined in the module is allowed this module is visible apply_function = any required_commands = [module_xml.get_command(command) for module_xml in self[module_id] for command in module_xml.commands()] if apply_function(cmd.allow_anonymous or acls.is_command_allowed(cmd.name, hostname, flavor=flavor.id) for cmd in required_commands): modules.setdefault(module_id, mod) all_commands = set(module_xml.get_command(command) for module_xml in self[module_id] for command in module_xml.commands()) modules[module_id].commands = JSON_List(set(modules[module_id].commands) | all_commands) elif mod.flavors: # if there is not one command allowed with this flavor # it should not be shown in the overview mod.flavors.remove(flavor) mod.flavors = JSON_List(f for f in mod.flavors if f.id not in deactivated_flavors) overwrites = set() for flavor in mod.flavors: overwrites.update(flavor.overwrites) mod.flavors = JSON_List(f for f in mod.flavors if f.id not in overwrites) return modules
[docs] def module_providing(self, modules, command): '''Searches a dictionary of modules (as returned by permitted_commands) for the given command. If found, the id of the module is returned, otherwise None''' RESOURCES.info('Searching for module providing command %s' % command) for module_id in modules: for cmd in modules[module_id].commands: if cmd.name == command: RESOURCES.info('Found module %s' % module_id) return module_id RESOURCES.info('No module provides %s' % command) return None
if __name__ == '__main__': mgr = Manager()