rxdjango 0.0.2__tar.gz

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,20 @@
1
+ Copyright (c) 2022 Control Devices, Inc
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.1
2
+ Name: rxdjango
3
+ Version: 0.0.2
4
+ Home-page: https://github.com/CDIGlobalTrack/rxdjango
5
+ Author: Luis Fagundes
6
+ Author-email: lhfagundes@gmail.com
7
+ License: LICENSE.md
8
+ Requires-Python: >=3.10
9
+ License-File: LICENSE.md
10
+ Requires-Dist: Django>=4.2
11
+ Requires-Dist: motor>=3.3
12
+ Requires-Dist: channels>=4
13
+ Requires-Dist: channels-redis>=4.1
14
+ Requires-Dist: djangorestframework>=3
@@ -0,0 +1,96 @@
1
+ RX-Django
2
+ =========
3
+
4
+ RX-Django is a layer over Django Channels aimed to make it as simple as possible to broadcast
5
+ changes in Django models to browsers through websockets, with minimal latency.
6
+
7
+ It's evolving in production for more than 2 years now and it's revised API has just been released
8
+ in Python Nordeste 2023. There's no stable release yet.
9
+
10
+ Quickstart
11
+ ==========
12
+
13
+ Start by defining a StateChannel and define an "anchor", which is a serializer that will build
14
+ the state that will be sent to user.
15
+
16
+ ```python
17
+ # chat/channels.py
18
+ from rxdjango.channels import StateChannel
19
+ from chat.serializers import ChatRoomSerializer
20
+
21
+
22
+ class ChatRoomChannel(StateChannel):
23
+
24
+ class Meta:
25
+ anchor = ChatRoomSerializer()
26
+
27
+ def has_permission(self, user, chat_room):
28
+ # check permission
29
+ return True
30
+
31
+ ```
32
+
33
+ Then define paths for the channels in websocket_urlpatterns.
34
+ [Django Channels documentation](https://channels.readthedocs.io/en/latest/tutorial/part_2.html)
35
+ suggests you put this in app/routing.py.
36
+
37
+ ```python
38
+ # chat/routing.py
39
+ from chat.channels import ChatRoomChannel
40
+
41
+ websocket_urlpatterns = [
42
+ path('ws/chat/<str:room_name>/', ChatRoomChannel.as_asgi()),
43
+ ]
44
+ ```
45
+
46
+ This is all the code it takes in the app! From that, RX-Django will generate all the frontend
47
+ code required to keep state in sync between backend and frontend. For that, we need to configure
48
+ settings.py with some information about the frontend.
49
+
50
+ ```python
51
+ # settings.py
52
+
53
+ RX_FRONTEND_DIR = os.path.join(BASE_DIR, '../frontend/src/app/modules')
54
+ RX_WEBSOCKET_URL = "import.meta.env.VITE_SOCKET_URL"
55
+ ```
56
+
57
+ In the above example, we want our code to be generated at src/app/modules, and we take the websocket url from Vite.
58
+ Also, there are some backend configuration required. Right now, we only support Redis and Mongo for caching.
59
+
60
+ ```python
61
+ # settings.py
62
+ MONGO_URL = "configure me"
63
+ REDIS_URL = "configure me"
64
+ # for now framework expects this to exist, and to be set to True during tests
65
+ TESTING = False
66
+ ```
67
+
68
+ And, of course, add `rxdjango` to your INSTALLED_APPS
69
+
70
+ ```python
71
+ # settings.py
72
+ INSTALLED_APPS = [
73
+ # ...
74
+ 'rxdjango',
75
+ ]
76
+ ```
77
+
78
+ Build all the frontend files:
79
+
80
+ ```bash
81
+ python manage.py makefrontend
82
+ ```
83
+
84
+ Check the files generated inside your modules app. Now, use the state
85
+ from the backend with:
86
+
87
+ ```typescript
88
+ import { ChatRoomChannel } from 'app/modules/chat.channels';
89
+ import { useChannelState } from 'django-react';
90
+
91
+ channel = new ChatRoomChannel(roomName, token);
92
+ chatState = useChannelState(channel);
93
+ ```
94
+
95
+ That's all it takes! Note that token is a rest_framework.authtoken token,
96
+ the only authentication method supported for now.
File without changes
@@ -0,0 +1,30 @@
1
+ import importlib
2
+ from django.apps import apps, AppConfig
3
+
4
+
5
+ class ReactFrameworkConfig(AppConfig):
6
+ default_auto_field = 'django.db.models.BigAutoField'
7
+ name = 'rxdjango'
8
+
9
+ def ready(self):
10
+ """Discover and register StateChannel subclasses within Django apps."""
11
+ from . import channels
12
+
13
+ for app_config in apps.get_app_configs():
14
+ try:
15
+ # Attempt to import the channels.py module from the app
16
+ channels_module = importlib.import_module(f"{app_config.name}.channels")
17
+
18
+ # Check for subclasses of StateChannel in the module
19
+ for attr_name in dir(channels_module):
20
+ attr = getattr(channels_module, attr_name)
21
+ # Register the subclass in the global dictionary
22
+ if not isinstance(attr, type) or \
23
+ not issubclass(attr, channels.StateChannel) or \
24
+ attr.Meta.abstract:
25
+ continue
26
+ attr._signal_handler.setup(app_config)
27
+
28
+ except ImportError:
29
+ # channels.py not found in the app, so just continue
30
+ pass
@@ -0,0 +1,128 @@
1
+ import json
2
+ from collections import deque, namedtuple
3
+ import pymongo
4
+ from django.utils import timezone
5
+ from hashlib import md5
6
+ from asgiref.sync import async_to_sync
7
+ from django.conf import settings
8
+ from django.db import models, connection, transaction, ProgrammingError
9
+ from rest_framework import serializers
10
+ from backend.celery import app
11
+
12
+ from .consumers import StateConsumer
13
+ from .transaction import get_transaction_id
14
+ from .state_model import StateModel
15
+ from .websocket_router import WebsocketRouter
16
+ from .signal_handler import SignalHandler
17
+
18
+
19
+ class StateChannelMeta(type):
20
+ """Metaclass for the StateChannel.
21
+
22
+ Builds the state model based on the provided fields and Meta class,
23
+ and assigns a WebsocketRouter and a SignalHandler to it.
24
+ """
25
+
26
+ def __new__(cls, name, bases, attrs):
27
+ """Create and return a new class instance."""
28
+
29
+ # Create the new class as usual.
30
+ new_class = super().__new__(cls, name, bases, attrs)
31
+
32
+ if new_class.__module__ == cls.__module__:
33
+ return new_class
34
+
35
+ # Set the name to be used for mongo collection names and redis keys.
36
+ new_class.name = '_'.join([
37
+ new_class.__module__.replace('.channels', '').replace('.', '_'),
38
+ new_class.__name__.lower()
39
+ ])
40
+
41
+ # Ensure the Meta class and anchor serializer are provided.
42
+ meta = attrs.get("Meta")
43
+ if not meta:
44
+ raise ProgrammingError(
45
+ f'{new_class.__name__} must define a Meta class'
46
+ )
47
+
48
+ meta.abstract = getattr(meta, 'abstract', False)
49
+ if meta.abstract:
50
+ return new_class
51
+
52
+ if not hasattr(meta, "anchor"):
53
+ raise ProgrammingError(
54
+ f'{new_class.__name__} must define a Meta class with an '
55
+ 'anchor serializer'
56
+ )
57
+
58
+ anchor = meta.anchor
59
+
60
+ if not isinstance(anchor, serializers.ModelSerializer):
61
+ raise ProgrammingError(
62
+ f'{new_class.__name__}.Meta.anchor must be an instance of '
63
+ 'serializers.ModelSerializer'
64
+ )
65
+
66
+ # Attach the state model, websocket router, and signal handler.
67
+ new_class._state_model = StateModel(anchor)
68
+ new_class._wsrouter = WebsocketRouter(new_class.name)
69
+ new_class._signal_handler = SignalHandler(new_class)
70
+
71
+ return new_class
72
+
73
+
74
+ class StateChannel(metaclass=StateChannelMeta):
75
+
76
+ class Meta:
77
+ abstract = True
78
+
79
+ @classmethod
80
+ def as_asgi(cls):
81
+ Consumer = type(
82
+ f'{cls.__name__}Consumer',
83
+ (StateConsumer,),
84
+ dict(
85
+ state_channel_class=cls,
86
+ wsrouter=cls._wsrouter,
87
+ ),
88
+ )
89
+
90
+ return Consumer.as_asgi()
91
+
92
+ def __init__(self, user, **kwargs):
93
+ self.kwargs = kwargs
94
+ self.user = user
95
+ self.user_id = user.id
96
+ self.anchor_id = self.get_anchor_id(**kwargs)
97
+
98
+ def get_anchor_id(self, **kwargs):
99
+ """Subclass may implement get_anchor_id, based on url parameters,
100
+ otherwise the first parameter will be assumed to be it"""
101
+ return next(iter(kwargs.values()))
102
+
103
+ @staticmethod
104
+ def has_permission(user, **kwargs):
105
+ """Implement this method to check if user has permission on a channel"""
106
+ return True
107
+
108
+ async def on_connect(self, tstamp):
109
+ """Called after user has been authenticated.
110
+ tstamp is the tstamp sent on connection, if this is a reconnection"""
111
+ pass
112
+
113
+ async def on_disconnect(self):
114
+ """Called when user disconnects"""
115
+ pass
116
+
117
+ async def receive(self, data):
118
+ """Any data sent by user will get here"""
119
+ pass
120
+
121
+ @classmethod
122
+ def broadcast_instance(cls, anchor_id, instance, operation='update'):
123
+ cls._signal_handler.broadcast_instance(anchor_id, instance, operation)
124
+
125
+ @classmethod
126
+ def broadcast_notification(cls, anchor_id, notification, user_id=None):
127
+ notification['_instance_type'] = '_notification'
128
+ cls._wsrouter.sync_dispatch([notification], anchor_id, user_id)
@@ -0,0 +1,138 @@
1
+ import json
2
+ from datetime import datetime
3
+ from pytz import utc
4
+ from django.utils import timezone
5
+ from django.db import models
6
+ from django.db.models.query import QuerySet
7
+ from asgiref.sync import sync_to_async, async_to_sync
8
+ from channels.db import database_sync_to_async
9
+ from channels.generic.websocket import AsyncWebsocketConsumer
10
+ from rest_framework.authtoken.models import Token
11
+ from .state_loader import StateLoader
12
+
13
+
14
+ class StateConsumer(AsyncWebsocketConsumer):
15
+ """
16
+ WebSocket consumer that manages user authentication, session management,
17
+ and real-time data relay to clients. A subclass of StateConsumer will be
18
+ dinamically created by StateChannel.as_asgi().
19
+ """
20
+ def __init__(self, *args, **kwargs):
21
+
22
+ super().__init__(*args, **kwargs)
23
+
24
+ self.channel = None
25
+ self.user = None
26
+ self.token = None
27
+ self.anchor_id = None
28
+ self.wsrouter = None
29
+ self.tstamp = None
30
+ self.session = None
31
+
32
+ async def connect(self):
33
+ """Accept any connection and just wait for a token."""
34
+ await self.accept()
35
+
36
+ async def receive(self, text_data):
37
+ if self.user:
38
+ await self.receive_command(text_data)
39
+ else:
40
+ await self.receive_authentication(text_data)
41
+
42
+ async def receive_authentication(self, text_data):
43
+ # If user is not logged, we expect a text with
44
+ # token and maybe last_update
45
+ data = json.loads(text_data)
46
+ token = data.get('token', None)
47
+ last_update = data.get('last_update', None)
48
+
49
+ user = await self.authenticate(token=token)
50
+ if user:
51
+ await self.start(user, last_update)
52
+ else:
53
+ await self.close()
54
+
55
+ async def start(self, user, tstamp):
56
+ kwargs = self.scope['url_route']['kwargs']
57
+ self.user = user
58
+ self.channel = self.state_channel_class(user, **kwargs)
59
+ self.user_id = self.user.id
60
+ self.anchor_id = self.channel.anchor_id
61
+ self.wsrouter = self.channel._wsrouter
62
+
63
+ await self.wsrouter.connect(
64
+ self.channel_layer,
65
+ self.channel_name,
66
+ self.anchor_id,
67
+ self.user_id,
68
+ )
69
+
70
+ async with StateLoader(self.channel) as loader:
71
+ await self.channel.on_connect(tstamp)
72
+
73
+ async for instances in loader.list_instances():
74
+ if instances:
75
+ data = json.dumps(instances, default=str)
76
+ await self.send(text_data=data)
77
+
78
+ self.tstamp = loader.tstamp
79
+ await self.send(text_data=self.end_of_data)
80
+
81
+ @property
82
+ def end_of_data(self):
83
+ return json.dumps([{
84
+ '_instance_type': '',
85
+ '_tstamp': self.tstamp,
86
+ '_operation': 'end_initial_state',
87
+ 'id': 0,
88
+ }])
89
+
90
+ @database_sync_to_async
91
+ def serialized_data(self, Serializer, data):
92
+ return Serializer(data).data
93
+
94
+ async def disconnect(self, close_code=None):
95
+ if not self.channel:
96
+ return
97
+
98
+ await self.wsrouter.disconnect(
99
+ self.channel_layer,
100
+ self.channel_name,
101
+ self.anchor_id,
102
+ self.user_id,
103
+ )
104
+
105
+ await self.channel.on_disconnect()
106
+
107
+ @database_sync_to_async
108
+ def authenticate(self, token):
109
+ try:
110
+ token = Token.objects.get(key=token)
111
+ except Token.DoesNotExist:
112
+ #self.send('ERROR/Unauthorized')
113
+ return
114
+ self.token = token.key
115
+
116
+ kwargs = self.scope['url_route']['kwargs']
117
+ if not self.state_channel_class.has_permission(
118
+ token.user,
119
+ **kwargs,
120
+ ):
121
+ #self.send('ERROR/Forbidden')
122
+ return
123
+
124
+ #self.send('OK')
125
+
126
+ return token.user
127
+
128
+ async def relay(self, payload):
129
+ payload = payload['payload']
130
+ await self.send(text_data=json.dumps(payload, default=str))
131
+
132
+ async def receive_command(self, text_data):
133
+ # If user is logged, we expect a JSON command
134
+ try:
135
+ data = json.loads(text_data)
136
+ except json.JSONDecodeError:
137
+ await self.disconnect()
138
+ await self.channel.receive(data)
@@ -0,0 +1 @@
1
+ from .related_properties import RelatedProperty as related_property
@@ -0,0 +1,5 @@
1
+ class UnknownProperty(Exception):
2
+ pass
3
+
4
+ class AnchorDoesNotExist(Exception):
5
+ pass
File without changes
@@ -0,0 +1,33 @@
1
+ from django.apps import apps
2
+ from django.db import ProgrammingError
3
+ from django.core.management.base import BaseCommand
4
+ from django.conf import settings
5
+ from rxdjango.ts.interfaces import create_app_interfaces
6
+ from rxdjango.ts.channels import create_app_channels
7
+
8
+
9
+ class Command(BaseCommand):
10
+ help = 'Generate typescript interfaces and classes'
11
+
12
+ def add_arguments(self, parser):
13
+ parser.add_argument('app', nargs='*', type=str)
14
+
15
+ def handle(self, *args, **options):
16
+ self._check()
17
+
18
+ all_apps = options['app']
19
+
20
+ if not all_apps:
21
+ models = apps.get_models()
22
+ all_apps = list(set([ x.__module__.split('.')[0] for x in models]))
23
+
24
+ for app in all_apps:
25
+ create_app_interfaces(app)
26
+ create_app_channels(app)
27
+
28
+ def _check(self):
29
+ if not getattr(settings, 'RX_FRONTEND_DIR', None):
30
+ raise ProgrammingError(
31
+ "settings.RX_FRONTEND_DIR is not set. Configure it with a folder "
32
+ "inside your react application."
33
+ )
@@ -0,0 +1,122 @@
1
+ import json
2
+ from datetime import datetime
3
+ from copy import copy
4
+ from decimal import Decimal
5
+ import pymongo
6
+ from motor import motor_asyncio
7
+ from django.db import ProgrammingError
8
+ from django.conf import settings
9
+ from .redis import get_tstamp, sync_get_tstamp
10
+
11
+
12
+ class MongoStateSession:
13
+
14
+ def __init__(self, channel):
15
+ self.channel = channel
16
+ self.anchor_id = channel.anchor_id
17
+ self.user_id = channel.user_id
18
+ self.state_model = channel._state_model
19
+ self._tstamp = None
20
+
21
+ client = motor_asyncio.AsyncIOMotorClient(settings.MONGO_URL)
22
+ db = client[settings.MONGO_STATE_DB]
23
+ self.collection = db[channel.__class__.__name__.lower()]
24
+
25
+ async def tstamp(self):
26
+ if self._tstamp:
27
+ return self._tstamp
28
+ self._tstamp = await get_tstamp()
29
+ return self._tstamp
30
+
31
+ async def list_instances(self, user_id):
32
+ for model in self.state_model.models():
33
+ instances = []
34
+ query = {
35
+ '_anchor_id': self.anchor_id,
36
+ '_user_key': {'$in': [None, user_id]},
37
+ '_instance_type': model.instance_type,
38
+ }
39
+
40
+ async for instance in self.collection.find(query):
41
+ del instance['_id']
42
+ del instance['_anchor_id']
43
+ instances.append(instance)
44
+
45
+ if instances:
46
+ yield instances
47
+ instances = []
48
+
49
+ async def write_instances(self, instances):
50
+ for instance in instances:
51
+ instance = _adapt(instance)
52
+ instance['_anchor_id'] = self.anchor_id
53
+ instance_type = instance['_instance_type']
54
+ if instance.get('id', None) is None:
55
+ raise ProgrammingError(f'Instance type {instance_type} has no "id"')
56
+ instance = _adapt(instance)
57
+ await self.collection.replace_one(
58
+ {
59
+ '_anchor_id': self.anchor_id,
60
+ '_instance_type': instance['_instance_type'],
61
+ 'id': instance['id'],
62
+ },
63
+ instance,
64
+ upsert=True,
65
+ )
66
+
67
+
68
+ class MongoSignalWriter:
69
+ def __init__(self, channel_class):
70
+ client = pymongo.MongoClient(settings.MONGO_URL)
71
+ db = client[settings.MONGO_STATE_DB]
72
+ self.collection = db[channel_class.__name__.lower()]
73
+
74
+ def init_database(self):
75
+ # Make a new connection, because this needs to be sync
76
+ self.collection.drop()
77
+
78
+ self.collection.create_index(
79
+ [
80
+ ('_anchor_id', pymongo.ASCENDING),
81
+ ('_user_key', pymongo.ASCENDING),
82
+ ('_instance_type', pymongo.ASCENDING),
83
+ ('id', pymongo.ASCENDING),
84
+ ],
85
+ name='instance_pkey',
86
+ )
87
+
88
+ self.collection.create_index(
89
+ [
90
+ ('_anchor_id', pymongo.ASCENDING),
91
+ ('_tstamp', pymongo.DESCENDING),
92
+ ],
93
+ name='reconnection_index',
94
+ )
95
+
96
+ def write_instances(self, anchor_id, instances):
97
+ for instance in instances:
98
+ instance = _adapt(instance)
99
+ instance['_anchor_id'] = anchor_id
100
+ assert instance['_tstamp']
101
+ self.collection.replace_one(
102
+ {
103
+ '_anchor_id': anchor_id,
104
+ '_instance_type': instance['_instance_type'],
105
+ 'id': instance['id'],
106
+ },
107
+ instance,
108
+ upsert=True,
109
+ )
110
+ pass
111
+
112
+
113
+ def _adapt(instance):
114
+ adapted = {}
115
+ for key, value in instance.items():
116
+ if isinstance(value, Decimal):
117
+ value = float(value)
118
+ elif isinstance(value, datetime):
119
+ value = value.isoformat()[:26] + 'Z'
120
+ adapted[key] = value
121
+
122
+ return adapted