Revision 2556cf45

b/Changelog
28 28

  
29 29
  * Improve recording of project, application, and membership actions.
30 30

  
31
* Implement API calls for projects.
32

  
31 33
Cyclades
32 34
--------
33 35

  
b/docs/dev-guide.rst
46 46

  
47 47
    Resource and Quota API <quota-api-guide.rst>
48 48

  
49
Project API
50
===========
51

  
52
.. toctree::
53
    :maxdepth: 2
54

  
55
    Project API <project-api-guide.rst>
49 56

  
50 57
Compute Service API (Cyclades)
51 58
==============================
b/docs/project-api-guide.rst
1
Projects
2
--------
3

  
4
Astakos allows users to create *projects*. Through a project, one can ask for
5
additional resources on the virtual infrastructure for a certain amount of
6
time. All users admitted to the project gain access to these resources.
7

  
8

  
9
Retrieve List of Projects
10
.........................
11

  
12
**GET** /account/v1.0/projects
13

  
14
Returns all accessible projects. See below.
15

  
16
====================  =========================
17
Request Header Name   Value
18
====================  =========================
19
X-Auth-Token          User authentication token
20
====================  =========================
21

  
22
Request can specify a filter.
23

  
24
**Example Request**:
25

  
26
.. code-block:: javascript
27

  
28
  {
29
      "filter": {
30
          "state": ["active", "suspended"],
31
          "owner": [uuid]
32
      }
33
  }
34

  
35
**Response Codes**:
36

  
37
======  =====================
38
Status  Description
39
======  =====================
40
200     Success
41
400     Bad Request
42
401     Unauthorized (Missing token)
43
500     Internal Server Error
44
======  =====================
45

  
46
**Example Successful Response**:
47

  
48
List of project details. See below.
49

  
50
Retrieve a Project
51
..................
52

  
53
**GET** /account/v1.0/projects/<proj_id>
54

  
55
====================  =========================
56
Request Header Name   Value
57
====================  =========================
58
X-Auth-Token          User authentication token
59
====================  =========================
60

  
61
A project is accessible when the request user is admin, project owner,
62
applicant or member, or the project is active.
63

  
64
**Response Codes**:
65

  
66
======  ============================
67
Status  Description
68
======  ============================
69
200     Success
70
401     Unauthorized (Missing token)
71
403     Forbidden
72
404     Not Found
73
500     Internal Server Error
74
======  ============================
75

  
76
**Example Successful Response**:
77

  
78
.. code-block:: javascript
79

  
80
  {
81
      "id": proj_id,
82
      "application": app_id,
83
      "state": "pending" | "active" | "denied" | "dismissed" | "cancelled" | "suspended" | "terminated",
84
      "creation_date": "2013-06-26T11:48:06.579100+00:00",
85
      "name": "name",
86
      "owner": uuid,
87
      "homepage": homepage or null,
88
      "description": description or null,
89
      "start_date": date,
90
      "end_date": date,
91
      "join_policy": "auto" | "moderated" | "closed",
92
      "leave_policy": "auto" | "moderated" | "closed",
93
      "max_members": int or null
94
      "resources": {"cyclades.vm": {"project_capacity": int or null,
95
                                    "member_capacity": int
96
                                   }
97
                   }
98
      # only if request user is admin or project owner:
99
      "comments": comments,
100
      "pending_application": last pending app id or null,
101
      "deactivation_date": date  # if applicable
102
  }
103

  
104
Create a Project
105
................
106

  
107
**POST** /account/v1.0/projects
108

  
109
====================  =========================
110
Request Header Name   Value
111
====================  =========================
112
X-Auth-Token          User authentication token
113
====================  =========================
114

  
115
**Example Request**:
116

  
117
.. code-block:: javascript
118

  
119
  {
120
      "name": name,
121
      "owner": uuid,  # if omitted, request user assumed
122
      "homepage": homepage,  # optional
123
      "description": description,  # optional
124
      "comments": comments,  # optional
125
      "start_date": date,  # optional
126
      "end_date": date,
127
      "join_policy": "auto" | "moderated" | "closed",  # default: "moderated"
128
      "leave_policy": "auto" | "moderated" | "closed",  # default: "auto"
129
      "resources": {"cyclades.vm": {"project_capacity": int or null,
130
                                    "member_capacity": int
131
                                   }
132
                   }
133
  }
134

  
135
**Response Codes**:
136

  
137
======  ============================
138
Status  Description
139
======  ============================
140
201     Created
141
400     Bad Request
142
401     Unauthorized (Missing token)
143
403     Forbidden
144
409     Conflict
145
500     Internal Server Error
146
======  ============================
147

  
148
**Example Successful Response**:
149

  
150
.. code-block:: javascript
151

  
152
  {
153
      "id": project_id,
154
      "application": application_id
155
  }
156

  
157

  
158
Modify a Project
159
................
160

  
161
**POST** /account/v1.0/projects/<proj_id>
162

  
163
====================  =========================
164
Request Header Name   Value
165
====================  =========================
166
X-Auth-Token          User authentication token
167
====================  =========================
168

  
169

  
170
**Example Request**:
171

  
172
As above.
173

  
174
**Response Codes**:
175

  
176
======  ============================
177
Status  Description
178
======  ============================
179
201     Created
180
400     Bad Request
181
401     Unauthorized (Missing token)
182
403     Forbidden
183
404     Not Found
184
409     Conflict
185
500     Internal Server Error
186
======  ============================
187

  
188
**Example Successful Response**:
189

  
190
As above.
191

  
192
Take Action on a Project
193
........................
194

  
195
**POST** /account/v1.0/projects/<proj_id>/action
196

  
197
====================  =========================
198
Request Header Name   Value
199
====================  =========================
200
X-Auth-Token          User authentication token
201
====================  =========================
202

  
203
**Example Request**:
204

  
205
.. code-block:: javascript
206

  
207
  {
208
      <action>: "reason"
209
  }
