Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: redesign notification module #513

Open
wants to merge 51 commits into
base: 3.2-dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 45 commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
a1063d8
[WIP] refactor: refactor notification module
100gle Oct 4, 2023
ac67230
feat: add brand new implementation for notification services
100gle Oct 6, 2023
45bacbf
feat: adapt services api
100gle Oct 6, 2023
3251392
chore: remove wrong ignore pattern
100gle Oct 6, 2023
32b8726
chore: fix wrong codes
100gle Oct 6, 2023
c3d8bac
chore: adjust try-except block position
100gle Oct 6, 2023
03c2222
test: add basic tests for notification services
100gle Oct 6, 2023
1cb13ba
test: add more unit test cases for notification services
100gle Oct 6, 2023
328a31b
feat: ignore backend test directory
100gle Oct 6, 2023
329538f
ci: fix the test workflow
100gle Oct 6, 2023
cc3642a
chore: add pytest coverage plugin
100gle Oct 6, 2023
f4f2621
feat: remove old notificaion implementation
100gle Oct 6, 2023
d31a775
test: add testing cases for `Notifier` and `NotifierHandler`
100gle Oct 7, 2023
df32588
refactor: reduce duplicated request codes and add new data processing…
100gle Oct 7, 2023
b7c4556
chore: record log for `asend` parameters
100gle Oct 7, 2023
c51394c
chore: add log content filling method
100gle Oct 7, 2023
0d3fb32
refactor: refactor data processing logic for notification services
100gle Oct 7, 2023
10f6052
test: add more unit testing cases for notification services
100gle Oct 7, 2023
9cd0c86
Merge branch '3.1-dev' into notification
100gle Oct 7, 2023
826de34
fix: use notification settings
100gle Oct 7, 2023
299f5cd
feat: integrate configuration for notification services
100gle Oct 7, 2023
d72a37c
feat: add sending test notification button for web ui
100gle Oct 7, 2023
87be3b3
refactor: provide async and sync send methods
100gle Oct 7, 2023
7c616bf
feat: add web api for send notification
100gle Oct 7, 2023
7a674e7
feat: add send notification api
100gle Oct 8, 2023
c5a480a
test: add more unit testing cases for notification services
100gle Oct 8, 2023
f1fad65
ci: fix test issue
100gle Oct 8, 2023
fd094ce
chore: adjust types
100gle Oct 8, 2023
b21a7c0
chore: replace yarl with built-in urllib.parse
100gle Oct 8, 2023
111fdf0
feat(webui): add basic notification component
100gle Oct 9, 2023
ad6387e
feat(webui): add basic notification item
100gle Oct 9, 2023
9909fe7
feat: add notification RESTful api
100gle Oct 10, 2023
c6e9de6
ci: fix test ci issue
100gle Oct 10, 2023
6c2289f
bugfix: fix the queue handling logic
100gle Oct 10, 2023
abb9031
feat: add relative component and page and api for notification
100gle Oct 10, 2023
5017971
feat: replace `aiohttp` with `httpx`
100gle Oct 11, 2023
b173b0c
ci: fix the test ci issue
100gle Oct 11, 2023
dc68e03
chore: reset notification poster path attribute
100gle Oct 11, 2023
c6dd181
chore: add try-except logic for loading local poster
100gle Oct 11, 2023
b63e80e
feat: add pagination for notification api
100gle Oct 11, 2023
3e52dac
feat: add authorization for notification api
100gle Oct 11, 2023
0cdc8d5
bugfix: specify keyword argument for send method
100gle Oct 12, 2023
e71e3af
feat: add notification handler for webui
100gle Oct 12, 2023
3f8bad9
ci: fix the test ci issue
100gle Oct 12, 2023
95e3e2a
Merge branch '3.2-dev' into notification
100gle Oct 12, 2023
de9bca4
refactor(webui): replace `useNotification` hook with `useNotification…
100gle Oct 13, 2023
4080a21
feat: add notification types for backend and frontend
100gle Oct 13, 2023
0afaf8e
ci: fix the test ci issue
100gle Oct 13, 2023
955990c
chore: add exit behaviour for `Notifier`
100gle Oct 13, 2023
9d6535a
feat: add response type for notification api
100gle Oct 13, 2023
45bd0b1
ci: fix the test ci issue
100gle Oct 13, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,5 @@ config
.pytest_cache
test
.env
test.py
test.py
/backend/src/test
3 changes: 1 addition & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
if [ -f backend/requirements.txt ]; then pip install -r backend/requirements.txt; fi
pip install pytest
if [ -f backend/requirements-dev.txt ]; then pip install -r backend/requirements-dev.txt; fi
- name: Test
working-directory: ./backend/src
run: |
Expand Down
5 changes: 0 additions & 5 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -212,8 +212,3 @@ dev-dist

# vitepress
/docs/.vitepress/cache/


# test file
test.*
test_*
8 changes: 7 additions & 1 deletion backend/requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,10 @@
ruff
black
pre-commit
pytest

# testing
pytest # testing framework
pytest-httpx # httpx mock testing
pytest-asyncio # asyncio testing
pytest-cov # testing coverage
pytest-mock # mock wrapper for pytest
3 changes: 3 additions & 0 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,6 @@ sqlmodel==0.0.8
sse-starlette==1.6.5
semver==3.0.1
openai==0.28.1
tenacity==8.2.3
litequeue==0.7
httpx==0.25.0
2 changes: 2 additions & 0 deletions backend/src/module/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from .bangumi import router as bangumi_router
from .config import router as config_router
from .log import router as log_router
from .notification import router as notification_router
from .program import router as program_router
from .rss import router as rss_router
from .search import router as search_router
Expand All @@ -19,3 +20,4 @@
v1.include_router(config_router)
v1.include_router(rss_router)
v1.include_router(search_router)
v1.include_router(notification_router)
115 changes: 115 additions & 0 deletions backend/src/module/api/notification.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
from fastapi import APIRouter, Body, Depends, HTTPException, Query

from module.conf.config import settings
from module.models.api import NotificationMessageIds
from module.notification import Notifier
from module.notification.base import NotificationContent
from module.security.api import get_current_user

router = APIRouter(
prefix="/notification",
tags=["notification"],
dependencies=[Depends(get_current_user)],
)


def get_notifier():
yield Notifier(
service_name=settings.notification.type,
config=settings.notification.dict(by_alias=True),
)


@router.get("/total")
100gle marked this conversation as resolved.
Show resolved Hide resolved
async def get_total_notification(notifier: Notifier = Depends(get_notifier)):
cursor = notifier.q.conn.cursor()
stmt = """SELECT COUNT(*) FROM Queue WHERE status=0"""

try:
total = cursor.execute(stmt).fetchone()[0]
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

return dict(code=0, msg="success", data=dict(total=total))


@router.get("")
async def get_notification(
page: int = Query(1, ge=1),
limit: int = Query(20, ge=10, le=20, description="max limit is 20 per page"),
notifier: Notifier = Depends(get_notifier),
):
cursor = notifier.q.conn.cursor()
stmt = r"""
SELECT message_id, data, in_time as datetime, status as has_read
FROM Queue
WHERE status=0
ORDER BY in_time DESC
"""

offset = (page - 1) * limit
stmt += f"LIMIT {limit} OFFSET {offset}"

try:
rows = cursor.execute(stmt).fetchall()
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

if not rows:
return dict(code=0, msg="success", data=dict(total=0, messages=[]))

messages = [
dict(message_id=data[0], data=data[1], datetime=data[2]) for data in rows
]

return dict(
code=0, msg="success", data=dict(total=len(messages), messages=messages)
)


@router.post("/read")
async def set_notification_read(
body: NotificationMessageIds,
notifier: Notifier = Depends(get_notifier),
):
try:
for message_id in body.message_ids:
notifier.q.done(message_id)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

return dict(code=0, msg="success")


@router.post("/send", description="send notification only for test")
async def send_notification(
content: NotificationContent, notifier: Notifier = Depends(get_notifier)
):
try:
await notifier.asend(content=content)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

return dict(code=0, msg="success")


@router.get("/clean")
async def clean_notification(notifier: Notifier = Depends(get_notifier)):
try:
notifier.q.prune()
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

return dict(code=0, msg="success")


# Note: put this vildcard route at the end of all routes to avoid masking other routes
@router.get("/get")
async def get_notification_by_id(
message_id: str, notifier: Notifier = Depends(get_notifier)
):
message = notifier.q.get(message_id)
if not message:
raise HTTPException(status_code=404, detail="message not found")

return dict(code=0, msg="success", data=message)
4 changes: 4 additions & 0 deletions backend/src/module/conf/const.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# -*- encoding: utf-8 -*-
import pathlib
from urllib.parse import parse_qs, urlparse

DEFAULT_SETTINGS = {
Expand Down Expand Up @@ -116,3 +117,6 @@ def _(color: str, *args: str) -> str:
ENDC = "\033[0m"
BOLD = "\033[1m"
UNDERLINE = "\033[4m"


ROOT = pathlib.Path(__file__).parent.parent.parent.absolute()
18 changes: 12 additions & 6 deletions backend/src/module/core/sub_thread.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import threading
import time
from concurrent.futures import ThreadPoolExecutor

from module.conf import settings
from module.downloader import DownloadClient
from module.manager import Renamer, eps_complete
from module.notification import PostNotification
from module.notification import Notifier
from module.rss import RSSAnalyser, RSSEngine

from .status import ProgramStatus
Expand Down Expand Up @@ -58,11 +58,17 @@ def rename_loop(self):
while not self.stop_event.is_set():
with Renamer() as renamer:
renamed_info = renamer.rename()

if settings.notification.enable:
with PostNotification() as notifier:
for info in renamed_info:
notifier.send_msg(info)
time.sleep(2)
notifier = Notifier(
settings.notification.type,
config=settings.notification.dict(by_alias=True),
)
with ThreadPoolExecutor(max_workers=2) as worker:
worker.map(
lambda info: notifier.send(notification=info), renamed_info
)

self.stop_event.wait(settings.program.rename_time)

def rename_start(self):
Expand Down
6 changes: 5 additions & 1 deletion backend/src/module/models/api.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from pydantic import BaseModel
from pydantic import BaseModel, Field


class RssLink(BaseModel):
Expand All @@ -16,3 +16,7 @@ class ChangeConfig(BaseModel):

class ChangeRule(BaseModel):
rule: dict


class NotificationMessageIds(BaseModel):
message_ids: list[str] = Field(..., description="message ids to be set read")
6 changes: 5 additions & 1 deletion backend/src/module/models/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

from pydantic import BaseModel, Field

from module.notification.services import NotificationType


class Program(BaseModel):
rss_time: int = Field(900, description="Sleep time")
Expand Down Expand Up @@ -70,9 +72,11 @@ def password(self):

class Notification(BaseModel):
enable: bool = Field(False, description="Enable notification")
type: str = Field("telegram", description="Notification type")
type: NotificationType = Field("telegram", description="Notification type")
token_: str = Field("", alias="token", description="Notification token")
chat_id_: str = Field("", alias="chat_id", description="Notification chat id")
base_url: str = Field("", description="Notification base url")
channel: str = Field("", description="Notification channel")

@property
def token(self):
Expand Down
106 changes: 105 additions & 1 deletion backend/src/module/notification/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,105 @@
from .notification import PostNotification
import json
import logging
from typing import get_args

from litequeue import SQLQueue
from tenacity import RetryError

from module.models.bangumi import Notification
from module.notification.base import NotificationContent

from .services import NotificationService, NotificationType, services

logger = logging.getLogger(__name__)


class NotifierHandler(logging.Handler):
def __init__(self, service_name: str, **kwargs) -> None:
notifier_config = kwargs.pop("config", {})
self.notifier = Notifier(service_name, config=notifier_config)
super().__init__(**kwargs)

def emit(self, record: logging.LogRecord) -> None:
try:
self.notifier.send(record=record)
except Exception as e:
logger.error(f"Can't send log record to notifier because: {e}")


class Notifier:
def __init__(self, service_name: str, **kwargs):
assert service_name in get_args(
NotificationType
), f"Invalid service name: {service_name}"

notifier_config = kwargs.pop("config", {})
if not notifier_config:
raise ValueError("Invalid notifier config")

self.notifier = services[service_name](**notifier_config)

from module.conf.const import ROOT

self._queue = SQLQueue(filename_or_conn=ROOT.joinpath("data", "queue.db"))

@property
def q(self) -> SQLQueue:
return self._queue

def _get_json(self, **kwargs):
content: NotificationContent = kwargs.get("content")
notification: Notification = kwargs.get("notification")
record: logging.LogRecord = kwargs.get("record")

if notification:
return notification.json()

if record:
args = dict(
name=record.name,
level=record.levelname,
pathname=record.pathname,
lineno=record.lineno,
msg=record.msg,
)
return json.dumps(args)

if content:
return content.json()

raise ValueError(f"Invalid input data: {kwargs}")

async def asend(self, **kwargs):
data = self._get_json(**kwargs)
try:
await self.notifier.asend(**kwargs)
self.q.put(data)
except RetryError as e:
e.reraise()
except Exception as e:
logger.warning(f"Failed to send notification: {e}")

def send(self, **kwargs) -> bool:
data = self._get_json(**kwargs)
try:
self.notifier.send(**kwargs)
self.q.put(data)
except RetryError as e:
e.reraise()
except Exception as e:
logger.warning(f"Failed to send notification: {e}")

def __enter__(self):
return self

def __exit__(self, exc_type, exc_val, exc_tb):
pass


__all__ = [
"Notifier",
"NotifierHandler",
"NotificationService",
"NotificationType",
"services",
]
Loading