Skip to content
Snippets Groups Projects
projects.py 21.76 KiB
# Copyright (C) 2010-2014 GRNET S.A.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import re
import operator

from django.utils import simplejson as json
from django.views.decorators.csrf import csrf_exempt
from django.http import HttpResponse
from django.db.models import Q
from django.db import transaction

from astakos.api.util import json_response

from snf_django.lib import api
from snf_django.lib.api import faults
from snf_django.lib.api import utils
from .util import user_from_token, invert_dict, check_is_dict

from astakos.im import functions
from astakos.im.models import (
    AstakosUser, Project, ProjectApplication, ProjectMembership,
    ProjectResourceQuota, ProjectResourceGrant, ProjectLog,
    ProjectMembershipLog)
import synnefo.util.date as date_util
from synnefo.util import units


MEMBERSHIP_POLICY_SHOW = {
    functions.AUTO_ACCEPT_POLICY: "auto",
    functions.MODERATED_POLICY:   "moderated",
    functions.CLOSED_POLICY:      "closed",
}

MEMBERSHIP_POLICY = invert_dict(MEMBERSHIP_POLICY_SHOW)

APPLICATION_STATE_SHOW = {
    ProjectApplication.PENDING:   "pending",
    ProjectApplication.APPROVED:  "approved",
    ProjectApplication.REPLACED:  "replaced",
    ProjectApplication.DENIED:    "denied",
    ProjectApplication.DISMISSED: "dismissed",
    ProjectApplication.CANCELLED: "cancelled",
}

PROJECT_STATE_SHOW = {
    Project.UNINITIALIZED: "uninitialized",
    Project.NORMAL:        "active",
    Project.SUSPENDED:     "suspended",
    Project.TERMINATED:    "terminated",
    Project.DELETED:       "deleted",
}

PROJECT_STATE = invert_dict(PROJECT_STATE_SHOW)

MEMBERSHIP_STATE_SHOW = {
    ProjectMembership.REQUESTED:       "requested",
    ProjectMembership.ACCEPTED:        "accepted",
    ProjectMembership.LEAVE_REQUESTED: "leave_requested",
    ProjectMembership.USER_SUSPENDED:  "suspended",
    ProjectMembership.REJECTED:        "rejected",
    ProjectMembership.CANCELLED:       "cancelled",
    ProjectMembership.REMOVED:         "removed",
}


def _grant_details(grants):
    resources = {}
    for grant in grants:
        if not grant.resource.api_visible:
            continue
        resources[grant.resource.name] = {
            "member_capacity": grant.member_capacity,
            "project_capacity": grant.project_capacity,
        }
    return resources


def _application_details(application, all_grants):
    grants = all_grants.get(application.id, [])
    resources = _grant_details(grants)
    join_policy = MEMBERSHIP_POLICY_SHOW.get(application.member_join_policy)
    leave_policy = MEMBERSHIP_POLICY_SHOW.get(application.member_leave_policy)

    d = {
        "id": application.id,
        "state": APPLICATION_STATE_SHOW[application.state],
        "name": application.name,
        "owner": application.owner.uuid if application.owner else None,
        "applicant": application.applicant.uuid,
        "homepage": application.homepage,
        "description": application.description,
        "start_date": application.start_date,
        "end_date": application.end_date,
        "comments": application.comments,
        "join_policy": join_policy,
        "leave_policy": leave_policy,
        "max_members": application.limit_on_members_number,
        "private": application.private,
        "resources": resources,
    }
    return d


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

    l = []
    for project in projects:
        join_policy = MEMBERSHIP_POLICY_SHOW[project.member_join_policy]
        leave_policy = MEMBERSHIP_POLICY_SHOW[project.member_leave_policy]
        quotas = proj_quotas.get(project.id, [])
        resources = _grant_details(quotas)

        d = {
            "id": project.uuid,
            "state": PROJECT_STATE_SHOW[project.state],
            "creation_date": project.creation_date,
            "name": project.realname,
            "owner": project.owner.uuid if project.owner else None,
            "homepage": project.homepage,
            "description": project.description,
            "end_date": project.end_date,
            "join_policy": join_policy,
            "leave_policy": leave_policy,
            "max_members": project.limit_on_members_number,
            "private": project.private,
            "system_project": project.is_base,
            "resources": resources,
            }

        check = functions.project_check_allowed
        if check(project, request_user,
                 level=functions.APPLICANT_LEVEL, silent=True):
            application = project.last_application
            if application:
                d["last_application"] = _application_details(
                    application, app_grants)
            deact = deactivations.get(project.id)
            if deact is not None:
                d["deactivation_date"] = deact.date
        l.append(d)
    return l


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


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

    l = []
    for membership in memberships:
        logs = all_logs.get(membership.id, {})
        dates = {}
        for s, log in logs.iteritems():
            dates[MEMBERSHIP_STATE_SHOW[s]] = log.date

        allowed_actions = functions.membership_allowed_actions(
            membership, request_user)
        d = {
            "id": membership.id,
            "user": membership.person.uuid,
            "project": membership.project.uuid,
            "state": MEMBERSHIP_STATE_SHOW[membership.state],
            "allowed_actions": allowed_actions,
        }
        d.update(dates)
        l.append(d)
    return l


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


