Statistics
| Branch: | Tag: | Revision:

root / snf-astakos-app / astakos / api / projects.py @ 362dadaa

History | View | Annotate | Download (21.9 kB)

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
import re
35
from django.utils import simplejson as json
36
from django.views.decorators.csrf import csrf_exempt
37
from django.http import HttpResponse
38
from django.db.models import Q
39
from django.db import transaction
40

    
41
from astakos.api.util import json_response
42

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

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

    
55

    
56
MEMBERSHIP_POLICY_SHOW = {
57
    functions.AUTO_ACCEPT_POLICY: "auto",
58
    functions.MODERATED_POLICY:   "moderated",
59
    functions.CLOSED_POLICY:      "closed",
60
}
61

    
62
MEMBERSHIP_POLICY = invert_dict(MEMBERSHIP_POLICY_SHOW)
63

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

    
73
PROJECT_STATE_SHOW = {
74
    Project.UNINITIALIZED: "uninitialized",
75
    Project.NORMAL:        "active",
76
    Project.SUSPENDED:     "suspended",
77
    Project.TERMINATED:    "terminated",
78
    Project.DELETED:       "deleted",
79
}
80

    
81
PROJECT_STATE = invert_dict(PROJECT_STATE_SHOW)
82

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

    
93

    
94
def _grant_details(grants):
95
    resources = {}
96
    for grant in grants:
97
        if not grant.resource.api_visible:
98
            continue
99
        resources[grant.resource.name] = {
100
            "member_capacity": grant.member_capacity,
101
            "project_capacity": grant.project_capacity,
102
        }
103
    return resources
104

    
105

    
106
def _application_details(application, all_grants):
107
    grants = all_grants.get(application.id, [])
108
    resources = _grant_details(grants)
109
    join_policy = MEMBERSHIP_POLICY_SHOW.get(application.member_join_policy)
110
    leave_policy = MEMBERSHIP_POLICY_SHOW.get(application.member_leave_policy)
111

    
112
    d = {
113
        "id": application.id,
114
        "state": APPLICATION_STATE_SHOW[application.state],
115
        "name": application.name,
116
        "owner": application.owner.uuid if application.owner else None,
117
        "applicant": application.applicant.uuid,
118
        "homepage": application.homepage,
119
        "description": application.description,
120
        "start_date": application.start_date,
121
        "end_date": application.end_date,
122
        "comments": application.comments,
123
        "join_policy": join_policy,
124
        "leave_policy": leave_policy,
125
        "max_members": application.limit_on_members_number,
126
        "private": application.private,
127
        "resources": resources,
128
    }
129
    return d
130

    
131

    
132
def get_projects_details(projects, request_user=None):
133
    applications = [p.last_application for p in projects if p.last_application]
134
    proj_quotas = ProjectResourceQuota.objects.quotas_per_project(projects)
135
    app_grants = ProjectResourceGrant.objects.grants_per_app(applications)
136
    deactivations = ProjectLog.objects.last_deactivations(projects)
137

    
138
    l = []
139
    for project in projects:
140
        join_policy = MEMBERSHIP_POLICY_SHOW[project.member_join_policy]
141
        leave_policy = MEMBERSHIP_POLICY_SHOW[project.member_leave_policy]
142
        quotas = proj_quotas.get(project.id, [])
143
        resources = _grant_details(quotas)
144

    
145
        d = {
146
            "id": project.uuid,
147
            "state": PROJECT_STATE_SHOW[project.state],
148
            "creation_date": project.creation_date,
149
            "name": project.realname,
150
            "owner": project.owner.uuid if project.owner else None,
151
            "homepage": project.homepage,
152
            "description": project.description,
153
            "end_date": project.end_date,
154
            "join_policy": join_policy,
155
            "leave_policy": leave_policy,
156
            "max_members": project.limit_on_members_number,
157
            "private": project.private,
158
            "base_project": project.is_base,
159
            "resources": resources,
160
            }
161

    
162
        check = functions.project_check_allowed
163
        if check(project, request_user,
164
                 level=functions.APPLICANT_LEVEL, silent=True):
165
            application = project.last_application
166
            if application:
167
                d["last_application"] = _application_details(
168
                    application, app_grants)
169
            deact = deactivations.get(project.id)
170
            if deact is not None:
171
                d["deactivation_date"] = deact.date
172
        l.append(d)
173
    return l
