diff --git a/docs/configuration.rst b/docs/configuration.rst index 4a416aa8..81cbc843 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -251,6 +251,7 @@ Section name must be either ``email`` (plus optional architecture name, e.g. ``e * ``password`` - SMTP password to authenticate, string, optional. * ``port`` - SMTP port for sending emails, integer, required. * ``receivers`` - SMTP receiver addresses, space separated list of strings, required. +* ``rss_url`` - link to RSS feed, string, optional. * ``sender`` - SMTP sender address, string, required. * ``ssl`` - SSL mode for SMTP connection, one of ``ssl``, ``starttls``, ``disabled``, optional, default ``disabled``. * ``template`` - Jinja2 template name, string, required. @@ -266,7 +267,8 @@ Section name must be either ``html`` (plus optional architecture name, e.g. ``ht * ``type`` - type of the report, string, optional, must be set to ``html`` if exists. * ``homepage`` - link to homepage, string, optional. * ``link_path`` - prefix for HTML links, string, required. -* ``path`` - path to html report file, string, required. +* ``path`` - path to HTML report file, string, required. +* ``rss_url`` - link to RSS feed, string, optional. * ``template`` - Jinja2 template name, string, required. * ``templates`` - path to templates directories, space separated list of paths, required. @@ -281,6 +283,20 @@ Section name must be either ``remote-call`` (plus optional architecture name, e. * ``manual`` - update manually built packages, boolean, optional, default ``no``. * ``wait_timeout`` - maximum amount of time in seconds to be waited before remote process will be terminated, integer, optional, default ``-1``. +``rss`` type +^^^^^^^^^^^^ + +Section name must be either ``rss`` (plus optional architecture name, e.g. ``rss:x86_64``) or random name with ``type`` set. + +* ``type`` - type of the report, string, optional, must be set to ``rss`` if exists. +* ``homepage`` - link to homepage, string, optional. +* ``link_path`` - prefix for HTML links, string, required. +* ``max_entries`` - maximal amount of entries to be included to the report, negative means no limit, integer, optional, default ``-1``. +* ``path`` - path to generated RSS file, string, required. +* ``rss_url`` - link to RSS feed, string, optional. +* ``template`` - Jinja2 template name, string, required. +* ``templates`` - path to templates directories, space separated list of paths, required. + ``telegram`` type ^^^^^^^^^^^^^^^^^ @@ -291,6 +307,7 @@ 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. +* ``rss_url`` - link to RSS feed, string, optional. * ``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 paths, required. diff --git a/package/share/ahriman/settings/ahriman.ini b/package/share/ahriman/settings/ahriman.ini index 9c22d7fa..bd815a8c 100644 --- a/package/share/ahriman/settings/ahriman.ini +++ b/package/share/ahriman/settings/ahriman.ini @@ -17,7 +17,7 @@ mirror = https://geo.mirror.pkgbuild.com/$repo/os/$arch repositories = core extra multilib ; Pacman's root directory. In the most cases it must point to the system root. root = / -; Sync files databases too, which is required by deep dependencies check +; Sync files databases too, which is required by deep dependencies check. sync_files_database = yes ; Use local packages cache. If this option is enabled, the service will be able to synchronize databases (available ; as additional option for some subcommands). If set to no, databases must be synchronized manually. @@ -52,17 +52,17 @@ allow_read_only = yes [build] ; List of additional flags passed to archbuild command. ;archbuild_flags = -; Path to build command +; Path to build command. ;build_command = ; List of packages to be ignored during automatic updates. ;ignore_packages = -; Include debug packages +; Include debug packages. ;include_debug_packages = yes ; List of additional flags passed to makechrootpkg command. ;makechrootpkg_flags = ; List of additional flags passed to makepkg command. makepkg_flags = --nocolor --ignorearch -; List of paths to be used for implicit dependency scan. Regular expressions are supported +; List of paths to be used for implicit dependency scan. Regular expressions are supported. scan_paths = ^usr/lib(?!/cmake).*$ ; List of enabled triggers in the order of calls. triggers = ahriman.core.gitremote.RemotePullTrigger ahriman.core.report.ReportTrigger ahriman.core.upload.UploadTrigger ahriman.core.gitremote.RemotePushTrigger @@ -212,14 +212,14 @@ target = console ; Console reporting trigger configuration sample. [console] -; Trigger type name +; Trigger type name. ;type = console ; Use utf8 symbols in output. use_utf = yes ; Email reporting trigger configuration sample. [email] -; Trigger type name +; Trigger type name. ;type = email ; Optional URL to the repository homepage. ;homepage= @@ -235,6 +235,8 @@ use_utf = yes ;port = ; List of emails to receive the reports. ;receivers = +; Optional link to the RSS feed. +;rss_url = ; Sender email. ;sender = ; SMTP server SSL mode, one of ssl, starttls, disabled. @@ -250,7 +252,7 @@ templates = /usr/share/ahriman/templates ; HTML reporting trigger configuration sample. [html] -; Trigger type name +; Trigger type name. ;type = html ; Optional URL to the repository homepage. ;homepage= @@ -258,6 +260,8 @@ templates = /usr/share/ahriman/templates ;link_path = ; Output path for the HTML report. ;path = +; Optional link to the RSS feed. +;rss_url = ; Template name to be used. template = repo-index.jinja2 ; List of directories with templates. @@ -265,7 +269,7 @@ templates = /usr/share/ahriman/templates ; Remote service callback trigger configuration sample. [remote-call] -; Trigger type name +; Trigger type name. ;type = remote-call ; Call for AUR packages update. ;aur = no @@ -276,9 +280,26 @@ templates = /usr/share/ahriman/templates ; Wait until remote process will be terminated in seconds. ;wait_timeout = -1 +; RSS reporting trigger configuration sample. +[rss] +; Trigger type name. +;type = rss +; Optional URL to the repository homepage. +;homepage= +; Prefix for packages links. Link to a package will be formed as link_path / filename. +;link_path = +; Output path for the RSS report. +;path = +; Optional link to the RSS feed. +;rss_url = +; Template name to be used. +template = rss.jinja2 +; List of directories with templates. +templates = /usr/share/ahriman/templates + ; Telegram reporting trigger configuration sample. [telegram] -; Trigger type name +; Trigger type name. ;type = telegram ; Telegram bot API key. ;api_key = @@ -288,6 +309,8 @@ templates = /usr/share/ahriman/templates ;homepage= ; Prefix for packages links. Link to a package will be formed as link_path / filename. ;link_path = +; Optional link to the RSS feed. +;rss_url = ; Template name to be used. template = telegram-index.jinja2 ; Telegram specific template mode, one of MarkdownV2, HTML or Markdown. @@ -304,7 +327,7 @@ target = ; GitHub upload trigger configuration sample. [github] -; Trigger type name +; Trigger type name. ;type = github ; GitHub repository owner username. ;owner = @@ -321,14 +344,14 @@ target = ; Remote instance upload trigger configuration sample. [remote-service] -; Trigger type name +; Trigger type name. ;type = remote-service ; HTTP request timeout in seconds. ;timeout = 30 ; rsync upload trigger configuration sample. [rsync] -; Trigger type name +; Trigger type name. ;type = rsync ; rsync command to run. command = rsync --archive --compress --partial --delete @@ -338,7 +361,7 @@ command = rsync --archive --compress --partial --delete ; S3 upload trigger configuration sample. [s3] -; Trigger type name +; Trigger type name. ;type = s3 ; AWS services access key. ;access_key = diff --git a/package/share/ahriman/templates/repo-index.jinja2 b/package/share/ahriman/templates/repo-index.jinja2 index e6314f53..6f2add03 100644 --- a/package/share/ahriman/templates/repo-index.jinja2 +++ b/package/share/ahriman/templates/repo-index.jinja2 @@ -7,6 +7,10 @@ {% include "utils/style.jinja2" %} {% include "user-style.jinja2" ignore missing %} + + {% if rss_url is not none %} + + {% endif %} diff --git a/package/share/ahriman/templates/rss.jinja2 b/package/share/ahriman/templates/rss.jinja2 new file mode 100644 index 00000000..07f45a85 --- /dev/null +++ b/package/share/ahriman/templates/rss.jinja2 @@ -0,0 +1,27 @@ + + + + {{ repository }}: Recent package updates + {% if homepage is not none %} + {{ homepage }} + {% endif %} + Recently updated packages in the {{ repository }}. + {% if rss_url is not none %} + + {% endif %} + en-us + {{ last_update }} + + {% for package in packages %} + + {{ package.name }} {{ package.version }} {{ package.architecture }} + {{ link_path }}/{{ package.filename }} + {{ package.description }} + {{ package.build_date }} + {{ package.tag }} + {{ repository }} + {{ package.architecture }} + + {% endfor %} + + diff --git a/src/ahriman/core/build_tools/package_archive.py b/src/ahriman/core/build_tools/package_archive.py index fe2f737c..006724d5 100644 --- a/src/ahriman/core/build_tools/package_archive.py +++ b/src/ahriman/core/build_tools/package_archive.py @@ -171,7 +171,7 @@ class PackageArchive: result: dict[Path, list[FilesystemPackage]] = {} # sort items from children directories to root - for path, packages in reversed(sorted(source.items())): + for path, packages in sorted(source.items(), reverse=True): # skip if this path belongs to the one of the base packages if any(package.package_name in base_packages for package in packages): continue diff --git a/src/ahriman/core/report/jinja_template.py b/src/ahriman/core/report/jinja_template.py index e00a366f..5facddae 100644 --- a/src/ahriman/core/report/jinja_template.py +++ b/src/ahriman/core/report/jinja_template.py @@ -17,14 +17,16 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . # +import datetime import jinja2 from collections.abc import Callable from pathlib import Path +from typing import Any from ahriman.core.configuration import Configuration from ahriman.core.sign.gpg import GPG -from ahriman.core.utils import pretty_datetime, pretty_size +from ahriman.core.utils import pretty_datetime, pretty_size, utcnow from ahriman.models.repository_id import RepositoryId from ahriman.models.result import Result from ahriman.models.sign_settings import SignSettings @@ -37,6 +39,7 @@ class JinjaTemplate: It uses jinja2 templates for report generation, the following variables are allowed: * homepage - link to homepage, string, optional + * last_update - report generation time, pretty printed datetime, required * link_path - prefix fo packages to download, string, required * has_package_signed - ``True`` in case if package sign enabled, ``False`` otherwise, required * has_repo_signed - ``True`` in case if repository database sign enabled, ``False`` otherwise, required @@ -46,21 +49,24 @@ class JinjaTemplate: * build_date, pretty printed datetime, string * depends, sorted list of strings * description, string - * filename, string, + * filename, string * groups, sorted list of strings - * installed_size, pretty printed datetime, string + * installed_size, pretty printed size, string * licenses, sorted list of strings * name, string + * tag, string * url, string * version, string * pgp_key - default PGP key ID, string, optional * repository - repository name, string, required + * rss_url - optional link to the RSS feed, string, optional 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 + rss_url(str | None): link to the RSS feed sign_targets(set[SignSettings]): targets to sign enabled in configuration templates(list[Path]): list of directories with templates """ @@ -80,8 +86,36 @@ class JinjaTemplate: self.homepage = configuration.get(section, "homepage", fallback=None) self.link_path = configuration.get(section, "link_path") self.name = repository_id.name + self.rss_url = configuration.get(section, "rss_url", fallback=None) self.sign_targets, self.default_pgp_key = GPG.sign_options(configuration) + @staticmethod + def format_datetime(timestamp: datetime.datetime | float | int | None) -> str: + """ + convert datetime object to string + + Args: + timestamp(datetime.datetime | float | int | None): datetime to convert + + Returns: + str: datetime as string representation + """ + return pretty_datetime(timestamp) + + @staticmethod + def sort_content(content: list[dict[str, Any]]) -> list[dict[str, Any]]: + """ + sort content before rendering + + Args: + content(list[dict[str, str]]): content of the template + + Returns: + list[dict[str, str]]: sorted content according to comparator defined + """ + comparator: Callable[[dict[str, str]], str] = lambda item: item["filename"] + return sorted(content, key=comparator) + def make_html(self, result: Result, template_name: Path | str) -> str: """ generate report for the specified packages @@ -104,7 +138,7 @@ class JinjaTemplate: { "architecture": properties.architecture or "", "archive_size": pretty_size(properties.archive_size), - "build_date": pretty_datetime(properties.build_date), + "build_date": self.format_datetime(properties.build_date), "depends": properties.depends, "description": properties.description or "", "filename": properties.filename, @@ -112,17 +146,20 @@ class JinjaTemplate: "installed_size": pretty_size(properties.installed_size), "licenses": properties.licenses, "name": package, + "tag": f"tag:{self.name}:{properties.architecture}:{package}:{base.version}:{properties.build_date}", "url": properties.url or "", - "version": base.version + "version": base.version, } for base in result.success for package, properties in base.packages.items() ] - comparator: Callable[[dict[str, str]], str] = lambda item: item["filename"] return template.render( homepage=self.homepage, + last_update=self.format_datetime(utcnow()), link_path=self.link_path, has_package_signed=SignSettings.Packages in self.sign_targets, has_repo_signed=SignSettings.Repository in self.sign_targets, - packages=sorted(content, key=comparator), + packages=self.sort_content(content), pgp_key=self.default_pgp_key, - repository=self.name) + repository=self.name, + rss_url=self.rss_url, + ) diff --git a/src/ahriman/core/report/report.py b/src/ahriman/core/report/report.py index 8fc5ca2c..c835f2ee 100644 --- a/src/ahriman/core/report/report.py +++ b/src/ahriman/core/report/report.py @@ -66,7 +66,7 @@ class Report(LazyLogging): self.configuration = configuration @staticmethod - def load(repository_id: RepositoryId, configuration: Configuration, target: str) -> Report: + def load(repository_id: RepositoryId, configuration: Configuration, target: str) -> Report: # pylint: disable=too-many-return-statements """ load client from settings @@ -92,6 +92,9 @@ class Report(LazyLogging): case ReportSettings.Telegram: from ahriman.core.report.telegram import Telegram return Telegram(repository_id, configuration, section) + case ReportSettings.RSS: + from ahriman.core.report.rss import RSS + return RSS(repository_id, configuration, section) case ReportSettings.RemoteCall: from ahriman.core.report.remote_call import RemoteCall return RemoteCall(repository_id, configuration, section) diff --git a/src/ahriman/core/report/report_trigger.py b/src/ahriman/core/report/report_trigger.py index 2be51a16..f9204324 100644 --- a/src/ahriman/core/report/report_trigger.py +++ b/src/ahriman/core/report/report_trigger.py @@ -116,6 +116,11 @@ class ReportTrigger(Trigger): "required": True, "empty": False, }, + "rss_url": { + "type": "string", + "empty": False, + "is_url": ["http", "https"], + }, "sender": { "type": "string", "required": True, @@ -187,6 +192,11 @@ class ReportTrigger(Trigger): "coerce": "absolute_path", "required": True, }, + "rss_url": { + "type": "string", + "empty": False, + "is_url": ["http", "https"], + }, "template": { "type": "string", "excludes": ["template_path"], @@ -243,6 +253,11 @@ class ReportTrigger(Trigger): "empty": False, "is_url": [], }, + "rss_url": { + "type": "string", + "empty": False, + "is_url": ["http", "https"], + }, "template": { "type": "string", "excludes": ["template_path"], @@ -304,7 +319,67 @@ class ReportTrigger(Trigger): "coerce": "integer", }, }, - } + }, + "rss": { + "type": "dict", + "schema": { + "type": { + "type": "string", + "allowed": ["rss"], + }, + "homepage": { + "type": "string", + "empty": False, + "is_url": ["http", "https"], + }, + "link_path": { + "type": "string", + "required": True, + "empty": False, + "is_url": [], + }, + "max_entries": { + "type": "integer", + "coerce": "integer", + }, + "path": { + "type": "path", + "coerce": "absolute_path", + "required": True, + }, + "rss_url": { + "type": "string", + "empty": False, + "is_url": ["http", "https"], + }, + "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, + }, + }, + }, } def __init__(self, repository_id: RepositoryId, configuration: Configuration) -> None: diff --git a/src/ahriman/core/report/rss.py b/src/ahriman/core/report/rss.py new file mode 100644 index 00000000..cc304914 --- /dev/null +++ b/src/ahriman/core/report/rss.py @@ -0,0 +1,130 @@ +# +# Copyright (c) 2021-2024 ahriman team. +# +# This file is part of ahriman +# (see https://github.com/arcan1s/ahriman). +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +import datetime + +from collections.abc import Callable +from email.utils import format_datetime, parsedate_to_datetime +from typing import Any + +from ahriman.core import context +from ahriman.core.configuration import Configuration +from ahriman.core.report.jinja_template import JinjaTemplate +from ahriman.core.report.report import Report +from ahriman.core.status import Client +from ahriman.models.event import EventType +from ahriman.models.package import Package +from ahriman.models.repository_id import RepositoryId +from ahriman.models.result import Result + + +class RSS(Report, JinjaTemplate): + """ + RSS report generator + + Attributes: + max_entries(int): the maximal amount of entries in RSS + report_path(Path): output path to RSS report + template(str): name of the template + """ + + def __init__(self, repository_id: RepositoryId, configuration: Configuration, section: str) -> None: + """ + default constructor + + Args: + repository_id(RepositoryId): repository unique identifier + configuration(Configuration): configuration instance + section(str): settings section name + """ + Report.__init__(self, repository_id, configuration) + JinjaTemplate.__init__(self, repository_id, configuration, section) + + self.max_entries = configuration.getint(section, "max_entries", fallback=-1) + self.report_path = configuration.getpath(section, "path") + self.template = configuration.get(section, "template") + + @staticmethod + def format_datetime(timestamp: datetime.datetime | float | int | None) -> str: + """ + convert datetime object to string + + Args: + timestamp(datetime.datetime | float | int | None): datetime to convert + + Returns: + str: datetime as string representation + """ + if timestamp is None: + return "" + if isinstance(timestamp, (int, float)): + timestamp = datetime.datetime.fromtimestamp(timestamp, datetime.UTC) + return format_datetime(timestamp) + + @staticmethod + def sort_content(content: list[dict[str, Any]]) -> list[dict[str, Any]]: + """ + sort content before rendering + + Args: + content(list[dict[str, str]]): content of the template + + Returns: + list[dict[str, str]]: sorted content according to comparator defined + """ + comparator: Callable[[dict[str, str]], datetime.datetime] = \ + lambda item: parsedate_to_datetime(item["build_date"]) + return sorted(content, key=comparator, reverse=True) + + def content(self, packages: list[Package]) -> Result: + """ + extract result to be written to template + + Args: + packages(list[Package]): list of packages to generate report + + Returns: + Result: result descriptor + """ + ctx = context.get() + reporter = ctx.get(Client) + events = reporter.event_get(EventType.PackageUpdated, None, limit=self.max_entries) + + known_packages = {package.base: package for package in packages} + + result = Result() + for event in events: + package = known_packages.get(event.object_id) + if package is None: + continue # package not found + result.add_updated(package) + + return result + + def generate(self, packages: list[Package], result: Result) -> None: + """ + generate report for the specified packages + + Args: + packages(list[Package]): list of packages to generate report + result(Result): build result + """ + result = self.content(packages) + rss = self.make_html(result, self.template) + self.report_path.write_text(rss, encoding="utf8") diff --git a/src/ahriman/models/report_settings.py b/src/ahriman/models/report_settings.py index 366c3149..01a4881a 100644 --- a/src/ahriman/models/report_settings.py +++ b/src/ahriman/models/report_settings.py @@ -32,6 +32,7 @@ class ReportSettings(StrEnum): Email(ReportSettings): (class attribute) email report generation Console(ReportSettings): (class attribute) print result to console Telegram(ReportSettings): (class attribute) markdown report to telegram channel + RSS(ReportSettings): (class attribute) RSS report generation RemoteCall(ReportSettings): (class attribute) remote ahriman server call """ @@ -40,10 +41,11 @@ class ReportSettings(StrEnum): Email = "email" Console = "console" Telegram = "telegram" + RSS = "rss" RemoteCall = "remote-call" @staticmethod - def from_option(value: str) -> ReportSettings: + def from_option(value: str) -> ReportSettings: # pylint: disable=too-many-return-statements """ construct value from configuration @@ -62,6 +64,8 @@ class ReportSettings(StrEnum): return ReportSettings.Console case "telegram": return ReportSettings.Telegram + case "rss": + return ReportSettings.RSS case "ahriman" | "remote-call": return ReportSettings.RemoteCall case _: diff --git a/tests/ahriman/application/handlers/test_handler_validate.py b/tests/ahriman/application/handlers/test_handler_validate.py index 2f4a4e2f..8525fdc4 100644 --- a/tests/ahriman/application/handlers/test_handler_validate.py +++ b/tests/ahriman/application/handlers/test_handler_validate.py @@ -75,6 +75,7 @@ def test_schema(configuration: Configuration) -> None: assert schema.pop("remote-push") assert schema.pop("remote-service") assert schema.pop("report") + assert schema.pop("rss") assert schema.pop("rsync") assert schema.pop("s3") assert schema.pop("telegram") diff --git a/tests/ahriman/core/report/conftest.py b/tests/ahriman/core/report/conftest.py index 8dcc79dd..b93054fa 100644 --- a/tests/ahriman/core/report/conftest.py +++ b/tests/ahriman/core/report/conftest.py @@ -3,6 +3,7 @@ import pytest from ahriman.core.configuration import Configuration from ahriman.core.report.email import Email from ahriman.core.report.remote_call import RemoteCall +from ahriman.core.report.rss import RSS from ahriman.core.report.telegram import Telegram @@ -15,7 +16,7 @@ def email(configuration: Configuration) -> Email: configuration(Configuration): configuration fixture Returns: - RemoteCall: email trigger test instance + Email: email trigger test instance """ _, repository_id = configuration.check_loaded() return Email(repository_id, configuration, "email") @@ -38,6 +39,21 @@ def remote_call(configuration: Configuration) -> RemoteCall: return RemoteCall(repository_id, configuration, "remote-call") +@pytest.fixture +def rss(configuration: Configuration) -> RSS: + """ + fixture for rss trigger + + Args: + configuration(Configuration): configuration fixture + + Returns: + RSS: rss trigger test instance + """ + _, repository_id = configuration.check_loaded() + return RSS(repository_id, configuration, "rss") + + @pytest.fixture def telegram(configuration: Configuration) -> Telegram: """ @@ -47,7 +63,7 @@ def telegram(configuration: Configuration) -> Telegram: configuration(Configuration): configuration fixture Returns: - RemoteCall: telegram trigger test instance + Telegram: telegram trigger test instance """ _, repository_id = configuration.check_loaded() return Telegram(repository_id, configuration, "telegram") diff --git a/tests/ahriman/core/report/test_jinja_template.py b/tests/ahriman/core/report/test_jinja_template.py index b9f845f5..2e5d3dcc 100644 --- a/tests/ahriman/core/report/test_jinja_template.py +++ b/tests/ahriman/core/report/test_jinja_template.py @@ -1,9 +1,24 @@ from ahriman.core.configuration import Configuration from ahriman.core.report.jinja_template import JinjaTemplate +from ahriman.core.utils import utcnow from ahriman.models.package import Package from ahriman.models.result import Result +def test_format_datetime() -> None: + """ + must format datetime + """ + assert JinjaTemplate.format_datetime(utcnow()) + + +def sort_content() -> None: + """ + must sort content for the template + """ + assert JinjaTemplate.sort_content([{"filename": "2"}, {"filename": "1"}]) == [{"filename": "1"}, {"filename": "2"}] + + def test_generate(configuration: Configuration, package_ahriman: Package) -> None: """ must generate html report diff --git a/tests/ahriman/core/report/test_report.py b/tests/ahriman/core/report/test_report.py index 7c92317d..cc1e649c 100644 --- a/tests/ahriman/core/report/test_report.py +++ b/tests/ahriman/core/report/test_report.py @@ -78,6 +78,17 @@ def test_report_remote_call(configuration: Configuration, result: Result, mocker report_mock.assert_called_once_with([], result) +def test_report_rss(configuration: Configuration, result: Result, mocker: MockerFixture) -> None: + """ + must instantiate rss trigger + """ + report_mock = mocker.patch("ahriman.core.report.rss.RSS.generate") + _, repository_id = configuration.check_loaded() + + Report.load(repository_id, configuration, "rss").run(result, []) + report_mock.assert_called_once_with([], result) + + def test_report_telegram(configuration: Configuration, result: Result, mocker: MockerFixture) -> None: """ must generate telegram report diff --git a/tests/ahriman/core/report/test_rss.py b/tests/ahriman/core/report/test_rss.py new file mode 100644 index 00000000..ffd6550a --- /dev/null +++ b/tests/ahriman/core/report/test_rss.py @@ -0,0 +1,79 @@ +import pytest + +from email.utils import parsedate_to_datetime +from pytest_mock import MockerFixture +from unittest.mock import MagicMock + +from ahriman.core.report.rss import RSS +from ahriman.core.status import Client +from ahriman.core.utils import utcnow +from ahriman.models.event import Event, EventType +from ahriman.models.package import Package +from ahriman.models.result import Result + + +def test_format_datetime() -> None: + """ + must format timestamp to rfc format + """ + timestamp = utcnow().replace(microsecond=0) + assert parsedate_to_datetime(RSS.format_datetime(timestamp.timestamp())) == timestamp + + +def test_format_datetime_datetime() -> None: + """ + must format datetime to rfc format + """ + timestamp = utcnow().replace(microsecond=0) + assert parsedate_to_datetime(RSS.format_datetime(timestamp)) == timestamp + + +def test_format_datetime_empty() -> None: + """ + must generate empty string from None timestamp + """ + assert RSS.format_datetime(None) == "" + + +def test_sort_content() -> None: + """ + must sort content for the template + """ + assert RSS.sort_content([ + {"filename": "2", "build_date": "Thu, 29 Aug 2024 16:36:55 -0000"}, + {"filename": "1", "build_date": "Thu, 29 Aug 2024 16:36:54 -0000"}, + {"filename": "3", "build_date": "Thu, 29 Aug 2024 16:36:56 -0000"}, + ]) == [ + {"filename": "3", "build_date": "Thu, 29 Aug 2024 16:36:56 -0000"}, + {"filename": "2", "build_date": "Thu, 29 Aug 2024 16:36:55 -0000"}, + {"filename": "1", "build_date": "Thu, 29 Aug 2024 16:36:54 -0000"}, + ] + + +def test_content(rss: RSS, package_ahriman: Package, mocker: MockerFixture) -> None: + """ + must generate RSS content correctly + """ + client_mock = MagicMock() + client_mock.event_get.return_value = [ + Event(EventType.PackageUpdated, package_ahriman.base), + Event(EventType.PackageUpdated, "random"), + Event(EventType.PackageUpdated, package_ahriman.base), + ] + context_mock = mocker.patch("ahriman.core._Context.get", return_value=client_mock) + + assert rss.content([package_ahriman]).success == [package_ahriman] + context_mock.assert_called_once_with(Client) + client_mock.event_get.assert_called_once_with(EventType.PackageUpdated, None, limit=rss.max_entries) + + +def test_generate(rss: RSS, package_ahriman: Package, mocker: MockerFixture) -> None: + """ + must generate report + """ + content_mock = mocker.patch("ahriman.core.report.rss.RSS.content", return_value=Result()) + write_mock = mocker.patch("pathlib.Path.write_text") + + rss.generate([package_ahriman], Result()) + content_mock.assert_called_once_with([package_ahriman]) + write_mock.assert_called_once_with(pytest.helpers.anyvar(int), encoding="utf8") diff --git a/tests/ahriman/core/test_utils.py b/tests/ahriman/core/test_utils.py index ecf2b9b6..d825a81d 100644 --- a/tests/ahriman/core/test_utils.py +++ b/tests/ahriman/core/test_utils.py @@ -489,6 +489,7 @@ def test_walk(resource_path_root: Path) -> None: resource_path_root / "web" / "templates" / "email-index.jinja2", resource_path_root / "web" / "templates" / "error.jinja2", resource_path_root / "web" / "templates" / "repo-index.jinja2", + resource_path_root / "web" / "templates" / "rss.jinja2", resource_path_root / "web" / "templates" / "shell", resource_path_root / "web" / "templates" / "telegram-index.jinja2", ]) diff --git a/tests/ahriman/models/test_report_settings.py b/tests/ahriman/models/test_report_settings.py index 8974cc56..25a5db7e 100644 --- a/tests/ahriman/models/test_report_settings.py +++ b/tests/ahriman/models/test_report_settings.py @@ -24,6 +24,9 @@ def test_from_option_valid() -> None: assert ReportSettings.from_option("telegram") == ReportSettings.Telegram assert ReportSettings.from_option("TElegraM") == ReportSettings.Telegram + assert ReportSettings.from_option("rss") == ReportSettings.RSS + assert ReportSettings.from_option("RSS") == ReportSettings.RSS + assert ReportSettings.from_option("remote-call") == ReportSettings.RemoteCall assert ReportSettings.from_option("reMOte-cALL") == ReportSettings.RemoteCall assert ReportSettings.from_option("ahriman") == ReportSettings.RemoteCall diff --git a/tests/testresources/core/ahriman.ini b/tests/testresources/core/ahriman.ini index fe88b912..d60f0737 100644 --- a/tests/testresources/core/ahriman.ini +++ b/tests/testresources/core/ahriman.ini @@ -81,6 +81,13 @@ templates = ../web/templates [remote-call] manual = yes +[rss] +path = +homepage = +link_path = +template = rss.jinja2 +templates = ../web/templates + [telegram] api_key = apikey chat_id = @ahrimantestchat