Refactor publication (#65)

* decoupled notifiers from event

* stub

* publishers working

* fixed format CLI

* fixed unit tests

* renamed abstractnotifier

* added another excluded character
This commit is contained in:
Simone Robutti 2021-10-02 18:09:03 +02:00 committed by GitHub
parent 6f81522ad0
commit b6b2402767
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 468 additions and 448 deletions

View File

@ -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

View File

@ -2,10 +2,10 @@ import click
from mobilizon_reshare.event.event import MobilizonEvent from mobilizon_reshare.event.event import MobilizonEvent
from mobilizon_reshare.models.event import Event 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( event = await Event.get_or_none(mobilizon_id=event_id).prefetch_related(
"publications__publisher" "publications__publisher"
) )
@ -13,5 +13,5 @@ async def format_event(event_id, publisher):
click.echo(f"Event with mobilizon_id {event_id} not found.") click.echo(f"Event with mobilizon_id {event_id} not found.")
return return
event = MobilizonEvent.from_model(event) 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) click.echo(message)

View File

@ -1,8 +1,10 @@
import logging.config import logging.config
from functools import partial
from mobilizon_reshare.event.event_selection_strategies import select_event_to_publish from mobilizon_reshare.event.event_selection_strategies import select_event_to_publish
from mobilizon_reshare.mobilizon.events import get_unpublished_events from mobilizon_reshare.mobilizon.events import get_unpublished_events
from mobilizon_reshare.models.publication import PublicationStatus from mobilizon_reshare.models.publication import PublicationStatus
from mobilizon_reshare.publishers.abstract import EventPublication
from mobilizon_reshare.publishers.coordinator import ( from mobilizon_reshare.publishers.coordinator import (
PublicationFailureNotifiersCoordinator, PublicationFailureNotifiersCoordinator,
) )
@ -43,16 +45,24 @@ async def main():
) )
if event: 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}") 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: else:
return 0 return 0

View File