174

    
175

    
176
def get_project_details(project, request_user=None):
177
    return get_projects_details([project], request_user=request_user)[0]
178

    
179

    
180
def get_memberships_details(memberships, request_user):
181
    all_logs = ProjectMembershipLog.objects.last_logs(memberships)
182

    
183
    l = []
184
    for membership in memberships:
185
        logs = all_logs.get(membership.id, {})
186
        dates = {}
187
        for s, log in logs.iteritems():
188
            dates[MEMBERSHIP_STATE_SHOW[s]] = log.date
189

    
190
        allowed_actions = functions.membership_allowed_actions(
191
            membership, request_user)
192
        d = {
193
            "id": membership.id,
194
            "user": membership.person.uuid,
195
            "project": membership.project.uuid,
196
            "state": MEMBERSHIP_STATE_SHOW[membership.state],
197
            "allowed_actions": allowed_actions,
198
        }
199
        d.update(dates)
200
        l.append(d)
201
    return l
202

    
203

    
204
def get_membership_details(membership, request_user):
205
    return get_memberships_details([membership], request_user)[0]
206

    
207

    
208
def _query(attr):
209
    def inner(val):
210
        kw = attr + "__in" if isinstance(val, list) else attr
211
        return Q(**{kw: val})
212
    return inner
213

    
214

    
215
def _get_project_state(val):
216
    try:
217
        return PROJECT_STATE[val]
218
    except KeyError:
219
        raise faults.BadRequest("Unrecognized state %s" % val)
220

    
221

    
222
def _project_state_query(val):
223
    if isinstance(val, list):
224
        states = [_get_project_state(v) for v in val]
225
        return Q(state__in=states)
226
    return Q(state=_get_project_state(val))
227

    
228

    
229
PROJECT_QUERY = {
230
    "name": _query("realname"),
231
    "owner": _query("owner__uuid"),
232
    "state": _project_state_query,
233
}
234

    
235

    
236
def make_project_query(filters):
237
    qs = Q()
238
    for attr, val in filters.iteritems():
239
        try:
240
            _q = PROJECT_QUERY[attr]
241
        except KeyError:
242
            raise faults.BadRequest("Unrecognized filter %s" % attr)
243
        qs &= _q(val)
244
    return qs
245

    
246

    
247
class ExceptionHandler(object):
248
    def __enter__(self):
249
        pass
250

    
251
    EXCS = {
252
        functions.ProjectNotFound:   faults.ItemNotFound,
253
        functions.ProjectForbidden:  faults.Forbidden,
254
        functions.ProjectBadRequest: faults.BadRequest,
255
        functions.ProjectConflict:   faults.Conflict,
256
    }
257

    
258
    def __exit__(self, exc_type, value, traceback):
259
        if value is not None:  # exception
260
            try:
261
                e = self.EXCS[exc_type]
262
            except KeyError:
263
                return False  # reraise
264
            raise e(value.message)
265

    
266

    
267
@csrf_exempt
268
def projects(request):
269
    method = request.method
270
    if method == "GET":
271
        return get_projects(request)
272
    elif method == "POST":
273
        return create_project(request)
274
    return api.api_method_not_allowed(request, allowed_methods=['GET', 'POST'])
275

    
276

    
277
@api.api_method(http_method="GET", token_required=True, user_required=False)
278
@user_from_token
279
@transaction.commit_on_success
280
def get_projects(request):
281
    user = request.user
282
    input_data = read_json_body(request, default={})
283
    filters = input_data.get("filter", {})
284
    query = make_project_query(filters)
285
    projects = _get_projects(query, request_user=user)
286
    data = get_projects_details(projects, request_user=user)
287
    return json_response(data)
288

    
289

    
290
def _get_projects(query, request_user=None):
291
    projects = Project.objects.filter(query)
292

    
293
    if not request_user.is_project_admin():
294
        membs = request_user.projectmembership_set.any_accepted()
295
        memb_projects = membs.values_list("project", flat=True)
296
        is_memb = Q(id__in=memb_projects)
297
        owned = Q(owner=request_user)
298
        active = (Q(state=Project.NORMAL) &
299
                  Q(private=False))
300
        projects = projects.filter(is_memb | owned | active)
301
    return projects.select_related("last_application")
302

    
303

    
304
@api.api_method(http_method="POST", token_required=True, user_required=False)
305
@user_from_token
306
@transaction.commit_on_success
307
def create_project(request):
308
    user = request.user
