diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index 7376cf0ff..7aeee7d70 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -462,9 +462,9 @@ class IssueAttachmentSerializer(BaseSerializer): # Issue Serializer with state details class IssueStateSerializer(BaseSerializer): - state_detail = StateSerializer(read_only=True, source="state") - project_detail = ProjectSerializer(read_only=True, source="project") - label_details = LabelSerializer(read_only=True, source="labels", many=True) + label_details = LabelLiteSerializer(read_only=True, source="labels", many=True) + state_detail = StateLiteSerializer(read_only=True, source="state") + project_detail = ProjectLiteSerializer(read_only=True, source="project") assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) sub_issues_count = serializers.IntegerField(read_only=True) bridge_id = serializers.UUIDField(read_only=True) @@ -477,7 +477,7 @@ class IssueStateSerializer(BaseSerializer): class IssueSerializer(BaseSerializer): - project_detail = ProjectSerializer(read_only=True, source="project") + project_detail = ProjectLiteSerializer(read_only=True, source="project") state_detail = StateSerializer(read_only=True, source="state") parent_detail = IssueFlatSerializer(read_only=True, source="parent") label_details = LabelSerializer(read_only=True, source="labels", many=True) diff --git a/apiserver/plane/api/serializers/module.py b/apiserver/plane/api/serializers/module.py index ea9edd82c..a82a0f39f 100644 --- a/apiserver/plane/api/serializers/module.py +++ b/apiserver/plane/api/serializers/module.py @@ -106,7 +106,7 @@ class ModuleFlatSerializer(BaseSerializer): class ModuleIssueSerializer(BaseSerializer): module_detail = ModuleFlatSerializer(read_only=True, source="module") - issue_detail = IssueStateSerializer(read_only=True, source="issue") + issue_detail = ProjectLiteSerializer(read_only=True, source="issue") sub_issues_count = serializers.IntegerField(read_only=True) class Meta: @@ -151,7 +151,7 @@ class ModuleLinkSerializer(BaseSerializer): class ModuleSerializer(BaseSerializer): - project_detail = ProjectSerializer(read_only=True, source="project") + project_detail = ProjectLiteSerializer(read_only=True, source="project") lead_detail = UserLiteSerializer(read_only=True, source="lead") members_detail = UserLiteSerializer(read_only=True, many=True, source="members") link_module = ModuleLinkSerializer(read_only=True, many=True) diff --git a/apiserver/plane/api/serializers/notification.py b/apiserver/plane/api/serializers/notification.py index 529cb9f9c..b6a4f3e4a 100644 --- a/apiserver/plane/api/serializers/notification.py +++ b/apiserver/plane/api/serializers/notification.py @@ -1,8 +1,10 @@ # Module imports from .base import BaseSerializer +from .user import UserLiteSerializer from plane.db.models import Notification class NotificationSerializer(BaseSerializer): + triggered_by_details = UserLiteSerializer(read_only=True, source="triggered_by") class Meta: model = Notification diff --git a/apiserver/plane/api/serializers/project.py b/apiserver/plane/api/serializers/project.py index db6021433..641edb07c 100644 --- a/apiserver/plane/api/serializers/project.py +++ b/apiserver/plane/api/serializers/project.py @@ -110,8 +110,8 @@ class ProjectMemberSerializer(BaseSerializer): class ProjectMemberInviteSerializer(BaseSerializer): - project = ProjectSerializer(read_only=True) - workspace = WorkSpaceSerializer(read_only=True) + project = ProjectLiteSerializer(read_only=True) + workspace = WorkspaceLiteSerializer(read_only=True) class Meta: model = ProjectMemberInvite @@ -125,7 +125,7 @@ class ProjectIdentifierSerializer(BaseSerializer): class ProjectFavoriteSerializer(BaseSerializer): - project_detail = ProjectSerializer(source="project", read_only=True) + project_detail = ProjectLiteSerializer(source="project", read_only=True) class Meta: model = ProjectFavorite @@ -136,11 +136,6 @@ class ProjectFavoriteSerializer(BaseSerializer): ] -class ProjectLiteSerializer(BaseSerializer): - class Meta: - model = Project - fields = ["id", "identifier", "name"] - read_only_fields = fields class ProjectMemberLiteSerializer(BaseSerializer): diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index 1958f5c18..04bbc2a47 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -22,6 +22,7 @@ from plane.api.views import ( # User UserEndpoint, UpdateUserOnBoardedEndpoint, + UpdateUserTourCompletedEndpoint, UserActivityEndpoint, ## End User # Workspaces @@ -152,6 +153,7 @@ from plane.api.views import ( ## End Analytics # Notification NotificationViewSet, + UnreadNotificationEndpoint, ## End Notification ) @@ -202,7 +204,12 @@ urlpatterns = [ path( "users/me/onboard/", UpdateUserOnBoardedEndpoint.as_view(), - name="change-password", + name="user-onboard", + ), + path( + "users/me/tour-completed/", + UpdateUserTourCompletedEndpoint.as_view(), + name="user-tour", ), path("users/activities/", UserActivityEndpoint.as_view(), name="user-activities"), # user workspaces @@ -472,7 +479,6 @@ urlpatterns = [ "workspaces//user-favorite-projects/", ProjectFavoritesViewSet.as_view( { - "get": "list", "post": "create", } ), @@ -1377,5 +1383,10 @@ urlpatterns = [ ), name="notifications", ), + path( + "workspaces//users/notifications/unread/", + UnreadNotificationEndpoint.as_view(), + name="unread-notifications", + ), ## End Notification ] diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index 9eba0868a..076cdd006 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -16,6 +16,7 @@ from .project import ( from .people import ( UserEndpoint, UpdateUserOnBoardedEndpoint, + UpdateUserTourCompletedEndpoint, UserActivityEndpoint, ) @@ -144,4 +145,4 @@ from .analytic import ( DefaultAnalyticsEndpoint, ) -from .notification import NotificationViewSet \ No newline at end of file +from .notification import NotificationViewSet, UnreadNotificationEndpoint \ No newline at end of file diff --git a/apiserver/plane/api/views/authentication.py b/apiserver/plane/api/views/authentication.py index 068fae5a9..0d37b1c33 100644 --- a/apiserver/plane/api/views/authentication.py +++ b/apiserver/plane/api/views/authentication.py @@ -345,7 +345,7 @@ class MagicSignInEndpoint(BaseAPIView): def post(self, request): try: - user_token = request.data.get("token", "").strip().lower() + user_token = request.data.get("token", "").strip() key = request.data.get("key", False) if not key or user_token == "": diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index 0dd5d67d0..d78333528 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -706,9 +706,6 @@ class CycleDateCheckEndpoint(BaseAPIView): class CycleFavoriteViewSet(BaseViewSet): - permission_classes = [ - ProjectEntityPermission, - ] serializer_class = CycleFavoriteSerializer model = CycleFavorite diff --git a/apiserver/plane/api/views/gpt.py b/apiserver/plane/api/views/gpt.py index 8878e99a5..f8065f6d0 100644 --- a/apiserver/plane/api/views/gpt.py +++ b/apiserver/plane/api/views/gpt.py @@ -30,31 +30,6 @@ class GPTIntegrationEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - count = 0 - - # If logger is enabled check for request limit - if settings.LOGGER_BASE_URL: - try: - headers = { - "Content-Type": "application/json", - } - - response = requests.post( - settings.LOGGER_BASE_URL, - json={"user_id": str(request.user.id)}, - headers=headers, - ) - count = response.json().get("count", 0) - if not response.json().get("success", False): - return Response( - { - "error": "You have surpassed the monthly limit for AI assistance" - }, - status=status.HTTP_429_TOO_MANY_REQUESTS, - ) - except Exception as e: - capture_exception(e) - prompt = request.data.get("prompt", False) task = request.data.get("task", False) @@ -82,7 +57,6 @@ class GPTIntegrationEndpoint(BaseAPIView): { "response": text, "response_html": text_html, - "count": count, "project_detail": ProjectLiteSerializer(project).data, "workspace_detail": WorkspaceLiteSerializer(workspace).data, }, diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 1c2c95a96..aab926fd2 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -45,7 +45,6 @@ from plane.api.serializers import ( IssueLiteSerializer, IssueAttachmentSerializer, IssueSubscriberSerializer, - ProjectMemberSerializer, ProjectMemberLiteSerializer, ) from plane.api.permissions import ( @@ -169,8 +168,8 @@ class IssueViewSet(BaseViewSet): issue_queryset = ( self.get_queryset() .filter(**filters) - .annotate(cycle_id=F("issue_cycle__id")) - .annotate(module_id=F("issue_module__id")) + .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate(module_id=F("issue_module__module_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -955,8 +954,8 @@ class IssueArchiveViewSet(BaseViewSet): issue_queryset = ( self.get_queryset() .filter(**filters) - .annotate(cycle_id=F("issue_cycle__id")) - .annotate(module_id=F("issue_module__id")) + .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate(module_id=F("issue_module__module_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py index 5a235ba8f..2a7532ecf 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -480,9 +480,6 @@ class ModuleLinkViewSet(BaseViewSet): class ModuleFavoriteViewSet(BaseViewSet): - permission_classes = [ - ProjectEntityPermission, - ] serializer_class = ModuleFavoriteSerializer model = ModuleFavorite diff --git a/apiserver/plane/api/views/notification.py b/apiserver/plane/api/views/notification.py index ac0082430..e81fd2b5f 100644 --- a/apiserver/plane/api/views/notification.py +++ b/apiserver/plane/api/views/notification.py @@ -8,7 +8,7 @@ from rest_framework.response import Response from sentry_sdk import capture_exception # Module imports -from .base import BaseViewSet +from .base import BaseViewSet, BaseAPIView from plane.db.models import Notification, IssueAssignee, IssueSubscriber, Issue from plane.api.serializers import NotificationSerializer @@ -25,7 +25,7 @@ class NotificationViewSet(BaseViewSet): workspace__slug=self.kwargs.get("slug"), receiver_id=self.request.user.id, ) - .select_related("workspace") + .select_related("workspace", "project," "triggered_by", "receiver") ) def list(self, request, slug): @@ -33,7 +33,7 @@ class NotificationViewSet(BaseViewSet): order_by = request.GET.get("order_by", "-created_at") snoozed = request.GET.get("snoozed", "false") archived = request.GET.get("archived", "false") - read = request.GET.get("read", "false") + read = request.GET.get("read", "true") # Filter type type = request.GET.get("type", "all") @@ -53,8 +53,6 @@ class NotificationViewSet(BaseViewSet): Q(snoozed_till__lt=timezone.now()) | Q(snoozed_till__isnull=False) ) - if read == "true": - notifications = notifications.filter(read_at__isnull=False) if read == "false": notifications = notifications.filter(read_at__isnull=True) @@ -123,7 +121,7 @@ class NotificationViewSet(BaseViewSet): {"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST, ) - + def mark_read(self, request, slug, pk): try: notification = Notification.objects.get( @@ -166,7 +164,6 @@ class NotificationViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) - def archive(self, request, slug, pk): try: notification = Notification.objects.get( @@ -209,3 +206,51 @@ class NotificationViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) + +class UnreadNotificationEndpoint(BaseAPIView): + def get(self, request, slug): + try: + # Watching Issues Count + watching_issues_count = Notification.objects.filter( + workspace__slug=slug, + receiver_id=request.user.id, + read_at__isnull=True, + entity_identifier__in=IssueSubscriber.objects.filter( + workspace__slug=slug, subscriber_id=request.user.id + ).values_list("issue_id", flat=True), + ).count() + + # My Issues Count + my_issues_count = Notification.objects.filter( + workspace__slug=slug, + receiver_id=request.user.id, + read_at__isnull=True, + entity_identifier__in=IssueAssignee.objects.filter( + workspace__slug=slug, assignee_id=request.user.id + ).values_list("issue_id", flat=True), + ).count() + + # Created Issues Count + created_issues_count = Notification.objects.filter( + workspace__slug=slug, + receiver_id=request.user.id, + read_at__isnull=True, + entity_identifier__in=Issue.objects.filter( + workspace__slug=slug, created_by=request.user + ).values_list("pk", flat=True), + ).count() + + return Response( + { + "watching_issues": watching_issues_count, + "my_issues": my_issues_count, + "created_issues": created_issues_count, + }, + status=status.HTTP_200_OK, + ) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/apiserver/plane/api/views/people.py b/apiserver/plane/api/views/people.py index 8e19fea1a..705f5c96e 100644 --- a/apiserver/plane/api/views/people.py +++ b/apiserver/plane/api/views/people.py @@ -37,7 +37,9 @@ class UserEndpoint(BaseViewSet): workspace_invites = WorkspaceMemberInvite.objects.filter( email=request.user.email ).count() - assigned_issues = Issue.issue_objects.filter(assignees__in=[request.user]).count() + assigned_issues = Issue.issue_objects.filter( + assignees__in=[request.user] + ).count() serialized_data = UserSerializer(request.user).data serialized_data["workspace"] = { @@ -47,7 +49,9 @@ class UserEndpoint(BaseViewSet): "fallback_workspace_slug": workspace.slug, "invites": workspace_invites, } - serialized_data.setdefault("issues", {})["assigned_issues"] = assigned_issues + serialized_data.setdefault("issues", {})[ + "assigned_issues" + ] = assigned_issues return Response( serialized_data, @@ -59,11 +63,15 @@ class UserEndpoint(BaseViewSet): workspace_invites = WorkspaceMemberInvite.objects.filter( email=request.user.email ).count() - assigned_issues = Issue.issue_objects.filter(assignees__in=[request.user]).count() + assigned_issues = Issue.issue_objects.filter( + assignees__in=[request.user] + ).count() - fallback_workspace = Workspace.objects.filter( - workspace_member__member=request.user - ).order_by("created_at").first() + fallback_workspace = ( + Workspace.objects.filter(workspace_member__member=request.user) + .order_by("created_at") + .first() + ) serialized_data = UserSerializer(request.user).data @@ -78,7 +86,9 @@ class UserEndpoint(BaseViewSet): else None, "invites": workspace_invites, } - serialized_data.setdefault("issues", {})["assigned_issues"] = assigned_issues + serialized_data.setdefault("issues", {})[ + "assigned_issues" + ] = assigned_issues return Response( serialized_data, @@ -109,6 +119,23 @@ class UpdateUserOnBoardedEndpoint(BaseAPIView): ) +class UpdateUserTourCompletedEndpoint(BaseAPIView): + def patch(self, request): + try: + user = User.objects.get(pk=request.user.id) + user.is_tour_completed = request.data.get("is_tour_completed", False) + user.save() + return Response( + {"message": "Updated successfully"}, status=status.HTTP_200_OK + ) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + class UserActivityEndpoint(BaseAPIView, BasePaginator): def get(self, request): try: diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index 822dc78b5..5c6ea3fd1 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -96,6 +96,7 @@ class ProjectViewSet(BaseViewSet): def list(self, request, slug): try: + is_favorite = request.GET.get("is_favorite", "all") subquery = ProjectFavorite.objects.filter( user=self.request.user, project_id=OuterRef("pk"), @@ -126,6 +127,12 @@ class ProjectViewSet(BaseViewSet): .values("count") ) ) + + if is_favorite == "true": + projects = projects.filter(is_favorite=True) + if is_favorite == "false": + projects = projects.filter(is_favorite=False) + return Response(ProjectDetailSerializer(projects, many=True).data) except Exception as e: capture_exception(e) @@ -153,32 +160,32 @@ class ProjectViewSet(BaseViewSet): states = [ { "name": "Backlog", - "color": "#5e6ad2", + "color": "#A3A3A3", "sequence": 15000, "group": "backlog", "default": True, }, { "name": "Todo", - "color": "#eb5757", + "color": "#3A3A3A", "sequence": 25000, "group": "unstarted", }, { "name": "In Progress", - "color": "#26b5ce", + "color": "#F59E0B", "sequence": 35000, "group": "started", }, { "name": "Done", - "color": "#f2c94c", + "color": "#16A34A", "sequence": 45000, "group": "completed", }, { "name": "Cancelled", - "color": "#4cb782", + "color": "#EF4444", "sequence": 55000, "group": "cancelled", }, diff --git a/apiserver/plane/api/views/workspace.py b/apiserver/plane/api/views/workspace.py index 4c136ed8c..305deb525 100644 --- a/apiserver/plane/api/views/workspace.py +++ b/apiserver/plane/api/views/workspace.py @@ -101,6 +101,7 @@ class WorkSpaceViewSet(BaseViewSet): .filter(workspace_member__member=self.request.user) .annotate(total_members=member_count) .annotate(total_issues=issue_count) + .select_related("owner") ) def create(self, request): diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index c05442163..e45ad9b32 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -570,7 +570,7 @@ def track_archive_at( comment=f"{actor.email} has restored the issue", verb="updated", actor=actor, - field="archvied_at", + field="archived_at", old_value="archive", new_value="restore", ) @@ -584,7 +584,7 @@ def track_archive_at( comment=f"Plane has archived the issue", verb="updated", actor=actor, - field="archvied_at", + field="archived_at", old_value=None, new_value="archive", ) @@ -1028,10 +1028,12 @@ def issue_activity( actor = User.objects.get(pk=actor_id) project = Project.objects.get(pk=project_id) - issue = Issue.objects.filter(pk=issue_id).first() + + issue = Issue.objects.filter(pk=issue_id, project_id=project_id).first() + if issue is not None: issue.updated_at = timezone.now() - issue.save() + issue.save(update_fields=["updated_at"]) if subscriber: # add the user to issue subscriber @@ -1109,10 +1111,12 @@ def issue_activity( issue_subscribers = issue_subscribers + issue_assignees - if issue.created_by_id: + issue = Issue.objects.filter(pk=issue_id, project_id=project_id).first() + + # Add bot filtering + if issue is not None and issue.created_by_id is not None and not issue.created_by.is_bot: issue_subscribers = issue_subscribers + [issue.created_by_id] - issue = Issue.objects.get(project=project, pk=issue_id) for subscriber in issue_subscribers: for issue_activity in issue_activities_created: bulk_notifications.append( @@ -1134,7 +1138,17 @@ def issue_activity( "state_name": issue.state.name, "state_group": issue.state.group, }, - "issue_activity": str(issue_activity.id), + "issue_activity": { + "id": str(issue_activity.id), + "verb": str(issue_activity.verb), + "field": str(issue_activity.field), + "actor": str(issue_activity.actor_id), + "new_value": str(issue_activity.new_value), + "old_value": str(issue_activity.old_value), + "issue_comment": str( + issue_activity.issue_comment.comment_stripped if issue_activity.issue_comment is not None else "" + ), + }, }, ) ) diff --git a/apiserver/plane/bgtasks/issue_automation_task.py b/apiserver/plane/bgtasks/issue_automation_task.py index 0fb6943ca..bb2528863 100644 --- a/apiserver/plane/bgtasks/issue_automation_task.py +++ b/apiserver/plane/bgtasks/issue_automation_task.py @@ -12,7 +12,7 @@ from celery import shared_task from sentry_sdk import capture_exception # Module imports -from plane.db.models import Issue, Project, IssueActivity, State +from plane.db.models import Issue, Project, State from plane.bgtasks.issue_activites_task import issue_activity @@ -49,6 +49,11 @@ def archive_old_issues(): Q(issue_module__module__target_date__lt=timezone.now().date()) & Q(issue_module__isnull=False) ), + ).filter( + Q(issue_inbox__status=1) + | Q(issue_inbox__status=-1) + | Q(issue_inbox__status=2) + | Q(issue_inbox__isnull=True) ) # Check if Issues @@ -65,7 +70,7 @@ def archive_old_issues(): [ issue_activity.delay( type="issue.activity.updated", - requested_data=json.dumps({"archive_at": issue.archived_at}), + requested_data=json.dumps({"archived_at": issue.archived_at}), actor_id=str(project.created_by_id), issue_id=issue.id, project_id=project_id, @@ -111,14 +116,19 @@ def close_old_issues(): Q(issue_module__module__target_date__lt=timezone.now().date()) & Q(issue_module__isnull=False) ), + ).filter( + Q(issue_inbox__status=1) + | Q(issue_inbox__status=-1) + | Q(issue_inbox__status=2) + | Q(issue_inbox__isnull=True) ) # Check if Issues if issues: if project.default_state is None: - close_state = project.default_state - else: close_state = State.objects.filter(group="cancelled").first() + else: + close_state = project.default_state issues_to_update = [] for issue in issues: diff --git a/apiserver/plane/db/migrations/0037_issue_archived_at_project_archive_in_and_more.py b/apiserver/plane/db/migrations/0037_issue_archived_at_project_archive_in_and_more.py new file mode 100644 index 000000000..bd2ff322f --- /dev/null +++ b/apiserver/plane/db/migrations/0037_issue_archived_at_project_archive_in_and_more.py @@ -0,0 +1,264 @@ +# Generated by Django 4.2.3 on 2023-07-19 06:52 + +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import plane.db.models.user +import uuid + + +def onboarding_default_steps(apps, schema_editor): + default_onboarding_schema = { + "workspace_join": True, + "profile_complete": True, + "workspace_create": True, + "workspace_invite": True, + } + + Model = apps.get_model("db", "User") + updated_user = [] + for obj in Model.objects.filter(is_onboarded=True): + obj.onboarding_step = default_onboarding_schema + obj.is_tour_completed = True + updated_user.append(obj) + + Model.objects.bulk_update(updated_user, ["onboarding_step"], batch_size=100) + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0036_alter_workspace_organization_size"), + ] + + operations = [ + migrations.AddField( + model_name="issue", + name="archived_at", + field=models.DateField(null=True), + ), + migrations.AddField( + model_name="project", + name="archive_in", + field=models.IntegerField( + default=0, + validators=[ + django.core.validators.MinValueValidator(0), + django.core.validators.MaxValueValidator(12), + ], + ), + ), + migrations.AddField( + model_name="project", + name="close_in", + field=models.IntegerField( + default=0, + validators=[ + django.core.validators.MinValueValidator(0), + django.core.validators.MaxValueValidator(12), + ], + ), + ), + migrations.AddField( + model_name="project", + name="default_state", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="default_state", + to="db.state", + ), + ), + migrations.AddField( + model_name="user", + name="is_tour_completed", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="user", + name="onboarding_step", + field=models.JSONField(default=plane.db.models.user.get_default_onboarding), + ), + migrations.CreateModel( + name="Notification", + fields=[ + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("data", models.JSONField(null=True)), + ("entity_identifier", models.UUIDField(null=True)), + ("entity_name", models.CharField(max_length=255)), + ("title", models.TextField()), + ("message", models.JSONField(null=True)), + ("message_html", models.TextField(blank=True, default="

