__init__.py 40.3 KB
Newer Older
Vangelis Koukis's avatar
Vangelis Koukis committed
1
# Copyright (C) 2010-2014 GRNET S.A.
2
#
Vangelis Koukis's avatar
Vangelis Koukis committed
3 4 5 6
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
7
#
Vangelis Koukis's avatar
Vangelis Koukis committed
8 9 10 11
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
12
#
Vangelis Koukis's avatar
Vangelis Koukis committed
13 14
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
15

16 17 18 19
"""
Simple and minimal client for the Astakos authentication service
"""

20 21
import logging
import urlparse
22
import urllib
23
import hashlib
24
from base64 import b64encode
25
from copy import copy
26 27

import simplejson
28
from astakosclient.utils import \
29
    retry_dec, scheme_to_class, parse_request, check_input, join_urls
30
from astakosclient.errors import \
31
    AstakosClientException, Unauthorized, BadRequest, NotFound, Forbidden, \
32
    NoUserName, NoUUID, BadValue, QuotaLimit, InvalidResponse, NoEndpoints
33 34


35
# --------------------------------------------------------------------
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
36
# Astakos Client Class
37

38
def get_token_from_cookie(request, cookie_name):
39 40 41 42 43 44 45 46 47 48
    """Extract token from the cookie name provided

    Cookie should be in the same form as astakos
    service sets its cookie contents:
        <user_uniq>|<user_token>

    """
    try:
        cookie_content = urllib.unquote(request.COOKIE.get(cookie_name, None))
        return cookie_content.split("|")[1]
49
    except BaseException:
50 51 52
        return None


53 54 55
# Too many instance attributes. pylint: disable-msg=R0902
# Too many public methods. pylint: disable-msg=R0904
class AstakosClient(object):
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
56 57 58
    """AstakosClient Class Implementation"""

    # ----------------------------------
59 60 61 62 63 64
    # Initialize AstakosClient Class
    # Too many arguments. pylint: disable-msg=R0913
    # Too many local variables. pylint: disable-msg=R0914
    # Too many statements. pylint: disable-msg=R0915
    def __init__(self, token, auth_url,
                 retry=0, use_pool=False, pool_size=8, logger=None):
65
        """Initialize AstakosClient Class
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
66 67

        Keyword arguments:
68 69
        token       -- user's/service's token (string)
        auth_url    -- i.e https://accounts.example.com/identity/v2.0
70
        retry       -- how many time to retry (integer)
71 72
        use_pool    -- use objpool for http requests (boolean)
        pool_size   -- if using pool, define the pool size
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
73 74 75
        logger      -- pass a different logger

        """
76 77

        # Get logger
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
78 79
        if logger is None:
            logger = logging.getLogger("astakosclient")
80
            logger.setLevel(logging.INFO)
81 82 83
        logger.debug("Intialize AstakosClient: auth_url = %s, "
                     "use_pool = %s, pool_size = %s",
                     auth_url, use_pool, pool_size)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
84

85 86
        # Check that token and auth_url (mandatory options) are given
        check_input("__init__", logger, token=token, auth_url=auth_url)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
87

88 89 90 91
        # Initialize connection class
        parsed_auth_url = urlparse.urlparse(auth_url)
        conn_class = \
            scheme_to_class(parsed_auth_url.scheme, use_pool, pool_size)
92
        if conn_class is None:
93 94 95
            msg = "Unsupported scheme: %s" % parsed_auth_url.scheme
            logger.error(msg)
            raise BadValue(msg)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
96

97
        # Save astakos base url, logger, connection class etc in our class
98
        self.retry = retry
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
99
        self.logger = logger
100 101 102
        self.token = token
        self.astakos_base_url = parsed_auth_url.netloc
        self.scheme = parsed_auth_url.scheme
103
        self.conn_class = conn_class
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
104

105 106 107 108 109
        # Initialize astakos api prefixes
        # API urls under auth_url
        self.auth_prefix = parsed_auth_url.path
        self.api_tokens = join_urls(self.auth_prefix, "tokens")

110 111 112 113 114 115 116 117 118 119
    def _fill_endpoints(self, endpoints, extra=False):
        """Fill the endpoints for our AstakosClient

        This will be done once (lazily) and the endpoints will be there
        to be used afterwards.
        The `extra' parameter is there for compatibility reasons. We are going
        to fill the oauth2 endpoint only if we need it. This way we are keeping
        astakosclient compatible with older Astakos version.

        """
