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.
- rxdjango-0.0.2/LICENSE.md +20 -0
- rxdjango-0.0.2/PKG-INFO +14 -0
- rxdjango-0.0.2/README.md +96 -0
- rxdjango-0.0.2/rxdjango/__init__.py +0 -0
- rxdjango-0.0.2/rxdjango/apps.py +30 -0
- rxdjango-0.0.2/rxdjango/channels.py +128 -0
- rxdjango-0.0.2/rxdjango/consumers.py +138 -0
- rxdjango-0.0.2/rxdjango/decorators.py +1 -0
- rxdjango-0.0.2/rxdjango/exceptions.py +5 -0
- rxdjango-0.0.2/rxdjango/management/__init__.py +0 -0
- rxdjango-0.0.2/rxdjango/management/commands/__init__.py +0 -0
- rxdjango-0.0.2/rxdjango/management/commands/makefrontend.py +33 -0
- rxdjango-0.0.2/rxdjango/mongo.py +122 -0
- rxdjango-0.0.2/rxdjango/redis.py +468 -0
- rxdjango-0.0.2/rxdjango/related_properties.py +53 -0
- rxdjango-0.0.2/rxdjango/signal_handler.py +137 -0
- rxdjango-0.0.2/rxdjango/state_loader.py +141 -0
- rxdjango-0.0.2/rxdjango/state_model.py +239 -0
- rxdjango-0.0.2/rxdjango/transaction.py +23 -0
- rxdjango-0.0.2/rxdjango/ts/__init__.py +80 -0
- rxdjango-0.0.2/rxdjango/ts/channels.py +186 -0
- rxdjango-0.0.2/rxdjango/ts/interfaces.py +221 -0
- rxdjango-0.0.2/rxdjango/websocket_router.py +62 -0
- rxdjango-0.0.2/rxdjango.egg-info/PKG-INFO +14 -0
- rxdjango-0.0.2/rxdjango.egg-info/SOURCES.txt +28 -0
- rxdjango-0.0.2/rxdjango.egg-info/dependency_links.txt +1 -0
- rxdjango-0.0.2/rxdjango.egg-info/requires.txt +5 -0
- rxdjango-0.0.2/rxdjango.egg-info/top_level.txt +1 -0
- rxdjango-0.0.2/setup.cfg +4 -0
- rxdjango-0.0.2/setup.py +25 -0
|
@@ -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.
|
rxdjango-0.0.2/PKG-INFO
ADDED
|
@@ -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
|
rxdjango-0.0.2/README.md
ADDED
|
@@ -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
|
|
File without changes
|
|
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
|