")), + ("message_stripped", models.TextField(blank=True, null=True)), + ("sender", models.CharField(max_length=255)), + ("read_at", models.DateTimeField(null=True)), + ("snoozed_till", models.DateTimeField(null=True)), + ("archived_at", models.DateTimeField(null=True)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "project", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="notifications", + to="db.project", + ), + ), + ( + "receiver", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="received_notifications", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "triggered_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="triggered_notifications", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="notifications", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Notification", + "verbose_name_plural": "Notifications", + "db_table": "notifications", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="IssueSubscriber", + fields=[ + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_subscribers", + to="db.issue", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + ( + "subscriber", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_subscribers", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Issue Subscriber", + "verbose_name_plural": "Issue Subscribers", + "db_table": "issue_subscribers", + "ordering": ("-created_at",), + "unique_together": {("issue", "subscriber")}, + }, + ), + ] diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index f301d4191..3e9da02d5 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -28,7 +28,6 @@ class IssueManager(models.Manager): | models.Q(issue_inbox__status=2) | models.Q(issue_inbox__isnull=True) ) - .filter(archived_at__isnull=True) .exclude(archived_at__isnull=False) ) diff --git a/apiserver/plane/db/models/user.py b/apiserver/plane/db/models/user.py index b0ab72159..36b3a1f6b 100644 --- a/apiserver/plane/db/models/user.py +++ b/apiserver/plane/db/models/user.py @@ -18,6 +18,13 @@ from sentry_sdk import capture_exception from slack_sdk import WebClient from slack_sdk.errors import SlackApiError +def get_default_onboarding(): + return { + "profile_complete": False, + "workspace_create": False, + "workspace_invite": False, + "workspace_join": False, + } class User(AbstractBaseUser, PermissionsMixin): id = models.UUIDField( @@ -73,6 +80,8 @@ class User(AbstractBaseUser, PermissionsMixin): role = models.CharField(max_length=300, null=True, blank=True) is_bot = models.BooleanField(default=False) theme = models.JSONField(default=dict) + is_tour_completed = models.BooleanField(default=False) + onboarding_step = models.JSONField(default=get_default_onboarding) USERNAME_FIELD = "email" diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index c4fa8ef2c..1eff57555 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -3,7 +3,7 @@ Django==4.2.3 django-braces==1.15.0 django-taggit==4.0.0 -psycopg2==2.9.6 +psycopg==3.1.9 django-oauth-toolkit==2.3.0 mistune==3.0.1 djangorestframework==3.14.0 diff --git a/apps/app/components/account/email-code-form.tsx b/apps/app/components/account/email-code-form.tsx index ba8c759af..f745c5521 100644 --- a/apps/app/components/account/email-code-form.tsx +++ b/apps/app/components/account/email-code-form.tsx @@ -32,6 +32,7 @@ export const EmailCodeForm = ({ handleSignIn }: any) => { setError, setValue, getValues, + watch, formState: { errors, isSubmitting, isValid, isDirty }, } = useForm({ defaultValues: { @@ -112,43 +113,35 @@ export const EmailCodeForm = ({ handleSignIn }: any) => { return ( <> -
- {(codeSent || codeResent) && ( -
-
-
-
-
-

- {codeResent - ? "Please check your mail for new code." - : "Please check your mail for code."} -

-
-
-
- )} -
+ {(codeSent || codeResent) && ( +

+ We have sent the sign in code. +
+ Please check your inbox at {watch("email")} +

+ )} + +
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( value - ) || "Email ID is not valid", + ) || "Email address is not valid", }} error={errors.email} - placeholder="Enter your Email ID" + placeholder="Enter your email address..." + className="border-custom-border-300 h-[46px]" />
{codeSent && ( -
+ <> { required: "Code is required", }} error={errors.token} - placeholder="Enter code" + placeholder="Enter code..." + className="border-custom-border-300 h-[46px]" /> -
+ + )} + {codeSent ? ( + + {isLoading ? "Signing in..." : "Sign in"} + + ) : ( + { + handleSubmit(onSubmit)().then(() => { + setResendCodeTimer(30); + }); + }} + disabled={!isValid && isDirty} + loading={isSubmitting} + > + {isSubmitting ? "Sending code..." : "Send sign in code"} + )} -
- {codeSent ? ( - - {isLoading ? "Signing in..." : "Sign in"} - - ) : ( - { - handleSubmit(onSubmit)().then(() => { - setResendCodeTimer(30); - }); - }} - loading={isSubmitting || (!isValid && isDirty)} - > - {isSubmitting ? "Sending code..." : "Send code"} - - )} -
); diff --git a/apps/app/components/account/email-password-form.tsx b/apps/app/components/account/email-password-form.tsx index 97da2b9e4..bb341b371 100644 --- a/apps/app/components/account/email-password-form.tsx +++ b/apps/app/components/account/email-password-form.tsx @@ -8,7 +8,7 @@ import { useForm } from "react-hook-form"; // components import { EmailResetPasswordForm } from "components/account"; // ui -import { Input, SecondaryButton } from "components/ui"; +import { Input, PrimaryButton } from "components/ui"; // types type EmailPasswordFormValues = { email: string; @@ -42,28 +42,39 @@ export const EmailPasswordForm: React.FC = ({ onSubmit }) => { return ( <> +

