[misc] Migrate to TortoiseORM

This commit is contained in:
Thomas Lindner 2023-03-19 19:03:25 +01:00
parent c468543fa5
commit 65a4211bcd
39 changed files with 653 additions and 632 deletions

View file

@ -26,16 +26,15 @@ install_requires =
fastapi fastapi
httpx httpx
hypercorn hypercorn
ormantic @ https://github.com/dl6tom/ormantic/tarball/master#egg=ormantic-0.0.32 Mastodon.py
passlib passlib
peony-twitter[all] peony-twitter[all]
pydantic[email]
pynacl pynacl
python-multipart python-multipart
pytoml pytoml
requests requests
scrypt tortoise-orm
Mastodon.py
pydantic[email]
[options.packages.find] [options.packages.find]
where = src where = src
@ -54,19 +53,19 @@ skip_install = True
deps = deps =
black black
flake8 flake8
mypy
types-requests
commands = commands =
black --check --diff src tests black --check --diff src tests
flake8 src tests flake8 src tests
# not yet
#mypy --ignore-missing-imports src tests
[testenv] [testenv]
deps = deps =
mypy
pytest pytest
pytest-asyncio pytest-asyncio
types-requests
commands = commands =
# not yet
#mypy --ignore-missing-imports src tests
pytest tests pytest tests
[flake8] [flake8]

View file

@ -4,17 +4,13 @@
# #
# SPDX-License-Identifier: 0BSD # SPDX-License-Identifier: 0BSD
from nacl.secret import SecretBox
from nacl.utils import random
"""Default configuration. """Default configuration.
The default configuration gets overwritten by a configuration file if one exists. The default configuration gets overwritten by a configuration file if one exists.
""" """
config = { config = {
"database_connection": "sqlite:////tmp/kibicara.sqlite", "database_connection": "sqlite://:memory:",
"frontend_url": "http://localhost:4200", # url of frontend, change in prod "frontend_url": "http://localhost:4200", # url of frontend, change in prod
"secret": random(SecretBox.KEY_SIZE).hex(), # generate with: openssl rand -hex 32
# production params # production params
"frontend_path": None, # required, path to frontend html/css/js files "frontend_path": None, # required, path to frontend html/css/js files
"production": True, "production": True,

View file

@ -16,9 +16,9 @@ from fastapi.staticfiles import StaticFiles
from hypercorn.asyncio import serve from hypercorn.asyncio import serve
from hypercorn.config import Config from hypercorn.config import Config
from pytoml import load from pytoml import load
from tortoise import Tortoise
from kibicara.config import config from kibicara.config import config
from kibicara.model import Mapping
from kibicara.platformapi import Spawner from kibicara.platformapi import Spawner
from kibicara.webapi import router from kibicara.webapi import router
@ -66,12 +66,26 @@ class Main:
format="%(asctime)s %(name)s %(message)s", format="%(asctime)s %(name)s %(message)s",
) )
getLogger("aiosqlite").setLevel(WARNING) getLogger("aiosqlite").setLevel(WARNING)
Mapping.create_all()
asyncio_run(self.__run()) asyncio_run(self.__run())
async def __run(self): async def __run(self):
await Tortoise.init(
db_url=config["database_connection"],
modules={
"models": [
"kibicara.model",
"kibicara.platforms.email.model",
"kibicara.platforms.mastodon.model",
"kibicara.platforms.telegram.model",
"kibicara.platforms.test.model",
"kibicara.platforms.twitter.model",
]
},
)
await Tortoise.generate_schemas()
await Spawner.init_all() await Spawner.init_all()
await self.__start_webserver() await self.__start_webserver()
await Tortoise.close_connections()
async def __start_webserver(self): async def __start_webserver(self):
class SinglePageApplication(StaticFiles): class SinglePageApplication(StaticFiles):

View file

@ -1,4 +1,4 @@
# Copyright (C) 2020 by Thomas Lindner <tom@dl6tom.de> # Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de> # Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org> # Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
# #
@ -6,69 +6,52 @@
"""ORM Models for core.""" """ORM Models for core."""
from databases import Database from tortoise import fields
from ormantic import Boolean, ForeignKey, Integer, Model, Text from tortoise.models import Model
from sqlalchemy import MetaData, create_engine
from kibicara.config import config
class Mapping:
database = Database(config["database_connection"])
metadata = MetaData()
@classmethod
def create_all(cls):
engine = create_engine(str(cls.database.url))
cls.metadata.create_all(engine)
@classmethod
def drop_all(cls):
engine = create_engine(str(cls.database.url))
cls.metadata.drop_all(engine)
class Admin(Model): class Admin(Model):
id: Integer(primary_key=True) = None id = fields.IntField(pk=True)
email: Text(unique=True) email = fields.CharField(64, unique=True)
passhash: Text() passhash = fields.TextField()
hoods: fields.ManyToManyRelation["Hood"] = fields.ManyToManyField(
"models.Hood", related_name="admins", through="admin_hood_relations"
)
class Mapping(Mapping): class Meta:
table_name = "admins" table = "admins"
class Hood(Model): class Hood(Model):
id: Integer(primary_key=True) = None id = fields.IntField(pk=True)
name: Text(unique=True) name = fields.CharField(64, unique=True)
landingpage: Text() landingpage = fields.TextField()
email_enabled: Boolean() = True email_enabled = fields.BooleanField(default=True)
admins: fields.ManyToManyRelation[Admin]
include_patterns: fields.ReverseRelation["IncludePattern"]
exclude_patterns: fields.ReverseRelation["ExcludePattern"]
class Mapping(Mapping): class Meta:
table_name = "hoods" table = "hoods"
class AdminHoodRelation(Model): class IncludePattern(Model):
id: Integer(primary_key=True) = None id = fields.IntField(pk=True)
admin: ForeignKey(Admin) hood: fields.ForeignKeyRelation[Hood] = fields.ForeignKeyField(
hood: ForeignKey(Hood) "models.Hood", related_name="include_patterns"
)
pattern = fields.TextField()
class Mapping(Mapping): class Meta:
table_name = "admin_hood_relations" table = "include_patterns"
class Trigger(Model): class ExcludePattern(Model):
id: Integer(primary_key=True) = None id = fields.IntField(pk=True)
hood: ForeignKey(Hood) hood: fields.ForeignKeyRelation[Hood] = fields.ForeignKeyField(
pattern: Text() "models.Hood", related_name="exclude_patterns"
)
pattern = fields.TextField()
class Mapping(Mapping): class Meta:
table_name = "triggers" table = "exclude_patterns"
class BadWord(Model):
id: Integer(primary_key=True) = None
hood: ForeignKey(Hood)
pattern: Text()
class Mapping(Mapping):
table_name = "badwords"

View file

@ -6,12 +6,15 @@
"""API classes for implementing bots for platforms.""" """API classes for implementing bots for platforms."""
from asyncio import Queue, create_task from asyncio import Queue, Task, create_task
from enum import Enum, auto from enum import Enum, auto
from logging import getLogger from logging import getLogger
from re import IGNORECASE, search from re import IGNORECASE, search
from typing import Generic, Optional, Type, TypeVar
from kibicara.model import BadWord, Trigger from tortoise.models import Model
from kibicara.model import ExcludePattern, Hood, IncludePattern
logger = getLogger(__name__) logger = getLogger(__name__)
@ -29,7 +32,7 @@ class Message:
**kwargs (object, optional): Other platform-specific data. **kwargs (object, optional): Other platform-specific data.
""" """
def __init__(self, text: str, **kwargs): def __init__(self, text: str, **kwargs) -> None:
self.text = text self.text = text
self.__dict__.update(kwargs) self.__dict__.update(kwargs)
@ -73,11 +76,11 @@ class Censor:
__instances: dict[int, list["Censor"]] = {} __instances: dict[int, list["Censor"]] = {}
def __init__(self, hood): def __init__(self, hood: Hood) -> None:
self.hood = hood self.hood = hood
self.enabled = True self.enabled = True
self._inbox = Queue() self._inbox: Queue[Message] = Queue()
self.__task = None self.__task: Optional[Task[None]] = None
self.__hood_censors = self.__instances.setdefault(hood.id, []) self.__hood_censors = self.__instances.setdefault(hood.id, [])
self.__hood_censors.append(self) self.__hood_censors.append(self)
self.status = BotStatus.INSTANTIATED self.status = BotStatus.INSTANTIATED
@ -93,7 +96,8 @@ class Censor:
self.__task.cancel() self.__task.cancel()
async def __run(self) -> None: async def __run(self) -> None:
await self.hood.load() assert self.__task is not None
await self.hood.refresh_from_db()
self.__task.set_name("{0} {1}".format(self.__class__.__name__, self.hood.name)) self.__task.set_name("{0} {1}".format(self.__class__.__name__, self.hood.name))
try: try:
self.status = BotStatus.RUNNING self.status = BotStatus.RUNNING
@ -112,7 +116,7 @@ class Censor:
pass pass
@classmethod @classmethod
async def destroy_hood(cls, hood) -> None: async def destroy_hood(cls, hood: Hood) -> None:
"""Remove all of its database entries. """Remove all of its database entries.
Note: Override this in the derived bot class. Note: Override this in the derived bot class.
@ -140,25 +144,29 @@ class Censor:
return await self._inbox.get() return await self._inbox.get()
async def __is_appropriate(self, message: Message) -> bool: async def __is_appropriate(self, message: Message) -> bool:
for badword in await BadWord.objects.filter(hood=self.hood).all(): for exclude in await ExcludePattern.filter(hood=self.hood):
if search(badword.pattern, message.text, IGNORECASE): if search(exclude.pattern, message.text, IGNORECASE):
logger.debug( logger.info(
"Matched bad word - dropped message: {0}".format(message.text) "Matched bad word - dropped message: {0}".format(message.text)
) )
return False return False
for trigger in await Trigger.objects.filter(hood=self.hood).all(): for include in await IncludePattern.filter(hood=self.hood):
if search(trigger.pattern, message.text, IGNORECASE): if search(include.pattern, message.text, IGNORECASE):
logger.debug( logger.info(
"Matched trigger - passed message: {0}".format(message.text) "Matched trigger - passed message: {0}".format(message.text)
) )
return True return True
logger.debug( logger.info(
"Did not match any trigger - dropped message: {0}".format(message.text) "Did not match any trigger - dropped message: {0}".format(message.text)
) )
return False return False
class Spawner: ORMClass = TypeVar("ORMClass", bound=Model)
BotClass = TypeVar("BotClass", bound=Censor)
class Spawner(Generic[ORMClass, BotClass]):
"""Spawns a bot with a specific bot model. """Spawns a bot with a specific bot model.
Examples: Examples:
@ -179,10 +187,10 @@ class Spawner:
__instances: list["Spawner"] = [] __instances: list["Spawner"] = []
def __init__(self, ORMClass, BotClass): def __init__(self, orm_class: Type[ORMClass], bot_class: Type[BotClass]) -> None:
self.ORMClass = ORMClass self.ORMClass = orm_class
self.BotClass = BotClass self.BotClass = bot_class
self.__bots = {} self.__bots: dict[int, BotClass] = {}
self.__instances.append(self) self.__instances.append(self)
@classmethod @classmethod
@ -192,7 +200,7 @@ class Spawner:
await spawner._init() await spawner._init()
@classmethod @classmethod
async def destroy_hood(cls, hood) -> None: async def destroy_hood(cls, hood: Hood) -> None:
for spawner in cls.__instances: for spawner in cls.__instances:
for pk in list(spawner.__bots): for pk in list(spawner.__bots):
bot = spawner.__bots[pk] bot = spawner.__bots[pk]
@ -202,15 +210,15 @@ class Spawner:
await spawner.BotClass.destroy_hood(hood) await spawner.BotClass.destroy_hood(hood)
async def _init(self) -> None: async def _init(self) -> None:
for item in await self.ORMClass.objects.all(): async for item in self.ORMClass.all():
self.start(item) self.start(item)
def start(self, item) -> None: def start(self, item: ORMClass) -> None:
"""Instantiate and start a bot with the provided ORM object. """Instantiate and start a bot with the provided ORM object.
Example: Example:
``` ```
xyz = await XYZ.objects.create(hood=hood, **values.__dict__) xyz = await XYZ.create(hood=hood, **values.__dict__)
spawner.start(xyz) spawner.start(xyz)
``` ```
@ -221,7 +229,7 @@ class Spawner:
if bot.enabled: if bot.enabled:
bot.start() bot.start()
def stop(self, item) -> None: def stop(self, item: ORMClass) -> None:
"""Stop and delete a bot. """Stop and delete a bot.
Args: Args:
@ -231,10 +239,10 @@ class Spawner:
if bot is not None: if bot is not None:
bot.stop() bot.stop()
def get(self, item) -> Censor: def get(self, item: ORMClass) -> BotClass:
"""Get a running bot. """Get a running bot.
Args: Args:
item (ORM Model object): ORM object corresponding to bot. item (ORM Model object): ORM object corresponding to bot.
""" """
return self.__bots.get(item.pk) return self.__bots[item.pk]