210

  
211
<action> can be: "suspend", "unsuspend", "terminate", "reinstate"
212

  
213
**Response Codes**:
214

  
215
======  ============================
216
Status  Description
217
======  ============================
218
200     Success
219
400     Bad Request
220
401     Unauthorized (Missing token)
221
403     Forbidden
222
404     Not Found
223
409     Conflict
224
500     Internal Server Error
225
======  ============================
226

  
227
Retrieve List of Applications
228
.............................
229

  
230
**GET** /account/v1.0/projects/apps
231

  
232
====================  =========================
233
Request Header Name   Value
234
====================  =========================
235
X-Auth-Token          User authentication token
236
====================  =========================
237

  
238
Get all accessible applications. See below.
239

  
240
**Example optional request**
241

  
242
.. code-block:: javascript
243

  
244
  {
245
      "project": <project_id>
246
  }
247

  
248
**Response Codes**:
249

  
250
======  ============================
251
Status  Description
252
======  ============================
253
200     Success
254
400     Bad Request
255
401     Unauthorized (Missing token)
256
500     Internal Server Error
257
======  ============================
258

  
259
**Example Successful Response**:
260

  
261
List of application details. See below.
262

  
263
Retrieve an Application
264
.......................
265

  
266
**GET** /account/v1.0/projects/apps/<app_id>
267

  
268
====================  =========================
269
Request Header Name   Value
270
====================  =========================
271
X-Auth-Token          User authentication token
272
====================  =========================
273

  
274
An application is accessible when the request user is admin or the
275
application owner/applicant.
276

  
277
**Response Codes**:
278

  
279
======  ============================
280
Status  Description
281
======  ============================
282
200     Success
283
401     Unauthorized (Missing token)
284
403     Forbidden
285
404     Not Found
286
500     Internal Server Error
287
======  ============================
288

  
289
**Example Successful Response**
290

  
291
.. code-block:: javascript
292

  
293
  {
294
      "id": app_id,
295
      "project": project_id,
296
      "state": "pending" | "approved" | "replaced" | "denied" | "dismissed" | "cancelled",
297
      "name": "name",
298
      "owner": uuid,
299
      "applicant": uuid,
300
      "homepage": homepage or null,
301
      "description": description or null,
302
      "start_date": date,
303
      "end_date": date,
304
      "join_policy": "auto" | "moderated" | "closed",
305
      "leave_policy": "auto" | "moderated" | "closed",
306
      "max_members": int or null
307
      "comments": comments,
308
      "resources": {"cyclades.vm": {"project_capacity": int or null,
309
                                    "member_capacity": int
310
                                   }
311
                   }
312
  }
313

  
314
Take Action on an Application
315
.............................
316

  
317
**POST** /account/v1.0/projects/apps/<app_id>/action
318

  
319
====================  ============================
320
Request Header Name   Value
321
====================  ============================
322
X-Auth-Token          User authentication token
323
====================  ============================
324

  
325
**Example Request**:
326

  
327
.. code-block:: javascript
328

  
329
  {
330
      <action>: "reason"
331
  }
332

  
333
<action> can be one of "approve", "deny", "dismiss", "cancel".
334

  
335
**Response Codes**:
336

  
337
======  ============================
338
Status  Description
339
======  ============================
340
200     Success
341
400     Bad Request
342
401     Unauthorized (Missing token)
343
403     Forbidden
344
404     Not Found
345
409     Conflict
346
500     Internal Server Error
347
======  ============================
348

  
349
Retrieve List of Memberships
350
............................
351

  
352
**GET** /account/v1.0/projects/memberships
353

  
354
====================  ============================
355
Request Header Name   Value
356
====================  ============================
357
X-Auth-Token          User authentication token
358
====================  ============================
359

  
360
Get all accessible memberships. See below.
361

  
362
**Example Optional Request**
363

  
364
.. code-block:: javascript
365

  
366
  {
367
      "project": <proj_id>
368
  }
369

  
370
**Response Codes**:
371

  
372
======  ============================
373
Status  Description
374
======  ============================
375
200     Success
376
400     Bad Request
377
401     Unauthorized (Missing token)
378
500     Internal Server Error
379
======  ============================
380

  
381
**Example Successful Response**
382

  
383
List of memberships. See below.
384

  
385
Retrieve a Membership
386
.....................
387

  
388
**GET** /account/v1.0/projects/memberships/<memb_id>
389

  
390
====================  ============================
391
Request Header Name   Value
392
====================  ============================
393
X-Auth-Token          User authentication token
394
====================  ============================
395

  
396
A membership is accessible if the request user is admin, project owner or
397
the member.
398

  
399
**Response Codes**:
400

  
401
======  ============================
402
Status  Description
403
======  ============================
404
200     Success
405
401     Unauthorized (Missing token)
406
403     Forbidden
407
404     Not Found
408
500     Internal Server Error
409
======  ============================
410

  
411
**Example Successful Response**
412

  
413
.. code-block:: javascript
414

  
415
  {
416
      "id": id,
417
      "user": uuid,
418
      "project": project_id,
419
      "state": "requested" | "accepted" | "leave_requested" | "suspended" | "rejected" | "cancelled" | "removed",
420
      "requested": last_request_date,
421
      "accepted": last_acceptance_date,
422
      "removed": last_removal_date,
423
      "allowed_actions": ["leave", "cancel", "accept", "reject", "remove"],
424
  }
425

  
426
Take Action on a Membership
427
...........................
428

  
429
**POST** /account/v1.0/projects/memberships/<memb_id>/action
430

  
431
====================  ============================
432
Request Header Name   Value
433
====================  ============================
434
X-Auth-Token          User authentication token
435
====================  ============================
436

  
437
**Example Request**
438

  
439
.. code-block:: javascript
440

  
441
  {
442
      <action>: "reason"
443
  }
