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.
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/bazis-ws.svg)](https://pypi.org/project/bazis-ws/)
29
+ [![Python Versions](https://img.shields.io/pypi/pyversions/bazis-ws.svg)](https://pypi.org/project/bazis-ws/)
30
+ [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ bazis