+ {isResettingPassword + ? "Reset your password" + : isSignUpPage + ? "Sign up on Plane" + : "Sign in to Plane"} +

{isResettingPassword ? ( ) : ( -
-
+ +
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( value - ) || "Email ID is not valid", + ) || "Email address is not valid", }} error={errors.email} - placeholder="Enter your email ID" + placeholder="Enter your email address..." + className="border-custom-border-300 h-[46px]" />
-
+
= ({ onSubmit }) => { required: "Password is required", }} error={errors.password} - placeholder="Enter your password" + placeholder="Enter your password..." + className="border-custom-border-300 h-[46px]" />
-
-
- {isSignUpPage ? ( - - - Already have an account? Sign in. - - - ) : ( - - )} -
+
+ {isSignUpPage ? ( + + + Already have an account? Sign in. + + + ) : ( + + )}
-
- + {isSignUpPage ? isSubmitting ? "Signing up..." - : "Sign Up" + : "Sign up" : isSubmitting ? "Signing in..." - : "Sign In"} - + : "Sign in"} + {!isSignUpPage && ( - + Don{"'"}t have an account? Sign up. diff --git a/apps/app/components/account/email-reset-password-form.tsx b/apps/app/components/account/email-reset-password-form.tsx index 03ea69042..3717e8801 100644 --- a/apps/app/components/account/email-reset-password-form.tsx +++ b/apps/app/components/account/email-reset-password-form.tsx @@ -59,32 +59,36 @@ export const EmailResetPasswordForm: React.FC = ({ setIsResettingPassword }; return ( - -
+ +
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( value - ) || "Email ID is not valid", + ) || "Email address is not valid", }} error={errors.email} - placeholder="Enter registered Email ID" + placeholder="Enter registered email address.." + className="border-custom-border-300 h-[46px]" />
-
+
setIsResettingPassword(false)} > Go Back - + {isSubmitting ? "Sending link..." : "Send reset link"}
diff --git a/apps/app/components/account/github-login-button.tsx b/apps/app/components/account/github-login-button.tsx index 889d46405..2f4fcbc4d 100644 --- a/apps/app/components/account/github-login-button.tsx +++ b/apps/app/components/account/github-login-button.tsx @@ -1,9 +1,14 @@ import { useEffect, useState, FC } from "react"; + import Link from "next/link"; import Image from "next/image"; import { useRouter } from "next/router"; + +// next-themes +import { useTheme } from "next-themes"; // images -import githubImage from "/public/logos/github-black.png"; +import githubBlackImage from "/public/logos/github-black.png"; +import githubWhiteImage from "/public/logos/github-white.png"; const { NEXT_PUBLIC_GITHUB_ID } = process.env; @@ -11,15 +16,15 @@ export interface GithubLoginButtonProps { handleSignIn: React.Dispatch; } -export const GithubLoginButton: FC = (props) => { - const { handleSignIn } = props; - // router +export const GithubLoginButton: FC = ({ handleSignIn }) => { + const [loginCallBackURL, setLoginCallBackURL] = useState(undefined); + const [gitCode, setGitCode] = useState(null); + const { query: { code }, } = useRouter(); - // states - const [loginCallBackURL, setLoginCallBackURL] = useState(undefined); - const [gitCode, setGitCode] = useState(null); + + const { theme } = useTheme(); useEffect(() => { if (code && !gitCode) { @@ -35,13 +40,18 @@ export const GithubLoginButton: FC = (props) => { }, []); return ( -
+
-
diff --git a/apps/app/components/account/google-login.tsx b/apps/app/components/account/google-login.tsx index c12fb4e24..67a77f2e4 100644 --- a/apps/app/components/account/google-login.tsx +++ b/apps/app/components/account/google-login.tsx @@ -1,5 +1,5 @@ import { FC, CSSProperties, useEffect, useRef, useCallback, useState } from "react"; -// next + import Script from "next/script"; export interface IGoogleLoginButton { @@ -8,18 +8,18 @@ export interface IGoogleLoginButton { styles?: CSSProperties; } -export const GoogleLoginButton: FC = (props) => { - const { handleSignIn } = props; - +export const GoogleLoginButton: FC = ({ handleSignIn }) => { const googleSignInButton = useRef(null); const [gsiScriptLoaded, setGsiScriptLoaded] = useState(false); const loadScript = useCallback(() => { if (!googleSignInButton.current || gsiScriptLoaded) return; + window?.google?.accounts.id.initialize({ client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENTID || "", callback: handleSignIn, }); + window?.google?.accounts.id.renderButton( googleSignInButton.current, { @@ -27,11 +27,13 @@ export const GoogleLoginButton: FC = (props) => { theme: "outline", size: "large", logo_alignment: "center", - width: "410", - text: "continue_with", + width: "360", + text: "signin_with", } as GsiButtonConfiguration // customization attributes ); + window?.google?.accounts.id.prompt(); // also display the One Tap dialog + setGsiScriptLoaded(true); }, [handleSignIn, gsiScriptLoaded]); @@ -48,7 +50,7 @@ export const GoogleLoginButton: FC = (props) => { <>