View file

@ -12,7 +12,7 @@ from kibicara import email
from kibicara.config import config from kibicara.config import config
from kibicara.model import Hood from kibicara.model import Hood
from kibicara.platformapi import Censor, Spawner from kibicara.platformapi import Censor, Spawner
from kibicara.platforms.email.model import Email, EmailSubscribers from kibicara.platforms.email.model import Email, EmailSubscriber
from kibicara.webapi.admin import to_token from kibicara.webapi.admin import to_token
logger = getLogger(__name__) logger = getLogger(__name__)
@ -26,9 +26,9 @@ class EmailBot(Censor):
@classmethod @classmethod
async def destroy_hood(cls, hood): async def destroy_hood(cls, hood):
"""Removes all its database entries.""" """Removes all its database entries."""
for inbox in await Email.objects.filter(hood=hood).all(): for inbox in await Email.filter(hood=hood).all():
await inbox.delete() await inbox.delete()
for subscriber in await EmailSubscribers.objects.filter(hood=hood).all(): for subscriber in await EmailSubscriber.filter(hood=hood).all():
await subscriber.delete() await subscriber.delete()
async def run(self): async def run(self):
@ -40,9 +40,7 @@ class EmailBot(Censor):
self.hood.name, message.text self.hood.name, message.text
) )
) )
for subscriber in await EmailSubscribers.objects.filter( for subscriber in await EmailSubscriber.filter(hood=self.hood).all():
hood=self.hood
).all():
token = to_token(email=subscriber.email, hood=self.hood.id) token = to_token(email=subscriber.email, hood=self.hood.id)
body = ( body = (
"{0}\n\n--\n" "{0}\n\n--\n"

View file

@ -20,7 +20,7 @@ from pytoml import load
from requests import post from requests import post
from kibicara.config import config from kibicara.config import config
from kibicara.platforms.email.model import Email, EmailSubscribers from kibicara.platforms.email.model import Email, EmailSubscriber
logger = getLogger(__name__) logger = getLogger(__name__)
@ -53,7 +53,7 @@ class Main:
async def __run(self, email_name): async def __run(self, email_name):
try: try:
email = await Email.objects.get(name=email_name) email = await Email.get(name=email_name)
except NoMatch: except NoMatch:
logger.error("No recipient with this name") logger.error("No recipient with this name")
exit(1) exit(1)
@ -75,7 +75,7 @@ class Main:
logger.error("Could not parse sender") logger.error("Could not parse sender")
exit(1) exit(1)
maybe_subscriber = await EmailSubscribers.objects.filter(email=sender).all() maybe_subscriber = await EmailSubscriber.filter(email=sender).all()
if len(maybe_subscriber) != 1 or maybe_subscriber[0].hood.id != email.hood.id: if len(maybe_subscriber) != 1 or maybe_subscriber[0].hood.id != email.hood.id:
logger.error("Not a subscriber") logger.error("Not a subscriber")
exit(1) exit(1)

View file

@ -1,33 +1,38 @@
# Copyright (C) 2020 by Maike <maike@systemli.org> # Copyright (C) 2020 by Maike <maike@systemli.org>
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de> # Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Thomas Lindner <tom@dl6tom.de> # Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org> # Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
# #
# SPDX-License-Identifier: 0BSD # SPDX-License-Identifier: 0BSD
from ormantic import ForeignKey, Integer, Model, Text from tortoise import fields
from tortoise.models import Model
from kibicara.model import Hood, Mapping from kibicara.model import Hood
class Email(Model): class Email(Model):
"""This table is used to track the names. It also stores the token secret.""" """This table is used to track the names. It also stores the token secret."""
id: Integer(primary_key=True) = None id = fields.IntField(pk=True)
hood: ForeignKey(Hood) hood: fields.ForeignKeyRelation[Hood] = fields.ForeignKeyField(
name: Text(unique=True) "models.Hood", related_name="platforms_email", unique=True
secret: Text() )
name = fields.CharField(32, unique=True)
secret = fields.TextField()
class Mapping(Mapping): class Meta:
table_name = "email" table = "platforms_email"
class EmailSubscribers(Model): class EmailSubscriber(Model):
"""This table stores all subscribers, who want to receive messages via email.""" """This table stores all subscribers, who want to receive messages via email."""
id: Integer(primary_key=True) = None id = fields.IntField(pk=True)
hood: ForeignKey(Hood) hood: fields.ForeignKeyRelation[Hood] = fields.ForeignKeyField(
email: Text(unique=True) "models.Hood", related_name="platforms_email_subscribers"
)
email = fields.CharField(64, unique=True)
class Mapping(Mapping): class Meta:
table_name = "email_subscribers" table = "platforms_email_subscribers"

View file

@ -1,6 +1,6 @@
# Copyright (C) 2020 by Maike <maike@systemli.org> # Copyright (C) 2020 by Maike <maike@systemli.org>
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de> # Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Thomas Lindner <tom@dl6tom.de> # Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org> # Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
# #
# SPDX-License-Identifier: 0BSD # SPDX-License-Identifier: 0BSD
@ -8,18 +8,18 @@
from logging import getLogger from logging import getLogger
from os import urandom from os import urandom
from smtplib import SMTPException from smtplib import SMTPException
from sqlite3 import IntegrityError
from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi import APIRouter, Depends, HTTPException, Response, status
from nacl import exceptions from nacl import exceptions
from ormantic.exceptions import NoMatch
from pydantic import BaseModel, validator from pydantic import BaseModel, validator
from tortoise.exceptions import DoesNotExist, IntegrityError
from kibicara import email from kibicara import email
from kibicara.config import config from kibicara.config import config
from kibicara.model import Hood
from kibicara.platformapi import Message from kibicara.platformapi import Message
from kibicara.platforms.email.bot import spawner from kibicara.platforms.email.bot import spawner
from kibicara.platforms.email.model import Email, EmailSubscribers from kibicara.platforms.email.model import Email, EmailSubscriber
from kibicara.webapi.admin import from_token, to_token from kibicara.webapi.admin import from_token, to_token
from kibicara.webapi.hoods import get_hood, get_hood_unauthorized from kibicara.webapi.hoods import get_hood, get_hood_unauthorized
@ -53,7 +53,7 @@ class BodySubscriber(BaseModel):
email: str email: str
async def get_email(email_id: int, hood=Depends(get_hood)): async def get_email(email_id: int, hood: Hood = Depends(get_hood)):
"""Get Email row by hood. """Get Email row by hood.
You can specify an email_id to nail it down, but it works without as well. You can specify an email_id to nail it down, but it works without as well.
@ -62,16 +62,16 @@ async def get_email(email_id: int, hood=Depends(get_hood)):
:return: Email row of the found email bot. :return: Email row of the found email bot.
""" """
try: try:
return await Email.objects.get(id=email_id, hood=hood) return await Email.get(id=email_id, hood=hood)
except NoMatch: except DoesNotExist:
return HTTPException(status_code=status.HTTP_404_NOT_FOUND) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
async def get_subscriber(subscriber_id: int, hood=Depends(get_hood)): async def get_subscriber(subscriber_id: int, hood: Hood = Depends(get_hood)):
try: try:
return await EmailSubscribers.objects.get(id=subscriber_id, hood=hood) return await EmailSubscriber.get(id=subscriber_id, hood=hood)
except NoMatch: except DoesNotExist:
return HTTPException(status_code=status.HTTP_404_NOT_FOUND) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
# registers the routes, gets imported in /kibicara/webapi/__init__.py # registers the routes, gets imported in /kibicara/webapi/__init__.py
@ -83,9 +83,9 @@ router = APIRouter()
# TODO response_model # TODO response_model
operation_id="get_emails_public", operation_id="get_emails_public",
) )
async def email_read_all_public(hood=Depends(get_hood_unauthorized)): async def email_read_all_public(hood: Hood = Depends(get_hood_unauthorized)):
if hood.email_enabled: if hood.email_enabled:
emails = await Email.objects.filter(hood=hood).all() emails = await Email.filter(hood=hood)
return [BodyEmailPublic(name=email.name) for email in emails] return [BodyEmailPublic(name=email.name) for email in emails]
return [] return []
@ -95,8 +95,8 @@ async def email_read_all_public(hood=Depends(get_hood_unauthorized)):
# TODO response_model # TODO response_model
operation_id="get_emails", operation_id="get_emails",
) )
async def email_read_all(hood=Depends(get_hood)): async def email_read_all(hood: Hood = Depends(get_hood)):
return await Email.objects.filter(hood=hood).select_related("hood").all() return await Email.filter(hood=hood)
@router.post( @router.post(
@ -112,7 +112,7 @@ async def email_create(values: BodyEmail, response: Response, hood=Depends(get_h
:return: Email row of the new email bot. :return: Email row of the new email bot.
""" """
try: try:
email = await Email.objects.create( email = await Email.create(
hood=hood, secret=urandom(32).hex(), **values.__dict__ hood=hood, secret=urandom(32).hex(), **values.__dict__
) )
response.headers["Location"] = str(hood.id) response.headers["Location"] = str(hood.id)
@ -200,7 +200,7 @@ async def email_subscribe(
token, token,
) )
try: try:
subs = await EmailSubscribers.objects.filter(email=subscriber.email).all() subs = await EmailSubscriber.filter(email=subscriber.email).all()
if subs: if subs:
raise HTTPException(status_code=status.HTTP_409_CONFLICT) raise HTTPException(status_code=status.HTTP_409_CONFLICT)
email.send_email( email.send_email(
@ -239,7 +239,7 @@ async def email_subscribe_confirm(token, hood=Depends(get_hood_unauthorized)):
if hood.id is not payload["hood"]: if hood.id is not payload["hood"]:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
try: try:
await EmailSubscribers.objects.create(hood=hood.id, email=payload["email"]) await EmailSubscriber.create(hood=hood, email=payload["email"])
return {} return {}
except IntegrityError: except IntegrityError:
raise HTTPException(status_code=status.HTTP_409_CONFLICT) raise HTTPException(status_code=status.HTTP_409_CONFLICT)
@ -262,12 +262,12 @@ async def email_unsubscribe(token, hood=Depends(get_hood_unauthorized)):
# If token.hood and url.hood are different, raise an error: # If token.hood and url.hood are different, raise an error:
if hood.id is not payload["hood"]: if hood.id is not payload["hood"]:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
subscriber = await EmailSubscribers.objects.filter( subscriber = await EmailSubscriber.filter(
hood=payload["hood"], email=payload["email"] hood=payload["hood"], email=payload["email"]
).get() ).get()
await subscriber.delete() await subscriber.delete()
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
except NoMatch: except DoesNotExist:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
except exceptions.CryptoError: except exceptions.CryptoError:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
@ -279,7 +279,7 @@ async def email_unsubscribe(token, hood=Depends(get_hood_unauthorized)):
operation_id="get_subscribers", operation_id="get_subscribers",
) )
async def subscribers_read_all(hood=Depends(get_hood)): async def subscribers_read_all(hood=Depends(get_hood)):
return await EmailSubscribers.objects.filter(hood=hood).all() return await EmailSubscriber.filter(hood=hood).all()
@router.get( @router.get(
@ -306,7 +306,7 @@ async def email_message_create(
:param hood: Hood the Email bot belongs to. :param hood: Hood the Email bot belongs to.
:return: returns status code 201 if the message is accepted by the censor. :return: returns status code 201 if the message is accepted by the censor.
""" """
for receiver in await Email.objects.filter(hood=hood).all(): for receiver in await Email.filter(hood=hood).all():
if message.secret == receiver.secret: if message.secret == receiver.secret:
# pass message.text to bot.py # pass message.text to bot.py
if await spawner.get(hood).publish(Message(message.text)): if await spawner.get(hood).publish(Message(message.text)):

View file

@ -1,17 +1,17 @@
# Copyright (C) 2020 by Thomas Lindner <tom@dl6tom.de> # Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de> # Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org> # Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
# #
# SPDX-License-Identifier: 0BSD # SPDX-License-Identifier: 0BSD
from asyncio import get_event_loop, sleep from asyncio import gather, get_event_loop, sleep
from kibicara.platformapi import Censor, Spawner, Message
from kibicara.platforms.mastodon.model import MastodonAccount
from logging import getLogger from logging import getLogger
import re
from mastodon import Mastodon, MastodonError from mastodon import Mastodon, MastodonError
from asyncio import gather
import re from kibicara.platformapi import Censor, Spawner, Message
from kibicara.platforms.mastodon.model import MastodonAccount
logger = getLogger(__name__) logger = getLogger(__name__)
@ -26,7 +26,7 @@ class MastodonBot(Censor):
@classmethod @classmethod
async def destroy_hood(cls, hood): async def destroy_hood(cls, hood):
"""Removes all its database entries.""" """Removes all its database entries."""
for mastodon in await MastodonAccount.objects.filter(hood=hood).all(): for mastodon in await MastodonAccount.filter(hood=hood).all():
await mastodon.delete() await mastodon.delete()
async def run(self): async def run(self):

View file

@ -1,30 +1,36 @@
# Copyright (C) 2020 by Thomas Lindner <tom@dl6tom.de> # Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org> # Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
# #
# SPDX-License-Identifier: 0BSD # SPDX-License-Identifier: 0BSD
from ormantic import ForeignKey, Integer, Text, Boolean, Model from tortoise import fields
from tortoise.models import Model
from kibicara.model import Hood, Mapping from kibicara.model import Hood
class MastodonInstance(Model): class MastodonInstance(Model):
id: Integer(primary_key=True) = None id = fields.IntField(pk=True)
name: Text() name = fields.TextField()
client_id: Text() client_id = fields.TextField()
client_secret: Text() client_secret = fields.TextField()
accounts: fields.ReverseRelation["MastodonAccount"]
class Mapping(Mapping): class Meta:
table_name = "mastodoninstances" table = "platforms_mastodon_instances"
class MastodonAccount(Model): class MastodonAccount(Model):
id: Integer(primary_key=True) = None id = fields.IntField(pk=True)
hood: ForeignKey(Hood) hood: fields.ForeignKeyRelation[Hood] = fields.ForeignKeyField(
instance: ForeignKey(MastodonInstance) "models.Hood", related_name="platforms_mastodon"
access_token: Text() )
username: Text(allow_null=True) = None instance: fields.ForeignKeyRelation[MastodonInstance] = fields.ForeignKeyField(
enabled: Boolean() = False "models.MastodonInstance", related_name="accounts"
)
access_token = fields.TextField()
username = fields.TextField(null=True)
enabled = fields.BooleanField()
class Mapping(Mapping): class Meta:
table_name = "mastodonaccounts" table = "platforms_mastodon_accounts"

View file

@ -1,24 +1,24 @@
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de> # Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org> # Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
# Copyright (C) 2023 by Thomas Lindner <tom@dl6tom.de>
# #
# SPDX-License-Identifier: 0BSD # SPDX-License-Identifier: 0BSD
from asyncio import get_event_loop from asyncio import get_event_loop
from logging import getLogger
from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi import APIRouter, Depends, HTTPException, Response, status
from ormantic.exceptions import NoMatch from mastodon import Mastodon, MastodonNetworkError
from mastodon.errors import MastodonIllegalArgumentError
from pydantic import BaseModel, validate_email, validator from pydantic import BaseModel, validate_email, validator
from sqlite3 import IntegrityError from tortoise.exceptions import DoesNotExist, IntegrityError
from kibicara.config import config from kibicara.config import config
from kibicara.model import Hood
from kibicara.platforms.mastodon.bot import spawner from kibicara.platforms.mastodon.bot import spawner
from kibicara.platforms.mastodon.model import MastodonAccount, MastodonInstance from kibicara.platforms.mastodon.model import MastodonAccount, MastodonInstance
from kibicara.webapi.hoods import get_hood, get_hood_unauthorized from kibicara.webapi.hoods import get_hood, get_hood_unauthorized
from mastodon import Mastodon, MastodonNetworkError
from mastodon.errors import MastodonIllegalArgumentError
from logging import getLogger
logger = getLogger(__name__) logger = getLogger(__name__)
@ -37,10 +37,12 @@ class BodyMastodonAccount(BaseModel):
return validate_email(value) return validate_email(value)
async def get_mastodon(mastodon_id, hood=Depends(get_hood)): async def get_mastodon(
mastodon_id: int, hood: Hood = Depends(get_hood)
) -> MastodonAccount:
try: try:
return await MastodonAccount.objects.get(id=mastodon_id, hood=hood) return await MastodonAccount.get(id=mastodon_id, hood=hood)
except NoMatch: except DoesNotExist:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
@ -51,16 +53,16 @@ async def get_mastodon_instance(instance_url: str) -> MastodonInstance:
:return the MastodonInstance ORM object :return the MastodonInstance ORM object
""" """
try: try:
return await MastodonInstance.objects.get(name=instance_url) return await MastodonInstance.get(name=instance_url)
except NoMatch: except DoesNotExist:
app_name = config.get("frontend_url") app_name = config.get("frontend_url")
client_id, client_secret = Mastodon.create_app( client_id, client_secret = Mastodon.create_app(
app_name, api_base_url=instance_url app_name, api_base_url=instance_url
) )
await MastodonInstance.objects.create( await MastodonInstance.create(
name=instance_url, client_id=client_id, client_secret=client_secret name=instance_url, client_id=client_id, client_secret=client_secret
) )
return await MastodonInstance.objects.get(name=instance_url) return await MastodonInstance.get(name=instance_url)
router = APIRouter() router = APIRouter()
@ -73,13 +75,11 @@ twitter_callback_router = APIRouter()
operation_id="get_mastodons_public", operation_id="get_mastodons_public",
) )
async def mastodon_read_all_public(hood=Depends(get_hood_unauthorized)): async def mastodon_read_all_public(hood=Depends(get_hood_unauthorized)):
mastodonbots = await MastodonAccount.objects.filter(hood=hood).all()
mbots = [] mbots = []
for mbot in mastodonbots: async for mbot in MastodonAccount.filter(hood=hood).prefetch_related("instance"):
if mbot.enabled == 1 and mbot.username: if mbot.enabled == 1 and mbot.username:
instance = await MastodonInstance.objects.get(id=mbot.instance)
mbots.append( mbots.append(
BodyMastodonPublic(username=mbot.username, instance=instance.name) BodyMastodonPublic(username=mbot.username, instance=mbot.instance.name)
) )
return mbots return mbots
@ -90,7 +90,7 @@ async def mastodon_read_all_public(hood=Depends(get_hood_unauthorized)):
operation_id="get_mastodons", operation_id="get_mastodons",
) )
async def mastodon_read_all(hood=Depends(get_hood)): async def mastodon_read_all(hood=Depends(get_hood)):
return await MastodonAccount.objects.filter(hood=hood).all() return await MastodonAccount.filter(hood=hood).all()
@router.delete( @router.delete(
@ -101,8 +101,8 @@ async def mastodon_read_all(hood=Depends(get_hood)):
) )
async def mastodon_delete(mastodon=Depends(get_mastodon)): async def mastodon_delete(mastodon=Depends(get_mastodon)):
spawner.stop(mastodon) spawner.stop(mastodon)
await mastodon.instance.load() await mastodon.fetch_related("instance")
object_with_instance = await MastodonAccount.objects.filter( object_with_instance = await MastodonAccount.filter(
instance=mastodon.instance instance=mastodon.instance
).all() ).all()
if len(object_with_instance) == 1 and object_with_instance[0] == mastodon: if len(object_with_instance) == 1 and object_with_instance[0] == mastodon:
@ -172,7 +172,7 @@ async def mastodon_create(values: BodyMastodonAccount, hood=Depends(get_hood)):
None, account.log_in, values.email, values.password None, account.log_in, values.email, values.password
) )
logger.debug(f"{access_token}") logger.debug(f"{access_token}")
mastodon = await MastodonAccount.objects.create( mastodon = await MastodonAccount.create(
hood=hood, instance=instance, access_token=access_token, enabled=True hood=hood, instance=instance, access_token=access_token, enabled=True
) )
spawner.start(mastodon) spawner.start(mastodon)
@ -181,4 +181,5 @@ async def mastodon_create(values: BodyMastodonAccount, hood=Depends(get_hood)):
logger.warning("Login to Mastodon failed.", exc_info=True) logger.warning("Login to Mastodon failed.", exc_info=True)
raise HTTPException(status_code=status.HTTP_422_INVALID_INPUT) raise HTTPException(status_code=status.HTTP_422_INVALID_INPUT)
except IntegrityError: except IntegrityError:
logger.warning("Login to Mastodon failed.", exc_info=True)
raise HTTPException(status_code=status.HTTP_409_CONFLICT) raise HTTPException(status_code=status.HTTP_409_CONFLICT)