120
        astakos_service_catalog = parse_endpoints(
121
            endpoints, ep_name="astakos_account", ep_version_id="v1.0")
122 123 124 125 126 127 128 129 130 131 132 133 134 135
        self._account_url = \
            astakos_service_catalog[0]['endpoints'][0]['publicURL']
        parsed_account_url = urlparse.urlparse(self._account_url)

        self._account_prefix = parsed_account_url.path
        self.logger.debug("Got account_prefix \"%s\"" % self._account_prefix)

        self._ui_url = \
            astakos_service_catalog[0]['endpoints'][0]['SNF:uiURL']
        parsed_ui_url = urlparse.urlparse(self._ui_url)

        self._ui_prefix = parsed_ui_url.path
        self.logger.debug("Got ui_prefix \"%s\"" % self._ui_prefix)

136 137 138 139 140 141 142
        if extra:
            oauth2_service_catalog = \
                parse_endpoints(endpoints, ep_name="astakos_oauth2")
            self._oauth2_url = \
                oauth2_service_catalog[0]['endpoints'][0]['publicURL']
            parsed_oauth2_url = urlparse.urlparse(self._oauth2_url)
            self._oauth2_prefix = parsed_oauth2_url.path
143

144
    def _get_value(self, s, extra=False):
145
        assert s in ['_account_url', '_account_prefix',
146
                     '_ui_url', '_ui_prefix',
147
                     '_oauth2_url', '_oauth2_prefix']
148 149 150
        try:
            return getattr(self, s)
        except AttributeError:
151
            self.get_endpoints(extra=extra)
152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169
            return getattr(self, s)

    @property
    def account_url(self):
        return self._get_value('_account_url')

    @property
    def account_prefix(self):
        return self._get_value('_account_prefix')

    @property
    def ui_url(self):
        return self._get_value('_ui_url')

    @property
    def ui_prefix(self):
        return self._get_value('_ui_prefix')

170
    @property
171
    def oauth2_url(self):
172
        return self._get_value('_oauth2_url', extra=True)
173 174

    @property
175
    def oauth2_prefix(self):
176
        return self._get_value('_oauth2_prefix', extra=True)
177

178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196
    @property
    def api_usercatalogs(self):
        return join_urls(self.account_prefix, "user_catalogs")

    @property
    def api_service_usercatalogs(self):
        return join_urls(self.account_prefix, "service/user_catalogs")

    @property
    def api_resources(self):
        return join_urls(self.account_prefix, "resources")

    @property
    def api_quotas(self):
        return join_urls(self.account_prefix, "quotas")

    @property
    def api_service_quotas(self):
        return join_urls(self.account_prefix, "service_quotas")
197

198 199 200 201
    @property
    def api_service_project_quotas(self):
        return join_urls(self.account_prefix, "service_project_quotas")

202 203 204
    @property
    def api_commissions(self):
        return join_urls(self.account_prefix, "commissions")
205

206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224
    @property
    def api_commissions_action(self):
        return join_urls(self.api_commissions, "action")

    @property
    def api_feedback(self):
        return join_urls(self.account_prefix, "feedback")

    @property
    def api_projects(self):
        return join_urls(self.account_prefix, "projects")

    @property
    def api_memberships(self):
        return join_urls(self.api_projects, "memberships")

    @property
    def api_getservices(self):
        return join_urls(self.ui_prefix, "get_services")
225

226
    @property
227 228
    def api_oauth2_auth(self):
        return join_urls(self.oauth2_prefix, "auth")
229 230

    @property
231 232
    def api_oauth2_token(self):
        return join_urls(self.oauth2_prefix, "token")
233

234
    # ----------------------------------
235 236
    @retry_dec
    def _call_astakos(self, request_path, headers=None,
237
                      body=None, method="GET", log_body=True):
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
238
        """Make the actual call to Astakos Service"""
239 240
        hashed_token = hashlib.sha1()
        hashed_token.update(self.token)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
241
        self.logger.debug(
242 243 244 245
            "Make a %s request to %s, using token with hash %s, "
            "with headers %s and body %s",
            method, request_path, hashed_token.hexdigest(), headers,
            body if log_body else "(not logged)")
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
246

Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
247
        # Check Input