309
    data = request.body
310
    app_data = json.loads(data)
311
    return submit_new_project(app_data, user)
312

    
313

    
314
@csrf_exempt
315
def project(request, project_id):
316
    method = request.method
317
    if method == "GET":
318
        return get_project(request, project_id)
319
    if method == "POST":
320
        return modify_project(request, project_id)
321
    return api.api_method_not_allowed(request, allowed_methods=['GET', 'POST'])
322

    
323

    
324
@api.api_method(http_method="GET", token_required=True, user_required=False)
325
@user_from_token
326
@transaction.commit_on_success
327
def get_project(request, project_id):
328
    user = request.user
329
    with ExceptionHandler():
330
        project = _get_project(project_id, request_user=user)
331
    data = get_project_details(project, user)
332
    return json_response(data)
333

    
334

    
335
def _get_project(project_id, request_user=None):
336
    project = functions.get_project_by_uuid(project_id)
337
    functions.project_check_allowed(
338
        project, request_user, level=functions.ANY_LEVEL)
339
    return project
340

    
341

    
342
@api.api_method(http_method="POST", token_required=True, user_required=False)
343
@user_from_token
344
@transaction.commit_on_success
345
def modify_project(request, project_id):
346
    user = request.user
347
    data = request.body
348
    app_data = json.loads(data)
349
    return submit_modification(app_data, user, project_id=project_id)
350

    
351

    
352
def _get_date(d, key):
353
    date_str = d.get(key)
354
    if date_str is not None:
355
        try:
356
            return date_util.isoparse(date_str)
357
        except:
358
            raise faults.BadRequest("Invalid %s" % key)
359
    else:
360
        return None
361

    
362

    
363
def _get_maybe_string(d, key, default=None):
364
    value = d.get(key)
365
    if value is not None and not isinstance(value, basestring):
366
        raise faults.BadRequest("%s must be string" % key)
367
    if value is None:
368
        return default
369
    return value
370

    
371

    
372
def _get_maybe_boolean(d, key, default=None):
373
    value = d.get(key)
374
    if value is not None and not isinstance(value, bool):
375
        raise faults.BadRequest("%s must be boolean" % key)
376
    if value is None:
377
        return default
378
    return value
379

    
380

    
381
DOMAIN_VALUE_REGEX = re.compile(
382
    r'^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,6}$',
383
    re.IGNORECASE)
384

    
385

    
386
def valid_project_name(name):
387
    return DOMAIN_VALUE_REGEX.match(name) is not None
388

    
389

    
390
def _parse_max_members(s):
391
    try:
392
        max_members = units.parse(s)
393
        if max_members < 0:
394
            raise faults.BadRequest("Invalid max_members")
395
        return max_members
396
    except units.ParseError:
397
        raise faults.BadRequest("Invalid max_members")
398

    
399

    
400
def submit_new_project(app_data, user):
401
    uuid = app_data.get("owner")
402
    if uuid is None:
403
        owner = user
404
    else:
405
        try:
406
            owner = AstakosUser.objects.accepted().get(uuid=uuid)
407
        except AstakosUser.DoesNotExist:
408
            raise faults.BadRequest("User does not exist.")
409

    
410
    try:
411
        name = app_data["name"]
412
    except KeyError:
413
        raise faults.BadRequest("Name missing.")
414

    
415
    if not valid_project_name(name):
416
        raise faults.BadRequest("Project name should be in domain format")
417

    
418
    join_policy = app_data.get("join_policy", "moderated")
419
    try:
420
        join_policy = MEMBERSHIP_POLICY[join_policy]
421
    except KeyError:
422
        raise faults.BadRequest("Invalid join policy")
423

    
424
    leave_policy = app_data.get("leave_policy", "auto")
425
    try:
426
        leave_policy = MEMBERSHIP_POLICY[leave_policy]
427
    except KeyError:
428
        raise faults.BadRequest("Invalid leave policy")
429

    
430
    start_date = _get_date(app_data, "start_date")
431
    end_date = _get_date(app_data, "end_date")
432

    
433
    if end_date is None:
434
        raise faults.BadRequest("Missing end date")
435

    
436
    try:
437
        max_members = _parse_max_members(app_data["max_members"])
438
    except KeyError:
439
        max_members = units.PRACTICALLY_INFINITE