444

  
445
<action> can be one of: "leave", "cancel", "accept", "reject", "remove"
446

  
447
**Response Codes**:
448

  
449
======  ============================
450
Status  Description
451
======  ============================
452
200     Success
453
400     Bad Request
454
401     Unauthorized (Missing token)
455
403     Forbidden
456
404     Not Found
457
409     Conflict
458
500     Internal Server Error
459
======  ============================
460

  
461
Create a Membership
462
...................
463

  
464
**POST** /account/v1.0/projects/memberships
465

  
466
====================  ============================
467
Request Header Name   Value
468
====================  ============================
469
X-Auth-Token          User authentication token
470
====================  ============================
471

  
472
**Example Requests**
473

  
474
.. code-block:: javascript
475

  
476
  {
477
      "join": {
478
          "project": proj_id
479
      }
480
  }
481

  
482
.. code-block:: javascript
483

  
484
  {
485
      "enroll": {
486
          "project": proj_id,
487
          "user": "user@example.org"
488
      }
489
  }
490

  
491
**Response Codes**:
492

  
493
======  ============================
494
Status  Description
495
======  ============================
496
200     Success
497
400     Bad Request
498
401     Unauthorized (Missing token)
499
403     Forbidden
500
409     Conflict
501
500     Internal Server Error
502
======  ============================
503

  
504
**Example Response**
505

  
506
.. code-block:: javascript
507

  
508
  {
509
      "id": membership_id
510
  }
b/snf-astakos-app/astakos/api/projects.py
1
# Copyright 2013 GRNET S.A. All rights reserved.
2
#
3
# Redistribution and use in source and binary forms, with or
4
# without modification, are permitted provided that the following
5
# conditions are met:
6
#
7
#   1. Redistributions of source code must retain the above
8
#      copyright notice, this list of conditions and the following
9
#      disclaimer.
10
#
11
#   2. Redistributions in binary form must reproduce the above
12
#      copyright notice, this list of conditions and the following
13
#      disclaimer in the documentation and/or other materials
14
#      provided with the distribution.
15
#
16
# THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS
17
# OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
18
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
19
# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR
20
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22
# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
23
# USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
24
# AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
25
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
26
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
27
# POSSIBILITY OF SUCH DAMAGE.
28
#
29
# The views and conclusions contained in the software and
30
# documentation are those of the authors and should not be
31
# interpreted as representing official policies, either expressed
32
# or implied, of GRNET S.A.
33

  
34
from django.utils import simplejson as json
35
from django.views.decorators.csrf import csrf_exempt
36
from django.http import HttpResponse
37
from django.db.models import Q
38

  
39
from snf_django.lib.db.transaction import commit_on_success_strict
40
from astakos.api.util import json_response
41

  
42
from snf_django.lib import api
43
from snf_django.lib.api import faults
44
from .util import user_from_token, invert_dict, read_json_body
45

  
46
from astakos.im import functions
47
from astakos.im.models import (
48
    AstakosUser, Project, ProjectApplication, ProjectMembership,
49
    ProjectResourceGrant, ProjectLog, ProjectMembershipLog)
50
import synnefo.util.date as date_util
51

  
52

  
53
MEMBERSHIP_POLICY_SHOW = {
54
    functions.AUTO_ACCEPT_POLICY: "auto",
55
    functions.MODERATED_POLICY:   "moderated",
56
    functions.CLOSED_POLICY:      "closed",
57
}
58

  
59
MEMBERSHIP_POLICY = invert_dict(MEMBERSHIP_POLICY_SHOW)
60

  
61
APPLICATION_STATE_SHOW = {
62
    ProjectApplication.PENDING:   "pending",
63
    ProjectApplication.APPROVED:  "approved",
64
    ProjectApplication.REPLACED:  "replaced",
65
    ProjectApplication.DENIED:    "denied",
66
    ProjectApplication.DISMISSED: "dismissed",
67
    ProjectApplication.CANCELLED: "cancelled",
68
}
69

  
70
PROJECT_STATE_SHOW = {
71
    Project.O_PENDING:    "pending",
72
    Project.O_ACTIVE:     "active",
73
    Project.O_DENIED:     "denied",
74
    Project.O_DISMISSED:  "dismissed",
75
    Project.O_CANCELLED:  "cancelled",
76
    Project.O_SUSPENDED:  "suspended",
77
    Project.O_TERMINATED: "terminated",
78
}
79

  
80
PROJECT_STATE = invert_dict(PROJECT_STATE_SHOW)
81

  
82
MEMBERSHIP_STATE_SHOW = {
83
    ProjectMembership.REQUESTED:       "requested",
84
    ProjectMembership.ACCEPTED:        "accepted",
85
    ProjectMembership.LEAVE_REQUESTED: "leave_requested",
86
    ProjectMembership.USER_SUSPENDED:  "suspended",
87
    ProjectMembership.REJECTED:        "rejected",
88
    ProjectMembership.CANCELLED:       "cancelled",
89
    ProjectMembership.REMOVED:         "removed",
90
}
91

  
92

  
93
def _application_details(application, all_grants):
94
    grants = all_grants.get(application.id, [])
95
    resources = {}
96
    for grant in grants:
97
        resources[grant.resource.name] = {
98
            "member_capacity": grant.member_capacity,
99
            "project_capacity": grant.project_capacity,
100
        }
101

  
102
    join_policy = MEMBERSHIP_POLICY_SHOW[application.member_join_policy]
103
    leave_policy = MEMBERSHIP_POLICY_SHOW[application.member_leave_policy]