def _query(attr):
    def inner(val):
        kw = attr + "__in" if isinstance(val, list) else attr
        return Q(**{kw: val})
    return inner


def _get_project_state(val):
    try:
        return PROJECT_STATE[val]
    except KeyError:
        raise faults.BadRequest("Unrecognized state %s" % val)


def _project_state_query(val):
    if isinstance(val, list):
        states = [_get_project_state(v) for v in val]
        return Q(state__in=states)
    return Q(state=_get_project_state(val))


PROJECT_QUERY = {
    "name": _query("realname"),
    "owner": _query("owner__uuid"),
    "state": _project_state_query,
}


def make_project_query(filters):
    qs = Q()
    for attr, val in filters.iteritems():
        try:
            _q = PROJECT_QUERY[attr]
        except KeyError:
            raise faults.BadRequest("Unrecognized filter %s" % attr)
        qs &= _q(val)
    return qs


class ExceptionHandler(object):
    def __enter__(self):
        pass

    EXCS = {
        functions.ProjectNotFound:   faults.ItemNotFound,
        functions.ProjectForbidden:  faults.Forbidden,
        functions.ProjectBadRequest: faults.BadRequest,
        functions.ProjectConflict:   faults.Conflict,
    }

    def __exit__(self, exc_type, value, traceback):
        if value is not None:  # exception
            try:
                e = self.EXCS[exc_type]
            except KeyError:
                return False  # reraise
            raise e(value.message)


@csrf_exempt
def projects(request):
    method = request.method
    if method == "GET":
        return get_projects(request)
    elif method == "POST":
        return create_project(request)
    return api.api_method_not_allowed(request, allowed_methods=['GET', 'POST'])


@api.api_method(http_method="GET", token_required=True, user_required=False)
@user_from_token
@transaction.commit_on_success
def get_projects(request):
    user = request.user
    filters = {}
    for key in PROJECT_QUERY.keys():
        value = request.GET.get(key)
        if value is not None:
            filters[key] = value
    mode = request.GET.get("mode", "default")
    query = make_project_query(filters)
    projects = _get_projects(query, mode=mode, request_user=user)
    data = get_projects_details(projects, request_user=user)
    return json_response(data)


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

    filters = [Q()]
    if mode == "member":
        membs = request_user.projectmembership_set.\
            actually_accepted_and_active()
        memb_projects = membs.values_list("project", flat=True)
        is_memb = Q(id__in=memb_projects)
        filters.append(is_memb)
    elif mode in ["related", "default"]:
        membs = request_user.projectmembership_set.any_accepted()
        memb_projects = membs.values_list("project", flat=True)
        is_memb = Q(id__in=memb_projects)
        owned = Q(owner=request_user)
        if not request_user.is_project_admin():
            filters.append(is_memb)
            filters.append(owned)
    elif mode in ["active", "default"]:
        active = (Q(state=Project.NORMAL) & Q(private=False))
        if not request_user.is_project_admin():
            filters.append(active)
    else:
        raise faults.BadRequest("Unrecognized mode '%s'." % mode)

    q = reduce(operator.or_, filters)
    projects = projects.filter(q)
    return projects.select_related("last_application")


@api.api_method(http_method="POST", token_required=True, user_required=False)
@user_from_token
@transaction.commit_on_success
def create_project(request):
    user = request.user
    app_data = utils.get_json_body(request)
    return submit_new_project(app_data, user)


@csrf_exempt
def project(request, project_id):
    method = request.method
    if method == "GET":
        return get_project(request, project_id)
    if method == "PUT":
        return modify_project(request, project_id)
    return api.api_method_not_allowed(request, allowed_methods=['GET', 'PUT'])


@api.api_method(http_method="GET", token_required=True, user_required=False)
@user_from_token
@transaction.commit_on_success
def get_project(request, project_id):
    user = request.user
    with ExceptionHandler():
        project = _get_project(project_id, request_user=user)
    data = get_project_details(project, user)
    return json_response(data)