440

    
441
    private = bool(_get_maybe_boolean(app_data, "private"))
442
    homepage = _get_maybe_string(app_data, "homepage", "")
443
    description = _get_maybe_string(app_data, "description", "")
444
    comments = _get_maybe_string(app_data, "comments", "")
445
    resources = app_data.get("resources", {})
446

    
447
    submit = functions.submit_application
448
    with ExceptionHandler():
449
        application = submit(
450
            owner=owner,
451
            name=name,
452
            project_id=None,
453
            homepage=homepage,
454
            description=description,
455
            start_date=start_date,
456
            end_date=end_date,
457
            member_join_policy=join_policy,
458
            member_leave_policy=leave_policy,
459
            limit_on_members_number=max_members,
460
            private=private,
461
            comments=comments,
462
            resources=resources,
463
            request_user=user)
464

    
465
    result = {"application": application.id,
466
              "id": application.chain.uuid,
467
              }
468
    return json_response(result, status_code=201)
469

    
470

    
471
def submit_modification(app_data, user, project_id):
472
    owner = app_data.get("owner")
473
    if owner is not None:
474
        try:
475
            owner = AstakosUser.objects.accepted().get(uuid=owner)
476
        except AstakosUser.DoesNotExist:
477
            raise faults.BadRequest("User does not exist.")
478

    
479
    name = app_data.get("name")
480

    
481
    if name is not None and not valid_project_name(name):
482
        raise faults.BadRequest("Project name should be in domain format")
483

    
484
    join_policy = app_data.get("join_policy")
485
    if join_policy is not None:
486
        try:
487
            join_policy = MEMBERSHIP_POLICY[join_policy]
488
        except KeyError:
489
            raise faults.BadRequest("Invalid join policy")
490

    
491
    leave_policy = app_data.get("leave_policy")
492
    if leave_policy is not None:
493
        try:
494
            leave_policy = MEMBERSHIP_POLICY[leave_policy]
495
        except KeyError:
496
            raise faults.BadRequest("Invalid leave policy")
497

    
498
    start_date = _get_date(app_data, "start_date")
499
    end_date = _get_date(app_data, "end_date")
500

    
501
    max_members = app_data.get("max_members")
502
    if max_members is not None:
503
        max_members = _parse_max_members(max_members)
504

    
505
    private = _get_maybe_boolean(app_data, "private")
506
    homepage = _get_maybe_string(app_data, "homepage")
507
    description = _get_maybe_string(app_data, "description")
508
    comments = _get_maybe_string(app_data, "comments")
509
    resources = app_data.get("resources", {})
510

    
511
    submit = functions.submit_application
512
    with ExceptionHandler():
513
        application = submit(
514
            owner=owner,
515
            name=name,
516
            project_id=project_id,
517
            homepage=homepage,
518
            description=description,
519
            start_date=start_date,
520
            end_date=end_date,
521
            member_join_policy=join_policy,
522
            member_leave_policy=leave_policy,
523
            limit_on_members_number=max_members,
524
            private=private,
525
            comments=comments,
526
            resources=resources,
527
            request_user=user)
528

    
529
    result = {"application": application.id,
530
              "id": application.chain.uuid,
531
              }
532
    return json_response(result, status_code=201)
533

    
534

    
535
def get_action(actions, input_data):
536
    action = None
537
    data = None
538
    for option in actions.keys():
539
        if option in input_data:
540
            if action:
541
                raise faults.BadRequest("Multiple actions not supported")
542
            else:
543
                action = option
544
                data = input_data[action]
545
    if not action:
546
        raise faults.BadRequest("No recognized action")
547
    return actions[action], data
548

    
549

    
550
PROJECT_ACTION = {
551
    "terminate": functions.terminate,
552
    "suspend":   functions.suspend,
553
    "unsuspend": functions.unsuspend,
554
    "reinstate": functions.reinstate,
555
}
556

    
557

    
558
APPLICATION_ACTION = {
559
    "approve": functions.approve_application,
560
    "deny":    functions.deny_application,
561
    "dismiss": functions.dismiss_application,
562
    "cancel":  functions.cancel_application,
563
}
564

    
565

    
566
PROJECT_ACTION.update(APPLICATION_ACTION)
567
APP_ACTION_FUNCS = APPLICATION_ACTION.values()
568

    
569

    
570
@csrf_exempt
571
@api.api_method(http_method="POST", token_required=True, user_required=False)
572
@user_from_token
573
@transaction.commit_on_success
574
def project_action(request, project_id):
575
    user = request.user
