bazis-ws 2.2.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.
- bazis/contrib/ws/__init__.py +23 -0
- bazis/contrib/ws/_version.py +34 -0
- bazis/contrib/ws/models_abstract.py +53 -0
- bazis/contrib/ws/routes_abstract.py +26 -0
- bazis/contrib/ws/ws.py +207 -0
- bazis_ws-2.2.0.dist-info/METADATA +746 -0
- bazis_ws-2.2.0.dist-info/RECORD +9 -0
- bazis_ws-2.2.0.dist-info/WHEEL +5 -0
- bazis_ws-2.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Copyright 2026 EcoFuture Technology Services LLC and contributors
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
17
|
+
__version__ = version('bazis-ws')
|
|
18
|
+
except PackageNotFoundError:
|
|
19
|
+
__version__ = 'dev'
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
WS_PREFIX = 'user_ws:'
|
|
23
|
+
COMMON_CHANNEL = 'user_ws:common'
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# file generated by setuptools-scm
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"__version__",
|
|
6
|
+
"__version_tuple__",
|
|
7
|
+
"version",
|
|
8
|
+
"version_tuple",
|
|
9
|
+
"__commit_id__",
|
|
10
|
+
"commit_id",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
TYPE_CHECKING = False
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from typing import Tuple
|
|
16
|
+
from typing import Union
|
|
17
|
+
|
|
18
|
+
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
19
|
+
COMMIT_ID = Union[str, None]
|
|
20
|
+
else:
|
|
21
|
+
VERSION_TUPLE = object
|
|
22
|
+
COMMIT_ID = object
|
|
23
|
+
|
|
24
|
+
version: str
|
|
25
|
+
__version__: str
|
|
26
|
+
__version_tuple__: VERSION_TUPLE
|
|
27
|
+
version_tuple: VERSION_TUPLE
|
|
28
|
+
commit_id: COMMIT_ID
|
|
29
|
+
__commit_id__: COMMIT_ID
|
|
30
|
+
|
|
31
|
+
__version__ = version = '2.2.0'
|
|
32
|
+
__version_tuple__ = version_tuple = (2, 2, 0)
|
|
33
|
+
|
|
34
|
+
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Copyright 2026 EcoFuture Technology Services LLC and contributors
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
|
|
18
|
+
from django.conf import settings
|
|
19
|
+
|
|
20
|
+
from redis import Redis
|
|
21
|
+
|
|
22
|
+
from bazis.core.models_abstract import InitialBase
|
|
23
|
+
|
|
24
|
+
from . import WS_PREFIX
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
redis = Redis.from_url(settings.CACHES['default']['LOCATION'])
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class UserWsMixin(InitialBase):
|
|
31
|
+
"""
|
|
32
|
+
Mixin for supporting user websockets
|
|
33
|
+
"""
|
|
34
|
+
class Meta:
|
|
35
|
+
abstract = True
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def user_channel(self) -> str:
|
|
39
|
+
return f'{WS_PREFIX}:{self.pk}'
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def ws_session(self):
|
|
43
|
+
return f'{WS_PREFIX}:{self.pk}:session'
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def is_online(self) -> bool:
|
|
47
|
+
return bool(redis.get(self.ws_session))
|
|
48
|
+
|
|
49
|
+
def ws_publish(self, data: dict) -> int:
|
|
50
|
+
"""
|
|
51
|
+
Publishing a message to the user's channel
|
|
52
|
+
"""
|
|
53
|
+
return redis.publish(self.user_channel, json.dumps(data))
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Copyright 2026 EcoFuture Technology Services LLC and contributors
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
from bazis.contrib.users.routes import UserRouteSet as UserRouteSetBase
|
|
16
|
+
from bazis.core.schemas import SchemaField, SchemaFields
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class UserWsRouteSet(UserRouteSetBase):
|
|
20
|
+
fields = {
|
|
21
|
+
None: SchemaFields(
|
|
22
|
+
include={
|
|
23
|
+
'is_online': SchemaField(),
|
|
24
|
+
},
|
|
25
|
+
),
|
|
26
|
+
}
|
bazis/contrib/ws/ws.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# Copyright 2026 EcoFuture Technology Services LLC and contributors
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
from typing import TYPE_CHECKING, Any, Optional
|
|
19
|
+
|
|
20
|
+
from django.conf import settings
|
|
21
|
+
from django.contrib.auth import get_user_model
|
|
22
|
+
from django.utils.translation import gettext as _
|
|
23
|
+
|
|
24
|
+
from starlette.endpoints import WebSocketEndpoint
|
|
25
|
+
from starlette.routing import WebSocketRoute
|
|
26
|
+
from starlette.websockets import WebSocket
|
|
27
|
+
|
|
28
|
+
import psycopg
|
|
29
|
+
from jose import jwt
|
|
30
|
+
from psycopg.rows import dict_row
|
|
31
|
+
from redis.asyncio import Redis
|
|
32
|
+
|
|
33
|
+
from . import COMMON_CHANNEL
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from .models_abstract import UserWsMixin
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger()
|
|
40
|
+
User = get_user_model()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
db_settings = settings.DATABASES["default"]
|
|
44
|
+
|
|
45
|
+
psycopg3_params = {
|
|
46
|
+
"host": db_settings.get("HOST", "localhost"),
|
|
47
|
+
"port": db_settings.get("PORT", 5432),
|
|
48
|
+
"dbname": db_settings.get("NAME"),
|
|
49
|
+
"user": db_settings.get("USER"),
|
|
50
|
+
"password": db_settings.get("PASSWORD"),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
redis = Redis.from_url(settings.CACHES['default']['LOCATION'])
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class WsError(Exception):
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class WsEndpoint(WebSocketEndpoint):
|
|
61
|
+
encoding = 'json'
|
|
62
|
+
user: Optional['UserWsMixin'] = None
|
|
63
|
+
is_running: bool = False
|
|
64
|
+
|
|
65
|
+
def __init__(self, scope, receive, send):
|
|
66
|
+
super().__init__(scope, receive, send)
|
|
67
|
+
self.active_tasks = []
|
|
68
|
+
|
|
69
|
+
async def on_connect(self, websocket: WebSocket):
|
|
70
|
+
await websocket.accept()
|
|
71
|
+
logger.info('WS:on_connect::start')
|
|
72
|
+
token = websocket.query_params.get('token')
|
|
73
|
+
if token:
|
|
74
|
+
await self.session_start(websocket, token)
|
|
75
|
+
|
|
76
|
+
async def on_receive(self, websocket: WebSocket, data: Any) -> None:
|
|
77
|
+
logger.info(f'WS:on_receive data: {data}')
|
|
78
|
+
|
|
79
|
+
data_json = None
|
|
80
|
+
if isinstance(data, dict):
|
|
81
|
+
data_json = data
|
|
82
|
+
else:
|
|
83
|
+
try:
|
|
84
|
+
data_json = json.loads(data)
|
|
85
|
+
except Exception as exc:
|
|
86
|
+
logger.error(f'WS:on_receive json.loads:error: {exc}')
|
|
87
|
+
|
|
88
|
+
if data_json:
|
|
89
|
+
if data_json.get('type') == 'ping':
|
|
90
|
+
logger.info('WS:received ping, sending pong')
|
|
91
|
+
await websocket.send_json({'type': 'pong'})
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
if token := data_json.get('token'):
|
|
95
|
+
await self.session_start(websocket, token)
|
|
96
|
+
|
|
97
|
+
await super().on_receive(websocket, data)
|
|
98
|
+
|
|
99
|
+
async def on_disconnect(self, websocket: WebSocket, close_code: int):
|
|
100
|
+
logger.info('WS:on_disconnect::start')
|
|
101
|
+
await self.session_stop(websocket)
|
|
102
|
+
logger.info('WS:on_disconnect::finish')
|
|
103
|
+
|
|
104
|
+
async def get_user_from_token(self, token, websocket: WebSocket):
|
|
105
|
+
try:
|
|
106
|
+
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[getattr(settings, 'BAZIS_JWT_SESSION_ALG', 'HS256')])
|
|
107
|
+
username = payload.get('sub')
|
|
108
|
+
except jwt.ExpiredSignatureError:
|
|
109
|
+
await websocket.send_json({
|
|
110
|
+
'type': 'error',
|
|
111
|
+
'code': 'expired_token',
|
|
112
|
+
'detail': _('Token expired'),
|
|
113
|
+
})
|
|
114
|
+
logger.error(f'WS:error:jwt.ExpiredSignatureError: {token}')
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
logger.info(f'WS:get_user_from_token:username: {username}')
|
|
118
|
+
|
|
119
|
+
async with await psycopg.AsyncConnection.connect(**psycopg3_params, row_factory=dict_row) as aconn:
|
|
120
|
+
async with aconn.cursor() as acur:
|
|
121
|
+
await acur.execute(f'SELECT * FROM {User._meta.db_table} WHERE username=%s', (username,))
|
|
122
|
+
result = await acur.fetchone()
|
|
123
|
+
if not result:
|
|
124
|
+
await websocket.send_json({
|
|
125
|
+
'type': 'error',
|
|
126
|
+
'code': 'user_not_found',
|
|
127
|
+
'detail': _('User not found'),
|
|
128
|
+
})
|
|
129
|
+
logger.error(f'WS:get_user_from_token:User not found: {username}')
|
|
130
|
+
return None
|
|
131
|
+
return User(**result)
|
|
132
|
+
|
|
133
|
+
async def session_start(self, websocket: WebSocket, token: str):
|
|
134
|
+
await self.session_stop(websocket)
|
|
135
|
+
|
|
136
|
+
self.user = await self.get_user_from_token(token, websocket)
|
|
137
|
+
logger.info(f'WS:session_start:username: {self.user}')
|
|
138
|
+
if self.user:
|
|
139
|
+
self.is_running = True
|
|
140
|
+
task = asyncio.create_task(self.task_online_update())
|
|
141
|
+
self.active_tasks.append(task)
|
|
142
|
+
task = asyncio.create_task(self.task_listen_queue(websocket))
|
|
143
|
+
self.active_tasks.append(task)
|
|
144
|
+
|
|
145
|
+
async def session_stop(self, websocket: WebSocket, code: int | None = None):
|
|
146
|
+
self.is_running = False
|
|
147
|
+
# waiting for background tasks to complete
|
|
148
|
+
if self.active_tasks:
|
|
149
|
+
await asyncio.gather(*self.active_tasks, return_exceptions=True)
|
|
150
|
+
self.active_tasks = []
|
|
151
|
+
|
|
152
|
+
if self.user:
|
|
153
|
+
await redis.delete(self.user.ws_session)
|
|
154
|
+
self.user = None
|
|
155
|
+
|
|
156
|
+
if code:
|
|
157
|
+
await websocket.close(code)
|
|
158
|
+
|
|
159
|
+
def running_check(self):
|
|
160
|
+
if not self.user:
|
|
161
|
+
raise WsError('WS: User not found')
|
|
162
|
+
if not self.is_running:
|
|
163
|
+
raise WsError('WS: Session not running')
|
|
164
|
+
|
|
165
|
+
for t in self.active_tasks:
|
|
166
|
+
if t.cancelled():
|
|
167
|
+
raise WsError(f'WS: Task {t} cancelled')
|
|
168
|
+
|
|
169
|
+
return True
|
|
170
|
+
|
|
171
|
+
async def task_online_update(self):
|
|
172
|
+
logger.info('WS:task_online_update::start')
|
|
173
|
+
try:
|
|
174
|
+
while self.running_check():
|
|
175
|
+
await redis.set(self.user.ws_session, '1', ex=10)
|
|
176
|
+
# logger.info(f'WS:task_online_update::set: {ws_session_set}')
|
|
177
|
+
await asyncio.sleep(5)
|
|
178
|
+
except Exception as e:
|
|
179
|
+
logger.error(f'WS:task_online_update:error: {e}')
|
|
180
|
+
return
|
|
181
|
+
logger.info('WS:task_online_update::finish')
|
|
182
|
+
|
|
183
|
+
async def task_listen_queue(self, websocket: WebSocket):
|
|
184
|
+
logger.info(f'WS:task_listen_queue::start {self.user.user_channel}')
|
|
185
|
+
try:
|
|
186
|
+
async with redis.pubsub(ignore_subscribe_messages=True) as pubsub:
|
|
187
|
+
await pubsub.subscribe(self.user.user_channel, COMMON_CHANNEL)
|
|
188
|
+
while self.running_check():
|
|
189
|
+
if message := await pubsub.get_message(timeout=1):
|
|
190
|
+
logger.info(f'WS:listen_queue::message::{message}')
|
|
191
|
+
if out_message := message.get('data'):
|
|
192
|
+
if isinstance(out_message, bytes):
|
|
193
|
+
out_message = out_message.decode()
|
|
194
|
+
# if isinstance(out_message, (str, bytes)):
|
|
195
|
+
# out_message = json.loads(out_message)
|
|
196
|
+
await websocket.send_json({
|
|
197
|
+
'type': 'data',
|
|
198
|
+
'data': out_message
|
|
199
|
+
})
|
|
200
|
+
await pubsub.unsubscribe()
|
|
201
|
+
except Exception as e:
|
|
202
|
+
logger.error(f'WS:task_listen_queue:error: {e}')
|
|
203
|
+
return
|
|
204
|
+
logger.info('WS:task_listen_queue::finish')
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
ws_route = WebSocketRoute("/ws", WsEndpoint)
|
|
@@ -0,0 +1,746 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bazis-ws
|
|
3
|
+
Version: 2.2.0
|
|
4
|
+
Summary: WebSocket module for Bazis framework.
|
|
5
|
+
Author-email: Ilya Kharyn <ilya.tt07@gmail.com>
|
|
6
|
+
Maintainer-email: Ilya Kharyn <ilya.tt07@gmail.com>
|
|
7
|
+
Project-URL: Home, https://github.com/ecofuture-tech/bazis-ws
|
|
8
|
+
Keywords: bazis,django,fastapi,pydantic,framework,jsonapi,websocket
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Framework :: Django
|
|
17
|
+
Classifier: Framework :: FastAPI
|
|
18
|
+
Requires-Python: >=3.12
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Requires-Dist: bazis
|
|
21
|
+
Provides-Extra: test
|
|
22
|
+
Requires-Dist: bazis-test-utils; extra == "test"
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: ruff; extra == "dev"
|
|
25
|
+
|
|
26
|
+
# Bazis WS
|
|
27
|
+
|
|
28
|
+
[](https://pypi.org/project/bazis-ws/)
|
|
29
|
+
[](https://pypi.org/project/bazis-ws/)
|
|
30
|
+
[](https://opensource.org/licenses/Apache-2.0)
|
|
31
|
+
|
|
32
|
+
Extension package for Bazis, providing WebSocket connections with authentication support, Redis pub/sub, and user online status tracking.
|
|
33
|
+
|
|
34
|
+
## Quick Start
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
uv add bazis-ws
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
# Add mixin to user model
|
|
42
|
+
from django.contrib.auth.models import AbstractUser
|
|
43
|
+
from bazis.contrib.ws.models_abstract import UserWsMixin
|
|
44
|
+
from bazis.core.models_abstract import JsonApiMixin
|
|
45
|
+
|
|
46
|
+
class User(UserWsMixin, JsonApiMixin, AbstractUser):
|
|
47
|
+
"""User with WebSocket support"""
|
|
48
|
+
class Meta:
|
|
49
|
+
verbose_name = 'User'
|
|
50
|
+
verbose_name_plural = 'Users'
|
|
51
|
+
|
|
52
|
+
# Register WebSocket route
|
|
53
|
+
from bazis.core.app import app
|
|
54
|
+
from bazis.contrib.ws.ws import ws_route
|
|
55
|
+
|
|
56
|
+
app.router.routes.append(ws_route)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Table of Contents
|
|
60
|
+
|
|
61
|
+
- [Description](#description)
|
|
62
|
+
- [Requirements](#requirements)
|
|
63
|
+
- [Installation](#installation)
|
|
64
|
+
- [Core Components](#core-components)
|
|
65
|
+
- [UserWsMixin](#userwsmixin)
|
|
66
|
+
- [WsEndpoint](#wsendpoint)
|
|
67
|
+
- [Architecture](#architecture)
|
|
68
|
+
- [Usage](#usage)
|
|
69
|
+
- [Project Setup](#project-setup)
|
|
70
|
+
- [Connecting to WebSocket](#connecting-to-websocket)
|
|
71
|
+
- [Sending Messages to Users](#sending-messages-to-users)
|
|
72
|
+
- [Checking Online Status](#checking-online-status)
|
|
73
|
+
- [WebSocket Protocol](#websocket-protocol)
|
|
74
|
+
- [Examples](#examples)
|
|
75
|
+
- [License](#license)
|
|
76
|
+
- [Links](#links)
|
|
77
|
+
|
|
78
|
+
## Description
|
|
79
|
+
|
|
80
|
+
**Bazis WS** is an extension package for the Bazis framework that provides a fully-featured WebSocket communication system. The package includes:
|
|
81
|
+
|
|
82
|
+
- **UserWsMixin** — mixin for user model with WebSocket support
|
|
83
|
+
- **WsEndpoint** — ready-to-use WebSocket endpoint with JWT authentication
|
|
84
|
+
- **Redis Pub/Sub** — messaging system between servers and clients
|
|
85
|
+
- **Online Status Tracking** — automatic detection of online/offline users
|
|
86
|
+
- **Personal Channels** — each user has their own channel for receiving messages
|
|
87
|
+
- **Common Channel** — for broadcasting messages to all connected users
|
|
88
|
+
|
|
89
|
+
**This package requires installation of `bazis` and a running Redis server.**
|
|
90
|
+
|
|
91
|
+
## Requirements
|
|
92
|
+
|
|
93
|
+
- **Python**: 3.12+
|
|
94
|
+
- **bazis**: latest version
|
|
95
|
+
- **PostgreSQL**: 12+
|
|
96
|
+
- **Redis**: For pub/sub and caching
|
|
97
|
+
- **Additional libraries**:
|
|
98
|
+
- `python-jose` — for JWT handling
|
|
99
|
+
- `psycopg[binary]` — for asynchronous PostgreSQL access
|
|
100
|
+
- `redis` — for Redis operations
|
|
101
|
+
|
|
102
|
+
## Installation
|
|
103
|
+
|
|
104
|
+
### Using uv (recommended)
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
uv add bazis-ws
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Using pip
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
pip install bazis-ws
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Core Components
|
|
117
|
+
|
|
118
|
+
### UserWsMixin
|
|
119
|
+
|
|
120
|
+
Mixin for user model that adds WebSocket support.
|
|
121
|
+
|
|
122
|
+
**Location**: `bazis.contrib.ws.models_abstract.UserWsMixin`
|
|
123
|
+
|
|
124
|
+
**Properties**:
|
|
125
|
+
|
|
126
|
+
- `user_channel` — user's personal channel in Redis (format: `user_ws:{user_id}`)
|
|
127
|
+
- `ws_session` — WebSocket session key in Redis (format: `user_ws:{user_id}:session`)
|
|
128
|
+
- `is_online` — boolean property indicating whether the user is connected to WebSocket
|
|
129
|
+
|
|
130
|
+
**Methods**:
|
|
131
|
+
|
|
132
|
+
- `ws_publish(data: dict) -> int` — send message to user via their personal channel
|
|
133
|
+
|
|
134
|
+
**Usage Example**:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
from django.contrib.auth.models import AbstractUser
|
|
138
|
+
from bazis.contrib.ws.models_abstract import UserWsMixin
|
|
139
|
+
from bazis.core.models_abstract import JsonApiMixin
|
|
140
|
+
|
|
141
|
+
class User(UserWsMixin, JsonApiMixin, AbstractUser):
|
|
142
|
+
"""User with WebSocket support"""
|
|
143
|
+
class Meta:
|
|
144
|
+
verbose_name = 'User'
|
|
145
|
+
verbose_name_plural = 'Users'
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### WsEndpoint
|
|
149
|
+
|
|
150
|
+
WebSocket endpoint with authentication and session management support.
|
|
151
|
+
|
|
152
|
+
**Location**: `bazis.contrib.ws.ws.WsEndpoint`
|
|
153
|
+
|
|
154
|
+
**Based on**: `starlette.endpoints.WebSocketEndpoint`
|
|
155
|
+
|
|
156
|
+
**Key Features**:
|
|
157
|
+
|
|
158
|
+
1. **JWT Token Authentication**:
|
|
159
|
+
- On connection: `ws://api.example.com/ws?token=<jwt_token>`
|
|
160
|
+
- During session: sending `{"token": "<jwt_token>"}`
|
|
161
|
+
|
|
162
|
+
2. **Automatic Online Status Tracking**:
|
|
163
|
+
- Status update every 5 seconds
|
|
164
|
+
- Redis entry TTL: 10 seconds
|
|
165
|
+
|
|
166
|
+
3. **Channel Subscription**:
|
|
167
|
+
- User's personal channel
|
|
168
|
+
- Common channel for all users
|
|
169
|
+
|
|
170
|
+
4. **Ping/Pong**:
|
|
171
|
+
- Client sends `{"type": "ping"}`
|
|
172
|
+
- Server responds `{"type": "pong"}`
|
|
173
|
+
|
|
174
|
+
**Connection Lifecycle**:
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
1. on_connect() → accept connection
|
|
178
|
+
2. session_start() → authenticate and start background tasks
|
|
179
|
+
├─ task_online_update() → update online status
|
|
180
|
+
└─ task_listen_queue() → listen to Redis channels
|
|
181
|
+
3. on_receive() → handle incoming messages
|
|
182
|
+
4. on_disconnect() → cleanup resources
|
|
183
|
+
5. session_stop() → stop background tasks
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### Architecture
|
|
187
|
+
|
|
188
|
+
```
|
|
189
|
+
┌─────────────┐ ┌──────────────┐
|
|
190
|
+
│ Client │◄──WebSocket───────►│ WsEndpoint │
|
|
191
|
+
│ (Browser/ │ │ │
|
|
192
|
+
│ Mobile) │ │ Starlette │
|
|
193
|
+
└─────────────┘ └───────┬──────┘
|
|
194
|
+
│
|
|
195
|
+
│ JWT Auth
|
|
196
|
+
▼
|
|
197
|
+
┌──────────────┐
|
|
198
|
+
│ PostgreSQL │
|
|
199
|
+
│ (User DB) │
|
|
200
|
+
└──────────────┘
|
|
201
|
+
│
|
|
202
|
+
│
|
|
203
|
+
▼
|
|
204
|
+
┌─────────────┐ ┌──────────────┐
|
|
205
|
+
│ Backend │────publish────────►│ Redis │
|
|
206
|
+
│ Service │ │ Pub/Sub │
|
|
207
|
+
└─────────────┘ └───────┬──────┘
|
|
208
|
+
│
|
|
209
|
+
│ subscribe
|
|
210
|
+
▼
|
|
211
|
+
┌──────────────┐
|
|
212
|
+
│ WsEndpoint │
|
|
213
|
+
│ │
|
|
214
|
+
└───────┬──────┘
|
|
215
|
+
│
|
|
216
|
+
│ send_json
|
|
217
|
+
▼
|
|
218
|
+
┌──────────────┐
|
|
219
|
+
│ Client │
|
|
220
|
+
└──────────────┘
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
**Redis Channels**:
|
|
224
|
+
|
|
225
|
+
- `user_ws:{user_id}` — user's personal channel
|
|
226
|
+
- `user_ws:common` — common channel for all users
|
|
227
|
+
- `user_ws:{user_id}:session` — active session key (TTL: 10 seconds)
|
|
228
|
+
|
|
229
|
+
## Usage
|
|
230
|
+
|
|
231
|
+
### Project Setup
|
|
232
|
+
|
|
233
|
+
**1. Add mixin to user model**:
|
|
234
|
+
|
|
235
|
+
```python
|
|
236
|
+
# models.py
|
|
237
|
+
from django.contrib.auth.models import AbstractUser
|
|
238
|
+
from bazis.contrib.ws.models_abstract import UserWsMixin
|
|
239
|
+
from bazis.core.models_abstract import JsonApiMixin
|
|
240
|
+
|
|
241
|
+
class User(UserWsMixin, JsonApiMixin, AbstractUser):
|
|
242
|
+
class Meta:
|
|
243
|
+
verbose_name = 'User'
|
|
244
|
+
verbose_name_plural = 'Users'
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
**2. Add `is_online` field to user routes**:
|
|
248
|
+
|
|
249
|
+
```python
|
|
250
|
+
# routes.py
|
|
251
|
+
from bazis.contrib.ws.routes_abstract import UserWsRouteSet
|
|
252
|
+
from django.apps import apps
|
|
253
|
+
|
|
254
|
+
class UserRouteSet(UserWsRouteSet):
|
|
255
|
+
model = apps.get_model('myapp.User')
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
**3. Register WebSocket route**:
|
|
259
|
+
|
|
260
|
+
```python
|
|
261
|
+
# main.py or app.py
|
|
262
|
+
from bazis.core.app import app
|
|
263
|
+
from bazis.contrib.ws.ws import ws_route
|
|
264
|
+
|
|
265
|
+
app.router.routes.append(ws_route)
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
### Connecting to WebSocket
|
|
269
|
+
|
|
270
|
+
#### JavaScript Client
|
|
271
|
+
|
|
272
|
+
```javascript
|
|
273
|
+
class WebSocketClient {
|
|
274
|
+
constructor(url, token) {
|
|
275
|
+
this.url = url;
|
|
276
|
+
this.token = token;
|
|
277
|
+
this.ws = null;
|
|
278
|
+
this.reconnectInterval = 5000;
|
|
279
|
+
this.pingInterval = 30000;
|
|
280
|
+
this.pingTimer = null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
connect() {
|
|
284
|
+
this.ws = new WebSocket(`${this.url}?token=${this.token}`);
|
|
285
|
+
|
|
286
|
+
this.ws.onopen = () => {
|
|
287
|
+
console.log('WebSocket connected');
|
|
288
|
+
this.startPing();
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
this.ws.onmessage = (event) => {
|
|
292
|
+
const data = JSON.parse(event.data);
|
|
293
|
+
this.handleMessage(data);
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
this.ws.onerror = (error) => {
|
|
297
|
+
console.error('WebSocket error:', error);
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
this.ws.onclose = () => {
|
|
301
|
+
console.log('WebSocket disconnected');
|
|
302
|
+
this.stopPing();
|
|
303
|
+
// Reconnect
|
|
304
|
+
setTimeout(() => this.connect(), this.reconnectInterval);
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
handleMessage(data) {
|
|
309
|
+
switch (data.type) {
|
|
310
|
+
case 'pong':
|
|
311
|
+
console.log('Received pong');
|
|
312
|
+
break;
|
|
313
|
+
case 'data':
|
|
314
|
+
console.log('Received data:', data.data);
|
|
315
|
+
// Process received data
|
|
316
|
+
this.onData(data.data);
|
|
317
|
+
break;
|
|
318
|
+
case 'error':
|
|
319
|
+
console.error('Error:', data.code, data.detail);
|
|
320
|
+
break;
|
|
321
|
+
default:
|
|
322
|
+
console.log('Unknown message type:', data);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
startPing() {
|
|
327
|
+
this.pingTimer = setInterval(() => {
|
|
328
|
+
if (this.ws.readyState === WebSocket.OPEN) {
|
|
329
|
+
this.ws.send(JSON.stringify({ type: 'ping' }));
|
|
330
|
+
}
|
|
331
|
+
}, this.pingInterval);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
stopPing() {
|
|
335
|
+
if (this.pingTimer) {
|
|
336
|
+
clearInterval(this.pingTimer);
|
|
337
|
+
this.pingTimer = null;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
onData(data) {
|
|
342
|
+
// Override this method to handle data
|
|
343
|
+
console.log('Data received:', data);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
disconnect() {
|
|
347
|
+
this.stopPing();
|
|
348
|
+
if (this.ws) {
|
|
349
|
+
this.ws.close();
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Usage
|
|
355
|
+
const ws = new WebSocketClient('ws://api.example.com/ws', jwtToken);
|
|
356
|
+
ws.onData = (data) => {
|
|
357
|
+
console.log('Processing data:', data);
|
|
358
|
+
// Your processing logic
|
|
359
|
+
};
|
|
360
|
+
ws.connect();
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
#### Python Client
|
|
364
|
+
|
|
365
|
+
```python
|
|
366
|
+
import asyncio
|
|
367
|
+
import json
|
|
368
|
+
import websockets
|
|
369
|
+
|
|
370
|
+
async def websocket_client(url, token):
|
|
371
|
+
uri = f"{url}?token={token}"
|
|
372
|
+
|
|
373
|
+
async with websockets.connect(uri) as websocket:
|
|
374
|
+
print("WebSocket connected")
|
|
375
|
+
|
|
376
|
+
# Background task for ping
|
|
377
|
+
async def send_ping():
|
|
378
|
+
while True:
|
|
379
|
+
await asyncio.sleep(30)
|
|
380
|
+
await websocket.send(json.dumps({"type": "ping"}))
|
|
381
|
+
|
|
382
|
+
ping_task = asyncio.create_task(send_ping())
|
|
383
|
+
|
|
384
|
+
try:
|
|
385
|
+
async for message in websocket:
|
|
386
|
+
data = json.loads(message)
|
|
387
|
+
|
|
388
|
+
if data['type'] == 'pong':
|
|
389
|
+
print("Received pong")
|
|
390
|
+
elif data['type'] == 'data':
|
|
391
|
+
print(f"Received data: {data['data']}")
|
|
392
|
+
elif data['type'] == 'error':
|
|
393
|
+
print(f"Error: {data['code']} - {data['detail']}")
|
|
394
|
+
finally:
|
|
395
|
+
ping_task.cancel()
|
|
396
|
+
|
|
397
|
+
# Usage
|
|
398
|
+
asyncio.run(websocket_client('ws://api.example.com/ws', jwt_token))
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
### Sending Messages to Users
|
|
402
|
+
|
|
403
|
+
#### From Django View or API Endpoint
|
|
404
|
+
|
|
405
|
+
```python
|
|
406
|
+
from django.contrib.auth import get_user_model
|
|
407
|
+
|
|
408
|
+
User = get_user_model()
|
|
409
|
+
|
|
410
|
+
def send_notification_to_user(user_id, message):
|
|
411
|
+
"""Send notification to specific user"""
|
|
412
|
+
user = User.objects.get(id=user_id)
|
|
413
|
+
|
|
414
|
+
if user.is_online:
|
|
415
|
+
user.ws_publish({
|
|
416
|
+
'type': 'notification',
|
|
417
|
+
'title': 'New Notification',
|
|
418
|
+
'message': message,
|
|
419
|
+
'timestamp': datetime.now().isoformat()
|
|
420
|
+
})
|
|
421
|
+
return True
|
|
422
|
+
return False
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
#### From Celery Task
|
|
426
|
+
|
|
427
|
+
```python
|
|
428
|
+
from celery import shared_task
|
|
429
|
+
from django.contrib.auth import get_user_model
|
|
430
|
+
|
|
431
|
+
User = get_user_model()
|
|
432
|
+
|
|
433
|
+
@shared_task
|
|
434
|
+
def notify_user_async(user_id, notification_data):
|
|
435
|
+
"""Asynchronously send notification to user"""
|
|
436
|
+
try:
|
|
437
|
+
user = User.objects.get(id=user_id)
|
|
438
|
+
user.ws_publish({
|
|
439
|
+
'type': 'task_completed',
|
|
440
|
+
'data': notification_data
|
|
441
|
+
})
|
|
442
|
+
except User.DoesNotExist:
|
|
443
|
+
pass
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
#### Broadcasting to All Online Users
|
|
447
|
+
|
|
448
|
+
```python
|
|
449
|
+
from redis import Redis
|
|
450
|
+
from django.conf import settings
|
|
451
|
+
import json
|
|
452
|
+
|
|
453
|
+
redis = Redis.from_url(settings.CACHES['default']['LOCATION'])
|
|
454
|
+
|
|
455
|
+
def broadcast_message(message):
|
|
456
|
+
"""Send message to all connected users"""
|
|
457
|
+
from bazis.contrib.ws import COMMON_CHANNEL
|
|
458
|
+
|
|
459
|
+
redis.publish(COMMON_CHANNEL, json.dumps({
|
|
460
|
+
'type': 'broadcast',
|
|
461
|
+
'message': message
|
|
462
|
+
}))
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
### Checking Online Status
|
|
466
|
+
|
|
467
|
+
#### In Django Template
|
|
468
|
+
|
|
469
|
+
```python
|
|
470
|
+
from django.contrib.auth import get_user_model
|
|
471
|
+
|
|
472
|
+
User = get_user_model()
|
|
473
|
+
|
|
474
|
+
def user_list_view(request):
|
|
475
|
+
users = User.objects.all()
|
|
476
|
+
|
|
477
|
+
online_users = [user for user in users if user.is_online]
|
|
478
|
+
offline_users = [user for user in users if not user.is_online]
|
|
479
|
+
|
|
480
|
+
return render(request, 'users.html', {
|
|
481
|
+
'online_users': online_users,
|
|
482
|
+
'offline_users': offline_users
|
|
483
|
+
})
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
#### Via API (using UserWsRouteSet)
|
|
487
|
+
|
|
488
|
+
```bash
|
|
489
|
+
GET /api/v1/<app>/<resource>/
|
|
490
|
+
Authorization: Bearer <token>
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
**Response**:
|
|
494
|
+
```json
|
|
495
|
+
{
|
|
496
|
+
"data": [
|
|
497
|
+
{
|
|
498
|
+
"type": "app.user",
|
|
499
|
+
"id": "123",
|
|
500
|
+
"attributes": {
|
|
501
|
+
"username": "john_doe",
|
|
502
|
+
"email": "john@example.com",
|
|
503
|
+
"is_online": true
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
]
|
|
507
|
+
}
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
## WebSocket Protocol
|
|
511
|
+
|
|
512
|
+
### Messages from Client
|
|
513
|
+
|
|
514
|
+
#### Ping
|
|
515
|
+
|
|
516
|
+
```json
|
|
517
|
+
{
|
|
518
|
+
"type": "ping"
|
|
519
|
+
}
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
#### Authentication During Session
|
|
523
|
+
|
|
524
|
+
```json
|
|
525
|
+
{
|
|
526
|
+
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
|
527
|
+
}
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
### Messages from Server
|
|
531
|
+
|
|
532
|
+
#### Pong
|
|
533
|
+
|
|
534
|
+
```json
|
|
535
|
+
{
|
|
536
|
+
"type": "pong"
|
|
537
|
+
}
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
#### Data
|
|
541
|
+
|
|
542
|
+
```json
|
|
543
|
+
{
|
|
544
|
+
"type": "data",
|
|
545
|
+
"data": {
|
|
546
|
+
"type": "notification",
|
|
547
|
+
"title": "New Message",
|
|
548
|
+
"message": "You have a new message from admin"
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
#### Error
|
|
554
|
+
|
|
555
|
+
```json
|
|
556
|
+
{
|
|
557
|
+
"type": "error",
|
|
558
|
+
"code": "expired_token",
|
|
559
|
+
"detail": "Token expired"
|
|
560
|
+
}
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
**Error Codes**:
|
|
564
|
+
|
|
565
|
+
- `expired_token` — JWT token has expired
|
|
566
|
+
- `user_not_found` — user not found in database
|
|
567
|
+
|
|
568
|
+
## Examples
|
|
569
|
+
|
|
570
|
+
### Real-time Chat Example
|
|
571
|
+
|
|
572
|
+
**Backend (sending message)**:
|
|
573
|
+
|
|
574
|
+
```python
|
|
575
|
+
from django.contrib.auth import get_user_model
|
|
576
|
+
from django.http import JsonResponse
|
|
577
|
+
from django.views.decorators.http import require_POST
|
|
578
|
+
import json
|
|
579
|
+
|
|
580
|
+
User = get_user_model()
|
|
581
|
+
|
|
582
|
+
@require_POST
|
|
583
|
+
def send_message(request):
|
|
584
|
+
data = json.loads(request.body)
|
|
585
|
+
recipient_id = data.get('recipient_id')
|
|
586
|
+
message = data.get('message')
|
|
587
|
+
|
|
588
|
+
try:
|
|
589
|
+
recipient = User.objects.get(id=recipient_id)
|
|
590
|
+
|
|
591
|
+
# Send message via WebSocket
|
|
592
|
+
if recipient.is_online:
|
|
593
|
+
recipient.ws_publish({
|
|
594
|
+
'type': 'chat_message',
|
|
595
|
+
'sender': {
|
|
596
|
+
'id': str(request.user.id),
|
|
597
|
+
'username': request.user.username
|
|
598
|
+
},
|
|
599
|
+
'message': message,
|
|
600
|
+
'timestamp': datetime.now().isoformat()
|
|
601
|
+
})
|
|
602
|
+
return JsonResponse({'status': 'sent'})
|
|
603
|
+
else:
|
|
604
|
+
# Save offline message
|
|
605
|
+
return JsonResponse({'status': 'saved_offline'})
|
|
606
|
+
|
|
607
|
+
except User.DoesNotExist:
|
|
608
|
+
return JsonResponse({'error': 'User not found'}, status=404)
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
**Frontend (receiving message)**:
|
|
612
|
+
|
|
613
|
+
```javascript
|
|
614
|
+
class ChatClient extends WebSocketClient {
|
|
615
|
+
onData(data) {
|
|
616
|
+
if (data.type === 'chat_message') {
|
|
617
|
+
this.displayMessage(data.sender, data.message, data.timestamp);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
displayMessage(sender, message, timestamp) {
|
|
622
|
+
const messageElement = document.createElement('div');
|
|
623
|
+
messageElement.className = 'chat-message';
|
|
624
|
+
messageElement.innerHTML = `
|
|
625
|
+
<div class="sender">${sender.username}</div>
|
|
626
|
+
<div class="message">${message}</div>
|
|
627
|
+
<div class="timestamp">${new Date(timestamp).toLocaleString()}</div>
|
|
628
|
+
`;
|
|
629
|
+
document.getElementById('chat-messages').appendChild(messageElement);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const chat = new ChatClient('ws://api.example.com/ws', jwtToken);
|
|
634
|
+
chat.connect();
|
|
635
|
+
```
|
|
636
|
+
|
|
637
|
+
### Task Notification Example
|
|
638
|
+
|
|
639
|
+
**Celery Task**:
|
|
640
|
+
|
|
641
|
+
```python
|
|
642
|
+
from celery import shared_task
|
|
643
|
+
from django.contrib.auth import get_user_model
|
|
644
|
+
|
|
645
|
+
User = get_user_model()
|
|
646
|
+
|
|
647
|
+
@shared_task
|
|
648
|
+
def process_long_running_task(user_id, task_data):
|
|
649
|
+
"""Long-running task with user notification"""
|
|
650
|
+
user = User.objects.get(id=user_id)
|
|
651
|
+
|
|
652
|
+
# Notify about start
|
|
653
|
+
user.ws_publish({
|
|
654
|
+
'type': 'task_started',
|
|
655
|
+
'task_id': process_long_running_task.request.id,
|
|
656
|
+
'message': 'Processing started...'
|
|
657
|
+
})
|
|
658
|
+
|
|
659
|
+
try:
|
|
660
|
+
# Execute task
|
|
661
|
+
result = perform_processing(task_data)
|
|
662
|
+
|
|
663
|
+
# Notify about success
|
|
664
|
+
user.ws_publish({
|
|
665
|
+
'type': 'task_completed',
|
|
666
|
+
'task_id': process_long_running_task.request.id,
|
|
667
|
+
'result': result,
|
|
668
|
+
'message': 'Processing completed successfully'
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
except Exception as e:
|
|
672
|
+
# Notify about error
|
|
673
|
+
user.ws_publish({
|
|
674
|
+
'type': 'task_failed',
|
|
675
|
+
'task_id': process_long_running_task.request.id,
|
|
676
|
+
'error': str(e),
|
|
677
|
+
'message': 'An error occurred during processing'
|
|
678
|
+
})
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
### Online Indicator Example
|
|
682
|
+
|
|
683
|
+
**JavaScript Component**:
|
|
684
|
+
|
|
685
|
+
```javascript
|
|
686
|
+
class OnlineIndicator {
|
|
687
|
+
constructor(userId) {
|
|
688
|
+
this.userId = userId;
|
|
689
|
+
this.indicator = document.getElementById(`user-${userId}-status`);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
async checkStatus() {
|
|
693
|
+
const response = await fetch(`/api/v1/users/user/${this.userId}/`, {
|
|
694
|
+
headers: {
|
|
695
|
+
'Authorization': `Bearer ${token}`
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
const data = await response.json();
|
|
700
|
+
const isOnline = data.data.attributes.is_online;
|
|
701
|
+
|
|
702
|
+
this.updateIndicator(isOnline);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
updateIndicator(isOnline) {
|
|
706
|
+
if (isOnline) {
|
|
707
|
+
this.indicator.classList.add('online');
|
|
708
|
+
this.indicator.classList.remove('offline');
|
|
709
|
+
this.indicator.textContent = 'Online';
|
|
710
|
+
} else {
|
|
711
|
+
this.indicator.classList.add('offline');
|
|
712
|
+
this.indicator.classList.remove('online');
|
|
713
|
+
this.indicator.textContent = 'Offline';
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Periodic status check
|
|
719
|
+
const indicator = new OnlineIndicator('user-123');
|
|
720
|
+
setInterval(() => indicator.checkStatus(), 10000);
|
|
721
|
+
```
|
|
722
|
+
|
|
723
|
+
## License
|
|
724
|
+
|
|
725
|
+
Apache License 2.0
|
|
726
|
+
|
|
727
|
+
See [LICENSE](LICENSE) file for details.
|
|
728
|
+
|
|
729
|
+
## Links
|
|
730
|
+
|
|
731
|
+
- [Bazis Documentation](https://github.com/ecofuture-tech/bazis) — main repository
|
|
732
|
+
- [Bazis WS Repository](https://github.com/ecofuture-tech/bazis-ws) — package repository
|
|
733
|
+
- [Issue Tracker](https://github.com/ecofuture-tech/bazis-ws/issues) — report bugs or request features
|
|
734
|
+
- [Starlette WebSockets](https://www.starlette.io/websockets/) — Starlette WebSocket documentation
|
|
735
|
+
- [Redis Pub/Sub](https://redis.io/docs/manual/pubsub/) — Redis Pub/Sub documentation
|
|
736
|
+
|
|
737
|
+
## Support
|
|
738
|
+
|
|
739
|
+
If you have questions or issues:
|
|
740
|
+
- Check the [Bazis documentation](https://github.com/ecofuture-tech/bazis)
|
|
741
|
+
- Search through [existing issues](https://github.com/ecofuture-tech/bazis-ws/issues)
|
|
742
|
+
- Create a [new issue](https://github.com/ecofuture-tech/bazis-ws/issues/new) with detailed information
|
|
743
|
+
|
|
744
|
+
---
|
|
745
|
+
|
|
746
|
+
Made with ❤️ by Bazis team
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
bazis/contrib/ws/__init__.py,sha256=r9miWfon4WhrG4ghc2-NW_Yh3cfsDcfRuz-nfF_PJaw,835
|
|
2
|
+
bazis/contrib/ws/_version.py,sha256=6OGz4a0gjMGlckPyPCNiJDWyFDO-tWO8O_ZNx4ajT2Y,704
|
|
3
|
+
bazis/contrib/ws/models_abstract.py,sha256=3dRFs1gWC59miEv2M2bBqA5sPzCOU1XB7oIexNaVzmQ,1422
|
|
4
|
+
bazis/contrib/ws/routes_abstract.py,sha256=XfPp7wHy7dRgUJ0x_IgjKC0hFB3ctVo0oqA9-wnEjvc,927
|
|
5
|
+
bazis/contrib/ws/ws.py,sha256=PKw91t-Z8uLTsKKoJjiU4CUMJ3BlmF4yXbuDbRF5yrc,7559
|
|
6
|
+
bazis_ws-2.2.0.dist-info/METADATA,sha256=k6emaD__JeFEv_qfbsZWbt3Ko777gHAFjiv1ax-pNP8,19909
|
|
7
|
+
bazis_ws-2.2.0.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
|
|
8
|
+
bazis_ws-2.2.0.dist-info/top_level.txt,sha256=WgdrPZTZBMG8i_EqxA3vU5qI4ETQ_RsqKqSqsfIApHY,6
|
|
9
|
+
bazis_ws-2.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bazis
|