Statistics
| Branch: | Tag: | Revision:

root / snf-astakos-app / astakos / api / projects.py @ ff5edb80

History | View | Annotate | Download (22.4 kB)

1
# Copyright 2013-2014 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
    filters = {}
283
    for key in PROJECT_QUERY.keys():
284
        value = request.GET.get(key)
285
        if value is not None:
286
            filters[key] = value
287
    mode = request.GET.get("mode", "default")
288
    query = make_project_query(filters)
289
    projects = _get_projects(query, mode=mode, request_user=user)
290
    data = get_projects_details(projects, request_user=user)
291
    return json_response(data)
292

    
293

    
294
def _get_projects(query, mode="default", request_user=None):
295
    projects = Project.objects.filter(query)
296

    
297
    if mode == "member":
298
        membs = request_user.projectmembership_set.\
299
            actually_accepted_and_active()
300
        memb_projects = membs.values_list("project", flat=True)
301
        is_memb = Q(id__in=memb_projects)
302
        projects = projects.filter(is_memb)
303
    elif mode == "default":
304
        if not request_user.is_project_admin():
305
            membs = request_user.projectmembership_set.any_accepted()
306
            memb_projects = membs.values_list("project", flat=True)
307
            is_memb = Q(id__in=memb_projects)
308
            owned = Q(owner=request_user)
309
            active = (Q(state=Project.NORMAL) &
310
                      Q(private=False))
311
            projects = projects.filter(is_memb | owned | active)
312
    else:
313
        raise faults.BadRequest("Unrecognized mode '%s'." % mode)
314
    return projects.select_related("last_application")
315

    
316

    
317
@api.api_method(http_method="POST", token_required=True, user_required=False)
318
@user_from_token
319
@transaction.commit_on_success
320
def create_project(request):
321
    user = request.user
322
    data = request.body
323
    app_data = json.loads(data)
324
    return submit_new_project(app_data, user)
325

    
326

    
327
@csrf_exempt
328
def project(request, project_id):
329
    method = request.method
330
    if method == "GET":
331
        return get_project(request, project_id)
332
    if method == "PUT":
333
        return modify_project(request, project_id)
334
    return api.api_method_not_allowed(request, allowed_methods=['GET', 'PUT'])
335

    
336

    
337
@api.api_method(http_method="GET", token_required=True, user_required=False)
338
@user_from_token
339
@transaction.commit_on_success
340
def get_project(request, project_id):
341
    user = request.user
342
    with ExceptionHandler():
343
        project = _get_project(project_id, request_user=user)
344
    data = get_project_details(project, user)
345
    return json_response(data)
346

    
347

    
348
def _get_project(project_id, request_user=None):
349
    project = functions.get_project_by_uuid(project_id)
350
    functions.project_check_allowed(
351
        project, request_user, level=functions.ANY_LEVEL)
352
    return project
353

    
354

    
355
@api.api_method(http_method="PUT", token_required=True, user_required=False)
356
@user_from_token
357
@transaction.commit_on_success
358
def modify_project(request, project_id):
359
    user = request.user
360
    data = request.body
361
    app_data = json.loads(data)
362
    return submit_modification(app_data, user, project_id=project_id)
363

    
364

    
365
def _get_date(d, key):
366
    date_str = d.get(key)
367
    if date_str is not None:
368
        try:
369
            return date_util.isoparse(date_str)
370
        except:
371
            raise faults.BadRequest("Invalid %s" % key)
372
    else:
373
        return None
374

    
375

    
376
def _get_maybe_string(d, key, default=None):
377
    value = d.get(key)
378
    if value is not None and not isinstance(value, basestring):
379
        raise faults.BadRequest("%s must be string" % key)
380
    if value is None:
381
        return default
382
    return value
383

    
384

    
385
def _get_maybe_boolean(d, key, default=None):
386
    value = d.get(key)
387
    if value is not None and not isinstance(value, bool):
388
        raise faults.BadRequest("%s must be boolean" % key)
389
    if value is None:
390
        return default
391
    return value
392

    
393

    
394
DOMAIN_VALUE_REGEX = re.compile(
395
    r'^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,6}$',
396
    re.IGNORECASE)
397

    
398

    
399
def valid_project_name(name):
400
    return DOMAIN_VALUE_REGEX.match(name) is not None
401

    
402

    
403
def _parse_max_members(s):
404
    try:
405
        max_members = units.parse(s)
406
        if max_members < 0:
407
            raise faults.BadRequest("Invalid max_members")
408
        return max_members
409
    except units.ParseError:
410
        raise faults.BadRequest("Invalid max_members")
411

    
412

    
413
def submit_new_project(app_data, user):
414
    uuid = app_data.get("owner")
415
    if uuid is None:
416
        owner = user
417
    else:
418
        try:
419
            owner = AstakosUser.objects.accepted().get(uuid=uuid)
420
        except AstakosUser.DoesNotExist:
421
            raise faults.BadRequest("User does not exist.")
