Replace everything cpanl with django-postfix-dovecot-api

This commit is contained in:
nikolark committed 2015-01-17 17:53:03 +01:00
1 parent 99b296aaea
commit 47c033b274
13 files changed
+209 -204

No files matched your search

Vendored
+63
View File
@@ -0,0 +1,63 @@
import logging
import sys
from contextlib import contextmanager as _contextmanager
from fabric.api import run, sudo, env, cd, prefix, lcd
from fabric.api import local as lrun
env.use_ssh_config = True
env.hosts = ['dreamcast.neuf.no']
env.project_path = '/var/www/neuf.no/mailadmin'
env.user = 'gitdeploy'
env.activate = 'source {}/venv/bin/activate'.format(env.project_path)
@_contextmanager
def virtualenv():
with cd(env.project_path):
with prefix(env.activate):
yield
def __git_validate_clean():
with lcd(env.project_path):
if lrun('git diff-files', capture=True) != '':
logging.error("Your git-tree isn't clean! Commit your changes before deploying.")
sys.exit(1)
def test():
lrun("python manage.py test dpdapi")
def commit():
lrun("git add -p && git commit")
def push():
lrun("git push")
def prepare_deploy():
test()
commit()
push()
def gitstatus():
# __git_validate_clean()
with virtualenv():
# Get source
run('git branch -v')
def deploy():
# __git_validate_clean()
with virtualenv():
run('git pull') # Get source
run('pip install -r requirements.txt') # install deps in virtualenv
run('python manage.py collectstatic --noinput') # Collect static
run('python manage.py migrate') # Run DB migrations
# Reload gunicorn
sudo('/etc/init.d/gunicorn reload', shell=False)
+34 -20
View File
@@ -2,33 +2,47 @@ import requests
from django.conf import settings
class CPanel(object):
class DjangoPostfixDovecotAPI(object):
"""
CPanel API
django-postfix-dovecot-api
https://git.neuf.no/edb/django-postfix-dovecot-api#tab-readme
https://documentation.cpanel.net/display/SDK/cPanel+API+2+-+Email
https://documentation.cpanel.net/display/SDK/cPanel+API+1+-+Email
"""
def __init__(self, base_url=settings.CP_API_URL, username=settings.CP_API_USERNAME, password=settings.CP_API_PASSWORD):
def __init__(self, base_url=settings.DPD_API_URL, username=settings.DPD_API_USERNAME, password=settings.DPD_API_PASSWORD):
self.base_url = base_url
self.username = username
self.password = password
def api(self, module, function, params=None, version=2):
generic = {
'cpanel_jsonapi_user': self.username,
'cpanel_jsonapi_module': module,
'cpanel_jsonapi_func': function,
'cpanel_jsonapi_apiversion': version,
}
if params is not None:
params = dict(generic.items() + params.items())
def _api(self, method, path, params=None, json=None):
headers = {'content-type': 'application/json'}
r = requests.get(
self.base_url,
url = "{}{}".format(self.base_url, path)
r = requests.request(
method,
url,
params=params,
headers=headers,
auth=requests.auth.HTTPBasicAuth(self.username, self.password),
verify=False)
return r.json()
json=json,
headers=headers)
return r.json()
def create_aliases(self, aliases):
return self._api('POST', '/aliases/create_bulk/', json=aliases)
def delete_aliases(self, aliases):
return self._api('DELETE', '/aliases/delete_bulk/', json=aliases)
def list_aliases_regex(self, regex):
""" Limit aliases by regular expression and domain name """
params = {
'domain__name': settings.NEUF_EMAIL_DOMAIN_NAME,
'source_regex': regex
}
print params
return self._api(
'GET',
'/aliases/',
params=params
)
+9 -20
View File
@@ -2,33 +2,22 @@ from rest_framework import permissions
from mailadmin.models import OrgUnit
import re
from mailadmin.serializers import ForwardSerializer
from mailadmin.serializers import AliasSerializer
class IsOwnerOrReadOnly(permissions.BasePermission):
"""
Custom permission to only allow owners of an object to edit it.
"""
def has_object_permission(self, request, view, obj):
# Read permissions are allowed to any request,
# so we'll always allow GET, HEAD or OPTIONS requests.
if request.method in permissions.SAFE_METHODS:
return True
# Write permissions are only allowed to the owner
return obj.owner == request.user
class DestinationPrefixOwner(permissions.BasePermission):
class SourcePrefixOwner(permissions.BasePermission):
# Ref: http://www.django-rest-framework.org/api-guide/permissions
# TODO have no idea if this works
def has_permission(self, request, view):
# Read permissions (GET, HEAD or OPTIONS) are allowed for all authenticated request
if request.method in permissions.SAFE_METHODS and request.user and request.user.is_authenticated():
return True
# TODO test handling the DELETE requests
if request.user.is_superuser:
return True
if request.method == 'DELETE':
if request.method in ['DELETE', 'POST']:
print view
print view.kwargs
forwarder = view.kwargs['forwarder'] # TODO you are here
@@ -37,9 +26,9 @@ class DestinationPrefixOwner(permissions.BasePermission):
return True
fwd_data = forwarder.split("=")
fw = ForwardSerializer(data={'dest': fwd_data[0], 'forward': fwd_data[1]})
fw = AliasSerializer(data={'dest': fwd_data[0], 'forward': fwd_data[1]})
else:
fw = ForwardSerializer(data=request.DATA)
fw = AliasSerializer(data=request.DATA)
if not fw.is_valid():
return True # Skip auth if not valid data
+7 -7
View File
@@ -27,14 +27,14 @@ class OrgUnitSerializer(serializers.ModelSerializer):
model = OrgUnit
class ForwardSerializer(serializers.Serializer):
dest = serializers.EmailField(required=True)
forward = serializers.EmailField(required=True)
_domain = settings.NEUF_EMAIL_DOMAIN
class AliasSerializer(serializers.Serializer):
source = serializers.EmailField(required=True)
destination = serializers.EmailField(required=True)
_domain_name = settings.NEUF_EMAIL_DOMAIN_NAME
def validate_dest(self, attrs, source):
def validate_destonation(self, attrs, source):
domain_part = attrs[source].split('@')[1]
if domain_part != self._domain:
raise serializers.ValidationError("dest '{0}' is not in domain {1}".format(attrs[source], self._domain))
if domain_part != self._domain_name:
raise serializers.ValidationError("dest '{0}' is not in domain {1}".format(attrs[source], self._domain_name))
return attrs
+16 -16
View File
@@ -65,9 +65,7 @@
$(document).ready(function() {
var csrf_token = $('meta[name=x-csrf-token]').attr('content');
var api_urls = {
fwd_list: '/api/forwards/',
fwd_delete: '/api/forward/',
fwd_create: '/api/forward/', // + forwarder
aliases: '/api/aliases/',
ou_list: '/api/orgunits/',
me: '/api/me/'
};
@@ -150,7 +148,7 @@
});
$.post(
api_urls.fwd_create,
api_urls.aliases,
new_list[dest],
function() {
notify('Lagt til', 'Ny liste: ' + dest + 'lagt til', 'success');
@@ -190,22 +188,24 @@
});
/* Load forwards from API and put on page */
$.getJSON(api_urls.fwd_list, function(data) {
if( !data.cpanelresult ) {
notify("Ingen lister.", 'Fant ingen tilhørende lister', 'info');
$.getJSON(api_urls.aliases, function(data) {
var err_msg;
if( data && data.length === 0 ) {
err_msg = 'Fant ingen tilhørende lister';
notify("Ingen lister.", err_msg, 'info');
forwards_container.html(err_msg);
return;
}
if( 'error' in data.cpanelresult ) {
notify('Oops!', 'Klarte ikke hente epostlister fra APIet: '+data.cpanelresult.error, 'error');
if( data && 'error' in data ) {
err_msg = 'Klarte ikke hente epostlister fra APIet: '+data.error;
notify('Oops!', err_msg, 'error');
forwards_container.html(err_msg);
return;
}
var forwards = data.cpanelresult.data;
var lists =_.groupBy(forwards, function(fwd) { return fwd.dest; });
var fw_html = nunjucks.render('list.html', {
lists: lists,
api_urls: api_urls
});
var forwards = data;
var lists =_.groupBy(forwards, function(fwd) { return fwd.source; });
var fw_html = nunjucks.render('list.html', { lists: lists });
forwards_container.html(fw_html);
@@ -248,7 +248,7 @@
var data = {
type: 'DELETE',
headers: {'X-CSRFToken': csrf_token},
url: api_urls.fwd_delete,
url: api_urls.aliases,
data: delete_these,
dataType: 'json'
};
+4 -4
View File
@@ -1,11 +1,11 @@
{% for list,members in lists %}
{% for list,aliases in lists %}
<div class="fwdlist" data-list-name="{{ list }}">
<div class="panel panel-default">
<div class="panel-heading"><span class="glyphicon glyphicon-list-alt icon-faded" aria-hidden="true"></span> {{ list }} <span class="badge">{{members|length}}</span></div>
<div class="panel-heading"><span class="glyphicon glyphicon-list-alt icon-faded" aria-hidden="true"></span> {{ list }} <span class="badge">{{aliases|length}}</span></div>
<table class="table table-condensed">
<tbody>
{% for member in members %}
<tr><td class="member">{{ member.forward }}</td><td class="del-cell"><label class="del-label" for="{{ member.dest }}={{ member.forward }}"><input id="{{ member.dest }}={{ member.forward }}" type="checkbox" name='fwd-delete' value="{{ member.dest }}={{ member.forward }}"></label></td></tr>
{% for member in aliases %}
<tr><td class="member">{{ member.destination }}</td><td class="del-cell"><label class="del-label" for="{{ member.source }}={{ member.destination }}"><input id="{{ member.source }}={{ member.destination }}" type="checkbox" name='fwd-delete' value="{{ member.source }}={{ member.destination }}"></label></td></tr>
{% endfor %}
<tr class="action-row">
<td>
+16 -16
View File
@@ -65,9 +65,7 @@
$(document).ready(function() {
var csrf_token = $('meta[name=x-csrf-token]').attr('content');
var api_urls = {
fwd_list: '/api/forwards/',
fwd_delete: '/api/forward/',
fwd_create: '/api/forward/', // + forwarder
aliases: '/api/aliases/',
ou_list: '/api/orgunits/',
me: '/api/me/'
};
@@ -150,7 +148,7 @@
});
$.post(
api_urls.fwd_create,
api_urls.aliases,
new_list[dest],
function() {
notify('Lagt til', 'Ny liste: ' + dest + 'lagt til', 'success');
@@ -190,22 +188,24 @@
});
/* Load forwards from API and put on page */
$.getJSON(api_urls.fwd_list, function(data) {
if( !data.cpanelresult ) {
notify("Ingen lister.", 'Fant ingen tilhørende lister', 'info');
$.getJSON(api_urls.aliases, function(data) {
var err_msg;
if( data && data.length === 0 ) {
err_msg = 'Fant ingen tilhørende lister';
notify("Ingen lister.", err_msg, 'info');
forwards_container.html(err_msg);
return;
}
if( 'error' in data.cpanelresult ) {
notify('Oops!', 'Klarte ikke hente epostlister fra APIet: '+data.cpanelresult.error, 'error');
if( data && 'error' in data ) {
err_msg = 'Klarte ikke hente epostlister fra APIet: '+data.error;
notify('Oops!', err_msg, 'error');
forwards_container.html(err_msg);
return;
}
var forwards = data.cpanelresult.data;
var lists =_.groupBy(forwards, function(fwd) { return fwd.dest; });
var fw_html = nunjucks.render('list.html', {
lists: lists,
api_urls: api_urls
});
var forwards = data;
var lists =_.groupBy(forwards, function(fwd) { return fwd.source; });
var fw_html = nunjucks.render('list.html', { lists: lists });
forwards_container.html(fw_html);
@@ -248,7 +248,7 @@
var data = {
type: 'DELETE',
headers: {'X-CSRFToken': csrf_token},
url: api_urls.fwd_delete,
url: api_urls.aliases,
data: delete_these,
dataType: 'json'
};
+16 -16
View File
@@ -40,7 +40,7 @@ for(t_1=0; t_1 < t_3.length; t_1++) {
var t_4 = t_3[t_1][0]
frame.set("list", t_3[t_1][0]);
var t_5 = t_3[t_1][1]
frame.set("members", t_3[t_1][1]);
frame.set("aliases", t_3[t_1][1]);
frame.set("loop.index", t_1 + 1);
frame.set("loop.index0", t_1);
frame.set("loop.revindex", t_2 - t_1);
@@ -69,19 +69,19 @@ frame.set("loop.first", t_6 === 0);
frame.set("loop.last", t_6 === t_7 - 1);
frame.set("loop.length", t_7);
output += "\n\t\t\t\t<tr><td class=\"member\">";
output += runtime.suppressValue(runtime.memberLookup((t_9),"forward", env.autoesc), env.autoesc);
output += runtime.suppressValue(runtime.memberLookup((t_9),"destination", env.autoesc), env.autoesc);
output += "</td><td class=\"del-cell\"><label class=\"del-label\" for=\"";
output += runtime.suppressValue(runtime.memberLookup((t_9),"dest", env.autoesc), env.autoesc);
output += runtime.suppressValue(runtime.memberLookup((t_9),"source", env.autoesc), env.autoesc);
output += "=";
output += runtime.suppressValue(runtime.memberLookup((t_9),"forward", env.autoesc), env.autoesc);
output += runtime.suppressValue(runtime.memberLookup((t_9),"destination", env.autoesc), env.autoesc);
output += "\"><input id=\"";
output += runtime.suppressValue(runtime.memberLookup((t_9),"dest", env.autoesc), env.autoesc);
output += runtime.suppressValue(runtime.memberLookup((t_9),"source", env.autoesc), env.autoesc);
output += "=";
output += runtime.suppressValue(runtime.memberLookup((t_9),"forward", env.autoesc), env.autoesc);
output += runtime.suppressValue(runtime.memberLookup((t_9),"destination", env.autoesc), env.autoesc);
output += "\" type=\"checkbox\" name='fwd-delete' value=\"";
output += runtime.suppressValue(runtime.memberLookup((t_9),"dest", env.autoesc), env.autoesc);
output += runtime.suppressValue(runtime.memberLookup((t_9),"source", env.autoesc), env.autoesc);
output += "=";
output += runtime.suppressValue(runtime.memberLookup((t_9),"forward", env.autoesc), env.autoesc);
output += runtime.suppressValue(runtime.memberLookup((t_9),"destination", env.autoesc), env.autoesc);
output += "\"></label></td></tr>\n\t\t\t";
;
}
@@ -101,7 +101,7 @@ for(var t_10 in t_3) {
t_1++;
var t_11 = t_3[t_10];
frame.set("list", t_10);
frame.set("members", t_11);
frame.set("aliases", t_11);
frame.set("loop.index", t_1 + 1);
frame.set("loop.index0", t_1);
frame.set("loop.revindex", t_2 - t_1);
@@ -130,19 +130,19 @@ frame.set("loop.first", t_12 === 0);
frame.set("loop.last", t_12 === t_13 - 1);
frame.set("loop.length", t_13);
output += "\n\t\t\t\t<tr><td class=\"member\">";
output += runtime.suppressValue(runtime.memberLookup((t_15),"forward", env.autoesc), env.autoesc);
output += runtime.suppressValue(runtime.memberLookup((t_15),"destination", env.autoesc), env.autoesc);
output += "</td><td class=\"del-cell\"><label class=\"del-label\" for=\"";
output += runtime.suppressValue(runtime.memberLookup((t_15),"dest", env.autoesc), env.autoesc);
output += runtime.suppressValue(runtime.memberLookup((t_15),"source", env.autoesc), env.autoesc);
output += "=";
output += runtime.suppressValue(runtime.memberLookup((t_15),"forward", env.autoesc), env.autoesc);
output += runtime.suppressValue(runtime.memberLookup((t_15),"destination", env.autoesc), env.autoesc);
output += "\"><input id=\"";
output += runtime.suppressValue(runtime.memberLookup((t_15),"dest", env.autoesc), env.autoesc);
output += runtime.suppressValue(runtime.memberLookup((t_15),"source", env.autoesc), env.autoesc);
output += "=";
output += runtime.suppressValue(runtime.memberLookup((t_15),"forward", env.autoesc), env.autoesc);
output += runtime.suppressValue(runtime.memberLookup((t_15),"destination", env.autoesc), env.autoesc);
output += "\" type=\"checkbox\" name='fwd-delete' value=\"";
output += runtime.suppressValue(runtime.memberLookup((t_15),"dest", env.autoesc), env.autoesc);
output += runtime.suppressValue(runtime.memberLookup((t_15),"source", env.autoesc), env.autoesc);
output += "=";
output += runtime.suppressValue(runtime.memberLookup((t_15),"forward", env.autoesc), env.autoesc);
output += runtime.suppressValue(runtime.memberLookup((t_15),"destination", env.autoesc), env.autoesc);
output += "\"></label></td></tr>\n\t\t\t";
;
}
+8 -31
View File
@@ -1,5 +1,5 @@
from django.test import TestCase
from mailadmin.api import CPanel
from mailadmin.api import DjangoPostfixDovecotAPI
import unittest
@@ -7,41 +7,18 @@ class APITest(TestCase):
@unittest.skip("Not now")
def test_listforwards(self):
server = CPanel()
_api = DjangoPostfixDovecotAPI()
params = dict(
domain='studentersamfundet.no',
regex='kak-*'
regex='^kak-*'
)
res = server.api('Email', 'listforwards', params)
self.assertIsInstance(res, dict)
self.assertIn('cpanelresult', res)
self.assertEqual(True, True)
@unittest.skip("Not now")
def test_addforward(self):
server = CPanel()
params = dict(
domain='studentersamfundet.no',
email='test.testesen',
fwdopt='fwd',
fwdemail='nikolaik@gmail.com'
)
res = server.api('Email', 'addforward', params)
self.assertIsInstance(res, dict)
self.assertIn('cpanelresult', res)
cp_res = res['cpanelresult']
self.assertIn('event', cp_res)
self.assertIn('result', cp_res['event'])
self.assertEqual(cp_res['event']['result'], 1)
_api = DjangoPostfixDovecotAPI()
self.assertEqual(True, True)
@unittest.skip("Not now")
def test_delforward(self):
server = CPanel()
params = {
'arg-0': 'kak-lol@studentersamfundet.no=nikolaik@gmail.com',
}
res = server.api('Email', 'delforward', params, version=1)
# FIXME: Does not work!
self.assertIsInstance(res, dict)
self.assertIn('event', res)
self.assertIn('result', res['event'])
self.assertEqual(res['event']['result'], 1)
_api = DjangoPostfixDovecotAPI()
self.assertEqual(True, True)
+2 -4
View File
@@ -1,10 +1,9 @@
from django.conf.urls import patterns, url
from rest_framework.routers import DefaultRouter
from mailadmin.views import ForwardsViewSet, MyUserViewSet, MyOrgUnitViewSet, OrgUnitViewSet, ForwardCreateView, ForwardDeleteView
from mailadmin.views import MyUserViewSet, MyOrgUnitViewSet, OrgUnitViewSet, AliasesView
router = DefaultRouter()
router.register(r'forwards', ForwardsViewSet, base_name='all')
router.register(r'me', MyUserViewSet, base_name='me')
router.register(r'orgunits', MyOrgUnitViewSet, base_name='ous')
router.register(r'orgunits/all', OrgUnitViewSet, base_name='allous')
@@ -12,6 +11,5 @@ urlpatterns = router.urls
urlpatterns += patterns(
'',
url(r'^forward/$', ForwardCreateView.as_view(), name='forward-create'),
url(r'^forward/(?P<forwarder>.+)/$', ForwardDeleteView.as_view(), name='forward-delete'),
url(r'^aliases/$', AliasesView.as_view(), name='aliases'),
)
+21 -58
View File
@@ -1,4 +1,3 @@
from django.conf import settings
from django.contrib.auth import login, logout as auth_logout
from django.contrib.auth.decorators import login_required
from django.contrib.auth.forms import AuthenticationForm
@@ -7,15 +6,15 @@ from django.shortcuts import render, redirect
from rest_framework import viewsets, views
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework import status
from mailadmin.api import CPanel
from mailadmin.api import DjangoPostfixDovecotAPI
from mailadmin.models import OrgUnit
from mailadmin.serializers import UserSerializer, OrgUnitSerializer, ForwardSerializer
from mailadmin.permissions import DestinationPrefixOwner
from mailadmin.serializers import UserSerializer, OrgUnitSerializer, AliasSerializer
from mailadmin.permissions import SourcePrefixOwner
def index(request):
form = {}
if request.method == "POST":
form = AuthenticationForm(data=request.POST)
if form.is_valid():
@@ -41,14 +40,12 @@ def logout(request):
return redirect('index')
class ForwardsViewSet(viewsets.ViewSet):
"""
Forwards from cPanel
"""
class AliasesView(views.APIView):
""" List, create and delete aliases from mxapi """
_api = DjangoPostfixDovecotAPI()
permission_classes = (SourcePrefixOwner,)
permission_classes = [IsAuthenticated]
def list(self, request):
def get(self, request):
group_filter = request.QUERY_PARAMS.get('groups', None)
if group_filter is None:
my_groups = request.user.groups.all()
@@ -67,62 +64,28 @@ class ForwardsViewSet(viewsets.ViewSet):
my_prefixes = orgunits.values_list('prefix', flat=True)
# Prepare regular expression
regexp = '|'.join(['^{0}-'.format(p) for p in my_prefixes])
regexp = '|'.join(['^{0}-|^{0}@'.format(p) for p in my_prefixes])
if len(my_prefixes) == 0:
return Response({'result': 'No results'}) # Need at least one
cp = CPanel()
params = dict(
domain=settings.NEUF_EMAIL_DOMAIN,
regex=regexp
)
res = cp.api('Email', 'listforwards', params)
res = self._api.list_aliases_regex(regexp)
return Response(res)
class ForwardCreateView(views.APIView):
_cp = CPanel()
permission_classes = (DestinationPrefixOwner,)
def post(self, request):
fw = ForwardSerializer(data=request.DATA)
if not fw.is_valid():
return Response(fw.errors, status=400)
aliases = AliasSerializer(data=request.DATA, many=True)
if not aliases.is_valid():
return Response(aliases.errors, status=status.HTTP_400_BAD_REQUEST)
# fw.data
params = dict(
domain=settings.NEUF_EMAIL_DOMAIN,
email=fw.data['dest'].replace('@{0}'.format(settings.NEUF_EMAIL_DOMAIN), ''),
fwdopt='fwd',
fwdemail=fw.data['forward']
)
# FIXME: unreliable
# res = self._cp.api('Email', 'addforward', params)
res = {'errors': 'Not implemented'}
res = self._api.create_aliases(aliases.data)
return Response(res)
def delete(self, request):
aliases = AliasSerializer(data=request.DATA, many=True)
if not aliases.is_valid():
return Response(aliases.errors, status=status.HTTP_400_BAD_REQUEST)
class ForwardDeleteView(views.APIView):
_cp = CPanel()
permission_classes = (DestinationPrefixOwner,)
def delete(self, request, forwarder=None):
invalid_forwarder = {'error': 'Invalid forwarder: {0}'.format(forwarder)}
if '=' not in forwarder:
return Response(invalid_forwarder, status=400)
fwd_data = forwarder.split("=")
fw = ForwardSerializer(data={'dest': fwd_data[0], 'forward': fwd_data[1]})
if not fw.is_valid():
return Response(fw.errors, status=400)
params = {
'arg-0': '{0}={1}'.format(fw.data['dest'], fw.data['forward'])
}
# FIXME: unreliable
# res = self._cp.api('Email', 'delforward', params, version=1)
# FIXME: not tested
# res = self._api.delete_aliases(aliases)
res = {'errors': 'Not implemented'}
return Response(res)
+4 -4
View File
@@ -86,11 +86,11 @@ STATIC_URL = '/static/'
LOGIN_REDIRECT_URL = '/lists/'
CP_API_URL = 'https://web.studentersamfundet.no:2083/json-api/cpanel'
CP_API_USERNAME = "root"
CP_API_PASSWORD = ""
DPD_API_URL= 'https://mxapi.neuf.no/api'
DPD_API_USERNAME = ""
DPD_API_PASSWORD = ""
NEUF_EMAIL_DOMAIN = 'studentersamfundet.no'
NEUF_EMAIL_DOMAIN_NAME = 'stry.no'
AUTHENTICATION_BACKENDS = (
'mailadmin.backends.LDAPEmailBackend',
+9 -8
View File
@@ -1,12 +1,13 @@
Django==1.7.1
django-auth-ldap==1.2.2
Django>=1.7,<1.8
django-auth-ldap==1.2.4
django-bootstrap-form==3.1
django-filter==0.8
djangorestframework==2.4.4
django-extensions==1.4.9
django-filter==0.9.1
djangorestframework==3.0.2
Fabric==1.10.1
Markdown==2.5.1
MySQL-python==1.2.5
ndg-httpsclient==0.3.2
ndg-httpsclient==0.3.3
pyasn1==0.1.7
pyOpenSSL==0.14
python-ldap==2.4.18
requests==2.4.3
python-ldap==2.4.19
requests==2.5.1