104

  
105
    d = {
106
        "name": application.name,
107
        "owner": application.owner.uuid,
108
        "applicant": application.applicant.uuid,
109
        "homepage": application.homepage,
110
        "description": application.description,
111
        "start_date": application.start_date,
112
        "end_date": application.end_date,
113
        "join_policy": join_policy,
114
        "leave_policy": leave_policy,
115
        "max_members": application.limit_on_members_number,
116
        "resources": resources,
117
    }
118
    return d
119

  
120

  
121
def get_applications_details(applications):
122
    grants = ProjectResourceGrant.objects.grants_per_app(applications)
123

  
124
    l = []
125
    for application in applications:
126
        d = {
127
            "id": application.id,
128
            "project": application.chain_id,
129
            "state": APPLICATION_STATE_SHOW[application.state],
130
            "comments": application.comments,
131
        }
132
        d.update(_application_details(application, grants))
133
        l.append(d)
134
    return l
135

  
136

  
137
def get_application_details(application):
138
    return get_applications_details([application])[0]
139

  
140

  
141
def get_projects_details(projects, request_user=None):
142
    pendings = ProjectApplication.objects.pending_per_project(projects)
143
    applications = [p.application for p in projects]
144
    grants = ProjectResourceGrant.objects.grants_per_app(applications)
145
    deactivations = ProjectLog.objects.last_deactivations(projects)
146

  
147
    l = []
148
    for project in projects:
149
        application = project.application
150
        d = {
151
            "id": project.id,
152
            "application": application.id,
153
            "state": PROJECT_STATE_SHOW[project.overall_state()],
154
            "creation_date": project.creation_date,
155
        }
156
        check = functions.project_check_allowed
157
        if check(project, request_user,
158
                 level=functions.APPLICANT_LEVEL, silent=True):
159
            d["comments"] = application.comments
160
            pending = pendings.get(project.id)
161
            d["pending_application"] = pending.id if pending else None
162
            deact = deactivations.get(project.id)
163
            if deact is not None:
164
                d["deactivation_date"] = deact.date
165
        d.update(_application_details(application, grants))
166
        l.append(d)
167
    return l
168

  
169

  
170
def get_project_details(project, request_user=None):
171
    return get_projects_details([project], request_user=request_user)[0]
172

  
173

  
174
def get_memberships_details(memberships, request_user):
175
    all_logs = ProjectMembershipLog.objects.last_logs(memberships)
176

  
177
    l = []
178
    for membership in memberships:
179
        logs = all_logs.get(membership.id, {})
180
        dates = {}
181
        for s, log in logs.iteritems():
182
            dates[MEMBERSHIP_STATE_SHOW[s]] = log.date
183

  
184
        allowed_actions = functions.membership_allowed_actions(
185
            membership, request_user)
186
        d = {
187
            "id": membership.id,
188
            "user": membership.person.uuid,
189
            "project": membership.project_id,
190
            "state": MEMBERSHIP_STATE_SHOW[membership.state],
191
            "allowed_actions": allowed_actions,
192
        }
193
        d.update(dates)
194
        l.append(d)
195
    return l
196

  
197

  
198
def get_membership_details(membership, request_user):
199
    return get_memberships_details([membership], request_user)[0]
200

  
201

  
202
def _query(attr):
203
    def inner(val):
204
        kw = attr + "__in" if isinstance(val, list) else attr
205
        return Q(**{kw: val})
206
    return inner
207

  
208

  
209
def _get_project_state(val):
210
    try:
211
        return PROJECT_STATE[val]
212
    except KeyError:
213
        raise faults.BadRequest("Unrecognized state %s" % val)
214

  
215

  
216
def _project_state_query(val):
217
    if isinstance(val, list):
218
        states = [_get_project_state(v) for v in val]
219
        return Project.o_states_q(states)
220
    return Project.o_state_q(_get_project_state(val))
221

  
222

  
223
PROJECT_QUERY = {
224
    "name": _query("application__name"),
225
    "owner": _query("application__owner__uuid"),
226
    "state": _project_state_query,
227
}
228

  
229

  
230
def make_project_query(filters):
231
    qs = Q()
232
    for attr, val in filters.iteritems():
233
        try:
234
            _q = PROJECT_QUERY[attr]
235
        except KeyError:
236
            raise faults.BadRequest("Unrecognized filter %s" % attr)
237
        qs &= _q(val)
238
    return qs
239

  
240

  
241
class ExceptionHandler(object):
242
    def __enter__(self):
243
        pass
244

  
245
    EXCS = {
246
        functions.ProjectNotFound:   faults.ItemNotFound,
247
        functions.ProjectForbidden:  faults.Forbidden,
248
        functions.ProjectBadRequest: faults.BadRequest,
249
        functions.ProjectConflict:   faults.Conflict,
250
    }
251

  
252
    def __exit__(self, exc_type, value, traceback):
253
        if value is not None:  # exception
254
            try:
255
                e = self.EXCS[exc_type]
256
            except KeyError:
257
                return False  # reraise
258
            raise e(value.message)
259

  
260

  
261
@csrf_exempt
262
def projects(request):
263
    method = request.method
264
    if method == "GET":
265
        return get_projects(request)
266
    elif method == "POST":
267
        return create_project(request)
268
    return api.api_method_not_allowed(request)
269

  
270

  
271
@api.api_method(http_method="GET", token_required=True, user_required=False)
272
@user_from_token
273
@commit_on_success_strict()
274
def get_projects(request):
275
    user = request.user
276
    input_data = read_json_body(request, default={})
277
    filters = input_data.get("filter", {})
278
    query = make_project_query(filters)
279
    projects = _get_projects(query, request_user=user)
280
    data = get_projects_details(projects, request_user=user)
281
    return json_response(data)
282

  
283

  
284
def _get_projects(query, request_user=None):
285
    projects = Project.objects.filter(query)