248 249 250 251
        if headers is None:
            headers = {}
        if body is None:
            body = {}
252 253 254
        # Initialize log_request and log_response attributes
        self.log_request = None
        self.log_response = None
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
255

Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
256 257
        # Build request's header and body
        kwargs = {}
258
        kwargs['headers'] = copy(headers)
259
        kwargs['headers']['X-Auth-Token'] = self.token
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
260
        if body:
261
            kwargs['body'] = copy(body)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
262 263 264 265 266 267
            kwargs['headers'].setdefault(
                'content-type', 'application/octet-stream')
        kwargs['headers'].setdefault('content-length',
                                     len(body) if body else 0)

        try:
268
            # Get the connection object
269
            with self.conn_class(self.astakos_base_url) as conn:
270 271 272 273 274
                # Log the request so other clients (like kamaki)
                # can use them to produce their own log messages.
                self.log_request = dict(method=method, path=request_path)
                self.log_request.update(kwargs)

275
                # Send request
276
                # Used * or ** magic. pylint: disable-msg=W0142
277
                (message, data, status) = \
278
                    _do_request(conn, method, request_path, **kwargs)
279 280 281 282 283

                # Log the response so other clients (like kamaki)
                # can use them to produce their own log messages.
                self.log_response = dict(
                    status=status, message=message, data=data)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
284
        except Exception as err:
285
            self.logger.error("Failed to send request: %s" % repr(err))
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
286 287 288 289
            raise AstakosClientException(str(err))

        # Return
        self.logger.debug("Request returned with status %s" % status)
290
        if status == 400:
291
            raise BadRequest(message, data)
292
        elif status == 401:
293
            raise Unauthorized(message, data)
294
        elif status == 403:
295
            raise Forbidden(message, data)
296
        elif status == 404:
297
            raise NotFound(message, data)
298
        elif status < 200 or status >= 300:
299
            raise AstakosClientException(message, data, status)
300 301 302 303 304

        try:
            if data:
                return simplejson.loads(unicode(data))
            else:
305
                return None
306
        except Exception as err:
307 308
            msg = "Cannot parse response \"%s\" with simplejson: %s"
            self.logger.error(msg % (data, str(err)))
309
            raise InvalidResponse(str(err), data)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
310 311

    # ----------------------------------
312
    # do a POST to ``API_USERCATALOGS`` (or ``API_SERVICE_USERCATALOGS``)
313
    #   with {'uuids': uuids}
314 315
    def _uuid_catalog(self, uuids, req_path):
        """Helper function to retrieve uuid catalog"""
316
        req_headers = {'content-type': 'application/json'}
317
        req_body = parse_request({'uuids': uuids}, self.logger)
318 319
        data = self._call_astakos(req_path, headers=req_headers,
                                  body=req_body, method="POST")
320 321 322
        if "uuid_catalog" in data:
            return data.get("uuid_catalog")
        else:
323 324 325 326
            msg = "_uuid_catalog request returned %s. No uuid_catalog found" \
                  % data
            self.logger.error(msg)
            raise AstakosClientException(msg)
327

328
    def get_usernames(self, uuids):
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
329 330 331 332 333 334 335 336 337
        """Return a uuid_catalog dictionary for the given uuids

        Keyword arguments:
        uuids   -- list of user ids (list of strings)

        The returned uuid_catalog is a dictionary with uuids as
        keys and the corresponding user names as values

        """
338
        return self._uuid_catalog(uuids, self.api_usercatalogs)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
339

340
    def get_username(self, uuid):
341
        """Return the user name of a uuid (see get_usernames)"""
342
        check_input("get_username", self.logger, uuid=uuid)
343
        uuid_dict = self.get_usernames([uuid])
344 345 346
        if uuid in uuid_dict:
            return uuid_dict.get(uuid)
        else:
347
            raise NoUserName(uuid)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
348

349
    def service_get_usernames(self, uuids):
350
        """Return a uuid_catalog dict using a service's token"""
351
        return self._uuid_catalog(uuids, self.api_service_usercatalogs)
352

353
    def service_get_username(self, uuid):
354
        """Return the displayName of a uuid using a service's token"""
355
        check_input("service_get_username", self.logger, uuid=uuid)
