mirror of
https://github.com/jazzband/django-ddp.git
synced 2026-03-16 22:40:24 +00:00
Merge branch 'release/0.14.0'
This commit is contained in:
commit
dba30fd3ab
13 changed files with 182 additions and 109 deletions
14
CHANGES.rst
14
CHANGES.rst
|
|
@ -1,6 +1,20 @@
|
|||
Change Log
|
||||
==========
|
||||
|
||||
0.14.0
|
||||
------
|
||||
* Correctly handle serving app content from the root path of a domain.
|
||||
* Account security tokens are now calculated for each minute allowing for finer grained token expiry.
|
||||
* Fix bug in error handling where invalid arguments were being passed to `logging.error()`.
|
||||
* Change setting names (and implied meanings):
|
||||
- DDP_PASSWORD_RESET_DAYS_VALID becomes
|
||||
DDP_PASSWORD_RESET_MINUTES_VALID.
|
||||
- DDP_LOGIN_RESUME_DAYS_VALID becomes DDP_LOGIN_RESUME_MINUTES_VALID.
|
||||
* Include `created` field in logs collection.
|
||||
* Stop depending on `Referrer` HTTP header which is optional.
|
||||
* Honour `--verbosity` in `dddp` command, now showing API endpoints in more verbose modes.
|
||||
* Updated `dddp.test` to Meteor 1.2 and also showing example of URL config to serve Meteor files from Python.
|
||||
|
||||
0.13.0
|
||||
------
|
||||
* Abstract DDPLauncher out from dddp.main.serve to permit use from other contexts.
|
||||
|
|
|
|||
|
|
@ -45,36 +45,36 @@ class HashPurpose(object):
|
|||
RESUME_LOGIN = 'resume_login'
|
||||
|
||||
|
||||
HASH_DAYS_VALID = {
|
||||
HASH_MINUTES_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',
|
||||
settings, 'DDP_PASSWORD_RESET_MINUTES_VALID', '1440', # 24 hours
|
||||
)
|
||||
),
|
||||
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',
|
||||
settings, 'DDP_LOGIN_RESUME_MINUTES_VALID', '240', # 4 hours
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def iter_auth_hashes(user, purpose, days_valid):
|
||||
def iter_auth_hashes(user, purpose, minutes_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.
|
||||
The hash expires at midnight on the minute of now + minutes_valid, such that
|
||||
when minutes_valid=1 you get *at least* 1 minute to use the token.
|
||||
"""
|
||||
today = timezone.now().date()
|
||||
for day in range(days_valid + 1):
|
||||
now = timezone.now().replace(microsecond=0, second=0)
|
||||
for minute in range(minutes_valid + 1):
|
||||
yield hashlib.sha1(
|
||||
'%s:%s:%s:%s:%s' % (
|
||||
today - datetime.timedelta(days=day),
|
||||
now - datetime.timedelta(minutes=minute),
|
||||
user.password,
|
||||
purpose,
|
||||
user.pk,
|
||||
|
|
@ -85,17 +85,17 @@ def iter_auth_hashes(user, purpose, days_valid):
|
|||
|
||||
def get_auth_hash(user, purpose):
|
||||
"""Generate a user hash for a particular purpose."""
|
||||
return iter_auth_hashes(user, purpose, days_valid=1).next()
|
||||
return iter_auth_hashes(user, purpose, minutes_valid=1).next()
|
||||
|
||||
|
||||
def calc_expiry_time(days_valid):
|
||||
def calc_expiry_time(minutes_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)
|
||||
timezone.now() + datetime.timedelta(minutes=minutes_valid + 1)
|
||||
).replace(second=0, microsecond=0)
|
||||
|
||||
|
||||
def get_user_token(user, purpose, days_valid):
|
||||
def get_user_token(user, purpose, minutes_valid):
|
||||
"""Return login token info for given user."""
|
||||
token = ''.join(
|
||||
dumps([
|
||||
|
|
@ -106,7 +106,7 @@ def get_user_token(user, purpose, days_valid):
|
|||
return {
|
||||
'id': get_meteor_id(user),
|
||||
'token': token,
|
||||
'tokenExpires': calc_expiry_time(days_valid),
|
||||
'tokenExpires': calc_expiry_time(minutes_valid),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -309,7 +309,7 @@ class Auth(APIMixin):
|
|||
raise MeteorError(403, 'Authentication failed.')
|
||||
|
||||
@classmethod
|
||||
def validated_user(cls, token, purpose, days_valid):
|
||||
def validated_user(cls, token, purpose, minutes_valid):
|
||||
"""Resolve and validate auth token, returns user object."""
|
||||
try:
|
||||
username, auth_hash = loads(token.decode('base64'))
|
||||
|
|
@ -323,7 +323,7 @@ class Auth(APIMixin):
|
|||
user.backend = 'django.contrib.auth.backends.ModelBackend'
|
||||
except cls.user_model.DoesNotExist:
|
||||
cls.auth_failed(username=username, token=token)
|
||||
if auth_hash not in iter_auth_hashes(user, purpose, days_valid):
|
||||
if auth_hash not in iter_auth_hashes(user, purpose, minutes_valid):
|
||||
cls.auth_failed(username=username, token=token)
|
||||
return user
|
||||
|
||||
|
|
@ -415,7 +415,7 @@ class Auth(APIMixin):
|
|||
self.do_login(user)
|
||||
return get_user_token(
|
||||
user=user, purpose=HashPurpose.RESUME_LOGIN,
|
||||
days_valid=HASH_DAYS_VALID[HashPurpose.RESUME_LOGIN],
|
||||
minutes_valid=HASH_MINUTES_VALID[HashPurpose.RESUME_LOGIN],
|
||||
)
|
||||
|
||||
def do_login(self, user):
|
||||
|
|
@ -472,7 +472,7 @@ class Auth(APIMixin):
|
|||
self.do_login(user)
|
||||
return get_user_token(
|
||||
user=user, purpose=HashPurpose.RESUME_LOGIN,
|
||||
days_valid=HASH_DAYS_VALID[HashPurpose.RESUME_LOGIN],
|
||||
minutes_valid=HASH_MINUTES_VALID[HashPurpose.RESUME_LOGIN],
|
||||
)
|
||||
|
||||
# Call to `authenticate` was unable to verify the username and password.
|
||||
|
|
@ -495,13 +495,13 @@ class Auth(APIMixin):
|
|||
# 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],
|
||||
minutes_valid=HASH_MINUTES_VALID[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],
|
||||
minutes_valid=HASH_MINUTES_VALID[HashPurpose.RESUME_LOGIN],
|
||||
)
|
||||
|
||||
@api_endpoint('changePassword')
|
||||
|
|
@ -538,10 +538,10 @@ class Auth(APIMixin):
|
|||
except self.user_model.DoesNotExist:
|
||||
self.auth_failed()
|
||||
|
||||
days_valid = HASH_DAYS_VALID[HashPurpose.PASSWORD_RESET]
|
||||
minutes_valid = HASH_MINUTES_VALID[HashPurpose.PASSWORD_RESET]
|
||||
token = get_user_token(
|
||||
user=user, purpose=HashPurpose.PASSWORD_RESET,
|
||||
days_valid=days_valid,
|
||||
minutes_valid=minutes_valid,
|
||||
)
|
||||
|
||||
forgot_password.send(
|
||||
|
|
@ -549,7 +549,7 @@ class Auth(APIMixin):
|
|||
user=user,
|
||||
token=token,
|
||||
request=this.request,
|
||||
expiry_date=calc_expiry_time(days_valid),
|
||||
expiry_date=calc_expiry_time(minutes_valid),
|
||||
)
|
||||
|
||||
@api_endpoint('resetPassword')
|
||||
|
|
@ -557,7 +557,7 @@ class Auth(APIMixin):
|
|||
"""Reset password using a token received in email then logs user in."""
|
||||
user = self.validated_user(
|
||||
token, purpose=HashPurpose.PASSWORD_RESET,
|
||||
days_valid=HASH_DAYS_VALID[HashPurpose.PASSWORD_RESET],
|
||||
minutes_valid=HASH_MINUTES_VALID[HashPurpose.PASSWORD_RESET],
|
||||
)
|
||||
user.set_password(new_password)
|
||||
user.save()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"""Django DDP logging helpers."""
|
||||
from __future__ import absolute_import, print_function
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
from dddp import THREAD_LOCAL as this, meteor_random_id, ADDED
|
||||
|
|
@ -18,6 +19,7 @@ class DDPHandler(logging.Handler):
|
|||
'collection': 'logs',
|
||||
'id': meteor_random_id('/collection/logs'),
|
||||
'fields': {
|
||||
'created': datetime.datetime.fromtimestamp(record.created),
|
||||
'name': record.name,
|
||||
'levelno': record.levelno,
|
||||
'levelname': record.levelname,
|
||||
|
|
|
|||
43
dddp/main.py
43
dddp/main.py
|
|
@ -18,22 +18,29 @@ import geventwebsocket.handler
|
|||
Addr = collections.namedtuple('Addr', ['host', 'port'])
|
||||
|
||||
|
||||
def common_headers(environ, **kwargs):
|
||||
"""Return list of common headers for SockJS HTTP responses."""
|
||||
return [
|
||||
# DDP doesn't use cookies or HTTP level auth, so CSRF attacks are
|
||||
# ineffective. We can safely allow cross-domain DDP connections and
|
||||
# developers may choose to allow anonymous access to publications and
|
||||
# RPC methods as they see fit. More to the point, developers should
|
||||
# restrict access to publications and RPC endpoints as appropriate.
|
||||
('Access-Control-Allow-Origin', '*'),
|
||||
('Access-Control-Allow-Credentials', 'false'),
|
||||
('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0'),
|
||||
('Connection', 'keep-alive'),
|
||||
('Vary', 'Origin'),
|
||||
]
|
||||
|
||||
|
||||
def ddpp_sockjs_xhr(environ, start_response):
|
||||
"""Dummy method that doesn't handle XHR requests."""
|
||||
start_response(
|
||||
'404 Not found',
|
||||
[
|
||||
('Content-Type', 'text/plain; charset=UTF-8'),
|
||||
(
|
||||
'Access-Control-Allow-Origin',
|
||||
'/'.join(environ['HTTP_REFERER'].split('/')[:3]),
|
||||
),
|
||||
('Access-Control-Allow-Credentials', 'true'),
|
||||
# ('access-control-allow-credentials', 'true'),
|
||||
('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0'),
|
||||
('Connection', 'keep-alive'),
|
||||
('Vary', 'Origin'),
|
||||
],
|
||||
] + common_headers(environ),
|
||||
)
|
||||
yield 'No.'
|
||||
|
||||
|
|
@ -47,16 +54,7 @@ def ddpp_sockjs_info(environ, start_response):
|
|||
'200 OK',
|
||||
[
|
||||
('Content-Type', 'application/json; charset=UTF-8'),
|
||||
(
|
||||
'Access-Control-Allow-Origin',
|
||||
'/'.join(environ['HTTP_REFERER'].split('/')[:3]),
|
||||
),
|
||||
('Access-Control-Allow-Credentials', 'true'),
|
||||
# ('access-control-allow-credentials', 'true'),
|
||||
('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0'),
|
||||
('Connection', 'keep-alive'),
|
||||
('Vary', 'Origin'),
|
||||
],
|
||||
] + common_headers(environ),
|
||||
)
|
||||
yield ejson.dumps(collections.OrderedDict([
|
||||
('websocket', True),
|
||||
|
|
@ -193,8 +191,9 @@ class DDPLauncher(object):
|
|||
self.logger.debug('PostgresGreenlet start')
|
||||
self._stop_event.clear()
|
||||
self.print('=> Discovering DDP endpoints...')
|
||||
for api_path in sorted(self.api.api_path_map()):
|
||||
self.logger.debug(' %s', api_path)
|
||||
if self.verbosity > 1:
|
||||
for api_path in sorted(self.api.api_path_map()):
|
||||
print(' %s' % api_path)
|
||||
|
||||
# start greenlets
|
||||
self.pgworker.start()
|
||||
|
|
|
|||
|
|
@ -6,3 +6,7 @@ notices-for-0.9.0
|
|||
notices-for-0.9.1
|
||||
0.9.4-platform-file
|
||||
notices-for-facebook-graph-api-2
|
||||
1.2.0-standard-minifiers-package
|
||||
1.2.0-meteor-platform-split
|
||||
1.2.0-cordova-changes
|
||||
1.2.0-breaking-changes
|
||||
|
|
|
|||
|
|
@ -4,6 +4,17 @@
|
|||
# 'meteor add' and 'meteor remove' will edit this file for you,
|
||||
# but you can also edit it by hand.
|
||||
|
||||
meteor-platform
|
||||
autopublish
|
||||
insecure
|
||||
standard-minifiers
|
||||
meteor-base
|
||||
mobile-experience
|
||||
blaze-html-templates
|
||||
session
|
||||
jquery
|
||||
tracker
|
||||
logging
|
||||
reload
|
||||
random
|
||||
ejson
|
||||
spacebars
|
||||
check
|
||||
mongo
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
METEOR@1.1.0.2
|
||||
METEOR@1.2
|
||||
|
|
|
|||
|
|
@ -1,48 +1,63 @@
|
|||
autopublish@1.0.3
|
||||
autoupdate@1.2.1
|
||||
base64@1.0.3
|
||||
binary-heap@1.0.3
|
||||
blaze@2.1.2
|
||||
blaze-tools@1.0.3
|
||||
boilerplate-generator@1.0.3
|
||||
callback-hook@1.0.3
|
||||
check@1.0.5
|
||||
ddp@1.1.0
|
||||
deps@1.0.7
|
||||
ejson@1.0.6
|
||||
fastclick@1.0.3
|
||||
geojson-utils@1.0.3
|
||||
html-tools@1.0.4
|
||||
htmljs@1.0.4
|
||||
http@1.1.0
|
||||
id-map@1.0.3
|
||||
insecure@1.0.3
|
||||
jquery@1.11.3_2
|
||||
json@1.0.3
|
||||
launch-screen@1.0.2
|
||||
livedata@1.0.13
|
||||
logging@1.0.7
|
||||
meteor@1.1.6
|
||||
meteor-platform@1.2.2
|
||||
minifiers@1.1.5
|
||||
minimongo@1.0.8
|
||||
mobile-status-bar@1.0.3
|
||||
mongo@1.1.0
|
||||
observe-sequence@1.0.6
|
||||
ordered-dict@1.0.3
|
||||
random@1.0.3
|
||||
reactive-dict@1.1.0
|
||||
reactive-var@1.0.5
|
||||
reload@1.1.3
|
||||
retry@1.0.3
|
||||
routepolicy@1.0.5
|
||||
session@1.1.0
|
||||
spacebars@1.0.6
|
||||
spacebars-compiler@1.0.6
|
||||
templating@1.1.1
|
||||
tracker@1.0.7
|
||||
ui@1.0.6
|
||||
underscore@1.0.3
|
||||
url@1.0.4
|
||||
webapp@1.2.0
|
||||
webapp-hashing@1.0.3
|
||||
autoupdate@1.2.3
|
||||
babel-compiler@5.8.24
|
||||
babel-runtime@0.1.4
|
||||
base64@1.0.4
|
||||
binary-heap@1.0.4
|
||||
blaze@2.1.3
|
||||
blaze-html-templates@1.0.1
|
||||
blaze-tools@1.0.4
|
||||
boilerplate-generator@1.0.4
|
||||
caching-compiler@1.0.0
|
||||
caching-html-compiler@1.0.1
|
||||
callback-hook@1.0.4
|
||||
check@1.0.6
|
||||
ddp@1.2.1
|
||||
ddp-client@1.2.1
|
||||
ddp-common@1.2.1
|
||||
ddp-server@1.2.1
|
||||
deps@1.0.8
|
||||
diff-sequence@1.0.1
|
||||
ecmascript@0.1.3
|
||||
ecmascript-collections@0.1.6
|
||||
ejson@1.0.7
|
||||
fastclick@1.0.7
|
||||
geojson-utils@1.0.4
|
||||
hot-code-push@1.0.0
|
||||
html-tools@1.0.5
|
||||
htmljs@1.0.5
|
||||
http@1.1.1
|
||||
id-map@1.0.4
|
||||
jquery@1.11.4
|
||||
launch-screen@1.0.3
|
||||
livedata@1.0.14
|
||||
logging@1.0.8
|
||||
meteor@1.1.7
|
||||
meteor-base@1.0.1
|
||||
minifiers@1.1.6
|
||||
minimongo@1.0.9
|
||||
mobile-experience@1.0.1
|
||||
mobile-status-bar@1.0.6
|
||||
mongo@1.1.1
|
||||
mongo-id@1.0.1
|
||||
npm-mongo@1.4.39_1
|
||||
observe-sequence@1.0.7
|
||||
ordered-dict@1.0.4
|
||||
promise@0.4.8
|
||||
random@1.0.4
|
||||
reactive-dict@1.1.1
|
||||
reactive-var@1.0.6
|
||||
reload@1.1.4
|
||||
retry@1.0.4
|
||||
routepolicy@1.0.6
|
||||
session@1.1.1
|
||||
spacebars@1.0.7
|
||||
spacebars-compiler@1.0.7
|
||||
standard-minifiers@1.0.0
|
||||
templating@1.1.2
|
||||
templating-tools@1.0.0
|
||||
tracker@1.0.8
|
||||
ui@1.0.7
|
||||
underscore@1.0.4
|
||||
url@1.0.5
|
||||
webapp@1.2.2
|
||||
webapp-hashing@1.0.4
|
||||
|
|
|
|||
|
|
@ -1,8 +1,14 @@
|
|||
if (Meteor.isClient) {
|
||||
// This code only runs on the client
|
||||
Django = DDP.connect('http://'+window.location.hostname+':8000/');
|
||||
Tasks = new Mongo.Collection("django_todos.task", {"connection": Django});
|
||||
Django.subscribe('Tasks');
|
||||
options = {};
|
||||
if (__meteor_runtime_config__.hasOwnProperty('DDP_DEFAULT_CONNECTION_URL')) {
|
||||
Django = Meteor;
|
||||
} else {
|
||||
Django = DDP.connect(window.location.protocol + '//'+window.location.hostname+':8000/');
|
||||
options.connection = Django;
|
||||
}
|
||||
Tasks = new Mongo.Collection("django_todos.task", options);
|
||||
TaskSub = Django.subscribe('Tasks');
|
||||
Template.body.helpers({
|
||||
tasks: function () {
|
||||
return Tasks.find({});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,18 @@
|
|||
"""Django DDP test project - URL configuraiton."""
|
||||
import os.path
|
||||
|
||||
from django.conf import settings
|
||||
from django.conf.urls import patterns, include, url
|
||||
from django.contrib import admin
|
||||
from dddp.views import MeteorView
|
||||
import dddp.test
|
||||
|
||||
app = MeteorView.as_view(
|
||||
json_path=os.path.join(
|
||||
os.path.dirname(dddp.test.__file__),
|
||||
'build', 'bundle', 'star.json'
|
||||
),
|
||||
)
|
||||
|
||||
urlpatterns = patterns('',
|
||||
# Examples:
|
||||
|
|
@ -16,4 +28,6 @@ urlpatterns = patterns('',
|
|||
'show_indexes': False,
|
||||
},
|
||||
),
|
||||
# all remaining URLs routed to Meteor app.
|
||||
url(r'^(?P<path>.*)$', app),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
"""Django DDP Server views."""
|
||||
from __future__ import print_function, absolute_import, unicode_literals
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import io
|
||||
import logging
|
||||
import mimetypes
|
||||
import os.path
|
||||
|
||||
|
|
@ -36,6 +37,7 @@ class MeteorView(View):
|
|||
|
||||
"""Django DDP Meteor server view."""
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
http_method_names = ['get', 'head']
|
||||
|
||||
json_path = None
|
||||
|
|
@ -44,7 +46,6 @@ class MeteorView(View):
|
|||
manifest = None
|
||||
program_json = None
|
||||
program_json_path = None
|
||||
runtime_config = None
|
||||
|
||||
star_json = None # top level layout
|
||||
|
||||
|
|
@ -60,6 +61,7 @@ class MeteorView(View):
|
|||
|
||||
def __init__(self, **kwargs):
|
||||
"""Initialisation for Django DDP server view."""
|
||||
self.runtime_config = {}
|
||||
# super(...).__init__ assigns kwargs to instance.
|
||||
super(MeteorView, self).__init__(**kwargs)
|
||||
|
||||
|
|
@ -146,6 +148,8 @@ class MeteorView(View):
|
|||
if item['where'] == 'client':
|
||||
if '?' in item['url']:
|
||||
item['url'] = item['url'].split('?', 1)[0]
|
||||
if item['url'].startswith('/'):
|
||||
item['url'] = item['url'][1:]
|
||||
self.client_map[item['url']] = item
|
||||
self.url_map[item['url']] = (
|
||||
item['path_full'],
|
||||
|
|
@ -190,7 +194,13 @@ class MeteorView(View):
|
|||
|
||||
def get(self, request, path):
|
||||
"""Return HTML (or other related content) for Meteor."""
|
||||
if path == '/meteor_runtime_config.js':
|
||||
self.logger.debug(
|
||||
'[%s:%s] %s %s %s',
|
||||
request.META['REMOTE_ADDR'], request.META['REMOTE_PORT'],
|
||||
request.method, request.path,
|
||||
request.META['SERVER_PROTOCOL'],
|
||||
)
|
||||
if path == 'meteor_runtime_config.js':
|
||||
config = {
|
||||
'DDP_DEFAULT_CONNECTION_URL': request.build_absolute_uri('/'),
|
||||
'ROOT_URL': request.build_absolute_uri(
|
||||
|
|
@ -217,6 +227,4 @@ class MeteorView(View):
|
|||
content_type=content_type,
|
||||
)
|
||||
except KeyError:
|
||||
print(path)
|
||||
return HttpResponse(self.html)
|
||||
# raise Http404
|
||||
|
|
|
|||
|
|
@ -308,7 +308,7 @@ class DDPWebSocketApplication(geventwebsocket.WebSocketApplication):
|
|||
}
|
||||
if record['exc_info'] == (None, None, None):
|
||||
del record['exc_info']
|
||||
self.logger.error('! %s %r', self, data, exc_info=exc_info, **record)
|
||||
self.logger.error('! %s %r', self, data, **record)
|
||||
self.reply(msg, **data)
|
||||
|
||||
def recv_connect(self, version=None, support=None, session=None):
|
||||
|
|
|
|||
2
setup.py
2
setup.py
|
|
@ -44,7 +44,7 @@ CLASSIFIERS = [
|
|||
|
||||
setup(
|
||||
name='django-ddp',
|
||||
version='0.13.0',
|
||||
version='0.14.0',
|
||||
description=__doc__,
|
||||
long_description=open('README.rst').read(),
|
||||
author='Tyson Clugg',
|
||||
|
|
|
|||
Loading…
Reference in a new issue