diff --git a/docs/configuration.rst b/docs/configuration.rst index e8fb141c..09db608b 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -118,7 +118,7 @@ Web server settings. If any of ``host``/``port`` is not set, web integration wil * ``password`` - password to authorize in web service in order to update service status, string, required in case if authorization enabled. * ``port`` - port to bind, int, optional. * ``static_path`` - path to directory with static files, string, required. -* ``templates`` - path to templates directory, string, required. +* ``templates`` - path to templates directories, space separated list of strings, required. * ``timeout`` - HTTP request timeout in seconds, int, optional, default is ``30``. * ``unix_socket`` - path to the listening unix socket, string, optional. If set, server will create the socket on the specified address which can (and will) be used by application. Note, that unlike usual host/port configuration, unix socket allows to perform requests without authorization. * ``unix_socket_unsafe`` - set unsafe (o+w) permissions to unix socket, boolean, optional, default ``yes``. This option is enabled by default, because it is supposed that unix socket is created in safe environment (only web service is supposed to be used in unsafe), but it can be disabled by configuration. @@ -231,7 +231,6 @@ Section name must be either ``console`` (plus optional architecture name, e.g. ` Section name must be either ``email`` (plus optional architecture name, e.g. ``email:x86_64``) or random name with ``type`` set. * ``type`` - type of the report, string, optional, must be set to ``email`` if exists. -* ``full_template_path`` - path to Jinja2 template for full package description index, string, optional. * ``homepage`` - link to homepage, string, optional. * ``host`` - SMTP host for sending emails, string, required. * ``link_path`` - prefix for HTML links, string, required. @@ -241,7 +240,9 @@ Section name must be either ``email`` (plus optional architecture name, e.g. ``e * ``receivers`` - SMTP receiver addresses, space separated list of strings, required. * ``sender`` - SMTP sender address, string, required. * ``ssl`` - SSL mode for SMTP connection, one of ``ssl``, ``starttls``, ``disabled``, optional, default ``disabled``. -* ``template_path`` - path to Jinja2 template, string, required. +* ``template`` - Jinja2 template name, string, required. +* ``template_full`` - Jinja2 template name for full package description index, string, optional. +* ``templates`` - path to templates directories, space separated list of strings, required. * ``user`` - SMTP user to authenticate, string, optional. ``html`` type @@ -253,7 +254,8 @@ Section name must be either ``html`` (plus optional architecture name, e.g. ``ht * ``homepage`` - link to homepage, string, optional. * ``link_path`` - prefix for HTML links, string, required. * ``path`` - path to html report file, string, required. -* ``template_path`` - path to Jinja2 template, string, required. +* ``template`` - Jinja2 template name, string, required. +* ``templates`` - path to templates directories, space separated list of strings, required. ``remote-call`` type ^^^^^^^^^^^^^^^^^^^^ @@ -276,8 +278,9 @@ Section name must be either ``telegram`` (plus optional architecture name, e.g. * ``chat_id`` - telegram chat id, either string with ``@`` or integer value, required. * ``homepage`` - link to homepage, string, optional. * ``link_path`` - prefix for HTML links, string, required. -* ``template_path`` - path to Jinja2 template, string, required. +* ``template`` - Jinja2 template name, string, required. * ``template_type`` - ``parse_mode`` to be passed to telegram API, one of ``MarkdownV2``, ``HTML``, ``Markdown``, string, optional, default ``HTML``. +* ``templates`` - path to templates directories, space separated list of strings, required. * ``timeout`` - HTTP request timeout in seconds, int, optional, default is ``30``. ``upload`` group diff --git a/docs/faq.rst b/docs/faq.rst index 43dc424e..578808be 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -1299,7 +1299,9 @@ The application uses java concept to log messages, e.g. class ``Application`` im Html customization ^^^^^^^^^^^^^^^^^^ -It is possible to customize html templates. In order to do so, create files somewhere (refer to Jinja2 documentation and the service source code for available parameters) and put ``template_path`` to configuration pointing to this directory. +It is possible to customize html templates. In order to do so, create files somewhere (refer to Jinja2 documentation and the service source code for available parameters) and prepend ``templates`` with value pointing to this directory. + +In addition, default html templates supports style customization out-of-box. In order to customize style, just put file named ``user-style.jinja2`` to the templates directory. I did not find my question ^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/package/share/ahriman/settings/ahriman.ini b/package/share/ahriman/settings/ahriman.ini index 440660eb..56b31ef9 100644 --- a/package/share/ahriman/settings/ahriman.ini +++ b/package/share/ahriman/settings/ahriman.ini @@ -54,14 +54,17 @@ use_utf = yes [email] no_empty_report = yes -template_path = /usr/share/ahriman/templates/email-index.jinja2 +template = email-index.jinja2 +templates = /usr/share/ahriman/templates ssl = disabled [html] -template_path = /usr/share/ahriman/templates/repo-index.jinja2 +template = repo-index.jinja2 +templates = /usr/share/ahriman/templates [telegram] -template_path = /usr/share/ahriman/templates/telegram-index.jinja2 +template = telegram-index.jinja2 +templates = /usr/share/ahriman/templates [upload] target = diff --git a/package/share/ahriman/templates/build-status.jinja2 b/package/share/ahriman/templates/build-status.jinja2 index 0b0dac54..880db5d9 100644 --- a/package/share/ahriman/templates/build-status.jinja2 +++ b/package/share/ahriman/templates/build-status.jinja2 @@ -8,6 +8,7 @@ {% include "utils/style.jinja2" %} + {% include "user-style.jinja2" ignore missing %} diff --git a/package/share/ahriman/templates/email-index.jinja2 b/package/share/ahriman/templates/email-index.jinja2 index 1fc99d32..bf91f2d0 100644 --- a/package/share/ahriman/templates/email-index.jinja2 +++ b/package/share/ahriman/templates/email-index.jinja2 @@ -7,6 +7,7 @@ {% include "utils/style.jinja2" %} + {% include "user-style.jinja2" ignore missing %} diff --git a/package/share/ahriman/templates/error.jinja2 b/package/share/ahriman/templates/error.jinja2 index 805690f1..f18f5257 100644 --- a/package/share/ahriman/templates/error.jinja2 +++ b/package/share/ahriman/templates/error.jinja2 @@ -8,6 +8,7 @@ {% include "utils/style.jinja2" %} + {% include "user-style.jinja2" ignore missing %} diff --git a/package/share/ahriman/templates/repo-index.jinja2 b/package/share/ahriman/templates/repo-index.jinja2 index 4090a683..fa0b4478 100644 --- a/package/share/ahriman/templates/repo-index.jinja2 +++ b/package/share/ahriman/templates/repo-index.jinja2 @@ -6,6 +6,7 @@ {% include "utils/style.jinja2" %} + {% include "user-style.jinja2" ignore missing %} diff --git a/src/ahriman/core/configuration/configuration.py b/src/ahriman/core/configuration/configuration.py index 4089cb5b..07a52f53 100644 --- a/src/ahriman/core/configuration/configuration.py +++ b/src/ahriman/core/configuration/configuration.py @@ -82,6 +82,7 @@ class Configuration(configparser.RawConfigParser): converters={ "list": shlex.split, "path": self._convert_path, + "pathlist": lambda value: [self._convert_path(element) for element in shlex.split(value)], } ) @@ -241,6 +242,8 @@ class Configuration(configparser.RawConfigParser): def getpath(self, *args: Any, **kwargs: Any) -> Path: ... # type: ignore[empty-body] + def getpathlist(self, *args: Any, **kwargs: Any) -> list[Path]: ... # type: ignore[empty-body] + def gettype(self, section: str, repository_id: RepositoryId, *, fallback: str | None = None) -> tuple[str, str]: """ get type variable with fallback to old logic. Despite the fact that it has same semantics as other get* methods, diff --git a/src/ahriman/core/configuration/schema.py b/src/ahriman/core/configuration/schema.py index 5b10dee7..43e75e74 100644 --- a/src/ahriman/core/configuration/schema.py +++ b/src/ahriman/core/configuration/schema.py @@ -44,12 +44,14 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = { "coerce": "absolute_path", "required": True, "path_exists": True, + "path_type": "dir", }, "logging": { "type": "path", "coerce": "absolute_path", "required": True, "path_exists": True, + "path_type": "file", }, "suppress_http_log_errors": { "type": "boolean", @@ -68,12 +70,16 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = { "mirror": { "type": "string", "required": True, + "empty": False, "is_url": [], }, "repositories": { "type": "list", "coerce": "list", - "schema": {"type": "string"}, + "schema": { + "type": "string", + "empty": False, + }, "required": True, "empty": False, }, @@ -82,6 +88,7 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = { "coerce": "absolute_path", "required": True, "path_exists": True, + "path_type": "dir", }, "use_ahriman_cache": { "type": "boolean", @@ -113,9 +120,11 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = { }, "client_id": { "type": "string", + "empty": False, }, "client_secret": { "type": "string", + "empty": False, }, "cookie_secret_key": { "type": "string", @@ -129,9 +138,11 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = { }, "oauth_provider": { "type": "string", + "empty": False, }, "oauth_scopes": { "type": "string", + "empty": False, }, "salt": { "type": "string", @@ -144,36 +155,55 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = { "archbuild_flags": { "type": "list", "coerce": "list", - "schema": {"type": "string"}, + "schema": { + "type": "string", + "empty": False, + }, }, "build_command": { "type": "string", "required": True, + "empty": False, }, "ignore_packages": { "type": "list", "coerce": "list", - "schema": {"type": "string"}, + "schema": { + "type": "string", + "empty": False, + }, }, "makepkg_flags": { "type": "list", "coerce": "list", - "schema": {"type": "string"}, + "schema": { + "type": "string", + "empty": False, + }, }, "makechrootpkg_flags": { "type": "list", "coerce": "list", - "schema": {"type": "string"}, + "schema": { + "type": "string", + "empty": False, + }, }, "triggers": { "type": "list", "coerce": "list", - "schema": {"type": "string"}, + "schema": { + "type": "string", + "empty": False, + }, }, "triggers_known": { "type": "list", "coerce": "list", - "schema": {"type": "string"}, + "schema": { + "type": "string", + "empty": False, + }, }, "vcs_allowed_age": { "type": "integer", @@ -187,10 +217,14 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = { "schema": { "name": { "type": "string", + "empty": False, }, "root": { - "type": "string", + "type": "path", + "coerce": "absolute_path", "required": True, + "path_exists": True, + "path_type": "dir", }, }, }, @@ -208,6 +242,7 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = { }, "key": { "type": "string", + "empty": False, }, }, }, @@ -216,6 +251,7 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = { "schema": { "address": { "type": "string", + "empty": False, "is_url": ["http", "https"], }, "debug": { @@ -229,7 +265,10 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = { "debug_allowed_hosts": { "type": "list", "coerce": "list", - "schema": {"type": "string"}, + "schema": { + "type": "string", + "empty": False, + }, }, "enable_archive_upload": { "type": "boolean", @@ -237,10 +276,12 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = { }, "host": { "type": "string", + "empty": False, "is_ip_address": ["localhost"], }, "index_url": { "type": "string", + "empty": False, "is_url": ["http", "https"], }, "max_body_size": { @@ -250,6 +291,7 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = { }, "password": { "type": "string", + "empty": False, }, "port": { "type": "integer", @@ -262,12 +304,18 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = { "coerce": "absolute_path", "required": True, "path_exists": True, + "path_type": "dir", }, "templates": { - "type": "path", - "coerce": "absolute_path", - "required": True, - "path_exists": True, + "type": "list", + "coerce": "list", + "schema": { + "type": "path", + "coerce": "absolute_path", + "path_exists": True, + "path_type": "dir", + }, + "empty": False, }, "timeout": { "type": "integer", @@ -284,6 +332,7 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = { }, "username": { "type": "string", + "empty": False, }, "wait_timeout": { "type": "integer", diff --git a/src/ahriman/core/configuration/validator.py b/src/ahriman/core/configuration/validator.py index e7499bc3..c10cbfc7 100644 --- a/src/ahriman/core/configuration/validator.py +++ b/src/ahriman/core/configuration/validator.py @@ -162,3 +162,21 @@ class Validator(RootValidator): self._error(field, f"Path {value} must not exist") case False if constraint: self._error(field, f"Path {value} must exist") + + def _validate_path_type(self, constraint: str, field: str, value: Path) -> None: + """ + check if paths is file, directory or whatever. The match will be performed as call of ``is_{constraint}`` + method of the path object + + Args: + constraint(str): path type to be matched + field(str): field name to be checked + value(Path): value to be checked + + Examples: + The rule's arguments are validated against this schema: + {"type": "string"} + """ + fn = getattr(value, f"is_{constraint}") + if not fn(): + self._error(field, f"Path {value} must be type of {constraint}") diff --git a/src/ahriman/core/gitremote/remote_pull_trigger.py b/src/ahriman/core/gitremote/remote_pull_trigger.py index 4fef34d5..43eefa6c 100644 --- a/src/ahriman/core/gitremote/remote_pull_trigger.py +++ b/src/ahriman/core/gitremote/remote_pull_trigger.py @@ -38,7 +38,10 @@ class RemotePullTrigger(Trigger): "target": { "type": "list", "coerce": "list", - "schema": {"type": "string"}, + "schema": { + "type": "string", + "empty": False, + }, }, }, }, @@ -48,9 +51,11 @@ class RemotePullTrigger(Trigger): "pull_url": { "type": "string", "required": True, + "empty": False, }, "pull_branch": { "type": "string", + "empty": False, }, }, }, diff --git a/src/ahriman/core/gitremote/remote_push_trigger.py b/src/ahriman/core/gitremote/remote_push_trigger.py index a46713e6..feebf415 100644 --- a/src/ahriman/core/gitremote/remote_push_trigger.py +++ b/src/ahriman/core/gitremote/remote_push_trigger.py @@ -43,7 +43,10 @@ class RemotePushTrigger(Trigger): "target": { "type": "list", "coerce": "list", - "schema": {"type": "string"}, + "schema": { + "type": "string", + "empty": False, + }, }, }, }, @@ -52,16 +55,20 @@ class RemotePushTrigger(Trigger): "schema": { "commit_email": { "type": "string", + "empty": False, }, "commit_user": { "type": "string", + "empty": False, }, "push_url": { "type": "string", "required": True, + "empty": False, }, "push_branch": { "type": "string", + "empty": False, }, }, }, diff --git a/src/ahriman/core/report/email.py b/src/ahriman/core/report/email.py index ad5360a9..e42b827a 100644 --- a/src/ahriman/core/report/email.py +++ b/src/ahriman/core/report/email.py @@ -37,7 +37,6 @@ class Email(Report, JinjaTemplate): email report generator Attributes: - full_template_path(Path): path to template for full package list host(str): SMTP host to connect no_empty_report(bool): skip empty report generation password(str | None): password to authenticate via SMTP @@ -45,7 +44,8 @@ class Email(Report, JinjaTemplate): receivers(list[str]): list of receivers emails sender(str): sender email address ssl(SmtpSSLSettings): SSL mode for SMTP connection - template_path(Path): path to template for built packages + template(Path | str): path or name to template for built packages + template_full(Path | str | None): path or name to template for full package list user(str | None): username to authenticate via SMTP """ @@ -61,8 +61,10 @@ class Email(Report, JinjaTemplate): Report.__init__(self, repository_id, configuration) JinjaTemplate.__init__(self, repository_id, configuration, section) - self.full_template_path = configuration.getpath(section, "full_template_path", fallback=None) - self.template_path = configuration.getpath(section, "template_path") + self.template = configuration.get(section, "template", fallback=None) or \ + configuration.getpath(section, "template_path") + self.template_full = configuration.get(section, "template_full", fallback=None) or \ + configuration.getpath(section, "full_template_path", fallback=None) # base smtp settings self.host = configuration.get(section, "host") @@ -114,9 +116,10 @@ class Email(Report, JinjaTemplate): """ if self.no_empty_report and not result.success: return - text = self.make_html(result, self.template_path) - if self.full_template_path is not None: - attachments = {"index.html": self.make_html(Result(success=packages), self.full_template_path)} - else: - attachments = {} + + text = self.make_html(result, self.template) + attachments = {} + if self.template_full is not None: + attachments["index.html"] = self.make_html(Result(success=packages), self.template_full) + self._send(text, attachments) diff --git a/src/ahriman/core/report/html.py b/src/ahriman/core/report/html.py index 11d89a87..55deb739 100644 --- a/src/ahriman/core/report/html.py +++ b/src/ahriman/core/report/html.py @@ -31,7 +31,7 @@ class HTML(Report, JinjaTemplate): Attributes: report_path(Path): output path to html report - template_path(Path): path to template for full package list + template(Path | str): name or path to template for full package list """ def __init__(self, repository_id: RepositoryId, configuration: Configuration, section: str) -> None: @@ -47,7 +47,8 @@ class HTML(Report, JinjaTemplate): JinjaTemplate.__init__(self, repository_id, configuration, section) self.report_path = configuration.getpath(section, "path") - self.template_path = configuration.getpath(section, "template_path") + self.template = configuration.get(section, "template", fallback=None) or \ + configuration.getpath(section, "template_path") def generate(self, packages: list[Package], result: Result) -> None: """ @@ -57,5 +58,5 @@ class HTML(Report, JinjaTemplate): packages(list[Package]): list of packages to generate report result(Result): build result """ - html = self.make_html(Result(success=packages), self.template_path) + html = self.make_html(Result(success=packages), self.template) self.report_path.write_text(html, encoding="utf8") diff --git a/src/ahriman/core/report/jinja_template.py b/src/ahriman/core/report/jinja_template.py index 4826d29c..50b416b8 100644 --- a/src/ahriman/core/report/jinja_template.py +++ b/src/ahriman/core/report/jinja_template.py @@ -57,11 +57,12 @@ class JinjaTemplate: * repository - repository name, string, required Attributes: + default_pgp_key(str | None): default PGP key homepage(str | None): homepage link if any (for footer) link_path(str): prefix fo packages to download name(str): repository name - default_pgp_key(str | None): default PGP key sign_targets(set[SignSettings]): targets to sign enabled in configuration + templates(list[Path]): list of directories with templates """ def __init__(self, repository_id: RepositoryId, configuration: Configuration, section: str) -> None: @@ -73,26 +74,31 @@ class JinjaTemplate: configuration(Configuration): configuration instance section(str): settings section name """ - self.link_path = configuration.get(section, "link_path") + self.templates = configuration.getpathlist(section, "templates", fallback=[]) # base template vars self.homepage = configuration.get(section, "homepage", fallback=None) + self.link_path = configuration.get(section, "link_path") self.name = repository_id.name - self.sign_targets, self.default_pgp_key = GPG.sign_options(configuration) - def make_html(self, result: Result, template_path: Path) -> str: + def make_html(self, result: Result, template_name: Path | str) -> str: """ generate report for the specified packages Args: result(Result): build result - template_path(Path): path to jinja template + template_name(Path | str): name of the template or path to it (legacy configuration) """ + templates = self.templates[:] + if isinstance(template_name, Path): + templates.append(template_name.parent) + template_name = template_name.name + # idea comes from https://stackoverflow.com/a/38642558 - loader = jinja2.FileSystemLoader(searchpath=template_path.parent) - environment = jinja2.Environment(loader=loader, autoescape=True) - template = environment.get_template(template_path.name) + loader = jinja2.FileSystemLoader(searchpath=templates) + environment = jinja2.Environment(trim_blocks=True, lstrip_blocks=True, autoescape=True, loader=loader) + template = environment.get_template(template_name) content = [ { diff --git a/src/ahriman/core/report/report_trigger.py b/src/ahriman/core/report/report_trigger.py index 19ac542d..c99ab46f 100644 --- a/src/ahriman/core/report/report_trigger.py +++ b/src/ahriman/core/report/report_trigger.py @@ -40,7 +40,10 @@ class ReportTrigger(Trigger): "target": { "type": "list", "coerce": "list", - "schema": {"type": "string"}, + "schema": { + "type": "string", + "empty": False, + }, }, }, }, @@ -67,19 +70,25 @@ class ReportTrigger(Trigger): "full_template_path": { "type": "path", "coerce": "absolute_path", + "excludes": ["template_full"], + "required": True, "path_exists": True, + "path_type": "file", }, "homepage": { "type": "string", + "empty": False, "is_url": ["http", "https"], }, "host": { "type": "string", "required": True, + "empty": False, }, "link_path": { "type": "string", "required": True, + "empty": False, "is_url": [], }, "no_empty_report": { @@ -88,6 +97,7 @@ class ReportTrigger(Trigger): }, "password": { "type": "string", + "empty": False, }, "port": { "type": "integer", @@ -99,26 +109,58 @@ class ReportTrigger(Trigger): "receivers": { "type": "list", "coerce": "list", - "schema": {"type": "string"}, + "schema": { + "type": "string", + "empty": False, + }, "required": True, "empty": False, }, "sender": { "type": "string", "required": True, + "empty": False, }, "ssl": { "type": "string", "allowed": ["ssl", "starttls", "disabled"], }, + "template": { + "type": "string", + "excludes": ["template_path"], + "dependencies": ["templates"], + "required": True, + "empty": False, + }, + "template_full": { + "type": "string", + "excludes": ["template_path"], + "dependencies": ["templates"], + "required": True, + "empty": False, + }, "template_path": { "type": "path", "coerce": "absolute_path", + "excludes": ["template"], "required": True, "path_exists": True, + "path_type": "file", + }, + "templates": { + "type": "list", + "coerce": "list", + "schema": { + "type": "path", + "coerce": "absolute_path", + "path_exists": True, + "path_type": "dir", + }, + "empty": False, }, "user": { "type": "string", + "empty": False, }, }, }, @@ -131,11 +173,13 @@ class ReportTrigger(Trigger): }, "homepage": { "type": "string", + "empty": False, "is_url": ["http", "https"], }, "link_path": { "type": "string", "required": True, + "empty": False, "is_url": [], }, "path": { @@ -143,11 +187,31 @@ class ReportTrigger(Trigger): "coerce": "absolute_path", "required": True, }, + "template": { + "type": "string", + "excludes": ["template_path"], + "dependencies": ["templates"], + "required": True, + "empty": False, + }, "template_path": { "type": "path", "coerce": "absolute_path", + "excludes": ["template"], "required": True, "path_exists": True, + "path_type": "file", + }, + "templates": { + "type": "list", + "coerce": "list", + "schema": { + "type": "path", + "coerce": "absolute_path", + "path_exists": True, + "path_type": "dir", + }, + "empty": False, }, }, }, @@ -161,30 +225,54 @@ class ReportTrigger(Trigger): "api_key": { "type": "string", "required": True, + "empty": False, }, "chat_id": { "type": "string", "required": True, + "empty": False, }, "homepage": { "type": "string", + "empty": False, "is_url": ["http", "https"], }, "link_path": { "type": "string", "required": True, + "empty": False, "is_url": [], }, + "template": { + "type": "string", + "excludes": ["template_path"], + "dependencies": ["templates"], + "required": True, + "empty": False, + }, "template_path": { "type": "path", "coerce": "absolute_path", + "excludes": ["template"], "required": True, "path_exists": True, + "path_type": "file", }, "template_type": { "type": "string", "allowed": ["MarkdownV2", "HTML", "Markdown"], }, + "templates": { + "type": "list", + "coerce": "list", + "schema": { + "type": "path", + "coerce": "absolute_path", + "path_exists": True, + "path_type": "dir", + }, + "empty": False, + }, "timeout": { "type": "integer", "coerce": "integer", diff --git a/src/ahriman/core/report/telegram.py b/src/ahriman/core/report/telegram.py index 46145150..4780b289 100644 --- a/src/ahriman/core/report/telegram.py +++ b/src/ahriman/core/report/telegram.py @@ -35,7 +35,7 @@ class Telegram(Report, JinjaTemplate, SyncHttpClient): TELEGRAM_MAX_CONTENT_LENGTH(int): (class attribute) max content length of the message api_key(str): bot api key chat_id(str): chat id to post message, either string with @ or integer - template_path(Path): path to template for built packages + template(Path | str): name or path to template for built packages template_type(str): template message type to be used in parse mode, one of MarkdownV2, HTML, Markdown """ @@ -57,7 +57,8 @@ class Telegram(Report, JinjaTemplate, SyncHttpClient): self.api_key = configuration.get(section, "api_key") self.chat_id = configuration.get(section, "chat_id") - self.template_path = configuration.getpath(section, "template_path") + self.template = configuration.get(section, "template", fallback=None) or \ + configuration.getpath(section, "template_path") self.template_type = configuration.get(section, "template_type", fallback="HTML") def _send(self, text: str) -> None: @@ -83,7 +84,7 @@ class Telegram(Report, JinjaTemplate, SyncHttpClient): """ if not result.success: return - text = self.make_html(result, self.template_path) + text = self.make_html(result, self.template) # telegram content is limited by 4096 symbols, so we are going to split the message by new lines # to fit into this restriction while len(text) > self.TELEGRAM_MAX_CONTENT_LENGTH: diff --git a/src/ahriman/core/support/keyring_trigger.py b/src/ahriman/core/support/keyring_trigger.py index 6fbc0a06..ed5c0985 100644 --- a/src/ahriman/core/support/keyring_trigger.py +++ b/src/ahriman/core/support/keyring_trigger.py @@ -43,7 +43,10 @@ class KeyringTrigger(Trigger): "target": { "type": "list", "coerce": "list", - "schema": {"type": "string"}, + "schema": { + "type": "string", + "empty": False, + }, }, }, }, @@ -56,28 +59,47 @@ class KeyringTrigger(Trigger): }, "description": { "type": "string", + "empty": False, }, "homepage": { "type": "string", + "empty": False, }, "license": { "type": "list", "coerce": "list", + "schema": { + "type": "string", + "empty": False, + }, }, "package": { "type": "string", + "empty": False, }, "packagers": { "type": "list", "coerce": "list", + "schema": { + "type": "string", + "empty": False, + }, }, "revoked": { "type": "list", "coerce": "list", + "schema": { + "type": "string", + "empty": False, + }, }, "trusted": { "type": "list", "coerce": "list", + "schema": { + "type": "string", + "empty": False, + }, }, }, }, diff --git a/src/ahriman/core/support/mirrorlist_trigger.py b/src/ahriman/core/support/mirrorlist_trigger.py index 37c3886a..beb8df51 100644 --- a/src/ahriman/core/support/mirrorlist_trigger.py +++ b/src/ahriman/core/support/mirrorlist_trigger.py @@ -39,7 +39,10 @@ class MirrorlistTrigger(Trigger): "target": { "type": "list", "coerce": "list", - "schema": {"type": "string"}, + "schema": { + "type": "string", + "empty": False, + }, }, }, }, @@ -52,16 +55,23 @@ class MirrorlistTrigger(Trigger): }, "description": { "type": "string", + "empty": False, }, "homepage": { "type": "string", + "empty": False, }, "license": { "type": "list", "coerce": "list", + "schema": { + "type": "string", + "empty": False, + }, }, "package": { "type": "string", + "empty": False, }, "path": { "type": "path", @@ -70,7 +80,12 @@ class MirrorlistTrigger(Trigger): "servers": { "type": "list", "coerce": "list", + "schema": { + "type": "string", + "empty": False, + }, "required": True, + "empty": False, }, }, }, diff --git a/src/ahriman/core/upload/upload_trigger.py b/src/ahriman/core/upload/upload_trigger.py index 8106d357..63e2b252 100644 --- a/src/ahriman/core/upload/upload_trigger.py +++ b/src/ahriman/core/upload/upload_trigger.py @@ -40,7 +40,10 @@ class UploadTrigger(Trigger): "target": { "type": "list", "coerce": "list", - "schema": {"type": "string"}, + "schema": { + "type": "string", + "empty": False, + }, }, }, }, @@ -54,13 +57,16 @@ class UploadTrigger(Trigger): "owner": { "type": "string", "required": True, + "empty": False, }, "password": { "type": "string", + "empty": False, }, "repository": { "type": "string", "required": True, + "empty": False, }, "timeout": { "type": "integer", @@ -73,6 +79,7 @@ class UploadTrigger(Trigger): }, "username": { "type": "string", + "empty": False, }, }, }, @@ -86,13 +93,17 @@ class UploadTrigger(Trigger): "command": { "type": "list", "coerce": "list", - "schema": {"type": "string"}, + "schema": { + "type": "string", + "empty": False, + }, "required": True, "empty": False, }, "remote": { "type": "string", "required": True, + "empty": False, }, }, }, @@ -120,10 +131,12 @@ class UploadTrigger(Trigger): "access_key": { "type": "string", "required": True, + "empty": False, }, "bucket": { "type": "string", "required": True, + "empty": False, }, "chunk_size": { "type": "integer", @@ -132,14 +145,17 @@ class UploadTrigger(Trigger): }, "object_path": { "type": "string", + "empty": False, }, "region": { "type": "string", "required": True, + "empty": False, }, "secret_key": { "type": "string", "required": True, + "empty": False, }, }, }, diff --git a/src/ahriman/web/web.py b/src/ahriman/web/web.py index 25d937a6..c7047ab6 100644 --- a/src/ahriman/web/web.py +++ b/src/ahriman/web/web.py @@ -147,7 +147,8 @@ def setup_service(repository_id: RepositoryId, configuration: Configuration, spa setup_cors(application) application.logger.info("setup templates") - aiohttp_jinja2.setup(application, loader=jinja2.FileSystemLoader(configuration.getpath("web", "templates"))) + loader = jinja2.FileSystemLoader(searchpath=configuration.getpathlist("web", "templates")) + aiohttp_jinja2.setup(application, trim_blocks=True, lstrip_blocks=True, autoescape=True, loader=loader) application.logger.info("setup configuration") application["configuration"] = configuration diff --git a/tests/ahriman/core/configuration/test_configuration.py b/tests/ahriman/core/configuration/test_configuration.py index db635f46..d5b207cf 100644 --- a/tests/ahriman/core/configuration/test_configuration.py +++ b/tests/ahriman/core/configuration/test_configuration.py @@ -229,6 +229,19 @@ def test_getpath_without_fallback(configuration: Configuration) -> None: assert configuration.getpath("build", "option") +def test_getpathlist(configuration: Configuration) -> None: + """ + must extract path list + """ + path = Path("/a/b/c") + configuration.set_option("build", "path", f"""{path} {path.relative_to("/")}""") + + result = configuration.getpathlist("build", "path") + assert all(element.is_absolute() for element in result) + assert path in result + assert all(element.is_relative_to("/") for element in result) + + def test_gettype(configuration: Configuration) -> None: """ must extract type from variable diff --git a/tests/ahriman/core/configuration/test_validator.py b/tests/ahriman/core/configuration/test_validator.py index b6edb8ba..42b3795b 100644 --- a/tests/ahriman/core/configuration/test_validator.py +++ b/tests/ahriman/core/configuration/test_validator.py @@ -118,3 +118,27 @@ def test_validate_path_exists(validator: Validator, mocker: MockerFixture) -> No MockCall("field", "Path 2 must not exist"), MockCall("field", "Path 3 must exist"), ]) + + +def test_validate_path_type(validator: Validator, mocker: MockerFixture) -> None: + """ + must correctly validate path type + """ + error_mock = mocker.patch("ahriman.core.configuration.validator.Validator._error") + + mocker.patch("pathlib.Path.is_file", return_value=True) + validator._validate_path_type("file", "field", Path("1")) + + mocker.patch("pathlib.Path.is_file", return_value=False) + validator._validate_path_type("file", "field", Path("2")) + + mocker.patch("pathlib.Path.is_dir", return_value=True) + validator._validate_path_type("dir", "field", Path("3")) + + mocker.patch("pathlib.Path.is_dir", return_value=False) + validator._validate_path_type("dir", "field", Path("4")) + + error_mock.assert_has_calls([ + MockCall("field", "Path 2 must be type of file"), + MockCall("field", "Path 4 must be type of dir"), + ]) diff --git a/tests/ahriman/core/report/test_email.py b/tests/ahriman/core/report/test_email.py index b835df7f..2515d857 100644 --- a/tests/ahriman/core/report/test_email.py +++ b/tests/ahriman/core/report/test_email.py @@ -8,6 +8,35 @@ from ahriman.models.package import Package from ahriman.models.result import Result +def test_template(configuration: Configuration) -> None: + """ + must correctly parse template name and path + """ + template = configuration.get("email", "template") + root, repository_id = configuration.check_loaded() + + assert Email(repository_id, configuration, "email").template == template + + configuration.remove_option("email", "template") + configuration.set_option("email", "template_path", template) + assert Email(repository_id, configuration, "email").template == root.parent / template + + +def test_template_full(configuration: Configuration) -> None: + """ + must correctly parse template name and path + """ + template = "template" + root, repository_id = configuration.check_loaded() + + configuration.set_option("email", "template_full", template) + assert Email(repository_id, configuration, "email").template_full == template + + configuration.remove_option("email", "template_full") + configuration.set_option("email", "full_template_path", template) + assert Email(repository_id, configuration, "email").template_full == root.parent / template + + def test_send(email: Email, mocker: MockerFixture) -> None: """ must send an email with attachment @@ -115,7 +144,7 @@ def test_generate_with_built_and_full_path(email: Email, package_ahriman: Packag """ send_mock = mocker.patch("ahriman.core.report.email.Email._send") - email.full_template_path = email.template_path + email.template_full = email.template email.generate([package_ahriman], result) send_mock.assert_called_once_with(pytest.helpers.anyvar(int), pytest.helpers.anyvar(int)) diff --git a/tests/ahriman/core/report/test_html.py b/tests/ahriman/core/report/test_html.py index 71690118..2a3f9611 100644 --- a/tests/ahriman/core/report/test_html.py +++ b/tests/ahriman/core/report/test_html.py @@ -8,6 +8,20 @@ from ahriman.models.package import Package from ahriman.models.result import Result +def test_template(configuration: Configuration) -> None: + """ + must correctly parse template name and path + """ + template = configuration.get("html", "template") + root, repository_id = configuration.check_loaded() + + assert HTML(repository_id, configuration, "html").template == template + + configuration.remove_option("html", "template") + configuration.set_option("html", "template_path", template) + assert HTML(repository_id, configuration, "html").template == root.parent / template + + def test_generate(configuration: Configuration, package_ahriman: Package, mocker: MockerFixture) -> None: """ must generate report diff --git a/tests/ahriman/core/report/test_jinja_template.py b/tests/ahriman/core/report/test_jinja_template.py index dd735d77..65cc5c23 100644 --- a/tests/ahriman/core/report/test_jinja_template.py +++ b/tests/ahriman/core/report/test_jinja_template.py @@ -8,7 +8,17 @@ def test_generate(configuration: Configuration, package_ahriman: Package) -> Non """ must generate html report """ - path = configuration.getpath("html", "template_path") + name = configuration.getpath("html", "template") + _, repository_id = configuration.check_loaded() + report = JinjaTemplate(repository_id, configuration, "html") + assert report.make_html(Result(success=[package_ahriman]), name) + + +def test_generate_from_path(configuration: Configuration, package_ahriman: Package) -> None: + """ + must generate html report from path + """ + path = configuration.getpath("html", "templates") / configuration.get("html", "template") _, repository_id = configuration.check_loaded() report = JinjaTemplate(repository_id, configuration, "html") assert report.make_html(Result(success=[package_ahriman]), path) diff --git a/tests/ahriman/core/report/test_telegram.py b/tests/ahriman/core/report/test_telegram.py index 047824fe..76cadbba 100644 --- a/tests/ahriman/core/report/test_telegram.py +++ b/tests/ahriman/core/report/test_telegram.py @@ -10,6 +10,20 @@ from ahriman.models.package import Package from ahriman.models.result import Result +def test_template(configuration: Configuration) -> None: + """ + must correctly parse template name and path + """ + template = configuration.get("telegram", "template") + root, repository_id = configuration.check_loaded() + + assert Telegram(repository_id, configuration, "telegram").template == template + + configuration.remove_option("telegram", "template") + configuration.set_option("telegram", "template_path", template) + assert Telegram(repository_id, configuration, "telegram").template == root.parent / template + + def test_send(telegram: Telegram, mocker: MockerFixture) -> None: """ must send a message diff --git a/tests/testresources/core/ahriman.ini b/tests/testresources/core/ahriman.ini index d12b4c3b..fc9ed8c7 100644 --- a/tests/testresources/core/ahriman.ini +++ b/tests/testresources/core/ahriman.ini @@ -63,7 +63,8 @@ no_empty_report = no port = 587 receivers = mail@example.com sender = mail@example.com -template_path = ../web/templates/repo-index.jinja2 +template = repo-index.jinja2 +templates = ../web/templates [console] use_utf = yes @@ -72,7 +73,8 @@ use_utf = yes path = homepage = link_path = -template_path = ../web/templates/repo-index.jinja2 +template = repo-index.jinja2 +templates = ../web/templates [remote-call] manual = yes @@ -82,7 +84,8 @@ api_key = apikey chat_id = @ahrimantestchat homepage = link_path = -template_path = ../web/templates/telegram-index.jinja2 +template = telegram-index.jinja2 +templates = ../web/templates [upload] target =