356
        uuid_dict = self.service_get_usernames([uuid])
357 358 359
        if uuid in uuid_dict:
            return uuid_dict.get(uuid)
        else:
360
            raise NoUserName(uuid)
361

362
    # ----------------------------------
363
    # do a POST to ``API_USERCATALOGS`` (or ``API_SERVICE_USERCATALOGS``)
364
    #   with {'displaynames': display_names}
365 366
    def _displayname_catalog(self, display_names, req_path):
        """Helper function to retrieve display names catalog"""
367
        req_headers = {'content-type': 'application/json'}
368
        req_body = parse_request({'displaynames': display_names}, self.logger)
369 370
        data = self._call_astakos(req_path, headers=req_headers,
                                  body=req_body, method="POST")
371 372 373
        if "displayname_catalog" in data:
            return data.get("displayname_catalog")
        else:
374 375 376 377
            msg = "_displayname_catalog request returned %s. " \
                  "No displayname_catalog found" % data
            self.logger.error(msg)
            raise AstakosClientException(msg)
378

379
    def get_uuids(self, display_names):
380 381 382 383 384 385 386 387 388
        """Return a displayname_catalog for the given names

        Keyword arguments:
        display_names   -- list of user names (list of strings)

        The returned displayname_catalog is a dictionary with
        the names as keys and the corresponding uuids as values

        """
389 390
        return self._displayname_catalog(
            display_names, self.api_usercatalogs)
391

392
    def get_uuid(self, display_name):
393
        """Return the uuid of a name (see getUUIDs)"""
394
        check_input("get_uuid", self.logger, display_name=display_name)
395
        name_dict = self.get_uuids([display_name])
396 397 398 399
        if display_name in name_dict:
            return name_dict.get(display_name)
        else:
            raise NoUUID(display_name)
400

401
    def service_get_uuids(self, display_names):
402
        """Return a display_name catalog using a service's token"""
403 404
        return self._displayname_catalog(
            display_names, self.api_service_usercatalogs)
405

406
    def service_get_uuid(self, display_name):
407
        """Return the uuid of a name using a service's token"""
408
        check_input("service_get_uuid", self.logger, display_name=display_name)
409
        name_dict = self.service_get_uuids([display_name])
410 411 412 413
        if display_name in name_dict:
            return name_dict.get(display_name)
        else:
            raise NoUUID(display_name)
414

Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
415
    # ----------------------------------
416
    # do a GET to ``API_GETSERVICES``
417
    def get_services(self):
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
418
        """Return a list of dicts with the registered services"""
419
        return self._call_astakos(self.api_getservices)
420 421

    # ----------------------------------
422
    # do a GET to ``API_RESOURCES``
423 424
    def get_resources(self):
        """Return a dict of dicts with the available resources"""
425
        return self._call_astakos(self.api_resources)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
426

427 428
    # ----------------------------------
    # do a POST to ``API_FEEDBACK``
429
    def send_feedback(self, message, data):
430 431 432 433 434 435 436 437 438 439 440 441 442
        """Send feedback to astakos service

        keyword arguments:
        message     -- Feedback message
        data        -- Additional information about service client status

        In case of success return nothing.
        Otherwise raise an AstakosClientException

        """
        check_input("send_feedback", self.logger, message=message, data=data)
        req_body = urllib.urlencode(
            {'feedback_msg': message, 'feedback_data': data})
443 444
        self._call_astakos(self.api_feedback, headers=None,
                           body=req_body, method="POST")
445

446 447
    # -----------------------------------------
    # do a POST to ``API_TOKENS`` with no token
448
    def get_endpoints(self, extra=False):
449 450
        """ Get services' endpoints

451
        The extra parameter is to be used by _fill_endpoints.
452 453 454 455 456 457 458 459
        In case of error raise an AstakosClientException.

        """
        req_headers = {'content-type': 'application/json'}
        req_body = None
        r = self._call_astakos(self.api_tokens, headers=req_headers,
                               body=req_body, method="POST",
                               log_body=False)
460
        self._fill_endpoints(r, extra=extra)
461 462 463 464 465
        return r

    # --------------------------------------
    # do a POST to ``API_TOKENS`` with a token
    def authenticate(self, tenant_name=None):
