Repository: Tech-Workers-Coalition-Italia/mobilizon-reshare (language: python)

Issue:
Single entry point for publishing
We need a single CLI interface to manually (re)publish an event. IMHO it needs to support at least the following three use cases:

- selecting an unpublished event with the configured strategy and publish it to all active platforms or an user provided one
- publishing an user provided event to all active  platforms or an user provided one (this allows for manually retrying)
- retry a specific publication (i.e. create a new publication for a given event, publisher pair)

I'm thinking about something like

```shell
# this should select an event with the current strategy and publish it to all active publishers
mobilizon-reshare publish
# retry a publication
mobilizon-reshare publish --publication 9e4d8dd3-d46f-44e4-8d49-e30bef6a0674
# publish an event to all active publishers
mobilizon-reshare publish --event 9e4d8dd3-d46f-44e4-8d49-e30bef6a0674
# publish an event to an user supplied platform
mobilizon-reshare publish --event 9e4d8dd3-d46f-44e4-8d49-e30bef6a0674 --publisher telegram
```

What do you think?


Discussion / hints:

Fix patch:
diff --git a/channels-lock.scm b/channels-lock.scm
index 520f730e..2558af29 100644
--- a/channels-lock.scm
+++ b/channels-lock.scm
@@ -1,11 +1,12 @@
-(use-modules (guix channels))
+(define-module (channels-lock)
+  #:use-module (guix channels))
 
 (list
  (channel
   (name 'guix)
   (url "https://git.savannah.gnu.org/git/guix.git")
   (commit
-   "72abf9b9b82d2f5f34b7d22b7aae6f2398336a93")
+   "61c8d0fe3dd5415de5617a5251d7ff14dd17a545")
   (introduction
    (make-channel-introduction
     "afb9f2752315f131e4ddd44eba02eed403365085"
diff --git a/guix.scm b/guix.scm
index 75b5293b..e78c5c02 100644
--- a/guix.scm
+++ b/guix.scm
@@ -1,3 +1,4 @@
-(use-modules (docker mobilizon-reshare))
+(define-module (guix)
+  #:use-module (docker mobilizon-reshare))
 
 mobilizon-reshare.git
diff --git a/manifest.scm b/manifest.scm
index 53494773..65b50ad6 100644
--- a/manifest.scm
+++ b/manifest.scm
@@ -7,28 +7,11 @@
   #:use-module (guix profiles)
   #:use-module (srfi srfi-1))
 
-(define channels
-  ;; This is the old revision from which we want to
-  ;; extract docker-compose.
-  (list (channel
-         (name 'guix)
-         (url "https://git.savannah.gnu.org/git/guix.git")
-         (commit
-          "07f55a361e23152b48f34425f116a725cce39e48"))))
-
-(define inferior
-  ;; An inferior representing the above revision.
-  (inferior-for-channels channels))
-
-;; Now create a manifest with the current packages
-;; and the old "docker-compose" package.
 (packages->manifest
   (append
-    ;; docker-compose fails to build on current master, hence this hack.
-    (list (first (lookup-inferior-packages inferior "docker-compose")))
     (map cadr (package-direct-inputs mobilizon-reshare.git))
     (map specification->package+output
        '("git-cal" "man-db" "texinfo"
          "python-pre-commit" "cloc"
          "ripgrep" "python-semver"
-         "fd"))))
+         "fd" "docker-compose"))))
diff --git a/mobilizon_reshare/cli/cli.py b/mobilizon_reshare/cli/cli.py
index d49c078f..1337ff2b 100644
--- a/mobilizon_reshare/cli/cli.py
+++ b/mobilizon_reshare/cli/cli.py
@@ -11,7 +11,7 @@
 from mobilizon_reshare.cli.commands.start.main import start_command as start_main
 from mobilizon_reshare.cli.commands.pull.main import pull_command as pull_main
 from mobilizon_reshare.cli.commands.publish.main import publish_command as publish_main
-from mobilizon_reshare.config.config import current_version
+from mobilizon_reshare.config.config import current_version, get_settings
 from mobilizon_reshare.config.publishers import publisher_names
 from mobilizon_reshare.event.event import EventPublicationStatus
 from mobilizon_reshare.cli.commands.retry.main import (
@@ -19,6 +19,31 @@
     retry_publication_command,
 )
 from mobilizon_reshare.models.publication import PublicationStatus
+from mobilizon_reshare.publishers import get_active_publishers
+
+
+def test_settings(ctx, param, value):
+    if not value or ctx.resilient_parsing:
+        return
+    get_settings()
+    click.echo("OK!")
+    ctx.exit()
+
+
+def print_version(ctx, param, value):
+    if not value or ctx.resilient_parsing:
+        return
+    click.echo(current_version())
+    ctx.exit()
+
+
+def print_platforms(ctx, param, value):
+    if not value or ctx.resilient_parsing:
+        return
+    for platform in get_active_publishers():
+        click.echo(platform)
+    ctx.exit()
+
 
 status_name_to_enum = {
     "event": {
@@ -46,32 +71,70 @@
     "--end",
     type=click.DateTime(),
     expose_value=True,
-    help="Include only events that begin before this datetime.",
+    help="Include only events that end before this datetime.",
 )
-event_status_option = click.argument(
+event_status_argument = click.argument(
     "status",
     type=click.Choice(list(status_name_to_enum["event"].keys())),
     default="all",
     expose_value=True,
 )
-publication_status_option = click.argument(
+publication_status_argument = click.argument(
     "status",
     type=click.Choice(list(status_name_to_enum["publication"].keys())),
     default="all",
     expose_value=True,
 )
-
-
-def print_version(ctx, param, value):
-    if not value or ctx.resilient_parsing:
-        return
-    click.echo(current_version())
-    ctx.exit()
+event_uuid_option = click.option(
+    "-E",
+    "--event",
+    type=click.UUID,
+    expose_value=True,
+    help="Publish the given event.",
+)
+publication_uuid_option = click.option(
+    "-P",
+    "--publication",
+    type=click.UUID,
+    expose_value=True,
+    help="Publish the given publication.",
+)
+platform_name_option = click.option(
+    "-p",
+    "--platform",
+    type=str,
+    expose_value=True,
+    help="Publish to the given platform. This makes sense only for events.",
+)
+list_supported_option = click.option(
+    "--list-platforms",
+    is_flag=True,
+    callback=print_platforms,
+    expose_value=False,
+    is_eager=True,
+    help="Show all active platforms.",
+)
+test_configuration = click.option(
+    "-t",
+    "--test-configuration",
+    is_flag=True,
+    callback=test_settings,
+    expose_value=False,
+    is_eager=True,
+    help="Validate the current configuration.",
+)
 
 
 @click.group()
+@test_configuration
+@list_supported_option
 @click.option(
-    "--version", is_flag=True, callback=print_version, expose_value=False, is_eager=True
+    "--version",
+    is_flag=True,
+    callback=print_version,
+    expose_value=False,
+    is_eager=True,
+    help="Show the current version.",
 )
 @pass_context
 def mobilizon_reshare(obj):
@@ -99,7 +162,8 @@ def recap():
 
 
 @mobilizon_reshare.command(
-    help="Fetch the latest events from Mobilizon and store them."
+    help="Fetch the latest events from Mobilizon, store them if they are unknown, "
+    "update them if they are known and changed."
 )
 def pull():
     safe_execution(
@@ -107,7 +171,13 @@ def pull():
     )
 
 
-@mobilizon_reshare.command(help="Select an event and publish it.")
+@mobilizon_reshare.command(
+    help="Select an event with the current configured strategy"
+    " and publish it to all active platforms."
+)
+@event_uuid_option
+@publication_uuid_option
+@platform_name_option
 def publish():
     safe_execution(
         publish_main,
@@ -125,7 +195,7 @@ def publication():
 
 
 @event.command(help="Query for events in the database.", name="list")
-@event_status_option
+@event_status_argument
 @from_date_option
 @to_date_option
 def event_list(status, begin, end):
@@ -141,7 +211,7 @@ def event_list(status, begin, end):
 
 
 @publication.command(help="Query for publications in the database.", name="list")
-@publication_status_option
+@publication_status_argument
 @from_date_option
 @to_date_option
 def publication_list(status, begin, end):
diff --git a/mobilizon_reshare/cli/commands/publish/main.py b/mobilizon_reshare/cli/commands/publish/main.py
index 6e1be91b..27ad2948 100644
--- a/mobilizon_reshare/cli/commands/publish/main.py
+++ b/mobilizon_reshare/cli/commands/publish/main.py
@@ -1,10 +1,14 @@
-from mobilizon_reshare.main.publish import publish
+import logging
+
+from mobilizon_reshare.main.publish import select_and_publish
+
+logger = logging.getLogger(__name__)
 
 
 async def publish_command():
     """
-    STUB
-    :return:
+    Select an event with the current configured strategy
+    and publish it to all active platforms.
     """
-    reports = await publish()
-    return 0 if reports and reports.successful else 1
+    report = await select_and_publish()
+    return 0 if report and report.successful else 1
diff --git a/mobilizon_reshare/event/event_selection_strategies.py b/mobilizon_reshare/event/event_selection_strategies.py
index 9ad32c83..c50bdaa5 100644
--- a/mobilizon_reshare/event/event_selection_strategies.py
+++ b/mobilizon_reshare/event/event_selection_strategies.py
@@ -93,7 +93,7 @@ def select_unpublished_events(
 
 def select_event_to_publish(
     published_events: List[MobilizonEvent], unpublished_events: List[MobilizonEvent],
-):
+) -> Optional[MobilizonEvent]:
 
     strategy = STRATEGY_NAME_TO_STRATEGY_CLASS[
         get_settings()["selection"]["strategy"]
diff --git a/mobilizon_reshare/main/publish.py b/mobilizon_reshare/main/publish.py
index 1461ab2a..59d5c2a9 100644
--- a/mobilizon_reshare/main/publish.py
+++ b/mobilizon_reshare/main/publish.py
@@ -3,6 +3,8 @@
 
 from mobilizon_reshare.event.event import MobilizonEvent
 from mobilizon_reshare.event.event_selection_strategies import select_event_to_publish
+from mobilizon_reshare.publishers import get_active_publishers
+from mobilizon_reshare.publishers.abstract import EventPublication
 from mobilizon_reshare.publishers.coordinator import (
     PublicationFailureNotifiersCoordinator,
     PublisherCoordinatorReport,
@@ -18,33 +20,49 @@
 logger = logging.getLogger(__name__)
 
 
-async def publish(
-    events: Optional[list[MobilizonEvent]] = None,
+async def publish_publications(
+    publications: list[EventPublication],
+) -> PublisherCoordinatorReport:
+    report = PublisherCoordinator(publications).run()
+
+    await save_publication_report(report)
+    for publication_report in report.reports:
+        if not publication_report.succesful:
+            PublicationFailureNotifiersCoordinator(
+                publication_report,
+            ).notify_failure()
+
+    return report
+
+
+async def publish_event(
+    event: MobilizonEvent, publishers: Optional[list[Optional[str]]] = None
+) -> PublisherCoordinatorReport:
+    logger.info(f"Event to publish found: {event.name}")
+
+    if not (publishers and all(publishers)):
+        publishers = get_active_publishers()
+
+    publications = await build_publications(event, publishers)
+    return await publish_publications(publications)
+
+
+async def select_and_publish(
+    unpublished_events: Optional[list[MobilizonEvent]] = None,
 ) -> Optional[PublisherCoordinatorReport]:
     """
     STUB
     :return:
     """
-    if events is None:
-        events = await events_without_publications()
+    if unpublished_events is None:
+        unpublished_events = await events_without_publications()
 
     event = select_event_to_publish(
         list(await get_published_events()),
-        events,
+        unpublished_events,
     )
 
     if event:
-        logger.info(f"Event to publish found: {event.name}")
-
-        publications = await build_publications(event)
-        reports = PublisherCoordinator(publications).run()
-
-        await save_publication_report(reports)
-        for report in reports.reports:
-            if not report.succesful:
-                PublicationFailureNotifiersCoordinator(
-                    report,
-                ).notify_failure()
-        return reports
+        return await publish_event(event)
     else:
         logger.info("No event to publish found")
diff --git a/mobilizon_reshare/main/retry.py b/mobilizon_reshare/main/retry.py
index 5b98b99e..4fb90a22 100644
--- a/mobilizon_reshare/main/retry.py
+++ b/mobilizon_reshare/main/retry.py
@@ -2,22 +2,20 @@
 from typing import Optional
 from uuid import UUID
 
+from mobilizon_reshare.main.publish import publish_publications
 from mobilizon_reshare.publishers.coordinator import (
-    PublisherCoordinator,
     PublisherCoordinatorReport,
-    PublicationFailureLoggerCoordinator,
 )
 from mobilizon_reshare.storage.query.exceptions import EventNotFound
 from mobilizon_reshare.storage.query.read import (
     get_failed_publications_for_event,
     get_publication,
 )
-from mobilizon_reshare.storage.query.write import save_publication_report
 
 logger = logging.getLogger(__name__)
 
 
-async def retry_event_publications(event_id):
+async def retry_event_publications(event_id) -> Optional[PublisherCoordinatorReport]:
 
     failed_publications = await get_failed_publications_for_event(event_id)
     if not failed_publications:
@@ -25,24 +23,17 @@ async def retry_event_publications(event_id):
         return
 
     logger.info(f"Found {len(failed_publications)} publications.")
-    return PublisherCoordinator(failed_publications).run()
+    return await publish_publications(failed_publications)
 
 
 async def retry_publication(publication_id) -> Optional[PublisherCoordinatorReport]:
-    # TODO test this function
     publication = await get_publication(publication_id)
     if not publication:
         logger.info(f"Publication {publication_id} not found.")
         return
 
     logger.info(f"Publication {publication_id} found.")
-    reports = PublisherCoordinator([publication]).run()
-
-    await save_publication_report(reports)
-
-    for report in reports.reports:
-        if not report.succesful:
-            PublicationFailureLoggerCoordinator(report,).notify_failure()
+    return await publish_publications([publication])
 
 
 async def retry_event(
@@ -53,15 +44,8 @@ async def retry_event(
             "Autonomous retry not implemented yet, please specify an event_id"
         )
     try:
-        reports = await retry_event_publications(mobilizon_event_id)
+        return await retry_event_publications(mobilizon_event_id)
     except EventNotFound as e:
         logger.debug(e, exc_info=True)
         logger.error(f"Event with id {mobilizon_event_id} not found")
         return
-
-    if not reports:
-        return
-    await save_publication_report(reports)
-    for report in reports.reports:
-        if not report.succesful:
-            PublicationFailureLoggerCoordinator(report,).notify_failure()
diff --git a/mobilizon_reshare/main/start.py b/mobilizon_reshare/main/start.py
index c2f3515c..29ada96c 100644
--- a/mobilizon_reshare/main/start.py
+++ b/mobilizon_reshare/main/start.py
@@ -1,7 +1,7 @@
 import logging.config
 from typing import Optional
 
-from mobilizon_reshare.main.publish import publish
+from mobilizon_reshare.main.publish import select_and_publish
 from mobilizon_reshare.main.pull import pull
 from mobilizon_reshare.publishers.coordinator import PublisherCoordinatorReport
 
@@ -14,4 +14,4 @@ async def start() -> Optional[PublisherCoordinatorReport]:
     :return:
     """
     events = await pull()
-    return await publish(events)
+    return await select_and_publish(events)
diff --git a/mobilizon_reshare/publishers/coordinator.py b/mobilizon_reshare/publishers/coordinator.py
index 62396311..baa53ab3 100644
--- a/mobilizon_reshare/publishers/coordinator.py
+++ b/mobilizon_reshare/publishers/coordinator.py
@@ -156,7 +156,7 @@ def send_to_all(self):
 
 
 class AbstractNotifiersCoordinator(ABC):
-    def __init__(self, report, notifiers: List[AbstractPlatform] = None):
+    def __init__(self, report: EventPublicationReport, notifiers: List[AbstractPlatform] = None):
         self.platforms = notifiers or [
             get_notifier_class(notifier)() for notifier in get_active_notifiers()
         ]
diff --git a/mobilizon_reshare/storage/query/read.py b/mobilizon_reshare/storage/query/read.py
index 723cac7a..ecd94744 100644
--- a/mobilizon_reshare/storage/query/read.py
+++ b/mobilizon_reshare/storage/query/read.py
@@ -1,4 +1,3 @@
-import dataclasses
 from functools import partial
 from typing import Iterable, Optional
 from uuid import UUID
@@ -12,7 +11,6 @@
 from mobilizon_reshare.models.event import Event
 from mobilizon_reshare.models.publication import Publication, PublicationStatus
 from mobilizon_reshare.models.publisher import Publisher
-from mobilizon_reshare.publishers import get_active_publishers
 from mobilizon_reshare.publishers.abstract import EventPublication
 from mobilizon_reshare.storage.query import CONNECTION_NAME
 from mobilizon_reshare.storage.query.converter import (
@@ -68,7 +66,7 @@ def _filter_event_with_status(event: Event) -> bool:
 async def get_all_publications(
     from_date: Optional[Arrow] = None,
     to_date: Optional[Arrow] = None,
-) -> Iterable[EventPublication]:
+) -> Iterable[Publication]:
     return await prefetch_publication_relations(
         _add_date_window(Publication.all(), "timestamp", from_date, to_date)
     )
@@ -86,6 +84,10 @@ async def get_all_events(
     ]
 
 
+async def get_all_publishers() -> list[Publisher]:
+    return await Publisher.all()
+
+
 async def prefetch_event_relations(queryset: QuerySet[Event]) -> list[Event]:
     return (
         await queryset.prefetch_related("publications__publisher")
@@ -152,6 +154,10 @@ async def get_event(event_mobilizon_id: UUID) -> Event:
     return events[0]
 
 
+async def get_mobilizon_event(event_mobilizon_id: UUID) -> MobilizonEvent:
+    return event_from_model(await get_event(event_mobilizon_id))
+
+
 async def get_publisher_by_name(name) -> Publisher:
     return await Publisher.filter(name=name).first()
 
@@ -165,13 +171,15 @@ async def is_known(event: MobilizonEvent) -> bool:
 
 
 @atomic(CONNECTION_NAME)
-async def build_publications(event: MobilizonEvent) -> list[EventPublication]:
+async def build_publications(
+    event: MobilizonEvent, publishers: list[str]
+) -> list[EventPublication]:
     event_model = await get_event(event.mobilizon_id)
     models = [
         await event_model.build_publication_by_publisher_name(name)
-        for name in get_active_publishers()
+        for name in publishers
     ]
-    return [publication_from_orm(m, dataclasses.replace(event)) for m in models]
+    return [publication_from_orm(m, event) for m in models]
 
 
 @atomic(CONNECTION_NAME)
@@ -194,7 +202,7 @@ async def get_failed_publications_for_event(
 
 
 @atomic(CONNECTION_NAME)
-async def get_publication(publication_id):
+async def get_publication(publication_id: UUID):
     try:
         publication = await prefetch_publication_relations(
             Publication.get(id=publication_id).first()
diff --git a/poetry.lock b/poetry.lock
index 732651a3..503ca54d 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,6 +1,6 @@
 [[package]]
 name = "aerich"
-version = "0.6.2"
+version = "0.6.3"
 description = "A database migrations tool for Tortoise ORM."
 category = "main"
 optional = false
@@ -8,8 +8,8 @@ python-versions = ">=3.7,<4.0"
 
 [package.dependencies]
 click = "*"
-ddlparse = "*"
 dictdiffer = "*"
+pydantic = "*"
 tomlkit = "*"
 tortoise-orm = "*"
 
@@ -87,11 +87,11 @@ tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>
 
 [[package]]
 name = "babel"
-version = "2.9.1"
+version = "2.10.1"
 description = "Internationalization utilities"
 category = "dev"
 optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+python-versions = ">=3.6"
 
 [package.dependencies]
 pytz = ">=2015.7"
@@ -151,7 +151,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
 
 [[package]]
 name = "coverage"
-version = "6.3.2"
+version = "6.3.3"
 description = "Code coverage measurement for Python"
 category = "dev"
 optional = false
@@ -168,17 +168,6 @@ category = "dev"
 optional = false
 python-versions = ">=3.6"
 
-[[package]]
-name = "ddlparse"
-version = "1.10.0"
-description = "DDL parase and Convert to BigQuery JSON schema"
-category = "main"
-optional = false
-python-versions = "*"
-
-[package.dependencies]
-pyparsing = "*"
-
 [[package]]
 name = "dictdiffer"
 version = "0.9.0"
@@ -203,7 +192,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
 
 [[package]]
 name = "dynaconf"
-version = "3.1.7"
+version = "3.1.8"
 description = "The dynamic configurator for your Python Project"
 category = "main"
 optional = false
@@ -214,6 +203,7 @@ all = ["redis", "ruamel.yaml", "configobj", "hvac"]
 configobj = ["configobj"]
 ini = ["configobj"]
 redis = ["redis"]
+test = ["pytest", "pytest-cov", "pytest-xdist", "pytest-mock", "flake8", "pep8-naming", "flake8-debugger", "flake8-print", "flake8-todo", "radon", "flask (>=0.12)", "django", "python-dotenv", "toml", "codecov", "redis", "hvac", "configobj"]
 toml = ["toml"]
 vault = ["hvac"]
 yaml = ["ruamel.yaml"]
@@ -279,11 +269,11 @@ python-versions = "*"
 
 [[package]]
 name = "jinja2"
-version = "3.0.3"
+version = "3.1.2"
 description = "A very fast and expressive template engine."
 category = "main"
 optional = false
-python-versions = ">=3.6"
+python-versions = ">=3.7"
 
 [package.dependencies]
 MarkupSafe = ">=2.0"
@@ -380,28 +370,43 @@ category = "dev"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
 
+[[package]]
+name = "pydantic"
+version = "1.9.0"
+description = "Data validation and settings management using python 3.6 type hinting"
+category = "main"
+optional = false
+python-versions = ">=3.6.1"
+
+[package.dependencies]
+typing-extensions = ">=3.7.4.3"
+
+[package.extras]
+dotenv = ["python-dotenv (>=0.10.4)"]
+email = ["email-validator (>=1.0.3)"]
+
 [[package]]
 name = "pygments"
-version = "2.11.2"
+version = "2.12.0"
 description = "Pygments is a syntax highlighting package written in Python."
 category = "dev"
 optional = false
-python-versions = ">=3.5"
+python-versions = ">=3.6"
 
 [[package]]
 name = "pyparsing"
-version = "3.0.7"
-description = "Python parsing module"
-category = "main"
+version = "3.0.9"
+description = "pyparsing module - Classes and methods to define and execute parsing grammars"
+category = "dev"
 optional = false
-python-versions = ">=3.6"
+python-versions = ">=3.6.8"
 
 [package.extras]
-diagrams = ["jinja2", "railroad-diagrams"]
+diagrams = ["railroad-diagrams", "jinja2"]
 
 [[package]]
 name = "pypika-tortoise"
-version = "0.1.3"
+version = "0.1.4"
 description = "Forked from pypika and streamline just for tortoise-orm"
 category = "main"
 optional = false
@@ -481,7 +486,7 @@ six = ">=1.5"
 
 [[package]]
 name = "python-slugify"
-version = "6.1.1"
+version = "6.1.2"
 description = "A Python slugify application that also handles Unicode"
 category = "dev"
 optional = false
@@ -569,7 +574,7 @@ python-versions = "*"
 
 [[package]]
 name = "soupsieve"
-version = "2.3.1"
+version = "2.3.2.post1"
 description = "A modern CSS selector implementation for Beautiful Soup."
 category = "main"
 optional = false
@@ -609,7 +614,7 @@ test = ["pytest", "pytest-cov", "html5lib", "cython", "typed-ast"]
 
 [[package]]
 name = "sphinx-autodoc-typehints"
-version = "1.17.0"
+version = "1.17.1"
 description = "Type hints (PEP 484) support for the Sphinx autodoc extension"
 category = "dev"
 optional = false
@@ -619,7 +624,7 @@ python-versions = ">=3.7"
 Sphinx = ">=4"
 
 [package.extras]
-testing = ["covdefaults (>=2)", "coverage (>=6)", "diff-cover (>=6.4)", "nptyping (>=1)", "pytest (>=6)", "pytest-cov (>=3)", "sphobjinv (>=2)", "typing-extensions (>=3.5)"]
+testing = ["covdefaults (>=2)", "coverage (>=6)", "diff-cover (>=6.4)", "nptyping (>=1,<2)", "pytest (>=6)", "pytest-cov (>=3)", "sphobjinv (>=2)", "typing-extensions (>=3.5)"]
 type_comments = ["typed-ast (>=1.4.0)"]
 
 [[package]]
@@ -741,7 +746,7 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
 
 [[package]]
 name = "tomlkit"
-version = "0.10.0"
+version = "0.10.2"
 description = "Style preserving TOML library"
 category = "main"
 optional = false
@@ -787,11 +792,11 @@ test = ["vcrpy (>=1.10.3)"]
 
 [[package]]
 name = "typing-extensions"
-version = "4.1.1"
-description = "Backported and Experimental Type Hints for Python 3.6+"
+version = "4.2.0"
+description = "Backported and Experimental Type Hints for Python 3.7+"
 category = "main"
 optional = false
-python-versions = ">=3.6"
+python-versions = ">=3.7"
 
 [[package]]
 name = "unidecode"
@@ -816,25 +821,25 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
 
 [[package]]
 name = "zipp"
-version = "3.7.0"
+version = "3.8.0"
 description = "Backport of pathlib-compatible object wrapper for zip files"
 category = "dev"
 optional = false
 python-versions = ">=3.7"
 
 [package.extras]
-docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"]
-testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"]
+docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"]
+testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"]
 
 [metadata]
 lock-version = "1.1"
 python-versions = "^3.9"
-content-hash = "19e6c973a5b0b994d22429a27794f96f8c616e13cc8249a9110b20d9e14eec8c"
+content-hash = "93588989100f72f9ee6e064af3ea66d2ae23e57c62acb0a92d414377f57490b9"
 
 [metadata.files]
 aerich = [
-    {file = "aerich-0.6.2-py3-none-any.whl", hash = "sha256:4e150c5549e75e0df1ba38b7e7bff0f92e2ef2b2937782ccc7dc90ea4c3f1f0f"},
-    {file = "aerich-0.6.2.tar.gz", hash = "sha256:784b91f977925b0303a6225db1af3a37c038ac383f6633da926d3bd013c79de4"},
+    {file = "aerich-0.6.3-py3-none-any.whl", hash = "sha256:d45f98214ed54b8ec9be949df264c5b0b9e8b79d8f8ba9e68714675bc81a5574"},
+    {file = "aerich-0.6.3.tar.gz", hash = "sha256:96ac087922048470687264125cb9dfeaade982219c78e6a9b91a0fb67eaa1cd1"},
 ]
 aiosqlite = [
     {file = "aiosqlite-0.17.0-py3-none-any.whl", hash = "sha256:6c49dc6d3405929b1d08eeccc72306d3677503cc5e5e43771efc1e00232e8231"},
@@ -865,8 +870,8 @@ attrs = [
     {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"},
 ]
 babel = [
-    {file = "Babel-2.9.1-py2.py3-none-any.whl", hash = "sha256:ab49e12b91d937cd11f0b67cb259a57ab4ad2b59ac7a3b41d6c06c0ac5b0def9"},
-    {file = "Babel-2.9.1.tar.gz", hash = "sha256:bc0c176f9f6a994582230df350aa6e05ba2ebe4b3ac317eab29d9be5d2768da0"},
+    {file = "Babel-2.10.1-py3-none-any.whl", hash = "sha256:3f349e85ad3154559ac4930c3918247d319f21910d5ce4b25d439ed8693b98d2"},
+    {file = "Babel-2.10.1.tar.gz", hash = "sha256:98aeaca086133efb3e1e2aad0396987490c8425929ddbcfe0550184fdc54cd13"},
 ]
 beautifulsoup4 = [
     {file = "beautifulsoup4-4.10.0-py3-none-any.whl", hash = "sha256:9a315ce70049920ea4572a4055bc4bd700c940521d36fc858205ad4fcde149bf"},
@@ -889,57 +894,53 @@ colorama = [
     {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"},
 ]
 coverage = [
-    {file = "coverage-6.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9b27d894748475fa858f9597c0ee1d4829f44683f3813633aaf94b19cb5453cf"},
-    {file = "coverage-6.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37d1141ad6b2466a7b53a22e08fe76994c2d35a5b6b469590424a9953155afac"},
-    {file = "coverage-6.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9987b0354b06d4df0f4d3e0ec1ae76d7ce7cbca9a2f98c25041eb79eec766f1"},
-    {file = "coverage-6.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:26e2deacd414fc2f97dd9f7676ee3eaecd299ca751412d89f40bc01557a6b1b4"},
-    {file = "coverage-6.3.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4dd8bafa458b5c7d061540f1ee9f18025a68e2d8471b3e858a9dad47c8d41903"},
-    {file = "coverage-6.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:46191097ebc381fbf89bdce207a6c107ac4ec0890d8d20f3360345ff5976155c"},
-    {file = "coverage-6.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6f89d05e028d274ce4fa1a86887b071ae1755082ef94a6740238cd7a8178804f"},
-    {file = "coverage-6.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:58303469e9a272b4abdb9e302a780072c0633cdcc0165db7eec0f9e32f901e05"},
-    {file = "coverage-6.3.2-cp310-cp310-win32.whl", hash = "sha256:2fea046bfb455510e05be95e879f0e768d45c10c11509e20e06d8fcaa31d9e39"},
-    {file = "coverage-6.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:a2a8b8bcc399edb4347a5ca8b9b87e7524c0967b335fbb08a83c8421489ddee1"},
-    {file = "coverage-6.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:f1555ea6d6da108e1999b2463ea1003fe03f29213e459145e70edbaf3e004aaa"},
-    {file = "coverage-6.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5f4e1edcf57ce94e5475fe09e5afa3e3145081318e5fd1a43a6b4539a97e518"},
-    {file = "coverage-6.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a15dc0a14008f1da3d1ebd44bdda3e357dbabdf5a0b5034d38fcde0b5c234b7"},
-    {file = "coverage-6.3.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21b7745788866028adeb1e0eca3bf1101109e2dc58456cb49d2d9b99a8c516e6"},
-    {file = "coverage-6.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:8ce257cac556cb03be4a248d92ed36904a59a4a5ff55a994e92214cde15c5bad"},
-    {file = "coverage-6.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b0be84e5a6209858a1d3e8d1806c46214e867ce1b0fd32e4ea03f4bd8b2e3359"},
-    {file = "coverage-6.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:acf53bc2cf7282ab9b8ba346746afe703474004d9e566ad164c91a7a59f188a4"},
-    {file = "coverage-6.3.2-cp37-cp37m-win32.whl", hash = "sha256:8bdde1177f2311ee552f47ae6e5aa7750c0e3291ca6b75f71f7ffe1f1dab3dca"},
-    {file = "coverage-6.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:b31651d018b23ec463e95cf10070d0b2c548aa950a03d0b559eaa11c7e5a6fa3"},
-    {file = "coverage-6.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:07e6db90cd9686c767dcc593dff16c8c09f9814f5e9c51034066cad3373b914d"},
-    {file = "coverage-6.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2c6dbb42f3ad25760010c45191e9757e7dce981cbfb90e42feef301d71540059"},
-    {file = "coverage-6.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c76aeef1b95aff3905fb2ae2d96e319caca5b76fa41d3470b19d4e4a3a313512"},
-    {file = "coverage-6.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cf5cfcb1521dc3255d845d9dca3ff204b3229401994ef8d1984b32746bb45ca"},
-    {file = "coverage-6.3.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8fbbdc8d55990eac1b0919ca69eb5a988a802b854488c34b8f37f3e2025fa90d"},
-    {file = "coverage-6.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ec6bc7fe73a938933d4178c9b23c4e0568e43e220aef9472c4f6044bfc6dd0f0"},
-    {file = "coverage-6.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:9baff2a45ae1f17c8078452e9e5962e518eab705e50a0aa8083733ea7d45f3a6"},
-    {file = "coverage-6.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fd9e830e9d8d89b20ab1e5af09b32d33e1a08ef4c4e14411e559556fd788e6b2"},
-    {file = "coverage-6.3.2-cp38-cp38-win32.whl", hash = "sha256:f7331dbf301b7289013175087636bbaf5b2405e57259dd2c42fdcc9fcc47325e"},
-    {file = "coverage-6.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:68353fe7cdf91f109fc7d474461b46e7f1f14e533e911a2a2cbb8b0fc8613cf1"},
-    {file = "coverage-6.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b78e5afb39941572209f71866aa0b206c12f0109835aa0d601e41552f9b3e620"},
-    {file = "coverage-6.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4e21876082ed887baed0146fe222f861b5815455ada3b33b890f4105d806128d"},
-    {file = "coverage-6.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34626a7eee2a3da12af0507780bb51eb52dca0e1751fd1471d0810539cefb536"},
-    {file = "coverage-6.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1ebf730d2381158ecf3dfd4453fbca0613e16eaa547b4170e2450c9707665ce7"},
-    {file = "coverage-6.3.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd6fe30bd519694b356cbfcaca9bd5c1737cddd20778c6a581ae20dc8c04def2"},
-    {file = "coverage-6.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:96f8a1cb43ca1422f36492bebe63312d396491a9165ed3b9231e778d43a7fca4"},
-    {file = "coverage-6.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:dd035edafefee4d573140a76fdc785dc38829fe5a455c4bb12bac8c20cfc3d69"},
-    {file = "coverage-6.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5ca5aeb4344b30d0bec47481536b8ba1181d50dbe783b0e4ad03c95dc1296684"},
-    {file = "coverage-6.3.2-cp39-cp39-win32.whl", hash = "sha256:f5fa5803f47e095d7ad8443d28b01d48c0359484fec1b9d8606d0e3282084bc4"},
-    {file = "coverage-6.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:9548f10d8be799551eb3a9c74bbf2b4934ddb330e08a73320123c07f95cc2d92"},
-    {file = "coverage-6.3.2-pp36.pp37.pp38-none-any.whl", hash = "sha256:18d520c6860515a771708937d2f78f63cc47ab3b80cb78e86573b0a760161faf"},
-    {file = "coverage-6.3.2.tar.gz", hash = "sha256:03e2a7826086b91ef345ff18742ee9fc47a6839ccd517061ef8fa1976e652ce9"},
+    {file = "coverage-6.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df32ee0f4935a101e4b9a5f07b617d884a531ed5666671ff6ac66d2e8e8246d8"},
+    {file = "coverage-6.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:75b5dbffc334e0beb4f6c503fb95e6d422770fd2d1b40a64898ea26d6c02742d"},
+    {file = "coverage-6.3.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:114944e6061b68a801c5da5427b9173a0dd9d32cd5fcc18a13de90352843737d"},
+    {file = "coverage-6.3.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2ab88a01cd180b5640ccc9c47232e31924d5f9967ab7edd7e5c91c68eee47a69"},
+    {file = "coverage-6.3.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad8f9068f5972a46d50fe5f32c09d6ee11da69c560fcb1b4c3baea246ca4109b"},
+    {file = "coverage-6.3.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4cd696aa712e6cd16898d63cf66139dc70d998f8121ab558f0e1936396dbc579"},
+    {file = "coverage-6.3.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c1a9942e282cc9d3ed522cd3e3cab081149b27ea3bda72d6f61f84eaf88c1a63"},
+    {file = "coverage-6.3.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c06455121a089252b5943ea682187a4e0a5cf0a3fb980eb8e7ce394b144430a9"},
+    {file = "coverage-6.3.3-cp310-cp310-win32.whl", hash = "sha256:cb5311d6ccbd22578c80028c5e292a7ab9adb91bd62c1982087fad75abe2e63d"},
+    {file = "coverage-6.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:6d4a6f30f611e657495cc81a07ff7aa8cd949144e7667c5d3e680d73ba7a70e4"},
+    {file = "coverage-6.3.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:79bf405432428e989cad7b8bc60581963238f7645ae8a404f5dce90236cc0293"},
+    {file = "coverage-6.3.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:338c417613f15596af9eb7a39353b60abec9d8ce1080aedba5ecee6a5d85f8d3"},
+    {file = "coverage-6.3.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db094a6a4ae6329ed322a8973f83630b12715654c197dd392410400a5bfa1a73"},
+    {file = "coverage-6.3.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1414e8b124611bf4df8d77215bd32cba6e3425da8ce9c1f1046149615e3a9a31"},
+    {file = "coverage-6.3.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:93b16b08f94c92cab88073ffd185070cdcb29f1b98df8b28e6649145b7f2c90d"},
+    {file = "coverage-6.3.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:fbc86ae8cc129c801e7baaafe3addf3c8d49c9c1597c44bdf2d78139707c3c62"},
+    {file = "coverage-6.3.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:b5ba058610e8289a07db2a57bce45a1793ec0d3d11db28c047aae2aa1a832572"},
+    {file = "coverage-6.3.3-cp37-cp37m-win32.whl", hash = "sha256:8329635c0781927a2c6ae068461e19674c564e05b86736ab8eb29c420ee7dc20"},
+    {file = "coverage-6.3.3-cp37-cp37m-win_amd64.whl", hash = "sha256:e5af1feee71099ae2e3b086ec04f57f9950e1be9ecf6c420696fea7977b84738"},
+    {file = "coverage-6.3.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e814a4a5a1d95223b08cdb0f4f57029e8eab22ffdbae2f97107aeef28554517e"},
+    {file = "coverage-6.3.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:61f4fbf3633cb0713437291b8848634ea97f89c7e849c2be17a665611e433f53"},
+    {file = "coverage-6.3.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3401b0d2ed9f726fadbfa35102e00d1b3547b73772a1de5508ef3bdbcb36afe7"},
+    {file = "coverage-6.3.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8586b177b4407f988731eb7f41967415b2197f35e2a6ee1a9b9b561f6323c8e9"},
+    {file = "coverage-6.3.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:892e7fe32191960da559a14536768a62e83e87bbb867e1b9c643e7e0fbce2579"},
+    {file = "coverage-6.3.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:afb03f981fadb5aed1ac6e3dd34f0488e1a0875623d557b6fad09b97a942b38a"},
+    {file = "coverage-6.3.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:cbe91bc84be4e5ef0b1480d15c7b18e29c73bdfa33e07d3725da7d18e1b0aff2"},
+    {file = "coverage-6.3.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:91502bf27cbd5c83c95cfea291ef387469f2387508645602e1ca0fd8a4ba7548"},
+    {file = "coverage-6.3.3-cp38-cp38-win32.whl", hash = "sha256:c488db059848702aff30aa1d90ef87928d4e72e4f00717343800546fdbff0a94"},
+    {file = "coverage-6.3.3-cp38-cp38-win_amd64.whl", hash = "sha256:ceb6534fcdfb5c503affb6b1130db7b5bfc8a0f77fa34880146f7a5c117987d0"},
+    {file = "coverage-6.3.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cc692c9ee18f0dd3214843779ba6b275ee4bb9b9a5745ba64265bce911aefd1a"},
+    {file = "coverage-6.3.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:462105283de203df8de58a68c1bb4ba2a8a164097c2379f664fa81d6baf94b81"},
+    {file = "coverage-6.3.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc972d829ad5ef4d4c5fcabd2bbe2add84ce8236f64ba1c0c72185da3a273130"},
+    {file = "coverage-6.3.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:06f54765cdbce99901871d50fe9f41d58213f18e98b170a30ca34f47de7dd5e8"},
+    {file = "coverage-6.3.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7835f76a081787f0ca62a53504361b3869840a1620049b56d803a8cb3a9eeea3"},
+    {file = "coverage-6.3.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:6f5fee77ec3384b934797f1873758f796dfb4f167e1296dc00f8b2e023ce6ee9"},
+    {file = "coverage-6.3.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:baa8be8aba3dd1e976e68677be68a960a633a6d44c325757aefaa4d66175050f"},
+    {file = "coverage-6.3.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4d06380e777dd6b35ee936f333d55b53dc4a8271036ff884c909cf6e94be8b6c"},
+    {file = "coverage-6.3.3-cp39-cp39-win32.whl", hash = "sha256:f8cabc5fd0091976ab7b020f5708335033e422de25e20ddf9416bdce2b7e07d8"},
+    {file = "coverage-6.3.3-cp39-cp39-win_amd64.whl", hash = "sha256:9c9441d57b0963cf8340268ad62fc83de61f1613034b79c2b1053046af0c5284"},
+    {file = "coverage-6.3.3-pp36.pp37.pp38-none-any.whl", hash = "sha256:d522f1dc49127eab0bfbba4e90fa068ecff0899bbf61bf4065c790ddd6c177fe"},
+    {file = "coverage-6.3.3.tar.gz", hash = "sha256:2781c43bffbbec2b8867376d4d61916f5e9c4cc168232528562a61d1b4b01879"},
 ]
 css-html-js-minify = [
     {file = "css-html-js-minify-2.5.5.zip", hash = "sha256:4a9f11f7e0496f5284d12111f3ba4ff5ff2023d12f15d195c9c48bd97013746c"},
     {file = "css_html_js_minify-2.5.5-py2.py3-none-any.whl", hash = "sha256:3da9d35ac0db8ca648c1b543e0e801d7ca0bab9e6bfd8418fee59d5ae001727a"},
     {file = "css_html_js_minify-2.5.5-py3.6.egg", hash = "sha256:4704e04a0cd6dd56d61bbfa3bfffc630da6b2284be33519be0b456672e2a2438"},
 ]
-ddlparse = [
-    {file = "ddlparse-1.10.0-py3-none-any.whl", hash = "sha256:71761b3457c8720853af3aeef266e2da1b6edef50936969492d586d7046a2ac2"},
-    {file = "ddlparse-1.10.0.tar.gz", hash = "sha256:6418681baa848eb01251ab79eb3d0ad7e140e6ab1deaae5a019353ddb3a908da"},
-]
 dictdiffer = [
     {file = "dictdiffer-0.9.0-py2.py3-none-any.whl", hash = "sha256:442bfc693cfcadaf46674575d2eba1c53b42f5e404218ca2c2ff549f2df56595"},
     {file = "dictdiffer-0.9.0.tar.gz", hash = "sha256:17bacf5fbfe613ccf1b6d512bd766e6b21fb798822a133aa86098b8ac9997578"},
@@ -949,8 +950,8 @@ docutils = [
     {file = "docutils-0.17.1.tar.gz", hash = "sha256:686577d2e4c32380bb50cbb22f575ed742d58168cee37e99117a854bcd88f125"},
 ]
 dynaconf = [
-    {file = "dynaconf-3.1.7-py2.py3-none-any.whl", hash = "sha256:f52fe5db7622da56a552275e8f64e4df46e3b4ae11158831b042e8ba2f6d1c96"},
-    {file = "dynaconf-3.1.7.tar.gz", hash = "sha256:e9d80b46ba4d9372f2f40c812594c963f74178140c0b596e57f2881001fc4d35"},
+    {file = "dynaconf-3.1.8-py2.py3-none-any.whl", hash = "sha256:dea41800cf4eef488f49d3b5d1ff6305b85c3c21538f4dcfc39ab34d29606d28"},
+    {file = "dynaconf-3.1.8.tar.gz", hash = "sha256:d141a6664fca3648d2d8e84440966af9f58c4f4201ca78353a3f595a67c19ab4"},
 ]
 facebook-sdk = [
     {file = "facebook-sdk-3.1.0.tar.gz", hash = "sha256:cabcd2e69ea3d9f042919c99b353df7aa1e2be86d040121f6e9f5e63c1cf0f8d"},
@@ -977,8 +978,8 @@ iso8601 = [
     {file = "iso8601-0.1.16.tar.gz", hash = "sha256:36532f77cc800594e8f16641edae7f1baf7932f05d8e508545b95fc53c6dc85b"},
 ]
 jinja2 = [
-    {file = "Jinja2-3.0.3-py3-none-any.whl", hash = "sha256:077ce6014f7b40d03b47d1f1ca4b0fc8328a692bd284016f806ed0eaca390ad8"},
-    {file = "Jinja2-3.0.3.tar.gz", hash = "sha256:611bb273cd68f3b993fabdc4064fc858c5b47a973cb5aa7999ec1ba405c87cd7"},
+    {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"},
+    {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"},
 ]
 lxml = [
     {file = "lxml-4.8.0-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:e1ab2fac607842ac36864e358c42feb0960ae62c34aa4caaf12ada0a1fb5d99b"},
@@ -1109,17 +1110,54 @@ py = [
     {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"},
     {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"},
 ]
+pydantic = [
+    {file = "pydantic-1.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cb23bcc093697cdea2708baae4f9ba0e972960a835af22560f6ae4e7e47d33f5"},
+    {file = "pydantic-1.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1d5278bd9f0eee04a44c712982343103bba63507480bfd2fc2790fa70cd64cf4"},
+    {file = "pydantic-1.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab624700dc145aa809e6f3ec93fb8e7d0f99d9023b713f6a953637429b437d37"},
+    {file = "pydantic-1.9.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c8d7da6f1c1049eefb718d43d99ad73100c958a5367d30b9321b092771e96c25"},
+    {file = "pydantic-1.9.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3c3b035103bd4e2e4a28da9da7ef2fa47b00ee4a9cf4f1a735214c1bcd05e0f6"},
+    {file = "pydantic-1.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3011b975c973819883842c5ab925a4e4298dffccf7782c55ec3580ed17dc464c"},
+    {file = "pydantic-1.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:086254884d10d3ba16da0588604ffdc5aab3f7f09557b998373e885c690dd398"},
+    {file = "pydantic-1.9.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:0fe476769acaa7fcddd17cadd172b156b53546ec3614a4d880e5d29ea5fbce65"},
+    {file = "pydantic-1.9.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8e9dcf1ac499679aceedac7e7ca6d8641f0193c591a2d090282aaf8e9445a46"},
+    {file = "pydantic-1.9.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d1e4c28f30e767fd07f2ddc6f74f41f034d1dd6bc526cd59e63a82fe8bb9ef4c"},
+    {file = "pydantic-1.9.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:c86229333cabaaa8c51cf971496f10318c4734cf7b641f08af0a6fbf17ca3054"},
+    {file = "pydantic-1.9.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:c0727bda6e38144d464daec31dff936a82917f431d9c39c39c60a26567eae3ed"},
+    {file = "pydantic-1.9.0-cp36-cp36m-win_amd64.whl", hash = "sha256:dee5ef83a76ac31ab0c78c10bd7d5437bfdb6358c95b91f1ba7ff7b76f9996a1"},
+    {file = "pydantic-1.9.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d9c9bdb3af48e242838f9f6e6127de9be7063aad17b32215ccc36a09c5cf1070"},
+    {file = "pydantic-1.9.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ee7e3209db1e468341ef41fe263eb655f67f5c5a76c924044314e139a1103a2"},
+    {file = "pydantic-1.9.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0b6037175234850ffd094ca77bf60fb54b08b5b22bc85865331dd3bda7a02fa1"},
+    {file = "pydantic-1.9.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b2571db88c636d862b35090ccf92bf24004393f85c8870a37f42d9f23d13e032"},
+    {file = "pydantic-1.9.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8b5ac0f1c83d31b324e57a273da59197c83d1bb18171e512908fe5dc7278a1d6"},
+    {file = "pydantic-1.9.0-cp37-cp37m-win_amd64.whl", hash = "sha256:bbbc94d0c94dd80b3340fc4f04fd4d701f4b038ebad72c39693c794fd3bc2d9d"},
+    {file = "pydantic-1.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e0896200b6a40197405af18828da49f067c2fa1f821491bc8f5bde241ef3f7d7"},
+    {file = "pydantic-1.9.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7bdfdadb5994b44bd5579cfa7c9b0e1b0e540c952d56f627eb227851cda9db77"},
+    {file = "pydantic-1.9.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:574936363cd4b9eed8acdd6b80d0143162f2eb654d96cb3a8ee91d3e64bf4cf9"},
+    {file = "pydantic-1.9.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c556695b699f648c58373b542534308922c46a1cda06ea47bc9ca45ef5b39ae6"},
+    {file = "pydantic-1.9.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:f947352c3434e8b937e3aa8f96f47bdfe6d92779e44bb3f41e4c213ba6a32145"},
+    {file = "pydantic-1.9.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5e48ef4a8b8c066c4a31409d91d7ca372a774d0212da2787c0d32f8045b1e034"},
+    {file = "pydantic-1.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:96f240bce182ca7fe045c76bcebfa0b0534a1bf402ed05914a6f1dadff91877f"},
+    {file = "pydantic-1.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:815ddebb2792efd4bba5488bc8fde09c29e8ca3227d27cf1c6990fc830fd292b"},
+    {file = "pydantic-1.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6c5b77947b9e85a54848343928b597b4f74fc364b70926b3c4441ff52620640c"},
+    {file = "pydantic-1.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c68c3bc88dbda2a6805e9a142ce84782d3930f8fdd9655430d8576315ad97ce"},
+    {file = "pydantic-1.9.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a79330f8571faf71bf93667d3ee054609816f10a259a109a0738dac983b23c3"},
+    {file = "pydantic-1.9.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f5a64b64ddf4c99fe201ac2724daada8595ada0d102ab96d019c1555c2d6441d"},
+    {file = "pydantic-1.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a733965f1a2b4090a5238d40d983dcd78f3ecea221c7af1497b845a9709c1721"},
+    {file = "pydantic-1.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:2cc6a4cb8a118ffec2ca5fcb47afbacb4f16d0ab8b7350ddea5e8ef7bcc53a16"},
+    {file = "pydantic-1.9.0-py3-none-any.whl", hash = "sha256:085ca1de245782e9b46cefcf99deecc67d418737a1fd3f6a4f511344b613a5b3"},
+    {file = "pydantic-1.9.0.tar.gz", hash = "sha256:742645059757a56ecd886faf4ed2441b9c0cd406079c2b4bee51bcc3fbcd510a"},
+]
 pygments = [
-    {file = "Pygments-2.11.2-py3-none-any.whl", hash = "sha256:44238f1b60a76d78fc8ca0528ee429702aae011c265fe6a8dd8b63049ae41c65"},
-    {file = "Pygments-2.11.2.tar.gz", hash = "sha256:4e426f72023d88d03b2fa258de560726ce890ff3b630f88c21cbb8b2503b8c6a"},
+    {file = "Pygments-2.12.0-py3-none-any.whl", hash = "sha256:dc9c10fb40944260f6ed4c688ece0cd2048414940f1cea51b8b226318411c519"},
+    {file = "Pygments-2.12.0.tar.gz", hash = "sha256:5eb116118f9612ff1ee89ac96437bb6b49e8f04d8a13b514ba26f620208e26eb"},
 ]
 pyparsing = [
-    {file = "pyparsing-3.0.7-py3-none-any.whl", hash = "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"},
-    {file = "pyparsing-3.0.7.tar.gz", hash = "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea"},
+    {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"},
+    {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"},
 ]
 pypika-tortoise = [
-    {file = "pypika-tortoise-0.1.3.tar.gz", hash = "sha256:ecdf2d6e0aeb0e15880d9e2ead41362ec7320f37fb25a3a71664c2e1105ad218"},
-    {file = "pypika_tortoise-0.1.3-py3-none-any.whl", hash = "sha256:28fb2715a94ff2f3bc1c4ef6cc46c385c244c27d100aac760231bf612361d5ba"},
+    {file = "pypika-tortoise-0.1.4.tar.gz", hash = "sha256:f4b4e6c72fc13f49058181928e66930b59fee988823c8fd01498f95e2ff788ca"},
+    {file = "pypika_tortoise-0.1.4-py3-none-any.whl", hash = "sha256:bdc7e63c88791679a8b3f0b008582b6d3fcd21830650d274db35a7012e7b8bcd"},
 ]
 pytest = [
     {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"},
@@ -1142,8 +1180,8 @@ python-dateutil = [
     {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"},
 ]
 python-slugify = [
-    {file = "python-slugify-6.1.1.tar.gz", hash = "sha256:00003397f4e31414e922ce567b3a4da28cf1436a53d332c9aeeb51c7d8c469fd"},
-    {file = "python_slugify-6.1.1-py2.py3-none-any.whl", hash = "sha256:8c0016b2d74503eb64761821612d58fcfc729493634b1eb0575d8f5b4aa1fbcf"},
+    {file = "python-slugify-6.1.2.tar.gz", hash = "sha256:272d106cb31ab99b3496ba085e3fea0e9e76dcde967b5e9992500d1f785ce4e1"},
+    {file = "python_slugify-6.1.2-py2.py3-none-any.whl", hash = "sha256:7b2c274c308b62f4269a9ba701aa69a797e9bca41aeee5b3a9e79e36b6656927"},
 ]
 pytz = [
     {file = "pytz-2022.1-py2.py3-none-any.whl", hash = "sha256:e68985985296d9a66a881eb3193b0906246245294a881e7c8afe623866ac6a5c"},
@@ -1170,16 +1208,16 @@ snowballstemmer = [
     {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"},
 ]
 soupsieve = [
-    {file = "soupsieve-2.3.1-py3-none-any.whl", hash = "sha256:1a3cca2617c6b38c0343ed661b1fa5de5637f257d4fe22bd9f1338010a1efefb"},
-    {file = "soupsieve-2.3.1.tar.gz", hash = "sha256:b8d49b1cd4f037c7082a9683dfa1801aa2597fb11c3a1155b7a5b94829b4f1f9"},
+    {file = "soupsieve-2.3.2.post1-py3-none-any.whl", hash = "sha256:3b2503d3c7084a42b1ebd08116e5f81aadfaea95863628c80a3b774a11b7c759"},
+    {file = "soupsieve-2.3.2.post1.tar.gz", hash = "sha256:fc53893b3da2c33de295667a0e19f078c14bf86544af307354de5fcf12a3f30d"},
 ]
 sphinx = [
     {file = "Sphinx-4.4.0-py3-none-any.whl", hash = "sha256:5da895959511473857b6d0200f56865ed62c31e8f82dd338063b84ec022701fe"},
     {file = "Sphinx-4.4.0.tar.gz", hash = "sha256:6caad9786055cb1fa22b4a365c1775816b876f91966481765d7d50e9f0dd35cc"},
 ]
 sphinx-autodoc-typehints = [
-    {file = "sphinx_autodoc_typehints-1.17.0-py3-none-any.whl", hash = "sha256:081daf53077b4ae1c28347d6d858e13e63aefe3b4aacef79fd717dd60687b470"},
-    {file = "sphinx_autodoc_typehints-1.17.0.tar.gz", hash = "sha256:51c7b3f5cb9ccd15d0b52088c62df3094f1abd9612930340365c26def8629a14"},
+    {file = "sphinx_autodoc_typehints-1.17.1-py3-none-any.whl", hash = "sha256:f16491cad05a13f4825ecdf9ee4ff02925d9a3b1cf103d4d02f2f81802cce653"},
+    {file = "sphinx_autodoc_typehints-1.17.1.tar.gz", hash = "sha256:844d7237d3f6280b0416f5375d9556cfd84df1945356fcc34b82e8aaacab40f3"},
 ]
 sphinx-material = [
     {file = "sphinx_material-0.0.35-py3-none-any.whl", hash = "sha256:a62a0a48d4c32edc260f9bdbca658e7d149beb10e1d338848b0076bb13be0775"},
@@ -1222,8 +1260,8 @@ toml = [
     {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
 ]
 tomlkit = [
-    {file = "tomlkit-0.10.0-py3-none-any.whl", hash = "sha256:cac4aeaff42f18fef6e07831c2c2689a51df76cf2ede07a6a4fa5fcb83558870"},
-    {file = "tomlkit-0.10.0.tar.gz", hash = "sha256:d99946c6aed3387c98b89d91fb9edff8f901bf9255901081266a84fb5604adcd"},
+    {file = "tomlkit-0.10.2-py3-none-any.whl", hash = "sha256:905cf92c2111ef80d355708f47ac24ad1b6fc2adc5107455940088c9bbecaedb"},
+    {file = "tomlkit-0.10.2.tar.gz", hash = "sha256:30d54c0b914e595f3d10a87888599eab5321a2a69abc773bbefff51599b72db6"},
 ]
 tortoise-orm = [
     {file = "tortoise-orm-0.18.1.tar.gz", hash = "sha256:537361ce2d0829741afd43afd9bc9413a314a176cb58747d88047c20ccc01db1"},
@@ -1234,8 +1272,8 @@ tweepy = [
     {file = "tweepy-4.4.0.tar.gz", hash = "sha256:8d4b4520271b796fa7efc4c5d5ef3228af4d79f6a4d3ace3900b2778ed8f6f1c"},
 ]
 typing-extensions = [
-    {file = "typing_extensions-4.1.1-py3-none-any.whl", hash = "sha256:21c85e0fe4b9a155d0799430b0ad741cdce7e359660ccbd8b530613e8df88ce2"},
-    {file = "typing_extensions-4.1.1.tar.gz", hash = "sha256:1a9462dcc3347a79b1f1c0271fbe79e844580bb598bafa1ed208b94da3cdcd42"},
+    {file = "typing_extensions-4.2.0-py3-none-any.whl", hash = "sha256:6657594ee297170d19f67d55c05852a874e7eb634f4f753dbd667855e07c1708"},
+    {file = "typing_extensions-4.2.0.tar.gz", hash = "sha256:f1c24655a0da0d1b67f07e17a5e6b2a105894e6824b92096378bb3668ef02376"},
 ]
 unidecode = [
     {file = "Unidecode-1.3.4-py3-none-any.whl", hash = "sha256:afa04efcdd818a93237574791be9b2817d7077c25a068b00f8cff7baa4e59257"},
@@ -1246,6 +1284,6 @@ urllib3 = [
     {file = "urllib3-1.26.9.tar.gz", hash = "sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e"},
 ]
 zipp = [
-    {file = "zipp-3.7.0-py3-none-any.whl", hash = "sha256:b47250dd24f92b7dd6a0a8fc5244da14608f3ca90a5efcd37a3b1642fac9a375"},
-    {file = "zipp-3.7.0.tar.gz", hash = "sha256:9f50f446828eb9d45b267433fd3e9da8d801f614129124863f9c51ebceafb87d"},
+    {file = "zipp-3.8.0-py3-none-any.whl", hash = "sha256:c4f6e5bbf48e74f7a38e7cc5b0480ff42b0ae5178957d564d18932525d5cf099"},
+    {file = "zipp-3.8.0.tar.gz", hash = "sha256:56bf8aadb83c24db6c4b577e13de374ccfb67da2078beba1d037c17980bf43ad"},
 ]
diff --git a/pyproject.toml b/pyproject.toml
index 32485d5c..4e2d53ec 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -13,7 +13,7 @@ python = "^3.9"
 dynaconf = "~3.1"
 tortoise-orm = "~0.18"
 aiosqlite = "~0.17"
-Jinja2 = "~3.0"
+Jinja2 = "~3.1"
 requests = "~2.26"
 arrow = "~1.1"
 click = "~8.0"
diff --git a/scripts/generate_completion_scripts.sh b/scripts/generate_completion_scripts.sh
index 272753e8..dd6f2df2 100755
--- a/scripts/generate_completion_scripts.sh
+++ b/scripts/generate_completion_scripts.sh
@@ -10,3 +10,5 @@ scripts/mobilizon-reshare.sh > etc/fish/completions/mobilizon-reshare.fish
 
 export _MOBILIZON_RESHARE_COMPLETE=zsh_source
 scripts/mobilizon-reshare.sh > etc/zsh/mobilizon-reshare-completion.zsh
+
+exit 0
\ No newline at end of file


Test patch:
diff --git a/tests/commands/conftest.py b/tests/commands/conftest.py
index f592964a..4d8d5af3 100644
--- a/tests/commands/conftest.py
+++ b/tests/commands/conftest.py
@@ -111,7 +111,7 @@ def _mock_format_class(name):
     )
 
     monkeypatch.setattr(
-        mobilizon_reshare.storage.query.read, "get_active_publishers", _mock_active_pub
+        mobilizon_reshare.main.publish, "get_active_publishers", _mock_active_pub
     )
 
     monkeypatch.setattr(
diff --git a/tests/commands/test_publish.py b/tests/commands/test_publish.py
new file mode 100644
index 00000000..e6278cfb
--- /dev/null
+++ b/tests/commands/test_publish.py
@@ -0,0 +1,110 @@
+from logging import DEBUG
+
+import pytest
+
+from mobilizon_reshare.main.publish import select_and_publish, publish_event
+from mobilizon_reshare.storage.query.converter import event_from_model
+from mobilizon_reshare.event.event import EventPublicationStatus
+from mobilizon_reshare.models.event import Event
+from mobilizon_reshare.models.publication import PublicationStatus
+from mobilizon_reshare.storage.query.read import get_all_publications
+from tests.conftest import event_0, event_1
+
+one_unpublished_event_specification = {
+    "event": 1,
+    "publisher": ["telegram", "twitter", "mastodon", "zulip"],
+}
+three_event_specification = {
+    "event": 3,
+    "publications": [
+        {"event_idx": 0, "publisher_idx": 0, "status": PublicationStatus.COMPLETED}
+    ],
+    "publisher": ["telegram", "twitter", "mastodon", "zulip"],
+}
+
+
+@pytest.mark.asyncio
+async def test_publish_no_event(caplog):
+    with caplog.at_level(DEBUG):
+        assert await select_and_publish() is None
+        assert "No event to publish found" in caplog.text
+
+
+@pytest.mark.parametrize(
+    "publisher_class", [pytest.lazy_fixture("mock_publisher_class")]
+)
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "specification,expected_event",
+    [
+        [one_unpublished_event_specification, event_0],
+        [three_event_specification, event_1],
+    ],
+)
+async def test_select_and_publish_new_event(
+    generate_models,
+    caplog,
+    mock_publisher_config,
+    message_collector,
+    specification,
+    expected_event,
+):
+    await generate_models(specification)
+    with caplog.at_level(DEBUG):
+        # calling the publish command without arguments
+        assert await select_and_publish() is not None
+
+        assert "Event to publish found" in caplog.text
+        assert message_collector == [
+            f"{expected_event.name}|{expected_event.description}",
+        ]
+
+        event = (
+            await Event.filter(mobilizon_id=expected_event.mobilizon_id)
+            .prefetch_related("publications")
+            .prefetch_related("publications__publisher")
+        )[0]
+
+        # it should create a publication for each publisher
+        publications = event.publications
+        assert len(publications) == 1, publications
+
+        # all the publications for the first event should be saved as COMPLETED
+        for p in publications:
+            assert p.status == PublicationStatus.COMPLETED
+
+        # the derived status for the event should be COMPLETED
+        assert event_from_model(event).status == EventPublicationStatus.COMPLETED
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "publisher_class,publishers,expected",
+    [
+        [pytest.lazy_fixture("mock_publisher_class"), None, {"mock"}],
+        [pytest.lazy_fixture("mock_publisher_class"), [], {"mock"}],
+        [pytest.lazy_fixture("mock_publisher_class"), [None, None], {"mock"}],
+        [pytest.lazy_fixture("mock_zulip_publisher_class"), ["zulip"], {"zulip"}],
+    ],
+)
+async def test_publish_event(
+    generate_models,
+    caplog,
+    mock_publisher_config,
+    message_collector,
+    publishers,
+    expected,
+):
+    await generate_models(one_unpublished_event_specification)
+    with caplog.at_level(DEBUG):
+        # calling mobilizon-reshare publish -E <UUID> -p <platform>
+        report = await publish_event(event_0, publishers)
+        assert report is not None
+        assert report.successful
+
+        # We test whether we published only to the expected platforms
+        assert {pub.publication.publisher.name for pub in report.reports} == expected
+        publications = list(await get_all_publications())
+        assert len(publications) == len(expected)
+        assert all(p.status == PublicationStatus.COMPLETED for p in publications)
+        assert {p.publisher.name for p in publications} == expected
diff --git a/tests/conftest.py b/tests/conftest.py
index 15429e52..6166e1a9 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -174,7 +174,9 @@ def _event_model_generator(
 
 @pytest.fixture()
 def publisher_model_generator():
-    def _publisher_model_generator(idx=1,):
+    def _publisher_model_generator(
+        idx=1,
+    ):
         return Publisher(name=f"publisher_{idx}", account_ref=f"account_ref_{idx}")
 
     return _publisher_model_generator
@@ -377,6 +379,23 @@ def validate_credentials(self) -> None:
     return MockPublisher
 
 
+@pytest.fixture
+def mock_zulip_publisher_class(message_collector):
+    class MockPublisher(AbstractPlatform):
+        name = "zulip"
+
+        def _send(self, message, event):
+            message_collector.append(message)
+
+        def _validate_response(self, response):
+            pass
+
+        def validate_credentials(self) -> None:
+            pass
+
+    return MockPublisher
+
+
 @pytest.fixture
 def mock_publisher_valid(message_collector, mock_publisher_class):
 
@@ -394,7 +413,10 @@ def mock_mobilizon_success_answer(mobilizon_answer, mobilizon_url):
     with responses.RequestsMock() as rsps:
 
         rsps.add(
-            responses.POST, mobilizon_url, json=mobilizon_answer, status=200,
+            responses.POST,
+            mobilizon_url,
+            json=mobilizon_answer,
+            status=200,
         )
         yield
 
@@ -406,7 +428,10 @@ def mock_multiple_success_answer(multiple_answers, mobilizon_url):
 
         for answer in multiple_answers:
             rsps.add(
-                responses.POST, mobilizon_url, json=answer, status=200,
+                responses.POST,
+                mobilizon_url,
+                json=answer,
+                status=200,
             )
 
         yield
diff --git a/tests/publishers/conftest.py b/tests/publishers/conftest.py
index 0db76cbb..eb1abdea 100644
--- a/tests/publishers/conftest.py
+++ b/tests/publishers/conftest.py
@@ -5,9 +5,7 @@
 import arrow
 import pytest
 
-import mobilizon_reshare.storage.query.read
 from mobilizon_reshare.event.event import MobilizonEvent
-from mobilizon_reshare.models.publisher import Publisher
 from mobilizon_reshare.publishers.abstract import (
     AbstractPlatform,
     AbstractEventFormatter,
@@ -80,18 +78,3 @@ def validate_credentials(self) -> None:
             pass
 
     return MockPublisher()
-
-
-@pytest.fixture()
-async def mock_active_publishers_config(monkeypatch):
-    publisher = Publisher(name="zulip")
-    await publisher.save()
-
-    def _mock_active_pub():
-        return ["zulip"]
-
-    monkeypatch.setattr(
-        mobilizon_reshare.storage.query.read, "get_active_publishers", _mock_active_pub
-    )
-
-    return publisher
diff --git a/tests/publishers/test_zulip.py b/tests/publishers/test_zulip.py
index 62857be1..6037952a 100644
--- a/tests/publishers/test_zulip.py
+++ b/tests/publishers/test_zulip.py
@@ -4,7 +4,6 @@
 
 from mobilizon_reshare.config.config import get_settings
 from mobilizon_reshare.models.publication import PublicationStatus
-from mobilizon_reshare.models.publisher import Publisher
 from mobilizon_reshare.publishers.coordinator import PublisherCoordinator
 from mobilizon_reshare.publishers.exceptions import (
     InvalidEvent,
@@ -14,8 +13,15 @@
 )
 from mobilizon_reshare.publishers.platforms.zulip import ZulipFormatter, ZulipPublisher
 from mobilizon_reshare.storage.query.converter import event_to_model
-from mobilizon_reshare.storage.query.read import build_publications
-
+from mobilizon_reshare.storage.query.read import build_publications, get_all_publishers
+
+one_publication_specification = {
+    "event": 1,
+    "publications": [
+        {"event_idx": 0, "publisher_idx": 0, "status": PublicationStatus.COMPLETED},
+    ],
+    "publisher": ["zulip"],
+}
 api_uri = "https://zulip.twc-italia.org/api/v1/"
 users_me = {
     "result": "success",
@@ -83,7 +89,7 @@ def mocked_client_error_response():
 @pytest.fixture
 @pytest.mark.asyncio
 async def setup_db(
-    mock_active_publishers_config, event_model_generator, publication_model_generator
+    generate_models
 ):
     settings = get_settings()
     settings["publisher"]["zulip"][
@@ -91,20 +97,15 @@ async def setup_db(
     ] = "giacomotest2-bot@zulip.twc-italia.org"
     settings["publisher"]["zulip"]["instance"] = "https://zulip.twc-italia.org"
 
-    publisher = await Publisher.filter(name="zulip").first()
-    event = event_model_generator()
-    await event.save()
-    publication = publication_model_generator(
-        event_id=event.id, publisher_id=publisher.id
-    )
-    await publication.save()
+    await generate_models(one_publication_specification)
 
 
 @pytest.fixture
 @pytest.mark.asyncio
-async def unsaved_publications(event):
+async def unsaved_publications(setup_db, event):
     await event_to_model(event).save()
-    return await build_publications(event)
+    publishers = [p.name for p in await get_all_publishers()]
+    return await build_publications(event, publishers)
 
 
 @pytest.mark.asyncio
diff --git a/tests/storage/conftest.py b/tests/storage/conftest.py
index 18d942ec..02039d10 100644
--- a/tests/storage/conftest.py
+++ b/tests/storage/conftest.py
@@ -1,6 +1,5 @@
 import pytest
 
-import mobilizon_reshare.storage.query.read
 from mobilizon_reshare.models.publisher import Publisher
 
 
@@ -9,11 +8,4 @@ async def mock_active_publishers(request, monkeypatch):
     for name in request.param:
         await Publisher.create(name=name)
 
-    def _mock_active_pub():
-        return request.param
-
-    monkeypatch.setattr(
-        mobilizon_reshare.storage.query.read, "get_active_publishers", _mock_active_pub
-    )
-
     return request.param
diff --git a/tests/storage/test_query.py b/tests/storage/test_query.py
index 369dbc33..e04e8e95 100644
--- a/tests/storage/test_query.py
+++ b/tests/storage/test_query.py
@@ -180,7 +180,7 @@ async def test_build_publications(
 ):
     await generate_models(spec)
 
-    publications = list(await build_publications(event))
+    publications = list(await build_publications(event, mock_active_publishers))
 
     assert len(publications) == n_publications