Commit 35aedfd9 authored by Christos Stavrakakis's avatar Christos Stavrakakis
Browse files

Merge branch 'feature-ui-improvements' of https://github.com/vinilios/synnefo into develop

parents 83fe02f5 b5abc0b9
......@@ -8,6 +8,7 @@ bin/
share/
build/
include/
!snf-cyclades-app/synnefo/ui/static/snf/extra/noVNC/include/
*.pt.py
*.installed.cfg
*.sqlite
......
......@@ -13,10 +13,10 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.conf.urls import url, patterns
from django.conf.urls import url, patterns, include
from django.http import Http404
from astakos.admin import views
from django.http import Http404
def index(request):
......
# Copyright 2014 GRNET S.A. All rights reserved.
#
# Redistribution and use in source and binary forms, with or
# without modification, are permitted provided that the following
# conditions are met:
#
# 1. Redistributions of source code must retain the above
# copyright notice, this list of conditions and the following
# disclaimer.
#
# 2. Redistributions in binary form must reproduce the above
# copyright notice, this list of conditions and the following
# disclaimer in the documentation and/or other materials
# provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS
# OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
# USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
# AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
# The views and conclusions contained in the software and
# documentation are those of the authors and should not be
# interpreted as representing official policies, either expressed
# or implied, of GRNET S.A.
import logging
from functools import wraps
from django import http
from django.utils import simplejson as json
from django.forms.models import model_to_dict
from django.core.validators import validate_email, ValidationError
from snf_django.lib import api
from snf_django.lib.api import faults
from astakos.im import settings
from astakos.admin import stats
from astakos.im.models import AstakosUser, get_latest_terms
from astakos.im.auth import make_local_user
logger = logging.getLogger(__name__)
try:
AUTH_URL = settings.astakos_services \
["astakos_identity"]["endpoints"][0]["publicURL"]
except (IndexError, KeyError) as e:
logger.error("Failed to load Astakos Auth URL: %s", e)
AUTH_URL = None
......@@ -14,19 +14,29 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import logging
from functools import wraps
from django import http
from django.utils import simplejson as json
from django.forms.models import model_to_dict
from django.core.validators import validate_email, ValidationError
from snf_django.lib import api
from astakos.im import settings
from snf_django.lib.api import faults
from astakos.im import settings
from astakos.admin import stats
from astakos.im.models import AstakosUser, get_latest_terms
from astakos.im.auth import make_local_user
logger = logging.getLogger(__name__)
PERMITTED_GROUPS = settings.ADMIN_STATS_PERMITTED_GROUPS
STATS_PERMITTED_GROUPS = settings.ADMIN_STATS_PERMITTED_GROUPS
try:
AUTH_URL = settings.astakos_services\
["astakos_identity"]["endpoints"][0]["publicURL"]
AUTH_URL = settings.astakos_services \
["astakos_identity"]["endpoints"][0]["publicURL"]
except (IndexError, KeyError) as e:
logger.error("Failed to load Astakos Auth URL: %s", e)
AUTH_URL = None
......@@ -44,9 +54,10 @@ def get_public_stats(request):
@api.api_method(http_method='GET', user_required=True, token_required=True,
astakos_auth_url=AUTH_URL,
logger=logger, serializations=['json'])
@api.user_in_groups(permitted_groups=PERMITTED_GROUPS,
@api.user_in_groups(permitted_groups=STATS_PERMITTED_GROUPS,
logger=logger)
def get_astakos_stats(request):
_stats = stats.get_astakos_stats()
data = json.dumps(_stats)
return http.HttpResponse(data, status=200, content_type='application/json')
......@@ -15,8 +15,21 @@
from django.conf.urls import patterns, url
from snf_django.lib.api import api_endpoint_not_found
from snf_django.lib.api.urls import api_patterns
from astakos.im import settings
urlpatterns = patterns(
urlpatterns = patterns('')
if settings.ADMIN_API_ENABLED:
urlpatterns += api_patterns(
'astakos.api.user',
(r'^v2.0/users(?:/|.json|.xml)?$', 'users_demux'),
(r'^v2.0/users/detail(?:.json|.xml)?$', 'users_list', {'detail': True}),
(r'^v2.0/users/([-\w]+)(?:/|.json|.xml)?$', 'user_demux'),
(r'^v2.0/users/([-\w]+)/action(?:/|.json|.xml)?$', 'user_action')
)
urlpatterns += patterns(
'astakos.api.tokens',
url(r'^v2.0/tokens/(?P<token_id>.+?)/?$', 'validate_token',
name='validate_token'),
......
......@@ -14,6 +14,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.conf.urls import patterns, url, include
from snf_django.lib.api import api_endpoint_not_found
......
......@@ -13,17 +13,36 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import logging
from functools import wraps, partial
from django.views.decorators.csrf import csrf_exempt
from django import http
from django.db import transaction
from django.utils import simplejson as json
from django.forms.models import model_to_dict
from django.core.validators import validate_email, ValidationError
from snf_django.lib import api
from snf_django.lib.api import faults
from .util import (
get_uuid_displayname_catalogs as get_uuid_displayname_catalogs_util,
send_feedback as send_feedback_util,
user_from_token)
import logging
logger = logging.getLogger(__name__)
from astakos.im import settings
from astakos.admin import stats
from astakos.im.models import AstakosUser, get_latest_terms
from astakos.im.auth import make_local_user
from astakos.im import activation_backends
ADMIN_GROUPS = settings.ADMIN_API_PERMITTED_GROUPS
activation_backend = activation_backends.get_backend()
logger = logging.getLogger(__name__)
@csrf_exempt
@api.api_method(http_method="POST", token_required=True, user_required=False,
......@@ -49,3 +68,282 @@ def send_feedback(request, email_template_name='im/feedback_mail.txt'):
# unauthorised (401)
return send_feedback_util(request, email_template_name)
# API ADMIN UTILS AND ENDPOINTS
def user_api_method(http_method):
"""
Common decorator for user admin api views.
"""
def wrapper(func):
@api.api_method(http_method=http_method, user_required=True,
token_required=True, logger=logger,
serializations=['json'])
@api.user_in_groups(permitted_groups=ADMIN_GROUPS,
logger=logger)
@wraps(func)
def method(*args, **kwargs):
return func(*args, **kwargs)
return method
return wrapper
def user_to_dict(user, detail=True):
user_fields = ['first_name', 'last_name', 'email']
date_fields = ['date_joined', 'moderated_at', 'verified_at',
'auth_token_expires']
status_fields = ['is_active', 'is_rejected', 'deactivated_reason',
'accepted_policy', 'rejected_reason']
if not detail:
fields = user_fields
date_fields = []
d = model_to_dict(user, fields=user_fields + status_fields)
d['id'] = user.uuid
for date_field in date_fields:
val = getattr(user, date_field)
if val:
d[date_field] = api.utils.isoformat(getattr(user, date_field))
else:
d[date_field] = None
methods = d['authentication_methods'] = []
d['roles'] = list(user.groups.values_list("name", flat=True))
for provider in user.auth_providers.filter():
method_fields = ['identifier', 'active', 'affiliation']
method = model_to_dict(provider, fields=method_fields)
method['backend'] = provider.auth_backend
method['metadata'] = provider.info
if provider.auth_backend == 'astakos':
method['identifier'] = user.email
methods.append(method)
return d
def users_demux(request):
if request.method == 'GET':
return users_list(request)
elif request.method == 'POST':
return users_create(request)
else:
return api.api_method_not_allowed(request)
def user_demux(request, user_id):
if request.method == 'GET':
return user_detail(request, user_id)
elif request.method == 'PUT':
return user_update(request, user_id)
else:
return api.api_method_not_allowed(request)
@user_api_method('GET')
def users_list(request, action='list', detail=False):
logger.debug('users_list detail=%s', detail)
users = AstakosUser.objects.filter()
dict_func = partial(user_to_dict, detail=detail)
users_dicts = map(dict_func, users)
data = json.dumps({'users': users_dicts})
return http.HttpResponse(data, status=200,
content_type='application/json')
@user_api_method('POST')
@transaction.commit_on_success
def users_create(request):
user_id = request.user_uniq
req = api.utils.get_json_body(request)
logger.info('users_create: %s request: %s', user_id, req)
user_data = req.get('user', {})
email = user_data.get('username', None)
first_name = user_data.get('first_name', None)
last_name = user_data.get('last_name', None)
affiliation = user_data.get('affiliation', None)
password = user_data.get('password', None)
metadata = user_data.get('metadata', {})
password_gen = AstakosUser.objects.make_random_password
if not password:
password = password_gen()
try:
validate_email(email)
except ValidationError:
raise faults.BadRequest("Invalid username (email format required)")
if AstakosUser.objects.verified_user_exists(email):
raise faults.Conflict("User '%s' already exists" % email)
if not first_name:
raise faults.BadRequest("Invalid first_name")
if not last_name:
raise faults.BadRequest("Invalid last_name")
has_signed_terms = not(get_latest_terms())
try:
user = make_local_user(email, first_name=first_name,
last_name=last_name, password=password,
has_signed_terms=has_signed_terms)
if metadata:
# we expect a unique local auth provider for the user
provider = user.auth_providers.get()
provider.info = metadata
provider.affiliation = affiliation
provider.save()
user = AstakosUser.objects.get(pk=user.pk)
code = user.verification_code
ver_res = activation_backend.handle_verification(user, code)
if ver_res.is_error():
raise Exception(ver_res.message)
mod_res = activation_backend.handle_moderation(user, accept=True)
if mod_res.is_error():
raise Exception(ver_res.message)
except Exception, e:
raise faults.BadRequest(e.message)
user_data = {
'id': user.uuid,
'password': password,
'auth_token': user.auth_token,
}
data = json.dumps({'user': user_data})
return http.HttpResponse(data, status=200, content_type='application/json')
@user_api_method('POST')
@transaction.commit_on_success
def user_action(request, user_id):
admin_id = request.user_uniq
req = api.utils.get_json_body(request)
logger.info('user_action: %s user: %s request: %s', admin_id, user_id, req)
if 'activate' in req:
try:
user = AstakosUser.objects.get(uuid=user_id)
except AstakosUser.DoesNotExist:
raise faults.ItemNotFound("User not found")
activation_backend.activate_user(user)
user = AstakosUser.objects.get(uuid=user_id)
user_data = {
'id': user.uuid,
'is_active': user.is_active
}
data = json.dumps({'user': user_data})
return http.HttpResponse(data, status=200,
content_type='application/json')
if 'deactivate' in req:
try:
user = AstakosUser.objects.get(uuid=user_id)
except AstakosUser.DoesNotExist:
raise faults.ItemNotFound("User not found")
activation_backend.deactivate_user(
user, reason=req['deactivate'].get('reason', None))
user = AstakosUser.objects.get(uuid=user_id)
user_data = {
'id': user.uuid,
'is_active': user.is_active
}
data = json.dumps({'user': user_data})
return http.HttpResponse(data, status=200,
content_type='application/json')
if 'renewToken' in req:
try:
user = AstakosUser.objects.get(uuid=user_id)
except AstakosUser.DoesNotExist:
raise faults.ItemNotFound("User not found")
user.renew_token()
user.save()
user_data = {
'id': user.uuid,
'auth_token': user.auth_token,
}
data = json.dumps({'user': user_data})
return http.HttpResponse(data, status=200,
content_type='application/json')
raise faults.BadRequest("Invalid action")
@user_api_method('PUT')
@transaction.commit_on_success
def user_update(request, user_id):
admin_id = request.user_uniq
req = api.utils.get_json_body(request)
logger.info('user_update: %s user: %s request: %s', admin_id, user_id, req)
user_data = req.get('user', {})
try:
user = AstakosUser.objects.get(uuid=user_id)
except AstakosUser.DoesNotExist:
raise faults.ItemNotFound("User not found")
email = user_data.get('username', None)
first_name = user_data.get('first_name', None)
last_name = user_data.get('last_name', None)
affiliation = user_data.get('affiliation', None)
password = user_data.get('password', None)
metadata = user_data.get('metadata', {})
if 'password' in user_data:
user.set_password(password)
if 'username' in user_data:
try:
validate_email(email)
except ValidationError:
raise faults.BadRequest("Invalid username (email format required)")
if AstakosUser.objects.verified_user_exists(email):
raise faults.Conflict("User '%s' already exists" % email)
user.email = email
if 'first_name' in user_data:
user.first_name = first_name
if 'last_name' in user_data:
user.last_name = last_name
try:
user.save()
if 'metadata' in user_data:
provider = user.auth_providers.get(auth_backend="astakos")
provider.info = metadata
if affiliation in user_data:
provider.affiliation = affiliation
provider.save()
except Exception, e:
raise faults.BadRequest(e.message)
data = json.dumps({'user': user_to_dict(user)})
return http.HttpResponse(data, status=200, content_type='application/json')
@user_api_method('GET')
def user_detail(request, user_id):
admin_id = request.user_uniq
logger.info('user_detail: %s user: %s', admin_id, user_id)
try:
user = AstakosUser.objects.get(uuid=user_id)
except AstakosUser.DoesNotExist:
raise faults.ItemNotFound("User not found")
user_data = user_to_dict(user, detail=True)
data = json.dumps({'user': user_data})
return http.HttpResponse(data, status=200, content_type='application/json')
......@@ -357,10 +357,10 @@ class ActivationBackend(object):
if not ok:
return ActivationResult(self.Result.ERROR, msg)
user.is_active = False
user.deactivated_reason = reason
if user.is_active:
user.deactivated_at = datetime.datetime.now()
user.is_active = False
user.deactivated_reason = reason
user.save()
logger.info("User deactivated: %s", user.log_display)
return ActivationResult(self.Result.DEACTIVATED)
......
......@@ -16,6 +16,8 @@
import copy
import json
from datetime import datetime
from synnefo.lib.ordereddict import OrderedDict
from django.core.urlresolvers import reverse, NoReverseMatch
......@@ -186,6 +188,14 @@ class AuthProvider(object):
from astakos.im.models import AstakosUserAuthProvider as AuthProvider
return AuthProvider
def update_last_login_at(self):
instance = self._instance
user = instance.user
date = datetime.now()
instance.last_login_at = user.last_login = date
instance.save()
user.save()
def remove_from_user(self):
if not self.get_remove_policy:
raise Exception("Provider cannot be removed")
......@@ -234,6 +244,7 @@ class AuthProvider(object):
user = pending._instance.user
logger.info("Removing existing unverified user (%r)",
user.log_display)
user.base_project and user.base_project.delete()
user.delete()
create_params = {
......
......@@ -18,7 +18,12 @@ import re
from django.utils.translation import ugettext as _
from django.utils.encoding import smart_str
from django.utils.encoding import force_unicode as force_text
from django.utils.safestring import mark_safe
from django import forms
from django.forms import widgets
from django.core import validators
from synnefo.util import units
class EmailValidator(object):
......@@ -73,3 +78,107 @@ class EmailValidator(object):
class EmailField(forms.EmailField):
default_validators = [EmailValidator()]
class CustomChoiceWidget(forms.MultiWidget):
def __init__(self, attrs=None, **kwargs):
_widgets = (
widgets.Select(attrs=attrs, **kwargs),
widgets.TextInput(attrs=attrs)
)
super(CustomChoiceWidget, self).__init__(_widgets, attrs)
def render(self, *args, **kwargs):
attrs = kwargs.get("attrs", {})
css_class = attrs.get("class", "") + " custom-select"
attrs['class'] = css_class
kwargs['attrs'] = attrs
out = super(CustomChoiceWidget, self).render(*args, **kwargs)
return mark_safe("""
%(html)s
<script>
$(document).ready(function() {
var select = $("#%(id)s_0");
var input = $("#%(id)s_1");
input.hide();
var check_custom = function() {
var val = select.val();
if (val == "custom") {
input.show().focus();
} else {
input.hide().val('');
}
}
select.bind("change", check_custom);
check_custom();
});
</script>
""" % ({
'id': attrs.get("id"),
'html': out
}))
def decompress(self, value):
if not value:
return ['custom', '']
if value == 'Unlimited':
return ['Unlimited', '']
try:
value = int(value)
except ValueError:
return ['custom', value]
values = dict(self.choices).values()
if value in values:
return [str(value), '']