View file

@ -5,13 +5,12 @@
from asyncio import CancelledError, gather, sleep from asyncio import CancelledError, gather, sleep
from logging import getLogger from logging import getLogger
from sqlite3 import IntegrityError
from aiogram import Bot, Dispatcher, exceptions, types from aiogram import Bot, Dispatcher, exceptions, types
from ormantic.exceptions import NoMatch from tortoise.exceptions import DoesNotExist, IntegrityError
from kibicara.platformapi import Censor, Message, Spawner from kibicara.platformapi import Censor, Message, Spawner
from kibicara.platforms.telegram.model import Telegram, TelegramUser from kibicara.platforms.telegram.model import Telegram, TelegramSubscriber
logger = getLogger(__name__) logger = getLogger(__name__)
@ -25,8 +24,8 @@ class TelegramBot(Censor):
@classmethod @classmethod
async def destroy_hood(cls, hood): async def destroy_hood(cls, hood):
"""Removes all its database entries.""" """Removes all its database entries."""
for telegram in await Telegram.objects.filter(hood=hood).all(): for telegram in await Telegram.filter(hood=hood).all():
for user in await TelegramUser.objects.filter(bot=telegram).all(): for user in await TelegramSubscriber.filter(bot=telegram).all():
await user.delete() await user.delete()
await telegram.delete() await telegram.delete()
@ -69,9 +68,7 @@ class TelegramBot(Censor):
self.telegram_model.hood.name, message.text self.telegram_model.hood.name, message.text
) )
) )
for user in await TelegramUser.objects.filter( for user in await TelegramSubscriber.filter(bot=self.telegram_model).all():
bot=self.telegram_model
).all():
await self._send_message(user.user_id, message.text) await self._send_message(user.user_id, message.text)
async def _send_message(self, user_id, message): async def _send_message(self, user_id, message):
@ -116,7 +113,7 @@ class TelegramBot(Censor):
if message.from_user.is_bot: if message.from_user.is_bot:
await message.reply("Error: Bots can not join here.") await message.reply("Error: Bots can not join here.")
return return
await TelegramUser.objects.create( await TelegramSubscriber.create(
user_id=message.from_user.id, bot=self.telegram_model user_id=message.from_user.id, bot=self.telegram_model
) )
await message.reply(self.telegram_model.welcome_message) await message.reply(self.telegram_model.welcome_message)
@ -125,12 +122,12 @@ class TelegramBot(Censor):
async def _remove_user(self, message: types.Message): async def _remove_user(self, message: types.Message):
try: try:
telegram_user = await TelegramUser.objects.get( telegram_user = await TelegramSubscriber.get(
user_id=message.from_user.id, bot=self.telegram_model user_id=message.from_user.id, bot=self.telegram_model
) )
await telegram_user.delete() await telegram_user.delete()
await message.reply("You were removed successfully from this bot.") await message.reply("You were removed successfully from this bot.")
except NoMatch: except DoesNotExist:
await message.reply("Error: You are not subscribed to this bot.") await message.reply("Error: You are not subscribed to this bot.")
async def _send_help(self, message: types.Message): async def _send_help(self, message: types.Message):