422

    
423
    try:
424
        name = app_data["name"]
425
    except KeyError:
426
        raise faults.BadRequest("Name missing.")
427

    
428
    if not valid_project_name(name):
429
        raise faults.BadRequest("Project name should be in domain format")
430

    
431
    join_policy = app_data.get("join_policy", "moderated")
432
    try:
433
        join_policy = MEMBERSHIP_POLICY[join_policy]
434
    except KeyError:
435
        raise faults.BadRequest("Invalid join policy")
436

    
437
    leave_policy = app_data.get("leave_policy", "auto")
438
    try:
439
        leave_policy = MEMBERSHIP_POLICY[leave_policy]
440
    except KeyError:
441
        raise faults.BadRequest("Invalid leave policy")
442

    
443
    start_date = _get_date(app_data, "start_date")
444
    end_date = _get_date(app_data, "end_date")
445

    
446
    if end_date is None:
447
        raise faults.BadRequest("Missing end date")
448

    
449
    try:
450
        max_members = _parse_max_members(app_data["max_members"])
451
    except KeyError:
452
        max_members = units.PRACTICALLY_INFINITE
453

    
454
    private = bool(_get_maybe_boolean(app_data, "private"))
455
    homepage = _get_maybe_string(app_data, "homepage", "")
456
    description = _get_maybe_string(app_data, "description", "")
457
    comments = _get_maybe_string(app_data, "comments", "")
458
    resources = app_data.get("resources", {})
459

    
460
    submit = functions.submit_application
461
    with ExceptionHandler():
462
        application = submit(
463
            owner=owner,
464
            name=name,
465
            project_id=None,
466
            homepage=homepage,
467
            description=description,
468
            start_date=start_date,
469
            end_date=end_date,
470
            member_join_policy=join_policy,
471
            member_leave_policy=leave_policy,
472
            limit_on_members_number=max_members,
473
            private=private,
474
            comments=comments,
475
            resources=resources,
476
            request_user=user)
477

    
478
    result = {"application": application.id,
479
              "id": application.chain.uuid,
480
              }
481
    return json_response(result, status_code=201)
482

    
483

    
484
def submit_modification(app_data, user, project_id):
485
    owner = app_data.get("owner")
486
    if owner is not None:
487
        try:
488
            owner = AstakosUser.objects.accepted().get(uuid=owner)
489
        except AstakosUser.DoesNotExist:
490
            raise faults.BadRequest("User does not exist.")
491

    
492
    name = app_data.get("name")
493

    
494
    if name is not None and not valid_project_name(name):
495
        raise faults.BadRequest("Project name should be in domain format")
496

    
497
    join_policy = app_data.get("join_policy")
498
    if join_policy is not None:
499
        try:
500
            join_policy = MEMBERSHIP_POLICY[join_policy]
501
        except KeyError:
502
            raise faults.BadRequest("Invalid join policy")
503

    
504
    leave_policy = app_data.get("leave_policy")
505
    if leave_policy is not None:
506
        try:
507
            leave_policy = MEMBERSHIP_POLICY[leave_policy]
508
        except KeyError:
509
            raise faults.BadRequest("Invalid leave policy")
510

    
511
    start_date = _get_date(app_data, "start_date")
512
    end_date = _get_date(app_data, "end_date")
513

    
514
    max_members = app_data.get("max_members")
515
    if max_members is not None:
516
        max_members = _parse_max_members(max_members)
517

    
518
    private = _get_maybe_boolean(app_data, "private")
519
    homepage = _get_maybe_string(app_data, "homepage")
520
    description = _get_maybe_string(app_data, "description")
521
    comments = _get_maybe_string(app_data, "comments")
522
    resources = app_data.get("resources", {})
523

    
524
    submit = functions.submit_application
525
    with ExceptionHandler():
526
        application = submit(
527
            owner=owner,
528
            name=name,
529
            project_id=project_id,
530
            homepage=homepage,
531
            description=description,
532
            start_date=start_date,
533
            end_date=end_date,
534
            member_join_policy=join_policy,
535
            member_leave_policy=leave_policy,
536
            limit_on_members_number=max_members,
537
            private=private,
538
            comments=comments,
539
            resources=resources,
540
            request_user=user)
541

    
542
    result = {"application": application.id,
543
              "id": application.chain.uuid,
544
              }
545
    return json_response(result, status_code=201)
546

    
547

    
548
def get_action(actions, input_data):
549
    action = None
550
    data = None
551
    for option in actions.keys():
552
        if option in input_data:
553
            if action:
554
                raise faults.BadRequest("Multiple actions not supported")
555
            else:
556
                action = option
557
                data = input_data[action]
558
    if not action:
559
        raise faults.BadRequest("No recognized action")
560
    return actions[action], data
