Virtual environment (venv) — an isolated Python environment that allows installing plugin dependencies without conflicting with system packages.
Action — a platform function that is called when a user interacts with the interface or during an internal event.
Module — an external executable file that implements the integration of the platform with an external system. The module is called with command-line arguments and exchanges data through standard input-output streams.
Plugin — a mechanism for extending the platform's functionality without changing its source code. The plugin registers event and function handlers via an XML file.
Before reading this article, it is recommended to review the developer documentation for COREmanager, because part of the mechanisms is inherited from the base framework.
Plugins allow you to change BILLmanager platform behavior, intercept events, extend the interface, and add new functions. The article describes how plugins work, their structure, and gives an example of creating a validation plugin. For plugin development details, see the article Creating modules.
BILLmanager modifications
BILLmanager allows you to add the following modifications:
modules — large and self-contained system components that perform a specific function or set of functions. For example, they extend functionality through integration with a third-party system. Modules:
usually have their own dependencies and can include several plugins;
are not intended to dynamically change system behavior in real time;
may require changes to the core system architecture;
by functionality are divided into:
service handlers;
payment methods;
message gateways;
online cash registers;
document workflow modules;
modules for coordination with third-party systems — LDAP, Omni, amoCRM, etc.
plugins — usually simpler software components that add specific functionality or change the behavior of the main system. Plugins allow you to:
integrate new features without changing the source code;
intercept internal events;
add new interface elements;
run background tasks through triggers on specific events;
adapt the system to specific needs.
See the comparison of plugins and modules in the article Background plugins.
Types of plugins
Plugins are divided by functionality into:
plugins that change platform behavior;
plugins that extend platform functionality;
plugins that run background tasks.
Plugins that change behavior let you intercept internal events and modify them. For example, add extra fields to the client card and fill them with the required values. Or add new logic for existing actions. See more in the COREmanager documentation article Changing system behavior through event handlers.
Plugins that extend functionality add:
new elements — configurable tables and forms needed to solve specific tasks;
newe actions and platform workflows.
Plugins that run background tasks:
add triggers for specific events in the platform;
allow heavy tasks to run in the background, so they do not affect working with the platform in the interface. For example, running background tasks based on events.
Handler file name in the /usr/local/mgr5/addon/ directory. The name must match the one specified in the handler node.
type
How BILLmanager interacts with the script: cgi or xml:
type="cgi" — BILLmanager builds environment variables with request information and calls the script. The script returns an XML file in stdout. BILLmanager merges the received XML file with the main one;
type="xml" — BILLmanager builds environment variables and passes the main XML file to the script's stdin. The script modifies the XML file and returns it to stdout. BILLmanager replaces the main XML file with the one received from the script.
protocol
Script call protocol::
fcgi — call through FastCGI;
not specified — regular call.
ignore_errors
If yes is specified, handler errors do not affect platform operation.
In the BILLmanager platform, any interaction (for example, a user click, an internal platform call, or a request from an external service) invokes the Action function (action). Events are bound to the Action and are called before, after, or in both cases (both before and after), depending on the settings.
When registering a plugin, one of the following nodes is used inside handler:
func — registers an Action: the script becomes the handler for the function;
event — registers an Event: the script is called when the event occurs;
task — registers an Event: the script is called regardless of the action that triggered it.
To get the list of Action, use the command:
/usr/local/mgr5/sbin/mgrctl -m billmgr actionlist
To get the list of Event for a specific Action, use the command:
Specify yes to pass the POST request body to the script's stdin. Relevant only for POST requests. For example, when saving a form sok=ok. For GET requests, the body is always empty.
Typical tasks:
add a plugin settings page to the admin interface;
display a list of objects managed by the plugin;
add an action button to an existing service list;
validate a form field when ordering (check.<name>).
event — Event, action interceptor
The plugin subscribes to a BILLmanager Action and is called automatically — before or after it is executed.
Specify yes to call the script before the main action is executed.
after
Specify yes to call the script after the main action is executed.
proirity
Defines the call order among other handlers for the same event: before — before the base handler, after — after it. If base is not specified: before — to the front of the queue, after — to the end.
Note: the attribute is spelled proirity, not priority — this is a COREmanager feature kept for compatibility.
base
Name of the base handler whose order is determined by proirity. project — subscribe to the BILLmanager Action, not COREmanager.
postdata
Specify yes to pass the POST request body to the script's stdin. Relevant only for POST requests. For example, when saving a form sok=ok. For GET requests, the body is always empty.
To block the standard action, the script with before="yes" must return <skipaction/> in the response:
<doc>
<skipaction/>
</doc>
If <skipaction/> is not returned, the platform will perform the standard action in the usual way. Handlers with after="yes" are not called if the standard action was blocked through <skipaction/>.
Typical tasks:
perform an action in an external system when a service is opened, closed, or changed;
synchronize data between BILLmanager and an external service;
block the standard platform action and replace it with your own.
task — Event, independent of the triggering action
The plugin subscribes to an Action, but is called independently of it. The main execution is not blocked.
The XML file registers the plugin in the platform and describes interface elements. It is placed in the /usr/local/mgr5/etc/xml/. The file name pattern is billmgr_mod_<name>.xml.
The file contains the root node <mgrdata> with child nodes:
Registers the plugin in the platform. The name attribute is the unique plugin name.
handler
Registers event and function handlers. See more in the section Plugin tasks.
metadata
Describes the interface element: form (type="form"), list (type="list"), or report (type="report"). The name attribute is the name of the function described by the element.
mainmenu
Adds items to the platform main menu. The level attribute is the access level.
lang
Describes localization. The name attribute is the language code (ru, en).
metadata
The metadata node describes forms, lists, and reports. Attributes:
All authenticated users. Access cannot be restricted through permission policies — the function is available even if the policies use the value "Everything is forbidden".
registered
from 2
All authenticated users.
nobody
= 0
Only unauthenticated users.
user
= 16
Only regular users.
reseller
= 24
Only resellers.
admin
= 29
Only platform administrators.
super
= 30
Only server administrators.
Modifiers:
+ after the name — the specified level and higher. admin+ grants access to platform administrators (29) and server administrators (30);
- after the name — the specified level and lower. reseller- grants access to resellers (24) and everyone with a lower level;
multiple values separated by commas are combined: admin,super+ is equivalent to admin+.
For administrative plugin forms, level="admin+" is recommended.
The values all, registered, and public already mean "this level and higher". The + modifier for them is redundant. For the other values, without a modifier, access is limited to the specified level.
Example of a validation plugin
This section provides an example of creating a plugin that checks whether the domain name entered by the user when ordering a service is valid.
Task statement
When ordering a service, the user enters a domain name in the plugin field. You need to validate the entered value before the order is placed. The standard validator domain.check is not suitable, because it works only with standard product types.
Solution
BILLmanager calls the validation plugin when checking a field with the check="checker" attribute and expects either a normalized value or an error in response.
The file registers the plugin and declares localization for validation errors. It is placed in /usr/local/mgr5/etc/xml/.
The validator error keys use the desc_ format — without the standard msg_error_ prefix (msg_error_ is used for pop-up errors).
Validators are registered automatically based on the naming pattern check.*. Therefore, you need to register a new Action (func) with a name that starts with check.. For example, check.checker.
XML file example
<mgrdata>
<plugin name="checker">
<group>plugin</group>
<author>BILLmanager team</author>
</plugin>
<handler name="checker.py" type="cgi">
<func name="check.checker"/>
</handler>
<lang name="ru">
<messages name="msgerror">
<msg name="desc_too_short">Value is too short (minimum 3 characters).</msg>
<msg name="desc_has_spaces">Value must not contain spaces.</msg>
<msg name="desc_empty_domain">Domain name cannot be empty.</msg>
<msg name="desc_domain_too_short">Domain name is too short (minimum 2 characters).</msg>
<msg name="desc_domain_too_long">Domain name is too long (maximum 63 characters per label).</msg>
<msg name="desc_invalid_hyphen_position">Hyphen cannot be at the beginning or end of a domain label.</msg>
<msg name="desc_invalid_dot_position">Dot cannot be at the beginning or end of domain name.</msg>
<msg name="desc_invalid_domain_format">Invalid domain name format. Example: example.com</msg>
<msg name="desc_reserved_domain">This domain name is reserved and cannot be used.</msg>
<msg name="desc_too_many_labels">Too many levels in domain name (maximum 127).</msg>
<msg name="desc_invalid_chars">Domain name contains invalid characters. Allowed: letters, numbers, hyphen and dot.</msg>
<msg name="desc_tld_too_short">TLD (last part of the domain) must contain at least 2 letters.</msg>
<msg name="desc_invalid_tld">TLD can contain only letters.</msg>
</messages>
</lang>
<lang name="en">
<messages name="msgerror">
<msg name="desc_too_short">Value is too short (minimum 3 characters).</msg>
<msg name="desc_has_spaces">Value must not contain spaces.</msg>
<msg name="desc_empty_domain">Domain name cannot be empty.</msg>
<msg name="desc_domain_too_short">Domain name is too short (minimum 2 characters).</msg>
<msg name="desc_domain_too_long">Domain name is too long (maximum 63 characters per label).</msg>
<msg name="desc_invalid_hyphen_position">Hyphen cannot be at the beginning or end of a domain label.</msg>
<msg name="desc_invalid_dot_position">Dot cannot be at the beginning or end of domain name.</msg>
<msg name="desc_invalid_domain_format">Invalid domain name format. Example: example.com</msg>
<msg name="desc_reserved_domain">This domain name is reserved and cannot be used.</msg>
<msg name="desc_too_many_labels">Too many levels in domain name (maximum 127).</msg>
<msg name="desc_invalid_chars">Domain name contains invalid characters. Allowed: letters, numbers, hyphen and dot.</msg>
<msg name="desc_tld_too_short">TLD must contain at least 2 letters.</msg>
<msg name="desc_invalid_tld">TLD can contain only letters.</msg>
</messages>
</lang>
</mgrdata>
Script
When developing in Python, we recommend running all code in a virtual environment. Examples are shown in the code below and in the Makefile section — these are different ways to provide isolation in a virtual environment.
The code below is an example solution to the task, not a ready-made solution.
The BILLmanager SDK developer kit does not include ready-made abstractions for plugins, but based on the information in the Plugin structure section, you can write the following code:
Code example
Code example
#!/usr/bin/env python3
import sys
import os
import xml.etree.ElementTree as ET
from abc import abstractmethod
from typing import Callable, Dict
# Check that the plugin is running inside venv.
# If not, restart the plugin through the venv interpreter.
VENV_PYTHON = "/usr/local/mgr5/src/checker/venv-checker/bin/python3"
if sys.executable != VENV_PYTHON and os.path.exists(VENV_PYTHON):
os.execv(VENV_PYTHON, [VENV_PYTHON] + sys.argv)
sys.path.insert(0, "/usr/local/mgr5/lib/python")
import billmgr.logger as logging
import billmgr.session as session
import billmgr.exception as bill_exc
_logger = logging.get_logger("checker")
# ---------------------------------------------------------------------------
# ValueException
# ---------------------------------------------------------------------------
class ValueException(bill_exc.XmlException):
"""Validation error for a form field. Used inside CheckerPlugin.check().
Equivalent to C++ mgr_err::Value(object, value, check, args).
BILLmanager will display a localized message with the key desc_{check}.
Localization is declared in the plugin XML file.
Args:
check: validator name.
args: validator parameters.
Example:
raise ValueException(check="too_short")
raise ValueException(check="invalid_format", args=r"\\d+")
"""
def __init__(self, err_object="", err_value="", check: str = None, args: str = None):
super().__init__("value", err_object, err_value)
self.messages = {}
self.params = {}
self.check = check
if self.err_object:
self.add_message("object", self.err_object)
if self.err_value:
self.add_param("value", self.err_value)
if args:
self.add_param("args", args)
if check:
self.add_message("desc", "desc_" + check)
else:
self.add_message("desc", "desk_empty")
def add_message(self, name: str, value: str):
self.messages[name] = value
def as_xml(self):
doc = ET.Element("doc")
error = ET.SubElement(doc, "error")
error.set("type", self.err_type)
error.set("object", self.err_object)
for param in self.params:
param_node = ET.SubElement(error, "param")
param_node.text = self.params[param]
param_node.set("name", param)
for msg in self.messages:
msg_node = ET.SubElement(error, "param")
msg_node.text = self.messages[msg]
msg_node.set("name", msg)
msg_node.set("type", "msg")
return ET.tostring(doc, encoding="unicode")
# ---------------------------------------------------------------------------
# Plugin
# ---------------------------------------------------------------------------
class Plugin:
"""Universal base class for BILLmanager plugins.
One handler in BILLmanager passes events (event), functions (func), and tasks(task)
in the same way — through the query parameter "func". Therefore, Plugin handles
all cases through a single handle() mechanism.
The descendant registers handlers in __init__ through handle():
Handler signature: (func: str, xml: ET.Element) -> str | None
func: current function/event name (from get_query_param("func"))
xml: incoming XML request body
return: XML string for the response, or None — then <doc/> is returned
"""
#: Plugin name — used to initialize the logger.
name: str = ""
def __init__(self) -> None:
if not self.name:
raise ValueError(
f"{self.__class__.__name__} must define class attribute 'name'"
)
logging.init_logging(self.name)
self._logger = logging.get_logger(self.name)
self._handlers: Dict[str, Callable] = {}
def handle(self, func: str, handler: Callable) -> None:
"""Register a handler.
Args:
func: BILLmanager function name.
handler: callable with signature (func: str, xml: ET.Element) -> str | None.
"""
self._handlers[func] = handler
def _dispatch(self, xml: ET.Element) -> str:
func = session.get_query_param("func", "")
self._logger.info("func=%r", func)
handler = self._handlers.get(func)
if handler is None:
self._logger.debug("no handler for %r, skipping", func)
return '\n<doc/>'
result = handler(func, xml)
return result if result is not None else '\n<doc/>'
def run(self) -> None:
"""Entry point. Called in if __name__ == '__main__'."""
try:
xml = session.get_input_xml(True)
session.debug_session(xml)
_logger.debug(f"environ: {session.envs()}")
_logger.debug(f"input xml: {ET.tostring(xml, encoding='unicode')}")
res = self._dispatch(xml)
self._logger.debug(f"ans={res}")
print(res)
except bill_exc.XmlException as err:
bill_exc.log_backtrace()
print(err.as_xml())
except Exception as err:
bill_exc.log_backtrace()
print(bill_exc.XmlException("unknown", "what", str(err)).as_xml())
# ---------------------------------------------------------------------------
# CheckerPlugin
# ---------------------------------------------------------------------------
class CheckerPlugin(Plugin):
"""Base class for checker plugins. Inherits Plugin.
Called by BILLmanager when validating a form field with the check="<name>" attribute.
Automatically registers the check.{name} handler and wraps the call to check().
XML registration:
<handler name="checker.py" type="xml">
<func name="check.checker"/>
</handler>
The descendant must define name and implement check():
class MyChecker(CheckerPlugin):
name = "checker"
def check(self, value: str, args: str) -> str:
if len(value) < 3:
raise ValueException(check="too_short")
return value.strip()
if __name__ == "__main__":
MyChecker().run()
"""
def __init__(self) -> None:
super().__init__()
# Register the check.{name} handler through the parent handle()
self.handle(f"check.{self.name}", self._check_handler)
@abstractmethod
def check(self, object:str, value: str, args: str) -> str:
"""Validation logic. Override in the descendant.
Args:
value: user-entered value (already normalized by the strip() method).
args: optional argument from the form field attribute.
Returns:
Normalized value is written back to the field.
Raises:
ValueException if the value does not pass validation.
"""
raise NotImplementedError
def _check_handler(self, func: str, xml: ET.Element) -> str:
"""Internal handler reads parameters and calls check()."""
raw_value = session.get_query_param("value", "")
field_name = session.get_query_param("name", "value")
args = session.get_query_param("args", "")
self._logger.info(
"check.%s called, field=%r value=%r", self.name, field_name, raw_value
)
value = raw_value.strip()
if not value:
doc = ET.Element("doc")
ET.SubElement(doc, "value")
return ET.tostring(doc, encoding="unicode")
try:
normalized = self.check(field_name, value, args)
except ValueException as err:
self._logger.info(
"check.%s failed: desc_%s field=%r value=%r",
self.name, err.check, field_name, value,
)
return err.as_xml()
doc = ET.Element("doc")
ET.SubElement(doc, "value").text = normalized
return ET.tostring(doc, encoding="unicode")
import re
class MyChecker(CheckerPlugin):
name = "checker"
def check(self, object: str, value: str, args: str) -> str:
"""
Domain name validation.
Args:
object: field name. For example, "domain".
value: value to validate
args: validation parameters. You can pass requirements, for example "min_length=2,max_length=63".
"""
# Extract arguments if they are provided.
min_length = 2
max_length = 63
allow_unicode = False
if args:
for arg in args.split(','):
if '=' in arg:
key, val = arg.split('=')
if key == 'min_length':
min_length = int(val)
elif key == 'max_length':
max_length = int(val)
elif key == 'allow_unicode':
allow_unicode = val.lower() == 'true'
# Check that the value is not empty.
if not value:
raise ValueException(check="empty_domain", err_object=object)
# Check that the domain name length meets the requirements.
if len(value) < min_length:
raise ValueException(check="domain_too_short", err_object=object)
if len(value) > max_length:
raise ValueException(check="domain_too_long", err_object=object)
# Check for spaces.
if ' ' in value:
raise ValueException(check="has_spaces", err_object=object)
# Check for invalid characters at the beginning or end.
if value.startswith('-') or value.endswith('-'):
raise ValueException(check="invalid_hyphen_position", err_object=object)
if value.startswith('.') or value.endswith('.'):
raise ValueException(check="invalid_dot_position", err_object=object)
# Check the domain format.
if not self._validate_domain_format(value, allow_unicode):
raise ValueException(check="invalid_domain_format", err_object=object)
# Perform an additional check for reserved names:
reserved = [
'localhost', 'local', 'example', 'invalid', 'test',
'xn--', # punycode prefix is not allowed
]
domain_lower = value.lower()
for reserved_name in reserved:
if domain_lower == reserved_name:
raise ValueException(check="reserved_domain", err_object=object)
# Check the number of dots. Maximum 127 for a full domain.
if value.count('.') > 127:
raise ValueException(check="too_many_labels", err_object=object)
return value.strip()
def _validate_domain_format(self, domain: str, allow_unicode: bool = False) -> bool:
"""
Check the domain name format.
"""
if allow_unicode:
# For Unicode domains (IDNA)
# Check that the domain name can be converted to ASCII
try:
domain.encode('idna').decode('ascii')
except (UnicodeError, UnicodeEncodeError):
return False
# Strict ASCII domain validation.
# Labels: letters, numbers, hyphen (not at the beginning or end).
# Label length: 1-63 characters.
# TLD: only Latin letters, minimum 2 characters.
pattern = r'^(?!(?:[0-9]+\.)+[0-9]+$)(?!-)[A-Za-z0-9-]{1,63}(?<!-)\.(?:[A-Za-z]{2,}|[A-Za-z0-9-]{1,63}(?<!-))$'
# Check a full domain name with multiple subdomains.
full_pattern = r'^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$'
if not allow_unicode:
# Check allowed characters for ASCII domains.
allowed_chars = set('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-')
if not all(c in allowed_chars for c in domain):
return False
# Check with a regular expression.
if not re.match(full_pattern, domain):
# Check each label separately:
labels = domain.split('.')
for i, label in enumerate(labels):
# Reject empty labels.
if not label:
return False
# Check label length.
if len(label) > 63 or len(label) < 1:
return False
# Check that the TLD (last label) contains only letters and is at least 2 characters long.
if i == len(labels) - 1:
if not re.match(r'^[A-Za-z]{2,}$', label):
return False
else:
# Check that subdomain names start and end with a letter or number.
if not re.match(r'^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$', label):
return False
return True
if __name__ == "__main__":
MyChecker().run()
Makefile
Makefile is responsible for copying the plugin files to the required BILLmanager directories. It is called by install.sh after the dependencies are installed.
isp.mk is the standard ISPsystem build rule set. It defines the DISTDIR variable, the install target, and other helper targets. It is included at the end through include.
The XML file and images are uploaded automatically if the required structure is followed:
XML file into the xml folder at the Makefile level;
images — into dist/skins/common/img/*.
After that, images can be used, for example, in such forms when the image name is sad.png: