diff --git a/apps/organizations/decorators.py b/apps/organizations/decorators.py index c7a2029c..cabd4798 100644 --- a/apps/organizations/decorators.py +++ b/apps/organizations/decorators.py @@ -2,14 +2,17 @@ from functools import wraps # Django Imports +from django.core.exceptions import ObjectDoesNotExist from django.http import ( Http404, HttpResponseForbidden, ) -from django.shortcuts import get_object_or_404 # HTK Imports -from htk.api.utils import json_response_error +from htk.api.utils import ( + json_response_error, + json_response_not_found, +) from htk.apps.organizations.enums import OrganizationMemberRoles from htk.utils import htk_setting from htk.utils.general import resolve_model_dynamically @@ -122,3 +125,84 @@ def __call__(self, view_func): return require_organization_permission( OrganizationMemberRoles.MEMBER, content_type=self.content_type )(view_func) + + +class require_organization_member_user(object): + def __init__(self, content_type='text/html'): + self.content_type = content_type + + def __call__(self, view_func): + @wraps(view_func) + def wrapped_view(request, *args, **kwargs): + # NOTE: Actual view function might use organization or team_id. Do not pop it. + organization = kwargs.get('organization') + member_id = kwargs.get('member_id') + + try: + member = organization.members.get(id=member_id) + except ObjectDoesNotExist: + if self.content_type == 'application/json': + response = json_response_not_found() + else: + raise Http404 + else: + kwargs['member'] = member + response = view_func(request, *args, **kwargs) + + return response + + return wrapped_view + + +class require_organization_team(object): + def __init__(self, content_type='text/html'): + self.content_type = content_type + + def __call__(self, view_func): + @wraps(view_func) + def wrapped_view(request, *args, **kwargs): + # NOTE: Actual view function might use organization or team_id. Do not pop it. + organization = kwargs.get('organization') + team_id = kwargs.get('team_id') + + try: + team = organization.teams.get(id=team_id) + except ObjectDoesNotExist: + if self.content_type == 'application/json': + response = json_response_not_found() + else: + raise Http404 + else: + kwargs['team'] = team + response = view_func(request, *args, **kwargs) + + return response + + return wrapped_view + + +class require_organization_invitation(object): + def __init__(self, content_type='text/html'): + self.content_type = content_type + + def __call__(self, view_func): + @wraps(view_func) + def wrapped_view(request, *args, **kwargs): + # NOTE: Actual view function might use organization or team_id. Do not pop it. + organization = kwargs.get('organization') + invitation_id = kwargs.get('invitation_id') + + try: + invitation = organization.invitations.get(id=invitation_id) + except ObjectDoesNotExist: + if self.content_type == 'application/json': + response = json_response_not_found() + else: + raise Http404 + else: + kwargs['invitation'] = invitation + response = view_func(request, *args, **kwargs) + + return response + + return wrapped_view diff --git a/apps/organizations/forms.py b/apps/organizations/forms.py index 2bbc40ed..b32b1213 100644 --- a/apps/organizations/forms.py +++ b/apps/organizations/forms.py @@ -1,8 +1,92 @@ +# Django Imports from django import forms -from htk.apps.accounts.utils.general import get_user_by_id +# HTK Imports +from htk.apps.accounts.utils import get_user_by_id, get_user_by_email +from htk.apps.organizations.utils import invite_organization_member -class HTKOrganizationTeamForm(forms.ModelForm): + +class AbstractOrganizationInvitationForm(forms.ModelForm): + ERROR_MESSAGES = { + 'already_accepted': 'This email address has already been accepted the invitation.', + } + + class Meta: + fields = ['email'] + widgets = { + 'email': forms.EmailInput(attrs={'placeholder': 'Email'}), + } + + def __init_subclass__(cls, model, **kwargs) -> None: + super().__init_subclass__(**kwargs) + cls.Meta.model = model + + def __init__(self, organization, invited_by, *args, **kwargs): + super().__init__(*args, **kwargs) + self.instance.organization = organization + self.instance.invited_by = invited_by + + def clean_email(self): + email = self.cleaned_data.get('email') + + q = self.instance.organization.invitations.filter(email=email) + if q.exists(): + self.instance = q.first() + else: + pass + + if self.instance.accepted: + raise forms.ValidationError(self.ERROR_MESSAGES['already_accepted']) + else: + pass + + return email + + def save(self, request, *args, **kwargs): + invitation = super().save(commit=False) + invitation = invite_organization_member(request, invitation) + return invitation + + +class AbstractOrganizationMemberForm(forms.ModelForm): + ERROR_MESSAGES = { + 'user_not_found': 'There is no user with this email address', + 'already_member': 'That user is already a member of this organization.', + } + + class Meta: + fields = ['email'] + widgets = { + 'email': forms.EmailInput(attrs={'placeholder': 'Email'}), + } + + def __init_subclass__(cls, model, **kwargs) -> None: + super().__init_subclass__(**kwargs) + cls.Meta.model = model + + def __init__(self, organization, *args, **kwargs): + super().__init__(*args, **kwargs) + self.instance.organization = organization + + def clean_email(self): + email = self.cleaned_data.get('email') + user = get_user_by_email(email) + + if user is None: + raise forms.ValidationError(self.ERROR_MESSAGES['user_not_found']) + else: + pass + + q = self.instance.organization.members.filter(user__id=user.id) + if q.exists(): + raise forms.ValidationError(self.ERROR_MESSAGES['already_member']) + else: + pass + + return email + + +class AbstractOrganizationTeamForm(forms.ModelForm): ERROR_MESSAGES = { 'already_member': 'A team with this name already exists', } @@ -10,6 +94,10 @@ class HTKOrganizationTeamForm(forms.ModelForm): class Meta: fields = ['name'] + def __init_subclass__(cls, model, **kwargs) -> None: + super().__init_subclass__(**kwargs) + cls.Meta.model = model + def __init__(self, organization, *args, **kwargs): super().__init__(*args, **kwargs) self.instance.organization = organization @@ -27,7 +115,7 @@ def clean_name(self): return team_name -class HTKOrganizationTeamMemberForm(forms.ModelForm): +class AbstractOrganizationTeamMemberForm(forms.ModelForm): ERROR_MESSAGES = { 'user_not_found': 'There is no user with this email address', 'already_member': 'That user is already a member of this team.', @@ -38,6 +126,10 @@ class HTKOrganizationTeamMemberForm(forms.ModelForm): class Meta: fields = ['user_id'] + def __init_subclass__(cls, model, **kwargs) -> None: + super().__init_subclass__(**kwargs) + cls.Meta.model = model + def __init__(self, team, *args, **kwargs): super().__init__(*args, **kwargs) self.instance.team = team diff --git a/apps/organizations/models.py b/apps/organizations/models.py index f8a40d59..d27a1bce 100644 --- a/apps/organizations/models.py +++ b/apps/organizations/models.py @@ -1,6 +1,5 @@ # Python Standard Library Imports import collections -import hashlib import uuid from typing import ( Any, @@ -10,7 +9,6 @@ # Django Imports from django.conf import settings from django.db import models -from django.contrib.auth import get_user_model # HTK Imports from htk.apps.organizations.enums import ( @@ -180,7 +178,6 @@ def add_member(self, user, role, allow_duplicates=False): return member def add_owner(self, user): - OrganizationMember = get_model_organization_member() new_owner = self.add_member(user, OrganizationMemberRoles.OWNER) return new_owner @@ -217,12 +214,10 @@ class Meta: ) def __str__(self): - value = ( - '{organization_name} Member - {member_name} (member_email)'.format( - organization_name=self.organization.name, - member_name=self.user.profile.get_full_name(), - member_email=self.user.email, - ) + value = '{organization_name} Member - {member_name} ({member_email})'.format( + organization_name=self.organization.name, + member_name=self.user.profile.get_full_name(), + member_email=self.user.email, ) return value @@ -322,7 +317,7 @@ def status(self) -> str: # Notifications def _build_notification_message(self, subject, verb): - msg = '{subject_name} ({subject_username}<{email}>) has {verb} an invitation for Organization <{organization_name}>'.format( # noqa: E501 + msg = '{subject_name} ({subject_username}<{email}>) has {verb} an invitation for Organization <{organization_name}>'.format( # noqa: E501 verb=verb, subject_name=subject.profile.get_full_name(), subject_username=subject.username, @@ -410,6 +405,19 @@ def __str__(self): ) return value + def json_encode(self): + value = super().json_encode() + profile = self.user.profile + value.update( + { + 'first_name': self.user.first_name, + 'last_name': self.user.last_name, + 'display_name': profile.display_name, + 'username': self.user.username, + } + ) + return value + class BaseAbstractOrganizationTeamPosition(HtkBaseModel): name = models.CharField(max_length=128) diff --git a/apps/organizations/utils.py b/apps/organizations/utils.py index dc60acd7..b21c6d87 100644 --- a/apps/organizations/utils.py +++ b/apps/organizations/utils.py @@ -1,6 +1,7 @@ # Django Imports from django.apps import apps from django.contrib.sites.shortcuts import get_current_site +from django.utils import timezone # HTK Imports from htk.apps.organizations.emailers import send_invitation_email @@ -77,7 +78,7 @@ def invite_organization_member(request, invitation): first_name=invitation.first_name, last_name=invitation.last_name, site=get_current_site(request), - enable_early_access=True + enable_early_access=True, ) early_access_code = prelaunch_signup.early_access_code else: @@ -90,8 +91,12 @@ def invite_organization_member(request, invitation): pass # Save invitation so that updatedAt field will be updated + invitation.invited_at = timezone.now() + invitation.invited_by = request.user invitation.save() send_invitation_email( request, invitation, early_access_code=early_access_code ) + + return invitation diff --git a/apps/organizations/views.py b/apps/organizations/views.py index 63ec8971..74de83ff 100644 --- a/apps/organizations/views.py +++ b/apps/organizations/views.py @@ -1,3 +1,6 @@ +# Python Standard Library Imports +import json + # Django Imports from django.contrib.auth.decorators import login_required from django.http import Http404 @@ -7,6 +10,18 @@ from django.views import View # HTK Imports +from htk.api.utils import ( + json_response_error, + json_response_form_error, + json_response_okay, +) +from htk.apps.organizations.decorators import ( + require_organization_admin, + require_organization_member, + require_organization_member_user, + require_organization_team, + require_organization_invitation, +) from htk.apps.organizations.enums import OrganizationMemberRoles from htk.utils import ( htk_setting, @@ -16,6 +31,22 @@ from htk.view_helpers import render_custom as _r +class BaseFormRequiredView(View): + """Base Form Required View + + TODO: Move this to htk.view_helpers when approved. + """ + + FORM = None + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.FORM is None: + raise NotImplementedError( + f'{self.__class__.__name__} must define FORM' + ) + + class OrganizationInvitationResponseView(View): """Organization Invitation Response Class Based View @@ -96,3 +127,268 @@ def build_context(self): htk_setting('HTK_VIEW_CONTEXT_GENERATOR') ) self.data = wrap_data(self.request) + + +@method_decorator( + require_organization_admin(content_type='application/json'), name='dispatch' +) +class OrganizationInvitationsAPIView(BaseFormRequiredView): + """Organization Invitations API Endpoint + + NOTE: `FORM` must be defined in the subclass + """ + + ORDER_BY = ['-responded_at'] + + def get(self, request, *args, **kwargs): + organization = kwargs.pop('organization') + invitations = organization.invitations.order_by(*self.ORDER_BY) + response = json_response_okay( + { + 'object': 'list', + 'data': [ + invitation.json_encode() for invitation in invitations + ], + } + ) + return response + + def post(self, request, *args, **kwargs): + organization = kwargs.pop('organization') + data = json.loads(request.body) + user = request.user + + form = self.FORM(organization, user, data) + + if form.is_valid(): + invitation = form.save(request) + response = json_response_okay(invitation.json_encode()) + else: + response = json_response_form_error(form) + + return response + + +@method_decorator( + require_organization_admin(content_type='application/json'), name='dispatch' +) +@method_decorator( + require_organization_invitation(content_type='application/json'), + name='dispatch', +) +class OrganizationInvitationAPIView(View): + def delete(self, request, *args, **kwargs): + invitation = kwargs.pop('invitation') + + invitation.delete() + + response = json_response_okay() + return response + + +class OrganizationMembersAPIView(BaseFormRequiredView): + """Organization Members API Endpoint + + NOTE: `FORM` must be defined in the subclass + """ + + @method_decorator( + require_organization_member(content_type='application/json') + ) + def get(self, request, *args, **kwargs): + organization = kwargs.pop('organization') + members = organization.get_distinct_members() + response = json_response_okay( + { + 'object': 'list', + 'data': [member.json_encode() for member in members], + } + ) + return response + + @method_decorator( + require_organization_admin(content_type='application/json') + ) + def post(self, request, *args, **kwargs): + organization = kwargs.pop('organization') + data = json.loads(request.body) + form = self.FORM(organization, data) + + if form.is_valid(): + member = form.save() + response = json_response_okay(member.json_encode()) + else: + response = json_response_form_error(form) + + return response + + +@method_decorator( + require_organization_admin(content_type='application/json'), name='dispatch' +) +@method_decorator( + require_organization_member_user(content_type='application/json'), + name='dispatch', +) +class OrganizationMemberAPIView(View): + def delete(self, request, *args, **kwargs): + member = kwargs.pop('member') + member.delete() + response = json_response_okay() + + return response + + +class OrganizationTeamsAPIView(BaseFormRequiredView): + """Organization Teams API Endpoint + + NOTE: `FORM` must be defined in the subclass + """ + + @method_decorator( + require_organization_member(content_type='application/json') + ) + def get(self, request, *args, **kwargs): + organization = kwargs.pop('organization') + teams = organization.teams.all() + response = json_response_okay( + { + 'object': 'list', + 'data': [team.json_encode() for team in teams], + } + ) + return response + + @method_decorator( + require_organization_admin(content_type='application/json') + ) + def post(self, request, *args, **kwargs): + organization = kwargs.pop('organization') + data = json.loads(request.body) + form = self.FORM(organization, data) + + if form.is_valid(): + team = form.save() + response = json_response_okay(team.json_encode()) + else: + response = json_response_form_error(form) + + return response + + +@method_decorator( + require_organization_admin(content_type='application/json'), name='dispatch' +) +@method_decorator( + require_organization_team(content_type='application/json'), name='dispatch' +) +class OrganizationTeamAPIView(BaseFormRequiredView): + """Organization Team API Endpoint + + NOTE: `FORM` must be defined in the subclass + """ + + MESSAGES = { + 'delete_failed': 'This team can not be deleted until all other members have been removed.', + } + + def patch(self, request, *args, **kwargs): + """Update existing Organization Team""" + organization = kwargs.pop('organization') + team = kwargs.pop('team') + data = json.loads(request.body) + form = self.FORM(organization, data, instance=team) + + if form.is_valid(): + team = form.save() + response = json_response_okay(team.json_encode()) + else: + response = json_response_form_error(form) + + return response + + def delete(self, request, *args, **kwargs): + """Delete existing Organization Team""" + team = kwargs.pop('team') + was_deleted = team.delete() + if was_deleted: + response = json_response_okay() + else: + response = json_response_error( + {'error': self.MESSAGES['delete_failed']} + ) + + return response + + +class OrganizationTeamMembersAPIView(BaseFormRequiredView): + """Organization Team Members API Endpoint + + NOTE: `FORM` must be defined in the subclass + """ + + @method_decorator( + require_organization_member(content_type='application/json') + ) + @method_decorator( + require_organization_team(content_type='application/json') + ) + def get(self, request, *args, **kwargs): + team = kwargs.pop('team') + team_members = team.get_members() + response = json_response_okay( + { + 'object': 'list', + 'data': [ + team_member.json_encode() for team_member in team_members + ], + } + ) + + return response + + @method_decorator( + require_organization_admin(content_type='application/json') + ) + @method_decorator( + require_organization_team(content_type='application/json') + ) + def post(self, request, *args, **kwargs): + """Add existing user as new Organization Team Member""" + team = kwargs.pop('team') + data = json.loads(request.body) + form = self.FORM(team, data) + + if form.is_valid(): + team_member = form.save() + response = json_response_okay(team_member.json_encode()) + else: + response = json_response_form_error(form) + + return response + + +@method_decorator( + require_organization_admin(content_type='application/json'), name='dispatch' +) +@method_decorator( + require_organization_team(content_type='application/json'), name='dispatch' +) +class OrganizationTeamMemberAPIView(View): + def delete(self, request, member_id, *args, **kwargs): + """Delete existing Organization Team Member""" + team = kwargs.pop('team') + user = request.user + + (deleted_count, _) = ( + team.members.exclude(user=user).filter(user__id=member_id).delete() + ) + + if deleted_count == 0: + response = json_response_error( + {'message': 'This member can not be deleted.'} + ) + else: + response = json_response_okay() + + return response