def _get_project(project_id, request_user=None):
    project = functions.get_project_by_uuid(project_id)
    functions.project_check_allowed(
        project, request_user, level=functions.ANY_LEVEL)
    return project


@api.api_method(http_method="PUT", token_required=True, user_required=False)
@user_from_token
@transaction.commit_on_success
def modify_project(request, project_id):
    user = request.user
    app_data = utils.get_json_body(request)
    return submit_modification(app_data, user, project_id=project_id)


def _get_date(d, key):
    date_str = d.get(key)
    if date_str is not None:
        try:
            return date_util.isoparse(date_str)
        except:
            raise faults.BadRequest("Invalid %s" % key)
    else:
        return None


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


def _get_maybe_boolean(d, key, default=None):
    value = d.get(key)
    if value is not None and not isinstance(value, bool):
        raise faults.BadRequest("%s must be boolean" % key)
    if value is None:
        return default
    return value


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


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


def _parse_max_members(s):
    try:
        max_members = units.parse(s)
        if max_members < 0:
            raise faults.BadRequest("Invalid max_members")
        return max_members
    except units.ParseError:
        raise faults.BadRequest("Invalid max_members")


def submit_new_project(app_data, user):
    uuid = app_data.get("owner")
    if uuid is None:
        owner = user
    else:
        try:
            owner = AstakosUser.objects.accepted().get(uuid=uuid)
        except AstakosUser.DoesNotExist:
            raise faults.BadRequest("User does not exist.")

    try:
        name = app_data["name"]
    except KeyError:
        raise faults.BadRequest("Name missing.")

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

    join_policy = app_data.get("join_policy", "moderated")
    try:
        join_policy = MEMBERSHIP_POLICY[join_policy]
    except KeyError:
        raise faults.BadRequest("Invalid join policy")

    leave_policy = app_data.get("leave_policy", "auto")
    try:
        leave_policy = MEMBERSHIP_POLICY[leave_policy]
    except KeyError:
        raise faults.BadRequest("Invalid leave policy")

    start_date = _get_date(app_data, "start_date")
    end_date = _get_date(app_data, "end_date")

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

    try:
        max_members = _parse_max_members(app_data["max_members"])
    except KeyError:
        max_members = units.PRACTICALLY_INFINITE

    private = bool(_get_maybe_boolean(app_data, "private"))
    homepage = _get_maybe_string(app_data, "homepage", "")
    description = _get_maybe_string(app_data, "description", "")
    comments = _get_maybe_string(app_data, "comments", "")
    resources = app_data.get("resources", {})

    submit = functions.submit_application
    with ExceptionHandler():
        application = submit(
            owner=owner,
            name=name,
            project_id=None,
            homepage=homepage,
            description=description,
            start_date=start_date,
            end_date=end_date,
            member_join_policy=join_policy,
            member_leave_policy=leave_policy,
            limit_on_members_number=max_members,
            private=private,
            comments=comments,
            resources=resources,
            request_user=user)

    result = {"application": application.id,
              "id": application.chain.uuid,
              }
    return json_response(result, status_code=201)


def submit_modification(app_data, user, project_id):
    owner = app_data.get("owner")
    if owner is not None:
        try:
            owner = AstakosUser.objects.accepted().get(uuid=owner)
        except AstakosUser.DoesNotExist:
            raise faults.BadRequest("User does not exist.")

    name = app_data.get("name")

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

    join_policy = app_data.get("join_policy")
    if join_policy is not None:
        try:
            join_policy = MEMBERSHIP_POLICY[join_policy]
        except KeyError:
            raise faults.BadRequest("Invalid join policy")

    leave_policy = app_data.get("leave_policy")
    if leave_policy is not None:
        try:
            leave_policy = MEMBERSHIP_POLICY[leave_policy]
        except KeyError:
            raise faults.BadRequest("Invalid leave policy")

    start_date = _get_date(app_data, "start_date")
    end_date = _get_date(app_data, "end_date")

    max_members = app_data.get("max_members")
    if max_members is not None:
        max_members = _parse_max_members(max_members)

    private = _get_maybe_boolean(app_data, "private")
    homepage = _get_maybe_string(app_data, "homepage")
    description = _get_maybe_string(app_data, "description")
    comments = _get_maybe_string(app_data, "comments")
    resources = app_data.get("resources", {})

    submit = functions.submit_application
    with ExceptionHandler():
        application = submit(
            owner=owner,
            name=name,
            project_id=project_id,
            homepage=homepage,
            description=description,
            start_date=start_date,
            end_date=end_date,
            member_join_policy=join_policy,
            member_leave_policy=leave_policy,
            limit_on_members_number=max_members,
            private=private,
            comments=comments,
            resources=resources,
            request_user=user)

    result = {"application": application.id,
              "id": application.chain.uuid,
              }
    return json_response(result, status_code=201)