286

  
287
    if not request_user.is_project_admin():
288
        membs = request_user.projectmembership_set.any_accepted()
289
        memb_projects = membs.values_list("project", flat=True)
290
        is_memb = Q(id__in=memb_projects)
291
        owned = (Q(application__owner=request_user) |
292
                 Q(application__applicant=request_user))
293
        active = Project.o_state_q(Project.O_ACTIVE)
294
        projects = projects.filter(is_memb | owned | active)
295
    return projects.select_related(
296
        "application", "application__owner", "application__applicant")
297

  
298

  
299
@api.api_method(http_method="POST", token_required=True, user_required=False)
300
@user_from_token
301
@commit_on_success_strict()
302
def create_project(request):
303
    user = request.user
304
    data = request.raw_post_data
305
    app_data = json.loads(data)
306
    return submit_application(app_data, user, project_id=None)
307

  
308

  
309
@csrf_exempt
310
def project(request, project_id):
311
    method = request.method
312
    if method == "GET":
313
        return get_project(request, project_id)
314
    if method == "POST":
315
        return modify_project(request, project_id)
316
    return api.api_method_not_allowed(request)
317

  
318

  
319
@api.api_method(http_method="GET", token_required=True, user_required=False)
320
@user_from_token
321
@commit_on_success_strict()
322
def get_project(request, project_id):
323
    user = request.user
324
    with ExceptionHandler():
325
        project = _get_project(project_id, request_user=user)
326
    data = get_project_details(project, user)
327
    return json_response(data)
328

  
329

  
330
def _get_project(project_id, request_user=None):
331
    project = functions.get_project_by_id(project_id)
332
    functions.project_check_allowed(
333
        project, request_user, level=functions.ANY_LEVEL)
334
    return project
335

  
336

  
337
@api.api_method(http_method="POST", token_required=True, user_required=False)
338
@user_from_token
339
@commit_on_success_strict()
340
def modify_project(request, project_id):
341
    user = request.user
342
    data = request.raw_post_data
343
    app_data = json.loads(data)
344
    return submit_application(app_data, user, project_id=project_id)
345

  
346

  
347
def _get_date(d, key):
348
    date_str = d.get(key)
349
    if date_str is not None:
350
        try:
351
            return date_util.isoparse(date_str)
352
        except:
353
            raise faults.BadRequest("Invalid %s" % key)
354
    else:
355
        return None
356

  
357

  
358
def _get_maybe_string(d, key):
359
    value = d.get(key)
360
    if value is not None and not isinstance(value, basestring):
361
        raise faults.BadRequest("%s must be string" % key)
362
    return value
363

  
364

  
365
def submit_application(app_data, user, project_id=None):
366
    uuid = app_data.get("owner")
367
    if uuid is None:
368
        owner = user
369
    else:
370
        try:
371
            owner = AstakosUser.objects.get(uuid=uuid, email_verified=True)
372
        except AstakosUser.DoesNotExist:
373
            raise faults.BadRequest("User does not exist.")
374

  
375
    try:
376
        name = app_data["name"]
377
    except KeyError:
378
        raise faults.BadRequest("Name missing.")
379

  
380
    join_policy = app_data.get("join_policy", "moderated")
381
    try:
382
        join_policy = MEMBERSHIP_POLICY[join_policy]
383
    except KeyError:
384
        raise faults.BadRequest("Invalid join policy")
385

  
386
    leave_policy = app_data.get("leave_policy", "auto")
387
    try:
388
        leave_policy = MEMBERSHIP_POLICY[leave_policy]
389
    except KeyError:
390
        raise faults.BadRequest("Invalid leave policy")
391

  
392
    start_date = _get_date(app_data, "start_date")
393
    end_date = _get_date(app_data, "end_date")
394

  
395
    if end_date is None:
396
        raise faults.BadRequest("Missing end date")
397

  
398
    max_members = app_data.get("max_members")
399
    if max_members is not None and \
400
            (not isinstance(max_members, (int, long)) or max_members < 0):
401
        raise faults.BadRequest("Invalid max_members")
402

  
403
    homepage = _get_maybe_string(app_data, "homepage")
404
    description = _get_maybe_string(app_data, "description")
405
    comments = _get_maybe_string(app_data, "comments")
406
    resources = app_data.get("resources", {})
407

  
408
    submit = functions.submit_application
409
    with ExceptionHandler():
410
        application = submit(
411
            owner=owner,
412
            name=name,
413
            project_id=project_id,
414
            homepage=homepage,
415
            description=description,
416
            start_date=start_date,
417
            end_date=end_date,
418
            member_join_policy=join_policy,
419
            member_leave_policy=leave_policy,
420
            limit_on_members_number=max_members,
421
            comments=comments,
422
            resources=resources,
423
            request_user=user)
424

  
425
    result = {"application": application.id,
426
              "id": application.chain_id
427
              }
428
    return json_response(result, status_code=201)
429

  
430

  
431
def get_action(actions, input_data):
432
    action = None
433
    data = None
434
    for option in actions.keys():
435
        if option in input_data:
436
            if action:
437
                raise faults.BadRequest("Multiple actions not supported")
438
            else:
439
                action = option
440
                data = input_data[action]
441
    if not action:
442
        raise faults.BadRequest("No recognized action")
443
    return actions[action], data
444

  
445

  
446
PROJECT_ACTION = {
447
    "terminate": functions.terminate,
448
    "suspend":   functions.suspend,
449
    "unsuspend": functions.unsuspend,
450
    "reinstate": functions.reinstate,
451
}
452

  
453

  
454
@csrf_exempt
455
@api.api_method(http_method="POST", token_required=True, user_required=False)
456
@user_from_token
457
@commit_on_success_strict()
458
def project_action(request, project_id):
459
    user = request.user