466
        """ Authenticate and get services' endpoints
467 468

        Keyword arguments:
469
        tenant_name         -- user's uniq id (optional)
470 471

        It returns back the token as well as information about the token
472
        holder and the services he/she can access (in json format).
473 474 475 476

        The tenant_name is optional and if it is given it must match the
        user's uuid.

477 478 479 480
        In case of error raise an AstakosClientException.

        """
        req_headers = {'content-type': 'application/json'}
481 482 483 484 485 486 487 488 489
        body = {'auth': {'token': {'id': self.token}}}
        if tenant_name is not None:
            body['auth']['tenantName'] = tenant_name
        req_body = parse_request(body, self.logger)
        r = self._call_astakos(self.api_tokens, headers=req_headers,
                               body=req_body, method="POST",
                               log_body=False)
        self._fill_endpoints(r)
        return r
490

491 492
    # --------------------------------------
    # do a GET to ``API_TOKENS`` with a token
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
493
    def validate_token(self, token_id, belongs_to=None):
494 495 496 497 498 499 500 501
        """ Validate a temporary access token (oath2)

        Keyword arguments:
        belongsTo         -- confirm that token belongs to tenant

        It returns back the token as well as information about the token
        holder.

Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
502
        The belongs_to is optional and if it is given it must be inside the
503 504 505 506 507 508
        token's scope.

        In case of error raise an AstakosClientException.

        """
        path = join_urls(self.api_tokens, str(token_id))
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
509 510
        if belongs_to is not None:
            params = {'belongsTo': belongs_to}
511 512 513
            path = '%s?%s' % (path, urllib.urlencode(params))
        return self._call_astakos(path, method="GET", log_body=False)

514
    # ----------------------------------
515
    # do a GET to ``API_QUOTAS``
516
    def get_quotas(self):
517 518 519 520 521 522
        """Get user's quotas

        In case of success return a dict of dicts with user's current quotas.
        Otherwise raise an AstakosClientException

        """
523
        return self._call_astakos(self.api_quotas)
524

525 526 527
    def _join_if_list(self, val):
        return ','.join(map(str, val)) if isinstance(val, list) else val

528
    # ----------------------------------
529
    # do a GET to ``API_SERVICE_QUOTAS``
530
    def service_get_quotas(self, user=None, project_id=None, project=None):
531 532 533
        """Get all quotas for resources associated with the service

        Keyword arguments:
534
        user    -- optionally, the uuid of a specific user, or a list thereof
535
        project_id -- optionally, the uuid of a specific project, or a list
536
                   thereof
537
        project -- backwards compatibility (replaced by "project_id")
538 539

        In case of success return a dict of dicts of dicts with current quotas
540
        for all users, or of a specified user, if user argument is set.
541 542 543
        Otherwise raise an AstakosClientException

        """
544
        project_id = project if project_id is None else project_id
545
        query = self.api_service_quotas
546
        filters = {}
547
        if user is not None:
548
            filters['user'] = self._join_if_list(user)
549 550
        if project_id is not None:
            filters['project'] = self._join_if_list(project_id)
551 552
        if filters:
            query += "?" + urllib.urlencode(filters)
553
        return self._call_astakos(query)
554

555 556
    # ----------------------------------
    # do a GET to ``API_SERVICE_PROJECT_QUOTAS``
557
    def service_get_project_quotas(self, project_id=None, project=None):
558 559 560
        """Get all project quotas for resources associated with the service

        Keyword arguments:
561 562
        project    -- optionally, the uuid of a specific project, or a list
                      thereof
563 564

        In case of success return a dict of dicts with current quotas
565 566
        for all projects, or of a specified project, if project argument is
        set. Otherwise raise an AstakosClientException
567 568

        """
569
        project_id = project if project_id is None else project_id
570
        query = self.api_service_project_quotas
571
        filters = {}
572 573
        if project_id is not None:
            filters['project'] = self._join_if_list(project_id)
574 575
        if filters:
            query += "?" + urllib.urlencode(filters)
576 577
        return self._call_astakos(query)

578
    # ----------------------------------
579
    # do a POST to ``API_COMMISSIONS``
580
    def _issue_commission(self, request):
581 582 583 584 585 586 587 588 589 590
        """Issue a commission

        Keyword arguments:
        request -- commision request (dict)

        In case of success return commission's id (int).
        Otherwise raise an AstakosClientException.

        """
        req_headers = {'content-type': 'application/json'}