View file

@ -1,30 +1,36 @@
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de> # Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org> # Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
# Copyright (C) 2023 by Thomas Lindner <tom@dl6tom.de>
# #
# SPDX-License-Identifier: 0BSD # SPDX-License-Identifier: 0BSD
from ormantic import Boolean, ForeignKey, Integer, Model, Text from tortoise import fields
from tortoise.models import Model
from kibicara.model import Hood, Mapping from kibicara.model import Hood
class Telegram(Model): class Telegram(Model):
id: Integer(primary_key=True) = None id = fields.IntField(pk=True)
hood: ForeignKey(Hood) hood: fields.ForeignKeyRelation[Hood] = fields.ForeignKeyField(
api_token: Text(unique=True) "models.Hood", related_name="platforms_telegram"
welcome_message: Text() )
username: Text(allow_null=True) = None api_token = fields.CharField(64, unique=True)
enabled: Boolean() = True welcome_message = fields.TextField()
username = fields.TextField(null=True)
enabled = fields.BooleanField(default=True)
subscribers: fields.ReverseRelation["TelegramSubscriber"]
class Mapping(Mapping): class Meta:
table_name = "telegrambots" table = "platforms_telegram"
class TelegramUser(Model): class TelegramSubscriber(Model):
id: Integer(primary_key=True) = None id = fields.IntField(pk=True)
user_id: Integer(unique=True) bot: fields.ForeignKeyRelation[Telegram] = fields.ForeignKeyField(
# TODO unique "models.Telegram", related_name="subscribers"
bot: ForeignKey(Telegram) )
user_id = fields.IntField()
class Mapping(Mapping): class Meta:
table_name = "telegramusers" table = "platforms_telegram_subscribers"

View file

@ -4,16 +4,15 @@
# SPDX-License-Identifier: 0BSD # SPDX-License-Identifier: 0BSD
from logging import getLogger from logging import getLogger
from sqlite3 import IntegrityError
from aiogram import exceptions from aiogram import exceptions
from aiogram.bot.api import check_token from aiogram.bot.api import check_token
from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi import APIRouter, Depends, HTTPException, Response, status
from ormantic.exceptions import NoMatch
from pydantic import BaseModel, validator from pydantic import BaseModel, validator
from tortoise.exceptions import DoesNotExist, IntegrityError
from kibicara.platforms.telegram.bot import spawner from kibicara.platforms.telegram.bot import spawner
from kibicara.platforms.telegram.model import Telegram, TelegramUser from kibicara.platforms.telegram.model import Telegram, TelegramSubscriber
from kibicara.webapi.hoods import get_hood, get_hood_unauthorized from kibicara.webapi.hoods import get_hood, get_hood_unauthorized
logger = getLogger(__name__) logger = getLogger(__name__)
@ -38,8 +37,8 @@ class BodyTelegramPublic(BaseModel):
async def get_telegram(telegram_id: int, hood=Depends(get_hood)): async def get_telegram(telegram_id: int, hood=Depends(get_hood)):
try: try:
return await Telegram.objects.get(id=telegram_id, hood=hood) return await Telegram.get(id=telegram_id, hood=hood)
except NoMatch: except DoesNotExist:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
@ -53,7 +52,7 @@ telegram_callback_router = APIRouter()
operation_id="get_telegrams_public", operation_id="get_telegrams_public",
) )
async def telegram_read_all_public(hood=Depends(get_hood_unauthorized)): async def telegram_read_all_public(hood=Depends(get_hood_unauthorized)):
telegrambots = await Telegram.objects.filter(hood=hood).all() telegrambots = await Telegram.filter(hood=hood).all()
return [ return [
BodyTelegramPublic(username=telegrambot.username) BodyTelegramPublic(username=telegrambot.username)
for telegrambot in telegrambots for telegrambot in telegrambots
@ -67,7 +66,7 @@ async def telegram_read_all_public(hood=Depends(get_hood_unauthorized)):
operation_id="get_telegrams", operation_id="get_telegrams",
) )
async def telegram_read_all(hood=Depends(get_hood)): async def telegram_read_all(hood=Depends(get_hood)):
return await Telegram.objects.filter(hood=hood).all() return await Telegram.filter(hood=hood).all()
@router.get( @router.get(
@ -86,7 +85,7 @@ async def telegram_read(telegram=Depends(get_telegram)):
) )
async def telegram_delete(telegram=Depends(get_telegram)): async def telegram_delete(telegram=Depends(get_telegram)):
spawner.stop(telegram) spawner.stop(telegram)
for user in await TelegramUser.objects.filter(bot=telegram).all(): for user in await TelegramSubscriber.filter(bot=telegram).all():
await user.delete() await user.delete()
await telegram.delete() await telegram.delete()
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
@ -102,7 +101,7 @@ async def telegram_create(
response: Response, values: BodyTelegram, hood=Depends(get_hood) response: Response, values: BodyTelegram, hood=Depends(get_hood)
): ):
try: try:
telegram = await Telegram.objects.create(hood=hood, **values.__dict__) telegram = await Telegram.create(hood=hood, **values.__dict__)
spawner.start(telegram) spawner.start(telegram)
response.headers["Location"] = str(telegram.id) response.headers["Location"] = str(telegram.id)
return telegram return telegram

View file

@ -1,17 +1,17 @@
# Copyright (C) 2020 by Thomas Lindner <tom@dl6tom.de> # Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de> # Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org> # Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
# #
# SPDX-License-Identifier: 0BSD # SPDX-License-Identifier: 0BSD
from kibicara.platformapi import Censor, Spawner from kibicara.platformapi import Censor, Message, Spawner
from kibicara.platforms.test.model import Test from kibicara.platforms.test.model import Test
class TestBot(Censor): class TestBot(Censor):
def __init__(self, test): def __init__(self, test: Test):
super().__init__(test.hood) super().__init__(test.hood)
self.messages = [] self.messages: list[Message] = []
async def run(self): async def run(self):
while True: while True:

View file

@ -1,16 +1,19 @@
# Copyright (C) 2020 by Thomas Lindner <tom@dl6tom.de> # Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org> # Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
# #
# SPDX-License-Identifier: 0BSD # SPDX-License-Identifier: 0BSD
from ormantic import ForeignKey, Integer, Model from tortoise import fields
from tortoise.models import Model
from kibicara.model import Hood, Mapping from kibicara.model import Hood
class Test(Model): class Test(Model):
id: Integer(primary_key=True) = None id = fields.IntField(pk=True)
hood: ForeignKey(Hood) hood: fields.ForeignKeyRelation[Hood] = fields.ForeignKeyField(
"models.Hood", related_name="platforms_test"
)
class Mapping(Mapping): class Meta:
table_name = "testapi" table = "platforms_test"

View file

@ -1,15 +1,14 @@
# Copyright (C) 2020 by Thomas Lindner <tom@dl6tom.de> # Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de> # Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org> # Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
# #
# SPDX-License-Identifier: 0BSD # SPDX-License-Identifier: 0BSD
from sqlite3 import IntegrityError
from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi import APIRouter, Depends, HTTPException, Response, status
from ormantic.exceptions import NoMatch
from pydantic import BaseModel from pydantic import BaseModel
from tortoise.exceptions import DoesNotExist, IntegrityError
from kibicara.model import Hood
from kibicara.platformapi import Message from kibicara.platformapi import Message
from kibicara.platforms.test.bot import spawner from kibicara.platforms.test.bot import spawner
from kibicara.platforms.test.model import Test from kibicara.platforms.test.model import Test
@ -20,10 +19,10 @@ class BodyMessage(BaseModel):
text: str text: str
async def get_test(test_id: int, hood=Depends(get_hood)): async def get_test(test_id: int, hood: Hood = Depends(get_hood)) -> Test:
try: try:
return await Test.objects.get(id=test_id, hood=hood) return await Test.get(id=test_id, hood=hood)
except NoMatch: except DoesNotExist:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
@ -31,14 +30,14 @@ router = APIRouter()
@router.get("/") @router.get("/")
async def test_read_all(hood=Depends(get_hood)): async def test_read_all(hood: Hood = Depends(get_hood)):
return await Test.objects.filter(hood=hood).all() return await Test.filter(hood=hood)
@router.post("/", status_code=status.HTTP_201_CREATED) @router.post("/", status_code=status.HTTP_201_CREATED)
async def test_create(response: Response, hood=Depends(get_hood)): async def test_create(response: Response, hood: Hood = Depends(get_hood)):
try: try:
test = await Test.objects.create(hood=hood) test = await Test.create(hood=hood)
spawner.start(test) spawner.start(test)
response.headers["Location"] = str(test.id) response.headers["Location"] = str(test.id)
return test return test
@ -47,22 +46,22 @@ async def test_create(response: Response, hood=Depends(get_hood)):
@router.get("/{test_id}") @router.get("/{test_id}")
async def test_read(test=Depends(get_test)): async def test_read(test: Test = Depends(get_test)):
return test return test
@router.delete("/{test_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{test_id}", status_code=status.HTTP_204_NO_CONTENT)
async def test_delete(test=Depends(get_test)): async def test_delete(test: Test = Depends(get_test)):
spawner.stop(test) spawner.stop(test)
await test.delete() await test.delete()
@router.get("/{test_id}/messages/") @router.get("/{test_id}/messages/")
async def test_message_read_all(test=Depends(get_test)): async def test_message_read_all(test: Test = Depends(get_test)):
return spawner.get(test).messages return spawner.get(test).messages
@router.post("/{test_id}/messages/") @router.post("/{test_id}/messages/")
async def test_message_create(message: BodyMessage, test=Depends(get_test)): async def test_message_create(message: BodyMessage, test: Test = Depends(get_test)):
await spawner.get(test).publish(Message(message.text)) await spawner.get(test).publish(Message(message.text))
return {} return {}

