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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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