fastapi-plugin-notification 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastapi_plugin_notification/__init__.py +29 -0
- fastapi_plugin_notification/app.py +162 -0
- fastapi_plugin_notification/client.py +206 -0
- fastapi_plugin_notification/controller.py +19 -0
- fastapi_plugin_notification/models.py +46 -0
- fastapi_plugin_notification/schemas.py +91 -0
- fastapi_plugin_notification-0.1.0.dist-info/METADATA +375 -0
- fastapi_plugin_notification-0.1.0.dist-info/RECORD +11 -0
- fastapi_plugin_notification-0.1.0.dist-info/WHEEL +5 -0
- fastapi_plugin_notification-0.1.0.dist-info/licenses/LICENSE +21 -0
- fastapi_plugin_notification-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""FastAPI Notification Plugin
|
|
2
|
+
|
|
3
|
+
A notification plugin for FastAPI that integrates with FastAPI SDK and Postmark provider
|
|
4
|
+
to send notifications via email and store them in a database.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from fastapi_plugin_notification.app import create_notification_router
|
|
8
|
+
from fastapi_plugin_notification.client import NotificationClient
|
|
9
|
+
from fastapi_plugin_notification.controller import NotificationController
|
|
10
|
+
from fastapi_plugin_notification.models import NotificationModel
|
|
11
|
+
from fastapi_plugin_notification.schemas import (
|
|
12
|
+
NotificationCreate,
|
|
13
|
+
NotificationResponse,
|
|
14
|
+
NotificationResponsePaginated,
|
|
15
|
+
NotificationUpdate,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"NotificationClient",
|
|
20
|
+
"NotificationController",
|
|
21
|
+
"NotificationModel",
|
|
22
|
+
"NotificationCreate",
|
|
23
|
+
"NotificationUpdate",
|
|
24
|
+
"NotificationResponse",
|
|
25
|
+
"NotificationResponsePaginated",
|
|
26
|
+
"create_notification_router",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""FastAPI app setup for notification plugin."""
|
|
2
|
+
|
|
3
|
+
from typing import Callable, Optional
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
6
|
+
from fastapi_sdk.security.permissions import require_permission
|
|
7
|
+
from odmantic import AIOEngine
|
|
8
|
+
|
|
9
|
+
from fastapi_plugin_notification.controller import NotificationController
|
|
10
|
+
from fastapi_plugin_notification.schemas import (
|
|
11
|
+
NotificationResponse,
|
|
12
|
+
NotificationResponsePaginated,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def create_notification_router(
|
|
17
|
+
*,
|
|
18
|
+
prefix: str = "/notifications",
|
|
19
|
+
tags: Optional[list[str]] = None,
|
|
20
|
+
get_db: Callable,
|
|
21
|
+
) -> APIRouter:
|
|
22
|
+
"""Create a FastAPI router for user-specific notification endpoints.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
prefix: URL prefix for all routes (default: "/notifications")
|
|
26
|
+
tags: List of tags for API documentation (default: ["notifications"])
|
|
27
|
+
get_db: Database dependency function that returns AIOEngine
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Configured APIRouter instance with user-specific routes only
|
|
31
|
+
"""
|
|
32
|
+
if tags is None:
|
|
33
|
+
tags = ["notifications"]
|
|
34
|
+
|
|
35
|
+
router = APIRouter(prefix=prefix, tags=tags)
|
|
36
|
+
|
|
37
|
+
@router.get("/me", response_model=NotificationResponsePaginated)
|
|
38
|
+
@require_permission("notification:read")
|
|
39
|
+
async def get_my_notifications(
|
|
40
|
+
request: Request,
|
|
41
|
+
page: int = Query(default=1, ge=1, description="Page number (1-based)"),
|
|
42
|
+
seen: Optional[bool] = Query(
|
|
43
|
+
default=None, description="Filter by seen status (true/false)"
|
|
44
|
+
),
|
|
45
|
+
db: AIOEngine = Depends(get_db),
|
|
46
|
+
):
|
|
47
|
+
"""Get notifications for the current user."""
|
|
48
|
+
user_id = request.state.claims.get("sub")
|
|
49
|
+
if not user_id:
|
|
50
|
+
raise HTTPException(status_code=400, detail="user_id not found in claims")
|
|
51
|
+
|
|
52
|
+
controller = NotificationController(db)
|
|
53
|
+
query = [{"user_id": user_id}]
|
|
54
|
+
if seen is not None:
|
|
55
|
+
query.append({"seen": seen})
|
|
56
|
+
|
|
57
|
+
result = await controller.list(
|
|
58
|
+
page=page,
|
|
59
|
+
query=query,
|
|
60
|
+
claims=None, # No ownership rule at controller level, enforced via query
|
|
61
|
+
)
|
|
62
|
+
return result
|
|
63
|
+
|
|
64
|
+
@router.get("/me/{notification_id}", response_model=NotificationResponse)
|
|
65
|
+
@require_permission("notification:read")
|
|
66
|
+
async def get_my_notification(
|
|
67
|
+
request: Request,
|
|
68
|
+
notification_id: str,
|
|
69
|
+
db: AIOEngine = Depends(get_db),
|
|
70
|
+
):
|
|
71
|
+
"""Get a specific notification by ID for the current user and automatically mark it as seen."""
|
|
72
|
+
user_id = request.state.claims.get("sub")
|
|
73
|
+
if not user_id:
|
|
74
|
+
raise HTTPException(status_code=400, detail="user_id not found in claims")
|
|
75
|
+
|
|
76
|
+
controller = NotificationController(db)
|
|
77
|
+
|
|
78
|
+
# Get the notification
|
|
79
|
+
notification = await controller.get(
|
|
80
|
+
uuid=notification_id,
|
|
81
|
+
claims=None, # No ownership rule at controller level
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
if not notification:
|
|
85
|
+
raise HTTPException(status_code=404, detail="Notification not found")
|
|
86
|
+
|
|
87
|
+
# Enforce ownership at route level
|
|
88
|
+
if notification.user_id != user_id:
|
|
89
|
+
raise HTTPException(
|
|
90
|
+
status_code=403,
|
|
91
|
+
detail="Access denied: notification does not belong to you",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# Automatically mark as seen if not already seen
|
|
95
|
+
if not notification.seen:
|
|
96
|
+
notification = await controller.update(
|
|
97
|
+
uuid=notification_id,
|
|
98
|
+
data={"seen": True},
|
|
99
|
+
claims=None, # No ownership rule at controller level
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
return NotificationResponse(**notification.model_dump())
|
|
103
|
+
|
|
104
|
+
@router.delete("/me/{notification_id}")
|
|
105
|
+
@require_permission("notification:delete")
|
|
106
|
+
async def delete_my_notification(
|
|
107
|
+
request: Request,
|
|
108
|
+
notification_id: str,
|
|
109
|
+
db: AIOEngine = Depends(get_db),
|
|
110
|
+
):
|
|
111
|
+
"""Delete a notification for the current user."""
|
|
112
|
+
user_id = request.state.claims.get("sub")
|
|
113
|
+
if not user_id:
|
|
114
|
+
raise HTTPException(status_code=400, detail="user_id not found in claims")
|
|
115
|
+
|
|
116
|
+
controller = NotificationController(db)
|
|
117
|
+
|
|
118
|
+
# Get the notification first to check ownership
|
|
119
|
+
notification = await controller.get(
|
|
120
|
+
uuid=notification_id,
|
|
121
|
+
claims=None, # No ownership rule at controller level
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
if not notification:
|
|
125
|
+
raise HTTPException(status_code=404, detail="Notification not found")
|
|
126
|
+
|
|
127
|
+
# Enforce ownership at route level
|
|
128
|
+
if notification.user_id != user_id:
|
|
129
|
+
raise HTTPException(
|
|
130
|
+
status_code=403,
|
|
131
|
+
detail="Access denied: notification does not belong to you",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# Delete the notification
|
|
135
|
+
deleted = await controller.delete(
|
|
136
|
+
uuid=notification_id,
|
|
137
|
+
claims=None, # No ownership rule at controller level
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
if not deleted:
|
|
141
|
+
raise HTTPException(status_code=404, detail="Notification not found")
|
|
142
|
+
return {"detail": "Notification deleted"}
|
|
143
|
+
|
|
144
|
+
@router.get("/count/unseen", response_model=dict)
|
|
145
|
+
@require_permission("notification:read")
|
|
146
|
+
async def get_unseen_count(
|
|
147
|
+
request: Request,
|
|
148
|
+
db: AIOEngine = Depends(get_db),
|
|
149
|
+
):
|
|
150
|
+
"""Get count of unseen notifications for the current user."""
|
|
151
|
+
user_id = request.state.claims.get("sub")
|
|
152
|
+
if not user_id:
|
|
153
|
+
raise HTTPException(status_code=400, detail="user_id not found in claims")
|
|
154
|
+
|
|
155
|
+
controller = NotificationController(db)
|
|
156
|
+
count = await controller.count(
|
|
157
|
+
query=[{"user_id": user_id}, {"seen": False}],
|
|
158
|
+
claims=None, # No ownership rule at controller level, enforced via query
|
|
159
|
+
)
|
|
160
|
+
return {"count": count}
|
|
161
|
+
|
|
162
|
+
return router
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Notification client for sending notifications."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
|
|
6
|
+
from fastapi_provider_postmark import PostmarkProvider
|
|
7
|
+
from odmantic import AIOEngine
|
|
8
|
+
|
|
9
|
+
from fastapi_plugin_notification.controller import NotificationController
|
|
10
|
+
from fastapi_plugin_notification.models import NotificationModel
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class NotificationClient:
|
|
16
|
+
"""Client for creating and sending notifications."""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
postmark_provider: PostmarkProvider,
|
|
21
|
+
default_template_id: Optional[int] = None,
|
|
22
|
+
):
|
|
23
|
+
"""Initialize the notification client.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
postmark_provider: PostmarkProvider instance for sending emails
|
|
27
|
+
default_template_id: Default Postmark template ID to use for emails
|
|
28
|
+
"""
|
|
29
|
+
self.postmark_provider = postmark_provider
|
|
30
|
+
self.default_template_id = default_template_id
|
|
31
|
+
|
|
32
|
+
async def send_notification(
|
|
33
|
+
self,
|
|
34
|
+
db_engine: AIOEngine,
|
|
35
|
+
name: str,
|
|
36
|
+
user_id: str,
|
|
37
|
+
user_email: str,
|
|
38
|
+
claims: Dict[str, Any],
|
|
39
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
40
|
+
link: Optional[str] = None,
|
|
41
|
+
link_name: Optional[str] = None,
|
|
42
|
+
template_id: Optional[int] = None,
|
|
43
|
+
template_variables: Optional[Dict[str, Any]] = None,
|
|
44
|
+
send_email: bool = True,
|
|
45
|
+
) -> NotificationModel:
|
|
46
|
+
"""Create a notification and optionally send an email.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
db_engine: MongoDB engine instance from odmantic
|
|
50
|
+
name: Name/type of the notification
|
|
51
|
+
user_id: ID of the user who should receive the notification
|
|
52
|
+
user_email: Email address of the user
|
|
53
|
+
claims: User claims for ownership verification (required)
|
|
54
|
+
metadata: Additional metadata about the notification
|
|
55
|
+
link: Call to action link for the notification
|
|
56
|
+
link_name: Text to display on the call to action button/link in the email
|
|
57
|
+
template_id: Postmark template ID to use (uses default if not provided)
|
|
58
|
+
template_variables: Variables to pass to the email template
|
|
59
|
+
send_email: Whether to send an email (default: True)
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
The created notification model instance
|
|
63
|
+
"""
|
|
64
|
+
controller = NotificationController(db_engine)
|
|
65
|
+
|
|
66
|
+
# Prepare notification data
|
|
67
|
+
notification_data = {
|
|
68
|
+
"name": name,
|
|
69
|
+
"user_id": user_id,
|
|
70
|
+
"user_email": user_email,
|
|
71
|
+
"metadata": metadata or {},
|
|
72
|
+
"link": link,
|
|
73
|
+
"link_name": link_name,
|
|
74
|
+
"seen": False,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# Create notification in database
|
|
78
|
+
notification = await controller.create(notification_data, claims=claims)
|
|
79
|
+
|
|
80
|
+
# Send email if requested
|
|
81
|
+
if send_email:
|
|
82
|
+
try:
|
|
83
|
+
# Use provided template_id or default
|
|
84
|
+
email_template_id = template_id or self.default_template_id
|
|
85
|
+
|
|
86
|
+
if not email_template_id:
|
|
87
|
+
logger.warning(
|
|
88
|
+
"No template_id provided and no default_template_id set. "
|
|
89
|
+
"Skipping email send for notification %s",
|
|
90
|
+
notification.uuid,
|
|
91
|
+
)
|
|
92
|
+
else:
|
|
93
|
+
# Prepare template variables
|
|
94
|
+
email_variables = {
|
|
95
|
+
"notification_name": name,
|
|
96
|
+
"user_email": user_email,
|
|
97
|
+
"link": link or "",
|
|
98
|
+
"link_name": link_name or "",
|
|
99
|
+
**(template_variables or {}),
|
|
100
|
+
**(metadata or {}),
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
# Send email via Postmark
|
|
104
|
+
await self.postmark_provider.send_email(
|
|
105
|
+
to=user_email,
|
|
106
|
+
template_id=email_template_id,
|
|
107
|
+
template_variables=email_variables,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
logger.info(
|
|
111
|
+
"Notification email sent successfully for notification %s to %s",
|
|
112
|
+
notification.uuid,
|
|
113
|
+
user_email,
|
|
114
|
+
)
|
|
115
|
+
except Exception as e:
|
|
116
|
+
# Log the error (suppress any warnings from exception handling)
|
|
117
|
+
try:
|
|
118
|
+
logger.error(
|
|
119
|
+
"Failed to send notification email for notification %s: %s",
|
|
120
|
+
notification.uuid,
|
|
121
|
+
str(e),
|
|
122
|
+
)
|
|
123
|
+
except Exception:
|
|
124
|
+
# If logging fails, just pass - don't let logging errors break notification creation
|
|
125
|
+
pass
|
|
126
|
+
# Don't fail the notification creation if email fails
|
|
127
|
+
# The notification is still stored in the database
|
|
128
|
+
|
|
129
|
+
return notification
|
|
130
|
+
|
|
131
|
+
async def get_user_notifications(
|
|
132
|
+
self,
|
|
133
|
+
db_engine: AIOEngine,
|
|
134
|
+
user_id: str,
|
|
135
|
+
page: int = 1,
|
|
136
|
+
seen: Optional[bool] = None,
|
|
137
|
+
claims: Optional[Dict[str, Any]] = None,
|
|
138
|
+
) -> Dict[str, Any]:
|
|
139
|
+
"""Get notifications for a specific user.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
db_engine: MongoDB engine instance from odmantic
|
|
143
|
+
user_id: ID of the user
|
|
144
|
+
page: Page number (1-based)
|
|
145
|
+
seen: Filter by seen status (None for all, True for seen, False for unseen)
|
|
146
|
+
claims: Optional user claims for ownership verification
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Paginated list of notifications
|
|
150
|
+
"""
|
|
151
|
+
controller = NotificationController(db_engine)
|
|
152
|
+
query = [{"user_id": user_id}]
|
|
153
|
+
if seen is not None:
|
|
154
|
+
query.append({"seen": seen})
|
|
155
|
+
|
|
156
|
+
return await controller.list(
|
|
157
|
+
page=page,
|
|
158
|
+
query=query,
|
|
159
|
+
claims=claims,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
async def get_unseen_count(
|
|
163
|
+
self,
|
|
164
|
+
db_engine: AIOEngine,
|
|
165
|
+
user_id: str,
|
|
166
|
+
claims: Optional[Dict[str, Any]] = None,
|
|
167
|
+
) -> int:
|
|
168
|
+
"""Get count of unseen notifications for a user.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
db_engine: MongoDB engine instance from odmantic
|
|
172
|
+
user_id: ID of the user
|
|
173
|
+
claims: Optional user claims for ownership verification
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Count of unseen notifications
|
|
177
|
+
"""
|
|
178
|
+
controller = NotificationController(db_engine)
|
|
179
|
+
return await controller.count(
|
|
180
|
+
query=[{"user_id": user_id}, {"seen": False}],
|
|
181
|
+
claims=claims,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
async def mark_as_seen(
|
|
185
|
+
self,
|
|
186
|
+
db_engine: AIOEngine,
|
|
187
|
+
notification_uuid: str,
|
|
188
|
+
claims: Optional[Dict[str, Any]] = None,
|
|
189
|
+
) -> Optional[NotificationModel]:
|
|
190
|
+
"""Mark a notification as seen.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
db_engine: MongoDB engine instance from odmantic
|
|
194
|
+
notification_uuid: UUID of the notification
|
|
195
|
+
claims: Optional user claims for ownership verification
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
Updated notification model instance or None if not found
|
|
199
|
+
"""
|
|
200
|
+
controller = NotificationController(db_engine)
|
|
201
|
+
result = await controller.update(
|
|
202
|
+
uuid=notification_uuid,
|
|
203
|
+
data={"seen": True},
|
|
204
|
+
claims=claims,
|
|
205
|
+
)
|
|
206
|
+
return result
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Notification controller for CRUD operations."""
|
|
2
|
+
|
|
3
|
+
from fastapi_sdk.controllers import ModelController
|
|
4
|
+
|
|
5
|
+
from fastapi_plugin_notification.models import NotificationModel
|
|
6
|
+
from fastapi_plugin_notification.schemas import (
|
|
7
|
+
NotificationCreate,
|
|
8
|
+
NotificationResponse,
|
|
9
|
+
NotificationUpdate,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class NotificationController(ModelController):
|
|
14
|
+
"""Controller for notification operations."""
|
|
15
|
+
|
|
16
|
+
model = NotificationModel
|
|
17
|
+
schema_create = NotificationCreate
|
|
18
|
+
schema_update = NotificationUpdate
|
|
19
|
+
schema_response = NotificationResponse
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Notification model for database storage."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
|
|
6
|
+
from fastapi_sdk.utils.model import ShortUUID, ShortUUIDType
|
|
7
|
+
from odmantic import Field, Index, Model
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class NotificationModel(Model):
|
|
11
|
+
"""Notification model for storing notifications in the database."""
|
|
12
|
+
|
|
13
|
+
created_at: datetime
|
|
14
|
+
updated_at: datetime
|
|
15
|
+
uuid: ShortUUIDType = Field(default_factory=lambda: ShortUUID.generate("not"))
|
|
16
|
+
name: str = Field(description="Name/type of the notification")
|
|
17
|
+
metadata: Dict[str, Any] = Field(
|
|
18
|
+
default_factory=dict, description="Additional metadata about the notification"
|
|
19
|
+
)
|
|
20
|
+
user_id: str = Field(
|
|
21
|
+
description="ID of the user who should receive the notification"
|
|
22
|
+
)
|
|
23
|
+
user_email: str = Field(
|
|
24
|
+
description="Email address of the user to send notification to"
|
|
25
|
+
)
|
|
26
|
+
link: Optional[str] = Field(
|
|
27
|
+
default=None, description="Call to action link for the notification"
|
|
28
|
+
)
|
|
29
|
+
link_name: Optional[str] = Field(
|
|
30
|
+
default=None,
|
|
31
|
+
description="Text to display on the call to action button/link in the email",
|
|
32
|
+
)
|
|
33
|
+
seen: bool = Field(
|
|
34
|
+
default=False, description="Whether the notification has been seen/acknowledged"
|
|
35
|
+
)
|
|
36
|
+
deleted: bool = False
|
|
37
|
+
|
|
38
|
+
model_config = {
|
|
39
|
+
"collection": "notification",
|
|
40
|
+
"indexes": lambda: [
|
|
41
|
+
Index(NotificationModel.uuid, unique=True),
|
|
42
|
+
Index(NotificationModel.user_id),
|
|
43
|
+
Index(NotificationModel.seen),
|
|
44
|
+
Index(NotificationModel.created_at),
|
|
45
|
+
],
|
|
46
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Schemas for notification API requests and responses."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
|
|
6
|
+
from fastapi_sdk.utils.model import ShortUUIDType
|
|
7
|
+
from fastapi_sdk.utils.schema import BaseResponsePaginated, datetime_now_sec
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class NotificationBase(BaseModel):
|
|
12
|
+
"""Base schema for common notification attributes."""
|
|
13
|
+
|
|
14
|
+
name: str = Field(
|
|
15
|
+
min_length=1, max_length=200, description="Name/type of the notification"
|
|
16
|
+
)
|
|
17
|
+
metadata: Dict[str, Any] = Field(
|
|
18
|
+
default_factory=dict, description="Additional metadata about the notification"
|
|
19
|
+
)
|
|
20
|
+
user_id: str = Field(
|
|
21
|
+
description="ID of the user who should receive the notification"
|
|
22
|
+
)
|
|
23
|
+
user_email: str = Field(
|
|
24
|
+
description="Email address of the user to send notification to"
|
|
25
|
+
)
|
|
26
|
+
link: Optional[str] = Field(
|
|
27
|
+
default=None, description="Call to action link for the notification"
|
|
28
|
+
)
|
|
29
|
+
link_name: Optional[str] = Field(
|
|
30
|
+
default=None,
|
|
31
|
+
description="Text to display on the call to action button/link in the email",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class NotificationCreate(NotificationBase):
|
|
36
|
+
"""Schema for creating a notification."""
|
|
37
|
+
|
|
38
|
+
created_at: datetime = Field(default_factory=datetime_now_sec)
|
|
39
|
+
updated_at: datetime = Field(default_factory=datetime_now_sec)
|
|
40
|
+
seen: bool = Field(
|
|
41
|
+
default=False, description="Whether the notification has been seen"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class NotificationUpdate(BaseModel):
|
|
46
|
+
"""Schema for updating a notification."""
|
|
47
|
+
|
|
48
|
+
name: Optional[str] = Field(
|
|
49
|
+
default=None,
|
|
50
|
+
min_length=1,
|
|
51
|
+
max_length=200,
|
|
52
|
+
description="Name/type of the notification",
|
|
53
|
+
)
|
|
54
|
+
metadata: Optional[Dict[str, Any]] = Field(
|
|
55
|
+
default=None, description="Additional metadata about the notification"
|
|
56
|
+
)
|
|
57
|
+
user_id: Optional[str] = Field(
|
|
58
|
+
default=None, description="ID of the user who should receive the notification"
|
|
59
|
+
)
|
|
60
|
+
user_email: Optional[str] = Field(
|
|
61
|
+
default=None, description="Email address of the user to send notification to"
|
|
62
|
+
)
|
|
63
|
+
link: Optional[str] = Field(
|
|
64
|
+
default=None, description="Call to action link for the notification"
|
|
65
|
+
)
|
|
66
|
+
link_name: Optional[str] = Field(
|
|
67
|
+
default=None,
|
|
68
|
+
description="Text to display on the call to action button/link in the email",
|
|
69
|
+
)
|
|
70
|
+
seen: Optional[bool] = Field(
|
|
71
|
+
default=None, description="Whether the notification has been seen/acknowledged"
|
|
72
|
+
)
|
|
73
|
+
updated_at: datetime = Field(default_factory=datetime_now_sec)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class NotificationResponse(NotificationBase):
|
|
77
|
+
"""Schema for API responses."""
|
|
78
|
+
|
|
79
|
+
uuid: ShortUUIDType
|
|
80
|
+
created_at: datetime
|
|
81
|
+
updated_at: datetime
|
|
82
|
+
seen: bool
|
|
83
|
+
deleted: bool
|
|
84
|
+
|
|
85
|
+
model_config = ConfigDict(from_attributes=True)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class NotificationResponsePaginated(BaseResponsePaginated):
|
|
89
|
+
"""Schema for paginated notification API responses."""
|
|
90
|
+
|
|
91
|
+
items: list[NotificationResponse]
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fastapi-plugin-notification
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Notification plugin for FastAPI projects using FastAPI SDK and Postmark provider.
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Dist: fastapi-sdk>=0.9.7
|
|
9
|
+
Requires-Dist: fastapi-provider-postmark>=0.1.1
|
|
10
|
+
Requires-Dist: fastapi>=0.115.11
|
|
11
|
+
Requires-Dist: odmantic>=1.0.2
|
|
12
|
+
Requires-Dist: pydantic>=2.10.6
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest>=9.0.1; extra == "dev"
|
|
15
|
+
Requires-Dist: pytest-asyncio>=0.25.3; extra == "dev"
|
|
16
|
+
Requires-Dist: motor>=3.6.0; extra == "dev"
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# FastAPI Plugin Notification
|
|
20
|
+
|
|
21
|
+
A notification plugin for FastAPI that integrates with FastAPI SDK and Postmark provider to send notifications via email and store them in a database.
|
|
22
|
+
|
|
23
|
+
## Features
|
|
24
|
+
|
|
25
|
+
- 📧 **Email Notifications**: Send notifications via Postmark email provider
|
|
26
|
+
- 💾 **Database Storage**: Store notifications in MongoDB using ODMantic
|
|
27
|
+
- 🔐 **User Ownership**: Automatic ownership filtering based on user claims
|
|
28
|
+
- 📊 **Notification Management**:
|
|
29
|
+
- Get all notifications for a user
|
|
30
|
+
- Mark notifications as seen/acknowledged
|
|
31
|
+
- Get unseen notification count
|
|
32
|
+
- 🚀 **FastAPI Integration**: Full CRUD API endpoints with authentication
|
|
33
|
+
- 📦 **PyPI Package**: Easy to install and use in any FastAPI project
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
Using UV:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
uv add fastapi-plugin-notification
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Or using pip:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install fastapi-plugin-notification
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Quick Start
|
|
50
|
+
|
|
51
|
+
### 1. Initialize the Notification Client
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from fastapi import FastAPI, Depends
|
|
55
|
+
from odmantic import AIOEngine
|
|
56
|
+
from fastapi_provider_postmark import PostmarkProvider
|
|
57
|
+
from fastapi_plugin_notification import NotificationClient
|
|
58
|
+
|
|
59
|
+
app = FastAPI()
|
|
60
|
+
|
|
61
|
+
# Initialize Postmark provider
|
|
62
|
+
postmark = PostmarkProvider(
|
|
63
|
+
api_key="your-postmark-api-key",
|
|
64
|
+
from_email="noreply@example.com",
|
|
65
|
+
from_name="My App",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Initialize notification client (no DB engine needed)
|
|
69
|
+
notification_client = NotificationClient(
|
|
70
|
+
postmark_provider=postmark,
|
|
71
|
+
default_template_id=123456, # Your Postmark template ID
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Dependency for notification client
|
|
75
|
+
def get_notification_client() -> NotificationClient:
|
|
76
|
+
return notification_client
|
|
77
|
+
|
|
78
|
+
# Dependency for database engine
|
|
79
|
+
def get_db() -> AIOEngine:
|
|
80
|
+
return AIOEngine(database="your_database")
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### 2. Add Notification Routes to Your App
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from fastapi_plugin_notification.app import create_notification_router
|
|
87
|
+
|
|
88
|
+
# Add notification routes
|
|
89
|
+
notification_router = create_notification_router(
|
|
90
|
+
prefix="/notifications",
|
|
91
|
+
get_db=lambda: engine,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
app.include_router(notification_router)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### 3. Send Notifications
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
@app.post("/send-welcome")
|
|
101
|
+
async def send_welcome_notification(
|
|
102
|
+
user_id: str,
|
|
103
|
+
user_email: str,
|
|
104
|
+
notification_client: NotificationClient = Depends(get_notification_client),
|
|
105
|
+
db: AIOEngine = Depends(get_db),
|
|
106
|
+
):
|
|
107
|
+
"""Send a welcome notification to a user."""
|
|
108
|
+
notification = await notification_client.send_notification(
|
|
109
|
+
db_engine=db,
|
|
110
|
+
name="welcome",
|
|
111
|
+
user_id=user_id,
|
|
112
|
+
user_email=user_email,
|
|
113
|
+
claims=request.state.claims, # Required: user claims for ownership verification
|
|
114
|
+
metadata={
|
|
115
|
+
"welcome_message": "Welcome to our platform!",
|
|
116
|
+
},
|
|
117
|
+
link="https://example.com/dashboard",
|
|
118
|
+
link_name="Go to Dashboard",
|
|
119
|
+
template_variables={
|
|
120
|
+
"user_name": "John Doe",
|
|
121
|
+
"action_url": "https://example.com/dashboard",
|
|
122
|
+
},
|
|
123
|
+
)
|
|
124
|
+
return {"notification_id": notification.uuid, "message": "Notification sent"}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### 4. Get User Notifications
|
|
128
|
+
|
|
129
|
+
The plugin automatically provides these user-specific endpoints:
|
|
130
|
+
|
|
131
|
+
- `GET /notifications/me` - Get all notifications for the current user
|
|
132
|
+
- `GET /notifications/me?seen=false` - Get only unseen notifications
|
|
133
|
+
- `GET /notifications/me/{notification_id}` - Get a specific notification (automatically marks as seen)
|
|
134
|
+
- `DELETE /notifications/me/{notification_id}` - Delete a notification
|
|
135
|
+
- `GET /notifications/count/unseen` - Get count of unseen notifications
|
|
136
|
+
|
|
137
|
+
## Usage Examples
|
|
138
|
+
|
|
139
|
+
### Sending a Notification with Email
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
# In a FastAPI route handler
|
|
143
|
+
@app.post("/send-password-reset")
|
|
144
|
+
async def send_password_reset(
|
|
145
|
+
user_id: str,
|
|
146
|
+
user_email: str,
|
|
147
|
+
notification_client: NotificationClient = Depends(get_notification_client),
|
|
148
|
+
db: AIOEngine = Depends(get_db),
|
|
149
|
+
):
|
|
150
|
+
notification = await notification_client.send_notification(
|
|
151
|
+
db_engine=db,
|
|
152
|
+
name="password_reset",
|
|
153
|
+
user_id=user_id,
|
|
154
|
+
user_email=user_email,
|
|
155
|
+
claims=request.state.claims, # Required: user claims for ownership verification
|
|
156
|
+
metadata={
|
|
157
|
+
"reset_token": "abc123",
|
|
158
|
+
"expires_in": 3600,
|
|
159
|
+
},
|
|
160
|
+
link="https://example.com/reset-password?token=abc123",
|
|
161
|
+
link_name="Reset Password",
|
|
162
|
+
template_variables={
|
|
163
|
+
"reset_link": "https://example.com/reset-password?token=abc123",
|
|
164
|
+
"expires_in_minutes": 60,
|
|
165
|
+
},
|
|
166
|
+
)
|
|
167
|
+
return {"notification_id": notification.uuid}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### Getting User Notifications
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
# Get all notifications for a user
|
|
174
|
+
notifications = await notification_client.get_user_notifications(
|
|
175
|
+
db_engine=db,
|
|
176
|
+
user_id="user_123",
|
|
177
|
+
page=1,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
# Get only unseen notifications
|
|
181
|
+
unseen_notifications = await notification_client.get_user_notifications(
|
|
182
|
+
db_engine=db,
|
|
183
|
+
user_id="user_123",
|
|
184
|
+
page=1,
|
|
185
|
+
seen=False,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Get unseen count
|
|
189
|
+
unseen_count = await notification_client.get_unseen_count(
|
|
190
|
+
db_engine=db,
|
|
191
|
+
user_id="user_123",
|
|
192
|
+
)
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### Marking Notifications as Seen
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
# Mark a specific notification as seen
|
|
199
|
+
notification = await notification_client.mark_as_seen(
|
|
200
|
+
db_engine=db,
|
|
201
|
+
notification_uuid="not_abc123",
|
|
202
|
+
)
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### Creating Notifications Without Email
|
|
206
|
+
|
|
207
|
+
```python
|
|
208
|
+
# Create notification without sending email
|
|
209
|
+
notification = await notification_client.send_notification(
|
|
210
|
+
db_engine=db,
|
|
211
|
+
name="system_alert",
|
|
212
|
+
user_id="user_123",
|
|
213
|
+
user_email="user@example.com",
|
|
214
|
+
claims=request.state.claims, # Required: user claims for ownership verification
|
|
215
|
+
metadata={"alert_type": "info"},
|
|
216
|
+
send_email=False, # Don't send email
|
|
217
|
+
)
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## API Endpoints
|
|
221
|
+
|
|
222
|
+
### Get My Notifications
|
|
223
|
+
|
|
224
|
+
```http
|
|
225
|
+
GET /notifications/me?page=1&seen=false
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
**Query Parameters:**
|
|
229
|
+
- `page` (int): Page number (default: 1)
|
|
230
|
+
- `seen` (bool, optional): Filter by seen status
|
|
231
|
+
|
|
232
|
+
**Response:**
|
|
233
|
+
```json
|
|
234
|
+
{
|
|
235
|
+
"items": [
|
|
236
|
+
{
|
|
237
|
+
"uuid": "not_abc123",
|
|
238
|
+
"name": "welcome",
|
|
239
|
+
"user_id": "user_123",
|
|
240
|
+
"user_email": "user@example.com",
|
|
241
|
+
"metadata": {},
|
|
242
|
+
"link": "https://example.com/dashboard",
|
|
243
|
+
"seen": false,
|
|
244
|
+
"created_at": "2024-01-01T00:00:00Z",
|
|
245
|
+
"updated_at": "2024-01-01T00:00:00Z",
|
|
246
|
+
"deleted": false
|
|
247
|
+
}
|
|
248
|
+
],
|
|
249
|
+
"total": 1,
|
|
250
|
+
"page": 1,
|
|
251
|
+
"pages": 1,
|
|
252
|
+
"size": 1
|
|
253
|
+
}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
### Get Unseen Count
|
|
257
|
+
|
|
258
|
+
```http
|
|
259
|
+
GET /notifications/count/unseen
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
**Response:**
|
|
263
|
+
```json
|
|
264
|
+
{
|
|
265
|
+
"count": 5
|
|
266
|
+
}
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
### Get My Notification (Auto-marks as Seen)
|
|
270
|
+
|
|
271
|
+
```http
|
|
272
|
+
GET /notifications/me/{notification_id}
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
**Note:** This endpoint automatically marks the notification as `seen: true` when accessed. Only returns notifications belonging to the current user.
|
|
276
|
+
|
|
277
|
+
**Response:**
|
|
278
|
+
```json
|
|
279
|
+
{
|
|
280
|
+
"uuid": "not_abc123",
|
|
281
|
+
"name": "welcome",
|
|
282
|
+
"user_id": "user_123",
|
|
283
|
+
"user_email": "user@example.com",
|
|
284
|
+
"metadata": {},
|
|
285
|
+
"link": "https://example.com/dashboard",
|
|
286
|
+
"link_name": "Go to Dashboard",
|
|
287
|
+
"seen": true,
|
|
288
|
+
"created_at": "2024-01-01T00:00:00Z",
|
|
289
|
+
"updated_at": "2024-01-01T00:00:00Z",
|
|
290
|
+
"deleted": false
|
|
291
|
+
}
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
## Model Structure
|
|
295
|
+
|
|
296
|
+
The `NotificationModel` includes:
|
|
297
|
+
|
|
298
|
+
- `uuid`: Unique identifier (short UUID with "not" prefix)
|
|
299
|
+
- `name`: Name/type of the notification
|
|
300
|
+
- `metadata`: Additional metadata (dict)
|
|
301
|
+
- `user_id`: ID of the user who should receive the notification
|
|
302
|
+
- `user_email`: Email address of the user
|
|
303
|
+
- `link`: Call to action link (optional)
|
|
304
|
+
- `link_name`: Text to display on the call to action button/link in the email (optional)
|
|
305
|
+
- `seen`: Whether the notification has been seen (default: False)
|
|
306
|
+
- `created_at`: Creation timestamp
|
|
307
|
+
- `updated_at`: Last update timestamp
|
|
308
|
+
- `deleted`: Soft delete flag
|
|
309
|
+
|
|
310
|
+
## Ownership and Permissions
|
|
311
|
+
|
|
312
|
+
The plugin uses FastAPI SDK's ownership rules to ensure users can only access their own notifications. The ownership is based on the `user_id` claim in the JWT token.
|
|
313
|
+
|
|
314
|
+
Required permissions:
|
|
315
|
+
- `notification:create` - Create notifications
|
|
316
|
+
- `notification:read` - Read notifications
|
|
317
|
+
- `notification:update` - Update notifications
|
|
318
|
+
- `notification:delete` - Delete notifications
|
|
319
|
+
|
|
320
|
+
## Configuration
|
|
321
|
+
|
|
322
|
+
### Custom Router Configuration
|
|
323
|
+
|
|
324
|
+
```python
|
|
325
|
+
from fastapi_plugin_notification.app import create_notification_router
|
|
326
|
+
|
|
327
|
+
# Create router with custom prefix and tags
|
|
328
|
+
notification_router = create_notification_router(
|
|
329
|
+
prefix="/notifications",
|
|
330
|
+
tags=["notifications", "alerts"],
|
|
331
|
+
get_db=lambda: engine,
|
|
332
|
+
)
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
**Note:** All routes are user-specific and automatically filter by the current user's ID from JWT claims.
|
|
336
|
+
|
|
337
|
+
### Custom Template Variables
|
|
338
|
+
|
|
339
|
+
The notification client automatically includes:
|
|
340
|
+
- `notification_name`: The name of the notification
|
|
341
|
+
- `user_email`: The user's email
|
|
342
|
+
- `link`: The notification link
|
|
343
|
+
- `link_name`: The text for the call to action button/link
|
|
344
|
+
- All `metadata` fields
|
|
345
|
+
- All `template_variables` fields
|
|
346
|
+
|
|
347
|
+
These are merged and passed to the Postmark template. You can use `link_name` in your email template to display custom button text for the call to action link.
|
|
348
|
+
|
|
349
|
+
## Error Handling
|
|
350
|
+
|
|
351
|
+
The plugin handles errors gracefully:
|
|
352
|
+
- If email sending fails, the notification is still stored in the database
|
|
353
|
+
- Errors are logged but don't interrupt the notification creation process
|
|
354
|
+
- Database errors are propagated as HTTP exceptions
|
|
355
|
+
|
|
356
|
+
## Development
|
|
357
|
+
|
|
358
|
+
### Setup
|
|
359
|
+
|
|
360
|
+
1. Clone the repository
|
|
361
|
+
2. Install dependencies:
|
|
362
|
+
```bash
|
|
363
|
+
uv sync
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
### Running Tests
|
|
367
|
+
|
|
368
|
+
```bash
|
|
369
|
+
pytest
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
## License
|
|
373
|
+
|
|
374
|
+
MIT License - see LICENSE file for details
|
|
375
|
+
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
fastapi_plugin_notification/__init__.py,sha256=Com8pdasPs9LAcqsmmSf0vax0DOjZYzrvXQ5xps_1xI,895
|
|
2
|
+
fastapi_plugin_notification/app.py,sha256=pUku4yW7VgRH5SalRn5lT4lZZz1-3mSHYOk3oy5nUKs,5738
|
|
3
|
+
fastapi_plugin_notification/client.py,sha256=9Z7-sxe8J4qqKrvcgQFan_2ecdFhEA3uZhBEAZWD6_s,7268
|
|
4
|
+
fastapi_plugin_notification/controller.py,sha256=_IJh59F0zGg2la1yjLY-tGXKrC14dofCYgV3Bnk6CCI,547
|
|
5
|
+
fastapi_plugin_notification/models.py,sha256=m-UrdUshVptxIZoAQxKaVz_qAQU77EyjbRxUyASzS-E,1583
|
|
6
|
+
fastapi_plugin_notification/schemas.py,sha256=MyyIM0GS8u9WriX4RFsRKHxrCpAhavM1O43Dss8Waqk,2965
|
|
7
|
+
fastapi_plugin_notification-0.1.0.dist-info/licenses/LICENSE,sha256=deA1OnQ6dNoSO7U62Kof2Dm8w3wiJVG43wen9Ayrvv4,1071
|
|
8
|
+
fastapi_plugin_notification-0.1.0.dist-info/METADATA,sha256=rgkqeXvw6CWV3qnlIJhb7M8Xs9_3VTARLv95bk-dKuU,9953
|
|
9
|
+
fastapi_plugin_notification-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
10
|
+
fastapi_plugin_notification-0.1.0.dist-info/top_level.txt,sha256=ClKDla4uJBmjEnZuGLXNf3zXj0Ecz34wtRzesi3joBY,28
|
|
11
|
+
fastapi_plugin_notification-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Guillaume Piot
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
fastapi_plugin_notification
|