561

    
562

    
563
PROJECT_ACTION = {
564
    "terminate": functions.terminate,
565
    "suspend":   functions.suspend,
566
    "unsuspend": functions.unsuspend,
567
    "reinstate": functions.reinstate,
568
}
569

    
570

    
571
APPLICATION_ACTION = {
572
    "approve": functions.approve_application,
573
    "deny":    functions.deny_application,
574
    "dismiss": functions.dismiss_application,
575
    "cancel":  functions.cancel_application,
576
}
577

    
578

    
579
PROJECT_ACTION.update(APPLICATION_ACTION)
580
APP_ACTION_FUNCS = APPLICATION_ACTION.values()
581

    
582

    
583
@csrf_exempt
584
@api.api_method(http_method="POST", token_required=True, user_required=False)
585
@user_from_token
586
@transaction.commit_on_success
587
def project_action(request, project_id):
588
    user = request.user
589
    data = request.body
590
    input_data = json.loads(data)
591

    
592
    func, action_data = get_action(PROJECT_ACTION, input_data)
593
    with ExceptionHandler():
594
        kwargs = {"request_user": user,
595
                  "reason": action_data.get("reason", ""),
596
                  }
597
        if func in APP_ACTION_FUNCS:
598
            kwargs["application_id"] = action_data["app_id"]
599
        func(project_id=project_id, **kwargs)
600
    return HttpResponse()
601

    
602

    
603
@csrf_exempt
604
def memberships(request):
605
    method = request.method
606
    if method == "GET":
607
        return get_memberships(request)
608
    elif method == "POST":
609
        return post_memberships(request)
610
    return api.api_method_not_allowed(request, allowed_methods=['GET', 'POST'])
611

    
612

    
613
def make_membership_query(input_data):
614
    project_id = input_data.get("project")
615
    if project_id is not None:
616
        return Q(project__uuid=project_id)
617
    return Q()
618

    
619

    
620
@api.api_method(http_method="GET", token_required=True, user_required=False)
621
@user_from_token
622
@transaction.commit_on_success
623
def get_memberships(request):
624
    user = request.user
625
    query = make_membership_query(request.GET)
626
    memberships = _get_memberships(query, request_user=user)
627
    data = get_memberships_details(memberships, user)
628
    return json_response(data)
629

    
630

    
631
def _get_memberships(query, request_user=None):
632
    memberships = ProjectMembership.objects
633
    if not request_user.is_project_admin():
634
        owned = Q(project__owner=request_user)
635
        memb = Q(person=request_user)
636
        memberships = memberships.filter(owned | memb)
637

    
638
    return memberships.select_related(
639
        "project", "project__owner", "person").filter(query)
640

    
641

    
642
def join_project(data, request_user):
643
    project_id = data.get("project")
644
    with ExceptionHandler():
645
        membership = functions.join_project(project_id, request_user)
646
    response = {"id": membership.id}
647
    return json_response(response)
648

    
649

    
650
def enroll_user(data, request_user):
651
    project_id = data.get("project")
652
    email = data.get("user")
653
    with ExceptionHandler():
654
        m = functions.enroll_member_by_email(
655
            project_id, email, request_user)
656

    
657
    response = {"id": m.id}
658
    return json_response(response)
659

    
660

    
661
MEMBERSHIPS_ACTION = {
662
    "join":   join_project,
663
    "enroll": enroll_user,
664
}
665

    
666

    
667
@api.api_method(http_method="POST", token_required=True, user_required=False)
668
@user_from_token
669
@transaction.commit_on_success
670
def post_memberships(request):
671
    user = request.user
672
    data = request.body
673
    input_data = json.loads(data)
674
    func, action_data = get_action(MEMBERSHIPS_ACTION, input_data)
675
    return func(action_data, user)
676

    
677

    
678
@api.api_method(http_method="GET", token_required=True, user_required=False)
679
@user_from_token
680
@transaction.commit_on_success
681
def membership(request, memb_id):
682
    user = request.user
683
    with ExceptionHandler():
684
        m = _get_membership(memb_id, request_user=user)
685
    data = get_membership_details(m, user)
686
    return json_response(data)
687

    
688

    
689
def _get_membership(memb_id, request_user=None):
690
    membership = functions.get_membership_by_id(memb_id)
691
    functions.membership_check_allowed(membership, request_user)
692
    return membership
693

    
694

    
695
MEMBERSHIP_ACTION = {
696
    "leave":  functions.leave_project,
697
    "cancel": functions.cancel_membership,
698
    "accept": functions.accept_membership,
699
    "reject": functions.reject_membership,
700
    "remove": functions.remove_membership,
701
}
702

    
703

    
704
@csrf_exempt
705
@api.api_method(http_method="POST", token_required=True, user_required=False)
706
@user_from_token
707
@transaction.commit_on_success
708
def membership_action(request, memb_id):
709
    user = request.user
710
    input_data = read_json_body(request, default={})
711
    func, action_data = get_action(MEMBERSHIP_ACTION, input_data)
712
    with ExceptionHandler():
713
        func(memb_id, user, reason=action_data)
714
    return HttpResponse()