View file

@ -27,7 +27,7 @@ class TwitterBot(Censor):
@classmethod @classmethod
async def destroy_hood(cls, hood): async def destroy_hood(cls, hood):
"""Removes all its database entries.""" """Removes all its database entries."""
for twitter in await Twitter.objects.filter(hood=hood).all(): for twitter in await Twitter.filter(hood=hood).all():
await twitter.delete() await twitter.delete()
async def run(self): async def run(self):

View file

@ -1,23 +1,27 @@
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de> # Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org> # Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
# Copyright (C) 2023 by Thomas Lindner <tom@dl6tom.de>
# #
# SPDX-License-Identifier: 0BSD # SPDX-License-Identifier: 0BSD
from ormantic import Boolean, ForeignKey, Integer, Model, Text from tortoise import fields
from tortoise.models import Model
from kibicara.model import Hood, Mapping from kibicara.model import Hood
class Twitter(Model): class Twitter(Model):
id: Integer(primary_key=True) = None id = fields.IntField(pk=True)
hood: ForeignKey(Hood) hood: fields.ForeignKeyRelation[Hood] = fields.ForeignKeyField(
dms_since_id: Integer(allow_null=True) = None "models.Hood", related_name="platforms_twitter"
mentions_since_id: Integer(allow_null=True) = None )
access_token: Text() dms_since_id = fields.IntField()
access_token_secret: Text() mentions_since_id = fields.IntField()
username: Text(allow_null=True) = None access_token = fields.TextField()
verified: Boolean() = False access_token_secret = fields.TextField()
enabled: Boolean() = False username = fields.TextField()
verified = fields.BooleanField(default=False)
enabled = fields.BooleanField(default=False)
class Mapping(Mapping): class Meta:
table_name = "twitterbots" table = "platforms_twitter"

View file