591
        req_body = parse_request(request, self.logger)
592
        try:
593 594 595 596
            response = self._call_astakos(self.api_commissions,
                                          headers=req_headers,
                                          body=req_body,
                                          method="POST")
597 598 599 600 601 602 603 604 605
        except AstakosClientException as err:
            if err.status == 413:
                raise QuotaLimit(err.message, err.details)
            else:
                raise

        if "serial" in response:
            return response['serial']
        else:
606 607 608 609
            msg = "issue_commission_core request returned %s. " + \
                  "No serial found" % response
            self.logger.error(msg)
            raise AstakosClientException(msg)
610

611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661
    def _mk_user_provision(self, holder, source, resource, quantity):
        holder = "user:" + holder
        source = "project:" + source
        return {"holder": holder, "source": source,
                "resource": resource, "quantity": quantity}

    def _mk_project_provision(self, holder, resource, quantity):
        holder = "project:" + holder
        return {"holder": holder, "source": None,
                "resource": resource, "quantity": quantity}

    def mk_provisions(self, holder, source, resource, quantity):
        return [self._mk_user_provision(holder, source, resource, quantity),
                self._mk_project_provision(source, resource, quantity)]

    def issue_commission_generic(self, user_provisions, project_provisions,
                                 name="", force=False, auto_accept=False):
        """Issue commission (for multiple holder/source pairs)

        keyword arguments:
        user_provisions  -- dict mapping user holdings
                            (user, project, resource) to integer quantities
        project_provisions -- dict mapping project holdings
                              (project, resource) to integer quantities
        name        -- description of the commission (string)
        force       -- force this commission (boolean)
        auto_accept -- auto accept this commission (boolean)

        In case of success return commission's id (int).
        Otherwise raise an AstakosClientException.

        """
        request = {}
        request["force"] = force
        request["auto_accept"] = auto_accept
        request["name"] = name
        try:
            request["provisions"] = []
            for (holder, source, resource), quantity in \
                    user_provisions.iteritems():
                p = self._mk_user_provision(holder, source, resource, quantity)
                request["provisions"].append(p)
            for (holder, resource), quantity in project_provisions.iteritems():
                p = self._mk_project_provision(holder, resource, quantity)
                request["provisions"].append(p)
        except Exception as err:
            self.logger.error(str(err))
            raise BadValue(str(err))

        return self._issue_commission(request)

662
    def issue_one_commission(self, holder, provisions,
663
                             name="", force=False, auto_accept=False):
664 665 666 667
        """Issue one commission (with specific holder and source)

        keyword arguments:
        holder      -- user's id (string)
668
        provisions  -- (source, resource) mapping to quantity
669
        name        -- description of the commission (string)
670 671 672 673 674 675 676
        force       -- force this commission (boolean)
        auto_accept -- auto accept this commission (boolean)

        In case of success return commission's id (int).
        Otherwise raise an AstakosClientException.

        """
677
        check_input("issue_one_commission", self.logger,
678
                    holder=holder, provisions=provisions)
679 680 681 682

        request = {}
        request["force"] = force
        request["auto_accept"] = auto_accept
683
        request["name"] = name
684 685
        try:
            request["provisions"] = []
686
            for (source, resource), quantity in provisions.iteritems():
687 688
                ps = self.mk_provisions(holder, source, resource, quantity)
                request["provisions"].extend(ps)
689 690 691 692
        except Exception as err:
            self.logger.error(str(err))
            raise BadValue(str(err))

693
        return self._issue_commission(request)
694

695
    def issue_resource_reassignment(self, holder, provisions, name="",
696 697 698 699 700 701 702 703 704 705 706
                                    force=False, auto_accept=False):
        """Change resource assignment to another project
        """

        request = {}
        request["force"] = force
        request["auto_accept"] = auto_accept
        request["name"] = name

        try:
            request["provisions"] = []
707 708
            for key, quantity in provisions.iteritems():
                (from_source, to_source, resource) = key
709 710 711 712 713 714 715 716 717 718
                ps = self.mk_provisions(
                    holder, from_source, resource, -quantity)
                ps += self.mk_provisions(holder, to_source, resource, quantity)
                request["provisions"].extend(ps)
        except Exception as err:
            self.logger.error(str(err))
            raise BadValue(str(err))

        return self._issue_commission(request)

