diff --git a/apiserver/plane/app/serializers/__init__.py b/apiserver/plane/app/serializers/__init__.py index 0d72f9192..495b57483 100644 --- a/apiserver/plane/app/serializers/__init__.py +++ b/apiserver/plane/app/serializers/__init__.py @@ -7,6 +7,8 @@ from .user import ( UserAdminLiteSerializer, UserMeSerializer, UserMeSettingsSerializer, + ProfileSerializer, + AccountSerializer, ) from .workspace import ( WorkSpaceSerializer, @@ -116,7 +118,10 @@ from .inbox import ( from .analytic import AnalyticViewSerializer -from .notification import NotificationSerializer, UserNotificationPreferenceSerializer +from .notification import ( + NotificationSerializer, + UserNotificationPreferenceSerializer, +) from .exporter import ExporterHistorySerializer diff --git a/apiserver/plane/app/serializers/user.py b/apiserver/plane/app/serializers/user.py index 8cd48827e..df7d4266d 100644 --- a/apiserver/plane/app/serializers/user.py +++ b/apiserver/plane/app/serializers/user.py @@ -3,7 +3,7 @@ from rest_framework import serializers # Module import from .base import BaseSerializer -from plane.db.models import User, Workspace, WorkspaceMemberInvite +from plane.db.models import User, Workspace, WorkspaceMemberInvite, Profile, Account from plane.license.models import InstanceAdmin, Instance @@ -24,7 +24,6 @@ class UserSerializer(BaseSerializer): "last_logout_ip", "last_login_uagent", "token_updated_at", - "is_onboarded", "is_bot", "is_password_autoset", "is_email_verified", @@ -52,16 +51,9 @@ class UserMeSerializer(BaseSerializer): "is_bot", "is_email_verified", "is_managed", - "is_onboarded", - "is_tour_completed", "mobile_number", - "role", - "onboarding_step", "user_timezone", "username", - "theme", - "last_workspace_id", - "use_case", "is_password_autoset", "is_email_verified", ] @@ -84,25 +76,28 @@ class UserMeSettingsSerializer(BaseSerializer): workspace_invites = WorkspaceMemberInvite.objects.filter( email=obj.email ).count() + + # profile + profile = Profile.objects.get(user=obj) if ( - obj.last_workspace_id is not None + profile.last_workspace_id is not None and Workspace.objects.filter( - pk=obj.last_workspace_id, + pk=profile.last_workspace_id, workspace_member__member=obj.id, workspace_member__is_active=True, ).exists() ): workspace = Workspace.objects.filter( - pk=obj.last_workspace_id, + pk=profile.last_workspace_id, workspace_member__member=obj.id, workspace_member__is_active=True, ).first() return { - "last_workspace_id": obj.last_workspace_id, + "last_workspace_id": profile.last_workspace_id, "last_workspace_slug": workspace.slug if workspace is not None else "", - "fallback_workspace_id": obj.last_workspace_id, + "fallback_workspace_id": profile.last_workspace_id, "fallback_workspace_slug": workspace.slug if workspace is not None else "", @@ -197,3 +192,16 @@ class ResetPasswordSerializer(serializers.Serializer): """ new_password = serializers.CharField(required=True, min_length=8) + +class ProfileSerializer(BaseSerializer): + + class Meta: + model = Profile + fields = "__all__" + + +class AccountSerializer(BaseSerializer): + + class Meta: + model = Account + fields = "__all__" \ No newline at end of file diff --git a/apiserver/plane/app/urls/user.py b/apiserver/plane/app/urls/user.py index 9dae7b5da..04ebddde5 100644 --- a/apiserver/plane/app/urls/user.py +++ b/apiserver/plane/app/urls/user.py @@ -3,6 +3,8 @@ from django.urls import path from plane.app.views import ( ## User UserEndpoint, + ProfileEndpoint, + AccountEndpoint, UpdateUserOnBoardedEndpoint, UpdateUserTourCompletedEndpoint, UserActivityEndpoint, @@ -39,6 +41,16 @@ urlpatterns = [ ), name="users", ), + path( + "users/me/profile/", + ProfileEndpoint.as_view(), + name="accounts", + ), + path( + "users/me/accounts/", + AccountEndpoint.as_view(), + name="accounts", + ), path( "users/me/instance-admin/", UserEndpoint.as_view( @@ -96,4 +108,4 @@ urlpatterns = [ name="set-password", ), ## End User Graph -] +] \ No newline at end of file diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index 0a959a667..9f9617e5c 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -18,6 +18,8 @@ from .user import ( UpdateUserOnBoardedEndpoint, UpdateUserTourCompletedEndpoint, UserActivityEndpoint, + ProfileEndpoint, + AccountEndpoint, ) from .oauth import OauthEndpoint @@ -180,7 +182,4 @@ from .webhook import ( WebhookSecretRegenerateEndpoint, ) -from .dashboard import ( - DashboardEndpoint, - WidgetsEndpoint -) \ No newline at end of file +from .dashboard import DashboardEndpoint, WidgetsEndpoint diff --git a/apiserver/plane/app/views/user.py b/apiserver/plane/app/views/user.py index 7764e3b97..99a1e607c 100644 --- a/apiserver/plane/app/views/user.py +++ b/apiserver/plane/app/views/user.py @@ -9,10 +9,19 @@ from plane.app.serializers import ( IssueActivitySerializer, UserMeSerializer, UserMeSettingsSerializer, + ProfileSerializer, + AccountSerializer, ) from plane.app.views.base import BaseViewSet, BaseAPIView -from plane.db.models import User, IssueActivity, WorkspaceMember, ProjectMember +from plane.db.models import ( + Account, + User, + IssueActivity, + WorkspaceMember, + ProjectMember, + Profile, +) from plane.license.models import Instance, InstanceAdmin from plane.utils.paginator import BasePaginator @@ -131,24 +140,29 @@ class UserEndpoint(BaseViewSet): # Deactivate the user user.is_active = False - user.last_workspace_id = None - user.is_tour_completed = False - user.is_onboarded = False - user.onboarding_step = { + + # Profile updates + profile = Profile.objects.get(user=user) + + profile.last_workspace_id = None + profile.is_tour_completed = False + profile.is_onboarded = False + profile.onboarding_step = { "workspace_join": False, "profile_complete": False, "workspace_create": False, "workspace_invite": False, } + profile.save() user.save() return Response(status=status.HTTP_204_NO_CONTENT) class UpdateUserOnBoardedEndpoint(BaseAPIView): def patch(self, request): - user = User.objects.get(pk=request.user.id, is_active=True) - user.is_onboarded = request.data.get("is_onboarded", False) - user.save() + profile = Profile.objects.get(user_id=request.user.id) + profile.is_onboarded = request.data.get("is_onboarded", False) + profile.save() return Response( {"message": "Updated successfully"}, status=status.HTTP_200_OK ) @@ -156,9 +170,11 @@ class UpdateUserOnBoardedEndpoint(BaseAPIView): class UpdateUserTourCompletedEndpoint(BaseAPIView): def patch(self, request): - user = User.objects.get(pk=request.user.id, is_active=True) - user.is_tour_completed = request.data.get("is_tour_completed", False) - user.save() + profile = Profile.objects.get(user_id=request.user.id, is_active=True) + profile.is_tour_completed = request.data.get( + "is_tour_completed", False + ) + profile.save() return Response( {"message": "Updated successfully"}, status=status.HTTP_200_OK ) @@ -177,3 +193,18 @@ class UserActivityEndpoint(BaseAPIView, BasePaginator): issue_activities, many=True ).data, ) + + +class ProfileEndpoint(BaseAPIView): + def get(self, request): + profile = Profile.objects.get(user=request.user) + serializer = ProfileSerializer(profile) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class AccountEndpoint(BaseAPIView): + + def get(self, request): + accounts = Account.objects.filter(user=request.user) + serializer = AccountSerializer(accounts, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) \ No newline at end of file diff --git a/apiserver/plane/db/migrations/0059_remove_user_billing_address_and_more.py b/apiserver/plane/db/migrations/0059_remove_user_billing_address_and_more.py new file mode 100644 index 000000000..463d0f905 --- /dev/null +++ b/apiserver/plane/db/migrations/0059_remove_user_billing_address_and_more.py @@ -0,0 +1,213 @@ +# Generated by Django 4.2.7 on 2024-01-26 12:35 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import plane.db.models.user + + +def migrate_user_extra_profile(apps, schema_editor): + Profile = apps.get_model("db", "Profile") + User = apps.get_model("db", "User") + + Profile.objects.bulk_create( + [ + Profile( + user_id=user.get("id"), + theme=user.get("theme"), + is_tour_completed=user.get("is_tour_completed"), + use_case=user.get("use_case"), + is_onboarded=user.get("is_onboarded"), + last_workspace_id=user.get("last_workspace_id"), + billing_address_country=user.get("billing_address_country"), + billing_address=user.get("billing_address"), + has_billing_address=user.get("has_billing_address"), + ) + for user in User.objects.values( + "id", + "theme", + "is_tour_completed", + "onboarding_step", + "use_case", + "role", + "is_onboarded", + "last_workspace_id", + "billing_address_country", + "billing_address", + "has_billing_address", + ) + ], + batch_size=1000, + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0058_alter_moduleissue_issue_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="Profile", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ("theme", models.JSONField(default=dict)), + ("is_tour_completed", models.BooleanField(default=False)), + ( + "onboarding_step", + models.JSONField( + default=plane.db.models.user.get_default_onboarding + ), + ), + ("use_case", models.TextField(blank=True, null=True)), + ( + "role", + models.CharField(blank=True, max_length=300, null=True), + ), + ("is_onboarded", models.BooleanField(default=False)), + ("last_workspace_id", models.UUIDField(null=True)), + ( + "billing_address_country", + models.CharField(default="INDIA", max_length=255), + ), + ("billing_address", models.JSONField(null=True)), + ("has_billing_address", models.BooleanField(default=False)), + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="profile", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Profile", + "verbose_name_plural": "Profiles", + "db_table": "profiles", + "ordering": ("-created_at",), + }, + ), + migrations.RunPython(migrate_user_extra_profile), + migrations.RemoveField( + model_name="user", + name="billing_address", + ), + migrations.RemoveField( + model_name="user", + name="billing_address_country", + ), + migrations.RemoveField( + model_name="user", + name="has_billing_address", + ), + migrations.RemoveField( + model_name="user", + name="is_onboarded", + ), + migrations.RemoveField( + model_name="user", + name="is_tour_completed", + ), + migrations.RemoveField( + model_name="user", + name="last_workspace_id", + ), + migrations.RemoveField( + model_name="user", + name="my_issues_prop", + ), + migrations.RemoveField( + model_name="user", + name="onboarding_step", + ), + migrations.RemoveField( + model_name="user", + name="role", + ), + migrations.RemoveField( + model_name="user", + name="theme", + ), + migrations.RemoveField( + model_name="user", + name="use_case", + ), + migrations.CreateModel( + name="Account", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ("provider_account_id", models.CharField(max_length=255)), + ( + "provider", + models.CharField( + choices=[("google", "Google"), ("github", "Github")] + ), + ), + ("access_token", models.TextField()), + ("access_token_expired_at", models.DateTimeField(null=True)), + ("refresh_token", models.TextField(blank=True, null=True)), + ("refresh_token_expired_at", models.DateTimeField(null=True)), + ( + "last_connected_at", + models.DateTimeField(default=django.utils.timezone.now), + ), + ("metadata", models.JSONField(default=dict)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="accounts", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Account", + "verbose_name_plural": "Accounts", + "db_table": "accounts", + "ordering": ("-created_at",), + }, + ), + ] \ No newline at end of file diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index d9096bd01..85c135d89 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -1,6 +1,6 @@ from .base import BaseModel -from .user import User +from .user import User, Profile, Account from .workspace import ( Workspace, diff --git a/apiserver/plane/db/models/user.py b/apiserver/plane/db/models/user.py index 6f8a82e56..d4f5efd69 100644 --- a/apiserver/plane/db/models/user.py +++ b/apiserver/plane/db/models/user.py @@ -21,6 +21,9 @@ from sentry_sdk import capture_exception from slack_sdk import WebClient from slack_sdk.errors import SlackApiError +# Module imports +from ..mixins import TimeAuditModel + def get_default_onboarding(): return { @@ -40,12 +43,14 @@ class User(AbstractBaseUser, PermissionsMixin): primary_key=True, ) username = models.CharField(max_length=128, unique=True) - # user fields mobile_number = models.CharField(max_length=255, blank=True, null=True) email = models.CharField( max_length=255, null=True, blank=True, unique=True ) + + # identity + display_name = models.CharField(max_length=255, default="") first_name = models.CharField(max_length=255, blank=True) last_name = models.CharField(max_length=255, blank=True) avatar = models.CharField(max_length=255, blank=True) @@ -72,19 +77,10 @@ class User(AbstractBaseUser, PermissionsMixin): is_staff = models.BooleanField(default=False) is_email_verified = models.BooleanField(default=False) is_password_autoset = models.BooleanField(default=False) - is_onboarded = models.BooleanField(default=False) + # random token generated token = models.CharField(max_length=64, blank=True) - billing_address_country = models.CharField(max_length=255, default="INDIA") - billing_address = models.JSONField(null=True) - has_billing_address = models.BooleanField(default=False) - - USER_TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones)) - user_timezone = models.CharField( - max_length=255, default="UTC", choices=USER_TIMEZONE_CHOICES - ) - last_active = models.DateTimeField(default=timezone.now, null=True) last_login_time = models.DateTimeField(null=True) last_logout_time = models.DateTimeField(null=True) @@ -96,18 +92,17 @@ class User(AbstractBaseUser, PermissionsMixin): ) last_login_uagent = models.TextField(blank=True) token_updated_at = models.DateTimeField(null=True) - last_workspace_id = models.UUIDField(null=True) - my_issues_prop = models.JSONField(null=True) - role = models.CharField(max_length=300, null=True, blank=True) + # my_issues_prop = models.JSONField(null=True) + is_bot = models.BooleanField(default=False) - theme = models.JSONField(default=dict) - display_name = models.CharField(max_length=255, default="") - is_tour_completed = models.BooleanField(default=False) - onboarding_step = models.JSONField(default=get_default_onboarding) - use_case = models.TextField(blank=True, null=True) + + # timezone + USER_TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones)) + user_timezone = models.CharField( + max_length=255, default="UTC", choices=USER_TIMEZONE_CHOICES + ) USERNAME_FIELD = "email" - REQUIRED_FIELDS = ["username"] objects = UserManager() @@ -144,6 +139,60 @@ class User(AbstractBaseUser, PermissionsMixin): super(User, self).save(*args, **kwargs) + +class Profile(TimeAuditModel): + # User + user = models.OneToOneField("db.User", on_delete=models.CASCADE, related_name="profile") + + # General + theme = models.JSONField(default=dict) + + # Onboarding + is_tour_completed = models.BooleanField(default=False) + onboarding_step = models.JSONField(default=get_default_onboarding) + use_case = models.TextField(blank=True, null=True) + role = models.CharField(max_length=300, null=True, blank=True) # job role + is_onboarded = models.BooleanField(default=False) + + # Last visited workspace + last_workspace_id = models.UUIDField(null=True) + + # address data + billing_address_country = models.CharField(max_length=255, default="INDIA") + billing_address = models.JSONField(null=True) + has_billing_address = models.BooleanField(default=False) + + class Meta: + verbose_name = "Profile" + verbose_name_plural = "Profiles" + db_table = "profiles" + ordering = ("-created_at",) + + + +class Account(TimeAuditModel): + user = models.ForeignKey( + "db.User", on_delete=models.CASCADE, related_name="accounts" + ) + provider_account_id = models.CharField(max_length=255) + provider = models.CharField( + choices=(("google", "Google"), ("github", "Github")) + ) + access_token = models.TextField() + access_token_expired_at = models.DateTimeField(null=True) + refresh_token = models.TextField(null=True, blank=True) + refresh_token_expired_at = models.DateTimeField(null=True) + last_connected_at = models.DateTimeField(default=timezone.now) + metadata = models.JSONField(default=dict) + + class Meta: + verbose_name = "Account" + verbose_name_plural = "Accounts" + db_table = "accounts" + ordering = ("-created_at",) + + + @receiver(post_save, sender=User) def send_welcome_slack(sender, instance, created, **kwargs): try: @@ -170,6 +219,7 @@ def create_user_notification(sender, instance, created, **kwargs): if created and not instance.is_bot: # Module imports from plane.db.models import UserNotificationPreference + UserNotificationPreference.objects.create( user=instance, - ) + ) \ No newline at end of file