diff --git a/mobilizon_reshare/.secrets.toml b/mobilizon_reshare/.secrets.toml deleted file mode 100644 index a09b2cc..0000000 --- a/mobilizon_reshare/.secrets.toml +++ /dev/null @@ -1,34 +0,0 @@ -[default.publisher.telegram] -active=true -chat_id="xxx" -msg_template_path="xxx" -token="xxx" -username="xxx" -[default.publisher.facebook] -active=false -[default.publisher.zulip] -active=true -chat_id="xxx" -subject="xxx" -bot_token="xxx" -bot_email="xxx" -[default.publisher.twitter] -active=false -[default.publisher.mastodon] -active=false - -[default.notifier.telegram] -active=true -chat_id="xxx" -token="xxx" -username="xxx" -[default.notifier.zulip] -active=true -chat_id="xxx" -subject="xxx" -bot_token="xxx" -bot_email="xxx" -[default.notifier.twitter] -active=false -[default.notifier.mastodon] -active=false diff --git a/mobilizon_reshare/cli/format.py b/mobilizon_reshare/cli/format.py index 491022b..8d69bbe 100644 --- a/mobilizon_reshare/cli/format.py +++ b/mobilizon_reshare/cli/format.py @@ -2,10 +2,10 @@ import click from mobilizon_reshare.event.event import MobilizonEvent from mobilizon_reshare.models.event import Event -from mobilizon_reshare.publishers.coordinator import PublisherCoordinator +from mobilizon_reshare.publishers.platforms.platform_mapping import get_formatter_class -async def format_event(event_id, publisher): +async def format_event(event_id, publisher_name: str): event = await Event.get_or_none(mobilizon_id=event_id).prefetch_related( "publications__publisher" ) @@ -13,5 +13,5 @@ async def format_event(event_id, publisher): click.echo(f"Event with mobilizon_id {event_id} not found.") return event = MobilizonEvent.from_model(event) - message = PublisherCoordinator.get_formatted_message(event, publisher) + message = get_formatter_class(publisher_name)().get_message_from_event(event) click.echo(message) diff --git a/mobilizon_reshare/cli/main.py b/mobilizon_reshare/cli/main.py index 393aab9..a94dedb 100644 --- a/mobilizon_reshare/cli/main.py +++ b/mobilizon_reshare/cli/main.py @@ -1,8 +1,10 @@ import logging.config +from functools import partial from mobilizon_reshare.event.event_selection_strategies import select_event_to_publish from mobilizon_reshare.mobilizon.events import get_unpublished_events from mobilizon_reshare.models.publication import PublicationStatus +from mobilizon_reshare.publishers.abstract import EventPublication from mobilizon_reshare.publishers.coordinator import ( PublicationFailureNotifiersCoordinator, ) @@ -43,16 +45,24 @@ async def main(): ) if event: - - waiting_publications = await publications_with_status( - status=PublicationStatus.WAITING, - event_mobilizon_id=event.mobilizon_id, - ) logger.debug(f"Event to publish found: {event.name}") - report = PublisherCoordinator(event, waiting_publications).run() - await save_publication_report(report, waiting_publications) - PublicationFailureNotifiersCoordinator(event, report).notify_failures() - return 0 if report.successful else 1 + waiting_publications_models = await publications_with_status( + status=PublicationStatus.WAITING, event_mobilizon_id=event.mobilizon_id, + ) + waiting_publications = list( + map( + partial(EventPublication.from_orm, event=event), + waiting_publications_models.values(), + ) + ) + + reports = PublisherCoordinator(waiting_publications).run() + + await save_publication_report(reports, waiting_publications_models) + for _, report in reports.reports.items(): + PublicationFailureNotifiersCoordinator(report).notify_failure() + + return 0 if reports.successful else 1 else: return 0 diff --git a/mobilizon_reshare/publishers/abstract.py b/mobilizon_reshare/publishers/abstract.py index 5cf4253..66fd883 100644 --- a/mobilizon_reshare/publishers/abstract.py +++ b/mobilizon_reshare/publishers/abstract.py @@ -1,13 +1,15 @@ import inspect import logging from abc import ABC, abstractmethod +from dataclasses import dataclass +from uuid import UUID from dynaconf.utils.boxing import DynaBox from jinja2 import Environment, FileSystemLoader, Template -from requests import Response from mobilizon_reshare.config.config import get_settings from mobilizon_reshare.event.event import MobilizonEvent +from mobilizon_reshare.models.publication import Publication as PublicationModel from .exceptions import PublisherError, InvalidAttribute JINJA_ENV = Environment(loader=FileSystemLoader("/")) @@ -15,52 +17,7 @@ JINJA_ENV = Environment(loader=FileSystemLoader("/")) logger = logging.getLogger(__name__) -class AbstractNotifier(ABC): - """ - Generic notifier class. - Shall be inherited from specific subclasses that will manage validation - process for messages and credentials, text formatting, posting, etc. - - Attributes: - - ``message``: a formatted ``str`` - """ - - # Non-abstract subclasses should define ``_conf`` as a 2-tuple, where the - # first element is the type of class (either 'notifier' or 'publisher') and - # the second the name of its service (ie: 'facebook', 'telegram') - _conf = tuple() - - def __repr__(self): - return type(self).__name__ - - __str__ = __repr__ - - @property - def conf(self) -> DynaBox: - """ - Retrieves class's settings. - """ - cls = type(self) - if cls in (AbstractPublisher, AbstractNotifier): - raise InvalidAttribute( - "Abstract classes cannot access notifiers/publishers' settings" - ) - try: - t, n = cls._conf or tuple() - return get_settings()[t][n] - except (KeyError, ValueError): - raise InvalidAttribute( - f"Class {cls.__name__} has invalid ``_conf`` attribute" - f" (should be 2-tuple)" - ) - - @abstractmethod - def send(self, message): - """ - Sends a message to the target channel - """ - raise NotImplementedError - +class LoggerMixin: def _log_debug(self, msg, *args, **kwargs): self.__log(logging.DEBUG, msg, *args, **kwargs) @@ -82,6 +39,65 @@ class AbstractNotifier(ABC): if raise_error is not None: raise raise_error(msg) + +class ConfLoaderMixin: + _conf = tuple() + + @property + def conf(self) -> DynaBox: + """ + Retrieves class's settings. + """ + cls = type(self) + + try: + t, n = cls._conf or tuple() + return get_settings()[t][n] + except (KeyError, ValueError): + raise InvalidAttribute( + f"Class {cls.__name__} has invalid ``_conf`` attribute" + f" (should be 2-tuple)" + ) + + +class AbstractPlatform(ABC, LoggerMixin, ConfLoaderMixin): + """ + Generic notifier class. + Shall be inherited from specific subclasses that will manage validation + process for messages and credentials, text formatting, posting, etc. + + Attributes: + - ``message``: a formatted ``str`` + """ + + # Non-abstract subclasses should define ``_conf`` as a 2-tuple, where the + # first element is the type of class (either 'notifier' or 'publisher') and + # the second the name of its service (ie: 'facebook', 'telegram') + + def __repr__(self): + return type(self).__name__ + + __str__ = __repr__ + + @abstractmethod + def _send(self, message: str): + raise NotImplementedError + + def send(self, message: str): + """ + Sends a message to the target channel + """ + message = self._preprocess_message(message) + response = self._send(message) + self._validate_response(response) + + def _preprocess_message(self, message: str): + return message + + @abstractmethod + def _validate_response(self, response): + raise NotImplementedError + def are_credentials_valid(self) -> bool: try: self.validate_credentials() @@ -98,59 +114,10 @@ class AbstractNotifier(ABC): """ raise NotImplementedError + +class AbstractEventFormatter(LoggerMixin, ConfLoaderMixin): @abstractmethod - def publish(self) -> None: - """ - Publishes the actual post on social media. - Should raise ``PublisherError`` (or one of its subclasses) if - anything goes wrong. - """ - raise NotImplementedError - - def is_message_valid(self) -> bool: - try: - self.validate_message() - except PublisherError: - return False - return True - - @abstractmethod - def validate_message(self) -> None: - """ - Validates notifier's message. - Should raise ``PublisherError`` (or one of its subclasses) if message - is not valid. - """ - raise NotImplementedError - - -class AbstractPublisher(AbstractNotifier): - """ - Generic publisher class. - Shall be inherited from specific subclasses that will manage validation - process for events and credentials, text formatting, posting, etc. - - Attributes: - - ``event``: a ``MobilizonEvent`` containing every useful info from - the event - - ``message``: a formatted ``str`` - """ - - _conf = tuple() - - def __init__(self, event: MobilizonEvent): - self.event = event - super().__init__() - - def is_event_valid(self) -> bool: - try: - self.validate_event() - except PublisherError: - return False - return True - - @abstractmethod - def validate_event(self) -> None: + def validate_event(self, message) -> None: """ Validates publisher's event. Should raise ``PublisherError`` (or one of its subclasses) if event @@ -158,18 +125,18 @@ class AbstractPublisher(AbstractNotifier): """ raise NotImplementedError - def _preprocess_event(self): + def _preprocess_event(self, event): """ Allows publishers to preprocess events before feeding them to the template """ pass - def get_message_from_event(self) -> str: + def get_message_from_event(self, event) -> str: """ Retrieves a message from the event itself. """ - self._preprocess_event() - return self.event.format(self.get_message_template()) + event = self._preprocess_event(event) + return event.format(self.get_message_template()) def get_message_template(self) -> Template: """ @@ -178,17 +145,45 @@ class AbstractPublisher(AbstractNotifier): template_path = self.conf.msg_template_path or self.default_template_path return JINJA_ENV.get_template(template_path) - @abstractmethod - def _send(self, message) -> Response: - pass + def is_message_valid(self, event: MobilizonEvent) -> bool: + try: + self.validate_message(self.get_message_from_event(event)) + except PublisherError: + return False + return True @abstractmethod - def _validate_response(self, response: Response) -> None: - pass + def validate_message(self, message: str) -> None: + """ + Validates notifier's message. + Should raise ``PublisherError`` (or one of its subclasses) if message + is not valid. + """ + raise NotImplementedError - def send(self, message): - res = self._send(message) - self._validate_response(res) + def is_event_valid(self, event) -> bool: + try: + self.validate_event(event) + except PublisherError: + return False + return True - def publish(self) -> None: - self.send(message=self.get_message_from_event()) + +@dataclass +class EventPublication: + event: MobilizonEvent + id: UUID + publisher: AbstractPlatform + formatter: AbstractEventFormatter + + @classmethod + def from_orm(cls, model: PublicationModel, event: MobilizonEvent): + # imported here to avoid circular dependencies + from mobilizon_reshare.publishers.platforms.platform_mapping import ( + get_publisher_class, + get_formatter_class, + ) + + publisher = get_publisher_class(model.publisher.name)() + formatter = get_formatter_class(model.publisher.name)() + return cls(event, model.id, publisher, formatter) diff --git a/mobilizon_reshare/publishers/coordinator.py b/mobilizon_reshare/publishers/coordinator.py index c9f0ca6..005ef39 100644 --- a/mobilizon_reshare/publishers/coordinator.py +++ b/mobilizon_reshare/publishers/coordinator.py @@ -1,32 +1,16 @@ import logging from dataclasses import dataclass, field +from typing import List from uuid import UUID -from mobilizon_reshare.event.event import MobilizonEvent -from mobilizon_reshare.models.publication import Publication from mobilizon_reshare.models.publication import PublicationStatus -from mobilizon_reshare.publishers import get_active_notifiers, get_active_publishers -from mobilizon_reshare.publishers.abstract import AbstractPublisher +from mobilizon_reshare.publishers import get_active_notifiers +from mobilizon_reshare.publishers.abstract import EventPublication from mobilizon_reshare.publishers.exceptions import PublisherError -from mobilizon_reshare.publishers.telegram import TelegramPublisher -from mobilizon_reshare.publishers.zulip import ZulipPublisher +from mobilizon_reshare.publishers.platforms.platform_mapping import get_notifier_class logger = logging.getLogger(__name__) -name_to_publisher_class = {"telegram": TelegramPublisher, "zulip": ZulipPublisher} - - -class BuildPublisherMixin: - @staticmethod - def build_publishers( - event: MobilizonEvent, publisher_names - ) -> dict[str, AbstractPublisher]: - - return { - publisher_name: name_to_publisher_class[publisher_name](event) - for publisher_name in publisher_names - } - @dataclass class PublicationReport: @@ -37,7 +21,7 @@ class PublicationReport: @dataclass class PublisherCoordinatorReport: - publishers: dict[UUID, AbstractPublisher] + publications: List[EventPublication] reports: dict[UUID, PublicationReport] = field(default_factory={}) @property @@ -47,118 +31,107 @@ class PublisherCoordinatorReport: ) -class PublisherCoordinator(BuildPublisherMixin): - def __init__(self, event: MobilizonEvent, publications: dict[UUID, Publication]): - publishers = self.build_publishers(event, get_active_publishers()) - self.publishers_by_publication_id = { - publication_id: publishers[publication.publisher.name] - for publication_id, publication in publications.items() - } +class PublisherCoordinator: + def __init__(self, publications: List[EventPublication]): + self.publications = publications def run(self) -> PublisherCoordinatorReport: errors = self._validate() if errors: return PublisherCoordinatorReport( - reports=errors, publishers=self.publishers_by_publication_id + reports=errors, publications=self.publications ) return self._post() def _make_successful_report(self, failed_ids): return { - publication_id: PublicationReport( + publication.id: PublicationReport( status=PublicationStatus.COMPLETED, reason="", - publication_id=publication_id, + publication_id=publication.id, ) - for publication_id in self.publishers_by_publication_id - if publication_id not in failed_ids + for publication in self.publications + if publication.id not in failed_ids } def _post(self): failed_publishers_reports = {} - for publication_id, p in self.publishers_by_publication_id.items(): + + for publication in self.publications: + try: - p.publish() + message = publication.formatter.get_message_from_event( + publication.event + ) + publication.publisher.send(message) except PublisherError as e: - failed_publishers_reports[publication_id] = PublicationReport( + failed_publishers_reports[publication.id] = PublicationReport( status=PublicationStatus.FAILED, reason=str(e), - publication_id=publication_id, + publication_id=publication.id, ) reports = failed_publishers_reports | self._make_successful_report( failed_publishers_reports.keys() ) return PublisherCoordinatorReport( - publishers=self.publishers_by_publication_id, reports=reports + publications=self.publications, reports=reports ) def _validate(self): errors: dict[UUID, PublicationReport] = {} - for publication_id, p in self.publishers_by_publication_id.items(): + + for publication in self.publications: + reason = [] - if not p.are_credentials_valid(): + if not publication.publisher.are_credentials_valid(): reason.append("Invalid credentials") - if not p.is_event_valid(): + if not publication.formatter.is_event_valid(publication.event): reason.append("Invalid event") - if not p.is_message_valid(): + if not publication.formatter.is_message_valid(publication.event): reason.append("Invalid message") if len(reason) > 0: - errors[publication_id] = PublicationReport( + errors[publication.id] = PublicationReport( status=PublicationStatus.FAILED, reason=", ".join(reason), - publication_id=publication_id, + publication_id=publication.id, ) return errors - @staticmethod - def get_formatted_message(event: MobilizonEvent, publisher: str) -> str: - """ - Returns the formatted message for a given event and publisher. - """ - if publisher not in name_to_publisher_class: - raise ValueError( - f"Publisher {publisher} does not exist.\nSupported publishers: " - f"{', '.join(list(name_to_publisher_class.keys()))}" - ) - return name_to_publisher_class[publisher](event).get_message_from_event() +class AbstractNotifiersCoordinator: + def __init__(self, message: str, notifiers=None): + self.message = message + self.notifiers = notifiers or [ + get_notifier_class(notifier)() for notifier in get_active_notifiers() + ] - -class AbstractNotifiersCoordinator(BuildPublisherMixin): - def __init__(self, event: MobilizonEvent): - self.event = event - self.notifiers = self.build_publishers(event, get_active_notifiers()) - - def send_to_all(self, message): + def send_to_all(self): # TODO: failure to notify should fail safely and write to a dedicated log - for notifier in self.notifiers.values(): - notifier.send(message) + for notifier in self.notifiers: + notifier.send(self.message) class PublicationFailureNotifiersCoordinator(AbstractNotifiersCoordinator): - def __init__( - self, - event: MobilizonEvent, - publisher_coordinator_report: PublisherCoordinatorReport, - ): - self.report = publisher_coordinator_report - super(PublicationFailureNotifiersCoordinator, self).__init__(event) + def __init__(self, report: PublicationReport, notifiers=None): + self.report = report + super(PublicationFailureNotifiersCoordinator, self).__init__( + message=self.build_failure_message(), notifiers=notifiers + ) - def build_failure_message(self, report: PublicationReport): + def build_failure_message(self): + report = self.report return ( f"Publication {report.publication_id} failed with status: {report.status}.\n" f"Reason: {report.reason}" ) - def notify_failures(self): - for publication_id, report in self.report.reports.items(): - - logger.info( - f"Sending failure notifications for publication: {publication_id}" - ) - if report.status == PublicationStatus.FAILED: - self.send_to_all(self.build_failure_message(report)) + def notify_failure(self): + logger.info( + f"Sending failure notifications for publication: {self.report.publication_id}" + ) + if self.report.status == PublicationStatus.FAILED: + self.send_to_all() diff --git a/mobilizon_reshare/publishers/platforms/__init__.py b/mobilizon_reshare/publishers/platforms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mobilizon_reshare/publishers/platforms/platform_mapping.py b/mobilizon_reshare/publishers/platforms/platform_mapping.py new file mode 100644 index 0000000..a90af9f --- /dev/null +++ b/mobilizon_reshare/publishers/platforms/platform_mapping.py @@ -0,0 +1,35 @@ +from mobilizon_reshare.publishers.platforms.telegram import ( + TelegramPublisher, + TelegramFormatter, + TelegramNotifier, +) +from mobilizon_reshare.publishers.platforms.zulip import ( + ZulipPublisher, + ZulipFormatter, + ZulipNotifier, +) + +name_to_publisher_class = { + "telegram": TelegramPublisher, + "zulip": ZulipPublisher, +} +name_to_formatter_class = { + "telegram": TelegramFormatter, + "zulip": ZulipFormatter, +} +name_to_notifier_class = { + "telegram": TelegramNotifier, + "zulip": ZulipNotifier, +} + + +def get_notifier_class(platform): + return name_to_notifier_class[platform] + + +def get_publisher_class(platform): + return name_to_publisher_class[platform] + + +def get_formatter_class(platform): + return name_to_formatter_class[platform] diff --git a/mobilizon_reshare/publishers/telegram.py b/mobilizon_reshare/publishers/platforms/telegram.py similarity index 68% rename from mobilizon_reshare/publishers/telegram.py rename to mobilizon_reshare/publishers/platforms/telegram.py index 7d58c01..2cfb979 100644 --- a/mobilizon_reshare/publishers/telegram.py +++ b/mobilizon_reshare/publishers/platforms/telegram.py @@ -2,8 +2,12 @@ import pkg_resources import requests from requests import Response +from mobilizon_reshare.event.event import MobilizonEvent from mobilizon_reshare.formatting.description import html_to_markdown -from mobilizon_reshare.publishers.abstract import AbstractPublisher +from mobilizon_reshare.publishers.abstract import ( + AbstractEventFormatter, + AbstractPlatform, +) from mobilizon_reshare.publishers.exceptions import ( InvalidBot, InvalidCredentials, @@ -12,35 +16,50 @@ from mobilizon_reshare.publishers.exceptions import ( ) -class TelegramPublisher(AbstractPublisher): - """ - Telegram publisher class. - """ - - _conf = ("publisher", "telegram") +class TelegramFormatter(AbstractEventFormatter): default_template_path = pkg_resources.resource_filename( "mobilizon_reshare.publishers.templates", "telegram.tmpl.j2" ) - def _escape_message(self, message: str) -> str: + _conf = ("publisher", "telegram") + + @staticmethod + def escape_message(message: str) -> str: message = ( message.replace("-", "\\-") .replace(".", "\\.") .replace("(", "\\(") + .replace("!", "\\!") .replace(")", "\\)") .replace("#", "") ) return message - def _send(self, message: str) -> Response: - return requests.post( - url=f"https://api.telegram.org/bot{self.conf.token}/sendMessage", - json={ - "chat_id": self.conf.chat_id, - "text": self._escape_message(message), - "parse_mode": "markdownv2", - }, - ) + def validate_event(self, event: MobilizonEvent) -> None: + text = event.description + if not (text and text.strip()): + self._log_error("No text was found", raise_error=InvalidEvent) + + def get_message_from_event(self, event: MobilizonEvent) -> str: + return super(TelegramFormatter, self).get_message_from_event(event) + + def validate_message(self, message: str) -> None: + # TODO implement + pass + + def _preprocess_event(self, event: MobilizonEvent): + event.description = html_to_markdown(event.description) + event.name = html_to_markdown(event.name) + return event + + +class TelegramPlatform(AbstractPlatform): + """ + Telegram publisher class. + """ + + def _preprocess_message(self, message: str): + return TelegramFormatter.escape_message(message) def validate_credentials(self): conf = self.conf @@ -67,13 +86,19 @@ class TelegramPublisher(AbstractPublisher): "Found a different bot than the expected one", raise_error=InvalidBot, ) - def validate_event(self) -> None: - text = self.event.description - if not (text and text.strip()): - self._log_error("No text was found", raise_error=InvalidEvent) + def _send(self, message) -> Response: + return requests.post( + url=f"https://api.telegram.org/bot{self.conf.token}/sendMessage", + json={ + "chat_id": self.conf.chat_id, + "text": message, + "parse_mode": "markdownv2", + }, + ) def _validate_response(self, res): try: + res.raise_for_status() except requests.exceptions.HTTPError as e: self._log_error( @@ -95,10 +120,12 @@ class TelegramPublisher(AbstractPublisher): return data - def validate_message(self) -> None: - # TODO implement - pass - def _preprocess_event(self): - self.event.description = html_to_markdown(self.event.description) - self.event.name = html_to_markdown(self.event.name) +class TelegramPublisher(TelegramPlatform): + + _conf = ("publisher", "telegram") + + +class TelegramNotifier(TelegramPlatform): + + _conf = ("notifier", "telegram") diff --git a/mobilizon_reshare/publishers/zulip.py b/mobilizon_reshare/publishers/platforms/zulip.py similarity index 70% rename from mobilizon_reshare/publishers/zulip.py rename to mobilizon_reshare/publishers/platforms/zulip.py index 0583bd6..81ddd36 100644 --- a/mobilizon_reshare/publishers/zulip.py +++ b/mobilizon_reshare/publishers/platforms/zulip.py @@ -3,8 +3,12 @@ import requests from requests import Response from requests.auth import HTTPBasicAuth +from mobilizon_reshare.event.event import MobilizonEvent from mobilizon_reshare.formatting.description import html_to_markdown -from mobilizon_reshare.publishers.abstract import AbstractPublisher +from mobilizon_reshare.publishers.abstract import ( + AbstractPlatform, + AbstractEventFormatter, +) from mobilizon_reshare.publishers.exceptions import ( InvalidBot, InvalidCredentials, @@ -14,7 +18,28 @@ from mobilizon_reshare.publishers.exceptions import ( ) -class ZulipPublisher(AbstractPublisher): +class ZulipFormatter(AbstractEventFormatter): + + _conf = ("publisher", "zulip") + default_template_path = pkg_resources.resource_filename( + "mobilizon_reshare.publishers.templates", "zulip.tmpl.j2" + ) + + def validate_event(self, event: MobilizonEvent) -> None: + text = event.description + if not (text and text.strip()): + self._log_error("No text was found", raise_error=InvalidEvent) + + def validate_message(self, message) -> None: + pass + + def _preprocess_event(self, event: MobilizonEvent): + event.description = html_to_markdown(event.description) + event.name = html_to_markdown(event.name) + return event + + +class ZulipPlatform(AbstractPlatform): """ Zulip publisher class. """ @@ -32,11 +57,7 @@ class ZulipPublisher(AbstractPublisher): return requests.post( url=self.api_uri + "messages", auth=HTTPBasicAuth(self.conf.bot_email, self.conf.bot_token), - data={ - "type": "private", - "to": f"[{self.user_id}]", - "content": message, - }, + data={"type": "private", "to": f"[{self.user_id}]", "content": message}, ) def _send(self, message: str) -> Response: @@ -68,8 +89,7 @@ class ZulipPublisher(AbstractPublisher): err.append("bot email") if err: self._log_error( - ", ".join(err) + " is/are missing", - raise_error=InvalidCredentials, + ", ".join(err) + " is/are missing", raise_error=InvalidCredentials, ) res = requests.get( @@ -80,8 +100,7 @@ class ZulipPublisher(AbstractPublisher): if not data["is_bot"]: self._log_error( - "These user is not a bot", - raise_error=InvalidBot, + "These user is not a bot", raise_error=InvalidBot, ) if not bot_email == data["email"]: @@ -92,13 +111,7 @@ class ZulipPublisher(AbstractPublisher): raise_error=InvalidBot, ) - def validate_event(self) -> None: - text = self.event.description - if not (text and text.strip()): - self._log_error("No text was found", raise_error=InvalidEvent) - def validate_message(self) -> None: - # We don't need this for Zulip. pass def _validate_response(self, res) -> dict: @@ -113,12 +126,17 @@ class ZulipPublisher(AbstractPublisher): if data["result"] == "error": self._log_error( - f"{res.status_code} Error - {data['msg']}", - raise_error=ZulipError, + f"{res.status_code} Error - {data['msg']}", raise_error=ZulipError, ) return data - def _preprocess_event(self): - self.event.description = html_to_markdown(self.event.description) - self.event.name = html_to_markdown(self.event.name) + +class ZulipPublisher(ZulipPlatform): + + _conf = ("publisher", "zulip") + + +class ZulipNotifier(ZulipPlatform): + + _conf = ("notifier", "zulip") diff --git a/tests/publishers/conftest.py b/tests/publishers/conftest.py index 60eb9db..9a99dde 100644 --- a/tests/publishers/conftest.py +++ b/tests/publishers/conftest.py @@ -5,13 +5,15 @@ import arrow import pytest from mobilizon_reshare.event.event import MobilizonEvent -from mobilizon_reshare.publishers.abstract import AbstractPublisher +from mobilizon_reshare.publishers.abstract import ( + AbstractPlatform, + AbstractEventFormatter, +) from mobilizon_reshare.publishers.exceptions import PublisherError, InvalidResponse @pytest.fixture def test_event(): - now = arrow.now() return MobilizonEvent( **{ @@ -26,66 +28,78 @@ def test_event(): @pytest.fixture -def mock_publisher_valid(event): - class MockPublisher(AbstractPublisher): - def validate_event(self) -> None: +def mock_formatter_valid(): + class MockFormatter(AbstractEventFormatter): + def validate_event(self, event) -> None: pass - def get_message_from_event(self) -> str: - return self.event.description + def get_message_from_event(self, event) -> str: + return event.description - def validate_credentials(self) -> None: - pass - - def validate_message(self) -> None: + def validate_message(self, event) -> None: pass def _send(self, message): pass - def _validate_response(self, response) -> None: - pass - - return MockPublisher(event) + return MockFormatter() @pytest.fixture -def mock_publisher_invalid(event): - class MockPublisher(AbstractPublisher): - def validate_event(self) -> None: +def mock_formatter_invalid(): + class MockFormatter(AbstractEventFormatter): + def validate_event(self, event) -> None: raise PublisherError("Invalid event") - def get_message_from_event(self) -> str: + def get_message_from_event(self, event) -> str: return "" - def validate_credentials(self) -> None: - raise PublisherError("Invalid credentials") - - def validate_message(self) -> None: + def validate_message(self, event) -> None: raise PublisherError("Invalid message") - def _send(self, message): - pass - - def _validate_response(self, response) -> None: - pass - - return MockPublisher(event) + return MockFormatter() @pytest.fixture -def mock_publisher_invalid_response(mock_publisher_invalid, event): - class MockPublisher(type(mock_publisher_invalid)): - def validate_event(self) -> None: +def mock_publisher_valid(): + class MockPublisher(AbstractPlatform): + def _send(self, message): + pass + + def _validate_response(self, response): pass def validate_credentials(self) -> None: pass - def validate_message(self) -> None: + return MockPublisher() + + +@pytest.fixture +def mock_publisher_invalid(): + class MockPublisher(AbstractPlatform): + def _send(self, message): pass - def _validate_response(self, response) -> None: + def _validate_response(self, response): + return InvalidResponse("error") + + def validate_credentials(self) -> None: + raise PublisherError("error") + + return MockPublisher() + + +@pytest.fixture +def mock_publisher_invalid_response(): + class MockPublisher(AbstractPlatform): + def _send(self, message): + pass + + def _validate_response(self, response): raise InvalidResponse("Invalid response") - return MockPublisher(event) + def validate_credentials(self) -> None: + pass + + return MockPublisher() diff --git a/tests/publishers/test_abstract_predicates.py b/tests/publishers/test_abstract_predicates.py deleted file mode 100644 index 40899f3..0000000 --- a/tests/publishers/test_abstract_predicates.py +++ /dev/null @@ -1,22 +0,0 @@ -def test_are_credentials_valid(test_event, mock_publisher_valid): - assert mock_publisher_valid.are_credentials_valid() - - -def test_are_credentials_valid_false(mock_publisher_invalid): - assert not mock_publisher_invalid.are_credentials_valid() - - -def test_is_event_valid(mock_publisher_valid): - assert mock_publisher_valid.is_event_valid() - - -def test_is_event_valid_false(mock_publisher_invalid): - assert not mock_publisher_invalid.is_event_valid() - - -def test_is_message_valid(mock_publisher_valid): - assert mock_publisher_valid.is_message_valid() - - -def test_is_message_valid_false(mock_publisher_invalid): - assert not mock_publisher_invalid.is_message_valid() diff --git a/tests/publishers/test_abstract_validators.py b/tests/publishers/test_abstract_validators.py new file mode 100644 index 0000000..30e51cd --- /dev/null +++ b/tests/publishers/test_abstract_validators.py @@ -0,0 +1,14 @@ +def test_is_event_valid(mock_formatter_valid, event): + assert mock_formatter_valid.is_event_valid(event) + + +def test_is_event_valid_false(mock_formatter_invalid, event): + assert not mock_formatter_invalid.is_event_valid(event) + + +def test_is_message_valid(mock_formatter_valid, event): + assert mock_formatter_valid.is_message_valid(event) + + +def test_is_message_valid_false(mock_formatter_invalid, event): + assert not mock_formatter_invalid.is_message_valid(event) diff --git a/tests/publishers/test_coordinator.py b/tests/publishers/test_coordinator.py index 710fbb8..47ccde2 100644 --- a/tests/publishers/test_coordinator.py +++ b/tests/publishers/test_coordinator.py @@ -3,17 +3,19 @@ from uuid import UUID import pytest from asynctest import MagicMock -from mobilizon_reshare.config.config import get_settings from mobilizon_reshare.event.event import MobilizonEvent -from mobilizon_reshare.models.publication import PublicationStatus, Publication +from mobilizon_reshare.models.publication import ( + PublicationStatus, + Publication as PublicationModel, +) from mobilizon_reshare.models.publisher import Publisher +from mobilizon_reshare.publishers.abstract import EventPublication from mobilizon_reshare.publishers.coordinator import ( PublisherCoordinatorReport, PublicationReport, PublisherCoordinator, PublicationFailureNotifiersCoordinator, ) -from mobilizon_reshare.publishers.telegram import TelegramPublisher @pytest.mark.parametrize( @@ -37,37 +39,38 @@ def test_publication_report_successful(statuses, successful): @pytest.fixture @pytest.mark.asyncio -async def mock_publication( +async def mock_publications( + num_publications: int, test_event: MobilizonEvent, + mock_publisher_valid, + mock_formatter_valid, ): - event = test_event.to_model() - await event.save() - publisher = Publisher(name="telegram") - await publisher.save() - publication = Publication( - id=UUID(int=1), - status=PublicationStatus.WAITING, - event=event, - publisher=publisher, - timestamp=None, - reason=None, - ) - await publication.save() - return publication + result = [] + for i in range(num_publications): + event = test_event.to_model() + await event.save() + publisher = Publisher(name="telegram") + await publisher.save() + publication = PublicationModel( + id=UUID(int=i + 1), + status=PublicationStatus.WAITING, + event=event, + publisher=publisher, + timestamp=None, + reason=None, + ) + await publication.save() + publication = EventPublication.from_orm(publication, test_event) + publication.publisher = mock_publisher_valid + publication.formatter = mock_formatter_valid + result.append(publication) + return result +@pytest.mark.parametrize("num_publications", [2]) @pytest.mark.asyncio -async def test_coordinator_run_success( - test_event, mock_publication, mock_publisher_valid -): - coordinator = PublisherCoordinator( - test_event, {UUID(int=1): mock_publication, UUID(int=2): mock_publication} - ) - coordinator.publishers_by_publication_id = { - UUID(int=1): mock_publisher_valid, - UUID(int=2): mock_publisher_valid, - } - +async def test_coordinator_run_success(mock_publications,): + coordinator = PublisherCoordinator(publications=mock_publications,) report = coordinator.run() assert len(report.reports) == 2 assert report.successful, "\n".join( @@ -75,14 +78,15 @@ async def test_coordinator_run_success( ) +@pytest.mark.parametrize("num_publications", [1]) @pytest.mark.asyncio async def test_coordinator_run_failure( - test_event, mock_publication, mock_publisher_invalid + mock_publications, mock_publisher_invalid, mock_formatter_invalid ): - coordinator = PublisherCoordinator(test_event, {UUID(int=1): mock_publication}) - coordinator.publishers_by_publication_id = { - UUID(int=1): mock_publisher_invalid, - } + for pub in mock_publications: + pub.publisher = mock_publisher_invalid + pub.formatter = mock_formatter_invalid + coordinator = PublisherCoordinator(mock_publications) report = coordinator.run() assert len(report.reports) == 1 @@ -93,15 +97,15 @@ async def test_coordinator_run_failure( ) +@pytest.mark.parametrize("num_publications", [1]) @pytest.mark.asyncio async def test_coordinator_run_failure_response( - test_event, mock_publication, mock_publisher_invalid_response + mock_publications, mock_publisher_invalid_response ): - coordinator = PublisherCoordinator(test_event, {UUID(int=1): mock_publication}) - coordinator.publishers_by_publication_id = { - UUID(int=1): mock_publisher_invalid_response, - } + for pub in mock_publications: + pub.publisher = mock_publisher_invalid_response + coordinator = PublisherCoordinator(publications=mock_publications) report = coordinator.run() assert len(report.reports) == 1 assert not report.successful @@ -109,39 +113,18 @@ async def test_coordinator_run_failure_response( @pytest.mark.asyncio -async def test_notifier_coordinator_publication_failed( - test_event, mock_publisher_valid -): +async def test_notifier_coordinator_publication_failed(mock_publisher_valid): mock_send = MagicMock() mock_publisher_valid._send = mock_send - report = PublisherCoordinatorReport( - {UUID(int=1): mock_publisher_valid, UUID(int=2): mock_publisher_valid}, - { - UUID(int=1): PublicationReport( - status=PublicationStatus.FAILED, - reason="some failure", - publication_id=UUID(int=1), - ), - UUID(int=2): PublicationReport( - status=PublicationStatus.FAILED, - reason="some failure", - publication_id=UUID(int=2), - ), - }, + report = PublicationReport( + status=PublicationStatus.FAILED, + reason="some failure", + publication_id=UUID(int=1), ) - coordinator = PublicationFailureNotifiersCoordinator(test_event, report) - coordinator.notifiers = { - UUID(int=1): mock_publisher_valid, - UUID(int=2): mock_publisher_valid, - } - coordinator.notify_failures() + coordinator = PublicationFailureNotifiersCoordinator( + report, [mock_publisher_valid, mock_publisher_valid] + ) + coordinator.notify_failure() # 4 = 2 reports * 2 notifiers - assert mock_send.call_count == 4 - - -def test_get_formatted_message(test_event): - settings = get_settings() - settings.update({"publisher.telegram.msg_template_path": None}) - message = PublisherCoordinator.get_formatted_message(test_event, "telegram") - assert message == TelegramPublisher(test_event).get_message_from_event() + assert mock_send.call_count == 2 diff --git a/tests/publishers/test_zulip.py b/tests/publishers/test_zulip.py index 83ddb52..71c56d9 100644 --- a/tests/publishers/test_zulip.py +++ b/tests/publishers/test_zulip.py @@ -1,18 +1,19 @@ +from functools import partial + import pytest import responses from mobilizon_reshare.config.config import get_settings from mobilizon_reshare.models.publication import PublicationStatus from mobilizon_reshare.publishers import get_active_publishers +from mobilizon_reshare.publishers.abstract import EventPublication from mobilizon_reshare.publishers.coordinator import PublisherCoordinator from mobilizon_reshare.storage.query import ( get_publishers, update_publishers, publications_with_status, - get_all_events, ) - api_uri = "https://zulip.twc-italia.org/api/v1/" users_me = { "result": "success", @@ -39,10 +40,7 @@ users_me = { def mocked_valid_response(): with responses.RequestsMock() as rsps: rsps.add( - responses.GET, - api_uri + "users/me", - json=users_me, - status=200, + responses.GET, api_uri + "users/me", json=users_me, status=200, ) rsps.add( responses.POST, @@ -69,10 +67,7 @@ def mocked_credential_error_response(): def mocked_client_error_response(): with responses.RequestsMock() as rsps: rsps.add( - responses.GET, - api_uri + "users/me", - json=users_me, - status=200, + responses.GET, api_uri + "users/me", json=users_me, status=200, ) rsps.add( responses.POST, @@ -105,11 +100,17 @@ async def setup_db(event_model_generator, publication_model_generator): @pytest.mark.asyncio -async def test_zulip_publisher(mocked_valid_response, setup_db): - +async def test_zulip_publisher(mocked_valid_response, setup_db, event): + publication_models = await publications_with_status( + status=PublicationStatus.WAITING + ) report = PublisherCoordinator( - list(await get_all_events())[0], - await publications_with_status(status=PublicationStatus.WAITING), + list( + map( + partial(EventPublication.from_orm, event=event), + publication_models.values(), + ) + ) ).run() assert list(report.reports.values())[0].status == PublicationStatus.COMPLETED @@ -117,11 +118,18 @@ async def test_zulip_publisher(mocked_valid_response, setup_db): @pytest.mark.asyncio async def test_zulip_publishr_failure_invalid_credentials( - mocked_credential_error_response, setup_db + mocked_credential_error_response, setup_db, event ): + publication_models = await publications_with_status( + status=PublicationStatus.WAITING + ) report = PublisherCoordinator( - list(await get_all_events())[0], - await publications_with_status(status=PublicationStatus.WAITING), + list( + map( + partial(EventPublication.from_orm, event=event), + publication_models.values(), + ) + ) ).run() assert list(report.reports.values())[0].status == PublicationStatus.FAILED assert list(report.reports.values())[0].reason == "Invalid credentials" @@ -129,11 +137,18 @@ async def test_zulip_publishr_failure_invalid_credentials( @pytest.mark.asyncio async def test_zulip_publishr_failure_client_error( - mocked_client_error_response, setup_db + mocked_client_error_response, setup_db, event ): + publication_models = await publications_with_status( + status=PublicationStatus.WAITING + ) report = PublisherCoordinator( - list(await get_all_events())[0], - await publications_with_status(status=PublicationStatus.WAITING), + list( + map( + partial(EventPublication.from_orm, event=event), + publication_models.values(), + ) + ) ).run() assert list(report.reports.values())[0].status == PublicationStatus.FAILED assert list(report.reports.values())[0].reason == "400 Error - Invalid request" diff --git a/tests/storage/test_update.py b/tests/storage/test_update.py index 2efc688..bb7d22a 100644 --- a/tests/storage/test_update.py +++ b/tests/storage/test_update.py @@ -48,10 +48,7 @@ two_publishers_specification = {"publisher": ["telegram", "twitter"]} ], ) async def test_update_publishers( - specification, - names, - expected_result, - generate_models, + specification, names, expected_result, generate_models, ): await generate_models(specification) await update_publishers(names) @@ -71,6 +68,7 @@ async def test_update_publishers( [ complete_specification, PublisherCoordinatorReport( + publications=[], reports={ UUID(int=3): PublicationReport( status=PublicationStatus.FAILED, @@ -83,7 +81,6 @@ async def test_update_publishers( publication_id=UUID(int=4), ), }, - publishers={}, ), MobilizonEvent( name="event_1", @@ -110,17 +107,12 @@ async def test_update_publishers( ], ) async def test_save_publication_report( - specification, - report, - event, - expected_result, - generate_models, + specification, report, event, expected_result, generate_models, ): await generate_models(specification) publications = await publications_with_status( - status=PublicationStatus.WAITING, - event_mobilizon_id=event.mobilizon_id, + status=PublicationStatus.WAITING, event_mobilizon_id=event.mobilizon_id, ) await save_publication_report(report, publications) publication_ids = set(report.reports.keys())