@ -4,13 +4,12 @@
# SPDX-License-Identifier: 0BSD # SPDX-License-Identifier: 0BSD
from logging import getLogger from logging import getLogger
from sqlite3 import IntegrityError
from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi import APIRouter, Depends, HTTPException, Response, status
from ormantic.exceptions import NoMatch
from peony.exceptions import NotAuthenticated from peony.exceptions import NotAuthenticated
from peony.oauth_dance import get_access_token, get_oauth_token from peony.oauth_dance import get_access_token, get_oauth_token
from pydantic import BaseModel from pydantic import BaseModel
from tortoise.exceptions import DoesNotExist, IntegrityError
from kibicara.config import config from kibicara.config import config
from kibicara.platforms.twitter.bot import spawner from kibicara.platforms.twitter.bot import spawner
@ -26,8 +25,8 @@ class BodyTwitterPublic(BaseModel):
async def get_twitter(twitter_id: int, hood=Depends(get_hood)): async def get_twitter(twitter_id: int, hood=Depends(get_hood)):
try: try:
return await Twitter.objects.get(id=twitter_id, hood=hood) return await Twitter.get(id=twitter_id, hood=hood)
except NoMatch: except DoesNotExist:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
@ -41,7 +40,7 @@ twitter_callback_router = APIRouter()
operation_id="get_twitters_public", operation_id="get_twitters_public",
) )
async def twitter_read_all_public(hood=Depends(get_hood_unauthorized)): async def twitter_read_all_public(hood=Depends(get_hood_unauthorized)):
twitterbots = await Twitter.objects.filter(hood=hood).all() twitterbots = await Twitter.filter(hood=hood).all()
return [ return [
BodyTwitterPublic(username=twitterbot.username) BodyTwitterPublic(username=twitterbot.username)
for twitterbot in twitterbots for twitterbot in twitterbots
@ -55,7 +54,7 @@ async def twitter_read_all_public(hood=Depends(get_hood_unauthorized)):
operation_id="get_twitters", operation_id="get_twitters",
) )
async def twitter_read_all(hood=Depends(get_hood)): async def twitter_read_all(hood=Depends(get_hood)):
return await Twitter.objects.filter(hood=hood).all() return await Twitter.filter(hood=hood).all()
@router.get( @router.get(
@ -125,7 +124,7 @@ async def twitter_create(response: Response, hood=Depends(get_hood)):
""" """
try: try:
# Purge Twitter corpses # Purge Twitter corpses
for corpse in await Twitter.objects.filter(hood=hood, verified=False).all(): for corpse in await Twitter.filter(hood=hood, verified=False).all():
await corpse.delete() await corpse.delete()
# Create Twitter # Create Twitter
request_token = await get_oauth_token( request_token = await get_oauth_token(
@ -137,7 +136,7 @@ async def twitter_create(response: Response, hood=Depends(get_hood)):
) )
if request_token["oauth_callback_confirmed"] != "true": if request_token["oauth_callback_confirmed"] != "true":
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE) raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE)
twitter = await Twitter.objects.create( twitter = await Twitter.create(
hood=hood, hood=hood,
access_token=request_token["oauth_token"], access_token=request_token["oauth_token"],
access_token_secret=request_token["oauth_token_secret"], access_token_secret=request_token["oauth_token_secret"],
@ -159,7 +158,7 @@ async def twitter_read_callback(
oauth_token: str, oauth_verifier: str, hood=Depends(get_hood) oauth_token: str, oauth_verifier: str, hood=Depends(get_hood)
): ):
try: try:
twitter = await Twitter.objects.filter(access_token=oauth_token).get() twitter = await Twitter.filter(access_token=oauth_token).get()
access_token = await get_access_token( access_token = await get_access_token(
config["twitter"]["consumer_key"], config["twitter"]["consumer_key"],
config["twitter"]["consumer_secret"], config["twitter"]["consumer_secret"],
@ -167,7 +166,7 @@ async def twitter_read_callback(
twitter.access_token_secret, twitter.access_token_secret,
oauth_verifier, oauth_verifier,
) )
await twitter.update( await Twitter.filter(id=twitter).update(
access_token=access_token["oauth_token"], access_token=access_token["oauth_token"],
access_token_secret=access_token["oauth_token_secret"], access_token_secret=access_token["oauth_token_secret"],
verified=True, verified=True,
@ -177,7 +176,7 @@ async def twitter_read_callback(
return {} return {}
except IntegrityError: except IntegrityError:
raise HTTPException(status_code=status.HTTP_409_CONFLICT) raise HTTPException(status_code=status.HTTP_409_CONFLICT)
except NoMatch: except DoesNotExist:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
except (KeyError, ValueError, NotAuthenticated): except (KeyError, ValueError, NotAuthenticated):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)

View file

@ -20,16 +20,20 @@ from kibicara.platforms.twitter.webapi import router as twitter_router
from kibicara.platforms.twitter.webapi import twitter_callback_router from kibicara.platforms.twitter.webapi import twitter_callback_router
from kibicara.webapi.admin import router as admin_router from kibicara.webapi.admin import router as admin_router
from kibicara.webapi.hoods import router as hoods_router from kibicara.webapi.hoods import router as hoods_router
from kibicara.webapi.hoods.badwords import router as badwords_router from kibicara.webapi.hoods.exclude_patterns import router as exclude_patterns_router
from kibicara.webapi.hoods.triggers import router as triggers_router from kibicara.webapi.hoods.include_patterns import router as include_patterns_router
router = APIRouter() router = APIRouter()
router.include_router(admin_router, prefix="/admin", tags=["admin"]) router.include_router(admin_router, prefix="/admin", tags=["admin"])
hoods_router.include_router( hoods_router.include_router(
triggers_router, prefix="/{hood_id}/triggers", tags=["triggers"] include_patterns_router,
prefix="/{hood_id}/triggers",
tags=["include_patterns"],
) )
hoods_router.include_router( hoods_router.include_router(
badwords_router, prefix="/{hood_id}/badwords", tags=["badwords"] exclude_patterns_router,
prefix="/{hood_id}/badwords",
tags=["exclude_patterns"],
) )
hoods_router.include_router(test_router, prefix="/{hood_id}/test", tags=["test"]) hoods_router.include_router(test_router, prefix="/{hood_id}/test", tags=["test"])
hoods_router.include_router( hoods_router.include_router(

View file

@ -1,4 +1,4 @@
# Copyright (C) 2020 by Thomas Lindner <tom@dl6tom.de> # Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de> # Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Christian Hagenest <c.hagenest@pm.me> # Copyright (C) 2020 by Christian Hagenest <c.hagenest@pm.me>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org> # Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
@ -11,20 +11,20 @@ from datetime import datetime, timedelta
from logging import getLogger from logging import getLogger
from pickle import dumps, loads from pickle import dumps, loads
from smtplib import SMTPException from smtplib import SMTPException
from sqlite3 import IntegrityError
from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from nacl.encoding import URLSafeBase64Encoder from nacl.encoding import URLSafeBase64Encoder
from nacl.exceptions import CryptoError from nacl.exceptions import CryptoError
from nacl.secret import SecretBox from nacl.secret import SecretBox
from ormantic.exceptions import NoMatch from nacl.utils import random
from passlib.hash import argon2 from passlib.hash import argon2
from pydantic import BaseModel, validator from pydantic import BaseModel, validator
from tortoise.exceptions import DoesNotExist, IntegrityError
from kibicara import email from kibicara import email
from kibicara.config import config from kibicara.config import config
from kibicara.model import Admin, AdminHoodRelation, Hood from kibicara.model import Admin, Hood
from kibicara.webapi.utils import delete_hood from kibicara.webapi.utils import delete_hood
logger = getLogger(__name__) logger = getLogger(__name__)
@ -54,41 +54,42 @@ class BodyAccessToken(BaseModel):
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/admin/login") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/admin/login")
secret_box = SecretBox(bytes.fromhex(config["secret"])) secret_box = SecretBox(
bytes.fromhex(str(config.get("secret", random(SecretBox.KEY_SIZE).hex())))
)
def to_token(**kwargs): def to_token(**kwargs) -> str:
return secret_box.encrypt(dumps(kwargs), encoder=URLSafeBase64Encoder).decode( return secret_box.encrypt(dumps(kwargs), encoder=URLSafeBase64Encoder).decode(
"ascii" "ascii"
) )
def from_token(token): def from_token(token: str) -> dict:
return loads( return loads(
secret_box.decrypt(token.encode("ascii"), encoder=URLSafeBase64Encoder) secret_box.decrypt(token.encode("ascii"), encoder=URLSafeBase64Encoder)
) )
async def get_auth(email, password): async def get_auth(email: str, password: str) -> Admin:
try: try:
admin = await Admin.objects.get(email=email) admin = await Admin.get(email=email)
if argon2.verify(password, admin.passhash): if argon2.verify(password, admin.passhash):
return admin return admin
raise ValueError raise ValueError
except NoMatch: except DoesNotExist:
raise ValueError raise ValueError
async def get_admin(access_token=Depends(oauth2_scheme)): async def get_admin(access_token: str = Depends(oauth2_scheme)) -> Admin:
try: try:
admin = await get_auth(**from_token(access_token)) return await get_auth(**from_token(access_token))
except (CryptoError, ValueError): except (CryptoError, ValueError):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials", detail="Invalid authentication credentials",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
return admin
router = APIRouter() router = APIRouter()
@ -109,9 +110,9 @@ async def admin_register(values: BodyAdmin):
register_token = to_token(**values.__dict__) register_token = to_token(**values.__dict__)
logger.debug("register_token={0}".format(register_token)) logger.debug("register_token={0}".format(register_token))
try: try:
admin = await Admin.objects.filter(email=values.email).all() if await Admin.exists(email=values.email):
if admin:
raise HTTPException(status_code=status.HTTP_409_CONFLICT) raise HTTPException(status_code=status.HTTP_409_CONFLICT)
# link goes to frontend. this is not the confirm API endpoint below!
body = "{0}/confirm?token={1}".format(config["frontend_url"], register_token) body = "{0}/confirm?token={1}".format(config["frontend_url"], register_token)
logger.debug(body) logger.debug(body)
email.send_email( email.send_email(
@ -138,7 +139,8 @@ async def admin_confirm(register_token: str):
try: try:
values = from_token(register_token) values = from_token(register_token)
passhash = argon2.hash(values["password"]) passhash = argon2.hash(values["password"])
await Admin.objects.create(email=values["email"], passhash=passhash) await Admin.create(email=values["email"], passhash=passhash)
# XXX login and registration tokens are exchangeable. does this hurt?
return BodyAccessToken(access_token=register_token) return BodyAccessToken(access_token=register_token)
except IntegrityError: except IntegrityError:
raise HTTPException(status_code=status.HTTP_409_CONFLICT) raise HTTPException(status_code=status.HTTP_409_CONFLICT)
@ -178,14 +180,14 @@ async def admin_reset_password(values: BodyEmail):
- **email**: E-Mail Address of new hood admin - **email**: E-Mail Address of new hood admin
- **password**: Password of new hood admin - **password**: Password of new hood admin
""" """
register_token = to_token(datetime=datetime.now().isoformat(), **values.__dict__) reset_token = to_token(datetime=datetime.now().isoformat(), **values.__dict__)
logger.debug("register_token={0}".format(register_token)) logger.debug("reset_token={0}".format(reset_token))
try: try:
admin = await Admin.objects.filter(email=values.email).all() if await Admin.exists(email=values.email):
if not admin:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
# link goes to frontend. this is not the reset API endpoint below!
body = "{0}/password-reset?token={1}".format( body = "{0}/password-reset?token={1}".format(
config["frontend_url"], register_token config["frontend_url"], reset_token
) )
logger.debug(body) logger.debug(body)
email.send_email( email.send_email(
@ -213,11 +215,10 @@ async def admin_confirm_reset(reset_token: str, values: BodyPassword):
): ):
raise HTTPException(status_code=status.HTTP_410_GONE) raise HTTPException(status_code=status.HTTP_410_GONE)
passhash = argon2.hash(values.password) passhash = argon2.hash(values.password)
admins = await Admin.objects.filter(email=token_values["email"]).all() await Admin.filter(email=token_values["email"]).update(passhash=passhash)
if len(admins) != 1:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
await admins[0].update(passhash=passhash)
return BodyAccessToken(access_token=reset_token) return BodyAccessToken(access_token=reset_token)
except DoesNotExist:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
except IntegrityError: except IntegrityError:
raise HTTPException(status_code=status.HTTP_409_CONFLICT) raise HTTPException(status_code=status.HTTP_409_CONFLICT)
except CryptoError: except CryptoError:
@ -229,11 +230,9 @@ async def admin_confirm_reset(reset_token: str, values: BodyPassword):
# TODO response_model, # TODO response_model,
operation_id="get_hoods_admin", operation_id="get_hoods_admin",
) )
async def admin_hood_read_all(admin=Depends(get_admin)): async def admin_hood_read_all(admin: Admin = Depends(get_admin)):
"""Get a list of all hoods of a given admin.""" """Get a list of all hoods of a given admin."""
return ( return await Hood.filter(admins=admin)
await AdminHoodRelation.objects.select_related("hood").filter(admin=admin).all()
)
@router.get( @router.get(
@ -241,12 +240,8 @@ async def admin_hood_read_all(admin=Depends(get_admin)):
# TODO response_model, # TODO response_model,
operation_id="get_admin", operation_id="get_admin",
) )
async def admin_read(admin=Depends(get_admin)): async def admin_read(admin: Admin = Depends(get_admin)):
"""Get a list of all hoods of a given admin.""" return BodyEmail(email=admin.email)
admin = await Admin.objects.filter(email=admin.email).all()
if len(admin) != 1:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return BodyEmail(email=admin[0].email)
@router.delete( @router.delete(
@ -254,18 +249,10 @@ async def admin_read(admin=Depends(get_admin)):
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
operation_id="delete_admin", operation_id="delete_admin",
) )
async def admin_delete(admin=Depends(get_admin)): async def admin_delete(admin: Admin = Depends(get_admin)):
hood_relations = ( async for hood in Hood.filter(admins__contains=admin):
await AdminHoodRelation.objects.select_related("hood").filter(admin=admin).all() await hood.admins.remove(admin)
) await hood.fetch_related("admins")
for hood in hood_relations: if len(hood.admins) == 0:
admins = ( await delete_hood(hood)
await AdminHoodRelation.objects.select_related("admin")
.filter(hood=hood.id)
.all()
)
if len(admins) == 1 and admins[0].id == admin.id:
actual_hood = await Hood.objects.filter(id=hood.id).all()
await delete_hood(actual_hood[0])
await admin.delete() await admin.delete()
return Response(status_code=status.HTTP_204_NO_CONTENT)

View file

@ -1,4 +1,4 @@
# Copyright (C) 2020 by Thomas Lindner <tom@dl6tom.de> # Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de> # Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org> # Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
# #
@ -6,13 +6,11 @@
"""REST API Endpoints for managing hoods.""" """REST API Endpoints for managing hoods."""
from sqlite3 import IntegrityError
from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi import APIRouter, Depends, HTTPException, Response, status
from ormantic.exceptions import NoMatch
from pydantic import BaseModel from pydantic import BaseModel
from tortoise.exceptions import DoesNotExist, IntegrityError
from kibicara.model import AdminHoodRelation, Hood, Trigger from kibicara.model import Admin, Hood, IncludePattern
from kibicara.platforms.email.bot import spawner from kibicara.platforms.email.bot import spawner
from kibicara.webapi.admin import get_admin from kibicara.webapi.admin import get_admin
from kibicara.webapi.utils import delete_hood from kibicara.webapi.utils import delete_hood
@ -20,28 +18,26 @@ from kibicara.webapi.utils import delete_hood
class BodyHood(BaseModel): class BodyHood(BaseModel):
name: str name: str
landingpage: str = """ landingpage: str = "Default Landing Page"
Default Landing Page
"""
async def get_hood_unauthorized(hood_id: int): async def get_hood_unauthorized(hood_id: int) -> Hood:
try: try:
hood = await Hood.objects.get(id=hood_id) return await Hood.get(id=hood_id)
except NoMatch: except DoesNotExist:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return hood
async def get_hood(hood=Depends(get_hood_unauthorized), admin=Depends(get_admin)): async def get_hood(
try: hood: Hood = Depends(get_hood_unauthorized), admin: Admin = Depends(get_admin)
await AdminHoodRelation.objects.get(admin=admin, hood=hood) ) -> Hood:
except NoMatch: await hood.fetch_related("admins")
raise HTTPException( if admin in hood.admins:
status_code=status.HTTP_401_UNAUTHORIZED, return hood
headers={"WWW-Authenticate": "Bearer"}, raise HTTPException(
) status_code=status.HTTP_401_UNAUTHORIZED,
return hood headers={"WWW-Authenticate": "Bearer"},
)
router = APIRouter() router = APIRouter()
@ -55,7 +51,7 @@ router = APIRouter()
) )
async def hood_read_all(): async def hood_read_all():
"""Get all existing hoods.""" """Get all existing hoods."""
return await Hood.objects.all() return await Hood.all()
@router.post( @router.post(
@ -65,19 +61,21 @@ async def hood_read_all():
operation_id="create_hood", operation_id="create_hood",
tags=["hoods"], tags=["hoods"],
) )
async def hood_create(values: BodyHood, response: Response, admin=Depends(get_admin)): async def hood_create(
values: BodyHood, response: Response, admin: Admin = Depends(get_admin)
):
"""Creates a hood. """Creates a hood.
- **name**: Name of the hood - **name**: Name of the hood
- **landingpage**: Markdown formatted description of the hood - **landingpage**: Markdown formatted description of the hood
""" """
try: try:
hood = await Hood.objects.create(**values.__dict__) hood = await Hood.create(**values.__dict__)
await AdminHoodRelation.objects.create(admin=admin.id, hood=hood.id) await admin.hoods.add(hood)
spawner.start(hood) spawner.start(hood)
# Initialize Triggers to match all # Initialize Triggers to match all
await Trigger.objects.create(hood=hood, pattern=".") await IncludePattern.create(hood=hood, pattern=".")
response.headers["Location"] = str(hood.id) response.headers["Location"] = str(hood.id)
return hood return hood
@ -91,25 +89,24 @@ async def hood_create(values: BodyHood, response: Response, admin=Depends(get_ad
operation_id="get_hood", operation_id="get_hood",
tags=["hoods"], tags=["hoods"],
) )
async def hood_read(hood=Depends(get_hood_unauthorized)): async def hood_read(hood: Hood = Depends(get_hood_unauthorized)):
"""Get hood with id **hood_id**.""" """Get hood with id **hood_id**."""
return hood return hood
@router.put( @router.put(
"/{hood_id}", "/{hood_id}",
status_code=status.HTTP_204_NO_CONTENT,
operation_id="update_hood", operation_id="update_hood",
tags=["hoods"], tags=["hoods"],
) )
async def hood_update(values: BodyHood, hood=Depends(get_hood)): async def hood_update(values: BodyHood, hood: Hood = Depends(get_hood)):
"""Updates hood with id **hood_id**. """Updates hood with id **hood_id**.
- **name**: New name of the hood - **name**: New name of the hood
- **landingpage**: New Markdown formatted description of the hood - **landingpage**: New Markdown formatted description of the hood
""" """
await hood.update(**values.__dict__) await Hood.filter(id=hood).update(**values.__dict__)
return Response(status_code=status.HTTP_204_NO_CONTENT) return hood
@router.delete( @router.delete(
@ -121,4 +118,3 @@ async def hood_update(values: BodyHood, hood=Depends(get_hood)):
async def hood_delete(hood=Depends(get_hood)): async def hood_delete(hood=Depends(get_hood)):
"""Deletes hood with id **hood_id**.""" """Deletes hood with id **hood_id**."""
await delete_hood(hood) await delete_hood(hood)
return Response(status_code=status.HTTP_204_NO_CONTENT)

View file

@ -1,106 +0,0 @@
# Copyright (C) 2020 by Thomas Lindner <tom@dl6tom.de>
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
#
# SPDX-License-Identifier: 0BSD
"""REST API endpoints for managing badwords.
Provides API endpoints for adding, removing and reading regular expressions that block a
received message to be dropped by a censor. This provides a message filter customizable
by the hood admins.
"""
from re import compile as regex_compile
from re import error as RegexError
from sqlite3 import IntegrityError
from fastapi import APIRouter, Depends, HTTPException, Response, status
from ormantic.exceptions import NoMatch
from pydantic import BaseModel
from kibicara.model import BadWord
from kibicara.webapi.hoods import get_hood
class BodyBadWord(BaseModel):
pattern: str
async def get_badword(badword_id: int, hood=Depends(get_hood)):
try:
return await BadWord.objects.get(id=badword_id, hood=hood)
except NoMatch:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
router = APIRouter()
@router.get(
"/",
# TODO response_model,
operation_id="get_badwords",
)
async def badword_read_all(hood=Depends(get_hood)):
"""Get all badwords of hood with id **hood_id**."""
return await BadWord.objects.filter(hood=hood).all()
@router.post(
"/",
status_code=status.HTTP_201_CREATED,
# TODO response_model,
operation_id="create_badword",
)
async def badword_create(
values: BodyBadWord, response: Response, hood=Depends(get_hood)
):
"""Creates a new badword for hood with id **hood_id**.
- **pattern**: Regular expression which is used to match a badword.
"""
try:
regex_compile(values.pattern)
badword = await BadWord.objects.create(hood=hood, **values.__dict__)
response.headers["Location"] = str(badword.id)
return badword
except IntegrityError:
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
except RegexError:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
@router.get(
"/{badword_id}",
# TODO response_model,
operation_id="get_badword",
)
async def badword_read(badword=Depends(get_badword)):
"""Reads badword with id **badword_id** for hood with id **hood_id**."""
return badword
@router.put(
"/{badword_id}",
status_code=status.HTTP_204_NO_CONTENT,
operation_id="update_badword",
)
async def badword_update(values: BodyBadWord, badword=Depends(get_badword)):
"""Updates badword with id **badword_id** for hood with id **hood_id**.
- **pattern**: Regular expression which is used to match a badword
"""
await badword.update(**values.__dict__)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.delete(
"/{badword_id}",
status_code=status.HTTP_204_NO_CONTENT,
operation_id="delete_badword",
)
async def badword_delete(badword=Depends(get_badword)):
"""Deletes badword with id **badword_id** for hood with id **hood_id**."""
await badword.delete()
return Response(status_code=status.HTTP_204_NO_CONTENT)

View file

@ -0,0 +1,116 @@
# Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
#
# SPDX-License-Identifier: 0BSD
"""REST API endpoints for managing exclude_patterns.
Provides API endpoints for adding, removing and reading regular expressions that block a
received message to be dropped by a censor. This provides a message filter customizable
by the hood admins.
"""
from re import compile as regex_compile, error as RegexError
from fastapi import APIRouter, Depends, HTTPException, Response, status
from pydantic import BaseModel
from tortoise.exceptions import DoesNotExist, IntegrityError
from kibicara.model import ExcludePattern, Hood
from kibicara.webapi.hoods import get_hood
class BodyExcludePattern(BaseModel):
pattern: str
async def get_exclude_pattern(
exclude_pattern_id: int, hood: Hood = Depends(get_hood)
) -> ExcludePattern:
try:
return await ExcludePattern.get(id=exclude_pattern_id, hood=hood)
except DoesNotExist:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
router = APIRouter()
@router.get(
"/",
# TODO response_model,
operation_id="get_exclude_patterns",
)
async def exclude_pattern_read_all(hood: Hood = Depends(get_hood)):
"""Get all exclude_patterns of hood with id **hood_id**."""
return await ExcludePattern.filter(hood=hood)
@router.post(
"/",
status_code=status.HTTP_201_CREATED,
# TODO response_model,
operation_id="create_exclude_pattern",
)
async def exclude_pattern_create(
values: BodyExcludePattern, response: Response, hood: Hood = Depends(get_hood)
):
"""Creates a new exclude_pattern for hood with id **hood_id**.
- **pattern**: Regular expression which is used to match a exclude_pattern.
"""
try:
regex_compile(values.pattern)
exclude_pattern = await ExcludePattern.create(hood=hood, **values.__dict__)
response.headers["Location"] = str(exclude_pattern.id)
return exclude_pattern
except IntegrityError:
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
except RegexError:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
@router.get(
"/{exclude_pattern_id}",
# TODO response_model,
operation_id="get_exclude_pattern",
)
async def exclude_pattern_read(
exclude_pattern: ExcludePattern = Depends(get_exclude_pattern),
):
"""
Reads exclude_pattern with id **exclude_pattern_id** for hood with id **hood_id**.
"""
return exclude_pattern
@router.put(
"/{exclude_pattern_id}",
operation_id="update_exclude_pattern",
)
async def exclude_pattern_update(
values: BodyExcludePattern,
exclude_pattern: ExcludePattern = Depends(get_exclude_pattern),
):
"""
Updates exclude_pattern with id **exclude_pattern_id** for hood with id **hood_id**.
- **pattern**: Regular expression which is used to match a exclude_pattern
"""
await ExcludePattern.filter(id=exclude_pattern).update(**values.__dict__)
return exclude_pattern
@router.delete(
"/{exclude_pattern_id}",
status_code=status.HTTP_204_NO_CONTENT,
operation_id="delete_exclude_pattern",
)
async def exclude_pattern_delete(
exclude_pattern: ExcludePattern = Depends(get_exclude_pattern),
):
"""
Deletes exclude_pattern with id **exclude_pattern_id** for hood with id **hood_id**.
"""
await exclude_pattern.delete()

View file

@ -0,0 +1,115 @@
# Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
#
# SPDX-License-Identifier: 0BSD
"""REST API endpoints for managing include_patterns.
Provides API endpoints for adding, removing and reading regular expressions that allow a
message to be passed through by a censor. A published message must match one of these
regular expressions otherwise it gets dropped by the censor. This provides a message
filter customizable by the hood admins.
"""
from re import compile as regex_compile, error as RegexError
from fastapi import APIRouter, Depends, HTTPException, Response, status
from pydantic import BaseModel
from tortoise.exceptions import DoesNotExist, IntegrityError
from kibicara.model import IncludePattern, Hood
from kibicara.webapi.hoods import get_hood
class BodyIncludePattern(BaseModel):
pattern: str
async def get_include_pattern(include_pattern_id: int, hood: Hood = Depends(get_hood)):
try:
return await IncludePattern.get(id=include_pattern_id, hood=hood)
except DoesNotExist:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
router = APIRouter()
@router.get(
"/",
# TODO response_model,
operation_id="get_include_patterns",
)
async def include_pattern_read_all(hood: Hood = Depends(get_hood)):
"""Get all include_patterns of hood with id **hood_id**."""
return await IncludePattern.filter(hood=hood)
@router.post(
"/",
status_code=status.HTTP_201_CREATED,
# TODO response_model,
operation_id="create_include_pattern",
)
async def include_pattern_create(
values: BodyIncludePattern, response: Response, hood: Hood = Depends(get_hood)
):
"""Creates a new include_pattern for hood with id **hood_id**.
- **pattern**: Regular expression which is used to match a include_pattern.
"""
try:
regex_compile(values.pattern)
include_pattern = await IncludePattern.create(hood=hood, **values.__dict__)
response.headers["Location"] = str(include_pattern.id)
return include_pattern
except IntegrityError:
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
except RegexError:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
@router.get(
"/{include_pattern_id}",
# TODO response_model,
operation_id="get_include_pattern",
)
async def include_pattern_read(
include_pattern: IncludePattern = Depends(get_include_pattern),
):
"""
Reads include_pattern with id **include_pattern_id** for hood with id **hood_id**.
"""
return include_pattern
@router.put(
"/{include_pattern_id}",
operation_id="update_include_pattern",
)
async def include_pattern_update(
values: BodyIncludePattern,
include_pattern: IncludePattern = Depends(get_include_pattern),
):
"""
Updates include_pattern with id **include_pattern_id** for hood with id **hood_id**.
- **pattern**: Regular expression which is used to match a include_pattern
"""
await IncludePattern.filter(id=include_pattern).update(**values.__dict__)
return include_pattern
@router.delete(
"/{include_pattern_id}",
status_code=status.HTTP_204_NO_CONTENT,
operation_id="delete_include_pattern",
)
async def include_pattern_delete(
include_pattern: IncludePattern = Depends(get_include_pattern),
):
"""
Deletes include_pattern with id **include_pattern_id** for hood with id **hood_id**.
"""
await include_pattern.delete()

View file

@ -1,107 +0,0 @@
# Copyright (C) 2020 by Thomas Lindner <tom@dl6tom.de>
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
#
# SPDX-License-Identifier: 0BSD
"""REST API endpoints for managing triggers.
Provides API endpoints for adding, removing and reading regular expressions that allow a
message to be passed through by a censor. A published message must match one of these
regular expressions otherwise it gets dropped by the censor. This provides a message
filter customizable by the hood admins.
"""
from re import compile as regex_compile
from re import error as RegexError
from sqlite3 import IntegrityError
from fastapi import APIRouter, Depends, HTTPException, Response, status
from ormantic.exceptions import NoMatch
from pydantic import BaseModel
from kibicara.model import Trigger
from kibicara.webapi.hoods import get_hood
class BodyTrigger(BaseModel):
pattern: str
async def get_trigger(trigger_id: int, hood=Depends(get_hood)):
try:
return await Trigger.objects.get(id=trigger_id, hood=hood)
except NoMatch:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
router = APIRouter()
@router.get(
"/",
# TODO response_model,
operation_id="get_triggers",
)
async def trigger_read_all(hood=Depends(get_hood)):
"""Get all triggers of hood with id **hood_id**."""
return await Trigger.objects.filter(hood=hood).all()
@router.post(
"/",
status_code=status.HTTP_201_CREATED,
# TODO response_model,
operation_id="create_trigger",
)
async def trigger_create(
values: BodyTrigger, response: Response, hood=Depends(get_hood)
):
"""Creates a new trigger for hood with id **hood_id**.
- **pattern**: Regular expression which is used to match a trigger.
"""
try:
regex_compile(values.pattern)
trigger = await Trigger.objects.create(hood=hood, **values.__dict__)
response.headers["Location"] = str(trigger.id)
return trigger
except IntegrityError:
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
except RegexError:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
@router.get(
"/{trigger_id}",
# TODO response_model,
operation_id="get_trigger",
)
async def trigger_read(trigger=Depends(get_trigger)):
"""Reads trigger with id **trigger_id** for hood with id **hood_id**."""
return trigger
@router.put(
"/{trigger_id}",
status_code=status.HTTP_204_NO_CONTENT,
operation_id="update_trigger",
)
async def trigger_update(values: BodyTrigger, trigger=Depends(get_trigger)):
"""Updates trigger with id **trigger_id** for hood with id **hood_id**.
- **pattern**: Regular expression which is used to match a trigger
"""
await trigger.update(**values.__dict__)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.delete(
"/{trigger_id}",
status_code=status.HTTP_204_NO_CONTENT,
operation_id="delete_trigger",
)
async def trigger_delete(trigger=Depends(get_trigger)):
"""Deletes trigger with id **trigger_id** for hood with id **hood_id**."""
await trigger.delete()
return Response(status_code=status.HTTP_204_NO_CONTENT)

View file

@ -1,17 +1,14 @@
# Copyright (C) 2023 by Thomas Lindner <tom@dl6tom.de>
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de> # Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# #
# SPDX-License-Identifier: 0BSD # SPDX-License-Identifier: 0BSD
from kibicara.model import AdminHoodRelation, BadWord, Trigger from kibicara.model import ExcludePattern, Hood, IncludePattern
from kibicara.platformapi import Spawner from kibicara.platformapi import Spawner
async def delete_hood(hood): async def delete_hood(hood: Hood) -> None:
await Spawner.destroy_hood(hood) await Spawner.destroy_hood(hood)
for trigger in await Trigger.objects.filter(hood=hood).all(): await IncludePattern.filter(hood=hood).delete()
await trigger.delete() await ExcludePattern.filter(hood=hood).delete()
for badword in await BadWord.objects.filter(hood=hood).all():
await badword.delete()
for relation in await AdminHoodRelation.objects.filter(hood=hood).all():
await relation.delete()
await hood.delete() await hood.delete()

View file

@ -9,27 +9,41 @@ from urllib.parse import urlparse
from fastapi import FastAPI, status from fastapi import FastAPI, status
from httpx import AsyncClient from httpx import AsyncClient
import pytest import pytest
from tortoise import Tortoise
from kibicara import email from kibicara import email
from kibicara.model import Mapping
from kibicara.webapi import router from kibicara.webapi import router
@pytest.fixture(scope="module") @pytest.fixture(scope="session")
def anyio_backend(): def anyio_backend():
return "asyncio" return "asyncio"
@pytest.fixture(scope="module") @pytest.fixture(scope="session")
def client(): @pytest.mark.anyio
Mapping.drop_all() async def client():
Mapping.create_all() await Tortoise.init(
db_url="sqlite://:memory:",
modules={
"models": [
"kibicara.model",
"kibicara.platforms.email.model",
"kibicara.platforms.mastodon.model",
"kibicara.platforms.telegram.model",
"kibicara.platforms.test.model",
"kibicara.platforms.twitter.model",
]
},
)
await Tortoise.generate_schemas()
app = FastAPI() app = FastAPI()
app.include_router(router, prefix="/api") app.include_router(router, prefix="/api")
return AsyncClient(app=app, base_url="http://test") yield AsyncClient(app=app, base_url="http://test")
await Tortoise.close_connections()
@pytest.fixture(scope="module") @pytest.fixture(scope="session")
def monkeymodule(): def monkeymodule():
from _pytest.monkeypatch import MonkeyPatch from _pytest.monkeypatch import MonkeyPatch
@ -38,7 +52,7 @@ def monkeymodule():
mpatch.undo() mpatch.undo()
@pytest.fixture(scope="module") @pytest.fixture(scope="session")
def receive_email(monkeymodule): def receive_email(monkeymodule):
mailbox = [] mailbox = []
@ -52,7 +66,7 @@ def receive_email(monkeymodule):
return mock_receive_email return mock_receive_email
@pytest.fixture(scope="module") @pytest.fixture(scope="session")
@pytest.mark.anyio @pytest.mark.anyio
async def register_token(client, receive_email): async def register_token(client, receive_email):
response = await client.post( response = await client.post(
@ -62,14 +76,14 @@ async def register_token(client, receive_email):
return urlparse(receive_email()["body"]).query.split("=", 1)[1] return urlparse(receive_email()["body"]).query.split("=", 1)[1]
@pytest.fixture(scope="module") @pytest.fixture(scope="session")
@pytest.mark.anyio @pytest.mark.anyio
async def register_confirmed(client, register_token): async def register_confirmed(client, register_token):
response = await client.post("/api/admin/confirm/{0}".format(register_token)) response = await client.post("/api/admin/confirm/{0}".format(register_token))
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
@pytest.fixture(scope="module") @pytest.fixture(scope="session")
@pytest.mark.anyio @pytest.mark.anyio
async def access_token(client, register_confirmed): async def access_token(client, register_confirmed):
response = await client.post( response = await client.post(
@ -79,7 +93,7 @@ async def access_token(client, register_confirmed):
return response.json()["access_token"] return response.json()["access_token"]
@pytest.fixture(scope="module") @pytest.fixture(scope="session")
def auth_header(access_token): def auth_header(access_token):
return {"Authorization": "Bearer {0}".format(access_token)} return {"Authorization": "Bearer {0}".format(access_token)}

View file

View file

@ -12,7 +12,7 @@ from kibicara.platforms.mastodon.model import MastodonAccount, MastodonInstance
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
@pytest.mark.anyio @pytest.mark.anyio
async def mastodon_instance(): async def mastodon_instance():
return await MastodonInstance.objects.create( return await MastodonInstance.create(
name="inst4nce", name="inst4nce",
client_id="cl13nt_id", client_id="cl13nt_id",
client_secret="cl13nt_s3cr3t", client_secret="cl13nt_s3cr3t",
@ -22,8 +22,8 @@ async def mastodon_instance():
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
@pytest.mark.anyio @pytest.mark.anyio
async def mastodon_account(hood_id, mastodon_instance): async def mastodon_account(hood_id, mastodon_instance):
hood = await Hood.objects.get(id=hood_id) hood = await Hood.get(id=hood_id)
return await MastodonAccount.objects.create( return await MastodonAccount.create(
hood=hood, hood=hood,
instance=mastodon_instance, instance=mastodon_instance,
access_token="t0k3n", access_token="t0k3n",

View file

@ -8,7 +8,7 @@ import pytest
from mastodon.Mastodon import Mastodon from mastodon.Mastodon import Mastodon
from kibicara.platforms import mastodon from kibicara.platforms import mastodon
from kibicara.platforms.mastodon.model import MastodonAccount, MastodonInstance from kibicara.platforms.mastodon.model import MastodonAccount
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
@ -52,19 +52,8 @@ async def test_mastodon_create_bot(
print(response.json()) print(response.json())
assert response.status_code == status.HTTP_201_CREATED assert response.status_code == status.HTTP_201_CREATED
bot_id = response.json()["id"] bot_id = response.json()["id"]
mastodon_obj = await MastodonAccount.objects.get(id=bot_id) mastodon_obj = await MastodonAccount.get(id=bot_id)
assert response.json()["access_token"] == mastodon_obj.access_token assert response.json()["access_token"] == mastodon_obj.access_token
mastodon_instance = await MastodonInstance.objects.get(id=mastodon_obj.instance.id)
assert (
response.json()["instance"]["name"]
== body["instance_url"]
== mastodon_instance.name
)
assert response.json()["hood"]["id"] == mastodon_obj.hood.id
assert response.json()["instance"]["client_id"] == mastodon_instance.client_id
assert (
response.json()["instance"]["client_secret"] == mastodon_instance.client_secret
)
assert mastodon_obj.enabled assert mastodon_obj.enabled

View file

@ -4,8 +4,8 @@
# SPDX-License-Identifier: 0BSD # SPDX-License-Identifier: 0BSD
from fastapi import status from fastapi import status
from ormantic.exceptions import NoMatch
import pytest import pytest
from tortoise.exceptions import DoesNotExist
from kibicara.platforms.mastodon.model import MastodonAccount from kibicara.platforms.mastodon.model import MastodonAccount
@ -19,8 +19,8 @@ async def test_mastodon_delete_bot(client, mastodon_account, auth_header):
headers=auth_header, headers=auth_header,
) )
assert response.status_code == status.HTTP_204_NO_CONTENT assert response.status_code == status.HTTP_204_NO_CONTENT
with pytest.raises(NoMatch): with pytest.raises(DoesNotExist):
await MastodonAccount.objects.get(id=mastodon_account.id) await MastodonAccount.get(id=mastodon_account.id)
@pytest.mark.anyio @pytest.mark.anyio
@ -36,7 +36,7 @@ async def test_mastodon_delete_bot_invalid_id(client, auth_header, hood_id):
response = await client.delete( response = await client.delete(
"/api/hoods/{0}/mastodon/wrong".format(hood_id), headers=auth_header "/api/hoods/{0}/mastodon/wrong".format(hood_id), headers=auth_header
) )
assert response.status_code == status.HTTP_404_NOT_FOUND assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
@pytest.mark.anyio @pytest.mark.anyio

View file

@ -13,7 +13,7 @@ from kibicara.platforms.mastodon.model import MastodonAccount
async def test_mastodon_get_bots( async def test_mastodon_get_bots(
client, auth_header, hood_id, mastodon_account, mastodon_instance client, auth_header, hood_id, mastodon_account, mastodon_instance
): ):
mastodon2 = await MastodonAccount.objects.create( mastodon2 = await MastodonAccount.create(
hood=mastodon_account.hood, hood=mastodon_account.hood,
instance=mastodon_instance, instance=mastodon_instance,
access_token="4cc3ss", access_token="4cc3ss",

View file

@ -12,8 +12,8 @@ from kibicara.platforms.telegram.model import Telegram
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
@pytest.mark.anyio @pytest.mark.anyio
async def telegram(hood_id, bot): async def telegram(hood_id, bot):
hood = await Hood.objects.get(id=hood_id) hood = await Hood.get(id=hood_id)
return await Telegram.objects.create( return await Telegram.create(
hood=hood, hood=hood,
api_token=bot["api_token"], api_token=bot["api_token"],
welcome_message=bot["welcome_message"], welcome_message=bot["welcome_message"],

View file

@ -41,14 +41,13 @@ async def test_telegram_create_bot(
) )
assert response.status_code == status.HTTP_201_CREATED assert response.status_code == status.HTTP_201_CREATED
bot_id = response.json()["id"] bot_id = response.json()["id"]
telegram_obj = await Telegram.objects.get(id=bot_id) telegram_obj = await Telegram.get(id=bot_id)
assert response.json()["api_token"] == body["api_token"] == telegram_obj.api_token assert response.json()["api_token"] == body["api_token"] == telegram_obj.api_token
assert ( assert (
response.json()["welcome_message"] response.json()["welcome_message"]
== body["welcome_message"] == body["welcome_message"]
== telegram_obj.welcome_message == telegram_obj.welcome_message
) )
assert response.json()["hood"]["id"] == telegram_obj.hood.id
assert telegram_obj.enabled assert telegram_obj.enabled

View file

@ -4,10 +4,10 @@
# SPDX-License-Identifier: 0BSD # SPDX-License-Identifier: 0BSD
from fastapi import status from fastapi import status
from ormantic.exceptions import NoMatch
import pytest import pytest
from tortoise.exceptions import DoesNotExist
from kibicara.platforms.telegram.model import Telegram, TelegramUser from kibicara.platforms.telegram.model import Telegram, TelegramSubscriber
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -15,17 +15,17 @@ from kibicara.platforms.telegram.model import Telegram, TelegramUser
) )
@pytest.mark.anyio @pytest.mark.anyio
async def test_telegram_delete_bot(client, bot, telegram, auth_header): async def test_telegram_delete_bot(client, bot, telegram, auth_header):
await TelegramUser.objects.create(user_id=1234, bot=telegram.id) await TelegramSubscriber.create(user_id=1234, bot=telegram)
await TelegramUser.objects.create(user_id=5678, bot=telegram.id) await TelegramSubscriber.create(user_id=5678, bot=telegram)
response = await client.delete( response = await client.delete(
"/api/hoods/{0}/telegram/{1}".format(telegram.hood.id, telegram.id), "/api/hoods/{0}/telegram/{1}".format(telegram.hood.id, telegram.id),
headers=auth_header, headers=auth_header,
) )
assert response.status_code == status.HTTP_204_NO_CONTENT assert response.status_code == status.HTTP_204_NO_CONTENT
with pytest.raises(NoMatch): with pytest.raises(DoesNotExist):
await Telegram.objects.get(id=telegram.id) await Telegram.get(id=telegram.id)
with pytest.raises(NoMatch): with pytest.raises(DoesNotExist):
await TelegramUser.objects.get(id=telegram.id) await TelegramSubscriber.get(id=telegram.id)
@pytest.mark.anyio @pytest.mark.anyio

View file

@ -12,13 +12,13 @@ from kibicara.platforms.telegram.model import Telegram
@pytest.mark.anyio @pytest.mark.anyio
async def test_telegram_get_bots(client, auth_header, hood_id): async def test_telegram_get_bots(client, auth_header, hood_id):
hood = await Hood.objects.get(id=hood_id) hood = await Hood.get(id=hood_id)
telegram0 = await Telegram.objects.create( telegram0 = await Telegram.create(
hood=hood, hood=hood,
api_token="api_token123", api_token="api_token123",
welcome_message="welcome_message123", welcome_message="welcome_message123",
) )
telegram1 = await Telegram.objects.create( telegram1 = await Telegram.create(
hood=hood, hood=hood,
api_token="api_token456", api_token="api_token456",
welcome_message="welcome_message123", welcome_message="welcome_message123",