460
    data = request.raw_post_data
461
    input_data = json.loads(data)
462

  
463
    func, action_data = get_action(PROJECT_ACTION, input_data)
464
    with ExceptionHandler():
465
        func(project_id, request_user=user, reason=action_data)
466
    return HttpResponse()
467

  
468

  
469
@csrf_exempt
470
def applications(request):
471
    method = request.method
472
    if method == "GET":
473
        return get_applications(request)
474
    return api.api_method_not_allowed(request)
475

  
476

  
477
def make_application_query(input_data):
478
    project_id = input_data.get("project")
479
    if project_id is not None:
480
        if not isinstance(project_id, (int, long)):
481
            raise faults.BadRequest("'project' must be integer")
482
        return Q(chain=project_id)
483
    return Q()
484

  
485

  
486
@api.api_method(http_method="GET", token_required=True, user_required=False)
487
@user_from_token
488
@commit_on_success_strict()
489
def get_applications(request):
490
    user = request.user
491
    input_data = read_json_body(request, default={})
492
    query = make_application_query(input_data)
493
    apps = _get_applications(query, request_user=user)
494
    data = get_applications_details(apps)
495
    return json_response(data)
496

  
497

  
498
def _get_applications(query, request_user=None):
499
    apps = ProjectApplication.objects.filter(query)
500

  
501
    if not request_user.is_project_admin():
502
        owned = (Q(owner=request_user) |
503
                 Q(applicant=request_user))
504
        apps = apps.filter(owned)
505
    return apps.select_related()
506

  
507

  
508
@csrf_exempt
509
@api.api_method(http_method="GET", token_required=True, user_required=False)
510
@user_from_token
511
@commit_on_success_strict()
512
def application(request, app_id):
513
    user = request.user
514
    with ExceptionHandler():
515
        application = _get_application(app_id, user)
516
    data = get_application_details(application)
517
    return json_response(data)
518

  
519

  
520
def _get_application(app_id, request_user=None):
521
    application = functions.get_application(app_id)
522
    functions.app_check_allowed(
523
        application, request_user, level=functions.APPLICANT_LEVEL)
524
    return application
525

  
526

  
527
APPLICATION_ACTION = {
528
    "approve": functions.approve_application,
529
    "deny": functions.deny_application,
530
    "dismiss": functions.dismiss_application,
531
    "cancel": functions.cancel_application,
532
}
533

  
534

  
535
@csrf_exempt
536
@api.api_method(http_method="POST", token_required=True, user_required=False)
537
@user_from_token
538
@commit_on_success_strict()
539
def application_action(request, app_id):
540
    user = request.user
541
    data = request.raw_post_data
542
    input_data = json.loads(data)
543

  
544
    func, action_data = get_action(APPLICATION_ACTION, input_data)
545
    with ExceptionHandler():
546
        func(app_id, request_user=user, reason=action_data)
547

  
548
    return HttpResponse()
549

  
550

  
551
@csrf_exempt
552
def memberships(request):
553
    method = request.method
554
    if method == "GET":
555
        return get_memberships(request)
556
    elif method == "POST":
557
        return post_memberships(request)
558
    return api.api_method_not_allowed(request)
559

  
560

  
561
def make_membership_query(input_data):
562
    project_id = input_data.get("project")
563
    if project_id is not None:
564
        if not isinstance(project_id, (int, long)):
565
            raise faults.BadRequest("'project' must be integer")
566
        return Q(project=project_id)
567
    return Q()
568

  
569

  
570
@api.api_method(http_method="GET", token_required=True, user_required=False)
571
@user_from_token
572
@commit_on_success_strict()
573
def get_memberships(request):
574
    user = request.user
575
    input_data = read_json_body(request, default={})
576
    query = make_membership_query(input_data)
577
    memberships = _get_memberships(query, request_user=user)
578
    data = get_memberships_details(memberships, user)
579
    return json_response(data)
580

  
581

  
582
def _get_memberships(query, request_user=None):
583
    memberships = ProjectMembership.objects
584
    if not request_user.is_project_admin():
585
        owned = Q(project__application__owner=request_user)
586
        memb = Q(person=request_user)
587
        memberships = memberships.filter(owned | memb)
588

  
589
    return memberships.select_related(
590
        "project", "project__application",
591
        "project__application__owner", "project__application__applicant",
592
        "person").filter(query)
593

  
594

  
595
def join_project(data, request_user):
596
    project_id = data.get("project")
597
    with ExceptionHandler():
598
        membership = functions.join_project(project_id, request_user)
599
    response = {"id": membership.id}
600
    return json_response(response)
601

  
602

  
603
def enroll_user(data, request_user):
604
    project_id = data.get("project")
605
    email = data.get("user")
606
    with ExceptionHandler():
607
        m = functions.enroll_member_by_email(
608
            project_id, email, request_user)
609

  
610
    response = {"id": m.id}
611
    return json_response(response)
612

  
613

  
614
MEMBERSHIPS_ACTION = {
615
    "join":   join_project,
616
    "enroll": enroll_user,
617
}
618

  
619

  
620
@api.api_method(http_method="POST", token_required=True, user_required=False)
621
@user_from_token
622
@commit_on_success_strict()
623
def post_memberships(request):
624
    user = request.user
625
    data = request.raw_post_data
626
    input_data = json.loads(data)
627
    func, action_data = get_action(MEMBERSHIPS_ACTION, input_data)
628
    return func(action_data, user)
629

  
630

  
631
@api.api_method(http_method="GET", token_required=True, user_required=False)
632
@user_from_token
633
@commit_on_success_strict()
634
def membership(request, memb_id):
635
    user = request.user
636
    with ExceptionHandler():
637
        m = _get_membership(memb_id, request_user=user)
638
    data = get_membership_details(m, user)
