From 43b35a12cecd30bba5818f6c00dddddbd42d4cbf Mon Sep 17 00:00:00 2001 From: Tyson Clugg Date: Tue, 14 Jul 2015 09:31:42 +1000 Subject: [PATCH 01/34] Add models and various shortcuts to allow for AleaIdField with primary_key=True. --- dddp/admin.py | 2 +- dddp/api.py | 22 ++++++++---- dddp/models.py | 95 ++++++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 105 insertions(+), 14 deletions(-) diff --git a/dddp/admin.py b/dddp/admin.py index ab21e4f..917c078 100644 --- a/dddp/admin.py +++ b/dddp/admin.py @@ -104,7 +104,7 @@ class SubscriptionCollection(admin.ModelAdmin): for name, attr in vars(models).items(): - if hasattr(attr, '_meta'): + if hasattr(attr, '_meta') and not getattr(attr._meta, 'abstract'): model_admin = locals().get(name, None) if model_admin is not False: admin.site.register(attr, model_admin) diff --git a/dddp/api.py b/dddp/api.py index d9d1e54..683bdc6 100644 --- a/dddp/api.py +++ b/dddp/api.py @@ -28,7 +28,9 @@ import ejson from dddp import ( AlreadyRegistered, THREAD_LOCAL as this, ADDED, CHANGED, REMOVED, ) -from dddp.models import Connection, Subscription, get_meteor_id, get_meteor_ids +from dddp.models import ( + AleaIdField, Connection, Subscription, get_meteor_id, get_meteor_ids, +) XMIN = {'select': {'xmin': "'xmin'"}} @@ -600,9 +602,12 @@ class DDP(APIMixin): model_name=model_name(qs.model), collection_name=col.name, ) - meteor_ids = get_meteor_ids( - qs.model, qs.values_list('pk', flat=True), - ) + if isinstance(col.model._meta.pk, AleaIdField): + meteor_ids = None + else: + meteor_ids = get_meteor_ids( + qs.model, qs.values_list('pk', flat=True), + ) for obj in qs: payload = col.obj_change_as_msg(obj, ADDED, meteor_ids) this.send(payload) @@ -615,9 +620,12 @@ class DDP(APIMixin): connection=this.ws.connection, sub_id=id_, ) for col, qs in self.sub_unique_objects(sub): - meteor_ids = get_meteor_ids( - qs.model, qs.values_list('pk', flat=True), - ) + if isinstance(col.model._meta.pk, AleaIdField): + meteor_ids = None + else: + meteor_ids = get_meteor_ids( + qs.model, qs.values_list('pk', flat=True), + ) for obj in qs: payload = col.obj_change_as_msg(obj, REMOVED, meteor_ids) this.send(payload) diff --git a/dddp/models.py b/dddp/models.py index 8eea57d..2bf5df7 100644 --- a/dddp/models.py +++ b/dddp/models.py @@ -23,10 +23,29 @@ def get_meteor_id(obj_or_model, obj_pk=None): if model is ObjectMapping: # this doesn't make sense - raise TypeError raise TypeError("Can't map ObjectMapping instances through self.") - if obj_or_model is not model and obj_pk is None: - obj_pk = str(obj_or_model.pk) + + # try getting value of AleaIdField straight from instance if possible + if isinstance(obj_or_model, model): + # obj_or_model is an instance, not a model. + if isinstance(meta.pk, AleaIdField): + return obj_or_model.pk + alea_unique_fields = [ + field + for field in meta.local_fields + if isinstance(field, AleaIdField) and field.unique + ] + if len(alea_unique_fields) == 1: + # found an AleaIdField with unique=True, assume it's got the value. + return getattr(obj_or_model, alea_unique_fields[0].attname) + if obj_pk is None: + # fall back to primary key, but coerce as string type for lookup. + obj_pk = str(obj_or_model.pk) + if obj_pk is None: + # bail out if args are (model, pk) but pk is None. return None + + # fallback to using AleaIdField from ObjectMapping model. content_type = ContentType.objects.get_for_model(model) try: return ObjectMapping.objects.values_list( @@ -47,6 +66,13 @@ def get_meteor_id(obj_or_model, obj_pk=None): def get_meteor_ids(model, object_ids): """Return Alea ID mapping for all given ids of specified model.""" content_type = ContentType.objects.get_for_model(model) + # Django model._meta is now public API -> pylint: disable=W0212 + meta = model._meta + if isinstance(meta.pk, AleaIdField): + # primary_key is an AleaIdField, use it. + return collections.OrderedDict( + (obj_pk, obj_pk) for obj_pk in object_ids + ) result = collections.OrderedDict( (str(obj_pk), None) for obj_pk @@ -59,11 +85,10 @@ def get_meteor_ids(model, object_ids): result[obj_pk] = meteor_id for obj_pk, meteor_id in result.items(): if meteor_id is None: - # Django model._meta is now public API -> pylint: disable=W0212 result[obj_pk] = ObjectMapping.objects.create( content_type=content_type, object_id=obj_pk, - meteor_id=meteor_random_id('/collection/%s' % model._meta), + meteor_id=meteor_random_id('/collection/%s' % meta), ).meteor_id return result @@ -71,10 +96,32 @@ def get_meteor_ids(model, object_ids): @transaction.atomic def get_object_id(model, meteor_id): """Return an object ID for the given meteor_id.""" + if meteor_id is None: + return None + # Django model._meta is now public API -> pylint: disable=W0212 + meta = model._meta + if model is ObjectMapping: # this doesn't make sense - raise TypeError raise TypeError("Can't map ObjectMapping instances through self.") - # Django model._meta is now public API -> pylint: disable=W0212 + + if isinstance(meta.pk, AleaIdField): + # meteor_id is the primary key + return meteor_id + + alea_unique_fields = [ + field + for field in meta.local_fields + if isinstance(field, AleaIdField) and field.unique + ] + if len(alea_unique_fields) == 1: + # found an AleaIdField with unique=True, assume it's got the value. + return model.objects.values_list( + 'pk', flat=True, + ).get(**{ + alea_unique_fields[0].attname: meteor_id, + }) + content_type = ContentType.objects.get_for_model(model) return ObjectMapping.objects.filter( content_type=content_type, @@ -85,6 +132,12 @@ def get_object_id(model, meteor_id): @transaction.atomic def get_object(model, meteor_id, *args, **kwargs): """Return an object for the given meteor_id.""" + # Django model._meta is now public API -> pylint: disable=W0212 + meta = model._meta + if isinstance(meta.pk, AleaIdField): + # meteor_id is the primary key + return model.objects.filter(*args, **kwargs).get(pk=meteor_id) + return model.objects.filter(*args, **kwargs).get( pk=get_object_id(model, meteor_id), ) @@ -97,11 +150,41 @@ class AleaIdField(models.CharField): def __init__(self, *args, **kwargs): """Assume max_length of 17 to match Meteor implementation.""" kwargs.update( - default=meteor_random_id, + editable=False, max_length=17, ) super(AleaIdField, self).__init__(*args, **kwargs) + def deconstruct(self): + """Return details on how this field was defined.""" + name, path, args, kwargs = super(AleaIdField, self).deconstruct() + del kwargs['max_length'] + return name, path, args, kwargs + + def pre_save(self, model_instance, add): + """Generate ID if required.""" + _, _, _, kwargs = self.deconstruct() + val = getattr(model_instance, self.attname) + if val is None and kwargs.get('default', None) is None: + val = meteor_random_id('/collection/%s' % model_instance._meta) + setattr(model_instance, self.attname, val) + return val + + +class AleaIdMixin(models.Model): + + """Django model mixin that provides AleaIdField field (as _id).""" + + id = AleaIdField( + primary_key=True, + ) + + class Meta(object): + + """Model meta options for AleaIdMixin.""" + + abstract = True + @python_2_unicode_compatible class ObjectMapping(models.Model): From ab29362ea17334e65eaf41e578e961c0055930d3 Mon Sep 17 00:00:00 2001 From: Tyson Clugg Date: Fri, 17 Jul 2015 18:09:07 +1000 Subject: [PATCH 02/34] Update CHANGES.rst, bump version number. --- CHANGES.rst | 6 ++++++ setup.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 39d433a..637d291 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,12 @@ Change Log ========== +0.9.13 +------ +* Add dddp.models.get_object_ids helper function. +* Add ObjectMappingMixini abstract model mixin providing + GenericRelation back to ObjectMapping model. + 0.9.12 ------ * Bugfix /app.model/schema helper method on collections to work with diff --git a/setup.py b/setup.py index 2494769..241f0c9 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ from setuptools import setup, find_packages setup( name='django-ddp', - version='0.9.12', + version='0.9.13', description=__doc__, long_description=open('README.rst').read(), author='Tyson Clugg', From 4608996e9fb44187e6870560bcd8e0051fc731f4 Mon Sep 17 00:00:00 2001 From: Tyson Clugg Date: Sat, 18 Jul 2015 14:09:50 +1000 Subject: [PATCH 03/34] Fix issue with incorrect ordering of messages during login/logout. --- dddp/accounts/ddp.py | 41 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/dddp/accounts/ddp.py b/dddp/accounts/ddp.py index b2bcfd3..6c3060a 100644 --- a/dddp/accounts/ddp.py +++ b/dddp/accounts/ddp.py @@ -18,7 +18,7 @@ from django.contrib.auth.signals import user_login_failed from django.dispatch import Signal from django.utils import timezone -from dddp import THREAD_LOCAL as this, ADDED, REMOVED +from dddp import THREAD_LOCAL as this, ADDED, REMOVED, meteor_random_id from dddp.models import get_meteor_id, get_object, Subscription from dddp.api import API, APIMixin, api_endpoint, Collection, Publication from dddp.websocket import MeteorError @@ -141,12 +141,18 @@ class Users(Collection): user.save() -class LoginPublication(Publication): +class LoginServiceConfiguration(Publication): - """Meteor Accounts emulation.""" + """Published list of authenitcation providers and their configuration.""" name = 'meteor.loginServiceConfiguration' + queries = [] + + +class LoggedInUser(Publication): + + """Meteor auto publication for showing logged in user.""" queries = [ (Users.model.objects.all(), 'users'), ] @@ -349,6 +355,7 @@ class Auth(APIMixin): username=user.get_username(), password=params['password'], ) auth.login(this.request, user) + self.sub_user() self.update_subs(user.pk) return self.get_user_token( user=user, @@ -361,6 +368,7 @@ class Auth(APIMixin): def logout(self): """Logout current user.""" auth.logout(this.request) + self.unsub_user() self.update_subs(None) @api_endpoint @@ -373,6 +381,28 @@ class Auth(APIMixin): else: self.auth_failed(**params) + def sub_user(self): + """Silent subscription (sans sub/nosub msg) to LoggedInUser pub.""" + this.send_orig = this.send + this.send = self.send_alt + this.user_sub_id = meteor_random_id() + API.sub(this.user_sub_id, 'LoggedInUser') + this.send = this.send_orig + + def unsub_user(self): + """Silent unsubscription (sans sub/nosub msg) from LoggedInUser pub.""" + this.send_orig = this.send + this.send = self.send_alt + API.unsub(this.user_sub_id) + this.send = this.send_orig + del this.send_orig + del this.user_sub_id + + def send_alt(self, msg): + """Alternative send for use within login method.""" + if msg['msg'] not in ['ready', 'nosub']: + this.send_orig(msg) + def login_with_password(self, params): """Authenticate using credentials supplied in params.""" # never allow insecure login @@ -386,6 +416,7 @@ class Auth(APIMixin): # the password verified for the user if user.is_active: auth.login(this.request, user) + self.sub_user() self.update_subs(user.pk) this.request.session.save() return self.get_user_token( @@ -418,6 +449,7 @@ class Auth(APIMixin): ) auth.login(this.request, user) + self.sub_user() self.update_subs(user.pk) this.request.session.save() return self.get_user_token( @@ -481,8 +513,9 @@ class Auth(APIMixin): user.set_password(new_password) user.save() auth.login(this.request, user) + self.sub_user() self.update_subs(user.pk) return {"userId": get_meteor_id(this.request.user)}; -API.register([Users, LoginPublication, Auth]) +API.register([Users, LoginServiceConfiguration, LoggedInUser, Auth]) From db33709b47621fc3c180d79bead36165b64a4f62 Mon Sep 17 00:00:00 2001 From: Tyson Clugg Date: Sat, 18 Jul 2015 14:11:50 +1000 Subject: [PATCH 04/34] Update CHANGES.rst, bump version number. --- CHANGES.rst | 5 +++++ setup.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 637d291..e95f5af 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,11 @@ Change Log ========== +0.9.14 +------ +* Fix ordering of user added vs login ready in dddp.accounts + authentication methods. + 0.9.13 ------ * Add dddp.models.get_object_ids helper function. diff --git a/setup.py b/setup.py index 241f0c9..d98046f 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ from setuptools import setup, find_packages setup( name='django-ddp', - version='0.9.13', + version='0.9.14', description=__doc__, long_description=open('README.rst').read(), author='Tyson Clugg', From b059fe37da153d1f98911cd449c86c2d019dca53 Mon Sep 17 00:00:00 2001 From: Tyson Clugg Date: Tue, 21 Jul 2015 09:23:06 +1000 Subject: [PATCH 05/34] Allow `silent` sub/unsub to support Meteor `null` publications. --- dddp/api.py | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/dddp/api.py b/dddp/api.py index d61b4cb..ff2d1b7 100644 --- a/dddp/api.py +++ b/dddp/api.py @@ -566,18 +566,23 @@ class DDP(APIMixin): @api_endpoint def sub(self, id_, name, *params): """Create subscription, send matched objects that haven't been sent.""" + return self.do_sub(id_, name, False, *params) + + def do_sub(self, id_, name, silent, *params): + """Subscribe the current thread to the specified publication.""" try: pub = self.get_pub_by_name(name) except KeyError: - this.send({ - 'msg': 'nosub', - 'error': { - 'error': 404, - 'errorType': 'Meteor.Error', - 'message': 'Subscription not found [404]', - 'reason': 'Subscription not found', - }, - }) + if not silent: + this.send({ + 'msg': 'nosub', + 'error': { + 'error': 404, + 'errorType': 'Meteor.Error', + 'message': 'Subscription not found [404]', + 'reason': 'Subscription not found', + }, + }) return sub, created = Subscription.objects.get_or_create( connection_id=this.ws.connection.pk, @@ -589,7 +594,8 @@ class DDP(APIMixin): }, ) if not created: - this.send({'msg': 'ready', 'subs': [id_]}) + if not silent: + this.send({'msg': 'ready', 'subs': [id_]}) return # re-read from DB so we can get transaction ID (xmin) sub = Subscription.objects.extra(**XMIN).get(pk=sub.pk) @@ -606,11 +612,16 @@ class DDP(APIMixin): for obj in qs: payload = col.obj_change_as_msg(obj, ADDED, meteor_ids) this.send(payload) - this.send({'msg': 'ready', 'subs': [id_]}) + if not silent: + this.send({'msg': 'ready', 'subs': [id_]}) @api_endpoint def unsub(self, id_): """Remove a subscription.""" + self.do_unsub(id_, False) + + def do_unsub(self, id_, silent): + """Unsubscribe the current thread from the specified subscription id.""" sub = Subscription.objects.get( connection=this.ws.connection, sub_id=id_, ) @@ -622,7 +633,8 @@ class DDP(APIMixin): payload = col.obj_change_as_msg(obj, REMOVED, meteor_ids) this.send(payload) sub.delete() - this.send({'msg': 'nosub', 'id': id_}) + if not silent: + this.send({'msg': 'nosub', 'id': id_}) @api_endpoint def method(self, method, params, id_): From adf8a5b9c3b4b7cf15d46d75f76b4e7ddcdb9922 Mon Sep 17 00:00:00 2001 From: Tyson Clugg Date: Tue, 21 Jul 2015 18:53:49 +1000 Subject: [PATCH 06/34] Some pylint cleanups to dddp.accounts.ddp module. --- dddp/accounts/ddp.py | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/dddp/accounts/ddp.py b/dddp/accounts/ddp.py index 6c3060a..5a2958a 100644 --- a/dddp/accounts/ddp.py +++ b/dddp/accounts/ddp.py @@ -24,6 +24,8 @@ from dddp.api import API, APIMixin, api_endpoint, Collection, Publication from dddp.websocket import MeteorError +# pylint dones't like lower case attribute names on modules, but it's the normal +# thing to do for Django signal names. --> pylint: disable=C0103 create_user = Signal(providing_args=['request', 'params']) password_changed = Signal(providing_args=['request', 'user']) forgot_password = Signal(providing_args=['request', 'user', 'token', 'expiry']) @@ -103,7 +105,7 @@ class Users(Collection): return data @staticmethod - def deserialize_profile(user, profile, key_prefix='', pop=False): + def deserialize_profile(profile, key_prefix='', pop=False): """De-serialize user profile fields into concrete model fields.""" result = {} if pop: @@ -126,12 +128,14 @@ class Users(Collection): @api_endpoint def update(self, selector, update, options=None): """Update user data.""" + # we're ignoring the `options` argument at this time + del options user = get_object( self.model, selector['_id'], pk=this.request.user.pk, ) profile_update = self.deserialize_profile( - user, update['$set'], key_prefix='profile.', pop=True, + update['$set'], key_prefix='profile.', pop=True, ) if len(update['$set']) != 0: raise MeteorError(400, 'Invalid update fields: %r') @@ -153,6 +157,7 @@ class LoginServiceConfiguration(Publication): class LoggedInUser(Publication): """Meteor auto publication for showing logged in user.""" + queries = [ (Users.model.objects.all(), 'users'), ] @@ -165,7 +170,8 @@ class Auth(APIMixin): api_path_prefix = '' # auth endpoints don't have a common prefix user_model = auth.get_user_model() - def update_subs(self, new_user_id): + @staticmethod + def update_subs(new_user_id): """Update subs to send added/removed for collections with user_rel.""" for sub in Subscription.objects.filter(connection=this.ws.connection): params = loads(sub.params_ejson) @@ -173,7 +179,7 @@ class Auth(APIMixin): # calculate the querysets prior to update pre = collections.OrderedDict([ - (col, qs) for col, qs + (col, query) for col, query in API.sub_unique_objects(sub, params, pub) ]) @@ -183,30 +189,32 @@ class Auth(APIMixin): # calculate the querysets after the update post = collections.OrderedDict([ - (col, qs) for col, qs + (col, query) for col, query in API.sub_unique_objects(sub, params, pub) ]) # first pass, send `added` for objs unique to `post` - for col_post, qs in post.items(): + for col_post, query in post.items(): try: qs_pre = pre[col_post] - qs = qs.exclude(pk__in=qs_pre.order_by().values('pk')) + query = query.exclude(pk__in=qs_pre.order_by().values('pk')) except KeyError: # collection not included pre-auth, everything is added. pass - for obj in qs: + for obj in query: this.ws.send(col_post.obj_change_as_msg(obj, ADDED)) # second pass, send `removed` for objs unique to `pre` - for col_pre, qs in pre.items(): + for col_pre, query in pre.items(): try: qs_post = post[col_pre] - qs = qs.exclude(pk__in=qs_post.order_by().values('pk')) + query = query.exclude( + pk__in=qs_post.order_by().values('pk'), + ) except KeyError: # collection not included post-auth, everything is removed. pass - for obj in qs: + for obj in query: this.ws.send(col_pre.obj_change_as_msg(obj, REMOVED)) @staticmethod From a462fc52b64c0831263602858a13aa30c32c050b Mon Sep 17 00:00:00 2001 From: Tyson Clugg Date: Tue, 21 Jul 2015 18:59:16 +1000 Subject: [PATCH 07/34] Refactor dddp.accounts.ddp to not use this.request.user or any of django.contrib.sessions. --- dddp/accounts/ddp.py | 236 ++++++++++++++++++++++++------------------- 1 file changed, 130 insertions(+), 106 deletions(-) diff --git a/dddp/accounts/ddp.py b/dddp/accounts/ddp.py index 5a2958a..dfee366 100644 --- a/dddp/accounts/ddp.py +++ b/dddp/accounts/ddp.py @@ -7,14 +7,16 @@ See http://docs.meteor.com/#/full/accounts_api for details of each method. """ from binascii import Error import collections +import datetime import hashlib from ejson import loads, dumps from django.conf import settings from django.contrib import auth -from django.contrib.sessions.backends.db import SessionStore -from django.contrib.auth.signals import user_login_failed +from django.contrib.auth.signals import ( + user_login_failed, user_logged_in, user_logged_out, +) from django.dispatch import Signal from django.utils import timezone @@ -38,8 +40,71 @@ class HashPurpose(object): PASSWORD_RESET = 'password_reset' RESUME_LOGIN = 'resume_login' - CHANGE_EMAIL = 'change_email' - CREATE_USER = 'create_user' + + +HASH_DAYS_VALID = { + HashPurpose.PASSWORD_RESET: int( + getattr( + # keep possible attack window short to reduce chance of account + # takeover through later discovery of password reset email message. + settings, 'DDP_PASSWORD_RESET_DAYS_VALID', '1', + ) + ), + HashPurpose.RESUME_LOGIN: int( + getattr( + # balance security and useability by allowing users to resume their + # logins within a reasonable time, but not forever. + settings, 'DDP_LOGIN_RESUME_DAYS_VALID', '10', + ) + ), +} + + +def iter_auth_hashes(user, purpose, days_valid): + """ + Generate auth tokens tied to user and specified purpose. + + The hash expires at midnight on the day of today + days_valid, such that + when days_valid=1 you get *at least* 24 hours to use the token. + """ + today = timezone.now().date() + for day in range(days_valid + 1): + yield hashlib.sha1( + '%s:%s:%s:%s:%s' % ( + today - datetime.timedelta(days=day), + user.password, + purpose, + user.pk, + settings.SECRET_KEY, + ), + ).hexdigest() + + +def get_auth_hash(user, purpose): + """Generate a user hash for a particular purpose.""" + return iter_auth_hashes(user, purpose, days_valid=1).next() + + +def calc_expiry_time(days_valid): + """Return specific time an auth_hash will expire.""" + return ( + timezone.now() + datetime.timedelta(days=days_valid + 1) + ).replace(hour=0, minute=0, second=0, microsecond=0) + + +def get_user_token(user, purpose, days_valid): + """Return login token info for given user.""" + token = ''.join( + dumps([ + user.get_username(), + get_auth_hash(user, purpose), + ]).encode('base64').split('\n') + ) + return { + 'id': get_meteor_id(user), + 'token': token, + 'tokenExpires': calc_expiry_time(days_valid), + } class Users(Collection): @@ -132,7 +197,7 @@ class Users(Collection): del options user = get_object( self.model, selector['_id'], - pk=this.request.user.pk, + pk=this.user_id, ) profile_update = self.deserialize_profile( update['$set'], key_prefix='profile.', pop=True, @@ -227,55 +292,24 @@ class Auth(APIMixin): ) raise MeteorError(403, 'Authentication failed.') - @staticmethod - def get_auth_hash(user, purpose): - """Generate a user hash for a particular purpose.""" - return hashlib.sha1( - ':'.join([ - settings.SECRET_KEY, - user.get_session_auth_hash(), - purpose, - ]) - ).hexdigest() - @classmethod - def validated_user_and_session(cls, token, purpose): - """Resolve and validate auth token, returns user and session objects.""" + def validated_user(cls, token, purpose, days_valid): + """Resolve and validate auth token, returns user object.""" try: - username, session_key, auth_hash = loads(token.decode('base64')) + username, auth_hash = loads(token.decode('base64')) except (ValueError, Error): cls.auth_failed(token=token) try: user = cls.user_model.objects.get(**{ cls.user_model.USERNAME_FIELD: username, + 'is_active': True, }) user.backend = 'django.contrib.auth.backends.ModelBackend' except cls.user_model.DoesNotExist: cls.auth_failed(username=username, token=token) - if cls.get_auth_hash(user, purpose) != auth_hash: + if auth_hash not in iter_auth_hashes(user, purpose, days_valid): cls.auth_failed(username=username, token=token) - session = SessionStore( - session_key=session_key, - ) - if session.get_expiry_date() <= timezone.now(): - cls.auth_failed(username=username, token=token) - return (user, session) - - @classmethod - def get_user_token(cls, user, session_key, expiry_date, purpose): - """Return login token info for given user.""" - token = ''.join( - dumps([ - user.get_username(), - session_key, - cls.get_auth_hash(user, purpose), - ]).encode('base64').split('\n') - ) - return { - 'id': get_meteor_id(user), - 'token': token, - 'tokenExpires': expiry_date, - } + return user @staticmethod def check_secure(): @@ -362,22 +396,41 @@ class Auth(APIMixin): user = auth.authenticate( username=user.get_username(), password=params['password'], ) - auth.login(this.request, user) - self.sub_user() + self.do_login(user) + return get_user_token( + user=user, purpose=HashPurpose.RESUME_LOGIN, + days_valid=HASH_DAYS_VALID[HashPurpose.RESUME_LOGIN], + ) + + def do_login(self, user): + """Login a user.""" + this.user_id = user.pk + this.user_ddp_id = get_meteor_id(user) + # silent subscription (sans sub/nosub msg) to LoggedInUser pub + this.user_sub_id = meteor_random_id() + API.do_sub(this.user_sub_id, 'LoggedInUser', silent=True) self.update_subs(user.pk) - return self.get_user_token( - user=user, - session_key=this.request.session.session_key, - expiry_date=this.request.session.get_expiry_date(), - purpose=HashPurpose.CREATE_USER, + user_logged_in.send( + sender=user.__class__, request=this.request, user=user, + ) + + def do_logout(self): + """Logout a user.""" + user = self.user_model.objects.get(pk=this.user_id) + this.user_id = None + this.user_ddp_id = None + # silent unsubscription (sans sub/nosub msg) from LoggedInUser pub + API.do_unsub(this.user_sub_id, silent=True) + del this.user_sub_id + self.update_subs(None) + user_logged_out.send( + sender=self.user_model, request=this.request, user=user, ) @api_endpoint def logout(self): """Logout current user.""" - auth.logout(this.request) - self.unsub_user() - self.update_subs(None) + self.do_logout() @api_endpoint def login(self, params): @@ -389,28 +442,6 @@ class Auth(APIMixin): else: self.auth_failed(**params) - def sub_user(self): - """Silent subscription (sans sub/nosub msg) to LoggedInUser pub.""" - this.send_orig = this.send - this.send = self.send_alt - this.user_sub_id = meteor_random_id() - API.sub(this.user_sub_id, 'LoggedInUser') - this.send = this.send_orig - - def unsub_user(self): - """Silent unsubscription (sans sub/nosub msg) from LoggedInUser pub.""" - this.send_orig = this.send - this.send = self.send_alt - API.unsub(this.user_sub_id) - this.send = this.send_orig - del this.send_orig - del this.user_sub_id - - def send_alt(self, msg): - """Alternative send for use within login method.""" - if msg['msg'] not in ['ready', 'nosub']: - this.send_orig(msg) - def login_with_password(self, params): """Authenticate using credentials supplied in params.""" # never allow insecure login @@ -423,15 +454,10 @@ class Auth(APIMixin): if user is not None: # the password verified for the user if user.is_active: - auth.login(this.request, user) - self.sub_user() - self.update_subs(user.pk) - this.request.session.save() - return self.get_user_token( - user=user, - session_key=this.request.session.session_key, - expiry_date=this.request.session.get_expiry_date(), - purpose=HashPurpose.RESUME_LOGIN, + self.do_login(user) + return get_user_token( + user=user, purpose=HashPurpose.RESUME_LOGIN, + days_valid=HASH_DAYS_VALID[HashPurpose.RESUME_LOGIN], ) # Call to `authenticate` was unable to verify the username and password. @@ -451,27 +477,27 @@ class Auth(APIMixin): # never allow insecure login self.check_secure() - # pull the username, session_key and auth_hash from the token - user, session = self.validated_user_and_session( + # pull the username and auth_hash from the token + user = self.validated_user( params['resume'], purpose=HashPurpose.RESUME_LOGIN, + days_valid=HASH_DAYS_VALID[HashPurpose.RESUME_LOGIN], ) - auth.login(this.request, user) - self.sub_user() - self.update_subs(user.pk) - this.request.session.save() - return self.get_user_token( - user=user, - session_key=session.session_key, - expiry_date=session.get_expiry_date(), - purpose=HashPurpose.RESUME_LOGIN, + self.do_login(user) + return get_user_token( + user=user, purpose=HashPurpose.RESUME_LOGIN, + days_valid=HASH_DAYS_VALID[HashPurpose.RESUME_LOGIN], ) @api_endpoint('changePassword') def change_password(self, old_password, new_password): """Change password.""" + try: + user = self.user_model.objects.get(pk=this.user_id) + except self.user_model.DoesNotExist: + self.auth_failed() user = auth.authenticate( - username=this.request.user.get_username(), + username=user.get_username(), password=self.get_password(old_password), ) if user is None: @@ -497,11 +523,10 @@ class Auth(APIMixin): except self.user_model.DoesNotExist: self.auth_failed() - expiry_date = this.request.session.get_expiry_date() - token = self.get_user_token( - user=user, session_key=this.request.session.session_key, - expiry_date=expiry_date, - purpose=HashPurpose.PASSWORD_RESET, + days_valid = HASH_DAYS_VALID[HashPurpose.PASSWORD_RESET], + token = get_user_token( + user=user, purpose=HashPurpose.PASSWORD_RESET, + days_valid=days_valid, ) forgot_password.send( @@ -509,21 +534,20 @@ class Auth(APIMixin): user=user, token=token, request=this.request, - expiry_date=expiry_date, + expiry_date=calc_expiry_time(days_valid), ) @api_endpoint('resetPassword') def reset_password(self, token, new_password): """Reset password using a token received in email then logs user in.""" - user, _ = self.validated_user_and_session( + user = self.validated_user( token, purpose=HashPurpose.PASSWORD_RESET, + days_valid=HASH_DAYS_VALID[HashPurpose.PASSWORD_RESET], ) user.set_password(new_password) user.save() - auth.login(this.request, user) - self.sub_user() - self.update_subs(user.pk) - return {"userId": get_meteor_id(this.request.user)}; + self.do_login(user) + return {"userId": this.user_ddp_id} API.register([Users, LoginServiceConfiguration, LoggedInUser, Auth]) From 36199d27c1da84bb7669ca0bef4fc7b737843b65 Mon Sep 17 00:00:00 2001 From: Tyson Clugg Date: Tue, 21 Jul 2015 19:17:21 +1000 Subject: [PATCH 08/34] Call ready() for each registered API provider as part of AppConfig.ready(). --- dddp/api.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/dddp/api.py b/dddp/api.py index ff2d1b7..72d1757 100644 --- a/dddp/api.py +++ b/dddp/api.py @@ -162,6 +162,10 @@ class APIMixin(object): """Return API endpoint for given api_path.""" return self.api_path_map()[api_path] + def ready(self): + """Initialisation (setup lookups and signal handlers).""" + pass + def model_name(model): """Return model name given model class.""" @@ -706,6 +710,9 @@ class DDP(APIMixin): signals.post_save.connect(self.on_post_save) signals.post_delete.connect(self.on_post_delete) signals.m2m_changed.connect(self.on_m2m_changed) + # call ready on each registered API endpoint + for api_provider in self.api_providers: + api_provider.ready() def on_pre_migrate(self, sender, **kwargs): """Pre-migrate signal handler.""" From 5088c0016d03cdc93f15b3dd2d9865de3d035bdd Mon Sep 17 00:00:00 2001 From: Tyson Clugg Date: Tue, 21 Jul 2015 21:17:23 +1000 Subject: [PATCH 09/34] Move thread local factories out of ThreadLocal so other modules may add their own factories. --- dddp/__init__.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/dddp/__init__.py b/dddp/__init__.py index 7c3c404..db0f1c4 100644 --- a/dddp/__init__.py +++ b/dddp/__init__.py @@ -48,28 +48,27 @@ class ThreadLocal(local): _init_done = False - def __init__(self, **default_factories): + def __init__(self): """Create new thread storage instance.""" if self._init_done: raise SystemError('__init__ called too many times') self._init_done = True - self._default_factories = default_factories def __getattr__(self, name): """Create missing attributes using default factories.""" try: - factory = self._default_factories[name] + factory = THREAD_LOCAL_FACTORIES[name] except KeyError: raise AttributeError(name) - obj = factory() - setattr(self, name, obj) - return obj + return self.get(name, factory) def get(self, name, factory, *factory_args, **factory_kwargs): """Get attribute, creating if required using specified factory.""" - if not hasattr(self, name): + update_thread_local = getattr(factory, 'update_thread_local', True) + if (not update_thread_local) or (not hasattr(self, name)): obj = factory(*factory_args, **factory_kwargs) - setattr(self, name, obj) + if update_thread_local: + setattr(self, name, obj) return obj return getattr(self, name) @@ -99,11 +98,12 @@ def serializer_factory(): return get_serializer('python')() -THREAD_LOCAL = ThreadLocal( - alea_random=alea.Alea, - random_streams=RandomStreams, - serializer=serializer_factory, -) +THREAD_LOCAL_FACTORIES = { + 'alea_random': alea.Alea, + 'random_streams': RandomStreams, + 'serializer': serializer_factory, +} +THREAD_LOCAL = ThreadLocal() METEOR_ID_CHARS = u'23456789ABCDEFGHJKLMNPQRSTWXYZabcdefghijkmnopqrstuvwxyz' From 305b00bc3d6814371939c5582a0434b56ea34542 Mon Sep 17 00:00:00 2001 From: Tyson Clugg Date: Tue, 21 Jul 2015 21:19:55 +1000 Subject: [PATCH 10/34] Add ThreadLocal factory for `this.user` which is retrieved from the datbase on demand. --- dddp/accounts/ddp.py | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/dddp/accounts/ddp.py b/dddp/accounts/ddp.py index dfee366..05d328e 100644 --- a/dddp/accounts/ddp.py +++ b/dddp/accounts/ddp.py @@ -20,7 +20,10 @@ from django.contrib.auth.signals import ( from django.dispatch import Signal from django.utils import timezone -from dddp import THREAD_LOCAL as this, ADDED, REMOVED, meteor_random_id +from dddp import ( + THREAD_LOCAL_FACTORIES, THREAD_LOCAL as this, ADDED, REMOVED, + meteor_random_id, +) from dddp.models import get_meteor_id, get_object, Subscription from dddp.api import API, APIMixin, api_endpoint, Collection, Publication from dddp.websocket import MeteorError @@ -234,6 +237,19 @@ class Auth(APIMixin): api_path_prefix = '' # auth endpoints don't have a common prefix user_model = auth.get_user_model() + user_id = None + user_ddp_id = None + + def user_factory(self): + """Retrieve the current user (or None) from the database.""" + if this.user_id is None: + return None + return self.user_model.objects.get(pk=this.user_id) + user_factory.update_thread_local = False + + def ready(self): + """Called after AppConfig.ready().""" + THREAD_LOCAL_FACTORIES['user'] = self.user_factory @staticmethod def update_subs(new_user_id): @@ -416,16 +432,15 @@ class Auth(APIMixin): def do_logout(self): """Logout a user.""" - user = self.user_model.objects.get(pk=this.user_id) - this.user_id = None - this.user_ddp_id = None # silent unsubscription (sans sub/nosub msg) from LoggedInUser pub API.do_unsub(this.user_sub_id, silent=True) del this.user_sub_id self.update_subs(None) user_logged_out.send( - sender=self.user_model, request=this.request, user=user, + sender=self.user_model, request=this.request, user=this.user, ) + this.user_id = None + this.user_ddp_id = None @api_endpoint def logout(self): @@ -493,7 +508,7 @@ class Auth(APIMixin): def change_password(self, old_password, new_password): """Change password.""" try: - user = self.user_model.objects.get(pk=this.user_id) + user = this.user except self.user_model.DoesNotExist: self.auth_failed() user = auth.authenticate( From d358ce413a41002259194a6d165265b7e1a13ee5 Mon Sep 17 00:00:00 2001 From: Tyson Clugg Date: Tue, 21 Jul 2015 21:23:37 +1000 Subject: [PATCH 11/34] Never assume `this.user_id` is available. --- dddp/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dddp/api.py b/dddp/api.py index 72d1757..bb1b88f 100644 --- a/dddp/api.py +++ b/dddp/api.py @@ -591,7 +591,7 @@ class DDP(APIMixin): sub, created = Subscription.objects.get_or_create( connection_id=this.ws.connection.pk, sub_id=id_, - user_id=this.request.user.pk, + user_id=getattr(this, 'user_id', None), defaults={ 'publication': pub.name, 'params_ejson': ejson.dumps(params), From e7b38b89db5c4e252ac37566f626b5e9e1651a29 Mon Sep 17 00:00:00 2001 From: Tyson Clugg Date: Tue, 21 Jul 2015 21:24:14 +1000 Subject: [PATCH 12/34] Stop running request middleware upon connection. This change deserves a more thorough explanation. All currently released versions of Django are based around the concept of receiving a request and immediately dispatching a response. WebSocket connections don't follow this convention, and the `request` hangs around for long periods of time. As such, things like `request.user` don't really make sense as a user may login, then logout, then login again all within the life of a single request. Given that the concepts applied in Django are based upon a premise that doesn't hold true for WebSockets (that a request is short-lived), it doesn't make sense to apply those concepts in django-ddp. --- dddp/websocket.py | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/dddp/websocket.py b/dddp/websocket.py index be4f507..5d2f85c 100644 --- a/dddp/websocket.py +++ b/dddp/websocket.py @@ -107,7 +107,6 @@ class DDPWebSocketApplication(geventwebsocket.WebSocketApplication): support = None connection = None subs = None - request = None remote_ids = None base_handler = BaseHandler() @@ -312,21 +311,11 @@ class DDPWebSocketApplication(geventwebsocket.WebSocketApplication): elif version not in support: self.error('Client version/support mismatch.') else: - self.request = WSGIRequest(self.ws.environ) - # Apply request middleware (so we get request.user and other attrs) - # pylint: disable=protected-access - if self.base_handler._request_middleware is None: - self.base_handler.load_middleware() - for middleware_method in self.base_handler._request_middleware: - response = middleware_method(self.request) - if response: - raise ValueError(response) + this.request = WSGIRequest(self.ws.environ) this.ws = self - this.request = self.request this.send = self.send this.reply = self.reply this.error = self.error - this.request.session.save() from dddp.models import Connection cur = connection.cursor() From 2ebaff14dd75331658052da85182673673d1ba69 Mon Sep 17 00:00:00 2001 From: Tyson Clugg Date: Tue, 21 Jul 2015 22:23:12 +1000 Subject: [PATCH 13/34] Update CHANGES.rst, bump version number. --- CHANGES.rst | 7 +++++++ setup.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index e95f5af..88647f4 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,13 @@ Change Log ========== +0.10.0 +------ +* Stop processing request middleware upon connection - see + https://github.com/commoncode/django-ddp/commit/e7b38b89db5c4e252ac37566f626b5e9e1651a29 + for rationale. Access to `this.request.user` is gone. +* Add `this.user` handling to dddp.accounts. + 0.9.14 ------ * Fix ordering of user added vs login ready in dddp.accounts diff --git a/setup.py b/setup.py index d98046f..a736cae 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ from setuptools import setup, find_packages setup( name='django-ddp', - version='0.9.14', + version='0.10.0', description=__doc__, long_description=open('README.rst').read(), author='Tyson Clugg', From d762fa45c498979fdb704567898f3bb028415861 Mon Sep 17 00:00:00 2001 From: Tyson Clugg Date: Wed, 22 Jul 2015 10:33:21 +1000 Subject: [PATCH 14/34] Updated error handling to ensure error reply to method messages when appropriate. --- dddp/api.py | 12 ++++++++++- dddp/websocket.py | 51 +++++++++++++++++++++++++++-------------------- 2 files changed, 40 insertions(+), 23 deletions(-) diff --git a/dddp/api.py b/dddp/api.py index bb1b88f..d61d9aa 100644 --- a/dddp/api.py +++ b/dddp/api.py @@ -646,7 +646,17 @@ class DDP(APIMixin): try: handler = self.api_path_map()[method] except KeyError: - this.error('Unknown method: %s' % method) + print('Unknown method: %s %r' % (method, params)) + this.send({ + 'msg': 'result', + 'id': id_, + 'error': { + 'error': 404, + 'errorType': 'Meteor.Error', + 'message': 'Unknown method: %s %r' % (method, params), + 'reason': 'Method not found', + }, + }) return params_repr = repr(params) try: diff --git a/dddp/websocket.py b/dddp/websocket.py index 5d2f85c..b599e29 100644 --- a/dddp/websocket.py +++ b/dddp/websocket.py @@ -159,32 +159,35 @@ class DDPWebSocketApplication(geventwebsocket.WebSocketApplication): try: msgs = ejson.loads(message) except ValueError, err: - raise MeteorError(400, 'Data is not valid EJSON') + self.error(400, 'Data is not valid EJSON') + return if not isinstance(msgs, list): - raise MeteorError(400, 'Invalid EJSON messages') + self.error(400, 'Invalid EJSON messages') + return # process individual messages while msgs: # parse message payload raw = msgs.pop(0) try: - try: - data = ejson.loads(raw) - except ValueError, err: - raise MeteorError(400, 'Data is not valid EJSON') - if not isinstance(data, dict): - self.error(400, 'Invalid EJSON message payload', raw) - continue - try: - msg = data.pop('msg') - except KeyError: - raise MeteorError( - 400, 'Bad request', None, {'offendingMessage': data} - ) - # dispatch message + data = ejson.loads(raw) + except ValueError, err: + self.error(400, 'Data is not valid EJSON') + continue + if not isinstance(data, dict): + self.error(400, 'Invalid EJSON message payload', raw) + continue + try: + msg = data.pop('msg') + except KeyError: + self.error( + 400, 'Bad request', offendingMessage=data, + ) + continue + # dispatch message + try: self.dispatch(msg, data) except MeteorError, err: - traceback.print_exc() self.error(err) except Exception, err: traceback.print_exc() @@ -199,13 +202,16 @@ class DDPWebSocketApplication(geventwebsocket.WebSocketApplication): """Dispatch msg to appropriate recv_foo handler.""" # enforce calling 'connect' first if self.connection is None and msg != 'connect': - raise MeteorError(400, 'Must connect first') + self.error(400, 'Must connect first') + return # lookup method handler try: handler = getattr(self, 'recv_%s' % msg) except (AttributeError, UnicodeEncodeError): - raise MeteorError(404, 'Method not found') + print('Method not found: %s %r' % (msg, kwargs)) + self.error(404, 'Method not found', msg='result') + return # validate handler arguments validate_kwargs(handler, kwargs) @@ -215,7 +221,7 @@ class DDPWebSocketApplication(geventwebsocket.WebSocketApplication): handler(**kwargs) except Exception, err: # print stack trace --> pylint: disable=W0703 traceback.print_exc() - self.error(MeteorError(500, 'Internal server error', err)) + self.error(500, 'Internal server error', err) def send(self, data, tx_id=None): """Send `data` (raw string or EJSON payload) to WebSocket client.""" @@ -272,7 +278,7 @@ class DDPWebSocketApplication(geventwebsocket.WebSocketApplication): kwargs['msg'] = msg self.send(kwargs) - def error(self, err, reason=None, detail=None, **kwargs): + def error(self, err, reason=None, detail=None, msg='error', **kwargs): """Send EJSON error to remote.""" if isinstance(err, MeteorError): ( @@ -296,12 +302,13 @@ class DDPWebSocketApplication(geventwebsocket.WebSocketApplication): if kwargs: data.update(kwargs) self.logger.error('! %s %r', self, data) - self.reply('error', **data) + self.reply(msg, **data) def recv_connect(self, version=None, support=None, session=None): """DDP connect handler.""" if self.connection is not None: self.error( + 400, 'Session already established.', reason='Current session in detail.', detail=self.connection.connection_id, From aa89ead1eda1fa7d154f253ced99a89298c402e2 Mon Sep 17 00:00:00 2001 From: Tyson Clugg Date: Thu, 23 Jul 2015 11:07:20 +1000 Subject: [PATCH 15/34] Allow support for multiple meteor apps in a single django-ddp enabled project by reading METEOR_STAR_JSON as part of view init instead of app ready. --- dddp/server/apps.py | 171 ------------------------------------------ dddp/server/views.py | 175 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 168 insertions(+), 178 deletions(-) diff --git a/dddp/server/apps.py b/dddp/server/apps.py index 3065be9..4a742e9 100644 --- a/dddp/server/apps.py +++ b/dddp/server/apps.py @@ -1,37 +1,9 @@ """Django DDP Server app config.""" from __future__ import print_function, absolute_import, unicode_literals -import io import mimetypes -import os.path from django.apps import AppConfig -from django.conf import settings -from django.core.exceptions import ImproperlyConfigured -from ejson import dumps, loads -import pybars - - -STAR_JSON_SETTING_NAME = 'METEOR_STAR_JSON' - - -def read(path, default=None, encoding='utf8'): - """Read encoded contents from specified path or return default.""" - if not path: - return default - try: - with io.open(path, mode='r', encoding=encoding) as contents: - return contents.read() - except IOError: - if default is not None: - return default - raise - - -def read_json(path): - """Read JSON encoded contents from specified path.""" - with open(path, mode='r') as json_file: - return loads(json_file.read()) class ServerConfig(AppConfig): @@ -41,149 +13,6 @@ class ServerConfig(AppConfig): name = 'dddp.server' verbose_name = 'Django DDP Meteor Web Server' - manifest = None - program_json = None - program_json_path = None - runtime_config = None - - star_json = None # top level layout - server_json = None # web server layout - web_browser_json = None # web.browser (client) layout - - url_map = None - internal_map = None - server_load_map = None - template_path = None # web server HTML template path - client_map = None # web.browser (client) URL to path map - html = '\nDDP App' - def ready(self): """Configure Django DDP server app.""" mimetypes.init() # read and process /etc/mime.types - self.url_map = {} - - try: - json_path = getattr(settings, STAR_JSON_SETTING_NAME) - except AttributeError: - raise ImproperlyConfigured( - '%s setting required by dddp.server.view.' % ( - STAR_JSON_SETTING_NAME, - ), - ) - - self.star_json = read_json(json_path) - star_format = self.star_json['format'] - if star_format != 'site-archive-pre1': - raise ValueError( - 'Unknown Meteor star format: %r' % star_format, - ) - programs = { - program['name']: program - for program in self.star_json['programs'] - } - - server_json_path = os.path.join( - os.path.dirname(json_path), - os.path.dirname(programs['server']['path']), - 'program.json', - ) - self.server_json = read_json(server_json_path) - server_format = self.server_json['format'] - if server_format != 'javascript-image-pre1': - raise ValueError( - 'Unknown Meteor server format: %r' % server_format, - ) - self.server_load_map = {} - for item in self.server_json['load']: - item['path_full'] = os.path.join( - os.path.dirname(server_json_path), - item['path'], - ) - self.server_load_map[item['path']] = item - self.url_map[item['path']] = ( - item['path_full'], 'text/javascript' - ) - try: - item['source_map_full'] = os.path.join( - os.path.dirname(server_json_path), - item['sourceMap'], - ) - self.url_map[item['sourceMap']] = ( - item['source_map_full'], 'text/plain' - ) - except KeyError: - pass - self.template_path = os.path.join( - os.path.dirname(server_json_path), - self.server_load_map[ - 'packages/boilerplate-generator.js' - ][ - 'assets' - ][ - 'boilerplate_web.browser.html' - ], - ) - - web_browser_json_path = os.path.join( - os.path.dirname(json_path), - programs['web.browser']['path'], - ) - self.web_browser_json = read_json(web_browser_json_path) - web_browser_format = self.web_browser_json['format'] - if web_browser_format != 'web-program-pre1': - raise ValueError( - 'Unknown Meteor web.browser format: %r' % ( - web_browser_format, - ), - ) - self.client_map = {} - self.internal_map = {} - for item in self.web_browser_json['manifest']: - item['path_full'] = os.path.join( - os.path.dirname(web_browser_json_path), - item['path'], - ) - if item['where'] == 'client': - if '?' in item['url']: - item['url'] = item['url'].split('?', 1)[0] - self.client_map[item['url']] = item - self.url_map[item['url']] = ( - item['path_full'], - mimetypes.guess_type( - item['path_full'], - )[0] or 'application/octet-stream', - ) - elif item['where'] == 'internal': - self.internal_map[item['type']] = item - - config = { - 'css': [ - {'url': item['path']} - for item in self.web_browser_json['manifest'] - if item['type'] == 'css' and item['where'] == 'client' - ], - 'js': [ - {'url': item['path']} - for item in self.web_browser_json['manifest'] - if item['type'] == 'js' and item['where'] == 'client' - ], - 'meteorRuntimeConfig': '"%s"' % ( - dumps(self.runtime_config) - ), - 'rootUrlPathPrefix': '/app', - 'bundledJsCssPrefix': '/app/', - 'inlineScriptsAllowed': False, - 'inline': None, - 'head': read( - self.internal_map.get('head', {}).get('path_full', None), - default=u'', - ), - 'body': read( - self.internal_map.get('body', {}).get('path_full', None), - default=u'', - ), - } - tmpl_raw = read(self.template_path, encoding='utf8') - compiler = pybars.Compiler() - tmpl = compiler.compile(tmpl_raw) - self.html = '\n%s' % tmpl(config) diff --git a/dddp/server/views.py b/dddp/server/views.py index 59e4575..9084fc5 100644 --- a/dddp/server/views.py +++ b/dddp/server/views.py @@ -1,13 +1,35 @@ """Django DDP Server views.""" from __future__ import print_function, absolute_import, unicode_literals -from ejson import dumps -from django.apps import apps + +import io +import mimetypes +import os.path + +from ejson import dumps, loads from django.conf import settings from django.http import HttpResponse from django.views.generic import View +import pybars -STAR_JSON_SETTING_NAME = 'METEOR_STAR_JSON' + +def read(path, default=None, encoding='utf8'): + """Read encoded contents from specified path or return default.""" + if not path: + return default + try: + with io.open(path, mode='r', encoding=encoding) as contents: + return contents.read() + except IOError: + if default is not None: + return default + raise + + +def read_json(path): + """Read JSON encoded contents from specified path.""" + with open(path, mode='r') as json_file: + return loads(json_file.read()) class MeteorView(View): @@ -16,13 +38,152 @@ class MeteorView(View): http_method_names = ['get', 'head'] - app = None + json_path = None runtime_config = None + manifest = None + program_json = None + program_json_path = None + runtime_config = None + + star_json = None # top level layout + + url_map = None + internal_map = None + server_load_map = None + template_path = None # web server HTML template path + client_map = None # web.browser (client) URL to path map + html = '\nDDP App' + + root_url_path_prefix = '' + bundled_js_css_prefix = '/' + def __init__(self, **kwargs): """Initialisation for Django DDP server view.""" + # super(...).__init__ assigns kwargs to instance. super(MeteorView, self).__init__(**kwargs) - self.app = apps.get_app_config('server') + + self.url_map = {} + + # process `star_json` + self.star_json = read_json(self.json_path) + star_format = self.star_json['format'] + if star_format != 'site-archive-pre1': + raise ValueError( + 'Unknown Meteor star format: %r' % star_format, + ) + programs = { + program['name']: program + for program in self.star_json['programs'] + } + + # process `bundle/programs/server/program.json` from build dir + server_json_path = os.path.join( + os.path.dirname(self.json_path), + os.path.dirname(programs['server']['path']), + 'program.json', + ) + server_json = read_json(server_json_path) + server_format = server_json['format'] + if server_format != 'javascript-image-pre1': + raise ValueError( + 'Unknown Meteor server format: %r' % server_format, + ) + self.server_load_map = {} + for item in server_json['load']: + item['path_full'] = os.path.join( + os.path.dirname(server_json_path), + item['path'], + ) + self.server_load_map[item['path']] = item + self.url_map[item['path']] = ( + item['path_full'], 'text/javascript' + ) + try: + item['source_map_full'] = os.path.join( + os.path.dirname(server_json_path), + item['sourceMap'], + ) + self.url_map[item['sourceMap']] = ( + item['source_map_full'], 'text/plain' + ) + except KeyError: + pass + self.template_path = os.path.join( + os.path.dirname(server_json_path), + self.server_load_map[ + 'packages/boilerplate-generator.js' + ][ + 'assets' + ][ + 'boilerplate_web.browser.html' + ], + ) + + # process `bundle/programs/web.browser/program.json` from build dir + web_browser_json_path = os.path.join( + os.path.dirname(self.json_path), + programs['web.browser']['path'], + ) + web_browser_json = read_json(web_browser_json_path) + web_browser_format = web_browser_json['format'] + if web_browser_format != 'web-program-pre1': + raise ValueError( + 'Unknown Meteor web.browser format: %r' % ( + web_browser_format, + ), + ) + self.client_map = {} + self.internal_map = {} + for item in web_browser_json['manifest']: + item['path_full'] = os.path.join( + os.path.dirname(web_browser_json_path), + item['path'], + ) + if item['where'] == 'client': + if '?' in item['url']: + item['url'] = item['url'].split('?', 1)[0] + self.client_map[item['url']] = item + self.url_map[item['url']] = ( + item['path_full'], + mimetypes.guess_type( + item['path_full'], + )[0] or 'application/octet-stream', + ) + elif item['where'] == 'internal': + self.internal_map[item['type']] = item + + config = { + 'css': [ + {'url': item['path']} + for item in web_browser_json['manifest'] + if item['type'] == 'css' and item['where'] == 'client' + ], + 'js': [ + {'url': item['path']} + for item in web_browser_json['manifest'] + if item['type'] == 'js' and item['where'] == 'client' + ], + 'meteorRuntimeConfig': '"%s"' % ( + dumps(self.runtime_config) + ), + 'rootUrlPathPrefix': self.root_url_path_prefix, + 'bundledJsCssPrefix': self.bundled_js_css_prefix, + 'inlineScriptsAllowed': False, + 'inline': None, + 'head': read( + self.internal_map.get('head', {}).get('path_full', None), + default=u'', + ), + 'body': read( + self.internal_map.get('body', {}).get('path_full', None), + default=u'', + ), + } + tmpl_raw = read(self.template_path, encoding='utf8') + compiler = pybars.Compiler() + tmpl = compiler.compile(tmpl_raw) + self.html = '\n%s' % tmpl(config) def get(self, request, path): """Return HTML (or other related content) for Meteor.""" @@ -46,7 +207,7 @@ class MeteorView(View): content_type='text/javascript', ) try: - file_path, content_type = self.app.url_map[path] + file_path, content_type = self.url_map[path] with open(file_path, 'r') as content: return HttpResponse( content.read(), @@ -54,5 +215,5 @@ class MeteorView(View): ) except KeyError: print(path) - return HttpResponse(self.app.html) + return HttpResponse(self.html) # raise Http404 From 6ea0e71a741dc74bddd31238d61877f5d9156971 Mon Sep 17 00:00:00 2001 From: Tyson Clugg Date: Tue, 28 Jul 2015 14:04:11 +1000 Subject: [PATCH 16/34] Bugfix dddp.accounts forgot_password feature. --- dddp/accounts/ddp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dddp/accounts/ddp.py b/dddp/accounts/ddp.py index 05d328e..d49b0e8 100644 --- a/dddp/accounts/ddp.py +++ b/dddp/accounts/ddp.py @@ -538,7 +538,7 @@ class Auth(APIMixin): except self.user_model.DoesNotExist: self.auth_failed() - days_valid = HASH_DAYS_VALID[HashPurpose.PASSWORD_RESET], + days_valid = HASH_DAYS_VALID[HashPurpose.PASSWORD_RESET] token = get_user_token( user=user, purpose=HashPurpose.PASSWORD_RESET, days_valid=days_valid, From ca2be7fec7b409989147f8d68b5b7d304848c3d4 Mon Sep 17 00:00:00 2001 From: Tyson Clugg Date: Tue, 28 Jul 2015 14:05:25 +1000 Subject: [PATCH 17/34] Update CHANGES.rst, bump version number. --- CHANGES.rst | 4 ++++ setup.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 88647f4..0055509 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,10 @@ Change Log ========== +0.10.1 +------ +* Bugfix dddp.accounts forgot_password feature. + 0.10.0 ------ * Stop processing request middleware upon connection - see diff --git a/setup.py b/setup.py index a736cae..8b1c8c9 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ from setuptools import setup, find_packages setup( name='django-ddp', - version='0.10.0', + version='0.10.1', description=__doc__, long_description=open('README.rst').read(), author='Tyson Clugg', From 5ec27b669c9c0712f43cf2b1453f4d82a3a76c8a Mon Sep 17 00:00:00 2001 From: Tyson Clugg Date: Mon, 10 Aug 2015 16:34:39 +1000 Subject: [PATCH 18/34] Add BackdoorServer support to dddp command, add "Logs" publication. --- dddp/api.py | 2 ++ dddp/ddp.py | 22 ++++++++++++++++++++++ dddp/logging.py | 39 +++++++++++++++++++++++++++++++++++++++ dddp/main.py | 47 ++++++++++++++++++++++++++++++++++++++--------- dddp/websocket.py | 3 +-- 5 files changed, 102 insertions(+), 11 deletions(-) create mode 100644 dddp/ddp.py create mode 100644 dddp/logging.py diff --git a/dddp/api.py b/dddp/api.py index d61d9aa..1780d27 100644 --- a/dddp/api.py +++ b/dddp/api.py @@ -597,6 +597,7 @@ class DDP(APIMixin): 'params_ejson': ejson.dumps(params), }, ) + this.subs.setdefault(sub.publication, set()).add(sub.pk) if not created: if not silent: this.send({'msg': 'ready', 'subs': [id_]}) @@ -636,6 +637,7 @@ class DDP(APIMixin): for obj in qs: payload = col.obj_change_as_msg(obj, REMOVED, meteor_ids) this.send(payload) + this.subs[sub.publication].remove(sub.pk) sub.delete() if not silent: this.send({'msg': 'nosub', 'id': id_}) diff --git a/dddp/ddp.py b/dddp/ddp.py new file mode 100644 index 0000000..0af0f11 --- /dev/null +++ b/dddp/ddp.py @@ -0,0 +1,22 @@ +from dddp import THREAD_LOCAL as this +from dddp.api import API, Publication +from django.contrib import auth + + +class Logs(Publication): + + users = auth.get_user_model() + + def get_queries(self): + user_pk = getattr(this, 'user_id', False) + if user_pk: + if self.users.objects.filter( + pk=user_pk, + is_active=True, + is_superuser=True, + ).exists(): + return [] + raise ValueError('User not permitted.') + + +API.register([Logs]) diff --git a/dddp/logging.py b/dddp/logging.py new file mode 100644 index 0000000..147873f --- /dev/null +++ b/dddp/logging.py @@ -0,0 +1,39 @@ +"""Django DDP logging helpers.""" +from __future__ import absolute_import, print_function + +import logging + +from dddp import THREAD_LOCAL as this, meteor_random_id, ADDED + + + + +class DDPHandler(logging.Handler): + + """Logging handler that streams log events via DDP to the current client.""" + + def __init__(self, *args, **kwargs): + print(self.__class__, args, kwargs) + self.logger = logging.getLogger('django.db.backends') + self.logger.info('Test') + super(DDPHandler, self).__init__(*args, **kwargs) + + def emit(self, record): + """Emit a formatted log record via DDP.""" + if getattr(this, 'subs', {}).get('Logs', False): + this.send({ + 'msg': ADDED, + 'collection': 'logs', + 'id': meteor_random_id('/collection/logs'), + 'fields': { + # 'name': record.name, + # 'levelno': record.levelno, + 'levelname': record.levelname, + # 'pathname': record.pathname, + # 'lineno': record.lineno, + 'msg': record.msg, + 'args': record.args, + # 'exc_info': record.exc_info, + # 'funcName': record.funcName, + }, + }) diff --git a/dddp/main.py b/dddp/main.py index 2b0f863..e8ab34a 100644 --- a/dddp/main.py +++ b/dddp/main.py @@ -60,7 +60,7 @@ def ddpp_sockjs_info(environ, start_response): ])) -def serve(listen, debug=False, **ssl_args): +def serve(listen, debug=False, verbosity=1, debug_port=0, **ssl_args): """Spawn greenlets for handling websockets and PostgreSQL calls.""" import signal from django.apps import apps @@ -99,7 +99,7 @@ def serve(listen, debug=False, **ssl_args): ) # setup WebSocketServer to dispatch web requests - webservers = [ + servers = [ geventwebsocket.WebSocketServer( (host, port), resource, @@ -113,8 +113,8 @@ def serve(listen, debug=False, **ssl_args): def killall(*args, **kwargs): """Kill all green threads.""" pgworker.stop() - for webserver in webservers: - webserver.stop() + for server in servers: + server.stop() # die gracefully with SIGINT or SIGQUIT gevent.signal(signal.SIGINT, killall) @@ -133,19 +133,38 @@ def serve(listen, debug=False, **ssl_args): ) # start greenlets + if debug_port: + from gevent.backdoor import BackdoorServer + servers.append( + BackdoorServer( + ('127.0.0.1', debug_port), + banner='Django DDP', + locals={ + 'servers': servers, + 'pgworker': pgworker, + 'killall': killall, + 'api': api, + 'resource': resource, + 'settings': settings, + 'wsgi_app': wsgi_app, + 'wsgi_name': wsgi_name, + }, + ) + ) + pgworker.start() print('=> Started PostgresGreenlet.') - web_threads = [ - gevent.spawn(webserver.serve_forever) - for webserver - in webservers + threads = [ + gevent.spawn(server.serve_forever) + for server + in servers ] print('=> Started DDPWebSocketApplication.') print('=> Started your app (%s).' % wsgi_name) print('') for host, port in listen: print('=> App running at: http://%s:%d/' % (host, port)) - gevent.joinall(web_threads) + gevent.joinall(threads) pgworker.stop() gevent.joinall([pgworker]) @@ -190,6 +209,14 @@ def main(): import argparse parser = argparse.ArgumentParser(description=__doc__) django = parser.add_argument_group('Django Options') + django.add_argument( + '--verbosity', '-v', metavar='VERBOSITY', dest='verbosity', type=int, + default=1, + ) + django.add_argument( + '--debug-port', metavar='DEBUG_PORT', dest='debug_port', type=int, + default=0, + ) django.add_argument( '--settings', metavar='SETTINGS', dest='settings', help="The Python path to a settings module, e.g. " @@ -218,8 +245,10 @@ def main(): os.environ['DJANGO_SETTINGS_MODULE'] = namespace.settings serve( namespace.listen or [Addr('localhost', 8000)], + debug_port=namespace.debug_port, keyfile=namespace.keyfile, certfile=namespace.certfile, + verbosity=namespace.verbosity, ) diff --git a/dddp/websocket.py b/dddp/websocket.py index b599e29..0676363 100644 --- a/dddp/websocket.py +++ b/dddp/websocket.py @@ -106,7 +106,6 @@ class DDPWebSocketApplication(geventwebsocket.WebSocketApplication): version = None support = None connection = None - subs = None remote_ids = None base_handler = BaseHandler() @@ -131,7 +130,7 @@ class DDPWebSocketApplication(geventwebsocket.WebSocketApplication): '{0[REMOTE_ADDR]}:{0[REMOTE_PORT]}'.format( self.ws.environ, ) - self.subs = {} + this.subs = {} self.logger.info('+ %s OPEN', self) self.send('o') self.send('a["{\\"server_id\\":\\"0\\"}"]') From 573886d96fb94e3577ee4fe9d94e2a938a49c9f7 Mon Sep 17 00:00:00 2001 From: Tyson Clugg Date: Mon, 10 Aug 2015 16:39:37 +1000 Subject: [PATCH 19/34] Update CHANGES.rst, bump version number. --- CHANGES.rst | 7 +++++++ setup.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 0055509..af1de8f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,13 @@ Change Log ========== +0.10.2 +------ +* Add `Logs` publication that can be configured to emit logs via DDP + through the use of the `dddp.logging.DDPHandler` log handler. +* Add option to dddp daemon to provide a BackdoorServer (telnet) for + interactive debugging (REPL) at runtime. + 0.10.1 ------ * Bugfix dddp.accounts forgot_password feature. diff --git a/setup.py b/setup.py index 8b1c8c9..ce1b417 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ from setuptools import setup, find_packages setup( name='django-ddp', - version='0.10.1', + version='0.10.2', description=__doc__, long_description=open('README.rst').read(), author='Tyson Clugg', From 7083c5c92f0c8bfd4f00d93e9dc4cc03456dba85 Mon Sep 17 00:00:00 2001 From: Tyson Clugg Date: Mon, 10 Aug 2015 18:47:04 +1000 Subject: [PATCH 20/34] Chunked payload for NOTIFY/LISTEN to get around 8KB limit on payload. --- dddp/api.py | 32 ++++++++++++++++++++++++++------ dddp/postgres.py | 25 ++++++++++++++++++++++++- 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/dddp/api.py b/dddp/api.py index 1780d27..b3d8d97 100644 --- a/dddp/api.py +++ b/dddp/api.py @@ -5,6 +5,7 @@ from __future__ import absolute_import, unicode_literals, print_function import collections from copy import deepcopy import traceback +import uuid # requirements import dbarray @@ -888,13 +889,32 @@ class DDP(APIMixin): if my_connection_id in connection_ids: # msg must go to connection that initiated the change payload['_tx_id'] = this.ws.get_tx_id() + # header is sent in every payload + header = { + 'uuid': uuid.uuid1().int, # UUID1 should be unique + 'seq': 1, # increments for each 8KB chunk + 'fin': 0, # zero if more chunks expected, 1 if last chunk. + } + data = ejson.dumps(payload) cursor = connections[using].cursor() - cursor.execute( - 'NOTIFY "ddp", %s', - [ - ejson.dumps(payload), - ], - ) + while data: + hdr = ejson.dumps(header) + # use all available payload space for chunk + max_len = 8000 - len(hdr) - 100 + # take a chunk from data + chunk, data = data[:max_len], data[max_len:] + if not data: + # last chunk, set fin=1. + header['fin'] = 1 + hdr = ejson.dumps(header) + # print('NOTIFY: %s' % hdr) + cursor.execute( + 'NOTIFY "ddp", %s', + [ + '%s|%s' % (hdr, chunk), # pipe separates hdr|chunk. + ], + ) + header['seq'] += 1 # increment sequence. API = DDP() diff --git a/dddp/postgres.py b/dddp/postgres.py index 2c2404c..51844d0 100644 --- a/dddp/postgres.py +++ b/dddp/postgres.py @@ -22,6 +22,7 @@ class PostgresGreenlet(gevent.Greenlet): # queues for processing incoming sub/unsub requests and processing self.connections = {} + self.chunks = {} self._stop_event = gevent.event.Event() # connect to DB in async mode @@ -62,7 +63,29 @@ class PostgresGreenlet(gevent.Greenlet): "Got NOTIFY (pid=%d, payload=%r)", notify.pid, notify.payload, ) - data = ejson.loads(notify.payload) + + # read the header and check seq/fin. + hdr, chunk = notify.payload.split('|', 1) + # print('RECEIVE: %s' % hdr) + header = ejson.loads(hdr) + uuid = header['uuid'] + size, chunks = self.chunks.setdefault(uuid, [0, {}]) + if header['fin']: + size = self.chunks[uuid][0] = header['seq'] + + # stash the chunk + chunks[header['seq']] = chunk + + if len(chunks) != size: + # haven't got all the chunks yet + continue # process next NOTIFY in loop + + # got the last chunk -> process it. + data = ''.join( + chunk for _, chunk in sorted(chunks.items()) + ) + del self.chunks[uuid] # don't forget to cleanup! + data = ejson.loads(data) sender = data.pop('_sender', None) tx_id = data.pop('_tx_id', None) for connection_id in data.pop('_connection_ids'): From edac793c2e8a033733b35bbafc8ec2f98861dacd Mon Sep 17 00:00:00 2001 From: Tyson Clugg Date: Mon, 10 Aug 2015 18:50:36 +1000 Subject: [PATCH 21/34] Update CHANGES.rst, bump version number. --- CHANGES.rst | 5 +++++ setup.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index af1de8f..5a8edb4 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,11 @@ Change Log ========== +0.11.0 +------ +* Support more than 8KB of change data by splitting large payloads into + multiple chunks. + 0.10.2 ------ * Add `Logs` publication that can be configured to emit logs via DDP diff --git a/setup.py b/setup.py index ce1b417..50e90a1 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ from setuptools import setup, find_packages setup( name='django-ddp', - version='0.10.2', + version='0.11.0', description=__doc__, long_description=open('README.rst').read(), author='Tyson Clugg', From 7663405702f9bc9531740bd11914d8ab3d43393f Mon Sep 17 00:00:00 2001 From: Tyson Clugg Date: Tue, 11 Aug 2015 09:44:54 +1000 Subject: [PATCH 22/34] Honour AleaIdField(max_length=...) when generating IDs. --- dddp/__init__.py | 4 ++-- dddp/models.py | 36 +++++++++++++++++++++--------------- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/dddp/__init__.py b/dddp/__init__.py index db0f1c4..db33c8f 100644 --- a/dddp/__init__.py +++ b/dddp/__init__.py @@ -107,12 +107,12 @@ THREAD_LOCAL = ThreadLocal() METEOR_ID_CHARS = u'23456789ABCDEFGHJKLMNPQRSTWXYZabcdefghijkmnopqrstuvwxyz' -def meteor_random_id(name=None): +def meteor_random_id(name=None, length=17): if name is None: stream = THREAD_LOCAL.alea_random else: stream = THREAD_LOCAL.random_streams[name] - return stream.random_string(17, METEOR_ID_CHARS) + return stream.random_string(length, METEOR_ID_CHARS) def autodiscover(): diff --git a/dddp/models.py b/dddp/models.py index c5d5b78..b812a5c 100644 --- a/dddp/models.py +++ b/dddp/models.py @@ -4,6 +4,7 @@ from __future__ import absolute_import import collections from django.db import models, transaction +from django.db.models.fields import NOT_PROVIDED from django.conf import settings from django.contrib.contenttypes.fields import ( GenericRelation, GenericForeignKey, @@ -173,26 +174,31 @@ class AleaIdField(models.CharField): def __init__(self, *args, **kwargs): """Assume max_length of 17 to match Meteor implementation.""" - kwargs.update( - editable=False, - max_length=17, - ) + kwargs.setdefault('editable', False) + kwargs.setdefault('max_length', 17) super(AleaIdField, self).__init__(*args, **kwargs) - def deconstruct(self): - """Return details on how this field was defined.""" - name, path, args, kwargs = super(AleaIdField, self).deconstruct() - del kwargs['max_length'] - return name, path, args, kwargs + def get_seeded_value(self, instance): + """Generate a syncronised value.""" + # Django model._meta is public API -> pylint: disable=W0212 + return meteor_random_id( + '/collection/%s' % instance._meta, self.max_length, + ) + + def get_pk_value_on_save(self, instance): + """Generate ID if required.""" + value = super(AleaIdField, self).get_pk_value_on_save(instance) + if not value: + value = self.get_seeded_value(instance) + return value def pre_save(self, model_instance, add): """Generate ID if required.""" - _, _, _, kwargs = self.deconstruct() - val = getattr(model_instance, self.attname) - if val is None and kwargs.get('default', None) is None: - val = meteor_random_id('/collection/%s' % model_instance._meta) - setattr(model_instance, self.attname, val) - return val + value = super(AleaIdField, self).pre_save(model_instance, add) + if (not value) and self.default is NOT_PROVIDED: + value = self.get_seeded_value(model_instance) + setattr(model_instance, self.attname, value) + return value class AleaIdMixin(models.Model): From 5939d47af634ae47de34764afec881f5af4d40a0 Mon Sep 17 00:00:00 2001 From: Tyson Clugg Date: Tue, 11 Aug 2015 09:48:00 +1000 Subject: [PATCH 23/34] Normalise paths in dddp.server.views before comparison. --- dddp/server/views.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dddp/server/views.py b/dddp/server/views.py index 9084fc5..318cfb3 100644 --- a/dddp/server/views.py +++ b/dddp/server/views.py @@ -187,6 +187,8 @@ class MeteorView(View): def get(self, request, path): """Return HTML (or other related content) for Meteor.""" + if path[:1] != '/': + path = '/%s' % path if path == '/meteor_runtime_config.js': config = { 'DDP_DEFAULT_CONNECTION_URL': request.build_absolute_uri('/'), From 00b143297482143b85f819afa3b44d05d2e5da50 Mon Sep 17 00:00:00 2001 From: Tyson Clugg Date: Thu, 23 Jul 2015 11:07:20 +1000 Subject: [PATCH 24/34] Allow support for multiple meteor apps in a single django-ddp enabled project by reading METEOR_STAR_JSON as part of view init instead of app ready. --- dddp/server/apps.py | 171 ------------------------------------------ dddp/server/views.py | 175 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 168 insertions(+), 178 deletions(-) diff --git a/dddp/server/apps.py b/dddp/server/apps.py index 3065be9..4a742e9 100644 --- a/dddp/server/apps.py +++ b/dddp/server/apps.py @@ -1,37 +1,9 @@ """Django DDP Server app config.""" from __future__ import print_function, absolute_import, unicode_literals -import io import mimetypes -import os.path from django.apps import AppConfig -from django.conf import settings -from django.core.exceptions import ImproperlyConfigured -from ejson import dumps, loads -import pybars - - -STAR_JSON_SETTING_NAME = 'METEOR_STAR_JSON' - - -def read(path, default=None, encoding='utf8'): - """Read encoded contents from specified path or return default.""" - if not path: - return default - try: - with io.open(path, mode='r', encoding=encoding) as contents: - return contents.read() - except IOError: - if default is not None: - return default - raise - - -def read_json(path): - """Read JSON encoded contents from specified path.""" - with open(path, mode='r') as json_file: - return loads(json_file.read()) class ServerConfig(AppConfig): @@ -41,149 +13,6 @@ class ServerConfig(AppConfig): name = 'dddp.server' verbose_name = 'Django DDP Meteor Web Server' - manifest = None - program_json = None - program_json_path = None - runtime_config = None - - star_json = None # top level layout - server_json = None # web server layout - web_browser_json = None # web.browser (client) layout - - url_map = None - internal_map = None - server_load_map = None - template_path = None # web server HTML template path - client_map = None # web.browser (client) URL to path map - html = '\nDDP App' - def ready(self): """Configure Django DDP server app.""" mimetypes.init() # read and process /etc/mime.types - self.url_map = {} - - try: - json_path = getattr(settings, STAR_JSON_SETTING_NAME) - except AttributeError: - raise ImproperlyConfigured( - '%s setting required by dddp.server.view.' % ( - STAR_JSON_SETTING_NAME, - ), - ) - - self.star_json = read_json(json_path) - star_format = self.star_json['format'] - if star_format != 'site-archive-pre1': - raise ValueError( - 'Unknown Meteor star format: %r' % star_format, - ) - programs = { - program['name']: program - for program in self.star_json['programs'] - } - - server_json_path = os.path.join( - os.path.dirname(json_path), - os.path.dirname(programs['server']['path']), - 'program.json', - ) - self.server_json = read_json(server_json_path) - server_format = self.server_json['format'] - if server_format != 'javascript-image-pre1': - raise ValueError( - 'Unknown Meteor server format: %r' % server_format, - ) - self.server_load_map = {} - for item in self.server_json['load']: - item['path_full'] = os.path.join( - os.path.dirname(server_json_path), - item['path'], - ) - self.server_load_map[item['path']] = item - self.url_map[item['path']] = ( - item['path_full'], 'text/javascript' - ) - try: - item['source_map_full'] = os.path.join( - os.path.dirname(server_json_path), - item['sourceMap'], - ) - self.url_map[item['sourceMap']] = ( - item['source_map_full'], 'text/plain' - ) - except KeyError: - pass - self.template_path = os.path.join( - os.path.dirname(server_json_path), - self.server_load_map[ - 'packages/boilerplate-generator.js' - ][ - 'assets' - ][ - 'boilerplate_web.browser.html' - ], - ) - - web_browser_json_path = os.path.join( - os.path.dirname(json_path), - programs['web.browser']['path'], - ) - self.web_browser_json = read_json(web_browser_json_path) - web_browser_format = self.web_browser_json['format'] - if web_browser_format != 'web-program-pre1': - raise ValueError( - 'Unknown Meteor web.browser format: %r' % ( - web_browser_format, - ), - ) - self.client_map = {} - self.internal_map = {} - for item in self.web_browser_json['manifest']: - item['path_full'] = os.path.join( - os.path.dirname(web_browser_json_path), - item['path'], - ) - if item['where'] == 'client': - if '?' in item['url']: - item['url'] = item['url'].split('?', 1)[0] - self.client_map[item['url']] = item - self.url_map[item['url']] = ( - item['path_full'], - mimetypes.guess_type( - item['path_full'], - )[0] or 'application/octet-stream', - ) - elif item['where'] == 'internal': - self.internal_map[item['type']] = item - - config = { - 'css': [ - {'url': item['path']} - for item in self.web_browser_json['manifest'] - if item['type'] == 'css' and item['where'] == 'client' - ], - 'js': [ - {'url': item['path']} - for item in self.web_browser_json['manifest'] - if item['type'] == 'js' and item['where'] == 'client' - ], - 'meteorRuntimeConfig': '"%s"' % ( - dumps(self.runtime_config) - ), - 'rootUrlPathPrefix': '/app', - 'bundledJsCssPrefix': '/app/', - 'inlineScriptsAllowed': False, - 'inline': None, - 'head': read( - self.internal_map.get('head', {}).get('path_full', None), - default=u'', - ), - 'body': read( - self.internal_map.get('body', {}).get('path_full', None), - default=u'', - ), - } - tmpl_raw = read(self.template_path, encoding='utf8') - compiler = pybars.Compiler() - tmpl = compiler.compile(tmpl_raw) - self.html = '\n%s' % tmpl(config) diff --git a/dddp/server/views.py b/dddp/server/views.py index 59e4575..9084fc5 100644 --- a/dddp/server/views.py +++ b/dddp/server/views.py @@ -1,13 +1,35 @@ """Django DDP Server views.""" from __future__ import print_function, absolute_import, unicode_literals -from ejson import dumps -from django.apps import apps + +import io +import mimetypes +import os.path + +from ejson import dumps, loads from django.conf import settings from django.http import HttpResponse from django.views.generic import View +import pybars -STAR_JSON_SETTING_NAME = 'METEOR_STAR_JSON' + +def read(path, default=None, encoding='utf8'): + """Read encoded contents from specified path or return default.""" + if not path: + return default + try: + with io.open(path, mode='r', encoding=encoding) as contents: + return contents.read() + except IOError: + if default is not None: + return default + raise + + +def read_json(path): + """Read JSON encoded contents from specified path.""" + with open(path, mode='r') as json_file: + return loads(json_file.read()) class MeteorView(View): @@ -16,13 +38,152 @@ class MeteorView(View): http_method_names = ['get', 'head'] - app = None + json_path = None runtime_config = None + manifest = None + program_json = None + program_json_path = None + runtime_config = None + + star_json = None # top level layout + + url_map = None + internal_map = None + server_load_map = None + template_path = None # web server HTML template path + client_map = None # web.browser (client) URL to path map + html = '\nDDP App' + + root_url_path_prefix = '' + bundled_js_css_prefix = '/' + def __init__(self, **kwargs): """Initialisation for Django DDP server view.""" + # super(...).__init__ assigns kwargs to instance. super(MeteorView, self).__init__(**kwargs) - self.app = apps.get_app_config('server') + + self.url_map = {} + + # process `star_json` + self.star_json = read_json(self.json_path) + star_format = self.star_json['format'] + if star_format != 'site-archive-pre1': + raise ValueError( + 'Unknown Meteor star format: %r' % star_format, + ) + programs = { + program['name']: program + for program in self.star_json['programs'] + } + + # process `bundle/programs/server/program.json` from build dir + server_json_path = os.path.join( + os.path.dirname(self.json_path), + os.path.dirname(programs['server']['path']), + 'program.json', + ) + server_json = read_json(server_json_path) + server_format = server_json['format'] + if server_format != 'javascript-image-pre1': + raise ValueError( + 'Unknown Meteor server format: %r' % server_format, + ) + self.server_load_map = {} + for item in server_json['load']: + item['path_full'] = os.path.join( + os.path.dirname(server_json_path), + item['path'], + ) + self.server_load_map[item['path']] = item + self.url_map[item['path']] = ( + item['path_full'], 'text/javascript' + ) + try: + item['source_map_full'] = os.path.join( + os.path.dirname(server_json_path), + item['sourceMap'], + ) + self.url_map[item['sourceMap']] = ( + item['source_map_full'], 'text/plain' + ) + except KeyError: + pass + self.template_path = os.path.join( + os.path.dirname(server_json_path), + self.server_load_map[ + 'packages/boilerplate-generator.js' + ][ + 'assets' + ][ + 'boilerplate_web.browser.html' + ], + ) + + # process `bundle/programs/web.browser/program.json` from build dir + web_browser_json_path = os.path.join( + os.path.dirname(self.json_path), + programs['web.browser']['path'], + ) + web_browser_json = read_json(web_browser_json_path) + web_browser_format = web_browser_json['format'] + if web_browser_format != 'web-program-pre1': + raise ValueError( + 'Unknown Meteor web.browser format: %r' % ( + web_browser_format, + ), + ) + self.client_map = {} + self.internal_map = {} + for item in web_browser_json['manifest']: + item['path_full'] = os.path.join( + os.path.dirname(web_browser_json_path), + item['path'], + ) + if item['where'] == 'client': + if '?' in item['url']: + item['url'] = item['url'].split('?', 1)[0] + self.client_map[item['url']] = item + self.url_map[item['url']] = ( + item['path_full'], + mimetypes.guess_type( + item['path_full'], + )[0] or 'application/octet-stream', + ) + elif item['where'] == 'internal': + self.internal_map[item['type']] = item + + config = { + 'css': [ + {'url': item['path']} + for item in web_browser_json['manifest'] + if item['type'] == 'css' and item['where'] == 'client' + ], + 'js': [ + {'url': item['path']} + for item in web_browser_json['manifest'] + if item['type'] == 'js' and item['where'] == 'client' + ], + 'meteorRuntimeConfig': '"%s"' % ( + dumps(self.runtime_config) + ), + 'rootUrlPathPrefix': self.root_url_path_prefix, + 'bundledJsCssPrefix': self.bundled_js_css_prefix, + 'inlineScriptsAllowed': False, + 'inline': None, + 'head': read( + self.internal_map.get('head', {}).get('path_full', None), + default=u'', + ), + 'body': read( + self.internal_map.get('body', {}).get('path_full', None), + default=u'', + ), + } + tmpl_raw = read(self.template_path, encoding='utf8') + compiler = pybars.Compiler() + tmpl = compiler.compile(tmpl_raw) + self.html = '\n%s' % tmpl(config) def get(self, request, path): """Return HTML (or other related content) for Meteor.""" @@ -46,7 +207,7 @@ class MeteorView(View): content_type='text/javascript', ) try: - file_path, content_type = self.app.url_map[path] + file_path, content_type = self.url_map[path] with open(file_path, 'r') as content: return HttpResponse( content.read(), @@ -54,5 +215,5 @@ class MeteorView(View): ) except KeyError: print(path) - return HttpResponse(self.app.html) + return HttpResponse(self.html) # raise Http404 From e5784ac4caee6447258cb801ef425bf2126c0c13 Mon Sep 17 00:00:00 2001 From: Tyson Clugg Date: Tue, 11 Aug 2015 14:55:43 +1000 Subject: [PATCH 25/34] Drop dddp.server app, dddp.server.views moved to dddp.views --- dddp/server/__init__.py | 1 - dddp/server/apps.py | 18 ------------------ dddp/{server => }/views.py | 3 +++ 3 files changed, 3 insertions(+), 19 deletions(-) delete mode 100644 dddp/server/__init__.py delete mode 100644 dddp/server/apps.py rename dddp/{server => }/views.py (99%) diff --git a/dddp/server/__init__.py b/dddp/server/__init__.py deleted file mode 100644 index cea26b4..0000000 --- a/dddp/server/__init__.py +++ /dev/null @@ -1 +0,0 @@ -default_app_config = 'dddp.server.apps.ServerConfig' diff --git a/dddp/server/apps.py b/dddp/server/apps.py deleted file mode 100644 index 4a742e9..0000000 --- a/dddp/server/apps.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Django DDP Server app config.""" -from __future__ import print_function, absolute_import, unicode_literals - -import mimetypes - -from django.apps import AppConfig - - -class ServerConfig(AppConfig): - - """Django config for dddp.server app.""" - - name = 'dddp.server' - verbose_name = 'Django DDP Meteor Web Server' - - def ready(self): - """Configure Django DDP server app.""" - mimetypes.init() # read and process /etc/mime.types diff --git a/dddp/server/views.py b/dddp/views.py similarity index 99% rename from dddp/server/views.py rename to dddp/views.py index 9084fc5..76ebfac 100644 --- a/dddp/server/views.py +++ b/dddp/views.py @@ -63,6 +63,9 @@ class MeteorView(View): # super(...).__init__ assigns kwargs to instance. super(MeteorView, self).__init__(**kwargs) + # read and process /etc/mime.types + mimetypes.init() + self.url_map = {} # process `star_json` From 66ed01a393db11e16acac22b22c94b96777a2625 Mon Sep 17 00:00:00 2001 From: Tyson Clugg Date: Tue, 11 Aug 2015 14:56:39 +1000 Subject: [PATCH 26/34] Update README.rst, CHANGES.rst and bumped version number. --- CHANGES.rst | 6 +++++ README.rst | 63 ++++++++++++++++++++++++++++++++++++++++++++++------- setup.py | 2 +- 3 files changed, 62 insertions(+), 9 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 5a8edb4..2eae416 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,12 @@ Change Log ========== +0.12.0 +------ +* Get path to `star.json` from view config (defined in your urls.py) + instead of from settings. +* Dropped `dddp.server.views`, use `dddp.views` instead. + 0.11.0 ------ * Support more than 8KB of change data by splitting large payloads into diff --git a/README.rst b/README.rst index afd6ece..367cc6e 100644 --- a/README.rst +++ b/README.rst @@ -103,6 +103,19 @@ Add ddp.py to your Django application: [Book, Author, AllBooks, BooksByAuthorEmail] ) +Start the Django DDP service: + +.. code:: sh + + DJANGO_SETTINGS_MODULE=myproject.settings dddp + + +Using django-ddp as a secondary DDP connection (RAPID DEVELOPMENT) +------------------------------------------------------------------ + +Running in this manner allows rapid development through use of the hot +code push features provided by Meteor. + Connect your Meteor application to the Django DDP service: .. code:: javascript @@ -118,19 +131,53 @@ Connect your Meteor application to the Django DDP service: Django.subscribe('BooksByAuthorEmail', 'janet@evanovich.com'); } -Start the Django DDP service: - -.. code:: sh - - DJANGO_SETTINGS_MODULE=myproject.settings dddp - -In a separate terminal, start Meteor (from within your meteor -application directory): +Start Meteor (from within your meteor application directory): .. code:: sh meteor +Using django-ddp as the primary DDP connection (RECOMMENDED) +------------------------------------------------------------ + +If you'd prefer to not have two DDP connections (one to Meteor and one +to django-ddp) you can set the `DDP_DEFAULT_CONNECTION_URL` environment +variable to use the specified URL as the primary DDP connection in +Meteor. When doing this, you won't need to use `DDP.connect(...)` or +specify `{connection: Django}` on your collections. Running with +django-ddp as the primary connection is recommended, and indeed required +if you wish to use `dddp.accounts` to provide authentication using +`django.contrib.auth` to your meteor app. + +.. code:: sh + + DDP_DEFAULT_CONNECTION_URL=http://localhost:8000/ meteor + + +Serving your Meteor applications from django-ddp +------------------------------------------------ + +First, you will need to build your meteor app into a directory (examples +below assume target directory named `myapp`): + +.. code:: sh + + meteor build ../myapp + +Then, add a MeteorView to your urls.py: + +.. code:: python + + from dddp.views import MeteorView + + urlpatterns = patterns( + url('^(?P/.*)$', MeteorView.as_view( + json_path=os.path.join( + settings.PROJ_ROOT, 'myapp', 'bundle', 'star.json', + ), + ), + ) + Adding API endpoints (server method definitions) ------------------------------------------------ diff --git a/setup.py b/setup.py index 50e90a1..c84f740 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ from setuptools import setup, find_packages setup( name='django-ddp', - version='0.11.0', + version='0.12.0', description=__doc__, long_description=open('README.rst').read(), author='Tyson Clugg', From f02df498a2f2181518def35b3a4881283c154d43 Mon Sep 17 00:00:00 2001 From: Tyson Clugg Date: Wed, 12 Aug 2015 12:02:56 +1000 Subject: [PATCH 27/34] Remove console noise from logging handler init. --- dddp/logging.py | 1 - 1 file changed, 1 deletion(-) diff --git a/dddp/logging.py b/dddp/logging.py index 147873f..121e542 100644 --- a/dddp/logging.py +++ b/dddp/logging.py @@ -13,7 +13,6 @@ class DDPHandler(logging.Handler): """Logging handler that streams log events via DDP to the current client.""" def __init__(self, *args, **kwargs): - print(self.__class__, args, kwargs) self.logger = logging.getLogger('django.db.backends') self.logger.info('Test') super(DDPHandler, self).__init__(*args, **kwargs) From b32e5aa54798aa7229e9cf2038fe31e272b22436 Mon Sep 17 00:00:00 2001 From: Tyson Clugg Date: Thu, 13 Aug 2015 09:43:38 +1000 Subject: [PATCH 28/34] Add missing imports --- dddp/views.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dddp/views.py b/dddp/views.py index a631b95..76ebfac 100644 --- a/dddp/views.py +++ b/dddp/views.py @@ -1,7 +1,9 @@ """Django DDP Server views.""" from __future__ import print_function, absolute_import, unicode_literals +import io import mimetypes +import os.path from ejson import dumps, loads from django.conf import settings From 489175d9a97f8d1538835e6a36b8a91e45a48a5f Mon Sep 17 00:00:00 2001 From: Tyson Clugg Date: Thu, 13 Aug 2015 09:44:45 +1000 Subject: [PATCH 29/34] Added helper for migrations involving AleaIdField to populate fields from ObjectMapping table. --- dddp/migrations/__init__.py | 48 +++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/dddp/migrations/__init__.py b/dddp/migrations/__init__.py index 29844c7..51b84bc 100644 --- a/dddp/migrations/__init__.py +++ b/dddp/migrations/__init__.py @@ -1,4 +1,7 @@ +import functools +from django.db import migrations from django.db.migrations.operations.base import Operation +from dddp.models import AleaIdField, get_meteor_id class TruncateOperation(Operation): @@ -35,3 +38,48 @@ class TruncateOperation(Operation): def describe(self): """Describe what the operation does in console output.""" return "Truncate tables" + + +def set_default_forwards(app_name, operation, apps, schema_editor): + """Set default value for AleaIdField.""" + model = apps.get_model(app_name, operation.model_name) + for obj_pk in model.objects.values_list('pk', flat=True): + model.objects.filter(pk=obj_pk).update(**{ + operation.name: get_meteor_id(model, obj_pk), + }) + + +def set_default_reverse(app_name, operation, apps, schema_editor): + """Unset default value for AleaIdField.""" + model = apps.get_model(app_name, operation.model_name) + for obj_pk in model.objects.values_list('pk', flat=True): + get_meteor_id(model, obj_pk) + + +class DefaultAleaIdOperations(object): + + def __init__(self, app_name): + self.app_name = app_name + + def __add__(self, operations): + default_operations = [] + for operation in operations: + if not isinstance(operation, migrations.AlterField): + continue + if not isinstance(operation.field, AleaIdField): + continue + if operation.name != 'aid': + continue + if operation.field.null: + continue + default_operations.append( + migrations.RunPython( + code=functools.partial( + set_default_forwards, self.app_name, operation, + ), + reverse_code=functools.partial( + set_default_reverse, self.app_name, operation, + ), + ) + ) + return default_operations + operations From 04ef1ab8a025751e0f6ab79b94f002ce5ad64753 Mon Sep 17 00:00:00 2001 From: Tyson Clugg Date: Thu, 13 Aug 2015 09:46:31 +1000 Subject: [PATCH 30/34] More aggressive searching for local AleaIdField(unique=True) when translating between meteor/object identifiers. --- dddp/api.py | 22 +++++++++ dddp/models.py | 121 +++++++++++++++++++++++++++++++++---------------- 2 files changed, 105 insertions(+), 38 deletions(-) diff --git a/dddp/api.py b/dddp/api.py index 9153c43..0cabe85 100644 --- a/dddp/api.py +++ b/dddp/api.py @@ -409,6 +409,15 @@ class Collection(APIMixin): ) elif isinstance(field, django.contrib.postgres.fields.ArrayField): fields[field.name] = field.to_python(fields.pop(field.name)) + elif ( + isinstance(field, AleaIdField) + ) and ( + not field.null + ) and ( + field.name == 'aid' + ): + # This will be sent as the `id`, don't send it in `fields`. + fields.pop(field.name) for field in meta.local_many_to_many: fields['%s_ids' % field.name] = get_meteor_ids( field.rel.to, fields.pop(field.name), @@ -616,6 +625,19 @@ class DDP(APIMixin): ) if isinstance(col.model._meta.pk, AleaIdField): meteor_ids = None + elif len([ + field + for field + in col.model._meta.local_fields + if ( + isinstance(field, AleaIdField) + ) and ( + field.unique + ) and ( + not field.null + ) + ]) == 1: + meteor_ids = None else: meteor_ids = get_meteor_ids( qs.model, qs.values_list('pk', flat=True), diff --git a/dddp/models.py b/dddp/models.py index b812a5c..f181842 100644 --- a/dddp/models.py +++ b/dddp/models.py @@ -2,6 +2,7 @@ from __future__ import absolute_import import collections +import os from django.db import models, transaction from django.db.models.fields import NOT_PROVIDED @@ -16,7 +17,6 @@ import ejson from dddp import meteor_random_id -@transaction.atomic def get_meteor_id(obj_or_model, obj_pk=None): """Return an Alea ID for the given object.""" if obj_or_model is None: @@ -33,17 +33,27 @@ def get_meteor_id(obj_or_model, obj_pk=None): # obj_or_model is an instance, not a model. if isinstance(meta.pk, AleaIdField): return obj_or_model.pk - alea_unique_fields = [ - field - for field in meta.local_fields - if isinstance(field, AleaIdField) and field.unique - ] - if len(alea_unique_fields) == 1: - # found an AleaIdField with unique=True, assume it's got the value. - return getattr(obj_or_model, alea_unique_fields[0].attname) if obj_pk is None: # fall back to primary key, but coerce as string type for lookup. obj_pk = str(obj_or_model.pk) + alea_unique_fields = [ + field + for field in meta.local_fields + if isinstance(field, AleaIdField) and field.unique + ] + if len(alea_unique_fields) == 1: + # found an AleaIdField with unique=True, assume it's got the value. + aid = alea_unique_fields[0].attname + if isinstance(obj_or_model, model): + val = getattr(obj_or_model, aid) + elif obj_pk is None: + val = None + else: + val = model.objects.values_list(aid, flat=True).get( + pk=obj_pk, + ) + if val: + return val if obj_pk is None: # bail out if args are (model, pk) but pk is None. @@ -67,38 +77,44 @@ def get_meteor_id(obj_or_model, obj_pk=None): get_meteor_id.short_description = 'DDP ID' # nice title for admin list_display -@transaction.atomic def get_meteor_ids(model, object_ids): """Return Alea ID mapping for all given ids of specified model.""" - content_type = ContentType.objects.get_for_model(model) # Django model._meta is now public API -> pylint: disable=W0212 meta = model._meta - if isinstance(meta.pk, AleaIdField): - # primary_key is an AleaIdField, use it. - return collections.OrderedDict( - (obj_pk, obj_pk) for obj_pk in object_ids - ) result = collections.OrderedDict( (str(obj_pk), None) for obj_pk in object_ids ) - for obj_pk, meteor_id in ObjectMapping.objects.filter( + if isinstance(meta.pk, AleaIdField): + # primary_key is an AleaIdField, use it. + return collections.OrderedDict( + (obj_pk, obj_pk) for obj_pk in object_ids + ) + alea_unique_fields = [ + field + for field in meta.local_fields + if isinstance(field, AleaIdField) and field.unique and not field.null + ] + if len(alea_unique_fields) == 1: + aid = alea_unique_fields[0].name + query = model.objects.filter( + pk__in=object_ids, + ).values_list('pk', aid) + else: + content_type = ContentType.objects.get_for_model(model) + query = ObjectMapping.objects.filter( content_type=content_type, object_id__in=list(result) - ).values_list('object_id', 'meteor_id'): + ).values_list('object_id', 'meteor_id') + for obj_pk, meteor_id in query: result[obj_pk] = meteor_id for obj_pk, meteor_id in result.items(): if meteor_id is None: - result[obj_pk] = ObjectMapping.objects.create( - content_type=content_type, - object_id=obj_pk, - meteor_id=meteor_random_id('/collection/%s' % meta), - ).meteor_id + result[obj_pk] = get_meteor_id(model, obj_pk) return result -@transaction.atomic def get_object_id(model, meteor_id): """Return an object ID for the given meteor_id.""" if meteor_id is None: @@ -121,11 +137,13 @@ def get_object_id(model, meteor_id): ] if len(alea_unique_fields) == 1: # found an AleaIdField with unique=True, assume it's got the value. - return model.objects.values_list( + val = model.objects.values_list( 'pk', flat=True, ).get(**{ alea_unique_fields[0].attname: meteor_id, }) + if val: + return val content_type = ContentType.objects.get_for_model(model) return ObjectMapping.objects.filter( @@ -134,27 +152,39 @@ def get_object_id(model, meteor_id): ).values_list('object_id', flat=True).get() -@transaction.atomic def get_object_ids(model, meteor_ids): """Return all object IDs for the given meteor_ids.""" if model is ObjectMapping: # this doesn't make sense - raise TypeError raise TypeError("Can't map ObjectMapping instances through self.") - content_type = ContentType.objects.get_for_model(model) + # Django model._meta is now public API -> pylint: disable=W0212 + meta = model._meta + alea_unique_fields = [ + field + for field in meta.local_fields + if isinstance(field, AleaIdField) and field.unique and not field.null + ] result = collections.OrderedDict( (str(meteor_id), None) for meteor_id in meteor_ids ) - for meteor_id, object_id in ObjectMapping.objects.filter( - content_type=content_type, - meteor_id__in=meteor_ids, - ).values_list('meteor_id', 'object_id'): + if len(alea_unique_fields) == 1: + aid = alea_unique_fields[0].name + query = model.objects.filter(**{ + '%s__in' % aid: meteor_ids, + }).values_list(aid, 'pk') + else: + content_type = ContentType.objects.get_for_model(model) + query = ObjectMapping.objects.filter( + content_type=content_type, + meteor_id__in=meteor_ids, + ).values_list('meteor_id', 'object_id') + for meteor_id, object_id in query: result[meteor_id] = object_id return result -@transaction.atomic def get_object(model, meteor_id, *args, **kwargs): """Return an object for the given meteor_id.""" # Django model._meta is now public API -> pylint: disable=W0212 @@ -163,6 +193,16 @@ def get_object(model, meteor_id, *args, **kwargs): # meteor_id is the primary key return model.objects.filter(*args, **kwargs).get(pk=meteor_id) + alea_unique_fields = [ + field + for field in meta.local_fields + if isinstance(field, AleaIdField) and field.unique and not field.null + ] + if len(alea_unique_fields) == 1: + return model.objects.filter(*args, **kwargs).get(**{ + alea_unique_fields[0].name: meteor_id, + }) + return model.objects.filter(*args, **kwargs).get( pk=get_object_id(model, meteor_id), ) @@ -174,7 +214,7 @@ class AleaIdField(models.CharField): def __init__(self, *args, **kwargs): """Assume max_length of 17 to match Meteor implementation.""" - kwargs.setdefault('editable', False) + kwargs.setdefault('verbose_name', 'Alea ID') kwargs.setdefault('max_length', 17) super(AleaIdField, self).__init__(*args, **kwargs) @@ -195,19 +235,24 @@ class AleaIdField(models.CharField): def pre_save(self, model_instance, add): """Generate ID if required.""" value = super(AleaIdField, self).pre_save(model_instance, add) - if (not value) and self.default is NOT_PROVIDED: + if (not value) and self.default in (meteor_random_id, NOT_PROVIDED): value = self.get_seeded_value(model_instance) setattr(model_instance, self.attname, value) return value +# Please don't hate me... +AID_KWARGS = {} + +if os.environ.get('AID_MIGRATE_STEP', '') == '1': + AID_KWARGS['null'] = True # ...please? + + class AleaIdMixin(models.Model): - """Django model mixin that provides AleaIdField field (as _id).""" + """Django model mixin that provides AleaIdField field (as aid).""" - id = AleaIdField( - primary_key=True, - ) + aid = AleaIdField(unique=True, editable=True, **AID_KWARGS) class Meta(object): From 7c54f4c324c8c664ceed6219ebd5cb5acc7e526c Mon Sep 17 00:00:00 2001 From: Tyson Clugg Date: Thu, 13 Aug 2015 09:47:16 +1000 Subject: [PATCH 31/34] Added migration for changed field defaults on Connection.connection_id. --- dddp/migrations/0009_auto_20150812_0856.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 dddp/migrations/0009_auto_20150812_0856.py diff --git a/dddp/migrations/0009_auto_20150812_0856.py b/dddp/migrations/0009_auto_20150812_0856.py new file mode 100644 index 0000000..3247443 --- /dev/null +++ b/dddp/migrations/0009_auto_20150812_0856.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +import dddp.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dddp', '0008_remove_subscription_publication_class'), + ] + + operations = [ + migrations.AlterField( + model_name='connection', + name='connection_id', + field=dddp.models.AleaIdField(max_length=17, verbose_name=b'Alea ID'), + ), + ] From b1017c78e5089da4b504b4f8f6c221a1ee05e676 Mon Sep 17 00:00:00 2001 From: Tyson Clugg Date: Thu, 13 Aug 2015 10:00:54 +1000 Subject: [PATCH 32/34] Update CHANGES.rst, bump version number. --- CHANGES.rst | 9 +++++++++ setup.py | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 2eae416..8a63954 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,15 @@ Change Log ========== +0.12.1 +------ +* Add `AleaIdMixin` which provides `aid = AleaIdField(unique=True)` to + models. +* Use `AleaIdField(unique=True)` wherever possible when translating + between Meteor style identifiers and Django primary keys, reducing + round trips to the database and hence drastically improving + performance when such fields are available. + 0.12.0 ------ * Get path to `star.json` from view config (defined in your urls.py) diff --git a/setup.py b/setup.py index c84f740..1126824 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ from setuptools import setup, find_packages setup( name='django-ddp', - version='0.12.0', + version='0.12.1', description=__doc__, long_description=open('README.rst').read(), author='Tyson Clugg', From 282fe36b7eae36e82a009dd0237936da07f44a9f Mon Sep 17 00:00:00 2001 From: Tyson Clugg Date: Thu, 27 Aug 2015 14:25:26 +1000 Subject: [PATCH 33/34] Set blank=True on AleaIdField, allowing adding items without inventing IDs yourself. --- dddp/models.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/dddp/models.py b/dddp/models.py index f181842..89d36b6 100644 --- a/dddp/models.py +++ b/dddp/models.py @@ -214,10 +214,17 @@ class AleaIdField(models.CharField): def __init__(self, *args, **kwargs): """Assume max_length of 17 to match Meteor implementation.""" + kwargs['blank'] = True kwargs.setdefault('verbose_name', 'Alea ID') kwargs.setdefault('max_length', 17) super(AleaIdField, self).__init__(*args, **kwargs) + def deconstruct(self): + """Return arguments to pass to __init__() to re-create this field.""" + name, path, args, kwargs = super(AleaIdField, self).deconstruct() + del kwargs['blank'] + return name, path, args, kwargs + def get_seeded_value(self, instance): """Generate a syncronised value.""" # Django model._meta is public API -> pylint: disable=W0212 From f68c454c7c78ab36afffd4cb65296eed718cb448 Mon Sep 17 00:00:00 2001 From: Tyson Clugg Date: Thu, 27 Aug 2015 14:27:33 +1000 Subject: [PATCH 34/34] Update CHANGES.rst, bump version number. --- CHANGES.rst | 5 +++++ setup.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 8a63954..2b98e96 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,11 @@ Change Log ========== +0.12.2 +------ +* Set blank=True on AleaIdField, allowing adding items without inventing + IDs yourself. + 0.12.1 ------ * Add `AleaIdMixin` which provides `aid = AleaIdField(unique=True)` to diff --git a/setup.py b/setup.py index 1126824..a269565 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ from setuptools import setup, find_packages setup( name='django-ddp', - version='0.12.1', + version='0.12.2', description=__doc__, long_description=open('README.rst').read(), author='Tyson Clugg',