def get_action(actions, input_data):
    action = None
    data = None
    check_is_dict(input_data)
    for option in actions.keys():
        if option in input_data:
            if action:
                raise faults.BadRequest("Multiple actions not supported")
            else:
                action = option
                data = input_data[action]
    if not action:
        raise faults.BadRequest("No recognized action")
    return actions[action], data


PROJECT_ACTION = {
    "terminate": functions.terminate,
    "suspend":   functions.suspend,
    "unsuspend": functions.unsuspend,
    "reinstate": functions.reinstate,
}


APPLICATION_ACTION = {
    "approve": functions.approve_application,
    "deny":    functions.deny_application,
    "dismiss": functions.dismiss_application,
    "cancel":  functions.cancel_application,
}


PROJECT_ACTION.update(APPLICATION_ACTION)
APP_ACTION_FUNCS = APPLICATION_ACTION.values()


@csrf_exempt
@api.api_method(http_method="POST", token_required=True, user_required=False)
@user_from_token
@transaction.commit_on_success
def project_action(request, project_id):
    user = request.user
    input_data = utils.get_json_body(request)

    func, action_data = get_action(PROJECT_ACTION, input_data)
    with ExceptionHandler():
        kwargs = {"request_user": user,
                  "reason": action_data.get("reason", ""),
                  }
        if func in APP_ACTION_FUNCS:
            kwargs["application_id"] = action_data["app_id"]
        func(project_id=project_id, **kwargs)
    return HttpResponse()


@csrf_exempt
def memberships(request):
    method = request.method
    if method == "GET":
        return get_memberships(request)
    elif method == "POST":
        return post_memberships(request)
    return api.api_method_not_allowed(request, allowed_methods=['GET', 'POST'])


def make_membership_query(input_data):
    project_id = input_data.get("project")
    if project_id is not None:
        return Q(project__uuid=project_id)
    return Q()


@api.api_method(http_method="GET", token_required=True, user_required=False)
@user_from_token
@transaction.commit_on_success
def get_memberships(request):
    user = request.user
    query = make_membership_query(request.GET)
    memberships = _get_memberships(query, request_user=user)
    data = get_memberships_details(memberships, user)
    return json_response(data)


def _get_memberships(query, request_user=None):
    memberships = ProjectMembership.objects
    if not request_user.is_project_admin():
        owned = Q(project__owner=request_user)
        memb = Q(person=request_user)
        memberships = memberships.filter(owned | memb)

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


def join_project(data, request_user):
    project_id = data.get("project")
    with ExceptionHandler():
        membership = functions.join_project(project_id, request_user)
    response = {"id": membership.id}
    return json_response(response)


def enroll_user(data, request_user):
    project_id = data.get("project")
    email = data.get("user")
    with ExceptionHandler():
        m = functions.enroll_member_by_email(
            project_id, email, request_user)

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


MEMBERSHIPS_ACTION = {
    "join":   join_project,
    "enroll": enroll_user,
}


@api.api_method(http_method="POST", token_required=True, user_required=False)
@user_from_token
@transaction.commit_on_success
def post_memberships(request):
    user = request.user
    data = request.body
    input_data = json.loads(data)
    func, action_data = get_action(MEMBERSHIPS_ACTION, input_data)
    return func(action_data, user)


@api.api_method(http_method="GET", token_required=True, user_required=False)
@user_from_token
@transaction.commit_on_success
def membership(request, memb_id):
    user = request.user
    with ExceptionHandler():
        m = _get_membership(memb_id, request_user=user)
    data = get_membership_details(m, user)
    return json_response(data)


def _get_membership(memb_id, request_user=None):
    membership = functions.get_membership_by_id(memb_id)
    functions.membership_check_allowed(membership, request_user)
    return membership


MEMBERSHIP_ACTION = {
    "leave":  functions.leave_project,
    "cancel": functions.cancel_membership,
    "accept": functions.accept_membership,
    "reject": functions.reject_membership,
    "remove": functions.remove_membership,
}


@csrf_exempt
@api.api_method(http_method="POST", token_required=True, user_required=False)
@user_from_token
@transaction.commit_on_success
def membership_action(request, memb_id):
    user = request.user
    input_data = utils.get_json_body(request)
    func, action_data = get_action(MEMBERSHIP_ACTION, input_data)
    with ExceptionHandler():
        func(memb_id, user, reason=action_data)
    return HttpResponse()