639
    return json_response(data)
640

  
641

  
642
def _get_membership(memb_id, request_user=None):
643
    membership = functions.get_membership_by_id(memb_id)
644
    functions.membership_check_allowed(membership, request_user)
645
    return membership
646

  
647

  
648
MEMBERSHIP_ACTION = {
649
    "leave":  functions.leave_project,
650
    "cancel": functions.cancel_membership,
651
    "accept": functions.accept_membership,
652
    "reject": functions.reject_membership,
653
    "remove": functions.remove_membership,
654
}
655

  
656

  
657
@csrf_exempt
658
@api.api_method(http_method="POST", token_required=True, user_required=False)
659
@user_from_token
660
@commit_on_success_strict()
661
def membership_action(request, memb_id):
662
    user = request.user
663
    input_data = read_json_body(request, default={})
664
    func, action_data = get_action(MEMBERSHIP_ACTION, input_data)
665
    with ExceptionHandler():
666
        func(memb_id, user, reason=action_data)
667
    return HttpResponse()
b/snf-astakos-app/astakos/api/urls.py
58 58
    url(r'^service/user_catalogs/?$', 'get_uuid_displayname_catalogs'),
59 59
)
60 60

  
61
astakos_account_v1_0 += patterns(
62
    'astakos.api.projects',
63
    url(r'^projects/?$', 'projects', name='api_projects'),
64
    url(r'^projects/(?P<project_id>\d+)/?$', 'project', name='api_project'),
65
    url(r'^projects/(?P<project_id>\d+)/action/?$', 'project_action',
66
        name='api_project_action'),
67
    url(r'^projects/apps/?$', 'applications', name='api_applications'),
68
    url(r'^projects/apps/(?P<app_id>\d+)/?$', 'application',
69
        name='api_application'),
70
    url(r'^projects/apps/(?P<app_id>\d+)/action/?$', 'application_action',
71
        name='api_application_action'),
72
    url(r'^projects/memberships/?$', 'memberships', name='api_memberships'),
73
    url(r'^projects/memberships/(?P<memb_id>\d+)/?$', 'membership',
74
        name='api_membership'),
75
    url(r'^projects/memberships/(?P<memb_id>\d+)/action/?$',
76
        'membership_action', name='api_membership_action'),
77
)
78

  
61 79
urlpatterns = patterns(
62 80
    '',
63 81
    url(r'^v1.0/', include(astakos_account_v1_0)),
b/snf-astakos-app/astakos/api/util.py
81 81
    return response
82 82

  
83 83

  
84
def read_json_body(request, default=None):
85
    body = request.raw_post_data
86
    if not body and request.method == "GET":
87
        body = request.GET.get("body")
88
    if not body:
89
        return default
90
    try:
91
        return json.loads(body)
92
    except json.JSONDecodeError:
93
        raise faults.BadRequest("Request body should be in json format.")
94

  
95

  
84 96
def is_integer(x):
85 97
    return isinstance(x, (int, long))
86 98

  
......
227 239
    if content_length is None:
228 240
        raise faults.LengthRequired('Missing or invalid Content-Length header')
229 241
    return content_length
242

  
243

  
244
def invert_dict(d):
245
    return dict((v, k) for k, v in d.iteritems())
b/snf-astakos-app/astakos/im/functions.py
280 280

  
281 281
def get_project_by_id(project_id):
282 282
    try:
283
        return Project.objects.get(id=project_id)
283
        return Project.objects.select_related(
284
            "application", "application__owner",
285
            "application__applicant").get(id=project_id)
284 286
    except Project.DoesNotExist:
285 287
        m = _(astakos_messages.UNKNOWN_PROJECT_ID) % project_id
286 288
        raise ProjectNotFound(m)
......
500 502
    return membership
501 503

  
502 504

  
505
def enroll_member_by_email(project_id, email, request_user=None, reason=None):
506
    try:
507
        user = AstakosUser.objects.verified().get(email=email)
508
        return enroll_member(project_id, user, request_user, reason=reason)
509
    except AstakosUser.DoesNotExist:
510
        raise ProjectConflict(astakos_messages.UNKNOWN_USERS)
511

  
512

  
503 513
def enroll_member(project_id, user, request_user=None, reason=None):
504 514
    project = get_project_for_update(project_id)
505 515
    try:
b/snf-astakos-app/astakos/im/models.py
1481 1481
        return presentation.PROJECT_MEMBER_LEAVE_POLICIES.get(policy)
1482 1482

  
1483 1483

  
1484
class ProjectResourceGrantManager(models.Manager):
1485
    def grants_per_app(self, applications):
1486
        app_ids = [app.id for app in applications]
1487
        grants = self.filter(
1488
            project_application__in=app_ids).select_related("resource")
1489
        return _partition_by(lambda g: g.project_application_id, grants)
1490

  
1491

  
1484 1492
class ProjectResourceGrant(models.Model):
1485 1493

  
1486 1494
    resource = models.ForeignKey(Resource)
......
1489 1497
    project_capacity = intDecimalField(null=True)
1490 1498
    member_capacity = intDecimalField(default=0)
1491 1499

  
1500
    objects = ProjectResourceGrantManager()
1501

  
1492 1502
    class Meta:
1493 1503
        unique_together = ("resource", "project_application")
1494 1504

  
b/snf-astakos-app/astakos/im/tests/projects.py
32 32
# or implied, of GRNET S.A.
33 33

  
34 34
from astakos.im.tests.common import *
35
from snf_django.utils.testing import assertGreater, assertIn, assertRaises
36

  
37

  
38
class ProjectAPITest(TestCase):
39

  
40
    def setUp(self):
41
        self.client = Client()
42
        component1 = Component.objects.create(name="comp1")
43
        register.add_service(component1, "service1", "type1", [])
44
        # custom service resources
45
        resource11 = {"name": "service1.resource11",
46
                      "desc": "resource11 desc",
47
                      "service_type": "type1",
48
                      "service_origin": "service1",
49
                      "allow_in_projects": True}
50
        r, _ = register.add_resource(resource11)
51
        register.update_resource(r, 100)
52
        resource12 = {"name": "service1.resource12",
53
                      "desc": "resource11 desc",
54
                      "service_type": "type1",
55
                      "service_origin": "service1",
56
                      "unit": "bytes"}
57
        r, _ = register.add_resource(resource12)
58
        register.update_resource(r, 1024)
59

  
60
        # create user
61
        self.user1 = get_local_user("test@grnet.gr")
62
        quotas.qh_sync_user(self.user1)
63
        self.user2 = get_local_user("test2@grnet.gr")
64
        self.user2.uuid = "uuid2"
65
        self.user2.save()
66
        quotas.qh_sync_user(self.user2)
67
        self.user3 = get_local_user("test3@grnet.gr")
68
        quotas.qh_sync_user(self.user3)
69

  
70
        astakos = Component.objects.create(name="astakos")
71
        register.add_service(astakos, "astakos_account", "account", [])
72
        # create another service
73
        pending_app = {"name": "astakos.pending_app",
74
                       "desc": "pend app desc",
75
                       "service_type": "account",
76
                       "service_origin": "astakos_account",
77
                       "allow_in_projects": False}
78
        r, _ = register.add_resource(pending_app)
79
        register.update_resource(r, 3)
80

  
81
    def create(self, app, headers):
82
        dump = json.dumps(app)
83
        r = self.client.post(reverse("api_projects"), dump,
84
                             content_type="application/json", **headers)
85
        body = json.loads(r.content)
86
        return r.status_code, body
87

  
88
    def modify(self, app, project_id, headers):
89
        dump = json.dumps(app)
90
        kwargs = {"project_id": project_id}
91
        r = self.client.post(reverse("api_project", kwargs=kwargs), dump,
92
                             content_type="application/json", **headers)
93
        body = json.loads(r.content)
94
        return r.status_code, body
95

  
96
    def project_action(self, project_id, action, headers):
97
        action = json.dumps({action: "reason"})
98
        r = self.client.post(reverse("api_project_action",
99
                                     kwargs={"project_id": project_id}),
100
                             action, content_type="application/json",
101
                             **headers)
102
        return r.status_code
103

  
104
    def app_action(self, app_id, action, headers):
105
        action = json.dumps({action: "reason"})
106
        r = self.client.post(reverse("api_application_action",
107
                                     kwargs={"app_id": app_id}),
108
                             action, content_type="application/json",
109
                             **headers)
110
        return r.status_code
111

  
112
    def memb_action(self, memb_id, action, headers):
113
        action = json.dumps({action: "reason"})
114
        r = self.client.post(reverse("api_membership_action",
115
                                     kwargs={"memb_id": memb_id}), action,
116
                             content_type="application/json", **headers)
117
        return r.status_code
118

  
119
    def join(self, project_id, headers):
120
        action = {"join": {"project": project_id}}
121
        req = json.dumps(action)
122
        r = self.client.post(reverse("api_memberships"), req,
123
                             content_type="application/json", **headers)
124
        body = json.loads(r.content)
125
        return r.status_code, body
126

  
127
    def enroll(self, project_id, user, headers):
128
        action = {
129
            "enroll": {
130
                "project": project_id,
131
                "user": user.email,
132
            }
133
        }
134
        req = json.dumps(action)
135
        r = self.client.post(reverse("api_memberships"), req,
136
                             content_type="application/json", **headers)
137
        body = json.loads(r.content)
138
        return r.status_code, body
139

  
140
    @im_settings(PROJECT_ADMINS=["uuid2"])
141
    def test_projects(self):
142
        client = self.client
143
        h_owner = {"HTTP_X_AUTH_TOKEN": self.user1.auth_token}
144
        h_admin = {"HTTP_X_AUTH_TOKEN": self.user2.auth_token}
145
        h_plain = {"HTTP_X_AUTH_TOKEN": self.user3.auth_token}
146
        r = client.get(reverse("api_project", kwargs={"project_id": 1}))
147
        self.assertEqual(r.status_code, 401)
148

  
149
        r = client.get(reverse("api_project", kwargs={"project_id": 1}),
150
                       **h_owner)
151
        self.assertEqual(r.status_code, 404)
152
        r = client.get(reverse("api_application", kwargs={"app_id": 1}),
153
                       **h_owner)
154
        self.assertEqual(r.status_code, 404)
155
        r = client.get(reverse("api_membership", kwargs={"memb_id": 1}),
156
                       **h_owner)
157
        self.assertEqual(r.status_code, 404)
158

  
159
        status = self.memb_action(1, "accept", h_admin)
160
        self.assertEqual(status, 404)
161

  
162
        app1 = {"name": "test.pr",
163
                "end_date": "2013-5-5T20:20:20Z",
164
                "join_policy": "auto",
165
                "resources": {"service1.resource11": {
166
                    "member_capacity": 512}}
167
                }
168

  
169
        status, body = self.modify(app1, 1, h_owner)
170
        self.assertEqual(status, 404)
171

  
172
        # Create
173
        status, body = self.create(app1, h_owner)
174
        self.assertEqual(status, 201)
175
        project_id = body["id"]
176
        app_id = body["application"]
177

  
178
        # Get project
179
        r = client.get(reverse("api_project",
180
                               kwargs={"project_id": project_id}),
181
                       **h_owner)
182
        self.assertEqual(r.status_code, 200)
183
        body = json.loads(r.content)
... This diff was truncated because it exceeds the maximum size that can be displayed.

Also available in: Unified diff