719
    # ----------------------------------
720
    # do a GET to ``API_COMMISSIONS``
721
    def get_pending_commissions(self):
722 723 724 725 726 727
        """Get Pending Commissions

        In case of success return a list of pending commissions' ids
        (list of integers)

        """
728
        return self._call_astakos(self.api_commissions)
729

730
    # ----------------------------------
731
    # do a GET to ``API_COMMISSIONS``/<serial>
732
    def get_commission_info(self, serial):
733 734 735 736 737 738
        """Get Description of a Commission

        Keyword arguments:
        serial  -- commission's id (int)

        In case of success return a dict of dicts containing
739
        informations (details) about the requested commission
740 741

        """
742
        check_input("get_commission_info", self.logger, serial=serial)
743

744 745
        path = self.api_commissions.rstrip('/') + "/" + str(serial)
        return self._call_astakos(path)
746

747
    # ----------------------------------
748
    # do a POST to ``API_COMMISSIONS``/<serial>/action"
749
    def commission_action(self, serial, action):
750
        """Perform a commission action
751 752 753 754 755 756 757 758

        Keyword arguments:
        serial  -- commission's id (int)
        action  -- action to perform, currently accept/reject (string)

        In case of success return nothing.

        """
759 760
        check_input("commission_action", self.logger,
                    serial=serial, action=action)
761

762
        path = self.api_commissions.rstrip('/') + "/" + str(serial) + "/action"
763
        req_headers = {'content-type': 'application/json'}
764
        req_body = parse_request({str(action): ""}, self.logger)
765 766
        self._call_astakos(path, headers=req_headers,
                           body=req_body, method="POST")
767

768
    def accept_commission(self, serial):
769
        """Accept a commission (see commission_action)"""
770
        self.commission_action(serial, "accept")
771

772
    def reject_commission(self, serial):
773
        """Reject a commission (see commission_action)"""
774
        self.commission_action(serial, "reject")
775

776
    # ----------------------------------
777
    # do a POST to ``API_COMMISSIONS_ACTION``
778
    def resolve_commissions(self, accept_serials, reject_serials):
779 780 781 782 783 784 785 786 787 788 789
        """Resolve multiple commissions at once

        Keyword arguments:
        accept_serials  -- commissions to accept (list of ints)
        reject_serials  -- commissions to reject (list of ints)

        In case of success return a dict of dicts describing which
        commissions accepted, which rejected and which failed to
        resolved.

        """
790 791 792
        check_input("resolve_commissions", self.logger,
                    accept_serials=accept_serials,
                    reject_serials=reject_serials)
793 794 795 796 797

        req_headers = {'content-type': 'application/json'}
        req_body = parse_request({"accept": accept_serials,
                                  "reject": reject_serials},
                                 self.logger)
798 799 800
        return self._call_astakos(self.api_commissions_action,
                                  headers=req_headers, body=req_body,
                                  method="POST")
801

802 803
    # ----------------------------
    # do a GET to ``API_PROJECTS``
804
    def get_projects(self, name=None, state=None, owner=None, mode=None):
805 806 807 808 809 810
        """Retrieve all accessible projects

        Arguments:
        name  -- filter by name (optional)
        state -- filter by state (optional)
        owner -- filter by owner (optional)
811 812
        mode  -- if value is 'member', return only active projects in which
                 the request user is an active member
813 814 815 816 817 818 819 820 821 822

        In case of success, return a list of project descriptions.
        """
        filters = {}
        if name is not None:
            filters["name"] = name
        if state is not None:
            filters["state"] = state
        if owner is not None:
            filters["owner"] = owner
823 824
        if mode is not None:
            filters["mode"] = mode
825 826 827
        path = self.api_projects
        if filters:
            path += "?" + urllib.urlencode(filters)
828
        req_headers = {'content-type': 'application/json'}
829
        return self._call_astakos(path, headers=req_headers)
830 831 832

    # -----------------------------------------
    # do a GET to ``API_PROJECTS``/<project_id>
833
    def get_project(self, project_id):
834 835 836 837 838 839 840
        """Retrieve project description, if accessible

        Arguments:
        project_id -- project identifier

        In case of success, return project description.
        """