@ -1,13 +1,15 @@
import inspect import inspect
import logging import logging
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass
from uuid import UUID
from dynaconf.utils.boxing import DynaBox from dynaconf.utils.boxing import DynaBox
from jinja2 import Environment, FileSystemLoader, Template from jinja2 import Environment, FileSystemLoader, Template
from requests import Response
from mobilizon_reshare.config.config import get_settings from mobilizon_reshare.config.config import get_settings
from mobilizon_reshare.event.event import MobilizonEvent from mobilizon_reshare.event.event import MobilizonEvent
from mobilizon_reshare.models.publication import Publication as PublicationModel
from .exceptions import PublisherError, InvalidAttribute from .exceptions import PublisherError, InvalidAttribute
JINJA_ENV = Environment(loader=FileSystemLoader("/")) JINJA_ENV = Environment(loader=FileSystemLoader("/"))
@ -15,52 +17,7 @@ JINJA_ENV = Environment(loader=FileSystemLoader("/"))
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class AbstractNotifier(ABC): class LoggerMixin:
"""
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
def _log_debug(self, msg, *args, **kwargs): def _log_debug(self, msg, *args, **kwargs):
self.__log(logging.DEBUG, msg, *args, **kwargs) self.__log(logging.DEBUG, msg, *args, **kwargs)
@ -82,6 +39,65 @@ class AbstractNotifier(ABC):
if raise_error is not None: if raise_error is not None:
raise raise_error(msg) 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: def are_credentials_valid(self) -> bool:
try: try:
self.validate_credentials() self.validate_credentials()
@ -98,59 +114,10 @@ class AbstractNotifier(ABC):
""" """
raise NotImplementedError raise NotImplementedError
class AbstractEventFormatter(LoggerMixin, ConfLoaderMixin):
@abstractmethod @abstractmethod
def publish(self) -> None: def validate_event(self, message) -> 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:
""" """
Validates publisher's event. Validates publisher's event.
Should raise ``PublisherError`` (or one of its subclasses) if event Should raise ``PublisherError`` (or one of its subclasses) if event
@ -158,18 +125,18 @@ class AbstractPublisher(AbstractNotifier):
""" """
raise NotImplementedError raise NotImplementedError
def _preprocess_event(self): def _preprocess_event(self, event):
""" """
Allows publishers to preprocess events before feeding them to the template Allows publishers to preprocess events before feeding them to the template
""" """
pass pass
def get_message_from_event(self) -> str: def get_message_from_event(self, event) -> str:
""" """
Retrieves a message from the event itself. Retrieves a message from the event itself.
""" """
self._preprocess_event() event = self._preprocess_event(event)
return self.event.format(self.get_message_template()) return event.format(self.get_message_template())
def get_message_template(self) -> 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 template_path = self.conf.msg_template_path or self.default_template_path
return JINJA_ENV.get_template(template_path) return JINJA_ENV.get_template(template_path)
@abstractmethod def is_message_valid(self, event: MobilizonEvent) -> bool:
def _send(self, message) -> Response: try:
pass self.validate_message(self.get_message_from_event(event))
except PublisherError:
return False
return True
@abstractmethod @abstractmethod
def _validate_response(self, response: Response) -> None: def validate_message(self, message: str) -> None:
pass """
Validates notifier's message.
Should raise ``PublisherError`` (or one of its subclasses) if message
is not valid.
"""
raise NotImplementedError
def send(self, message): def is_event_valid(self, event) -> bool:
res = self._send(message) try:
self._validate_response(res) 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)

View File

@ -1,32 +1,16 @@
import logging import logging
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import List
from uuid import UUID 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.models.publication import PublicationStatus
from mobilizon_reshare.publishers import get_active_notifiers, get_active_publishers from mobilizon_reshare.publishers import get_active_notifiers
from mobilizon_reshare.publishers.abstract import AbstractPublisher from mobilizon_reshare.publishers.abstract import EventPublication
from mobilizon_reshare.publishers.exceptions import PublisherError from mobilizon_reshare.publishers.exceptions import PublisherError
from mobilizon_reshare.publishers.telegram import TelegramPublisher from mobilizon_reshare.publishers.platforms.platform_mapping import get_notifier_class
from mobilizon_reshare.publishers.zulip import ZulipPublisher
logger = logging.getLogger(__name__) 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 @dataclass
class PublicationReport: class PublicationReport:
@ -37,7 +21,7 @@ class PublicationReport:
@dataclass @dataclass
class PublisherCoordinatorReport: class PublisherCoordinatorReport:
publishers: dict[UUID, AbstractPublisher] publications: List[EventPublication]
reports: dict[UUID, PublicationReport] = field(default_factory={}) reports: dict[UUID, PublicationReport] = field(default_factory={})
@property @property
@ -47,118 +31,107 @@ class PublisherCoordinatorReport:
) )
class PublisherCoordinator(BuildPublisherMixin): class PublisherCoordinator:
def __init__(self, event: MobilizonEvent, publications: dict[UUID, Publication]): def __init__(self, publications: List[EventPublication]):
publishers = self.build_publishers(event, get_active_publishers()) self.publications = publications
self.publishers_by_publication_id = {
publication_id: publishers[publication.publisher.name]
for publication_id, publication in publications.items()
}
def run(self) -> PublisherCoordinatorReport: def run(self) -> PublisherCoordinatorReport:
errors = self._validate() errors = self._validate()
if errors: if errors:
return PublisherCoordinatorReport( return PublisherCoordinatorReport(
reports=errors, publishers=self.publishers_by_publication_id reports=errors, publications=self.publications
) )
return self._post() return self._post()
def _make_successful_report(self, failed_ids): def _make_successful_report(self, failed_ids):
return { return {
publication_id: PublicationReport( publication.id: PublicationReport(
status=PublicationStatus.COMPLETED, status=PublicationStatus.COMPLETED,
reason="", reason="",
publication_id=publication_id, publication_id=publication.id,
) )
for publication_id in self.publishers_by_publication_id for publication in self.publications
if publication_id not in failed_ids if publication.id not in failed_ids
} }
def _post(self): def _post(self):
failed_publishers_reports = {} failed_publishers_reports = {}
for publication_id, p in self.publishers_by_publication_id.items():
for publication in self.publications:
try: try:
p.publish() message = publication.formatter.get_message_from_event(
publication.event
)
publication.publisher.send(message)
except PublisherError as e: except PublisherError as e:
failed_publishers_reports[publication_id] = PublicationReport( failed_publishers_reports[publication.id] = PublicationReport(
status=PublicationStatus.FAILED, status=PublicationStatus.FAILED,
reason=str(e), reason=str(e),
publication_id=publication_id, publication_id=publication.id,
) )
reports = failed_publishers_reports | self._make_successful_report( reports = failed_publishers_reports | self._make_successful_report(
failed_publishers_reports.keys() failed_publishers_reports.keys()
) )
return PublisherCoordinatorReport( return PublisherCoordinatorReport(
publishers=self.publishers_by_publication_id, reports=reports publications=self.publications, reports=reports
) )
def _validate(self): def _validate(self):
errors: dict[UUID, PublicationReport] = {} errors: dict[UUID, PublicationReport] = {}
for publication_id, p in self.publishers_by_publication_id.items():
for publication in self.publications:
reason = [] reason = []
if not p.are_credentials_valid(): if not publication.publisher.are_credentials_valid():
reason.append("Invalid credentials") reason.append("Invalid credentials")
if not p.is_event_valid(): if not publication.formatter.is_event_valid(publication.event):
reason.append("Invalid event") reason.append("Invalid event")
if not p.is_message_valid(): if not publication.formatter.is_message_valid(publication.event):
reason.append("Invalid message") reason.append("Invalid message")
if len(reason) > 0: if len(reason) > 0:
errors[publication_id] = PublicationReport( errors[publication.id] = PublicationReport(
status=PublicationStatus.FAILED, status=PublicationStatus.FAILED,
reason=", ".join(reason), reason=", ".join(reason),
publication_id=publication_id, publication_id=publication.id,
) )
return errors 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()
]
def send_to_all(self):
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):
# TODO: failure to notify should fail safely and write to a dedicated log # TODO: failure to notify should fail safely and write to a dedicated log
for notifier in self.notifiers.values(): for notifier in self.notifiers:
notifier.send(message) notifier.send(self.message)
class PublicationFailureNotifiersCoordinator(AbstractNotifiersCoordinator): class PublicationFailureNotifiersCoordinator(AbstractNotifiersCoordinator):
def __init__( def __init__(self, report: PublicationReport, notifiers=None):
self, self.report = report
event: MobilizonEvent, super(PublicationFailureNotifiersCoordinator, self).__init__(
publisher_coordinator_report: PublisherCoordinatorReport, message=self.build_failure_message(), notifiers=notifiers
): )
self.report = publisher_coordinator_report
super(PublicationFailureNotifiersCoordinator, self).__init__(event)
def build_failure_message(self, report: PublicationReport): def build_failure_message(self):
report = self.report
return ( return (
f"Publication {report.publication_id} failed with status: {report.status}.\n" f"Publication {report.publication_id} failed with status: {report.status}.\n"
f"Reason: {report.reason}" f"Reason: {report.reason}"
) )
def notify_failures(self): def notify_failure(self):
for publication_id, report in self.report.reports.items(): logger.info(
f"Sending failure notifications for publication: {self.report.publication_id}"
logger.info( )
f"Sending failure notifications for publication: {publication_id}" if self.report.status == PublicationStatus.FAILED:
) self.send_to_all()
if report.status == PublicationStatus.FAILED:
self.send_to_all(self.build_failure_message(report))

View File

@ -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]

View File

@ -2,8 +2,12 @@ import pkg_resources
import requests import requests
from requests import Response from requests import Response
from mobilizon_reshare.event.event import MobilizonEvent
from mobilizon_reshare.formatting.description import html_to_markdown 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 ( from mobilizon_reshare.publishers.exceptions import (
InvalidBot, InvalidBot,
InvalidCredentials, InvalidCredentials,
@ -12,35 +16,50 @@ from mobilizon_reshare.publishers.exceptions import (
) )
class TelegramPublisher(AbstractPublisher): class TelegramFormatter(AbstractEventFormatter):
"""
Telegram publisher class.
"""
_conf = ("publisher", "telegram")
default_template_path = pkg_resources.resource_filename( default_template_path = pkg_resources.resource_filename(
"mobilizon_reshare.publishers.templates", "telegram.tmpl.j2" "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 = (
message.replace("-", "\\-") message.replace("-", "\\-")
.replace(".", "\\.") .replace(".", "\\.")
.replace("(", "\\(") .replace("(", "\\(")
.replace("!", "\\!")
.replace(")", "\\)") .replace(")", "\\)")
.replace("#", "") .replace("#", "")
) )
return message return message
def _send(self, message: str) -> Response: def validate_event(self, event: MobilizonEvent) -> None:
return requests.post( text = event.description
url=f"https://api.telegram.org/bot{self.conf.token}/sendMessage", if not (text and text.strip()):
json={ self._log_error("No text was found", raise_error=InvalidEvent)
"chat_id": self.conf.chat_id,
"text": self._escape_message(message), def get_message_from_event(self, event: MobilizonEvent) -> str:
"parse_mode": "markdownv2", 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): def validate_credentials(self):
conf = self.conf conf = self.conf
@ -67,13 +86,19 @@ class TelegramPublisher(AbstractPublisher):
"Found a different bot than the expected one", raise_error=InvalidBot, "Found a different bot than the expected one", raise_error=InvalidBot,
) )
def validate_event(self) -> None: def _send(self, message) -> Response:
text = self.event.description return requests.post(
if not (text and text.strip()): url=f"https://api.telegram.org/bot{self.conf.token}/sendMessage",
self._log_error("No text was found", raise_error=InvalidEvent) json={
"chat_id": self.conf.chat_id,
"text": message,
"parse_mode": "markdownv2",
},
)
def _validate_response(self, res): def _validate_response(self, res):
try: try:
res.raise_for_status() res.raise_for_status()
except requests.exceptions.HTTPError as e: except requests.exceptions.HTTPError as e:
self._log_error( self._log_error(
@ -95,10 +120,12 @@ class TelegramPublisher(AbstractPublisher):
return data return data
def validate_message(self) -> None:
# TODO implement
pass
def _preprocess_event(self): class TelegramPublisher(TelegramPlatform):
self.event.description = html_to_markdown(self.event.description)
self.event.name = html_to_markdown(self.event.name) _conf = ("publisher", "telegram")
class TelegramNotifier(TelegramPlatform):
_conf = ("notifier", "telegram")

View File

@ -3,8 +3,12 @@ import requests
from requests import Response from requests import Response
from requests.auth import HTTPBasicAuth from requests.auth import HTTPBasicAuth
from mobilizon_reshare.event.event import MobilizonEvent
from mobilizon_reshare.formatting.description import html_to_markdown 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 ( from mobilizon_reshare.publishers.exceptions import (
InvalidBot, InvalidBot,
InvalidCredentials, 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. Zulip publisher class.
""" """
@ -32,11 +57,7 @@ class ZulipPublisher(AbstractPublisher):
return requests.post( return requests.post(
url=self.api_uri + "messages", url=self.api_uri + "messages",
auth=HTTPBasicAuth(self.conf.bot_email, self.conf.bot_token), auth=HTTPBasicAuth(self.conf.bot_email, self.conf.bot_token),
data={ data={"type": "private", "to": f"[{self.user_id}]", "content": message},
"type": "private",
"to": f"[{self.user_id}]",
"content": message,
},
) )
def _send(self, message: str) -> Response: def _send(self, message: str) -> Response:
@ -68,8 +89,7 @@ class ZulipPublisher(AbstractPublisher):
err.append("bot email") err.append("bot email")
if err: if err:
self._log_error( self._log_error(
", ".join(err) + " is/are missing", ", ".join(err) + " is/are missing", raise_error=InvalidCredentials,
raise_error=InvalidCredentials,
) )
res = requests.get( res = requests.get(
@ -80,8 +100,7 @@ class ZulipPublisher(AbstractPublisher):
if not data["is_bot"]: if not data["is_bot"]:
self._log_error( self._log_error(
"These user is not a bot", "These user is not a bot", raise_error=InvalidBot,
raise_error=InvalidBot,
) )
if not bot_email == data["email"]: if not bot_email == data["email"]:
@ -92,13 +111,7 @@ class ZulipPublisher(AbstractPublisher):
raise_error=InvalidBot, 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: def validate_message(self) -> None:
# We don't need this for Zulip.
pass pass
def _validate_response(self, res) -> dict: def _validate_response(self, res) -> dict:
@ -113,12 +126,17 @@ class ZulipPublisher(AbstractPublisher):
if data["result"] == "error": if data["result"] == "error":
self._log_error( self._log_error(
f"{res.status_code} Error - {data['msg']}", f"{res.status_code} Error - {data['msg']}", raise_error=ZulipError,
raise_error=ZulipError,
) )
return data return data
def _preprocess_event(self):
self.event.description = html_to_markdown(self.event.description) class ZulipPublisher(ZulipPlatform):
self.event.name = html_to_markdown(self.event.name)
_conf = ("publisher", "zulip")
class ZulipNotifier(ZulipPlatform):
_conf = ("notifier", "zulip")

View File

@ -5,13 +5,15 @@ import arrow
import pytest import pytest
from mobilizon_reshare.event.event import MobilizonEvent 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 from mobilizon_reshare.publishers.exceptions import PublisherError, InvalidResponse
@pytest.fixture @pytest.fixture
def test_event(): def test_event():
now = arrow.now() now = arrow.now()
return MobilizonEvent( return MobilizonEvent(
**{ **{
@ -26,66 +28,78 @@ def test_event():
@pytest.fixture @pytest.fixture
def mock_publisher_valid(event): def mock_formatter_valid():
class MockPublisher(AbstractPublisher): class MockFormatter(AbstractEventFormatter):
def validate_event(self) -> None: def validate_event(self, event) -> None:
pass pass
def get_message_from_event(self) -> str: def get_message_from_event(self, event) -> str:
return self.event.description return event.description
def validate_credentials(self) -> None: def validate_message(self, event) -> None:
pass
def validate_message(self) -> None:
pass pass
def _send(self, message): def _send(self, message):
pass pass
def _validate_response(self, response) -> None: return MockFormatter()
pass
return MockPublisher(event)
@pytest.fixture @pytest.fixture
def mock_publisher_invalid(event): def mock_formatter_invalid():
class MockPublisher(AbstractPublisher): class MockFormatter(AbstractEventFormatter):
def validate_event(self) -> None: def validate_event(self, event) -> None:
raise PublisherError("Invalid event") raise PublisherError("Invalid event")
def get_message_from_event(self) -> str: def get_message_from_event(self, event) -> str:
return "" return ""
def validate_credentials(self) -> None: def validate_message(self, event) -> None:
raise PublisherError("Invalid credentials")
def validate_message(self) -> None:
raise PublisherError("Invalid message") raise PublisherError("Invalid message")
def _send(self, message): return MockFormatter()
pass
def _validate_response(self, response) -> None:
pass
return MockPublisher(event)
@pytest.fixture @pytest.fixture
def mock_publisher_invalid_response(mock_publisher_invalid, event): def mock_publisher_valid():
class MockPublisher(type(mock_publisher_invalid)): class MockPublisher(AbstractPlatform):
def validate_event(self) -> None: def _send(self, message):
pass
def _validate_response(self, response):
pass pass
def validate_credentials(self) -> None: def validate_credentials(self) -> None:
pass pass
def validate_message(self) -> None: return MockPublisher()
@pytest.fixture
def mock_publisher_invalid():
class MockPublisher(AbstractPlatform):
def _send(self, message):
pass 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") raise InvalidResponse("Invalid response")
return MockPublisher(event) def validate_credentials(self) -> None:
pass
return MockPublisher()

View File

@ -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()

View File

@ -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)

View File

@ -3,17 +3,19 @@ from uuid import UUID
import pytest import pytest
from asynctest import MagicMock from asynctest import MagicMock
from mobilizon_reshare.config.config import get_settings
from mobilizon_reshare.event.event import MobilizonEvent 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.models.publisher import Publisher
from mobilizon_reshare.publishers.abstract import EventPublication
from mobilizon_reshare.publishers.coordinator import ( from mobilizon_reshare.publishers.coordinator import (
PublisherCoordinatorReport, PublisherCoordinatorReport,
PublicationReport, PublicationReport,
PublisherCoordinator, PublisherCoordinator,
PublicationFailureNotifiersCoordinator, PublicationFailureNotifiersCoordinator,
) )
from mobilizon_reshare.publishers.telegram import TelegramPublisher
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -37,37 +39,38 @@ def test_publication_report_successful(statuses, successful):
@pytest.fixture @pytest.fixture
@pytest.mark.asyncio @pytest.mark.asyncio
async def mock_publication( async def mock_publications(
num_publications: int,
test_event: MobilizonEvent, test_event: MobilizonEvent,
mock_publisher_valid,
mock_formatter_valid,
): ):
event = test_event.to_model() result = []
await event.save() for i in range(num_publications):
publisher = Publisher(name="telegram") event = test_event.to_model()
await publisher.save() await event.save()
publication = Publication( publisher = Publisher(name="telegram")
id=UUID(int=1), await publisher.save()
status=PublicationStatus.WAITING, publication = PublicationModel(
event=event, id=UUID(int=i + 1),
publisher=publisher, status=PublicationStatus.WAITING,
timestamp=None, event=event,
reason=None, publisher=publisher,
) timestamp=None,
await publication.save() reason=None,
return publication )
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 @pytest.mark.asyncio
async def test_coordinator_run_success( async def test_coordinator_run_success(mock_publications,):
test_event, mock_publication, mock_publisher_valid coordinator = PublisherCoordinator(publications=mock_publications,)
):
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,
}
report = coordinator.run() report = coordinator.run()
assert len(report.reports) == 2 assert len(report.reports) == 2
assert report.successful, "\n".join( assert report.successful, "\n".join(
@ -75,14 +78,15 @@ async def test_coordinator_run_success(
) )
@pytest.mark.parametrize("num_publications", [1])
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_coordinator_run_failure( 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}) for pub in mock_publications:
coordinator.publishers_by_publication_id = { pub.publisher = mock_publisher_invalid
UUID(int=1): mock_publisher_invalid, pub.formatter = mock_formatter_invalid
} coordinator = PublisherCoordinator(mock_publications)
report = coordinator.run() report = coordinator.run()
assert len(report.reports) == 1 assert len(report.reports) == 1
@ -93,15 +97,15 @@ async def test_coordinator_run_failure(
) )
@pytest.mark.parametrize("num_publications", [1])
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_coordinator_run_failure_response( 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() report = coordinator.run()
assert len(report.reports) == 1 assert len(report.reports) == 1
assert not report.successful assert not report.successful
@ -109,39 +113,18 @@ async def test_coordinator_run_failure_response(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_notifier_coordinator_publication_failed( async def test_notifier_coordinator_publication_failed(mock_publisher_valid):
test_event, mock_publisher_valid
):
mock_send = MagicMock() mock_send = MagicMock()
mock_publisher_valid._send = mock_send mock_publisher_valid._send = mock_send
report = PublisherCoordinatorReport( report = PublicationReport(
{UUID(int=1): mock_publisher_valid, UUID(int=2): mock_publisher_valid}, status=PublicationStatus.FAILED,
{ reason="some failure",
UUID(int=1): PublicationReport( publication_id=UUID(int=1),
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),
),
},
) )
coordinator = PublicationFailureNotifiersCoordinator(test_event, report) coordinator = PublicationFailureNotifiersCoordinator(
coordinator.notifiers = { report, [mock_publisher_valid, mock_publisher_valid]
UUID(int=1): mock_publisher_valid, )
UUID(int=2): mock_publisher_valid, coordinator.notify_failure()
}
coordinator.notify_failures()
# 4 = 2 reports * 2 notifiers # 4 = 2 reports * 2 notifiers
assert mock_send.call_count == 4 assert mock_send.call_count == 2
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()

View File

@ -1,18 +1,19 @@
from functools import partial
import pytest import pytest
import responses import responses
from mobilizon_reshare.config.config import get_settings from mobilizon_reshare.config.config import get_settings
from mobilizon_reshare.models.publication import PublicationStatus from mobilizon_reshare.models.publication import PublicationStatus
from mobilizon_reshare.publishers import get_active_publishers 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.publishers.coordinator import PublisherCoordinator
from mobilizon_reshare.storage.query import ( from mobilizon_reshare.storage.query import (
get_publishers, get_publishers,
update_publishers, update_publishers,
publications_with_status, publications_with_status,
get_all_events,
) )
api_uri = "https://zulip.twc-italia.org/api/v1/" api_uri = "https://zulip.twc-italia.org/api/v1/"
users_me = { users_me = {
"result": "success", "result": "success",
@ -39,10 +40,7 @@ users_me = {
def mocked_valid_response(): def mocked_valid_response():
with responses.RequestsMock() as rsps: with responses.RequestsMock() as rsps:
rsps.add( rsps.add(
responses.GET, responses.GET, api_uri + "users/me", json=users_me, status=200,
api_uri + "users/me",
json=users_me,
status=200,
) )
rsps.add( rsps.add(
responses.POST, responses.POST,
@ -69,10 +67,7 @@ def mocked_credential_error_response():
def mocked_client_error_response(): def mocked_client_error_response():
with responses.RequestsMock() as rsps: with responses.RequestsMock() as rsps:
rsps.add( rsps.add(
responses.GET, responses.GET, api_uri + "users/me", json=users_me, status=200,
api_uri + "users/me",
json=users_me,
status=200,
) )
rsps.add( rsps.add(
responses.POST, responses.POST,
@ -105,11 +100,17 @@ async def setup_db(event_model_generator, publication_model_generator):
@pytest.mark.asyncio @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( report = PublisherCoordinator(
list(await get_all_events())[0], list(
await publications_with_status(status=PublicationStatus.WAITING), map(
partial(EventPublication.from_orm, event=event),
publication_models.values(),
)
)
).run() ).run()
assert list(report.reports.values())[0].status == PublicationStatus.COMPLETED 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 @pytest.mark.asyncio
async def test_zulip_publishr_failure_invalid_credentials( 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( report = PublisherCoordinator(
list(await get_all_events())[0], list(
await publications_with_status(status=PublicationStatus.WAITING), map(
partial(EventPublication.from_orm, event=event),
publication_models.values(),
)
)
).run() ).run()
assert list(report.reports.values())[0].status == PublicationStatus.FAILED assert list(report.reports.values())[0].status == PublicationStatus.FAILED
assert list(report.reports.values())[0].reason == "Invalid credentials" assert list(report.reports.values())[0].reason == "Invalid credentials"
@ -129,11 +137,18 @@ async def test_zulip_publishr_failure_invalid_credentials(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_zulip_publishr_failure_client_error( 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( report = PublisherCoordinator(
list(await get_all_events())[0], list(
await publications_with_status(status=PublicationStatus.WAITING), map(
partial(EventPublication.from_orm, event=event),
publication_models.values(),
)
)
).run() ).run()
assert list(report.reports.values())[0].status == PublicationStatus.FAILED assert list(report.reports.values())[0].status == PublicationStatus.FAILED
assert list(report.reports.values())[0].reason == "400 Error - Invalid request" assert list(report.reports.values())[0].reason == "400 Error - Invalid request"

View File

@ -48,10 +48,7 @@ two_publishers_specification = {"publisher": ["telegram", "twitter"]}
], ],
) )
async def test_update_publishers( async def test_update_publishers(
specification, specification, names, expected_result, generate_models,
names,
expected_result,
generate_models,
): ):
await generate_models(specification) await generate_models(specification)
await update_publishers(names) await update_publishers(names)
@ -71,6 +68,7 @@ async def test_update_publishers(
[ [
complete_specification, complete_specification,
PublisherCoordinatorReport( PublisherCoordinatorReport(
publications=[],
reports={ reports={
UUID(int=3): PublicationReport( UUID(int=3): PublicationReport(
status=PublicationStatus.FAILED, status=PublicationStatus.FAILED,
@ -83,7 +81,6 @@ async def test_update_publishers(
publication_id=UUID(int=4), publication_id=UUID(int=4),
), ),
}, },
publishers={},
), ),
MobilizonEvent( MobilizonEvent(
name="event_1", name="event_1",
@ -110,17 +107,12 @@ async def test_update_publishers(
], ],
) )
async def test_save_publication_report( async def test_save_publication_report(
specification, specification, report, event, expected_result, generate_models,
report,
event,
expected_result,
generate_models,
): ):
await generate_models(specification) await generate_models(specification)
publications = await publications_with_status( publications = await publications_with_status(
status=PublicationStatus.WAITING, status=PublicationStatus.WAITING, event_mobilizon_id=event.mobilizon_id,
event_mobilizon_id=event.mobilizon_id,
) )
await save_publication_report(report, publications) await save_publication_report(report, publications)
publication_ids = set(report.reports.keys()) publication_ids = set(report.reports.keys())