# Copyright (C) 2015 Catalyst IT Ltd # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain # a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. from decorator import decorator from rest_framework.views import APIView from rest_framework.response import Response from models import Registration, Token, Notification from django.utils import timezone from datetime import timedelta from uuid import uuid4 from logging import getLogger from django.core.mail import send_mail from smtplib import SMTPException from django.conf import settings from django.template import loader, Context @decorator def admin_or_owner(func, *args, **kwargs): """ endpoints setup with this decorator require the defined roles. """ req_roles = {'admin', 'project_owner'} request = args[1] if not request.keystone_user.get('authenticated', False): return Response({'notes': ["Credentials incorrect or none given."]}, 401) roles = set(request.keystone_user.get('roles', [])) if roles & req_roles: return func(*args, **kwargs) return Response({'notes': ["Must have one of the following roles: %s" % list(req_roles)]}, 403) @decorator def admin(func, *args, **kwargs): """ endpoints setup with this decorator require the admin role. """ request = args[1] if not request.keystone_user.get('authenticated', False): return Response({'notes': ["Credentials incorrect or none given."]}, 401) roles = request.keystone_user.get('roles', []) if "admin" in roles: return func(*args, **kwargs) return Response({'notes': ["Must be admin."]}, 403) def create_token(registration): # expire needs to be made configurable. expire = timezone.now() + timedelta(hours=24) # is this a good way to create tokens? uuid = uuid4().hex token = Token.objects.create( registration=registration, token=uuid, expires=expire ) token.save() return token def email_token(registration, token): emails = set() actions = [] for action in registration.actions: act = action.get_action() if act.need_token: emails.add(act.token_email()) actions.append(unicode(act)) if len(emails) > 1: notes = { 'notes': (("Error: Unable to send token, More than one email for" + " registration: %s") % registration.uuid) } create_notification(registration, notes) # TODO(adriant): raise some error? # and surround calls to this function with try/except context = {'actions': actions, 'token': token.token} email_template = loader.get_template("token.txt") try: message = email_template.render(Context(context)) send_mail( 'Your token', message, 'no-reply@example.com', [emails.pop()], fail_silently=False) except SMTPException as e: notes = { 'notes': ("Error: '%s' while emailing token for registration: %s" % (e, registration.uuid)) } create_notification(registration, notes) # TODO(adriant): raise some error? # and surround calls to this function with try/except def create_notification(registration, notes): notification = Notification.objects.create( registration=registration, notes=notes ) notification.save() class APIViewWithLogger(APIView): """ APIView with a logger. """ def __init__(self, *args, **kwargs): super(APIViewWithLogger, self).__init__(*args, **kwargs) self.logger = getLogger('django.request') class NotificationList(APIViewWithLogger): @admin def get(self, request, format=None): """ A list of unacknowledged Notification objects as dicts. """ notifications = Notification.objects.filter(acknowledged__exact=False) note_list = [] for notification in notifications: note_list.append(notification.to_dict()) return Response(note_list, status=200) @admin def post(self, request, format=None): """ Acknowledge notifications. """ note_list = request.data.get('notifications', None) if note_list and isinstance(note_list, list): notifications = Notification.objects.filter(pk__in=note_list) for notification in notifications: notification.acknowledged = True notification.save() return Response({'notes': ['Notifications acknowledged.']}, status=200) else: return Response({'notifications': ["this field is required" + "needs to be a list."]}, status=400) class NotificationDetail(APIViewWithLogger): @admin def get(self, request, pk, format=None): """ Dict notification of a Notification object and its related actions. """ try: notification = Notification.objects.get(pk=pk) except Notification.DoesNotExist: return Response( {'notes': ['No notification with this id.']}, status=404) return Response(notification.to_dict()) @admin def post(self, request, pk, format=None): """ Acknowledge notification. """ try: notification = Notification.objects.get(pk=pk) except Notification.DoesNotExist: return Response( {'notes': ['No notification with this id.']}, status=404) if request.data.get('acknowledged', False) is True: notification.acknowledged = True notification.save() return Response({'notes': ['Notification acknowledged.']}, status=200) else: return Response({'acknowledged': ["this field is required."]}, status=400) class RegistrationList(APIViewWithLogger): @admin def get(self, request, format=None): """ A list of dict representations of Registration objects and their related actions. """ registrations = Registration.objects.all() reg_list = [] for registration in registrations: reg_list.append(registration.to_dict()) return Response(reg_list, status=200) class RegistrationDetail(APIViewWithLogger): @admin def get(self, request, uuid, format=None): """ Dict representation of a Registration object and its related actions. """ try: registration = Registration.objects.get(uuid=uuid) except Registration.DoesNotExist: return Response( {'notes': ['No registration with this id.']}, status=404) return Response(registration.to_dict()) @admin def put(self, request, uuid, format=None): """ Allows the updating of action data and retriggering of the pre_approve step. """ try: registration = Registration.objects.get(uuid=uuid) except Registration.DoesNotExist: return Response( {'notes': ['No registration with this id.']}, status=404) if registration.completed: return Response( {'notes': ['This registration has already been completed.']}, status=400) act_list = [] valid = True for action in registration.actions: action_serializer = settings.ACTION_CLASSES[action.action_name][1] if action_serializer is not None: serializer = action_serializer(data=request.data) else: serializer = None act_list.append({ 'name': action.action_name, 'action': action, 'serializer': serializer}) if serializer is not None and not serializer.is_valid(): valid = False if valid: for act in act_list: if act['serializer'] is not None: data = act['serializer'].validated_data else: data = {} act['action'].action_data = data act['action'].save() try: act['action'].get_action().pre_approve() except Exception as e: notes = { 'errors': [("Error: '%s' while updating registration. " + "See registration itself for details.") % e], 'registration': registration.uuid } create_notification(registration, notes) import traceback trace = traceback.format_exc() self.logger.critical(("(%s) - Exception escaped! %s\n" + "Trace: \n%s") % (timezone.now(), e, trace)) response_dict = { 'errors': ["Error: Something went wrong on the server. " + "It will be looked into shortly."] } return Response(response_dict, status=500) return Response( {'notes': ["Registration successfully updated."]}, status=200) else: errors = {} for act in act_list: if act['serializer'] is not None: errors.update(act['serializer'].errors) return Response({'errors': errors}, status=400) @admin def post(self, request, uuid, format=None): """ Will approve the Registration specified, followed by running the post_approve actions and if valid will setup and create a related token. """ try: registration = Registration.objects.get(uuid=uuid) except Registration.DoesNotExist: return Response( {'notes': ['No registration with this id.']}, status=404) if request.data.get('approved', False) is True: if registration.completed: return Response( {'notes': ['This registration has already been completed.']}, status=400) need_token = False valid = True actions = [] for action in registration.actions: act_model = action.get_action() actions.append(act_model) try: act_model.post_approve() except Exception as e: notes = { 'errors': [("Error: '%s' while approving registration. " + "See registration itself for details.") % e], 'registration': registration.uuid } create_notification(registration, notes) import traceback trace = traceback.format_exc() self.logger.critical(("(%s) - Exception escaped! %s\n" + "Trace: \n%s") % (timezone.now(), e, trace)) return Response(notes, status=500) if not action.valid: valid = False if action.need_token: need_token = True if valid: registration.approved = True registration.approved_on = timezone.now() registration.save() if need_token: token = create_token(registration) email_token(registration, token) return Response({'notes': ['created token']}, status=200) else: for action in actions: try: action.submit({}) except Exception as e: notes = { 'errors': [("Error: '%s' while submitting " + "registration. See registration " + "itself for details.") % e], 'registration': registration.uuid } create_notification(registration, notes) import traceback trace = traceback.format_exc() self.logger.critical(("(%s) - Exception escaped!" + " %s\n Trace: \n%s") % (timezone.now(), e, trace)) return Response(notes, status=500) registration.completed = True registration.completed_on = timezone.now() registration.save() return Response( {'notes': "Registration completed successfully."}, status=200) return Response({'notes': ['actions invalid']}, status=400) else: return Response({'approved': ["this field is required."]}, status=400) class TokenList(APIViewWithLogger): """ Admin functionality for managing/monitoring tokens. """ @admin def get(self, request, format=None): """ A list of dict representations of Token objects. """ tokens = Token.objects.all() token_list = [] for token in tokens: token_list.append(token.to_dict()) return Response(token_list) @admin def post(self, request, format=None): """ Reissue a token for an approved registration. Clears other tokens for it. """ uuid = request.data.get('registration', None) if uuid is None: return Response( {'registration': ["This field is required.", ]}, status=400) try: registration = Registration.objects.get(uuid=uuid) except Registration.DoesNotExist: return Response( {'notes': ['No registration with this id.']}, status=404) if not registration.approved: return Response( {'notes': ['This registration has not been approved.']}, status=400) for token in registration.tokens: token.delete() token = create_token(registration) email_token(registration, token) return Response( {'notes': ['Token reissued.']}, status=200) @admin def delete(self, request, format=None): """ Delete all expired tokens. """ now = timezone.now() Token.objects.filter(expires__lt=now).delete() return Response( {'notes': ['Deleted all expired tokens.']}, status=200) class TokenDetail(APIViewWithLogger): def get(self, request, id, format=None): """ Returns a response with the list of required fields and what actions those go towards. """ try: token = Token.objects.get(token=id) except Token.DoesNotExist: return Response( {'notes': ['This token does not exist.']}, status=404) if token.registration.completed: return Response( {'notes': ['This registration has already been completed.']}, status=400) if token.expires < timezone.now(): token.delete() return Response({'notes': ['This token has expired.']}, status=400) required_fields = [] actions = [] for action in token.registration.actions: action = action.get_action() actions.append(action) for field in action.token_fields: if field not in required_fields: required_fields.append(field) return Response({'actions': [unicode(act) for act in actions], 'required_fields': required_fields}) def post(self, request, id, format=None): """ Ensures the required fields are present, will then pass those to the actions via the submit function. """ try: token = Token.objects.get(token=id) except Token.DoesNotExist: return Response( {'notes': ['This token does not exist.']}, status=404) if token.registration.completed: return Response( {'notes': ['This registration has already been completed.']}, status=400) if token.expires < timezone.now(): token.delete() return Response({'notes': ['This token has expired.']}, status=400) required_fields = set() actions = [] for action in token.registration.actions: action = action.get_action() actions.append(action) for field in action.token_fields: required_fields.add(field) errors = {} data = {} for field in required_fields: try: data[field] = request.data[field] except KeyError: errors[field] = ["This field is required.", ] if errors: return Response(errors, status=400) for action in actions: try: action.submit(data) except Exception as e: notes = { 'errors': [("Error: '%s' while submitting registration. " + "See registration itself for details.") % e], 'registration': token.registration.uuid } create_notification(token.registration, notes) import traceback trace = traceback.format_exc() self.logger.critical(("(%s) - Exception escaped! %s\n" + "Trace: \n%s") % (timezone.now(), e, trace)) response_dict = { 'errors': ["Error: Something went wrong on the server. " + "It will be looked into shortly."] } return Response(response_dict, status=500) token.registration.completed = True token.registration.completed_on = timezone.now() token.registration.save() token.delete() return Response( {'notes': "Token submitted successfully."}, status=200) class ActionView(APIViewWithLogger): """ Base class for api calls that start a Registration. Until it is moved to settings, 'default_action' is a required hardcoded field. The default_action is considered the primary action and will always run first. Addtional actions are defined in the settings file and will run in the order supplied, but after the default_action. """ def get(self, request): """ The get method will return a json listing the actions this view will run, and the data fields that those actons require. """ actions = [self.default_action, ] actions += settings.API_ACTIONS.get(self.__class__.__name__, []) required_fields = [] for action in actions: action_class, action_serializer = settings.ACTION_CLASSES[action] for field in action_class.required: if field not in required_fields: required_fields.append(field) return Response({'actions': actions, 'required_fields': required_fields}) def process_actions(self, request): """ Will ensure the request data contains the required data based on the action serializer, and if present will create a Registration and the linked actions, attaching notes based on running of the the pre_approve validation function on all the actions. """ actions = [self.default_action, ] actions += settings.API_ACTIONS.get(self.__class__.__name__, []) act_list = [] valid = True for action in actions: action_class, action_serializer = settings.ACTION_CLASSES[action] if action_serializer is not None: serializer = action_serializer(data=request.data) else: serializer = None act_list.append({ 'name': action, 'action': action_class, 'serializer': serializer}) if serializer is not None and not serializer.is_valid(): valid = False if valid: ip_addr = request.META['REMOTE_ADDR'] keystone_user = request.keystone_user registration = Registration.objects.create( reg_ip=ip_addr, keystone_user=keystone_user) registration.save() for i, act in enumerate(act_list): if act['serializer'] is not None: data = act['serializer'].validated_data else: data = {} action = act['action']( data=data, registration=registration, order=i ) try: action.pre_approve() except Exception as e: notes = { 'errors': [("Error: '%s' while setting up registration. " + "See registration itself for details.") % e], 'registration': registration.uuid } create_notification(registration, notes) import traceback trace = traceback.format_exc() self.logger.critical(("(%s) - Exception escaped! %s\n" + "Trace: \n%s") % (timezone.now(), e, trace)) response_dict = { 'errors': ["Error: Something went wrong on the server. " + "It will be looked into shortly."] } return response_dict return {'registration': registration} else: errors = {} for act in act_list: if act['serializer'] is not None: errors.update(act['serializer'].errors) return {'errors': errors} def approve(self, registration): """ Approves the registration and runs the post_approve steps. Will create a token if required, otherwise will run the submit steps. """ registration.approved = True registration.approved_on = timezone.now() registration.save() action_models = registration.actions actions = [] valid = True need_token = False for action in action_models: act = action.get_action() actions.append(act) if not act.valid: valid = False if valid: for action in actions: try: action.post_approve() except Exception as e: notes = { 'errors': [("Error: '%s' while approving registration. " + "See registration itself for details.") % e], 'registration': registration.uuid } create_notification(registration, notes) import traceback trace = traceback.format_exc() self.logger.critical(("(%s) - Exception escaped! %s\n" + "Trace: \n%s") % (timezone.now(), e, trace)) response_dict = { 'errors': ["Error: Something went wrong on the server. " + "It will be looked into shortly."] } return Response(response_dict, status=500) if not action.valid: valid = False if action.need_token: need_token = True if valid: if need_token: token = create_token(registration) email_token(registration, token) return Response({'notes': ['created token']}, status=200) else: for action in actions: try: action.submit({}) except Exception as e: notes = { 'errors': [("Error: '%s' while submitting " + "registration. See registration " + "itself for details.") % e], 'registration': registration.uuid } create_notification(registration, notes) import traceback trace = traceback.format_exc() self.logger.critical(("(%s) - Exception escaped!" + " %s\n Trace: \n%s") % (timezone.now(), e, trace)) response_dict = { 'errors': ["Error: Something went wrong on the " + "server. It will be looked into shortly."] } return Response(response_dict, status=500) registration.completed = True registration.completed_on = timezone.now() registration.save() return Response( {'notes': "Registration completed successfully."}, status=200) return Response({'notes': ['actions invalid']}, status=400) return Response({'notes': ['actions invalid']}, status=400) class CreateProject(ActionView): default_action = "NewProject" def post(self, request, format=None): """ Unauthenticated endpoint bound primarily to NewProject. This process requires approval, so this will validate incoming data and create a registration to be approved later. """ self.logger.info("(%s) - Starting new project registration." % timezone.now()) processed = self.process_actions(request) errors = processed.get('errors', None) if errors: self.logger.info("(%s) - Validation errors with registration." % timezone.now()) return Response(errors, status=400) notes = { 'notes': ['New registration for CreateProject.'] } create_notification(processed['registration'], notes) self.logger.info("(%s) - Registration created." % timezone.now()) return Response({'notes': ['registration created']}, status=200) class AttachUser(ActionView): default_action = 'NewUser' @admin_or_owner def get(self, request): return super(AttachUser, self).get(request) @admin_or_owner def post(self, request, format=None): """ This endpoint requires either Admin access or the request to come from a project_owner. As such this Registration is considered pre-approved. Runs process_actions, then does the approve step and post_approve validation, and creates a Token if valid. """ self.logger.info("(%s) - New AttachUser request." % timezone.now()) processed = self.process_actions(request) errors = processed.get('errors', None) if errors: self.logger.info("(%s) - Validation errors with registration." % timezone.now()) return Response(errors, status=400) registration = processed['registration'] self.logger.info("(%s) - AutoApproving AttachUser request." % timezone.now()) return self.approve(registration) class ResetPassword(ActionView): default_action = 'ResetUser' def post(self, request, format=None): """ Unauthenticated endpoint bound to the password reset action. """ self.logger.info("(%s) - New ResetUser request." % timezone.now()) processed = self.process_actions(request) errors = processed.get('errors', None) if errors: self.logger.info("(%s) - Validation errors with registration." % timezone.now()) return Response(errors, status=400) registration = processed['registration'] self.logger.info("(%s) - AutoApproving Resetuser request." % timezone.now()) return self.approve(registration)