576
    data = request.body
577
    input_data = json.loads(data)
578

    
579
    func, action_data = get_action(PROJECT_ACTION, input_data)
580
    with ExceptionHandler():
581
        kwargs = {"request_user": user,
582
                  "reason": action_data.get("reason", ""),
583
                  }
584
        if func in APP_ACTION_FUNCS:
585
            kwargs["application_id"] = action_data["app_id"]
586
        func(project_id=project_id, **kwargs)
587
    return HttpResponse()
588

    
589

    
590
@csrf_exempt
591
def memberships(request):
592
    method = request.method
593
    if method == "GET":
594
        return get_memberships(request)
595
    elif method == "POST":
596
        return post_memberships(request)
597
    return api.api_method_not_allowed(request, allowed_methods=['GET', 'POST'])
598

    
599

    
600
def make_membership_query(input_data):
601
    project_id = input_data.get("project")
602
    if project_id is not None:
603
        return Q(project__uuid=project_id)
604
    return Q()
605

    
606

    
607
@api.api_method(http_method="GET", token_required=True, user_required=False)
608
@user_from_token
609
@transaction.commit_on_success
610
def get_memberships(request):
611
    user = request.user
612
    input_data = read_json_body(request, default={})
613
    query = make_membership_query(input_data)
614
    memberships = _get_memberships(query, request_user=user)
615
    data = get_memberships_details(memberships, user)
616
    return json_response(data)
617

    
618

    
619
def _get_memberships(query, request_user=None):
620
    memberships = ProjectMembership.objects
621
    if not request_user.is_project_admin():
622
        owned = Q(project__owner=request_user)
623
        memb = Q(person=request_user)
624
        memberships = memberships.filter(owned | memb)
625

    
626
    return memberships.select_related(
627
        "project", "project__owner", "person").filter(query)
628

    
629

    
630
def join_project(data, request_user):
631
    project_id = data.get("project")
632
    with ExceptionHandler():
633
        membership = functions.join_project(project_id, request_user)
634
    response = {"id": membership.id}
635
    return json_response(response)
636

    
637

    
638
def enroll_user(data, request_user):
639
    project_id = data.get("project")
640
    email = data.get("user")
641
    with ExceptionHandler():
642
        m = functions.enroll_member_by_email(
643
            project_id, email, request_user)
644

    
645
    response = {"id": m.id}
646
    return json_response(response)
647

    
648

    
649
MEMBERSHIPS_ACTION = {
650
    "join":   join_project,
651
    "enroll": enroll_user,
652
}
653

    
654

    
655
@api.api_method(http_method="POST", token_required=True, user_required=False)
656
@user_from_token
657
@transaction.commit_on_success
658
def post_memberships(request):
659
    user = request.user
660
    data = request.body
661
    input_data = json.loads(data)
662
    func, action_data = get_action(MEMBERSHIPS_ACTION, input_data)
663
    return func(action_data, user)
664

    
665

    
666
@api.api_method(http_method="GET", token_required=True, user_required=False)
667
@user_from_token
668
@transaction.commit_on_success
669
def membership(request, memb_id):
670
    user = request.user
671
    with ExceptionHandler():
672
        m = _get_membership(memb_id, request_user=user)
673
    data = get_membership_details(m, user)
674
    return json_response(data)
675

    
676

    
677
def _get_membership(memb_id, request_user=None):
678
    membership = functions.get_membership_by_id(memb_id)
679
    functions.membership_check_allowed(membership, request_user)
680
    return membership
681

    
682

    
683
MEMBERSHIP_ACTION = {
684
    "leave":  functions.leave_project,
685
    "cancel": functions.cancel_membership,
686
    "accept": functions.accept_membership,
687
    "reject": functions.reject_membership,
688
    "remove": functions.remove_membership,
689
}
690

    
691

    
692
@csrf_exempt
693
@api.api_method(http_method="POST", token_required=True, user_required=False)
694
@user_from_token
695
@transaction.commit_on_success
696
def membership_action(request, memb_id):
697
    user = request.user
698
    input_data = read_json_body(request, default={})
699
    func, action_data = get_action(MEMBERSHIP_ACTION, input_data)
700
    with ExceptionHandler():
701
        func(memb_id, user, reason=action_data)
702
    return HttpResponse()