Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Migrate team invite signup to React - Part I (Backend) #2734

Merged
merged 20 commits into from
Feb 11, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions frontend/src/scenes/onboarding/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ function Signup() {
const [state, setState] = useState({ submitted: false })
const [formState, setFormState] = useState({
firstName: {},
companyName: {},
organizationName: {},
email: {},
password: {},
emailOptIn: { value: true },
Expand Down Expand Up @@ -43,7 +43,7 @@ function Signup() {
}
const payload = {
first_name: formState.firstName.value,
company_name: formState.companyName.value || undefined,
organization_name: formState.organizationName.value || undefined,
email: formState.email.value,
password: formState.password.value,
email_opt_in: formState.emailOptIn.value,
Expand Down Expand Up @@ -125,13 +125,13 @@ function Signup() {
</div>

<div className="input-set">
<label htmlFor="signupCompanyName">Company or Project</label>
<label htmlFor="signupOrganizationName">Organization Name</label>
<Input
placeholder="Hogflix Movies"
value={formState.companyName.value}
onChange={(e) => updateForm('companyName', e.target)}
value={formState.organizationName.value}
onChange={(e) => updateForm('organizationName', e.target)}
disabled={accountLoading}
id="signupCompanyName"
id="signupOrganizationName"
/>
</div>

Expand Down
182 changes: 161 additions & 21 deletions posthog/api/organization.py
Original file line number Diff line number Diff line change
@@ -1,41 +1,37 @@
from typing import Any, Dict, List, Optional, Union
from typing import Any, Dict, List, Optional, Union, cast

import posthoganalytics
from django.conf import settings
from django.contrib.auth import login, password_validation
from django.db import transaction
from django.db.models import QuerySet
from django.shortcuts import get_object_or_404
from rest_framework import (
exceptions,
generics,
permissions,
request,
response,
serializers,
status,
viewsets,
)
from django.shortcuts import get_object_or_404, redirect
from django.urls.base import reverse
from rest_framework import exceptions, generics, permissions, response, serializers, status, viewsets
from rest_framework.request import Request

from posthog.api.routing import StructuredViewSetMixin
from posthog.api.user import UserSerializer
from posthog.demo import create_demo_team
from posthog.event_usage import report_onboarding_completed, report_user_signed_up
from posthog.event_usage import report_onboarding_completed, report_user_joined_organization, report_user_signed_up
from posthog.models import Organization, Team, User
from posthog.models.organization import OrganizationMembership
from posthog.models.organization import OrganizationInvite, OrganizationMembership
from posthog.permissions import (
CREATE_METHODS,
OrganizationAdminWritePermissions,
OrganizationMemberPermissions,
UninitiatedOrCloudOnly,
)
from posthog.tasks import user_identify
from posthog.utils import mask_email_address


class PremiumMultiorganizationPermissions(permissions.BasePermission):
"""Require user to have all necessary premium features on their plan for create access to the endpoint."""

message = "You must upgrade your PostHog plan to be able to create and manage multiple organizations."

def has_permission(self, request: request.Request, view) -> bool:
def has_permission(self, request: Request, view) -> bool:
if (
# make multiple orgs only premium on self-hosted, since enforcement of this is not possible on Cloud
not getattr(settings, "MULTI_TENANCY", False)
Expand Down Expand Up @@ -126,8 +122,8 @@ class OrganizationViewSet(viewsets.ModelViewSet):

def get_permissions(self) -> List[permissions.BasePermission]:
if self.request.method == "POST":
# Cannot use `OrganizationMemberPermissions` or `OrganizationAdminWritePermissions` because they require an existing org,
# unneded anyways because permissions are organization-based
# Cannot use `OrganizationMemberPermissions` or `OrganizationAdminWritePermissions`
# because they require an existing org, unneded anyways because permissions are organization-based
return [permission() for permission in [permissions.IsAuthenticated, PremiumMultiorganizationPermissions]]
return super().get_permissions()

Expand Down Expand Up @@ -162,7 +158,7 @@ class OrganizationSignupSerializer(serializers.Serializer):
first_name: serializers.Field = serializers.CharField(max_length=128)
email: serializers.Field = serializers.EmailField()
password: serializers.Field = serializers.CharField(allow_null=True)
company_name: serializers.Field = serializers.CharField(max_length=128, required=False, allow_blank=True)
organization_name: serializers.Field = serializers.CharField(max_length=128, required=False, allow_blank=True)
email_opt_in: serializers.Field = serializers.BooleanField(default=True)

def validate_password(self, value):
Expand All @@ -173,10 +169,10 @@ def validate_password(self, value):
def create(self, validated_data, **kwargs):
is_instance_first_user: bool = not User.objects.exists()

company_name = validated_data.pop("company_name", validated_data["first_name"])
organization_name = validated_data.pop("organization_name", validated_data["first_name"])

self._organization, self._team, self._user = User.objects.bootstrap(
company_name=company_name, create_team=self.create_team, **validated_data,
organization_name=organization_name, create_team=self.create_team, **validated_data,
)
user = self._user

Expand Down Expand Up @@ -216,9 +212,153 @@ def enable_new_onboarding(self, user: Optional[User] = None) -> bool:
return posthoganalytics.feature_enabled("new-onboarding-2822", user.distinct_id) or settings.DEBUG


class OrganizationSocialSignupSerializer(serializers.Serializer):
"""
Signup serializer when the account is created using social authentication.
Pre-processes information not obtained from SSO provider to create organization.
"""

organization_name: serializers.Field = serializers.CharField(max_length=128)
email_opt_in: serializers.Field = serializers.BooleanField(default=True)

def create(self, validated_data, **kwargs):
request = self.context["request"]

if not request.session.get("backend"):
raise serializers.ValidationError(
"Inactive social login session. Go to /login and log in before continuing.",
)

request.session["organization_name"] = validated_data["organization_name"]
request.session["email_opt_in"] = validated_data["email_opt_in"]
request.session.set_expiry(3600) # 1 hour to complete process
return {"continue_url": reverse("social:complete", args=[request.session["backend"]])}

def to_representation(self, instance: Any) -> Any:
return self.instance


class OrganizationSignupViewset(generics.CreateAPIView):
serializer_class = OrganizationSignupSerializer
permission_classes = [UninitiatedOrCloudOnly]
permission_classes = (UninitiatedOrCloudOnly,)


class OrganizationSocialSignupViewset(generics.CreateAPIView):
serializer_class = OrganizationSocialSignupSerializer
permission_classes = (UninitiatedOrCloudOnly,)


class OrganizationInviteSignupSerializer(serializers.Serializer):
first_name: serializers.Field = serializers.CharField(max_length=128, required=False)
password: serializers.Field = serializers.CharField(required=False)
email_opt_in: serializers.Field = serializers.BooleanField(default=True)

def validate_password(self, value):
password_validation.validate_password(value)
return value

def to_representation(self, instance):
serializer = UserSerializer(instance=instance)
return serializer.data

def validate(self, data: Dict[str, Any]) -> Dict[str, Any]:

if "request" not in self.context or not self.context["request"].user.is_authenticated:
# If there's no authenticated user and we're creating a new one, attributes are required.

for attr in ["first_name", "password"]:
if not data.get(attr):
raise serializers.ValidationError({attr: "This field is required."}, code="required")

return data

def create(self, validated_data, **kwargs):
if "view" not in self.context or not self.context["view"].kwargs.get("invite_id"):
raise serializers.ValidationError("Please provide an invite ID to continue.")

user: Optional[User] = None
is_new_user: bool = False

if self.context["request"].user.is_authenticated:
user = cast(User, self.context["request"].user)

invite_id = self.context["view"].kwargs.get("invite_id")

try:
invite: OrganizationInvite = OrganizationInvite.objects.select_related("organization").get(id=invite_id)
except (OrganizationInvite.DoesNotExist):
raise serializers.ValidationError("The provided invite ID is not valid.")

with transaction.atomic():
if not user:
is_new_user = True
user = User.objects.create_user(
invite.target_email,
validated_data.pop("password"),
validated_data.pop("first_name"),
**validated_data,
)

try:
invite.use(user)
except ValueError as e:
raise serializers.ValidationError(str(e))

if is_new_user:
login(
self.context["request"], user, backend="django.contrib.auth.backends.ModelBackend",
)

report_user_signed_up(
user.distinct_id,
is_instance_first_user=False,
is_organization_first_user=False,
new_onboarding_enabled=(not invite.organization.setup_section_2_completed),
backend_processor="OrganizationInviteSignupSerializer",
)

else:
report_user_joined_organization(organization=invite.organization, current_user=user)

# Update user props
user_identify.identify_task.delay(user_id=user.id)

return user


class OrganizationInviteSignupViewset(generics.CreateAPIView):
serializer_class = OrganizationInviteSignupSerializer
permission_classes = (permissions.AllowAny,)

def get(self, request, *args, **kwargs):
"""
Pre-validates an invite code.
"""

invite_id = kwargs.get("invite_id")

if not invite_id:
raise exceptions.ValidationError("Please provide an invite ID to continue.")

try:
invite: OrganizationInvite = OrganizationInvite.objects.get(id=invite_id)
except (OrganizationInvite.DoesNotExist):
raise serializers.ValidationError("The provided invite ID is not valid.")

user = request.user if request.user.is_authenticated else None

try:
invite.validate(user=user)
except ValueError as e:
raise serializers.ValidationError(str(e))

return response.Response(
{
"target_email": mask_email_address(invite.target_email),
"first_name": invite.first_name,
"organization_name": invite.organization.name,
}
)


class OrganizationOnboardingViewset(StructuredViewSetMixin, viewsets.GenericViewSet):
Expand Down
18 changes: 6 additions & 12 deletions posthog/api/organization_invite.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Any, Dict, List
from typing import Any, Dict, List, cast

from django.db import transaction
from rest_framework import exceptions, mixins, serializers, viewsets
Expand All @@ -9,6 +9,7 @@
from posthog.email import is_email_available
from posthog.event_usage import report_bulk_invited, report_team_member_invited
from posthog.models import OrganizationInvite, OrganizationMembership
from posthog.models.organization import Organization
from posthog.permissions import OrganizationAdminWritePermissions, OrganizationMemberPermissions
from posthog.tasks.email import send_invite

Expand Down Expand Up @@ -54,10 +55,7 @@ def create(self, validated_data: Dict[str, Any], *args: Any, **kwargs: Any) -> O
report_team_member_invited(
self.context["request"].user.distinct_id,
name_provided=bool(validated_data.get("first_name")),
current_invite_count=OrganizationInvite.objects.filter(
organization_id=self.context["organization_id"],
).count()
- 1,
current_invite_count=invite.organization.active_invites.count(),
current_member_count=OrganizationMembership.objects.filter(
organization_id=self.context["organization_id"],
).count(),
Expand All @@ -79,9 +77,7 @@ def validate_invites(self, data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:

def create(self, validated_data: Dict[str, Any]) -> Dict[str, Any]:
output = []
current_invite_count = OrganizationInvite.objects.filter(
organization_id=self.context["organization_id"],
).count()
organization = Organization.objects.get(id=self.context["organization_id"])

with transaction.atomic():
for invite in validated_data["invites"]:
Expand All @@ -94,10 +90,8 @@ def create(self, validated_data: Dict[str, Any]) -> Dict[str, Any]:
self.context["request"].user.distinct_id,
invitee_count=len(validated_data["invites"]),
name_count=sum(1 for invite in validated_data["invites"] if invite["first_name"]),
current_invite_count=current_invite_count,
current_member_count=OrganizationMembership.objects.filter(
organization_id=self.context["organization_id"],
).count(),
current_invite_count=organization.active_invites.count(),
current_member_count=organization.memberships.count(),
email_available=is_email_available(),
)

Expand Down
4 changes: 2 additions & 2 deletions posthog/api/test/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

class TestMixin:
TESTS_API: bool = False
TESTS_COMPANY_NAME: str = "Test"
TESTS_ORGANIZATION_NAME: str = "Test Org"
TESTS_EMAIL: Optional[str] = "[email protected]"
TESTS_PASSWORD: Optional[str] = "testpassword12345"
TESTS_API_TOKEN: str = "token123"
Expand All @@ -22,7 +22,7 @@ def _create_user(self, email: str, password: Optional[str] = None, first_name: s

def setUp(self):
super().setUp() # type: ignore
self.organization: Organization = Organization.objects.create(name=self.TESTS_COMPANY_NAME)
self.organization: Organization = Organization.objects.create(name=self.TESTS_ORGANIZATION_NAME)
self.team: Team = Team.objects.create(organization=self.organization, api_token=self.TESTS_API_TOKEN)
if self.TESTS_EMAIL:
self.user: User = self._create_user(self.TESTS_EMAIL, self.TESTS_PASSWORD)
Expand Down
Loading