841 842
        path = join_urls(self.api_projects, str(project_id))
        return self._call_astakos(path)
843 844 845

    # -----------------------------
    # do a POST to ``API_PROJECTS``
846
    def create_project(self, specs):
847 848 849 850 851 852 853 854 855
        """Submit application to create a new project

        Arguments:
        specs -- dict describing a project

        In case of success, return project and application identifiers.
        """
        req_headers = {'content-type': 'application/json'}
        req_body = parse_request(specs, self.logger)
856 857 858
        return self._call_astakos(self.api_projects,
                                  headers=req_headers, body=req_body,
                                  method="POST")
859 860

    # ------------------------------------------
861
    # do a PUT to ``API_PROJECTS``/<project_id>
862
    def modify_project(self, project_id, specs):
863 864 865 866 867 868 869 870
        """Submit application to modify an existing project

        Arguments:
        project_id -- project identifier
        specs      -- dict describing a project

        In case of success, return project and application identifiers.
        """
871
        path = join_urls(self.api_projects, str(project_id))
872 873
        req_headers = {'content-type': 'application/json'}
        req_body = parse_request(specs, self.logger)
874
        return self._call_astakos(path, headers=req_headers,
875
                                  body=req_body, method="PUT")
876 877 878

    # -------------------------------------------------
    # do a POST to ``API_PROJECTS``/<project_id>/action
879
    def project_action(self, project_id, action, reason=""):
880 881 882 883 884 885 886 887 888 889
        """Perform action on a project

        Arguments:
        project_id -- project identifier
        action     -- action to perform, one of "suspend", "unsuspend",
                      "terminate", "reinstate"
        reason     -- reason of performing the action

        In case of success, return nothing.
        """
890
        path = join_urls(self.api_projects, str(project_id))
891 892
        path = join_urls(path, "action")
        req_headers = {'content-type': 'application/json'}
893
        req_body = parse_request({action: {"reason": reason}}, self.logger)
894 895
        return self._call_astakos(path, headers=req_headers,
                                  body=req_body, method="POST")
896 897

    # -------------------------------------------------
898 899 900
    # do a POST to ``API_PROJECTS``/<project_id>/action
    def application_action(self, project_id, app_id, action, reason=""):
        """Perform action on a project application
901 902

        Arguments:
903 904 905 906 907
        project_id -- project identifier
        app_id     -- application identifier
        action     -- action to perform, one of "approve", "deny",
                      "dismiss", "cancel"
        reason     -- reason of performing the action
908 909 910

        In case of success, return nothing.
        """
911
        path = join_urls(self.api_projects, str(project_id))
912 913
        path = join_urls(path, "action")
        req_headers = {'content-type': 'application/json'}
914
        req_body = parse_request({action: {
Giorgos Korfiatis's avatar
Giorgos Korfiatis committed
915 916
            "reasons": reason,
            "app_id": app_id}}, self.logger)
917 918
        return self._call_astakos(path, headers=req_headers,
                                  body=req_body, method="POST")
919 920 921

    # -------------------------------
    # do a GET to ``API_MEMBERSHIPS``
922
    def get_memberships(self, project_id=None, project=None):
923 924 925
        """Retrieve all accessible memberships

        Arguments:
926 927
        project_id -- filter by project (optional)
        project    -- backwards compatibility
928 929 930

        In case of success, return a list of membership descriptions.
        """
931
        project_id = project if project_id is None else project_id
932
        req_headers = {'content-type': 'application/json'}
933
        filters = {}
934 935
        if project_id is not None:
            filters["project"] = project_id
936 937 938 939
        path = self.api_memberships
        if filters:
            path += '?' + urllib.urlencode(filters)
        return self._call_astakos(path, headers=req_headers)
940 941 942

    # -----------------------------------------
    # do a GET to ``API_MEMBERSHIPS``/<memb_id>
943
    def get_membership(self, memb_id):
944 945 946 947 948 949 950
        """Retrieve membership description, if accessible

        Arguments:
        memb_id -- membership identifier

        In case of success, return membership description.
        """
951 952
        path = join_urls(self.api_memberships, str(memb_id))
        return self._call_astakos(path)
953 954 955

    # -------------------------------------------------
    # do a POST to ``API_MEMBERSHIPS``/<memb_id>/action
956