Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b4eab66e3d | |||
| d9d39199ae | |||
| f30e31e294 | |||
| dc57098507 | |||
| 141cb17e8a | |||
| 26b62c4a70 | |||
| e388a9a279 | |||
| a3a580923c | |||
| b4bc49971c | |||
| 04c7c53e09 | |||
| 78cc32765b | |||
| 4e485d6402 | |||
| 5a208cb1b9 | |||
| 0eafbb698a | |||
| 193ae9bfc8 | |||
| 7cb5a9120a | |||
| 84fc81dd98 | |||
| 2d0c0c7f8a | |||
| 5c9bdb1cea | |||
| f8ca1e46b1 | |||
| a3b9152a9b | |||
| 5223bd01e8 | |||
| 6eb0b5ddb0 | |||
| cd200169b6 | |||
| 037bb88b53 | |||
| 643390e723 | |||
| 731c4e8fcd | |||
| 6216ad77f4 | |||
| 9812129ad3 | |||
| 5226b17f90 | |||
| b376e5300a | |||
| 4460529b37 | |||
| 0a8cc24da5 | |||
| 2f4aa843fc | |||
| cfac8ce350 | |||
| 75a11ba31a | |||
| 1fc3709731 | |||
| 7e21618762 | |||
| 2d475491e9 | |||
| 2a2feaf88e | |||
| e48b2da623 | |||
| 9c9952a823 | |||
| 906ce8b500 | |||
| 6c483fad2f | |||
| 5b776392bd | |||
| ba158d5d6e | |||
| 084cc75726 | |||
| 534f5c7dd0 | |||
| 080cf70e3f | |||
| 4c3f7f27a5 | |||
| 803f6cc62a | |||
| 3a6d0c11fb | |||
| 75d81f9e95 | |||
| 0d5c7c6653 | |||
| 079c3a3a99 | |||
| 5f8d5ea388 | |||
| 8613a80b16 | |||
| dc16f2862e | |||
| e68d344410 | |||
| 26c8cba322 | |||
| b435ceedfc | |||
| 13c46e0fdf | |||
| 02bccb44d6 | |||
| b5634f5fa1 |
@@ -53,6 +53,8 @@ mediafiles
|
||||
.env
|
||||
.DS_Store
|
||||
logs/
|
||||
htmlcov/
|
||||
.coverage
|
||||
|
||||
node_modules/
|
||||
assets/dist/
|
||||
|
||||
@@ -26,16 +26,16 @@ export const InstanceAIForm: FC<IInstanceAIForm> = (props) => {
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<AIFormValues>({
|
||||
defaultValues: {
|
||||
OPENAI_API_KEY: config["OPENAI_API_KEY"],
|
||||
GPT_ENGINE: config["GPT_ENGINE"],
|
||||
LLM_API_KEY: config["LLM_API_KEY"],
|
||||
LLM_MODEL: config["LLM_MODEL"],
|
||||
},
|
||||
});
|
||||
|
||||
const aiFormFields: TControllerInputFormField[] = [
|
||||
{
|
||||
key: "GPT_ENGINE",
|
||||
key: "LLM_MODEL",
|
||||
type: "text",
|
||||
label: "GPT_ENGINE",
|
||||
label: "LLM Model",
|
||||
description: (
|
||||
<>
|
||||
Choose an OpenAI engine.{" "}
|
||||
@@ -49,12 +49,12 @@ export const InstanceAIForm: FC<IInstanceAIForm> = (props) => {
|
||||
</a>
|
||||
</>
|
||||
),
|
||||
placeholder: "gpt-3.5-turbo",
|
||||
error: Boolean(errors.GPT_ENGINE),
|
||||
placeholder: "gpt-4o-mini",
|
||||
error: Boolean(errors.LLM_MODEL),
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
key: "OPENAI_API_KEY",
|
||||
key: "LLM_API_KEY",
|
||||
type: "password",
|
||||
label: "API key",
|
||||
description: (
|
||||
@@ -71,7 +71,7 @@ export const InstanceAIForm: FC<IInstanceAIForm> = (props) => {
|
||||
</>
|
||||
),
|
||||
placeholder: "sk-asddassdfasdefqsdfasd23das3dasdcasd",
|
||||
error: Boolean(errors.OPENAI_API_KEY),
|
||||
error: Boolean(errors.LLM_API_KEY),
|
||||
required: false,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -98,11 +98,7 @@ export const InstanceGithubConfigForm: FC<Props> = (props) => {
|
||||
key: "GITHUB_ORGANIZATION_ID",
|
||||
type: "text",
|
||||
label: "Organization ID",
|
||||
description: (
|
||||
<>
|
||||
The organization github ID.
|
||||
</>
|
||||
),
|
||||
description: <>The organization github ID.</>,
|
||||
placeholder: "123456789",
|
||||
error: Boolean(errors.GITHUB_ORGANIZATION_ID),
|
||||
required: false,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { LogOut, UserCog2, Palette } from "lucide-react";
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
// plane internal packages
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
import {AuthService } from "@plane/services";
|
||||
import { AuthService } from "@plane/services";
|
||||
import { Avatar } from "@plane/ui";
|
||||
import { getFileURL, cn } from "@plane/utils";
|
||||
// hooks
|
||||
|
||||
@@ -2,7 +2,7 @@ import set from "lodash/set";
|
||||
import { observable, action, computed, makeObservable, runInAction } from "mobx";
|
||||
// plane internal packages
|
||||
import { EInstanceStatus, TInstanceStatus } from "@plane/constants";
|
||||
import {InstanceService} from "@plane/services";
|
||||
import { InstanceService } from "@plane/services";
|
||||
import {
|
||||
IInstance,
|
||||
IInstanceAdmin,
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from "ce/components/authentication/authentication-modes";
|
||||
export * from "ce/components/authentication/authentication-modes";
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"build": "next build",
|
||||
"preview": "next build && next start",
|
||||
"start": "next start",
|
||||
"format": "prettier --write .",
|
||||
"lint": "eslint . --ext .ts,.tsx",
|
||||
"lint:errors": "eslint . --ext .ts,.tsx --quiet"
|
||||
},
|
||||
|
||||
+7
-2
@@ -1,14 +1,19 @@
|
||||
{
|
||||
"extends": "@plane/typescript-config/nextjs.json",
|
||||
"compilerOptions": {
|
||||
"plugins": [{ "name": "next" }],
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["core/*"],
|
||||
"@/public/*": ["public/*"],
|
||||
"@/plane-admin/*": ["ce/*"],
|
||||
"@/styles/*": ["styles/*"]
|
||||
}
|
||||
},
|
||||
"strictNullChecks": true
|
||||
},
|
||||
"include": ["next-env.d.ts", "next.config.js", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
[run]
|
||||
source = plane
|
||||
omit =
|
||||
*/tests/*
|
||||
*/migrations/*
|
||||
*/settings/*
|
||||
*/wsgi.py
|
||||
*/asgi.py
|
||||
*/urls.py
|
||||
manage.py
|
||||
*/admin.py
|
||||
*/apps.py
|
||||
|
||||
[report]
|
||||
exclude_lines =
|
||||
pragma: no cover
|
||||
def __repr__
|
||||
if self.debug:
|
||||
raise NotImplementedError
|
||||
if __name__ == .__main__.
|
||||
pass
|
||||
raise ImportError
|
||||
|
||||
[html]
|
||||
directory = htmlcov
|
||||
@@ -15,4 +15,4 @@ from .state import StateLiteSerializer, StateSerializer
|
||||
from .cycle import CycleSerializer, CycleIssueSerializer, CycleLiteSerializer
|
||||
from .module import ModuleSerializer, ModuleIssueSerializer, ModuleLiteSerializer
|
||||
from .intake import IntakeIssueSerializer
|
||||
from .estimate import EstimatePointSerializer
|
||||
from .estimate import EstimatePointSerializer
|
||||
|
||||
@@ -160,12 +160,15 @@ class IssueSerializer(BaseSerializer):
|
||||
else:
|
||||
try:
|
||||
# Then assign it to default assignee, if it is a valid assignee
|
||||
if default_assignee_id is not None and ProjectMember.objects.filter(
|
||||
member_id=default_assignee_id,
|
||||
project_id=project_id,
|
||||
role__gte=15,
|
||||
is_active=True
|
||||
).exists():
|
||||
if (
|
||||
default_assignee_id is not None
|
||||
and ProjectMember.objects.filter(
|
||||
member_id=default_assignee_id,
|
||||
project_id=project_id,
|
||||
role__gte=15,
|
||||
is_active=True,
|
||||
).exists()
|
||||
):
|
||||
IssueAssignee.objects.create(
|
||||
assignee_id=default_assignee_id,
|
||||
issue=issue,
|
||||
|
||||
@@ -53,6 +53,7 @@ def get_entity_model_and_serializer(entity_type):
|
||||
}
|
||||
return entity_map.get(entity_type, (None, None))
|
||||
|
||||
|
||||
class UserFavoriteSerializer(serializers.ModelSerializer):
|
||||
entity_data = serializers.SerializerMethodField()
|
||||
|
||||
|
||||
@@ -148,7 +148,6 @@ class WorkspaceUserLinkSerializer(BaseSerializer):
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def create(self, validated_data):
|
||||
# Filtering the WorkspaceUserLink with the given url to check if the link already exists.
|
||||
|
||||
@@ -157,7 +156,7 @@ class WorkspaceUserLinkSerializer(BaseSerializer):
|
||||
workspace_user_link = WorkspaceUserLink.objects.filter(
|
||||
url=url,
|
||||
workspace_id=validated_data.get("workspace_id"),
|
||||
owner_id=validated_data.get("owner_id")
|
||||
owner_id=validated_data.get("owner_id"),
|
||||
)
|
||||
|
||||
if workspace_user_link.exists():
|
||||
@@ -173,10 +172,8 @@ class WorkspaceUserLinkSerializer(BaseSerializer):
|
||||
url = validated_data.get("url")
|
||||
|
||||
workspace_user_link = WorkspaceUserLink.objects.filter(
|
||||
url=url,
|
||||
workspace_id=instance.workspace_id,
|
||||
owner=instance.owner
|
||||
)
|
||||
url=url, workspace_id=instance.workspace_id, owner=instance.owner
|
||||
)
|
||||
|
||||
if workspace_user_link.exclude(pk=instance.id).exists():
|
||||
raise serializers.ValidationError(
|
||||
@@ -185,6 +182,7 @@ class WorkspaceUserLinkSerializer(BaseSerializer):
|
||||
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class IssueRecentVisitSerializer(serializers.ModelSerializer):
|
||||
project_identifier = serializers.SerializerMethodField()
|
||||
|
||||
|
||||
@@ -6,8 +6,14 @@ from plane.app.views import (
|
||||
AnalyticViewViewset,
|
||||
SavedAnalyticEndpoint,
|
||||
ExportAnalyticsEndpoint,
|
||||
AdvanceAnalyticsEndpoint,
|
||||
AdvanceAnalyticsStatsEndpoint,
|
||||
AdvanceAnalyticsChartEndpoint,
|
||||
DefaultAnalyticsEndpoint,
|
||||
ProjectStatsEndpoint,
|
||||
ProjectAdvanceAnalyticsEndpoint,
|
||||
ProjectAdvanceAnalyticsStatsEndpoint,
|
||||
ProjectAdvanceAnalyticsChartEndpoint,
|
||||
)
|
||||
|
||||
|
||||
@@ -49,4 +55,34 @@ urlpatterns = [
|
||||
ProjectStatsEndpoint.as_view(),
|
||||
name="project-analytics",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/advance-analytics/",
|
||||
AdvanceAnalyticsEndpoint.as_view(),
|
||||
name="advance-analytics",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/advance-analytics-stats/",
|
||||
AdvanceAnalyticsStatsEndpoint.as_view(),
|
||||
name="advance-analytics-stats",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/advance-analytics-charts/",
|
||||
AdvanceAnalyticsChartEndpoint.as_view(),
|
||||
name="advance-analytics-chart",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/advance-analytics/",
|
||||
ProjectAdvanceAnalyticsEndpoint.as_view(),
|
||||
name="project-advance-analytics",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/advance-analytics-stats/",
|
||||
ProjectAdvanceAnalyticsStatsEndpoint.as_view(),
|
||||
name="project-advance-analytics-stats",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/advance-analytics-charts/",
|
||||
ProjectAdvanceAnalyticsChartEndpoint.as_view(),
|
||||
name="project-advance-analytics-chart",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -199,6 +199,18 @@ from .analytic.base import (
|
||||
ProjectStatsEndpoint,
|
||||
)
|
||||
|
||||
from .analytic.advance import (
|
||||
AdvanceAnalyticsEndpoint,
|
||||
AdvanceAnalyticsStatsEndpoint,
|
||||
AdvanceAnalyticsChartEndpoint,
|
||||
)
|
||||
|
||||
from .analytic.project_analytics import (
|
||||
ProjectAdvanceAnalyticsEndpoint,
|
||||
ProjectAdvanceAnalyticsStatsEndpoint,
|
||||
ProjectAdvanceAnalyticsChartEndpoint,
|
||||
)
|
||||
|
||||
from .notification.base import (
|
||||
NotificationViewSet,
|
||||
UnreadNotificationEndpoint,
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from typing import Dict, List, Any
|
||||
from django.db.models import QuerySet, Q, Count
|
||||
from django.http import HttpRequest
|
||||
from django.db.models.functions import TruncMonth
|
||||
from django.utils import timezone
|
||||
from plane.app.views.base import BaseAPIView
|
||||
from plane.app.permissions import ROLE, allow_permission
|
||||
from plane.db.models import (
|
||||
WorkspaceMember,
|
||||
Project,
|
||||
Issue,
|
||||
Cycle,
|
||||
Module,
|
||||
IssueView,
|
||||
ProjectPage,
|
||||
Workspace,
|
||||
CycleIssue,
|
||||
ModuleIssue,
|
||||
ProjectMember,
|
||||
)
|
||||
from plane.utils.build_chart import build_analytics_chart
|
||||
from plane.utils.date_utils import (
|
||||
get_analytics_filters,
|
||||
)
|
||||
|
||||
|
||||
class AdvanceAnalyticsBaseView(BaseAPIView):
|
||||
def initialize_workspace(self, slug: str, type: str) -> None:
|
||||
self._workspace_slug = slug
|
||||
self.filters = get_analytics_filters(
|
||||
slug=slug,
|
||||
type=type,
|
||||
user=self.request.user,
|
||||
date_filter=self.request.GET.get("date_filter", None),
|
||||
project_ids=self.request.GET.get("project_ids", None),
|
||||
)
|
||||
|
||||
|
||||
class AdvanceAnalyticsEndpoint(AdvanceAnalyticsBaseView):
|
||||
def get_filtered_counts(self, queryset: QuerySet) -> Dict[str, int]:
|
||||
def get_filtered_count() -> int:
|
||||
if self.filters["analytics_date_range"]:
|
||||
return queryset.filter(
|
||||
created_at__gte=self.filters["analytics_date_range"]["current"][
|
||||
"gte"
|
||||
],
|
||||
created_at__lte=self.filters["analytics_date_range"]["current"][
|
||||
"lte"
|
||||
],
|
||||
).count()
|
||||
return queryset.count()
|
||||
|
||||
def get_previous_count() -> int:
|
||||
if self.filters["analytics_date_range"] and self.filters[
|
||||
"analytics_date_range"
|
||||
].get("previous"):
|
||||
return queryset.filter(
|
||||
created_at__gte=self.filters["analytics_date_range"]["previous"][
|
||||
"gte"
|
||||
],
|
||||
created_at__lte=self.filters["analytics_date_range"]["previous"][
|
||||
"lte"
|
||||
],
|
||||
).count()
|
||||
return 0
|
||||
|
||||
return {
|
||||
"count": get_filtered_count(),
|
||||
# "filter_count": get_previous_count(),
|
||||
}
|
||||
|
||||
def get_overview_data(self) -> Dict[str, Dict[str, int]]:
|
||||
members_query = WorkspaceMember.objects.filter(
|
||||
workspace__slug=self._workspace_slug, is_active=True
|
||||
)
|
||||
|
||||
if self.request.GET.get("project_ids", None):
|
||||
project_ids = self.request.GET.get("project_ids", None)
|
||||
project_ids = [str(project_id) for project_id in project_ids.split(",")]
|
||||
members_query = ProjectMember.objects.filter(
|
||||
project_id__in=project_ids, is_active=True
|
||||
)
|
||||
|
||||
return {
|
||||
"total_users": self.get_filtered_counts(members_query),
|
||||
"total_admins": self.get_filtered_counts(
|
||||
members_query.filter(role=ROLE.ADMIN.value)
|
||||
),
|
||||
"total_members": self.get_filtered_counts(
|
||||
members_query.filter(role=ROLE.MEMBER.value)
|
||||
),
|
||||
"total_guests": self.get_filtered_counts(
|
||||
members_query.filter(role=ROLE.GUEST.value)
|
||||
),
|
||||
"total_projects": self.get_filtered_counts(
|
||||
Project.objects.filter(**self.filters["project_filters"])
|
||||
),
|
||||
"total_work_items": self.get_filtered_counts(
|
||||
Issue.issue_objects.filter(**self.filters["base_filters"])
|
||||
),
|
||||
"total_cycles": self.get_filtered_counts(
|
||||
Cycle.objects.filter(**self.filters["base_filters"])
|
||||
),
|
||||
"total_intake": self.get_filtered_counts(
|
||||
Issue.objects.filter(**self.filters["base_filters"]).filter(
|
||||
issue_intake__status__in=["-2", "0"]
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
def get_work_items_stats(self) -> Dict[str, Dict[str, int]]:
|
||||
base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
|
||||
|
||||
return {
|
||||
"total_work_items": self.get_filtered_counts(base_queryset),
|
||||
"started_work_items": self.get_filtered_counts(
|
||||
base_queryset.filter(state__group="started")
|
||||
),
|
||||
"backlog_work_items": self.get_filtered_counts(
|
||||
base_queryset.filter(state__group="backlog")
|
||||
),
|
||||
"un_started_work_items": self.get_filtered_counts(
|
||||
base_queryset.filter(state__group="unstarted")
|
||||
),
|
||||
"completed_work_items": self.get_filtered_counts(
|
||||
base_queryset.filter(state__group="completed")
|
||||
),
|
||||
}
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def get(self, request: HttpRequest, slug: str) -> Response:
|
||||
self.initialize_workspace(slug, type="analytics")
|
||||
tab = request.GET.get("tab", "overview")
|
||||
|
||||
if tab == "overview":
|
||||
return Response(
|
||||
self.get_overview_data(),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
elif tab == "work-items":
|
||||
return Response(
|
||||
self.get_work_items_stats(),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
return Response({"message": "Invalid tab"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class AdvanceAnalyticsStatsEndpoint(AdvanceAnalyticsBaseView):
|
||||
def get_project_issues_stats(self) -> QuerySet:
|
||||
# Get the base queryset with workspace and project filters
|
||||
base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
|
||||
|
||||
# Apply date range filter if available
|
||||
if self.filters["chart_period_range"]:
|
||||
start_date, end_date = self.filters["chart_period_range"]
|
||||
base_queryset = base_queryset.filter(
|
||||
created_at__date__gte=start_date, created_at__date__lte=end_date
|
||||
)
|
||||
|
||||
return (
|
||||
base_queryset.values("project_id", "project__name").annotate(
|
||||
cancelled_work_items=Count("id", filter=Q(state__group="cancelled")),
|
||||
completed_work_items=Count("id", filter=Q(state__group="completed")),
|
||||
backlog_work_items=Count("id", filter=Q(state__group="backlog")),
|
||||
un_started_work_items=Count("id", filter=Q(state__group="unstarted")),
|
||||
started_work_items=Count("id", filter=Q(state__group="started")),
|
||||
)
|
||||
.order_by("project_id")
|
||||
)
|
||||
|
||||
def get_work_items_stats(self) -> Dict[str, Dict[str, int]]:
|
||||
base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
|
||||
return (
|
||||
base_queryset
|
||||
.values("project_id", "project__name")
|
||||
.annotate(
|
||||
cancelled_work_items=Count("id", filter=Q(state__group="cancelled")),
|
||||
completed_work_items=Count("id", filter=Q(state__group="completed")),
|
||||
backlog_work_items=Count("id", filter=Q(state__group="backlog")),
|
||||
un_started_work_items=Count("id", filter=Q(state__group="unstarted")),
|
||||
started_work_items=Count("id", filter=Q(state__group="started")),
|
||||
)
|
||||
.order_by("project_id")
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def get(self, request: HttpRequest, slug: str) -> Response:
|
||||
self.initialize_workspace(slug, type="chart")
|
||||
type = request.GET.get("type", "work-items")
|
||||
|
||||
if type == "work-items":
|
||||
return Response(
|
||||
self.get_work_items_stats(),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
return Response({"message": "Invalid type"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView):
|
||||
def project_chart(self) -> List[Dict[str, Any]]:
|
||||
# Get the base queryset with workspace and project filters
|
||||
base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
|
||||
date_filter = {}
|
||||
|
||||
# Apply date range filter if available
|
||||
if self.filters["chart_period_range"]:
|
||||
start_date, end_date = self.filters["chart_period_range"]
|
||||
date_filter = {
|
||||
"created_at__date__gte": start_date,
|
||||
"created_at__date__lte": end_date,
|
||||
}
|
||||
|
||||
total_work_items = base_queryset.filter(**date_filter).count()
|
||||
total_cycles = Cycle.objects.filter(
|
||||
**self.filters["base_filters"], **date_filter
|
||||
).count()
|
||||
total_modules = Module.objects.filter(
|
||||
**self.filters["base_filters"], **date_filter
|
||||
).count()
|
||||
total_intake = Issue.objects.filter(
|
||||
issue_intake__isnull=False, **self.filters["base_filters"], **date_filter
|
||||
).count()
|
||||
total_members = WorkspaceMember.objects.filter(
|
||||
workspace__slug=self._workspace_slug, is_active=True, **date_filter
|
||||
).count()
|
||||
total_pages = ProjectPage.objects.filter(
|
||||
**self.filters["base_filters"], **date_filter
|
||||
).count()
|
||||
total_views = IssueView.objects.filter(
|
||||
**self.filters["base_filters"], **date_filter
|
||||
).count()
|
||||
|
||||
data = {
|
||||
"work_items": total_work_items,
|
||||
"cycles": total_cycles,
|
||||
"modules": total_modules,
|
||||
"intake": total_intake,
|
||||
"members": total_members,
|
||||
"pages": total_pages,
|
||||
"views": total_views,
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
"key": key,
|
||||
"name": key.replace("_", " ").title(),
|
||||
"count": value or 0,
|
||||
}
|
||||
for key, value in data.items()
|
||||
]
|
||||
|
||||
def work_item_completion_chart(self) -> Dict[str, Any]:
|
||||
# Get the base queryset
|
||||
queryset = (
|
||||
Issue.issue_objects.filter(**self.filters["base_filters"])
|
||||
.select_related("workspace", "state", "parent")
|
||||
.prefetch_related(
|
||||
"assignees", "labels", "issue_module__module", "issue_cycle__cycle"
|
||||
)
|
||||
)
|
||||
|
||||
workspace = Workspace.objects.get(slug=self._workspace_slug)
|
||||
start_date = workspace.created_at.date().replace(day=1)
|
||||
|
||||
# Apply date range filter if available
|
||||
if self.filters["chart_period_range"]:
|
||||
start_date, end_date = self.filters["chart_period_range"]
|
||||
queryset = queryset.filter(
|
||||
created_at__date__gte=start_date, created_at__date__lte=end_date
|
||||
)
|
||||
|
||||
# Annotate by month and count
|
||||
monthly_stats = (
|
||||
queryset.annotate(month=TruncMonth("created_at"))
|
||||
.values("month")
|
||||
.annotate(
|
||||
created_count=Count("id"),
|
||||
completed_count=Count("id", filter=Q(state__group="completed")),
|
||||
)
|
||||
.order_by("month")
|
||||
)
|
||||
|
||||
# Create dictionary of month -> counts
|
||||
stats_dict = {
|
||||
stat["month"].strftime("%Y-%m-%d"): {
|
||||
"created_count": stat["created_count"],
|
||||
"completed_count": stat["completed_count"],
|
||||
}
|
||||
for stat in monthly_stats
|
||||
}
|
||||
|
||||
# Generate monthly data (ensure months with 0 count are included)
|
||||
data = []
|
||||
# include the current date at the end
|
||||
end_date = timezone.now().date()
|
||||
last_month = end_date.replace(day=1)
|
||||
current_month = start_date
|
||||
|
||||
while current_month <= last_month:
|
||||
date_str = current_month.strftime("%Y-%m-%d")
|
||||
stats = stats_dict.get(date_str, {"created_count": 0, "completed_count": 0})
|
||||
data.append(
|
||||
{
|
||||
"key": date_str,
|
||||
"name": date_str,
|
||||
"count": stats["created_count"],
|
||||
"completed_issues": stats["completed_count"],
|
||||
"created_issues": stats["created_count"],
|
||||
}
|
||||
)
|
||||
# Move to next month
|
||||
if current_month.month == 12:
|
||||
current_month = current_month.replace(
|
||||
year=current_month.year + 1, month=1
|
||||
)
|
||||
else:
|
||||
current_month = current_month.replace(month=current_month.month + 1)
|
||||
|
||||
schema = {
|
||||
"completed_issues": "completed_issues",
|
||||
"created_issues": "created_issues",
|
||||
}
|
||||
|
||||
return {"data": data, "schema": schema}
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def get(self, request: HttpRequest, slug: str) -> Response:
|
||||
self.initialize_workspace(slug, type="chart")
|
||||
type = request.GET.get("type", "projects")
|
||||
group_by = request.GET.get("group_by", None)
|
||||
x_axis = request.GET.get("x_axis", "PRIORITY")
|
||||
|
||||
if type == "projects":
|
||||
return Response(self.project_chart(), status=status.HTTP_200_OK)
|
||||
|
||||
elif type == "custom-work-items":
|
||||
queryset = (
|
||||
Issue.issue_objects.filter(**self.filters["base_filters"])
|
||||
.select_related("workspace", "state", "parent")
|
||||
.prefetch_related(
|
||||
"assignees", "labels", "issue_module__module", "issue_cycle__cycle"
|
||||
)
|
||||
)
|
||||
|
||||
# Apply date range filter if available
|
||||
if self.filters["chart_period_range"]:
|
||||
start_date, end_date = self.filters["chart_period_range"]
|
||||
queryset = queryset.filter(
|
||||
created_at__date__gte=start_date, created_at__date__lte=end_date
|
||||
)
|
||||
|
||||
return Response(
|
||||
build_analytics_chart(queryset, x_axis, group_by),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
elif type == "work-items":
|
||||
return Response(
|
||||
self.work_item_completion_chart(),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
return Response({"message": "Invalid type"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -0,0 +1,421 @@
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from typing import Dict, Any
|
||||
from django.db.models import QuerySet, Q, Count
|
||||
from django.http import HttpRequest
|
||||
from django.db.models.functions import TruncMonth
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from plane.app.views.base import BaseAPIView
|
||||
from plane.app.permissions import ROLE, allow_permission
|
||||
from plane.db.models import (
|
||||
Project,
|
||||
Issue,
|
||||
Cycle,
|
||||
Module,
|
||||
CycleIssue,
|
||||
ModuleIssue,
|
||||
)
|
||||
from django.db import models
|
||||
from django.db.models import F, Case, When, Value
|
||||
from django.db.models.functions import Concat
|
||||
from plane.utils.build_chart import build_analytics_chart
|
||||
from plane.utils.date_utils import (
|
||||
get_analytics_filters,
|
||||
)
|
||||
|
||||
|
||||
class ProjectAdvanceAnalyticsBaseView(BaseAPIView):
|
||||
def initialize_workspace(self, slug: str, type: str) -> None:
|
||||
self._workspace_slug = slug
|
||||
self.filters = get_analytics_filters(
|
||||
slug=slug,
|
||||
type=type,
|
||||
user=self.request.user,
|
||||
date_filter=self.request.GET.get("date_filter", None),
|
||||
project_ids=self.request.GET.get("project_ids", None),
|
||||
)
|
||||
|
||||
|
||||
class ProjectAdvanceAnalyticsEndpoint(ProjectAdvanceAnalyticsBaseView):
|
||||
def get_filtered_counts(self, queryset: QuerySet) -> Dict[str, int]:
|
||||
def get_filtered_count() -> int:
|
||||
if self.filters["analytics_date_range"]:
|
||||
return queryset.filter(
|
||||
created_at__gte=self.filters["analytics_date_range"]["current"][
|
||||
"gte"
|
||||
],
|
||||
created_at__lte=self.filters["analytics_date_range"]["current"][
|
||||
"lte"
|
||||
],
|
||||
).count()
|
||||
return queryset.count()
|
||||
|
||||
return {
|
||||
"count": get_filtered_count(),
|
||||
}
|
||||
|
||||
def get_work_items_stats(
|
||||
self, project_id, cycle_id=None, module_id=None
|
||||
) -> Dict[str, Dict[str, int]]:
|
||||
"""
|
||||
Returns work item stats for the workspace, or filtered by cycle_id or module_id if provided.
|
||||
"""
|
||||
base_queryset = None
|
||||
if cycle_id is not None:
|
||||
cycle_issues = CycleIssue.objects.filter(
|
||||
**self.filters["base_filters"], cycle_id=cycle_id
|
||||
).values_list("issue_id", flat=True)
|
||||
base_queryset = Issue.issue_objects.filter(id__in=cycle_issues)
|
||||
elif module_id is not None:
|
||||
module_issues = ModuleIssue.objects.filter(
|
||||
**self.filters["base_filters"], module_id=module_id
|
||||
).values_list("issue_id", flat=True)
|
||||
base_queryset = Issue.issue_objects.filter(id__in=module_issues)
|
||||
else:
|
||||
base_queryset = Issue.issue_objects.filter(
|
||||
**self.filters["base_filters"], project_id=project_id
|
||||
)
|
||||
|
||||
return {
|
||||
"total_work_items": self.get_filtered_counts(base_queryset),
|
||||
"started_work_items": self.get_filtered_counts(
|
||||
base_queryset.filter(state__group="started")
|
||||
),
|
||||
"backlog_work_items": self.get_filtered_counts(
|
||||
base_queryset.filter(state__group="backlog")
|
||||
),
|
||||
"un_started_work_items": self.get_filtered_counts(
|
||||
base_queryset.filter(state__group="unstarted")
|
||||
),
|
||||
"completed_work_items": self.get_filtered_counts(
|
||||
base_queryset.filter(state__group="completed")
|
||||
),
|
||||
}
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def get(self, request: HttpRequest, slug: str, project_id: str) -> Response:
|
||||
self.initialize_workspace(slug, type="analytics")
|
||||
|
||||
# Optionally accept cycle_id or module_id as query params
|
||||
cycle_id = request.GET.get("cycle_id", None)
|
||||
module_id = request.GET.get("module_id", None)
|
||||
return Response(
|
||||
self.get_work_items_stats(
|
||||
cycle_id=cycle_id, module_id=module_id, project_id=project_id
|
||||
),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
class ProjectAdvanceAnalyticsStatsEndpoint(ProjectAdvanceAnalyticsBaseView):
|
||||
def get_project_issues_stats(self) -> QuerySet:
|
||||
# Get the base queryset with workspace and project filters
|
||||
base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
|
||||
|
||||
# Apply date range filter if available
|
||||
if self.filters["chart_period_range"]:
|
||||
start_date, end_date = self.filters["chart_period_range"]
|
||||
base_queryset = base_queryset.filter(
|
||||
created_at__date__gte=start_date, created_at__date__lte=end_date
|
||||
)
|
||||
|
||||
return (
|
||||
base_queryset.values("project_id", "project__name")
|
||||
.annotate(
|
||||
cancelled_work_items=Count("id", filter=Q(state__group="cancelled")),
|
||||
completed_work_items=Count("id", filter=Q(state__group="completed")),
|
||||
backlog_work_items=Count("id", filter=Q(state__group="backlog")),
|
||||
un_started_work_items=Count("id", filter=Q(state__group="unstarted")),
|
||||
started_work_items=Count("id", filter=Q(state__group="started")),
|
||||
)
|
||||
.order_by("project_id")
|
||||
)
|
||||
|
||||
def get_work_items_stats(
|
||||
self, project_id, cycle_id=None, module_id=None
|
||||
) -> Dict[str, Dict[str, int]]:
|
||||
base_queryset = None
|
||||
if cycle_id is not None:
|
||||
cycle_issues = CycleIssue.objects.filter(
|
||||
**self.filters["base_filters"], cycle_id=cycle_id
|
||||
).values_list("issue_id", flat=True)
|
||||
base_queryset = Issue.issue_objects.filter(id__in=cycle_issues)
|
||||
elif module_id is not None:
|
||||
module_issues = ModuleIssue.objects.filter(
|
||||
**self.filters["base_filters"], module_id=module_id
|
||||
).values_list("issue_id", flat=True)
|
||||
base_queryset = Issue.issue_objects.filter(id__in=module_issues)
|
||||
else:
|
||||
base_queryset = Issue.issue_objects.filter(
|
||||
**self.filters["base_filters"], project_id=project_id
|
||||
)
|
||||
return (
|
||||
base_queryset.annotate(display_name=F("assignees__display_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(avatar=F("assignees__avatar"))
|
||||
.annotate(
|
||||
avatar_url=Case(
|
||||
# If `avatar_asset` exists, use it to generate the asset URL
|
||||
When(
|
||||
assignees__avatar_asset__isnull=False,
|
||||
then=Concat(
|
||||
Value("/api/assets/v2/static/"),
|
||||
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
|
||||
Value("/"),
|
||||
),
|
||||
),
|
||||
# If `avatar_asset` is None, fall back to using `avatar` field directly
|
||||
When(
|
||||
assignees__avatar_asset__isnull=True, then="assignees__avatar"
|
||||
),
|
||||
default=Value(None),
|
||||
output_field=models.CharField(),
|
||||
)
|
||||
)
|
||||
.values("display_name", "assignee_id", "avatar_url")
|
||||
.annotate(
|
||||
cancelled_work_items=Count(
|
||||
"id", filter=Q(state__group="cancelled"), distinct=True
|
||||
),
|
||||
completed_work_items=Count(
|
||||
"id", filter=Q(state__group="completed"), distinct=True
|
||||
),
|
||||
backlog_work_items=Count(
|
||||
"id", filter=Q(state__group="backlog"), distinct=True
|
||||
),
|
||||
un_started_work_items=Count(
|
||||
"id", filter=Q(state__group="unstarted"), distinct=True
|
||||
),
|
||||
started_work_items=Count(
|
||||
"id", filter=Q(state__group="started"), distinct=True
|
||||
),
|
||||
)
|
||||
.order_by("display_name")
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def get(self, request: HttpRequest, slug: str, project_id: str) -> Response:
|
||||
self.initialize_workspace(slug, type="chart")
|
||||
type = request.GET.get("type", "work-items")
|
||||
|
||||
if type == "work-items":
|
||||
# Optionally accept cycle_id or module_id as query params
|
||||
cycle_id = request.GET.get("cycle_id", None)
|
||||
module_id = request.GET.get("module_id", None)
|
||||
return Response(
|
||||
self.get_work_items_stats(
|
||||
project_id=project_id, cycle_id=cycle_id, module_id=module_id
|
||||
),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
return Response({"message": "Invalid type"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class ProjectAdvanceAnalyticsChartEndpoint(ProjectAdvanceAnalyticsBaseView):
|
||||
def work_item_completion_chart(
|
||||
self, project_id, cycle_id=None, module_id=None
|
||||
) -> Dict[str, Any]:
|
||||
# Get the base queryset
|
||||
queryset = (
|
||||
Issue.issue_objects.filter(**self.filters["base_filters"])
|
||||
.filter(project_id=project_id)
|
||||
.select_related("workspace", "state", "parent")
|
||||
.prefetch_related(
|
||||
"assignees", "labels", "issue_module__module", "issue_cycle__cycle"
|
||||
)
|
||||
)
|
||||
|
||||
if cycle_id is not None:
|
||||
cycle_issues = CycleIssue.objects.filter(
|
||||
**self.filters["base_filters"], cycle_id=cycle_id
|
||||
).values_list("issue_id", flat=True)
|
||||
cycle = Cycle.objects.filter(id=cycle_id).first()
|
||||
if cycle and cycle.start_date:
|
||||
start_date = cycle.start_date.date()
|
||||
end_date = cycle.end_date.date()
|
||||
else:
|
||||
return {"data": [], "schema": {}}
|
||||
queryset = cycle_issues
|
||||
|
||||
elif module_id is not None:
|
||||
module_issues = ModuleIssue.objects.filter(
|
||||
**self.filters["base_filters"], module_id=module_id
|
||||
).values_list("issue_id", flat=True)
|
||||
module = Module.objects.filter(id=module_id).first()
|
||||
if module and module.start_date:
|
||||
start_date = module.start_date
|
||||
end_date = module.target_date
|
||||
else:
|
||||
return {"data": [], "schema": {}}
|
||||
queryset = module_issues
|
||||
|
||||
else:
|
||||
project = Project.objects.filter(id=project_id).first()
|
||||
if project.created_at:
|
||||
start_date = project.created_at.date().replace(day=1)
|
||||
else:
|
||||
return {"data": [], "schema": {}}
|
||||
|
||||
if cycle_id or module_id:
|
||||
# Get daily stats with optimized query
|
||||
daily_stats = (
|
||||
queryset.values("created_at__date")
|
||||
.annotate(
|
||||
created_count=Count("id"),
|
||||
completed_count=Count(
|
||||
"id", filter=Q(issue__state__group="completed")
|
||||
),
|
||||
)
|
||||
.order_by("created_at__date")
|
||||
)
|
||||
|
||||
# Create a dictionary of existing stats with summed counts
|
||||
stats_dict = {
|
||||
stat["created_at__date"].strftime("%Y-%m-%d"): {
|
||||
"created_count": stat["created_count"],
|
||||
"completed_count": stat["completed_count"],
|
||||
}
|
||||
for stat in daily_stats
|
||||
}
|
||||
|
||||
# Generate data for all days in the range
|
||||
data = []
|
||||
current_date = start_date
|
||||
while current_date <= end_date:
|
||||
date_str = current_date.strftime("%Y-%m-%d")
|
||||
stats = stats_dict.get(
|
||||
date_str, {"created_count": 0, "completed_count": 0}
|
||||
)
|
||||
data.append(
|
||||
{
|
||||
"key": date_str,
|
||||
"name": date_str,
|
||||
"count": stats["created_count"] + stats["completed_count"],
|
||||
"completed_issues": stats["completed_count"],
|
||||
"created_issues": stats["created_count"],
|
||||
}
|
||||
)
|
||||
current_date += timedelta(days=1)
|
||||
else:
|
||||
# Apply date range filter if available
|
||||
if self.filters["chart_period_range"]:
|
||||
start_date, end_date = self.filters["chart_period_range"]
|
||||
queryset = queryset.filter(
|
||||
created_at__date__gte=start_date, created_at__date__lte=end_date
|
||||
)
|
||||
|
||||
# Annotate by month and count
|
||||
monthly_stats = (
|
||||
queryset.annotate(month=TruncMonth("created_at"))
|
||||
.values("month")
|
||||
.annotate(
|
||||
created_count=Count("id"),
|
||||
completed_count=Count("id", filter=Q(state__group="completed")),
|
||||
)
|
||||
.order_by("month")
|
||||
)
|
||||
|
||||
# Create dictionary of month -> counts
|
||||
stats_dict = {
|
||||
stat["month"].strftime("%Y-%m-%d"): {
|
||||
"created_count": stat["created_count"],
|
||||
"completed_count": stat["completed_count"],
|
||||
}
|
||||
for stat in monthly_stats
|
||||
}
|
||||
|
||||
# Generate monthly data (ensure months with 0 count are included)
|
||||
data = []
|
||||
# include the current date at the end
|
||||
end_date = timezone.now().date()
|
||||
last_month = end_date.replace(day=1)
|
||||
current_month = start_date
|
||||
|
||||
while current_month <= last_month:
|
||||
date_str = current_month.strftime("%Y-%m-%d")
|
||||
stats = stats_dict.get(
|
||||
date_str, {"created_count": 0, "completed_count": 0}
|
||||
)
|
||||
data.append(
|
||||
{
|
||||
"key": date_str,
|
||||
"name": date_str,
|
||||
"count": stats["created_count"],
|
||||
"completed_issues": stats["completed_count"],
|
||||
"created_issues": stats["created_count"],
|
||||
}
|
||||
)
|
||||
# Move to next month
|
||||
if current_month.month == 12:
|
||||
current_month = current_month.replace(
|
||||
year=current_month.year + 1, month=1
|
||||
)
|
||||
else:
|
||||
current_month = current_month.replace(month=current_month.month + 1)
|
||||
|
||||
schema = {
|
||||
"completed_issues": "completed_issues",
|
||||
"created_issues": "created_issues",
|
||||
}
|
||||
|
||||
return {"data": data, "schema": schema}
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def get(self, request: HttpRequest, slug: str, project_id: str) -> Response:
|
||||
self.initialize_workspace(slug, type="chart")
|
||||
type = request.GET.get("type", "projects")
|
||||
group_by = request.GET.get("group_by", None)
|
||||
x_axis = request.GET.get("x_axis", "PRIORITY")
|
||||
cycle_id = request.GET.get("cycle_id", None)
|
||||
module_id = request.GET.get("module_id", None)
|
||||
|
||||
if type == "custom-work-items":
|
||||
queryset = (
|
||||
Issue.issue_objects.filter(**self.filters["base_filters"])
|
||||
.filter(project_id=project_id)
|
||||
.select_related("workspace", "state", "parent")
|
||||
.prefetch_related(
|
||||
"assignees", "labels", "issue_module__module", "issue_cycle__cycle"
|
||||
)
|
||||
)
|
||||
|
||||
# Apply cycle/module filters if present
|
||||
if cycle_id is not None:
|
||||
cycle_issues = CycleIssue.objects.filter(
|
||||
**self.filters["base_filters"], cycle_id=cycle_id
|
||||
).values_list("issue_id", flat=True)
|
||||
queryset = queryset.filter(id__in=cycle_issues)
|
||||
|
||||
elif module_id is not None:
|
||||
module_issues = ModuleIssue.objects.filter(
|
||||
**self.filters["base_filters"], module_id=module_id
|
||||
).values_list("issue_id", flat=True)
|
||||
queryset = queryset.filter(id__in=module_issues)
|
||||
|
||||
# Apply date range filter if available
|
||||
if self.filters["chart_period_range"]:
|
||||
start_date, end_date = self.filters["chart_period_range"]
|
||||
queryset = queryset.filter(
|
||||
created_at__date__gte=start_date, created_at__date__lte=end_date
|
||||
)
|
||||
|
||||
return Response(
|
||||
build_analytics_chart(queryset, x_axis, group_by),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
elif type == "work-items":
|
||||
# Optionally accept cycle_id or module_id as query params
|
||||
cycle_id = request.GET.get("cycle_id", None)
|
||||
module_id = request.GET.get("module_id", None)
|
||||
|
||||
return Response(
|
||||
self.work_item_completion_chart(
|
||||
project_id=project_id, cycle_id=cycle_id, module_id=module_id
|
||||
),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
return Response({"message": "Invalid type"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -1119,14 +1119,13 @@ class CycleUserPropertiesEndpoint(BaseAPIView):
|
||||
class CycleProgressEndpoint(BaseAPIView):
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def get(self, request, slug, project_id, cycle_id):
|
||||
|
||||
cycle = Cycle.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, id=cycle_id
|
||||
).first()
|
||||
if not cycle:
|
||||
return Response(
|
||||
{"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
)
|
||||
aggregate_estimates = (
|
||||
Issue.issue_objects.filter(
|
||||
estimate_point__estimate__type="points",
|
||||
@@ -1177,7 +1176,7 @@ class CycleProgressEndpoint(BaseAPIView):
|
||||
),
|
||||
)
|
||||
)
|
||||
if cycle.progress_snapshot:
|
||||
if cycle.progress_snapshot:
|
||||
backlog_issues = cycle.progress_snapshot.get("backlog_issues", 0)
|
||||
unstarted_issues = cycle.progress_snapshot.get("unstarted_issues", 0)
|
||||
started_issues = cycle.progress_snapshot.get("started_issues", 0)
|
||||
|
||||
@@ -29,6 +29,7 @@ from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPagina
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
class CycleIssueViewSet(BaseViewSet):
|
||||
serializer_class = CycleIssueSerializer
|
||||
model = CycleIssue
|
||||
|
||||
+41
-28
@@ -11,8 +11,7 @@ from rest_framework.response import Response
|
||||
|
||||
# Module import
|
||||
from plane.app.permissions import ROLE, allow_permission
|
||||
from plane.app.serializers import (ProjectLiteSerializer,
|
||||
WorkspaceLiteSerializer)
|
||||
from plane.app.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer
|
||||
from plane.db.models import Project, Workspace
|
||||
from plane.license.utils.instance_value import get_configuration_value
|
||||
from plane.utils.exception_logger import log_exception
|
||||
@@ -22,6 +21,7 @@ from ..base import BaseAPIView
|
||||
|
||||
class LLMProvider:
|
||||
"""Base class for LLM provider configurations"""
|
||||
|
||||
name: str = ""
|
||||
models: List[str] = []
|
||||
default_model: str = ""
|
||||
@@ -34,11 +34,13 @@ class LLMProvider:
|
||||
"default_model": cls.default_model,
|
||||
}
|
||||
|
||||
|
||||
class OpenAIProvider(LLMProvider):
|
||||
name = "OpenAI"
|
||||
models = ["gpt-3.5-turbo", "gpt-4o-mini", "gpt-4o", "o1-mini", "o1-preview"]
|
||||
default_model = "gpt-4o-mini"
|
||||
|
||||
|
||||
class AnthropicProvider(LLMProvider):
|
||||
name = "Anthropic"
|
||||
models = [
|
||||
@@ -49,40 +51,45 @@ class AnthropicProvider(LLMProvider):
|
||||
"claude-2.1",
|
||||
"claude-2",
|
||||
"claude-instant-1.2",
|
||||
"claude-instant-1"
|
||||
"claude-instant-1",
|
||||
]
|
||||
default_model = "claude-3-sonnet-20240229"
|
||||
|
||||
|
||||
class GeminiProvider(LLMProvider):
|
||||
name = "Gemini"
|
||||
models = ["gemini-pro", "gemini-1.5-pro-latest", "gemini-pro-vision"]
|
||||
default_model = "gemini-pro"
|
||||
|
||||
|
||||
SUPPORTED_PROVIDERS = {
|
||||
"openai": OpenAIProvider,
|
||||
"anthropic": AnthropicProvider,
|
||||
"gemini": GeminiProvider,
|
||||
}
|
||||
|
||||
|
||||
def get_llm_config() -> Tuple[str | None, str | None, str | None]:
|
||||
"""
|
||||
Helper to get LLM configuration values, returns:
|
||||
- api_key, model, provider
|
||||
"""
|
||||
api_key, provider_key, model = get_configuration_value([
|
||||
{
|
||||
"key": "LLM_API_KEY",
|
||||
"default": os.environ.get("LLM_API_KEY", None),
|
||||
},
|
||||
{
|
||||
"key": "LLM_PROVIDER",
|
||||
"default": os.environ.get("LLM_PROVIDER", "openai"),
|
||||
},
|
||||
{
|
||||
"key": "LLM_MODEL",
|
||||
"default": os.environ.get("LLM_MODEL", None),
|
||||
},
|
||||
])
|
||||
api_key, provider_key, model = get_configuration_value(
|
||||
[
|
||||
{
|
||||
"key": "LLM_API_KEY",
|
||||
"default": os.environ.get("LLM_API_KEY", None),
|
||||
},
|
||||
{
|
||||
"key": "LLM_PROVIDER",
|
||||
"default": os.environ.get("LLM_PROVIDER", "openai"),
|
||||
},
|
||||
{
|
||||
"key": "LLM_MODEL",
|
||||
"default": os.environ.get("LLM_MODEL", None),
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
provider = SUPPORTED_PROVIDERS.get(provider_key.lower())
|
||||
if not provider:
|
||||
@@ -99,16 +106,20 @@ def get_llm_config() -> Tuple[str | None, str | None, str | None]:
|
||||
|
||||
# Validate model is supported by provider
|
||||
if model not in provider.models:
|
||||
log_exception(ValueError(
|
||||
f"Model {model} not supported by {provider.name}. "
|
||||
f"Supported models: {', '.join(provider.models)}"
|
||||
))
|
||||
log_exception(
|
||||
ValueError(
|
||||
f"Model {model} not supported by {provider.name}. "
|
||||
f"Supported models: {', '.join(provider.models)}"
|
||||
)
|
||||
)
|
||||
return None, None, None
|
||||
|
||||
return api_key, model, provider_key
|
||||
|
||||
|
||||
def get_llm_response(task, prompt, api_key: str, model: str, provider: str) -> Tuple[str | None, str | None]:
|
||||
def get_llm_response(
|
||||
task, prompt, api_key: str, model: str, provider: str
|
||||
) -> Tuple[str | None, str | None]:
|
||||
"""Helper to get LLM completion response"""
|
||||
final_text = task + "\n" + prompt
|
||||
try:
|
||||
@@ -118,10 +129,7 @@ def get_llm_response(task, prompt, api_key: str, model: str, provider: str) -> T
|
||||
|
||||
client = OpenAI(api_key=api_key)
|
||||
chat_completion = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[
|
||||
{"role": "user", "content": final_text}
|
||||
]
|
||||
model=model, messages=[{"role": "user", "content": final_text}]
|
||||
)
|
||||
text = chat_completion.choices[0].message.content
|
||||
return text, None
|
||||
@@ -135,6 +143,7 @@ def get_llm_response(task, prompt, api_key: str, model: str, provider: str) -> T
|
||||
else:
|
||||
return None, f"Error occurred while generating response from {provider}"
|
||||
|
||||
|
||||
class GPTIntegrationEndpoint(BaseAPIView):
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def post(self, request, slug, project_id):
|
||||
@@ -152,7 +161,9 @@ class GPTIntegrationEndpoint(BaseAPIView):
|
||||
{"error": "Task is required"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
text, error = get_llm_response(task, request.data.get("prompt", False), api_key, model, provider)
|
||||
text, error = get_llm_response(
|
||||
task, request.data.get("prompt", False), api_key, model, provider
|
||||
)
|
||||
if not text and error:
|
||||
return Response(
|
||||
{"error": "An internal error has occurred."},
|
||||
@@ -190,7 +201,9 @@ class WorkspaceGPTIntegrationEndpoint(BaseAPIView):
|
||||
{"error": "Task is required"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
text, error = get_llm_response(task, request.data.get("prompt", False), api_key, model, provider)
|
||||
text, error = get_llm_response(
|
||||
task, request.data.get("prompt", False), api_key, model, provider
|
||||
)
|
||||
if not text and error:
|
||||
return Response(
|
||||
{"error": "An internal error has occurred."},
|
||||
|
||||
@@ -38,6 +38,7 @@ from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPagina
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
from plane.utils.error_codes import ERROR_CODES
|
||||
from plane.utils.host import base_host
|
||||
|
||||
# Module imports
|
||||
from .. import BaseViewSet, BaseAPIView
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ from plane.settings.storage import S3Storage
|
||||
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
class IssueAttachmentEndpoint(BaseAPIView):
|
||||
serializer_class = IssueAttachmentSerializer
|
||||
model = FileAsset
|
||||
|
||||
@@ -19,6 +19,7 @@ from plane.db.models import IssueComment, ProjectMember, CommentReaction, Projec
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
class IssueCommentViewSet(BaseViewSet):
|
||||
serializer_class = IssueCommentSerializer
|
||||
model = IssueComment
|
||||
|
||||
@@ -17,6 +17,7 @@ from plane.db.models import IssueLink
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
class IssueLinkViewSet(BaseViewSet):
|
||||
permission_classes = [ProjectEntityPermission]
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from plane.db.models import IssueReaction
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
class IssueReactionViewSet(BaseViewSet):
|
||||
serializer_class = IssueReactionSerializer
|
||||
model = IssueReaction
|
||||
|
||||
@@ -29,6 +29,7 @@ from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.utils.issue_relation_mapper import get_actual_relation
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
class IssueRelationViewSet(BaseViewSet):
|
||||
serializer_class = IssueRelationSerializer
|
||||
model = IssueRelation
|
||||
|
||||
@@ -25,6 +25,7 @@ from collections import defaultdict
|
||||
from plane.utils.host import base_host
|
||||
from plane.utils.order_queryset import order_issue_queryset
|
||||
|
||||
|
||||
class SubIssuesEndpoint(BaseAPIView):
|
||||
permission_classes = [ProjectEntityPermission]
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ from .. import BaseAPIView, BaseViewSet
|
||||
from plane.bgtasks.recent_visited_task import recent_visited_task
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
class ModuleViewSet(BaseViewSet):
|
||||
model = Module
|
||||
webhook_event = "module"
|
||||
|
||||
@@ -36,6 +36,7 @@ from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPagina
|
||||
from .. import BaseViewSet
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
class ModuleIssueViewSet(BaseViewSet):
|
||||
serializer_class = ModuleIssueSerializer
|
||||
model = ModuleIssue
|
||||
@@ -280,7 +281,11 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
issue_id=str(issue_id),
|
||||
project_id=str(project_id),
|
||||
current_instance=json.dumps(
|
||||
{"module_name": module_issue.first().module.name if (module_issue.first() and module_issue.first().module) else None}
|
||||
{
|
||||
"module_name": module_issue.first().module.name
|
||||
if (module_issue.first() and module_issue.first().module)
|
||||
else None
|
||||
}
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
|
||||
@@ -445,7 +445,7 @@ class ProjectViewSet(BaseViewSet):
|
||||
is_active=True,
|
||||
).exists()
|
||||
):
|
||||
project = Project.objects.get(pk=pk)
|
||||
project = Project.objects.get(pk=pk, workspace__slug=slug)
|
||||
project.delete()
|
||||
webhook_activity.delay(
|
||||
event="project",
|
||||
|
||||
@@ -29,6 +29,7 @@ from plane.db.models import (
|
||||
from plane.db.models.project import ProjectNetwork
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
class ProjectInvitationsViewset(BaseViewSet):
|
||||
serializer_class = ProjectMemberInviteSerializer
|
||||
model = ProjectMemberInvite
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Django imports
|
||||
from django.db.models import Q
|
||||
from django.db.models import Q, QuerySet
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
@@ -12,6 +12,95 @@ from plane.utils.issue_search import search_issues
|
||||
|
||||
|
||||
class IssueSearchEndpoint(BaseAPIView):
|
||||
def filter_issues_by_project(self, project_id: int, issues: QuerySet) -> QuerySet:
|
||||
"""
|
||||
Filter issues by project
|
||||
"""
|
||||
|
||||
issues = issues.filter(project_id=project_id)
|
||||
|
||||
return issues
|
||||
|
||||
def search_issues_by_query(self, query: str, issues: QuerySet) -> QuerySet:
|
||||
"""
|
||||
Search issues by query
|
||||
"""
|
||||
|
||||
issues = search_issues(query, issues)
|
||||
|
||||
return issues
|
||||
|
||||
def search_issues_and_excluding_parent(
|
||||
self, issues: QuerySet, issue_id: str
|
||||
) -> QuerySet:
|
||||
"""
|
||||
Search issues and epics by query excluding the parent
|
||||
"""
|
||||
|
||||
issue = Issue.issue_objects.filter(pk=issue_id).first()
|
||||
if issue:
|
||||
issues = issues.filter(
|
||||
~Q(pk=issue_id), ~Q(pk=issue.parent_id), ~Q(parent_id=issue_id)
|
||||
)
|
||||
return issues
|
||||
|
||||
def filter_issues_excluding_related_issues(
|
||||
self, issue_id: str, issues: QuerySet
|
||||
) -> QuerySet:
|
||||
"""
|
||||
Filter issues excluding related issues
|
||||
"""
|
||||
|
||||
issue = Issue.issue_objects.filter(pk=issue_id).first()
|
||||
related_issue_ids = (
|
||||
IssueRelation.objects.filter(Q(related_issue=issue) | Q(issue=issue))
|
||||
.values_list("issue_id", "related_issue_id")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
related_issue_ids = [item for sublist in related_issue_ids for item in sublist]
|
||||
|
||||
if issue:
|
||||
issues = issues.filter(~Q(pk=issue_id), ~Q(pk__in=related_issue_ids))
|
||||
|
||||
return issues
|
||||
|
||||
def filter_root_issues_only(self, issue_id: str, issues: QuerySet) -> QuerySet:
|
||||
"""
|
||||
Filter root issues only
|
||||
"""
|
||||
issue = Issue.issue_objects.filter(pk=issue_id).first()
|
||||
if issue:
|
||||
issues = issues.filter(~Q(pk=issue_id), parent__isnull=True)
|
||||
if issue.parent:
|
||||
issues = issues.filter(~Q(pk=issue.parent_id))
|
||||
return issues
|
||||
|
||||
def exclude_issues_in_cycles(self, issues: QuerySet) -> QuerySet:
|
||||
"""
|
||||
Exclude issues in cycles
|
||||
"""
|
||||
issues = issues.exclude(
|
||||
Q(issue_cycle__isnull=False) & Q(issue_cycle__deleted_at__isnull=True)
|
||||
)
|
||||
return issues
|
||||
|
||||
def exclude_issues_in_module(self, issues: QuerySet, module: str) -> QuerySet:
|
||||
"""
|
||||
Exclude issues in a module
|
||||
"""
|
||||
issues = issues.exclude(
|
||||
Q(issue_module__module=module) & Q(issue_module__deleted_at__isnull=True)
|
||||
)
|
||||
return issues
|
||||
|
||||
def filter_issues_without_target_date(self, issues: QuerySet) -> QuerySet:
|
||||
"""
|
||||
Filter issues without a target date
|
||||
"""
|
||||
issues = issues.filter(target_date__isnull=True)
|
||||
return issues
|
||||
|
||||
def get(self, request, slug, project_id):
|
||||
query = request.query_params.get("search", False)
|
||||
workspace_search = request.query_params.get("workspace_search", "false")
|
||||
@@ -21,7 +110,6 @@ class IssueSearchEndpoint(BaseAPIView):
|
||||
module = request.query_params.get("module", False)
|
||||
sub_issue = request.query_params.get("sub_issue", "false")
|
||||
target_date = request.query_params.get("target_date", True)
|
||||
|
||||
issue_id = request.query_params.get("issue_id", False)
|
||||
|
||||
issues = Issue.issue_objects.filter(
|
||||
@@ -32,52 +120,28 @@ class IssueSearchEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
if workspace_search == "false":
|
||||
issues = issues.filter(project_id=project_id)
|
||||
issues = self.filter_issues_by_project(project_id, issues)
|
||||
|
||||
if query:
|
||||
issues = search_issues(query, issues)
|
||||
issues = self.search_issues_by_query(query, issues)
|
||||
|
||||
if parent == "true" and issue_id:
|
||||
issue = Issue.issue_objects.filter(pk=issue_id).first()
|
||||
if issue:
|
||||
issues = issues.filter(
|
||||
~Q(pk=issue_id), ~Q(pk=issue.parent_id), ~Q(parent_id=issue_id)
|
||||
)
|
||||
issues = self.search_issues_and_excluding_parent(issues, issue_id)
|
||||
|
||||
if issue_relation == "true" and issue_id:
|
||||
issue = Issue.issue_objects.filter(pk=issue_id).first()
|
||||
related_issue_ids = IssueRelation.objects.filter(
|
||||
Q(related_issue=issue) | Q(issue=issue)
|
||||
).values_list(
|
||||
"issue_id", "related_issue_id"
|
||||
).distinct()
|
||||
issues = self.filter_issues_excluding_related_issues(issue_id, issues)
|
||||
|
||||
related_issue_ids = [item for sublist in related_issue_ids for item in sublist]
|
||||
|
||||
if issue:
|
||||
issues = issues.filter(
|
||||
~Q(pk=issue_id),
|
||||
~Q(pk__in=related_issue_ids),
|
||||
)
|
||||
if sub_issue == "true" and issue_id:
|
||||
issue = Issue.issue_objects.filter(pk=issue_id).first()
|
||||
if issue:
|
||||
issues = issues.filter(~Q(pk=issue_id), parent__isnull=True)
|
||||
if issue.parent:
|
||||
issues = issues.filter(~Q(pk=issue.parent_id))
|
||||
issues = self.filter_root_issues_only(issue_id, issues)
|
||||
|
||||
if cycle == "true":
|
||||
issues = issues.exclude(
|
||||
Q(issue_cycle__isnull=False) & Q(issue_cycle__deleted_at__isnull=True)
|
||||
)
|
||||
issues = self.exclude_issues_in_cycles(issues)
|
||||
|
||||
if module:
|
||||
issues = issues.exclude(
|
||||
Q(issue_module__module=module)
|
||||
& Q(issue_module__deleted_at__isnull=True)
|
||||
)
|
||||
issues = self.exclude_issues_in_module(issues, module)
|
||||
|
||||
if target_date == "none":
|
||||
issues = issues.filter(target_date__isnull=True)
|
||||
issues = self.filter_issues_without_target_date(issues)
|
||||
|
||||
if ProjectMember.objects.filter(
|
||||
project_id=project_id, member=self.request.user, is_active=True, role=5
|
||||
|
||||
@@ -24,125 +24,152 @@ class TimezoneEndpoint(APIView):
|
||||
@method_decorator(cache_page(60 * 60 * 2))
|
||||
def get(self, request):
|
||||
timezone_locations = [
|
||||
('Midway Island', 'Pacific/Midway'), # UTC-11:00
|
||||
('American Samoa', 'Pacific/Pago_Pago'), # UTC-11:00
|
||||
('Hawaii', 'Pacific/Honolulu'), # UTC-10:00
|
||||
('Aleutian Islands', 'America/Adak'), # UTC-10:00 (DST: UTC-09:00)
|
||||
('Marquesas Islands', 'Pacific/Marquesas'), # UTC-09:30
|
||||
('Alaska', 'America/Anchorage'), # UTC-09:00 (DST: UTC-08:00)
|
||||
('Gambier Islands', 'Pacific/Gambier'), # UTC-09:00
|
||||
('Pacific Time (US and Canada)', 'America/Los_Angeles'), # UTC-08:00 (DST: UTC-07:00)
|
||||
('Baja California', 'America/Tijuana'), # UTC-08:00 (DST: UTC-07:00)
|
||||
('Mountain Time (US and Canada)', 'America/Denver'), # UTC-07:00 (DST: UTC-06:00)
|
||||
('Arizona', 'America/Phoenix'), # UTC-07:00
|
||||
('Chihuahua, Mazatlan', 'America/Chihuahua'), # UTC-07:00 (DST: UTC-06:00)
|
||||
('Central Time (US and Canada)', 'America/Chicago'), # UTC-06:00 (DST: UTC-05:00)
|
||||
('Saskatchewan', 'America/Regina'), # UTC-06:00
|
||||
('Guadalajara, Mexico City, Monterrey', 'America/Mexico_City'), # UTC-06:00 (DST: UTC-05:00)
|
||||
('Tegucigalpa, Honduras', 'America/Tegucigalpa'), # UTC-06:00
|
||||
('Costa Rica', 'America/Costa_Rica'), # UTC-06:00
|
||||
('Eastern Time (US and Canada)', 'America/New_York'), # UTC-05:00 (DST: UTC-04:00)
|
||||
('Lima', 'America/Lima'), # UTC-05:00
|
||||
('Bogota', 'America/Bogota'), # UTC-05:00
|
||||
('Quito', 'America/Guayaquil'), # UTC-05:00
|
||||
('Chetumal', 'America/Cancun'), # UTC-05:00 (DST: UTC-04:00)
|
||||
('Caracas (Old Venezuela Time)', 'America/Caracas'), # UTC-04:30
|
||||
('Atlantic Time (Canada)', 'America/Halifax'), # UTC-04:00 (DST: UTC-03:00)
|
||||
('Caracas', 'America/Caracas'), # UTC-04:00
|
||||
('Santiago', 'America/Santiago'), # UTC-04:00 (DST: UTC-03:00)
|
||||
('La Paz', 'America/La_Paz'), # UTC-04:00
|
||||
('Manaus', 'America/Manaus'), # UTC-04:00
|
||||
('Georgetown', 'America/Guyana'), # UTC-04:00
|
||||
('Bermuda', 'Atlantic/Bermuda'), # UTC-04:00 (DST: UTC-03:00)
|
||||
('Newfoundland Time (Canada)', 'America/St_Johns'), # UTC-03:30 (DST: UTC-02:30)
|
||||
('Buenos Aires', 'America/Argentina/Buenos_Aires'), # UTC-03:00
|
||||
('Brasilia', 'America/Sao_Paulo'), # UTC-03:00
|
||||
('Greenland', 'America/Godthab'), # UTC-03:00 (DST: UTC-02:00)
|
||||
('Montevideo', 'America/Montevideo'), # UTC-03:00
|
||||
('Falkland Islands', 'Atlantic/Stanley'), # UTC-03:00
|
||||
('South Georgia and the South Sandwich Islands', 'Atlantic/South_Georgia'), # UTC-02:00
|
||||
('Azores', 'Atlantic/Azores'), # UTC-01:00 (DST: UTC+00:00)
|
||||
('Cape Verde Islands', 'Atlantic/Cape_Verde'), # UTC-01:00
|
||||
('Dublin', 'Europe/Dublin'), # UTC+00:00 (DST: UTC+01:00)
|
||||
('Reykjavik', 'Atlantic/Reykjavik'), # UTC+00:00
|
||||
('Lisbon', 'Europe/Lisbon'), # UTC+00:00 (DST: UTC+01:00)
|
||||
('Monrovia', 'Africa/Monrovia'), # UTC+00:00
|
||||
('Casablanca', 'Africa/Casablanca'), # UTC+00:00 (DST: UTC+01:00)
|
||||
('Central European Time (Berlin, Rome, Paris)', 'Europe/Paris'), # UTC+01:00 (DST: UTC+02:00)
|
||||
('West Central Africa', 'Africa/Lagos'), # UTC+01:00
|
||||
('Algiers', 'Africa/Algiers'), # UTC+01:00
|
||||
('Lagos', 'Africa/Lagos'), # UTC+01:00
|
||||
('Tunis', 'Africa/Tunis'), # UTC+01:00
|
||||
('Eastern European Time (Cairo, Helsinki, Kyiv)', 'Europe/Kiev'), # UTC+02:00 (DST: UTC+03:00)
|
||||
('Athens', 'Europe/Athens'), # UTC+02:00 (DST: UTC+03:00)
|
||||
('Jerusalem', 'Asia/Jerusalem'), # UTC+02:00 (DST: UTC+03:00)
|
||||
('Johannesburg', 'Africa/Johannesburg'), # UTC+02:00
|
||||
('Harare, Pretoria', 'Africa/Harare'), # UTC+02:00
|
||||
('Moscow Time', 'Europe/Moscow'), # UTC+03:00
|
||||
('Baghdad', 'Asia/Baghdad'), # UTC+03:00
|
||||
('Nairobi', 'Africa/Nairobi'), # UTC+03:00
|
||||
('Kuwait, Riyadh', 'Asia/Riyadh'), # UTC+03:00
|
||||
('Tehran', 'Asia/Tehran'), # UTC+03:30 (DST: UTC+04:30)
|
||||
('Abu Dhabi', 'Asia/Dubai'), # UTC+04:00
|
||||
('Baku', 'Asia/Baku'), # UTC+04:00 (DST: UTC+05:00)
|
||||
('Yerevan', 'Asia/Yerevan'), # UTC+04:00 (DST: UTC+05:00)
|
||||
('Astrakhan', 'Europe/Astrakhan'), # UTC+04:00
|
||||
('Tbilisi', 'Asia/Tbilisi'), # UTC+04:00
|
||||
('Mauritius', 'Indian/Mauritius'), # UTC+04:00
|
||||
('Islamabad', 'Asia/Karachi'), # UTC+05:00
|
||||
('Karachi', 'Asia/Karachi'), # UTC+05:00
|
||||
('Tashkent', 'Asia/Tashkent'), # UTC+05:00
|
||||
('Yekaterinburg', 'Asia/Yekaterinburg'), # UTC+05:00
|
||||
('Maldives', 'Indian/Maldives'), # UTC+05:00
|
||||
('Chagos', 'Indian/Chagos'), # UTC+05:00
|
||||
('Chennai', 'Asia/Kolkata'), # UTC+05:30
|
||||
('Kolkata', 'Asia/Kolkata'), # UTC+05:30
|
||||
('Mumbai', 'Asia/Kolkata'), # UTC+05:30
|
||||
('New Delhi', 'Asia/Kolkata'), # UTC+05:30
|
||||
('Sri Jayawardenepura', 'Asia/Colombo'), # UTC+05:30
|
||||
('Kathmandu', 'Asia/Kathmandu'), # UTC+05:45
|
||||
('Dhaka', 'Asia/Dhaka'), # UTC+06:00
|
||||
('Almaty', 'Asia/Almaty'), # UTC+06:00
|
||||
('Bishkek', 'Asia/Bishkek'), # UTC+06:00
|
||||
('Thimphu', 'Asia/Thimphu'), # UTC+06:00
|
||||
('Yangon (Rangoon)', 'Asia/Yangon'), # UTC+06:30
|
||||
('Cocos Islands', 'Indian/Cocos'), # UTC+06:30
|
||||
('Bangkok', 'Asia/Bangkok'), # UTC+07:00
|
||||
('Hanoi', 'Asia/Ho_Chi_Minh'), # UTC+07:00
|
||||
('Jakarta', 'Asia/Jakarta'), # UTC+07:00
|
||||
('Novosibirsk', 'Asia/Novosibirsk'), # UTC+07:00
|
||||
('Krasnoyarsk', 'Asia/Krasnoyarsk'), # UTC+07:00
|
||||
('Beijing', 'Asia/Shanghai'), # UTC+08:00
|
||||
('Singapore', 'Asia/Singapore'), # UTC+08:00
|
||||
('Perth', 'Australia/Perth'), # UTC+08:00
|
||||
('Hong Kong', 'Asia/Hong_Kong'), # UTC+08:00
|
||||
('Ulaanbaatar', 'Asia/Ulaanbaatar'), # UTC+08:00
|
||||
('Palau', 'Pacific/Palau'), # UTC+08:00
|
||||
('Eucla', 'Australia/Eucla'), # UTC+08:45
|
||||
('Tokyo', 'Asia/Tokyo'), # UTC+09:00
|
||||
('Seoul', 'Asia/Seoul'), # UTC+09:00
|
||||
('Yakutsk', 'Asia/Yakutsk'), # UTC+09:00
|
||||
('Adelaide', 'Australia/Adelaide'), # UTC+09:30 (DST: UTC+10:30)
|
||||
('Darwin', 'Australia/Darwin'), # UTC+09:30
|
||||
('Sydney', 'Australia/Sydney'), # UTC+10:00 (DST: UTC+11:00)
|
||||
('Brisbane', 'Australia/Brisbane'), # UTC+10:00
|
||||
('Guam', 'Pacific/Guam'), # UTC+10:00
|
||||
('Vladivostok', 'Asia/Vladivostok'), # UTC+10:00
|
||||
('Tahiti', 'Pacific/Tahiti'), # UTC+10:00
|
||||
('Lord Howe Island', 'Australia/Lord_Howe'), # UTC+10:30 (DST: UTC+11:00)
|
||||
('Solomon Islands', 'Pacific/Guadalcanal'), # UTC+11:00
|
||||
('Magadan', 'Asia/Magadan'), # UTC+11:00
|
||||
('Norfolk Island', 'Pacific/Norfolk'), # UTC+11:00
|
||||
('Bougainville Island', 'Pacific/Bougainville'), # UTC+11:00
|
||||
('Chokurdakh', 'Asia/Srednekolymsk'), # UTC+11:00
|
||||
('Auckland', 'Pacific/Auckland'), # UTC+12:00 (DST: UTC+13:00)
|
||||
('Wellington', 'Pacific/Auckland'), # UTC+12:00 (DST: UTC+13:00)
|
||||
('Fiji Islands', 'Pacific/Fiji'), # UTC+12:00 (DST: UTC+13:00)
|
||||
('Anadyr', 'Asia/Anadyr'), # UTC+12:00
|
||||
('Chatham Islands', 'Pacific/Chatham'), # UTC+12:45 (DST: UTC+13:45)
|
||||
("Nuku'alofa", 'Pacific/Tongatapu'), # UTC+13:00
|
||||
('Samoa', 'Pacific/Apia'), # UTC+13:00 (DST: UTC+14:00)
|
||||
('Kiritimati Island', 'Pacific/Kiritimati') # UTC+14:00
|
||||
("Midway Island", "Pacific/Midway"), # UTC-11:00
|
||||
("American Samoa", "Pacific/Pago_Pago"), # UTC-11:00
|
||||
("Hawaii", "Pacific/Honolulu"), # UTC-10:00
|
||||
("Aleutian Islands", "America/Adak"), # UTC-10:00 (DST: UTC-09:00)
|
||||
("Marquesas Islands", "Pacific/Marquesas"), # UTC-09:30
|
||||
("Alaska", "America/Anchorage"), # UTC-09:00 (DST: UTC-08:00)
|
||||
("Gambier Islands", "Pacific/Gambier"), # UTC-09:00
|
||||
(
|
||||
"Pacific Time (US and Canada)",
|
||||
"America/Los_Angeles",
|
||||
), # UTC-08:00 (DST: UTC-07:00)
|
||||
("Baja California", "America/Tijuana"), # UTC-08:00 (DST: UTC-07:00)
|
||||
(
|
||||
"Mountain Time (US and Canada)",
|
||||
"America/Denver",
|
||||
), # UTC-07:00 (DST: UTC-06:00)
|
||||
("Arizona", "America/Phoenix"), # UTC-07:00
|
||||
("Chihuahua, Mazatlan", "America/Chihuahua"), # UTC-07:00 (DST: UTC-06:00)
|
||||
(
|
||||
"Central Time (US and Canada)",
|
||||
"America/Chicago",
|
||||
), # UTC-06:00 (DST: UTC-05:00)
|
||||
("Saskatchewan", "America/Regina"), # UTC-06:00
|
||||
(
|
||||
"Guadalajara, Mexico City, Monterrey",
|
||||
"America/Mexico_City",
|
||||
), # UTC-06:00 (DST: UTC-05:00)
|
||||
("Tegucigalpa, Honduras", "America/Tegucigalpa"), # UTC-06:00
|
||||
("Costa Rica", "America/Costa_Rica"), # UTC-06:00
|
||||
(
|
||||
"Eastern Time (US and Canada)",
|
||||
"America/New_York",
|
||||
), # UTC-05:00 (DST: UTC-04:00)
|
||||
("Lima", "America/Lima"), # UTC-05:00
|
||||
("Bogota", "America/Bogota"), # UTC-05:00
|
||||
("Quito", "America/Guayaquil"), # UTC-05:00
|
||||
("Chetumal", "America/Cancun"), # UTC-05:00 (DST: UTC-04:00)
|
||||
("Caracas (Old Venezuela Time)", "America/Caracas"), # UTC-04:30
|
||||
("Atlantic Time (Canada)", "America/Halifax"), # UTC-04:00 (DST: UTC-03:00)
|
||||
("Caracas", "America/Caracas"), # UTC-04:00
|
||||
("Santiago", "America/Santiago"), # UTC-04:00 (DST: UTC-03:00)
|
||||
("La Paz", "America/La_Paz"), # UTC-04:00
|
||||
("Manaus", "America/Manaus"), # UTC-04:00
|
||||
("Georgetown", "America/Guyana"), # UTC-04:00
|
||||
("Bermuda", "Atlantic/Bermuda"), # UTC-04:00 (DST: UTC-03:00)
|
||||
(
|
||||
"Newfoundland Time (Canada)",
|
||||
"America/St_Johns",
|
||||
), # UTC-03:30 (DST: UTC-02:30)
|
||||
("Buenos Aires", "America/Argentina/Buenos_Aires"), # UTC-03:00
|
||||
("Brasilia", "America/Sao_Paulo"), # UTC-03:00
|
||||
("Greenland", "America/Godthab"), # UTC-03:00 (DST: UTC-02:00)
|
||||
("Montevideo", "America/Montevideo"), # UTC-03:00
|
||||
("Falkland Islands", "Atlantic/Stanley"), # UTC-03:00
|
||||
(
|
||||
"South Georgia and the South Sandwich Islands",
|
||||
"Atlantic/South_Georgia",
|
||||
), # UTC-02:00
|
||||
("Azores", "Atlantic/Azores"), # UTC-01:00 (DST: UTC+00:00)
|
||||
("Cape Verde Islands", "Atlantic/Cape_Verde"), # UTC-01:00
|
||||
("Dublin", "Europe/Dublin"), # UTC+00:00 (DST: UTC+01:00)
|
||||
("Reykjavik", "Atlantic/Reykjavik"), # UTC+00:00
|
||||
("Lisbon", "Europe/Lisbon"), # UTC+00:00 (DST: UTC+01:00)
|
||||
("Monrovia", "Africa/Monrovia"), # UTC+00:00
|
||||
("Casablanca", "Africa/Casablanca"), # UTC+00:00 (DST: UTC+01:00)
|
||||
(
|
||||
"Central European Time (Berlin, Rome, Paris)",
|
||||
"Europe/Paris",
|
||||
), # UTC+01:00 (DST: UTC+02:00)
|
||||
("West Central Africa", "Africa/Lagos"), # UTC+01:00
|
||||
("Algiers", "Africa/Algiers"), # UTC+01:00
|
||||
("Lagos", "Africa/Lagos"), # UTC+01:00
|
||||
("Tunis", "Africa/Tunis"), # UTC+01:00
|
||||
(
|
||||
"Eastern European Time (Cairo, Helsinki, Kyiv)",
|
||||
"Europe/Kiev",
|
||||
), # UTC+02:00 (DST: UTC+03:00)
|
||||
("Athens", "Europe/Athens"), # UTC+02:00 (DST: UTC+03:00)
|
||||
("Jerusalem", "Asia/Jerusalem"), # UTC+02:00 (DST: UTC+03:00)
|
||||
("Johannesburg", "Africa/Johannesburg"), # UTC+02:00
|
||||
("Harare, Pretoria", "Africa/Harare"), # UTC+02:00
|
||||
("Moscow Time", "Europe/Moscow"), # UTC+03:00
|
||||
("Baghdad", "Asia/Baghdad"), # UTC+03:00
|
||||
("Nairobi", "Africa/Nairobi"), # UTC+03:00
|
||||
("Kuwait, Riyadh", "Asia/Riyadh"), # UTC+03:00
|
||||
("Tehran", "Asia/Tehran"), # UTC+03:30 (DST: UTC+04:30)
|
||||
("Abu Dhabi", "Asia/Dubai"), # UTC+04:00
|
||||
("Baku", "Asia/Baku"), # UTC+04:00 (DST: UTC+05:00)
|
||||
("Yerevan", "Asia/Yerevan"), # UTC+04:00 (DST: UTC+05:00)
|
||||
("Astrakhan", "Europe/Astrakhan"), # UTC+04:00
|
||||
("Tbilisi", "Asia/Tbilisi"), # UTC+04:00
|
||||
("Mauritius", "Indian/Mauritius"), # UTC+04:00
|
||||
("Islamabad", "Asia/Karachi"), # UTC+05:00
|
||||
("Karachi", "Asia/Karachi"), # UTC+05:00
|
||||
("Tashkent", "Asia/Tashkent"), # UTC+05:00
|
||||
("Yekaterinburg", "Asia/Yekaterinburg"), # UTC+05:00
|
||||
("Maldives", "Indian/Maldives"), # UTC+05:00
|
||||
("Chagos", "Indian/Chagos"), # UTC+05:00
|
||||
("Chennai", "Asia/Kolkata"), # UTC+05:30
|
||||
("Kolkata", "Asia/Kolkata"), # UTC+05:30
|
||||
("Mumbai", "Asia/Kolkata"), # UTC+05:30
|
||||
("New Delhi", "Asia/Kolkata"), # UTC+05:30
|
||||
("Sri Jayawardenepura", "Asia/Colombo"), # UTC+05:30
|
||||
("Kathmandu", "Asia/Kathmandu"), # UTC+05:45
|
||||
("Dhaka", "Asia/Dhaka"), # UTC+06:00
|
||||
("Almaty", "Asia/Almaty"), # UTC+06:00
|
||||
("Bishkek", "Asia/Bishkek"), # UTC+06:00
|
||||
("Thimphu", "Asia/Thimphu"), # UTC+06:00
|
||||
("Yangon (Rangoon)", "Asia/Yangon"), # UTC+06:30
|
||||
("Cocos Islands", "Indian/Cocos"), # UTC+06:30
|
||||
("Bangkok", "Asia/Bangkok"), # UTC+07:00
|
||||
("Hanoi", "Asia/Ho_Chi_Minh"), # UTC+07:00
|
||||
("Jakarta", "Asia/Jakarta"), # UTC+07:00
|
||||
("Novosibirsk", "Asia/Novosibirsk"), # UTC+07:00
|
||||
("Krasnoyarsk", "Asia/Krasnoyarsk"), # UTC+07:00
|
||||
("Beijing", "Asia/Shanghai"), # UTC+08:00
|
||||
("Singapore", "Asia/Singapore"), # UTC+08:00
|
||||
("Perth", "Australia/Perth"), # UTC+08:00
|
||||
("Hong Kong", "Asia/Hong_Kong"), # UTC+08:00
|
||||
("Ulaanbaatar", "Asia/Ulaanbaatar"), # UTC+08:00
|
||||
("Palau", "Pacific/Palau"), # UTC+08:00
|
||||
("Eucla", "Australia/Eucla"), # UTC+08:45
|
||||
("Tokyo", "Asia/Tokyo"), # UTC+09:00
|
||||
("Seoul", "Asia/Seoul"), # UTC+09:00
|
||||
("Yakutsk", "Asia/Yakutsk"), # UTC+09:00
|
||||
("Adelaide", "Australia/Adelaide"), # UTC+09:30 (DST: UTC+10:30)
|
||||
("Darwin", "Australia/Darwin"), # UTC+09:30
|
||||
("Sydney", "Australia/Sydney"), # UTC+10:00 (DST: UTC+11:00)
|
||||
("Brisbane", "Australia/Brisbane"), # UTC+10:00
|
||||
("Guam", "Pacific/Guam"), # UTC+10:00
|
||||
("Vladivostok", "Asia/Vladivostok"), # UTC+10:00
|
||||
("Tahiti", "Pacific/Tahiti"), # UTC+10:00
|
||||
("Lord Howe Island", "Australia/Lord_Howe"), # UTC+10:30 (DST: UTC+11:00)
|
||||
("Solomon Islands", "Pacific/Guadalcanal"), # UTC+11:00
|
||||
("Magadan", "Asia/Magadan"), # UTC+11:00
|
||||
("Norfolk Island", "Pacific/Norfolk"), # UTC+11:00
|
||||
("Bougainville Island", "Pacific/Bougainville"), # UTC+11:00
|
||||
("Chokurdakh", "Asia/Srednekolymsk"), # UTC+11:00
|
||||
("Auckland", "Pacific/Auckland"), # UTC+12:00 (DST: UTC+13:00)
|
||||
("Wellington", "Pacific/Auckland"), # UTC+12:00 (DST: UTC+13:00)
|
||||
("Fiji Islands", "Pacific/Fiji"), # UTC+12:00 (DST: UTC+13:00)
|
||||
("Anadyr", "Asia/Anadyr"), # UTC+12:00
|
||||
("Chatham Islands", "Pacific/Chatham"), # UTC+12:45 (DST: UTC+13:45)
|
||||
("Nuku'alofa", "Pacific/Tongatapu"), # UTC+13:00
|
||||
("Samoa", "Pacific/Apia"), # UTC+13:00 (DST: UTC+14:00)
|
||||
("Kiritimati Island", "Pacific/Kiritimati"), # UTC+14:00
|
||||
]
|
||||
|
||||
timezone_list = []
|
||||
@@ -150,7 +177,6 @@ class TimezoneEndpoint(APIView):
|
||||
|
||||
# Process timezone mapping
|
||||
for friendly_name, tz_identifier in timezone_locations:
|
||||
|
||||
try:
|
||||
tz = pytz.timezone(tz_identifier)
|
||||
current_offset = now.astimezone(tz).strftime("%z")
|
||||
|
||||
@@ -12,6 +12,7 @@ from plane.app.permissions import WorkspaceViewerPermission
|
||||
from plane.app.serializers.cycle import CycleSerializer
|
||||
from plane.utils.timezone_converter import user_timezone_converter
|
||||
|
||||
|
||||
class WorkspaceCyclesEndpoint(BaseAPIView):
|
||||
permission_classes = [WorkspaceViewerPermission]
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
class WorkspaceDraftIssueViewSet(BaseViewSet):
|
||||
model = DraftIssue
|
||||
|
||||
|
||||
@@ -27,10 +27,7 @@ class WorkspaceUserPreferenceViewSet(BaseAPIView):
|
||||
|
||||
create_preference_keys = []
|
||||
|
||||
keys = [
|
||||
key
|
||||
for key, _ in WorkspaceUserPreference.UserPreferenceKeys.choices
|
||||
]
|
||||
keys = [key for key, _ in WorkspaceUserPreference.UserPreferenceKeys.choices]
|
||||
|
||||
for preference in keys:
|
||||
if preference not in get_preference.values_list("key", flat=True):
|
||||
@@ -39,7 +36,10 @@ class WorkspaceUserPreferenceViewSet(BaseAPIView):
|
||||
preference = WorkspaceUserPreference.objects.bulk_create(
|
||||
[
|
||||
WorkspaceUserPreference(
|
||||
key=key, user=request.user, workspace=workspace, sort_order=(65535 + (i*10000))
|
||||
key=key,
|
||||
user=request.user,
|
||||
workspace=workspace,
|
||||
sort_order=(65535 + (i * 10000)),
|
||||
)
|
||||
for i, key in enumerate(create_preference_keys)
|
||||
],
|
||||
@@ -47,10 +47,13 @@ class WorkspaceUserPreferenceViewSet(BaseAPIView):
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
preferences = WorkspaceUserPreference.objects.filter(
|
||||
user=request.user, workspace_id=workspace.id
|
||||
).order_by("sort_order").values("key", "is_pinned", "sort_order")
|
||||
|
||||
preferences = (
|
||||
WorkspaceUserPreference.objects.filter(
|
||||
user=request.user, workspace_id=workspace.id
|
||||
)
|
||||
.order_by("sort_order")
|
||||
.values("key", "is_pinned", "sort_order")
|
||||
)
|
||||
|
||||
user_preferences = {}
|
||||
|
||||
@@ -58,7 +61,7 @@ class WorkspaceUserPreferenceViewSet(BaseAPIView):
|
||||
user_preferences[(str(preference["key"]))] = {
|
||||
"is_pinned": preference["is_pinned"],
|
||||
"sort_order": preference["sort_order"],
|
||||
}
|
||||
}
|
||||
return Response(
|
||||
user_preferences,
|
||||
status=status.HTTP_200_OK,
|
||||
|
||||
@@ -18,6 +18,7 @@ from plane.bgtasks.user_activation_email_task import user_activation_email
|
||||
from plane.utils.host import base_host
|
||||
from plane.utils.ip_address import get_client_ip
|
||||
|
||||
|
||||
class Adapter:
|
||||
"""Common interface for all auth providers"""
|
||||
|
||||
|
||||
@@ -41,7 +41,6 @@ AUTHENTICATION_ERROR_CODES = {
|
||||
"GOOGLE_OAUTH_PROVIDER_ERROR": 5115,
|
||||
"GITHUB_OAUTH_PROVIDER_ERROR": 5120,
|
||||
"GITLAB_OAUTH_PROVIDER_ERROR": 5121,
|
||||
|
||||
# Reset Password
|
||||
"INVALID_PASSWORD_TOKEN": 5125,
|
||||
"EXPIRED_PASSWORD_TOKEN": 5130,
|
||||
|
||||
@@ -25,23 +25,24 @@ class GitHubOAuthProvider(OauthAdapter):
|
||||
|
||||
organization_scope = "read:org"
|
||||
|
||||
|
||||
def __init__(self, request, code=None, state=None, callback=None):
|
||||
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_ORGANIZATION_ID = get_configuration_value(
|
||||
[
|
||||
{
|
||||
"key": "GITHUB_CLIENT_ID",
|
||||
"default": os.environ.get("GITHUB_CLIENT_ID"),
|
||||
},
|
||||
{
|
||||
"key": "GITHUB_CLIENT_SECRET",
|
||||
"default": os.environ.get("GITHUB_CLIENT_SECRET"),
|
||||
},
|
||||
{
|
||||
"key": "GITHUB_ORGANIZATION_ID",
|
||||
"default": os.environ.get("GITHUB_ORGANIZATION_ID"),
|
||||
},
|
||||
]
|
||||
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_ORGANIZATION_ID = (
|
||||
get_configuration_value(
|
||||
[
|
||||
{
|
||||
"key": "GITHUB_CLIENT_ID",
|
||||
"default": os.environ.get("GITHUB_CLIENT_ID"),
|
||||
},
|
||||
{
|
||||
"key": "GITHUB_CLIENT_SECRET",
|
||||
"default": os.environ.get("GITHUB_CLIENT_SECRET"),
|
||||
},
|
||||
{
|
||||
"key": "GITHUB_ORGANIZATION_ID",
|
||||
"default": os.environ.get("GITHUB_ORGANIZATION_ID"),
|
||||
},
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
if not (GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET):
|
||||
@@ -128,7 +129,10 @@ class GitHubOAuthProvider(OauthAdapter):
|
||||
|
||||
def is_user_in_organization(self, github_username):
|
||||
headers = {"Authorization": f"Bearer {self.token_data.get('access_token')}"}
|
||||
response = requests.get(f"{self.org_membership_url}/{self.organization_id}/memberships/{github_username}", headers=headers)
|
||||
response = requests.get(
|
||||
f"{self.org_membership_url}/{self.organization_id}/memberships/{github_username}",
|
||||
headers=headers,
|
||||
)
|
||||
return response.status_code == 200 # 200 means the user is a member
|
||||
|
||||
def set_user_data(self):
|
||||
@@ -145,7 +149,6 @@ class GitHubOAuthProvider(OauthAdapter):
|
||||
error_message="GITHUB_USER_NOT_IN_ORG",
|
||||
)
|
||||
|
||||
|
||||
email = self.__get_email(headers=headers)
|
||||
super().set_user_data(
|
||||
{
|
||||
|
||||
@@ -42,11 +42,11 @@ urlpatterns = [
|
||||
# credentials
|
||||
path("sign-in/", SignInAuthEndpoint.as_view(), name="sign-in"),
|
||||
path("sign-up/", SignUpAuthEndpoint.as_view(), name="sign-up"),
|
||||
path("spaces/sign-in/", SignInAuthSpaceEndpoint.as_view(), name="sign-in"),
|
||||
path("spaces/sign-up/", SignUpAuthSpaceEndpoint.as_view(), name="sign-in"),
|
||||
path("spaces/sign-in/", SignInAuthSpaceEndpoint.as_view(), name="space-sign-in"),
|
||||
path("spaces/sign-up/", SignUpAuthSpaceEndpoint.as_view(), name="space-sign-up"),
|
||||
# signout
|
||||
path("sign-out/", SignOutAuthEndpoint.as_view(), name="sign-out"),
|
||||
path("spaces/sign-out/", SignOutAuthSpaceEndpoint.as_view(), name="sign-out"),
|
||||
path("spaces/sign-out/", SignOutAuthSpaceEndpoint.as_view(), name="space-sign-out"),
|
||||
# csrf token
|
||||
path("get-csrf-token/", CSRFTokenEndpoint.as_view(), name="get_csrf_token"),
|
||||
# Magic sign in
|
||||
@@ -56,17 +56,17 @@ urlpatterns = [
|
||||
path(
|
||||
"spaces/magic-generate/",
|
||||
MagicGenerateSpaceEndpoint.as_view(),
|
||||
name="magic-generate",
|
||||
name="space-magic-generate",
|
||||
),
|
||||
path(
|
||||
"spaces/magic-sign-in/",
|
||||
MagicSignInSpaceEndpoint.as_view(),
|
||||
name="magic-sign-in",
|
||||
name="space-magic-sign-in",
|
||||
),
|
||||
path(
|
||||
"spaces/magic-sign-up/",
|
||||
MagicSignUpSpaceEndpoint.as_view(),
|
||||
name="magic-sign-up",
|
||||
name="space-magic-sign-up",
|
||||
),
|
||||
## Google Oauth
|
||||
path("google/", GoogleOauthInitiateEndpoint.as_view(), name="google-initiate"),
|
||||
@@ -74,12 +74,12 @@ urlpatterns = [
|
||||
path(
|
||||
"spaces/google/",
|
||||
GoogleOauthInitiateSpaceEndpoint.as_view(),
|
||||
name="google-initiate",
|
||||
name="space-google-initiate",
|
||||
),
|
||||
path(
|
||||
"google/callback/",
|
||||
"spaces/google/callback/",
|
||||
GoogleCallbackSpaceEndpoint.as_view(),
|
||||
name="google-callback",
|
||||
name="space-google-callback",
|
||||
),
|
||||
## Github Oauth
|
||||
path("github/", GitHubOauthInitiateEndpoint.as_view(), name="github-initiate"),
|
||||
@@ -87,12 +87,12 @@ urlpatterns = [
|
||||
path(
|
||||
"spaces/github/",
|
||||
GitHubOauthInitiateSpaceEndpoint.as_view(),
|
||||
name="github-initiate",
|
||||
name="space-github-initiate",
|
||||
),
|
||||
path(
|
||||
"spaces/github/callback/",
|
||||
GitHubCallbackSpaceEndpoint.as_view(),
|
||||
name="github-callback",
|
||||
name="space-github-callback",
|
||||
),
|
||||
## Gitlab Oauth
|
||||
path("gitlab/", GitLabOauthInitiateEndpoint.as_view(), name="gitlab-initiate"),
|
||||
@@ -100,12 +100,12 @@ urlpatterns = [
|
||||
path(
|
||||
"spaces/gitlab/",
|
||||
GitLabOauthInitiateSpaceEndpoint.as_view(),
|
||||
name="gitlab-initiate",
|
||||
name="space-gitlab-initiate",
|
||||
),
|
||||
path(
|
||||
"spaces/gitlab/callback/",
|
||||
GitLabCallbackSpaceEndpoint.as_view(),
|
||||
name="gitlab-callback",
|
||||
name="space-gitlab-callback",
|
||||
),
|
||||
# Email Check
|
||||
path("email-check/", EmailCheckEndpoint.as_view(), name="email-check"),
|
||||
@@ -120,12 +120,12 @@ urlpatterns = [
|
||||
path(
|
||||
"spaces/forgot-password/",
|
||||
ForgotPasswordSpaceEndpoint.as_view(),
|
||||
name="forgot-password",
|
||||
name="space-forgot-password",
|
||||
),
|
||||
path(
|
||||
"spaces/reset-password/<uidb64>/<token>/",
|
||||
ResetPasswordSpaceEndpoint.as_view(),
|
||||
name="forgot-password",
|
||||
name="space-forgot-password",
|
||||
),
|
||||
path("change-password/", ChangePasswordEndpoint.as_view(), name="forgot-password"),
|
||||
path("set-password/", SetUserPasswordEndpoint.as_view(), name="set-password"),
|
||||
|
||||
@@ -21,7 +21,9 @@ def base_host(
|
||||
|
||||
# Admin redirection
|
||||
if is_admin:
|
||||
admin_base_path = getattr(settings, "ADMIN_BASE_PATH", "/god-mode/")
|
||||
admin_base_path = getattr(settings, "ADMIN_BASE_PATH", None)
|
||||
if not isinstance(admin_base_path, str):
|
||||
admin_base_path = "/god-mode/"
|
||||
if not admin_base_path.startswith("/"):
|
||||
admin_base_path = "/" + admin_base_path
|
||||
if not admin_base_path.endswith("/"):
|
||||
@@ -34,7 +36,9 @@ def base_host(
|
||||
|
||||
# Space redirection
|
||||
if is_space:
|
||||
space_base_path = getattr(settings, "SPACE_BASE_PATH", "/spaces/")
|
||||
space_base_path = getattr(settings, "SPACE_BASE_PATH", None)
|
||||
if not isinstance(space_base_path, str):
|
||||
space_base_path = "/spaces/"
|
||||
if not space_base_path.startswith("/"):
|
||||
space_base_path = "/" + space_base_path
|
||||
if not space_base_path.endswith("/"):
|
||||
|
||||
@@ -6,6 +6,7 @@ from django.conf import settings
|
||||
from plane.utils.host import base_host
|
||||
from plane.utils.ip_address import get_client_ip
|
||||
|
||||
|
||||
def user_login(request, user, is_app=False, is_admin=False, is_space=False):
|
||||
login(request=request, user=user)
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from plane.authentication.adapter.error import (
|
||||
)
|
||||
from plane.utils.path_validator import validate_next_path
|
||||
|
||||
|
||||
class SignInAuthEndpoint(View):
|
||||
def post(self, request):
|
||||
next_path = request.POST.get("next_path")
|
||||
|
||||
@@ -18,6 +18,7 @@ from plane.authentication.adapter.error import (
|
||||
)
|
||||
from plane.utils.path_validator import validate_next_path
|
||||
|
||||
|
||||
class GitHubOauthInitiateEndpoint(View):
|
||||
def get(self, request):
|
||||
# Get host and next path
|
||||
|
||||
@@ -18,6 +18,7 @@ from plane.authentication.adapter.error import (
|
||||
)
|
||||
from plane.utils.path_validator import validate_next_path
|
||||
|
||||
|
||||
class GitLabOauthInitiateEndpoint(View):
|
||||
def get(self, request):
|
||||
# Get host and next path
|
||||
|
||||
@@ -20,6 +20,7 @@ from plane.authentication.adapter.error import (
|
||||
)
|
||||
from plane.utils.path_validator import validate_next_path
|
||||
|
||||
|
||||
class GoogleOauthInitiateEndpoint(View):
|
||||
def get(self, request):
|
||||
request.session["host"] = base_host(request=request, is_app=True)
|
||||
@@ -95,7 +96,9 @@ class GoogleCallbackEndpoint(View):
|
||||
# Get the redirection path
|
||||
path = get_redirection_path(user=user)
|
||||
# redirect to referer path
|
||||
url = urljoin(base_host, str(validate_next_path(next_path)) if next_path else path)
|
||||
url = urljoin(
|
||||
base_host, str(validate_next_path(next_path)) if next_path else path
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
|
||||
@@ -53,12 +53,14 @@ class ChangePasswordEndpoint(APIView):
|
||||
error_message="MISSING_PASSWORD",
|
||||
payload={"error": "Old password is missing"},
|
||||
)
|
||||
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response(
|
||||
exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Get the new password
|
||||
new_password = request.data.get("new_password", False)
|
||||
|
||||
if not new_password:
|
||||
if not new_password:
|
||||
exc = AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["MISSING_PASSWORD"],
|
||||
error_message="MISSING_PASSWORD",
|
||||
@@ -66,7 +68,6 @@ class ChangePasswordEndpoint(APIView):
|
||||
)
|
||||
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
# If the user password is not autoset then we need to check the old passwords
|
||||
if not user.is_password_autoset and not user.check_password(old_password):
|
||||
exc = AuthenticationException(
|
||||
|
||||
@@ -25,6 +25,7 @@ from plane.authentication.adapter.error import (
|
||||
)
|
||||
from plane.utils.path_validator import validate_next_path
|
||||
|
||||
|
||||
class MagicGenerateSpaceEndpoint(APIView):
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
@@ -38,7 +39,6 @@ class MagicGenerateSpaceEndpoint(APIView):
|
||||
)
|
||||
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
email = request.data.get("email", "").strip().lower()
|
||||
try:
|
||||
validate_email(email)
|
||||
|
||||
@@ -464,3 +464,32 @@ def analytic_export_task(email, data, slug):
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
|
||||
@shared_task
|
||||
def export_analytics_to_csv_email(data, headers, keys, email, slug):
|
||||
try:
|
||||
"""
|
||||
Prepares a CSV from data and sends it as an email attachment.
|
||||
|
||||
Parameters:
|
||||
- data: List of dictionaries (e.g. from .values())
|
||||
- headers: List of CSV column headers
|
||||
- keys: Keys to extract from each data item (dict)
|
||||
- email: Email address to send to
|
||||
- slug: Used for the filename
|
||||
"""
|
||||
# Prepare rows: header + data rows
|
||||
rows = [headers]
|
||||
for item in data:
|
||||
row = [item.get(key, "") for key in keys]
|
||||
rows.append(row)
|
||||
|
||||
# Generate CSV buffer
|
||||
csv_buffer = generate_csv_from_rows(rows)
|
||||
|
||||
# Send email with CSV attachment
|
||||
send_export_email(email=email, slug=slug, csv_buffer=csv_buffer, rows=rows)
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
@@ -3,34 +3,49 @@ import csv
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
|
||||
from typing import List
|
||||
import boto3
|
||||
from botocore.client import Config
|
||||
from uuid import UUID
|
||||
from datetime import datetime, date
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from openpyxl import Workbook
|
||||
from django.db.models import F, Prefetch
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import ExporterHistory, Issue
|
||||
from plane.db.models import ExporterHistory, Issue, FileAsset, Label, User, IssueComment
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
def dateTimeConverter(time):
|
||||
def dateTimeConverter(time: datetime) -> str | None:
|
||||
"""
|
||||
Convert a datetime object to a formatted string.
|
||||
"""
|
||||
if time:
|
||||
return time.strftime("%a, %d %b %Y %I:%M:%S %Z%z")
|
||||
|
||||
|
||||
def dateConverter(time):
|
||||
def dateConverter(time: date) -> str | None:
|
||||
"""
|
||||
Convert a date object to a formatted string.
|
||||
"""
|
||||
if time:
|
||||
return time.strftime("%a, %d %b %Y")
|
||||
|
||||
|
||||
def create_csv_file(data):
|
||||
def create_csv_file(data: List[List[str]]) -> str:
|
||||
"""
|
||||
Create a CSV file from the provided data.
|
||||
"""
|
||||
csv_buffer = io.StringIO()
|
||||
csv_writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL)
|
||||
|
||||
@@ -41,11 +56,17 @@ def create_csv_file(data):
|
||||
return csv_buffer.getvalue()
|
||||
|
||||
|
||||
def create_json_file(data):
|
||||
def create_json_file(data: List[dict]) -> str:
|
||||
"""
|
||||
Create a JSON file from the provided data.
|
||||
"""
|
||||
return json.dumps(data)
|
||||
|
||||
|
||||
def create_xlsx_file(data):
|
||||
def create_xlsx_file(data: List[List[str]]) -> bytes:
|
||||
"""
|
||||
Create an XLSX file from the provided data.
|
||||
"""
|
||||
workbook = Workbook()
|
||||
sheet = workbook.active
|
||||
|
||||
@@ -58,7 +79,10 @@ def create_xlsx_file(data):
|
||||
return xlsx_buffer.getvalue()
|
||||
|
||||
|
||||
def create_zip_file(files):
|
||||
def create_zip_file(files: List[tuple[str, str | bytes]]) -> io.BytesIO:
|
||||
"""
|
||||
Create a ZIP file from the provided files.
|
||||
"""
|
||||
zip_buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zipf:
|
||||
for filename, file_content in files:
|
||||
@@ -68,7 +92,13 @@ def create_zip_file(files):
|
||||
return zip_buffer
|
||||
|
||||
|
||||
def upload_to_s3(zip_file, workspace_id, token_id, slug):
|
||||
# TODO: Change the upload_to_s3 function to use the new storage method with entry in file asset table
|
||||
def upload_to_s3(
|
||||
zip_file: io.BytesIO, workspace_id: UUID, token_id: str, slug: str
|
||||
) -> None:
|
||||
"""
|
||||
Upload a ZIP file to S3 and generate a presigned URL.
|
||||
"""
|
||||
file_name = (
|
||||
f"{workspace_id}/export-{slug}-{token_id[:6]}-{str(timezone.now().date())}.zip"
|
||||
)
|
||||
@@ -150,75 +180,85 @@ def upload_to_s3(zip_file, workspace_id, token_id, slug):
|
||||
exporter_instance.save(update_fields=["status", "url", "key"])
|
||||
|
||||
|
||||
def generate_table_row(issue):
|
||||
def generate_table_row(issue: dict) -> List[str]:
|
||||
"""
|
||||
Generate a table row from an issue dictionary.
|
||||
"""
|
||||
return [
|
||||
f"""{issue["project__identifier"]}-{issue["sequence_id"]}""",
|
||||
issue["project__name"],
|
||||
f"""{issue["project_identifier"]}-{issue["sequence_id"]}""",
|
||||
issue["project_name"],
|
||||
issue["name"],
|
||||
issue["description_stripped"],
|
||||
issue["state__name"],
|
||||
issue["description"],
|
||||
issue["state_name"],
|
||||
dateConverter(issue["start_date"]),
|
||||
dateConverter(issue["target_date"]),
|
||||
issue["priority"],
|
||||
(
|
||||
f"{issue['created_by__first_name']} {issue['created_by__last_name']}"
|
||||
if issue["created_by__first_name"] and issue["created_by__last_name"]
|
||||
else ""
|
||||
),
|
||||
(
|
||||
f"{issue['assignees__first_name']} {issue['assignees__last_name']}"
|
||||
if issue["assignees__first_name"] and issue["assignees__last_name"]
|
||||
else ""
|
||||
),
|
||||
issue["labels__name"] if issue["labels__name"] else "",
|
||||
issue["issue_cycle__cycle__name"],
|
||||
dateConverter(issue["issue_cycle__cycle__start_date"]),
|
||||
dateConverter(issue["issue_cycle__cycle__end_date"]),
|
||||
issue["issue_module__module__name"],
|
||||
dateConverter(issue["issue_module__module__start_date"]),
|
||||
dateConverter(issue["issue_module__module__target_date"]),
|
||||
issue["created_by"],
|
||||
", ".join(issue["labels"]) if issue["labels"] else "",
|
||||
issue["cycle_name"],
|
||||
issue["cycle_start_date"],
|
||||
issue["cycle_end_date"],
|
||||
", ".join(issue.get("module_name", "")) if issue.get("module_name") else "",
|
||||
dateTimeConverter(issue["created_at"]),
|
||||
dateTimeConverter(issue["updated_at"]),
|
||||
dateTimeConverter(issue["completed_at"]),
|
||||
dateTimeConverter(issue["archived_at"]),
|
||||
(
|
||||
", ".join(
|
||||
[
|
||||
f"{comment['comment']} ({comment['created_at']} by {comment['created_by']})"
|
||||
for comment in issue["comments"]
|
||||
]
|
||||
)
|
||||
if issue["comments"]
|
||||
else ""
|
||||
),
|
||||
issue["estimate"] if issue["estimate"] else "",
|
||||
", ".join(issue["link"]) if issue["link"] else "",
|
||||
", ".join(issue["assignees"]) if issue["assignees"] else "",
|
||||
issue["subscribers_count"] if issue["subscribers_count"] else "",
|
||||
issue["attachment_count"] if issue["attachment_count"] else "",
|
||||
", ".join(issue["attachment_links"]) if issue["attachment_links"] else "",
|
||||
]
|
||||
|
||||
|
||||
def generate_json_row(issue):
|
||||
def generate_json_row(issue: dict) -> dict:
|
||||
"""
|
||||
Generate a JSON row from an issue dictionary.
|
||||
"""
|
||||
return {
|
||||
"ID": f"""{issue["project__identifier"]}-{issue["sequence_id"]}""",
|
||||
"Project": issue["project__name"],
|
||||
"ID": f"""{issue["project_identifier"]}-{issue["sequence_id"]}""",
|
||||
"Project": issue["project_name"],
|
||||
"Name": issue["name"],
|
||||
"Description": issue["description_stripped"],
|
||||
"State": issue["state__name"],
|
||||
"Description": issue["description"],
|
||||
"State": issue["state_name"],
|
||||
"Start Date": dateConverter(issue["start_date"]),
|
||||
"Target Date": dateConverter(issue["target_date"]),
|
||||
"Priority": issue["priority"],
|
||||
"Created By": (
|
||||
f"{issue['created_by__first_name']} {issue['created_by__last_name']}"
|
||||
if issue["created_by__first_name"] and issue["created_by__last_name"]
|
||||
else ""
|
||||
),
|
||||
"Assignee": (
|
||||
f"{issue['assignees__first_name']} {issue['assignees__last_name']}"
|
||||
if issue["assignees__first_name"] and issue["assignees__last_name"]
|
||||
else ""
|
||||
),
|
||||
"Labels": issue["labels__name"] if issue["labels__name"] else "",
|
||||
"Cycle Name": issue["issue_cycle__cycle__name"],
|
||||
"Cycle Start Date": dateConverter(issue["issue_cycle__cycle__start_date"]),
|
||||
"Cycle End Date": dateConverter(issue["issue_cycle__cycle__end_date"]),
|
||||
"Module Name": issue["issue_module__module__name"],
|
||||
"Module Start Date": dateConverter(issue["issue_module__module__start_date"]),
|
||||
"Module Target Date": dateConverter(issue["issue_module__module__target_date"]),
|
||||
"Created By": (f"{issue['created_by']}" if issue["created_by"] else ""),
|
||||
"Assignee": issue["assignees"],
|
||||
"Labels": issue["labels"],
|
||||
"Cycle Name": issue["cycle_name"],
|
||||
"Cycle Start Date": issue["cycle_start_date"],
|
||||
"Cycle End Date": issue["cycle_end_date"],
|
||||
"Module Name": issue["module_name"],
|
||||
"Created At": dateTimeConverter(issue["created_at"]),
|
||||
"Updated At": dateTimeConverter(issue["updated_at"]),
|
||||
"Completed At": dateTimeConverter(issue["completed_at"]),
|
||||
"Archived At": dateTimeConverter(issue["archived_at"]),
|
||||
"Comments": issue["comments"],
|
||||
"Estimate": issue["estimate"],
|
||||
"Link": issue["link"],
|
||||
"Subscribers Count": issue["subscribers_count"],
|
||||
"Attachment Count": issue["attachment_count"],
|
||||
"Attachment Links": issue["attachment_links"],
|
||||
}
|
||||
|
||||
|
||||
def update_json_row(rows, row):
|
||||
def update_json_row(rows: List[dict], row: dict) -> None:
|
||||
"""
|
||||
Update the json row with the new assignee and label.
|
||||
"""
|
||||
matched_index = next(
|
||||
(
|
||||
index
|
||||
@@ -247,7 +287,10 @@ def update_json_row(rows, row):
|
||||
rows.append(row)
|
||||
|
||||
|
||||
def update_table_row(rows, row):
|
||||
def update_table_row(rows: List[List[str]], row: List[str]) -> None:
|
||||
"""
|
||||
Update the table row with the new assignee and label.
|
||||
"""
|
||||
matched_index = next(
|
||||
(index for index, existing_row in enumerate(rows) if existing_row[0] == row[0]),
|
||||
None,
|
||||
@@ -269,7 +312,12 @@ def update_table_row(rows, row):
|
||||
rows.append(row)
|
||||
|
||||
|
||||
def generate_csv(header, project_id, issues, files):
|
||||
def generate_csv(
|
||||
header: List[str],
|
||||
project_id: str,
|
||||
issues: List[dict],
|
||||
files: List[tuple[str, str | bytes]],
|
||||
) -> None:
|
||||
"""
|
||||
Generate CSV export for all the passed issues.
|
||||
"""
|
||||
@@ -281,7 +329,15 @@ def generate_csv(header, project_id, issues, files):
|
||||
files.append((f"{project_id}.csv", csv_file))
|
||||
|
||||
|
||||
def generate_json(header, project_id, issues, files):
|
||||
def generate_json(
|
||||
header: List[str],
|
||||
project_id: str,
|
||||
issues: List[dict],
|
||||
files: List[tuple[str, str | bytes]],
|
||||
) -> None:
|
||||
"""
|
||||
Generate JSON export for all the passed issues.
|
||||
"""
|
||||
rows = []
|
||||
for issue in issues:
|
||||
row = generate_json_row(issue)
|
||||
@@ -290,68 +346,169 @@ def generate_json(header, project_id, issues, files):
|
||||
files.append((f"{project_id}.json", json_file))
|
||||
|
||||
|
||||
def generate_xlsx(header, project_id, issues, files):
|
||||
def generate_xlsx(
|
||||
header: List[str],
|
||||
project_id: str,
|
||||
issues: List[dict],
|
||||
files: List[tuple[str, str | bytes]],
|
||||
) -> None:
|
||||
"""
|
||||
Generate XLSX export for all the passed issues.
|
||||
"""
|
||||
rows = [header]
|
||||
for issue in issues:
|
||||
row = generate_table_row(issue)
|
||||
|
||||
update_table_row(rows, row)
|
||||
xlsx_file = create_xlsx_file(rows)
|
||||
files.append((f"{project_id}.xlsx", xlsx_file))
|
||||
|
||||
|
||||
def get_created_by(obj: Issue | IssueComment) -> str:
|
||||
"""
|
||||
Get the created by user for the given object.
|
||||
"""
|
||||
if obj.created_by:
|
||||
return f"{obj.created_by.first_name} {obj.created_by.last_name}"
|
||||
return ""
|
||||
|
||||
|
||||
@shared_task
|
||||
def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, slug):
|
||||
def issue_export_task(
|
||||
provider: str,
|
||||
workspace_id: UUID,
|
||||
project_ids: List[str],
|
||||
token_id: str,
|
||||
multiple: bool,
|
||||
slug: str,
|
||||
):
|
||||
"""
|
||||
Export issues from the workspace.
|
||||
provider (str): The provider to export the issues to csv | json | xlsx.
|
||||
token_id (str): The export object token id.
|
||||
multiple (bool): Whether to export the issues to multiple files per project.
|
||||
"""
|
||||
try:
|
||||
exporter_instance = ExporterHistory.objects.get(token=token_id)
|
||||
exporter_instance.status = "processing"
|
||||
exporter_instance.save(update_fields=["status"])
|
||||
|
||||
# Base query to get the issues
|
||||
workspace_issues = (
|
||||
(
|
||||
Issue.objects.filter(
|
||||
workspace__id=workspace_id,
|
||||
project_id__in=project_ids,
|
||||
project__project_projectmember__member=exporter_instance.initiated_by_id,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__archived_at__isnull=True,
|
||||
)
|
||||
.select_related("project", "workspace", "state", "parent", "created_by")
|
||||
.prefetch_related(
|
||||
"assignees", "labels", "issue_cycle__cycle", "issue_module__module"
|
||||
)
|
||||
.values(
|
||||
"id",
|
||||
"project__identifier",
|
||||
"project__name",
|
||||
"project__id",
|
||||
"sequence_id",
|
||||
"name",
|
||||
"description_stripped",
|
||||
"priority",
|
||||
"start_date",
|
||||
"target_date",
|
||||
"state__name",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"completed_at",
|
||||
"archived_at",
|
||||
"issue_cycle__cycle__name",
|
||||
"issue_cycle__cycle__start_date",
|
||||
"issue_cycle__cycle__end_date",
|
||||
"issue_module__module__name",
|
||||
"issue_module__module__start_date",
|
||||
"issue_module__module__target_date",
|
||||
"created_by__first_name",
|
||||
"created_by__last_name",
|
||||
"assignees__first_name",
|
||||
"assignees__last_name",
|
||||
"labels__name",
|
||||
)
|
||||
Issue.objects.filter(
|
||||
workspace__id=workspace_id,
|
||||
project_id__in=project_ids,
|
||||
project__project_projectmember__member=exporter_instance.initiated_by_id,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__archived_at__isnull=True,
|
||||
)
|
||||
.select_related(
|
||||
"project",
|
||||
"workspace",
|
||||
"state",
|
||||
"parent",
|
||||
"created_by",
|
||||
"estimate_point",
|
||||
)
|
||||
.prefetch_related(
|
||||
"labels",
|
||||
"issue_cycle__cycle",
|
||||
"issue_module__module",
|
||||
"issue_comments",
|
||||
"assignees",
|
||||
Prefetch(
|
||||
"assignees",
|
||||
queryset=User.objects.only("first_name", "last_name").distinct(),
|
||||
to_attr="assignee_details",
|
||||
),
|
||||
Prefetch(
|
||||
"labels",
|
||||
queryset=Label.objects.only("name").distinct(),
|
||||
to_attr="label_details",
|
||||
),
|
||||
"issue_subscribers",
|
||||
"issue_link",
|
||||
)
|
||||
.order_by("project__identifier", "sequence_id")
|
||||
.distinct()
|
||||
)
|
||||
# CSV header
|
||||
|
||||
# Get the attachments for the issues
|
||||
file_assets = FileAsset.objects.filter(
|
||||
issue_id__in=workspace_issues.values_list("id", flat=True),
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
).annotate(work_item_id=F("issue_id"), asset_id=F("id"))
|
||||
|
||||
# Create a dictionary to store the attachments for the issues
|
||||
attachment_dict = defaultdict(list)
|
||||
for asset in file_assets:
|
||||
attachment_dict[asset.work_item_id].append(asset.asset_id)
|
||||
|
||||
# Create a list to store the issues data
|
||||
issues_data = []
|
||||
|
||||
# Iterate over the issues
|
||||
for issue in workspace_issues:
|
||||
attachments = attachment_dict.get(issue.id, [])
|
||||
|
||||
issue_data = {
|
||||
"id": issue.id,
|
||||
"project_identifier": issue.project.identifier,
|
||||
"project_name": issue.project.name,
|
||||
"project_id": issue.project.id,
|
||||
"sequence_id": issue.sequence_id,
|
||||
"name": issue.name,
|
||||
"description": issue.description_stripped,
|
||||
"priority": issue.priority,
|
||||
"start_date": issue.start_date,
|
||||
"target_date": issue.target_date,
|
||||
"state_name": issue.state.name if issue.state else None,
|
||||
"created_at": issue.created_at,
|
||||
"updated_at": issue.updated_at,
|
||||
"completed_at": issue.completed_at,
|
||||
"archived_at": issue.archived_at,
|
||||
"module_name": [
|
||||
module.module.name for module in issue.issue_module.all()
|
||||
],
|
||||
"created_by": get_created_by(issue),
|
||||
"labels": [label.name for label in issue.label_details],
|
||||
"comments": [
|
||||
{
|
||||
"comment": comment.comment_stripped,
|
||||
"created_at": dateConverter(comment.created_at),
|
||||
"created_by": get_created_by(comment),
|
||||
}
|
||||
for comment in issue.issue_comments.all()
|
||||
],
|
||||
"estimate": issue.estimate_point.value
|
||||
if issue.estimate_point and issue.estimate_point.value
|
||||
else "",
|
||||
"link": [link.url for link in issue.issue_link.all()],
|
||||
"assignees": [
|
||||
f"{assignee.first_name} {assignee.last_name}"
|
||||
for assignee in issue.assignee_details
|
||||
],
|
||||
"subscribers_count": issue.issue_subscribers.count(),
|
||||
"attachment_count": len(attachments),
|
||||
"attachment_links": [
|
||||
f"/api/assets/v2/workspaces/{issue.workspace.slug}/projects/{issue.project_id}/issues/{issue.id}/attachments/{asset}/"
|
||||
for asset in attachments
|
||||
],
|
||||
}
|
||||
|
||||
# Get Cycles data for the issue
|
||||
cycle = issue.issue_cycle.last()
|
||||
if cycle:
|
||||
# Update cycle data
|
||||
issue_data["cycle_name"] = cycle.cycle.name
|
||||
issue_data["cycle_start_date"] = dateConverter(cycle.cycle.start_date)
|
||||
issue_data["cycle_end_date"] = dateConverter(cycle.cycle.end_date)
|
||||
else:
|
||||
issue_data["cycle_name"] = ""
|
||||
issue_data["cycle_start_date"] = ""
|
||||
issue_data["cycle_end_date"] = ""
|
||||
|
||||
issues_data.append(issue_data)
|
||||
|
||||
# CSV header
|
||||
header = [
|
||||
"ID",
|
||||
"Project",
|
||||
@@ -362,20 +519,25 @@ def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, s
|
||||
"Target Date",
|
||||
"Priority",
|
||||
"Created By",
|
||||
"Assignee",
|
||||
"Labels",
|
||||
"Cycle Name",
|
||||
"Cycle Start Date",
|
||||
"Cycle End Date",
|
||||
"Module Name",
|
||||
"Module Start Date",
|
||||
"Module Target Date",
|
||||
"Created At",
|
||||
"Updated At",
|
||||
"Completed At",
|
||||
"Archived At",
|
||||
"Comments",
|
||||
"Estimate",
|
||||
"Link",
|
||||
"Assignees",
|
||||
"Subscribers Count",
|
||||
"Attachment Count",
|
||||
"Attachment Links",
|
||||
]
|
||||
|
||||
# Map the provider to the function
|
||||
EXPORTER_MAPPER = {
|
||||
"csv": generate_csv,
|
||||
"json": generate_json,
|
||||
@@ -384,8 +546,13 @@ def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, s
|
||||
|
||||
files = []
|
||||
if multiple:
|
||||
project_dict = defaultdict(list)
|
||||
for issue in issues_data:
|
||||
project_dict[str(issue["project_id"])].append(issue)
|
||||
|
||||
for project_id in project_ids:
|
||||
issues = workspace_issues.filter(project__id=project_id)
|
||||
issues = project_dict.get(str(project_id), [])
|
||||
|
||||
exporter = EXPORTER_MAPPER.get(provider)
|
||||
if exporter is not None:
|
||||
exporter(header, project_id, issues, files)
|
||||
@@ -393,7 +560,7 @@ def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, s
|
||||
else:
|
||||
exporter = EXPORTER_MAPPER.get(provider)
|
||||
if exporter is not None:
|
||||
exporter(header, workspace_id, workspace_issues, files)
|
||||
exporter(header, workspace_id, issues_data, files)
|
||||
|
||||
zip_buffer = create_zip_file(files)
|
||||
upload_to_s3(zip_buffer, workspace_id, token_id, slug)
|
||||
|
||||
@@ -5,7 +5,9 @@ from plane.db.models import Workspace
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Updates the slug of a soft-deleted workspace by appending the epoch timestamp"
|
||||
help = (
|
||||
"Updates the slug of a soft-deleted workspace by appending the epoch timestamp"
|
||||
)
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
@@ -75,4 +77,4 @@ class Command(BaseCommand):
|
||||
self.style.ERROR(
|
||||
f"Error updating workspace '{workspace.name}': {str(e)}"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.2.20 on 2025-05-21 13:43
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("db", "0095_page_external_id_page_external_source"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="is_email_valid",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="masked_at",
|
||||
field=models.DateTimeField(null=True),
|
||||
),
|
||||
]
|
||||
@@ -82,4 +82,4 @@ from .label import Label
|
||||
|
||||
from .device import Device, DeviceSession
|
||||
|
||||
from .sticky import Sticky
|
||||
from .sticky import Sticky
|
||||
|
||||
@@ -18,22 +18,28 @@ class BaseModel(AuditModel):
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
user = get_current_user()
|
||||
def save(self, *args, created_by_id=None, disable_auto_set_user=False, **kwargs):
|
||||
if not disable_auto_set_user:
|
||||
# Check if created_by_id is provided
|
||||
if created_by_id:
|
||||
self.created_by_id = created_by_id
|
||||
else:
|
||||
user = get_current_user()
|
||||
|
||||
if user is None or user.is_anonymous:
|
||||
self.created_by = None
|
||||
self.updated_by = None
|
||||
super(BaseModel, self).save(*args, **kwargs)
|
||||
else:
|
||||
# Check if the model is being created or updated
|
||||
if self._state.adding:
|
||||
# If created only set created_by value: set updated_by to None
|
||||
self.created_by = user
|
||||
self.updated_by = None
|
||||
# If updated only set updated_by value don't touch created_by
|
||||
self.updated_by = user
|
||||
super(BaseModel, self).save(*args, **kwargs)
|
||||
if user is None or user.is_anonymous:
|
||||
self.created_by = None
|
||||
self.updated_by = None
|
||||
else:
|
||||
# Check if the model is being created or updated
|
||||
if self._state.adding:
|
||||
# If creating, set created_by and leave updated_by as None
|
||||
self.created_by = user
|
||||
self.updated_by = None
|
||||
else:
|
||||
# If updating, set updated_by only
|
||||
self.updated_by = user
|
||||
|
||||
super(BaseModel, self).save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return str(self.id)
|
||||
|
||||
@@ -106,6 +106,12 @@ class User(AbstractBaseUser, PermissionsMixin):
|
||||
max_length=255, default="UTC", choices=USER_TIMEZONE_CHOICES
|
||||
)
|
||||
|
||||
# email validation
|
||||
is_email_valid = models.BooleanField(default=False)
|
||||
|
||||
# masking
|
||||
masked_at = models.DateTimeField(null=True)
|
||||
|
||||
USERNAME_FIELD = "email"
|
||||
REQUIRED_FIELDS = ["username"]
|
||||
|
||||
|
||||
@@ -153,12 +153,8 @@ class Workspace(BaseModel):
|
||||
return None
|
||||
|
||||
def delete(
|
||||
self,
|
||||
using: Optional[str] = None,
|
||||
soft: bool = True,
|
||||
*args: Any,
|
||||
**kwargs: Any
|
||||
):
|
||||
self, using: Optional[str] = None, soft: bool = True, *args: Any, **kwargs: Any
|
||||
):
|
||||
"""
|
||||
Override the delete method to append epoch timestamp to the slug when soft deleting.
|
||||
|
||||
@@ -172,7 +168,7 @@ class Workspace(BaseModel):
|
||||
result = super().delete(using=using, soft=soft, *args, **kwargs)
|
||||
|
||||
# If it's a soft delete and the model still exists (not hard deleted)
|
||||
if soft and hasattr(self, 'deleted_at') and self.deleted_at:
|
||||
if soft and hasattr(self, "deleted_at") and self.deleted_at:
|
||||
# Use the deleted_at timestamp to update the slug
|
||||
deletion_timestamp: int = int(self.deleted_at.timestamp())
|
||||
self.slug = f"{self.slug}__{deletion_timestamp}"
|
||||
|
||||
@@ -57,7 +57,7 @@ class InstanceEndpoint(BaseAPIView):
|
||||
POSTHOG_API_KEY,
|
||||
POSTHOG_HOST,
|
||||
UNSPLASH_ACCESS_KEY,
|
||||
OPENAI_API_KEY,
|
||||
LLM_API_KEY,
|
||||
IS_INTERCOM_ENABLED,
|
||||
INTERCOM_APP_ID,
|
||||
) = get_configuration_value(
|
||||
@@ -112,8 +112,8 @@ class InstanceEndpoint(BaseAPIView):
|
||||
"default": os.environ.get("UNSPLASH_ACCESS_KEY", ""),
|
||||
},
|
||||
{
|
||||
"key": "OPENAI_API_KEY",
|
||||
"default": os.environ.get("OPENAI_API_KEY", ""),
|
||||
"key": "LLM_API_KEY",
|
||||
"default": os.environ.get("LLM_API_KEY", ""),
|
||||
},
|
||||
# Intercom settings
|
||||
{
|
||||
@@ -151,7 +151,7 @@ class InstanceEndpoint(BaseAPIView):
|
||||
data["has_unsplash_configured"] = bool(UNSPLASH_ACCESS_KEY)
|
||||
|
||||
# Open AI settings
|
||||
data["has_openai_configured"] = bool(OPENAI_API_KEY)
|
||||
data["has_llm_configured"] = bool(LLM_API_KEY)
|
||||
|
||||
# File size settings
|
||||
data["file_size_limit"] = float(os.environ.get("FILE_SIZE_LIMIT", 5242880))
|
||||
|
||||
@@ -157,7 +157,7 @@ class Command(BaseCommand):
|
||||
},
|
||||
# Deprecated, use LLM_MODEL
|
||||
{
|
||||
"key": "GPT_ENGINE",
|
||||
"key": "GPT_ENGINE",
|
||||
"value": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"),
|
||||
"category": "SMTP",
|
||||
"is_encrypted": False,
|
||||
|
||||
@@ -83,6 +83,32 @@ class APITokenLogMiddleware:
|
||||
self.process_request(request, response, request_body)
|
||||
return response
|
||||
|
||||
def _safe_decode_body(self, content):
|
||||
"""
|
||||
Safely decodes request/response body content, handling binary data.
|
||||
Returns None if content is None, or a string representation of the content.
|
||||
"""
|
||||
# If the content is None, return None
|
||||
if content is None:
|
||||
return None
|
||||
|
||||
# If the content is an empty bytes object, return None
|
||||
if content == b"":
|
||||
return None
|
||||
|
||||
# Check if content is binary by looking for common binary file signatures
|
||||
if (
|
||||
content.startswith(b"\x89PNG")
|
||||
or content.startswith(b"\xff\xd8\xff")
|
||||
or content.startswith(b"%PDF")
|
||||
):
|
||||
return "[Binary Content]"
|
||||
|
||||
try:
|
||||
return content.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return "[Could not decode content]"
|
||||
|
||||
def process_request(self, request, response, request_body):
|
||||
api_key_header = "X-Api-Key"
|
||||
api_key = request.headers.get(api_key_header)
|
||||
@@ -95,9 +121,13 @@ class APITokenLogMiddleware:
|
||||
method=request.method,
|
||||
query_params=request.META.get("QUERY_STRING", ""),
|
||||
headers=str(request.headers),
|
||||
body=(request_body.decode("utf-8") if request_body else None),
|
||||
body=(
|
||||
self._safe_decode_body(request_body) if request_body else None
|
||||
),
|
||||
response_body=(
|
||||
response.content.decode("utf-8") if response.content else None
|
||||
self._safe_decode_body(response.content)
|
||||
if response.content
|
||||
else None
|
||||
),
|
||||
response_code=response.status_code,
|
||||
ip_address=get_client_ip(request=request),
|
||||
|
||||
@@ -32,7 +32,6 @@ class S3Storage(S3Boto3Storage):
|
||||
) or os.environ.get("MINIO_ENDPOINT_URL")
|
||||
|
||||
if os.environ.get("USE_MINIO") == "1":
|
||||
|
||||
# Determine protocol based on environment variable
|
||||
if os.environ.get("MINIO_ENDPOINT_SSL") == "1":
|
||||
endpoint_protocol = "https"
|
||||
|
||||
@@ -135,7 +135,7 @@ def issue_on_results(
|
||||
default=None,
|
||||
output_field=JSONField(),
|
||||
),
|
||||
filter=Q(votes__isnull=False,votes__deleted_at__isnull=True),
|
||||
filter=Q(votes__isnull=False, votes__deleted_at__isnull=True),
|
||||
distinct=True,
|
||||
),
|
||||
reaction_items=ArrayAgg(
|
||||
@@ -169,7 +169,9 @@ def issue_on_results(
|
||||
default=None,
|
||||
output_field=JSONField(),
|
||||
),
|
||||
filter=Q(issue_reactions__isnull=False, issue_reactions__deleted_at__isnull=True),
|
||||
filter=Q(
|
||||
issue_reactions__isnull=False, issue_reactions__deleted_at__isnull=True
|
||||
),
|
||||
distinct=True,
|
||||
),
|
||||
).values(*required_fields, "vote_items", "reaction_items")
|
||||
|
||||
@@ -179,7 +179,7 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
|
||||
Q(issue_intake__status=1)
|
||||
| Q(issue_intake__status=-1)
|
||||
| Q(issue_intake__status=2)
|
||||
| Q(issue_intake__status=True),
|
||||
| Q(issue_intake__isnull=True),
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
@@ -205,7 +205,7 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
|
||||
Q(issue_intake__status=1)
|
||||
| Q(issue_intake__status=-1)
|
||||
| Q(issue_intake__status=2)
|
||||
| Q(issue_intake__status=True),
|
||||
| Q(issue_intake__isnull=True),
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
|
||||
@@ -14,9 +14,7 @@ class ProjectMetaDataEndpoint(BaseAPIView):
|
||||
|
||||
def get(self, request, anchor):
|
||||
try:
|
||||
deploy_board = DeployBoard.objects.get(
|
||||
anchor=anchor, entity_name="project"
|
||||
)
|
||||
deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project")
|
||||
except DeployBoard.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
# Plane Tests
|
||||
|
||||
This directory contains tests for the Plane application. The tests are organized using pytest.
|
||||
|
||||
## Test Structure
|
||||
|
||||
Tests are organized into the following categories:
|
||||
|
||||
- **Unit tests**: Test individual functions or classes in isolation.
|
||||
- **Contract tests**: Test interactions between components and verify API contracts are fulfilled.
|
||||
- **API tests**: Test the external API endpoints (under `/api/v1/`).
|
||||
- **App tests**: Test the web application API endpoints (under `/api/`).
|
||||
- **Smoke tests**: Basic tests to verify that the application runs correctly.
|
||||
|
||||
## API vs App Endpoints
|
||||
|
||||
Plane has two types of API endpoints:
|
||||
|
||||
1. **External API** (`plane.api`):
|
||||
- Available at `/api/v1/` endpoint
|
||||
- Uses API key authentication (X-Api-Key header)
|
||||
- Designed for external API contracts and third-party access
|
||||
- Tests use the `api_key_client` fixture for authentication
|
||||
- Test files are in `contract/api/`
|
||||
|
||||
2. **Web App API** (`plane.app`):
|
||||
- Available at `/api/` endpoint
|
||||
- Uses session-based authentication (CSRF disabled)
|
||||
- Designed for the web application frontend
|
||||
- Tests use the `session_client` fixture for authentication
|
||||
- Test files are in `contract/app/`
|
||||
|
||||
## Running Tests
|
||||
|
||||
To run all tests:
|
||||
|
||||
```bash
|
||||
python -m pytest
|
||||
```
|
||||
|
||||
To run specific test categories:
|
||||
|
||||
```bash
|
||||
# Run unit tests
|
||||
python -m pytest plane/tests/unit/
|
||||
|
||||
# Run API contract tests
|
||||
python -m pytest plane/tests/contract/api/
|
||||
|
||||
# Run App contract tests
|
||||
python -m pytest plane/tests/contract/app/
|
||||
|
||||
# Run smoke tests
|
||||
python -m pytest plane/tests/smoke/
|
||||
```
|
||||
|
||||
For convenience, we also provide a helper script:
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
./run_tests.py
|
||||
|
||||
# Run only unit tests
|
||||
./run_tests.py -u
|
||||
|
||||
# Run contract tests with coverage report
|
||||
./run_tests.py -c -o
|
||||
|
||||
# Run tests in parallel
|
||||
./run_tests.py -p
|
||||
```
|
||||
|
||||
## Fixtures
|
||||
|
||||
The following fixtures are available for testing:
|
||||
|
||||
- `api_client`: Unauthenticated API client
|
||||
- `create_user`: Creates a test user
|
||||
- `api_token`: API token for the test user
|
||||
- `api_key_client`: API client with API key authentication (for external API tests)
|
||||
- `session_client`: API client with session authentication (for app API tests)
|
||||
- `plane_server`: Live Django test server for HTTP-based smoke tests
|
||||
|
||||
## Writing Tests
|
||||
|
||||
When writing tests, follow these guidelines:
|
||||
|
||||
1. Place tests in the appropriate directory based on their type.
|
||||
2. Use the correct client fixture based on the API being tested:
|
||||
- For external API (`/api/v1/`), use `api_key_client`
|
||||
- For web app API (`/api/`), use `session_client`
|
||||
- For smoke tests with real HTTP, use `plane_server`
|
||||
3. Use the correct URL namespace when reverse-resolving URLs:
|
||||
- For external API, use `reverse("api:endpoint_name")`
|
||||
- For web app API, use `reverse("endpoint_name")`
|
||||
4. Add the `@pytest.mark.django_db` decorator to tests that interact with the database.
|
||||
5. Add the appropriate markers (`@pytest.mark.contract`, etc.) to categorize tests.
|
||||
|
||||
## Test Fixtures
|
||||
|
||||
Common fixtures are defined in:
|
||||
|
||||
- `conftest.py`: General fixtures for authentication, database access, etc.
|
||||
- `conftest_external.py`: Fixtures for external services (Redis, Elasticsearch, Celery, MongoDB)
|
||||
- `factories.py`: Test factories for easy model instance creation
|
||||
|
||||
## Best Practices
|
||||
|
||||
When writing tests, follow these guidelines:
|
||||
|
||||
1. **Use pytest's assert syntax** instead of Django's `self.assert*` methods.
|
||||
2. **Add markers to categorize tests**:
|
||||
```python
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.contract
|
||||
@pytest.mark.smoke
|
||||
```
|
||||
3. **Use fixtures instead of setUp/tearDown methods** for cleaner, more reusable test code.
|
||||
4. **Mock external dependencies** with the provided fixtures to avoid external service dependencies.
|
||||
5. **Write focused tests** that verify one specific behavior or edge case.
|
||||
6. **Keep test files small and organized** by logical components or endpoints.
|
||||
7. **Target 90% code coverage** for models, serializers, and business logic.
|
||||
|
||||
## External Dependencies
|
||||
|
||||
Tests for components that interact with external services should:
|
||||
|
||||
1. Use the `mock_redis`, `mock_elasticsearch`, `mock_mongodb`, and `mock_celery` fixtures for unit and most contract tests.
|
||||
2. For more comprehensive contract tests, use Docker-based test containers (optional).
|
||||
|
||||
## Coverage Reports
|
||||
|
||||
Generate a coverage report with:
|
||||
|
||||
```bash
|
||||
python -m pytest --cov=plane --cov-report=term --cov-report=html
|
||||
```
|
||||
|
||||
This creates an HTML report in the `htmlcov/` directory.
|
||||
|
||||
## Migration from Old Tests
|
||||
|
||||
Some tests are still in the old format in the `api/` directory. These need to be migrated to the new contract test structure in the appropriate directories.
|
||||
@@ -0,0 +1,151 @@
|
||||
# Testing Guide for Plane
|
||||
|
||||
This guide explains how to write tests for Plane using our pytest-based testing strategy.
|
||||
|
||||
## Test Categories
|
||||
|
||||
We divide tests into three categories:
|
||||
|
||||
1. **Unit Tests**: Testing individual components in isolation.
|
||||
2. **Contract Tests**: Testing API endpoints and verifying contracts between components.
|
||||
3. **Smoke Tests**: Basic end-to-end tests for critical flows.
|
||||
|
||||
## Writing Unit Tests
|
||||
|
||||
Unit tests should be placed in the appropriate directory under `tests/unit/` depending on what you're testing:
|
||||
|
||||
- `tests/unit/models/` - For model tests
|
||||
- `tests/unit/serializers/` - For serializer tests
|
||||
- `tests/unit/utils/` - For utility function tests
|
||||
|
||||
### Example Unit Test:
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from plane.api.serializers import MySerializer
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestMySerializer:
|
||||
def test_serializer_valid_data(self):
|
||||
# Create input data
|
||||
data = {"field1": "value1", "field2": 42}
|
||||
|
||||
# Initialize the serializer
|
||||
serializer = MySerializer(data=data)
|
||||
|
||||
# Validate
|
||||
assert serializer.is_valid()
|
||||
|
||||
# Check validated data
|
||||
assert serializer.validated_data["field1"] == "value1"
|
||||
assert serializer.validated_data["field2"] == 42
|
||||
```
|
||||
|
||||
## Writing Contract Tests
|
||||
|
||||
Contract tests should be placed in `tests/contract/api/` or `tests/contract/app/` directories and should test the API endpoints.
|
||||
|
||||
### Example Contract Test:
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestMyEndpoint:
|
||||
@pytest.mark.django_db
|
||||
def test_my_endpoint_get(self, auth_client):
|
||||
# Get the URL
|
||||
url = reverse("my-endpoint")
|
||||
|
||||
# Make request
|
||||
response = auth_client.get(url)
|
||||
|
||||
# Check response
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert "data" in response.data
|
||||
```
|
||||
|
||||
## Writing Smoke Tests
|
||||
|
||||
Smoke tests should be placed in `tests/smoke/` directory and use the `plane_server` fixture to test against a real HTTP server.
|
||||
|
||||
### Example Smoke Test:
|
||||
|
||||
```python
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
@pytest.mark.smoke
|
||||
class TestCriticalFlow:
|
||||
@pytest.mark.django_db
|
||||
def test_login_flow(self, plane_server, create_user, user_data):
|
||||
# Get login URL
|
||||
url = f"{plane_server.url}/api/auth/signin/"
|
||||
|
||||
# Test login
|
||||
response = requests.post(
|
||||
url,
|
||||
json={
|
||||
"email": user_data["email"],
|
||||
"password": user_data["password"]
|
||||
}
|
||||
)
|
||||
|
||||
# Verify
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
```
|
||||
|
||||
## Useful Fixtures
|
||||
|
||||
Our test setup provides several useful fixtures:
|
||||
|
||||
1. `api_client`: An unauthenticated DRF APIClient
|
||||
2. `api_key_client`: API client with API key authentication (for external API tests)
|
||||
3. `session_client`: API client with session authentication (for web app API tests)
|
||||
4. `create_user`: Creates and returns a test user
|
||||
5. `mock_redis`: Mocks Redis interactions
|
||||
6. `mock_elasticsearch`: Mocks Elasticsearch interactions
|
||||
7. `mock_celery`: Mocks Celery task execution
|
||||
|
||||
## Using Factory Boy
|
||||
|
||||
For more complex test data setup, use the provided factories:
|
||||
|
||||
```python
|
||||
from plane.tests.factories import UserFactory, WorkspaceFactory
|
||||
|
||||
# Create a user
|
||||
user = UserFactory()
|
||||
|
||||
# Create a workspace with a specific owner
|
||||
workspace = WorkspaceFactory(owner=user)
|
||||
|
||||
# Create multiple objects
|
||||
users = UserFactory.create_batch(5)
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
Use pytest to run tests:
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
python -m pytest
|
||||
|
||||
# Run only unit tests with coverage
|
||||
python -m pytest -m unit --cov=plane
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Keep tests small and focused** - Each test should verify one specific behavior.
|
||||
2. **Use markers** - Always add appropriate markers (`@pytest.mark.unit`, etc.).
|
||||
3. **Mock external dependencies** - Use the provided mock fixtures.
|
||||
4. **Use factories** - For complex data setup, use factories.
|
||||
5. **Don't test the framework** - Focus on testing your business logic, not Django/DRF itself.
|
||||
6. **Write readable assertions** - Use plain `assert` statements with clear messaging.
|
||||
7. **Focus on coverage** - Aim for ≥90% code coverage for critical components.
|
||||
@@ -1 +1 @@
|
||||
from .api import *
|
||||
# Test package initialization
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
# Third party imports
|
||||
from rest_framework.test import APITestCase, APIClient
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import User
|
||||
from plane.app.views.authentication import get_tokens_for_user
|
||||
|
||||
|
||||
class BaseAPITest(APITestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient(HTTP_USER_AGENT="plane/test", REMOTE_ADDR="10.10.10.10")
|
||||
|
||||
|
||||
class AuthenticatedAPITest(BaseAPITest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
## Create Dummy User
|
||||
self.email = "user@plane.so"
|
||||
user = User.objects.create(email=self.email)
|
||||
user.set_password("user@123")
|
||||
user.save()
|
||||
|
||||
# Set user
|
||||
self.user = user
|
||||
|
||||
# Set Up User ID
|
||||
self.user_id = user.id
|
||||
|
||||
access_token, _ = get_tokens_for_user(user)
|
||||
self.access_token = access_token
|
||||
|
||||
# Set Up Authentication Token
|
||||
self.client.credentials(HTTP_AUTHORIZATION="Bearer " + access_token)
|
||||
@@ -1 +0,0 @@
|
||||
# TODO: Tests for File Asset Uploads
|
||||
@@ -1 +0,0 @@
|
||||
# TODO: Tests for ChangePassword and other Endpoints
|
||||
@@ -1,183 +0,0 @@
|
||||
# Python import
|
||||
import json
|
||||
|
||||
# Django imports
|
||||
from django.urls import reverse
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework import status
|
||||
from .base import BaseAPITest
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import User
|
||||
from plane.settings.redis import redis_instance
|
||||
|
||||
|
||||
class SignInEndpointTests(BaseAPITest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
user = User.objects.create(email="user@plane.so")
|
||||
user.set_password("user@123")
|
||||
user.save()
|
||||
|
||||
def test_without_data(self):
|
||||
url = reverse("sign-in")
|
||||
response = self.client.post(url, {}, format="json")
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_email_validity(self):
|
||||
url = reverse("sign-in")
|
||||
response = self.client.post(
|
||||
url, {"email": "useremail.com", "password": "user@123"}, format="json"
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertEqual(
|
||||
response.data, {"error": "Please provide a valid email address."}
|
||||
)
|
||||
|
||||
def test_password_validity(self):
|
||||
url = reverse("sign-in")
|
||||
response = self.client.post(
|
||||
url, {"email": "user@plane.so", "password": "user123"}, format="json"
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
self.assertEqual(
|
||||
response.data,
|
||||
{
|
||||
"error": "Sorry, we could not find a user with the provided credentials. Please try again."
|
||||
},
|
||||
)
|
||||
|
||||
def test_user_exists(self):
|
||||
url = reverse("sign-in")
|
||||
response = self.client.post(
|
||||
url, {"email": "user@email.so", "password": "user123"}, format="json"
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
self.assertEqual(
|
||||
response.data,
|
||||
{
|
||||
"error": "Sorry, we could not find a user with the provided credentials. Please try again."
|
||||
},
|
||||
)
|
||||
|
||||
def test_user_login(self):
|
||||
url = reverse("sign-in")
|
||||
|
||||
response = self.client.post(
|
||||
url, {"email": "user@plane.so", "password": "user@123"}, format="json"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data.get("user").get("email"), "user@plane.so")
|
||||
|
||||
|
||||
class MagicLinkGenerateEndpointTests(BaseAPITest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
user = User.objects.create(email="user@plane.so")
|
||||
user.set_password("user@123")
|
||||
user.save()
|
||||
|
||||
def test_without_data(self):
|
||||
url = reverse("magic-generate")
|
||||
response = self.client.post(url, {}, format="json")
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_email_validity(self):
|
||||
url = reverse("magic-generate")
|
||||
response = self.client.post(url, {"email": "useremail.com"}, format="json")
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertEqual(
|
||||
response.data, {"error": "Please provide a valid email address."}
|
||||
)
|
||||
|
||||
def test_magic_generate(self):
|
||||
url = reverse("magic-generate")
|
||||
|
||||
ri = redis_instance()
|
||||
ri.delete("magic_user@plane.so")
|
||||
|
||||
response = self.client.post(url, {"email": "user@plane.so"}, format="json")
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
def test_max_generate_attempt(self):
|
||||
url = reverse("magic-generate")
|
||||
|
||||
ri = redis_instance()
|
||||
ri.delete("magic_user@plane.so")
|
||||
|
||||
for _ in range(4):
|
||||
response = self.client.post(url, {"email": "user@plane.so"}, format="json")
|
||||
|
||||
response = self.client.post(url, {"email": "user@plane.so"}, format="json")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertEqual(
|
||||
response.data, {"error": "Max attempts exhausted. Please try again later."}
|
||||
)
|
||||
|
||||
|
||||
class MagicSignInEndpointTests(BaseAPITest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
user = User.objects.create(email="user@plane.so")
|
||||
user.set_password("user@123")
|
||||
user.save()
|
||||
|
||||
def test_without_data(self):
|
||||
url = reverse("magic-sign-in")
|
||||
response = self.client.post(url, {}, format="json")
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertEqual(response.data, {"error": "User token and key are required"})
|
||||
|
||||
def test_expired_invalid_magic_link(self):
|
||||
ri = redis_instance()
|
||||
ri.delete("magic_user@plane.so")
|
||||
|
||||
url = reverse("magic-sign-in")
|
||||
response = self.client.post(
|
||||
url,
|
||||
{"key": "magic_user@plane.so", "token": "xxxx-xxxxx-xxxx"},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertEqual(
|
||||
response.data, {"error": "The magic code/link has expired please try again"}
|
||||
)
|
||||
|
||||
def test_invalid_magic_code(self):
|
||||
ri = redis_instance()
|
||||
ri.delete("magic_user@plane.so")
|
||||
## Create Token
|
||||
url = reverse("magic-generate")
|
||||
self.client.post(url, {"email": "user@plane.so"}, format="json")
|
||||
|
||||
url = reverse("magic-sign-in")
|
||||
response = self.client.post(
|
||||
url,
|
||||
{"key": "magic_user@plane.so", "token": "xxxx-xxxxx-xxxx"},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertEqual(
|
||||
response.data, {"error": "Your login code was incorrect. Please try again."}
|
||||
)
|
||||
|
||||
def test_magic_code_sign_in(self):
|
||||
ri = redis_instance()
|
||||
ri.delete("magic_user@plane.so")
|
||||
## Create Token
|
||||
url = reverse("magic-generate")
|
||||
self.client.post(url, {"email": "user@plane.so"}, format="json")
|
||||
|
||||
# Get the token
|
||||
user_data = json.loads(ri.get("magic_user@plane.so"))
|
||||
token = user_data["token"]
|
||||
|
||||
url = reverse("magic-sign-in")
|
||||
response = self.client.post(
|
||||
url, {"key": "magic_user@plane.so", "token": token}, format="json"
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data.get("user").get("email"), "user@plane.so")
|
||||
@@ -1 +0,0 @@
|
||||
# TODO: Write Test for Cycle Endpoints
|
||||
@@ -1 +0,0 @@
|
||||
# TODO: Write Test for Issue Endpoints
|
||||
@@ -1 +0,0 @@
|
||||
# TODO: Tests for OAuth Authentication Endpoint
|
||||
@@ -1 +0,0 @@
|
||||
# TODO: Write Test for people Endpoint
|
||||
@@ -1 +0,0 @@
|
||||
# TODO: Write Tests for project endpoints
|
||||
@@ -1 +0,0 @@
|
||||
# TODO: Write Test for shortcuts
|
||||
@@ -1 +0,0 @@
|
||||
# TODO: Wrote test for state endpoints
|
||||
@@ -1 +0,0 @@
|
||||
# TODO: Write test for view endpoints
|
||||
@@ -1,44 +0,0 @@
|
||||
# Django imports
|
||||
from django.urls import reverse
|
||||
|
||||
# Third party import
|
||||
from rest_framework import status
|
||||
|
||||
# Module imports
|
||||
from .base import AuthenticatedAPITest
|
||||
from plane.db.models import Workspace, WorkspaceMember
|
||||
|
||||
|
||||
class WorkSpaceCreateReadUpdateDelete(AuthenticatedAPITest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
def test_create_workspace(self):
|
||||
url = reverse("workspace")
|
||||
|
||||
# Test with empty data
|
||||
response = self.client.post(url, {}, format="json")
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Test with valid data
|
||||
response = self.client.post(
|
||||
url, {"name": "Plane", "slug": "pla-ne"}, format="json"
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Workspace.objects.count(), 1)
|
||||
# Check if the member is created
|
||||
self.assertEqual(WorkspaceMember.objects.count(), 1)
|
||||
|
||||
# Check other values
|
||||
workspace = Workspace.objects.get(pk=response.data["id"])
|
||||
workspace_member = WorkspaceMember.objects.get(
|
||||
workspace=workspace, member_id=self.user_id
|
||||
)
|
||||
self.assertEqual(workspace.owner_id, self.user_id)
|
||||
self.assertEqual(workspace_member.role, 20)
|
||||
|
||||
# Create a already existing workspace
|
||||
response = self.client.post(
|
||||
url, {"name": "Plane", "slug": "pla-ne"}, format="json"
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
|
||||
@@ -0,0 +1,78 @@
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from rest_framework.test import APIClient
|
||||
from pytest_django.fixtures import django_db_setup
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from plane.db.models import User
|
||||
from plane.db.models.api import APIToken
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def django_db_setup(django_db_setup):
|
||||
"""Set up the Django database for the test session"""
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_client():
|
||||
"""Return an unauthenticated API client"""
|
||||
return APIClient()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_data():
|
||||
"""Return standard user data for tests"""
|
||||
return {
|
||||
"email": "test@plane.so",
|
||||
"password": "test-password",
|
||||
"first_name": "Test",
|
||||
"last_name": "User"
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_user(db, user_data):
|
||||
"""Create and return a user instance"""
|
||||
user = User.objects.create(
|
||||
email=user_data["email"],
|
||||
first_name=user_data["first_name"],
|
||||
last_name=user_data["last_name"]
|
||||
)
|
||||
user.set_password(user_data["password"])
|
||||
user.save()
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_token(db, create_user):
|
||||
"""Create and return an API token for testing the external API"""
|
||||
token = APIToken.objects.create(
|
||||
user=create_user,
|
||||
label="Test API Token",
|
||||
token="test-api-token-12345",
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_key_client(api_client, api_token):
|
||||
"""Return an API key authenticated client for external API testing"""
|
||||
api_client.credentials(HTTP_X_API_KEY=api_token.token)
|
||||
return api_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_client(api_client, create_user):
|
||||
"""Return a session authenticated API client for app API testing, which is what plane.app uses"""
|
||||
api_client.force_authenticate(user=create_user)
|
||||
return api_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plane_server(live_server):
|
||||
"""
|
||||
Renamed version of live_server fixture to avoid name clashes.
|
||||
Returns a live Django server for testing HTTP requests.
|
||||
"""
|
||||
return live_server
|
||||
@@ -0,0 +1,117 @@
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redis():
|
||||
"""
|
||||
Mock Redis for testing without actual Redis connection.
|
||||
|
||||
This fixture patches the redis_instance function to return a MagicMock
|
||||
that behaves like a Redis client.
|
||||
"""
|
||||
mock_redis_client = MagicMock()
|
||||
|
||||
# Configure the mock to handle common Redis operations
|
||||
mock_redis_client.get.return_value = None
|
||||
mock_redis_client.set.return_value = True
|
||||
mock_redis_client.delete.return_value = True
|
||||
mock_redis_client.exists.return_value = 0
|
||||
mock_redis_client.ttl.return_value = -1
|
||||
|
||||
# Start the patch
|
||||
with patch('plane.settings.redis.redis_instance', return_value=mock_redis_client):
|
||||
yield mock_redis_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_elasticsearch():
|
||||
"""
|
||||
Mock Elasticsearch for testing without actual ES connection.
|
||||
|
||||
This fixture patches Elasticsearch to return a MagicMock
|
||||
that behaves like an Elasticsearch client.
|
||||
"""
|
||||
mock_es_client = MagicMock()
|
||||
|
||||
# Configure the mock to handle common ES operations
|
||||
mock_es_client.indices.exists.return_value = True
|
||||
mock_es_client.indices.create.return_value = {"acknowledged": True}
|
||||
mock_es_client.search.return_value = {"hits": {"total": {"value": 0}, "hits": []}}
|
||||
mock_es_client.index.return_value = {"_id": "test_id", "result": "created"}
|
||||
mock_es_client.update.return_value = {"_id": "test_id", "result": "updated"}
|
||||
mock_es_client.delete.return_value = {"_id": "test_id", "result": "deleted"}
|
||||
|
||||
# Start the patch
|
||||
with patch('elasticsearch.Elasticsearch', return_value=mock_es_client):
|
||||
yield mock_es_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_mongodb():
|
||||
"""
|
||||
Mock MongoDB for testing without actual MongoDB connection.
|
||||
|
||||
This fixture patches PyMongo to return a MagicMock that behaves like a MongoDB client.
|
||||
"""
|
||||
# Create mock MongoDB clients and collections
|
||||
mock_mongo_client = MagicMock()
|
||||
mock_mongo_db = MagicMock()
|
||||
mock_mongo_collection = MagicMock()
|
||||
|
||||
# Set up the chain: client -> database -> collection
|
||||
mock_mongo_client.__getitem__.return_value = mock_mongo_db
|
||||
mock_mongo_client.get_database.return_value = mock_mongo_db
|
||||
mock_mongo_db.__getitem__.return_value = mock_mongo_collection
|
||||
|
||||
# Configure common MongoDB collection operations
|
||||
mock_mongo_collection.find_one.return_value = None
|
||||
mock_mongo_collection.find.return_value = MagicMock(
|
||||
__iter__=lambda x: iter([]),
|
||||
count=lambda: 0
|
||||
)
|
||||
mock_mongo_collection.insert_one.return_value = MagicMock(
|
||||
inserted_id="mock_id_123",
|
||||
acknowledged=True
|
||||
)
|
||||
mock_mongo_collection.insert_many.return_value = MagicMock(
|
||||
inserted_ids=["mock_id_123", "mock_id_456"],
|
||||
acknowledged=True
|
||||
)
|
||||
mock_mongo_collection.update_one.return_value = MagicMock(
|
||||
modified_count=1,
|
||||
matched_count=1,
|
||||
acknowledged=True
|
||||
)
|
||||
mock_mongo_collection.update_many.return_value = MagicMock(
|
||||
modified_count=2,
|
||||
matched_count=2,
|
||||
acknowledged=True
|
||||
)
|
||||
mock_mongo_collection.delete_one.return_value = MagicMock(
|
||||
deleted_count=1,
|
||||
acknowledged=True
|
||||
)
|
||||
mock_mongo_collection.delete_many.return_value = MagicMock(
|
||||
deleted_count=2,
|
||||
acknowledged=True
|
||||
)
|
||||
mock_mongo_collection.count_documents.return_value = 0
|
||||
|
||||
# Start the patch
|
||||
with patch('pymongo.MongoClient', return_value=mock_mongo_client):
|
||||
yield mock_mongo_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_celery():
|
||||
"""
|
||||
Mock Celery for testing without actual task execution.
|
||||
|
||||
This fixture patches Celery's task.delay() to prevent actual task execution.
|
||||
"""
|
||||
# Start the patch
|
||||
with patch('celery.app.task.Task.delay') as mock_delay:
|
||||
mock_delay.return_value = MagicMock(id="mock-task-id")
|
||||
yield mock_delay
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
import json
|
||||
import uuid
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
from django.test import Client
|
||||
from django.core.exceptions import ValidationError
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from plane.db.models import User
|
||||
from plane.settings.redis import redis_instance
|
||||
from plane.license.models import Instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup_instance(db):
|
||||
"""Create and configure an instance for authentication tests"""
|
||||
instance_id = uuid.uuid4() if not Instance.objects.exists() else Instance.objects.first().id
|
||||
|
||||
# Create or update instance with all required fields
|
||||
instance, _ = Instance.objects.update_or_create(
|
||||
id=instance_id,
|
||||
defaults={
|
||||
"instance_name": "Test Instance",
|
||||
"instance_id": str(uuid.uuid4()),
|
||||
"current_version": "1.0.0",
|
||||
"domain": "http://localhost:8000",
|
||||
"last_checked_at": timezone.now(),
|
||||
"is_setup_done": True,
|
||||
}
|
||||
)
|
||||
return instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def django_client():
|
||||
"""Return a Django test client with User-Agent header for handling redirects"""
|
||||
client = Client(HTTP_USER_AGENT="Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:15.0) Gecko/20100101 Firefox/15.0.1")
|
||||
return client
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestMagicLinkGenerate:
|
||||
"""Test magic link generation functionality"""
|
||||
|
||||
@pytest.fixture
|
||||
def setup_user(self, db):
|
||||
"""Create a test user for magic link tests"""
|
||||
user = User.objects.create(email="user@plane.so")
|
||||
user.set_password("user@123")
|
||||
user.save()
|
||||
return user
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_without_data(self, api_client, setup_user, setup_instance):
|
||||
"""Test magic link generation with empty data"""
|
||||
url = reverse("magic-generate")
|
||||
try:
|
||||
response = api_client.post(url, {}, format="json")
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
except ValidationError:
|
||||
# If a ValidationError is raised directly, that's also acceptable
|
||||
# as it indicates the empty email was rejected
|
||||
assert True
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_email_validity(self, api_client, setup_user, setup_instance):
|
||||
"""Test magic link generation with invalid email format"""
|
||||
url = reverse("magic-generate")
|
||||
try:
|
||||
response = api_client.post(url, {"email": "useremail.com"}, format="json")
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "error_code" in response.data # Check for error code in response
|
||||
except ValidationError:
|
||||
# If a ValidationError is raised directly, that's also acceptable
|
||||
# as it indicates the invalid email was rejected
|
||||
assert True
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
|
||||
def test_magic_generate(self, mock_magic_link, api_client, setup_user, setup_instance):
|
||||
"""Test successful magic link generation"""
|
||||
url = reverse("magic-generate")
|
||||
|
||||
ri = redis_instance()
|
||||
ri.delete("magic_user@plane.so")
|
||||
|
||||
response = api_client.post(url, {"email": "user@plane.so"}, format="json")
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert "key" in response.data # Check for key in response
|
||||
|
||||
# Verify the mock was called with the expected arguments
|
||||
mock_magic_link.assert_called_once()
|
||||
args = mock_magic_link.call_args[0]
|
||||
assert args[0] == "user@plane.so" # First arg should be the email
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
|
||||
def test_max_generate_attempt(self, mock_magic_link, api_client, setup_user, setup_instance):
|
||||
"""Test exceeding maximum magic link generation attempts"""
|
||||
url = reverse("magic-generate")
|
||||
|
||||
ri = redis_instance()
|
||||
ri.delete("magic_user@plane.so")
|
||||
|
||||
for _ in range(4):
|
||||
api_client.post(url, {"email": "user@plane.so"}, format="json")
|
||||
|
||||
response = api_client.post(url, {"email": "user@plane.so"}, format="json")
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "error_code" in response.data # Check for error code in response
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestSignInEndpoint:
|
||||
"""Test sign-in functionality"""
|
||||
|
||||
@pytest.fixture
|
||||
def setup_user(self, db):
|
||||
"""Create a test user for authentication tests"""
|
||||
user = User.objects.create(email="user@plane.so")
|
||||
user.set_password("user@123")
|
||||
user.save()
|
||||
return user
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_without_data(self, django_client, setup_user, setup_instance):
|
||||
"""Test sign-in with empty data"""
|
||||
url = reverse("sign-in")
|
||||
response = django_client.post(url, {}, follow=True)
|
||||
|
||||
# Check redirect contains error code
|
||||
assert "REQUIRED_EMAIL_PASSWORD_SIGN_IN" in response.redirect_chain[-1][0]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_email_validity(self, django_client, setup_user, setup_instance):
|
||||
"""Test sign-in with invalid email format"""
|
||||
url = reverse("sign-in")
|
||||
response = django_client.post(
|
||||
url, {"email": "useremail.com", "password": "user@123"}, follow=True
|
||||
)
|
||||
|
||||
# Check redirect contains error code
|
||||
assert "INVALID_EMAIL_SIGN_IN" in response.redirect_chain[-1][0]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_exists(self, django_client, setup_user, setup_instance):
|
||||
"""Test sign-in with non-existent user"""
|
||||
url = reverse("sign-in")
|
||||
response = django_client.post(
|
||||
url, {"email": "user@email.so", "password": "user123"}, follow=True
|
||||
)
|
||||
|
||||
# Check redirect contains error code
|
||||
assert "USER_DOES_NOT_EXIST" in response.redirect_chain[-1][0]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_password_validity(self, django_client, setup_user, setup_instance):
|
||||
"""Test sign-in with incorrect password"""
|
||||
url = reverse("sign-in")
|
||||
response = django_client.post(
|
||||
url, {"email": "user@plane.so", "password": "user123"}, follow=True
|
||||
)
|
||||
|
||||
|
||||
# Check for the specific authentication error in the URL
|
||||
redirect_urls = [url for url, _ in response.redirect_chain]
|
||||
redirect_contents = ' '.join(redirect_urls)
|
||||
|
||||
# The actual error code for invalid password is AUTHENTICATION_FAILED_SIGN_IN
|
||||
assert "AUTHENTICATION_FAILED_SIGN_IN" in redirect_contents
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_login(self, django_client, setup_user, setup_instance):
|
||||
"""Test successful sign-in"""
|
||||
url = reverse("sign-in")
|
||||
|
||||
# First make the request without following redirects
|
||||
response = django_client.post(
|
||||
url, {"email": "user@plane.so", "password": "user@123"}, follow=False
|
||||
)
|
||||
|
||||
# Check that the initial response is a redirect (302) without error code
|
||||
assert response.status_code == 302
|
||||
assert "error_code" not in response.url
|
||||
|
||||
# Now follow just the first redirect to avoid 404s
|
||||
response = django_client.get(response.url, follow=False)
|
||||
|
||||
# The user should be authenticated regardless of the final page
|
||||
assert "_auth_user_id" in django_client.session
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_next_path_redirection(self, django_client, setup_user, setup_instance):
|
||||
"""Test sign-in with next_path parameter"""
|
||||
url = reverse("sign-in")
|
||||
next_path = "workspaces"
|
||||
|
||||
# First make the request without following redirects
|
||||
response = django_client.post(
|
||||
url,
|
||||
{"email": "user@plane.so", "password": "user@123", "next_path": next_path},
|
||||
follow=False
|
||||
)
|
||||
|
||||
# Check that the initial response is a redirect (302) without error code
|
||||
assert response.status_code == 302
|
||||
assert "error_code" not in response.url
|
||||
|
||||
|
||||
# In a real browser, the next_path would be used to build the absolute URL
|
||||
# Since we're just testing the authentication logic, we won't check for the exact URL structure
|
||||
# Instead, just verify that we're authenticated
|
||||
assert "_auth_user_id" in django_client.session
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestMagicSignIn:
|
||||
"""Test magic link sign-in functionality"""
|
||||
|
||||
@pytest.fixture
|
||||
def setup_user(self, db):
|
||||
"""Create a test user for magic sign-in tests"""
|
||||
user = User.objects.create(email="user@plane.so")
|
||||
user.set_password("user@123")
|
||||
user.save()
|
||||
return user
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_without_data(self, django_client, setup_user, setup_instance):
|
||||
"""Test magic link sign-in with empty data"""
|
||||
url = reverse("magic-sign-in")
|
||||
response = django_client.post(url, {}, follow=True)
|
||||
|
||||
# Check redirect contains error code
|
||||
assert "MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED" in response.redirect_chain[-1][0]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_expired_invalid_magic_link(self, django_client, setup_user, setup_instance):
|
||||
"""Test magic link sign-in with expired/invalid link"""
|
||||
ri = redis_instance()
|
||||
ri.delete("magic_user@plane.so")
|
||||
|
||||
url = reverse("magic-sign-in")
|
||||
response = django_client.post(
|
||||
url,
|
||||
{"email": "user@plane.so", "code": "xxxx-xxxxx-xxxx"},
|
||||
follow=False
|
||||
)
|
||||
|
||||
# Check that we get a redirect
|
||||
assert response.status_code == 302
|
||||
|
||||
# The actual error code is EXPIRED_MAGIC_CODE_SIGN_IN (when key doesn't exist)
|
||||
# or INVALID_MAGIC_CODE_SIGN_IN (when key exists but code doesn't match)
|
||||
assert "EXPIRED_MAGIC_CODE_SIGN_IN" in response.url or "INVALID_MAGIC_CODE_SIGN_IN" in response.url
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_does_not_exist(self, django_client, setup_instance):
|
||||
"""Test magic sign-in with non-existent user"""
|
||||
url = reverse("magic-sign-in")
|
||||
response = django_client.post(
|
||||
url,
|
||||
{"email": "nonexistent@plane.so", "code": "xxxx-xxxxx-xxxx"},
|
||||
follow=True
|
||||
)
|
||||
|
||||
# Check redirect contains error code
|
||||
assert "USER_DOES_NOT_EXIST" in response.redirect_chain[-1][0]
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
|
||||
def test_magic_code_sign_in(self, mock_magic_link, django_client, api_client, setup_user, setup_instance):
|
||||
"""Test successful magic link sign-in process"""
|
||||
# First generate a magic link token
|
||||
gen_url = reverse("magic-generate")
|
||||
response = api_client.post(gen_url, {"email": "user@plane.so"}, format="json")
|
||||
|
||||
# Check that the token generation was successful
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
# Since we're mocking the magic_link task, we need to manually get the token from Redis
|
||||
ri = redis_instance()
|
||||
user_data = json.loads(ri.get("magic_user@plane.so"))
|
||||
token = user_data["token"]
|
||||
|
||||
# Use Django client to test the redirect flow without following redirects
|
||||
url = reverse("magic-sign-in")
|
||||
response = django_client.post(
|
||||
url,
|
||||
{"email": "user@plane.so", "code": token},
|
||||
follow=False
|
||||
)
|
||||
|
||||
# Check that the initial response is a redirect without error code
|
||||
assert response.status_code == 302
|
||||
assert "error_code" not in response.url
|
||||
|
||||
# The user should now be authenticated
|
||||
assert "_auth_user_id" in django_client.session
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
|
||||
def test_magic_sign_in_with_next_path(self, mock_magic_link, django_client, api_client, setup_user, setup_instance):
|
||||
"""Test magic sign-in with next_path parameter"""
|
||||
# First generate a magic link token
|
||||
gen_url = reverse("magic-generate")
|
||||
response = api_client.post(gen_url, {"email": "user@plane.so"}, format="json")
|
||||
|
||||
# Check that the token generation was successful
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
# Since we're mocking the magic_link task, we need to manually get the token from Redis
|
||||
ri = redis_instance()
|
||||
user_data = json.loads(ri.get("magic_user@plane.so"))
|
||||
token = user_data["token"]
|
||||
|
||||
# Use Django client to test the redirect flow without following redirects
|
||||
url = reverse("magic-sign-in")
|
||||
next_path = "workspaces"
|
||||
response = django_client.post(
|
||||
url,
|
||||
{"email": "user@plane.so", "code": token, "next_path": next_path},
|
||||
follow=False
|
||||
)
|
||||
|
||||
# Check that the initial response is a redirect without error code
|
||||
assert response.status_code == 302
|
||||
assert "error_code" not in response.url
|
||||
|
||||
# Check that the redirect URL contains the next_path
|
||||
assert next_path in response.url
|
||||
|
||||
# The user should now be authenticated
|
||||
assert "_auth_user_id" in django_client.session
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestMagicSignUp:
|
||||
"""Test magic link sign-up functionality"""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_without_data(self, django_client, setup_instance):
|
||||
"""Test magic link sign-up with empty data"""
|
||||
url = reverse("magic-sign-up")
|
||||
response = django_client.post(url, {}, follow=True)
|
||||
|
||||
# Check redirect contains error code
|
||||
assert "MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED" in response.redirect_chain[-1][0]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_already_exists(self, django_client, db, setup_instance):
|
||||
"""Test magic sign-up with existing user"""
|
||||
# Create a user that already exists
|
||||
User.objects.create(email="existing@plane.so")
|
||||
|
||||
url = reverse("magic-sign-up")
|
||||
response = django_client.post(
|
||||
url,
|
||||
{"email": "existing@plane.so", "code": "xxxx-xxxxx-xxxx"},
|
||||
follow=True
|
||||
)
|
||||
|
||||
# Check redirect contains error code
|
||||
assert "USER_ALREADY_EXIST" in response.redirect_chain[-1][0]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_expired_invalid_magic_link(self, django_client, setup_instance):
|
||||
"""Test magic link sign-up with expired/invalid link"""
|
||||
url = reverse("magic-sign-up")
|
||||
response = django_client.post(
|
||||
url,
|
||||
{"email": "new@plane.so", "code": "xxxx-xxxxx-xxxx"},
|
||||
follow=False
|
||||
)
|
||||
|
||||
# Check that we get a redirect
|
||||
assert response.status_code == 302
|
||||
|
||||
# The actual error code is EXPIRED_MAGIC_CODE_SIGN_UP (when key doesn't exist)
|
||||
# or INVALID_MAGIC_CODE_SIGN_UP (when key exists but code doesn't match)
|
||||
assert "EXPIRED_MAGIC_CODE_SIGN_UP" in response.url or "INVALID_MAGIC_CODE_SIGN_UP" in response.url
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
|
||||
def test_magic_code_sign_up(self, mock_magic_link, django_client, api_client, setup_instance):
|
||||
"""Test successful magic link sign-up process"""
|
||||
email = "newuser@plane.so"
|
||||
|
||||
# First generate a magic link token
|
||||
gen_url = reverse("magic-generate")
|
||||
response = api_client.post(gen_url, {"email": email}, format="json")
|
||||
|
||||
# Check that the token generation was successful
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
# Since we're mocking the magic_link task, we need to manually get the token from Redis
|
||||
ri = redis_instance()
|
||||
user_data = json.loads(ri.get(f"magic_{email}"))
|
||||
token = user_data["token"]
|
||||
|
||||
# Use Django client to test the redirect flow without following redirects
|
||||
url = reverse("magic-sign-up")
|
||||
response = django_client.post(
|
||||
url,
|
||||
{"email": email, "code": token},
|
||||
follow=False
|
||||
)
|
||||
|
||||
# Check that the initial response is a redirect without error code
|
||||
assert response.status_code == 302
|
||||
assert "error_code" not in response.url
|
||||
|
||||
# Check if user was created
|
||||
assert User.objects.filter(email=email).exists()
|
||||
|
||||
# Check if user is authenticated
|
||||
assert "_auth_user_id" in django_client.session
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
|
||||
def test_magic_sign_up_with_next_path(self, mock_magic_link, django_client, api_client, setup_instance):
|
||||
"""Test magic sign-up with next_path parameter"""
|
||||
email = "newuser2@plane.so"
|
||||
|
||||
# First generate a magic link token
|
||||
gen_url = reverse("magic-generate")
|
||||
response = api_client.post(gen_url, {"email": email}, format="json")
|
||||
|
||||
# Check that the token generation was successful
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
# Since we're mocking the magic_link task, we need to manually get the token from Redis
|
||||
ri = redis_instance()
|
||||
user_data = json.loads(ri.get(f"magic_{email}"))
|
||||
token = user_data["token"]
|
||||
|
||||
# Use Django client to test the redirect flow without following redirects
|
||||
url = reverse("magic-sign-up")
|
||||
next_path = "onboarding"
|
||||
response = django_client.post(
|
||||
url,
|
||||
{"email": email, "code": token, "next_path": next_path},
|
||||
follow=False
|
||||
)
|
||||
|
||||
# Check that the initial response is a redirect without error code
|
||||
assert response.status_code == 302
|
||||
assert "error_code" not in response.url
|
||||
|
||||
# In a real browser, the next_path would be used to build the absolute URL
|
||||
# Since we're just testing the authentication logic, we won't check for the exact URL structure
|
||||
|
||||
# Check if user was created
|
||||
assert User.objects.filter(email=email).exists()
|
||||
|
||||
# Check if user is authenticated
|
||||
assert "_auth_user_id" in django_client.session
|
||||
@@ -0,0 +1,79 @@
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from unittest.mock import patch
|
||||
|
||||
from plane.db.models import Workspace, WorkspaceMember
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestWorkspaceAPI:
|
||||
"""Test workspace CRUD operations"""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_workspace_empty_data(self, session_client):
|
||||
"""Test creating a workspace with empty data"""
|
||||
url = reverse("workspace")
|
||||
|
||||
# Test with empty data
|
||||
response = session_client.post(url, {}, format="json")
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.workspace_seed_task.workspace_seed.delay")
|
||||
def test_create_workspace_valid_data(self, mock_workspace_seed, session_client, create_user):
|
||||
"""Test creating a workspace with valid data"""
|
||||
url = reverse("workspace")
|
||||
user = create_user # Use the create_user fixture directly as it returns a user object
|
||||
|
||||
# Test with valid data - include all required fields
|
||||
workspace_data = {
|
||||
"name": "Plane",
|
||||
"slug": "pla-ne-test",
|
||||
"company_name": "Plane Inc."
|
||||
}
|
||||
|
||||
# Make the request
|
||||
response = session_client.post(url, workspace_data, format="json")
|
||||
|
||||
# Check response status
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
# Verify workspace was created
|
||||
assert Workspace.objects.count() == 1
|
||||
|
||||
# Check if the member is created
|
||||
assert WorkspaceMember.objects.count() == 1
|
||||
|
||||
# Check other values
|
||||
workspace = Workspace.objects.get(slug=workspace_data["slug"])
|
||||
workspace_member = WorkspaceMember.objects.filter(
|
||||
workspace=workspace, member=user
|
||||
).first()
|
||||
assert workspace.owner == user
|
||||
assert workspace_member.role == 20
|
||||
|
||||
# Verify the workspace_seed task was called
|
||||
mock_workspace_seed.assert_called_once_with(response.data["id"])
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch('plane.bgtasks.workspace_seed_task.workspace_seed.delay')
|
||||
def test_create_duplicate_workspace(self, mock_workspace_seed, session_client):
|
||||
"""Test creating a duplicate workspace"""
|
||||
url = reverse("workspace")
|
||||
|
||||
# Create first workspace
|
||||
session_client.post(
|
||||
url, {"name": "Plane", "slug": "pla-ne"}, format="json"
|
||||
)
|
||||
|
||||
# Try to create a workspace with the same slug
|
||||
response = session_client.post(
|
||||
url, {"name": "Plane", "slug": "pla-ne"}, format="json"
|
||||
)
|
||||
|
||||
# The API returns 400 BAD REQUEST for duplicate slugs, not 409 CONFLICT
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
# Optionally check the error message to confirm it's related to the duplicate slug
|
||||
assert "slug" in response.data
|
||||
@@ -0,0 +1,82 @@
|
||||
import factory
|
||||
from uuid import uuid4
|
||||
from django.utils import timezone
|
||||
|
||||
from plane.db.models import (
|
||||
User,
|
||||
Workspace,
|
||||
WorkspaceMember,
|
||||
Project,
|
||||
ProjectMember
|
||||
)
|
||||
|
||||
|
||||
class UserFactory(factory.django.DjangoModelFactory):
|
||||
"""Factory for creating User instances"""
|
||||
class Meta:
|
||||
model = User
|
||||
django_get_or_create = ('email',)
|
||||
|
||||
id = factory.LazyFunction(uuid4)
|
||||
email = factory.Sequence(lambda n: f'user{n}@plane.so')
|
||||
password = factory.PostGenerationMethodCall('set_password', 'password')
|
||||
first_name = factory.Sequence(lambda n: f'First{n}')
|
||||
last_name = factory.Sequence(lambda n: f'Last{n}')
|
||||
is_active = True
|
||||
is_superuser = False
|
||||
is_staff = False
|
||||
|
||||
|
||||
class WorkspaceFactory(factory.django.DjangoModelFactory):
|
||||
"""Factory for creating Workspace instances"""
|
||||
class Meta:
|
||||
model = Workspace
|
||||
django_get_or_create = ('slug',)
|
||||
|
||||
id = factory.LazyFunction(uuid4)
|
||||
name = factory.Sequence(lambda n: f'Workspace {n}')
|
||||
slug = factory.Sequence(lambda n: f'workspace-{n}')
|
||||
owner = factory.SubFactory(UserFactory)
|
||||
created_at = factory.LazyFunction(timezone.now)
|
||||
updated_at = factory.LazyFunction(timezone.now)
|
||||
|
||||
|
||||
class WorkspaceMemberFactory(factory.django.DjangoModelFactory):
|
||||
"""Factory for creating WorkspaceMember instances"""
|
||||
class Meta:
|
||||
model = WorkspaceMember
|
||||
|
||||
id = factory.LazyFunction(uuid4)
|
||||
workspace = factory.SubFactory(WorkspaceFactory)
|
||||
member = factory.SubFactory(UserFactory)
|
||||
role = 20 # Admin role by default
|
||||
created_at = factory.LazyFunction(timezone.now)
|
||||
updated_at = factory.LazyFunction(timezone.now)
|
||||
|
||||
|
||||
class ProjectFactory(factory.django.DjangoModelFactory):
|
||||
"""Factory for creating Project instances"""
|
||||
class Meta:
|
||||
model = Project
|
||||
django_get_or_create = ('name', 'workspace')
|
||||
|
||||
id = factory.LazyFunction(uuid4)
|
||||
name = factory.Sequence(lambda n: f'Project {n}')
|
||||
workspace = factory.SubFactory(WorkspaceFactory)
|
||||
created_by = factory.SelfAttribute('workspace.owner')
|
||||
updated_by = factory.SelfAttribute('workspace.owner')
|
||||
created_at = factory.LazyFunction(timezone.now)
|
||||
updated_at = factory.LazyFunction(timezone.now)
|
||||
|
||||
|
||||
class ProjectMemberFactory(factory.django.DjangoModelFactory):
|
||||
"""Factory for creating ProjectMember instances"""
|
||||
class Meta:
|
||||
model = ProjectMember
|
||||
|
||||
id = factory.LazyFunction(uuid4)
|
||||
project = factory.SubFactory(ProjectFactory)
|
||||
member = factory.SubFactory(UserFactory)
|
||||
role = 20 # Admin role by default
|
||||
created_at = factory.LazyFunction(timezone.now)
|
||||
updated_at = factory.LazyFunction(timezone.now)
|
||||
@@ -0,0 +1,100 @@
|
||||
import pytest
|
||||
import requests
|
||||
from django.urls import reverse
|
||||
|
||||
|
||||
@pytest.mark.smoke
|
||||
class TestAuthSmoke:
|
||||
"""Smoke tests for authentication endpoints"""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_login_endpoint_available(self, plane_server, create_user, user_data):
|
||||
"""Test that the login endpoint is available and responds correctly"""
|
||||
# Get the sign-in URL
|
||||
relative_url = reverse("sign-in")
|
||||
url = f"{plane_server.url}{relative_url}"
|
||||
|
||||
# 1. Test bad login - test with wrong password
|
||||
response = requests.post(
|
||||
url,
|
||||
data={
|
||||
"email": user_data["email"],
|
||||
"password": "wrong-password"
|
||||
}
|
||||
)
|
||||
|
||||
# For bad credentials, any of these status codes would be valid
|
||||
# The test shouldn't be brittle to minor implementation changes
|
||||
assert response.status_code != 500, "Authentication should not cause server errors"
|
||||
assert response.status_code != 404, "Authentication endpoint should exist"
|
||||
|
||||
if response.status_code == 200:
|
||||
# If API returns 200 for failures, check the response body for error indication
|
||||
if hasattr(response, 'json'):
|
||||
try:
|
||||
data = response.json()
|
||||
# JSON response might indicate error in its structure
|
||||
assert "error" in data or "error_code" in data or "detail" in data or response.url.endswith("sign-in"), \
|
||||
"Error response should contain error details"
|
||||
except ValueError:
|
||||
# It's ok if response isn't JSON format
|
||||
pass
|
||||
elif response.status_code in [302, 303]:
|
||||
# If it's a redirect, it should redirect to a login page or error page
|
||||
redirect_url = response.headers.get('Location', '')
|
||||
assert "error" in redirect_url or "sign-in" in redirect_url, \
|
||||
"Failed login should redirect to login page or error page"
|
||||
|
||||
# 2. Test good login with correct credentials
|
||||
response = requests.post(
|
||||
url,
|
||||
data={
|
||||
"email": user_data["email"],
|
||||
"password": user_data["password"]
|
||||
},
|
||||
allow_redirects=False # Don't follow redirects
|
||||
)
|
||||
|
||||
# Successful auth should not be a client error or server error
|
||||
assert response.status_code not in range(400, 600), \
|
||||
f"Authentication with valid credentials failed with status {response.status_code}"
|
||||
|
||||
# Specific validation based on response type
|
||||
if response.status_code in [302, 303]:
|
||||
# Redirect-based auth: check that redirect URL doesn't contain error
|
||||
redirect_url = response.headers.get('Location', '')
|
||||
assert "error" not in redirect_url and "error_code" not in redirect_url, \
|
||||
"Successful login redirect should not contain error parameters"
|
||||
|
||||
elif response.status_code == 200:
|
||||
# API token-based auth: check for tokens or user session
|
||||
if hasattr(response, 'json'):
|
||||
try:
|
||||
data = response.json()
|
||||
# If it's a token response
|
||||
if "access_token" in data:
|
||||
assert "refresh_token" in data, "JWT auth should return both access and refresh tokens"
|
||||
# If it's a user session response
|
||||
elif "user" in data:
|
||||
assert "is_authenticated" in data and data["is_authenticated"], \
|
||||
"User session response should indicate authentication"
|
||||
# Otherwise it should at least indicate success
|
||||
else:
|
||||
assert not any(error_key in data for error_key in ["error", "error_code", "detail"]), \
|
||||
"Success response should not contain error keys"
|
||||
except ValueError:
|
||||
# Non-JSON is acceptable if it's a redirect or HTML response
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.smoke
|
||||
class TestHealthCheckSmoke:
|
||||
"""Smoke test for health check endpoint"""
|
||||
|
||||
def test_healthcheck_endpoint(self, plane_server):
|
||||
"""Test that the health check endpoint is available and responds correctly"""
|
||||
# Make a request to the health check endpoint
|
||||
response = requests.get(f"{plane_server.url}/")
|
||||
|
||||
# Should be OK
|
||||
assert response.status_code == 200, "Health check endpoint should return 200 OK"
|
||||
@@ -0,0 +1,50 @@
|
||||
import pytest
|
||||
from uuid import uuid4
|
||||
|
||||
from plane.db.models import Workspace, WorkspaceMember, User
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestWorkspaceModel:
|
||||
"""Test the Workspace model"""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_workspace_creation(self, create_user):
|
||||
"""Test creating a workspace"""
|
||||
# Create a workspace
|
||||
workspace = Workspace.objects.create(
|
||||
name="Test Workspace",
|
||||
slug="test-workspace",
|
||||
id=uuid4(),
|
||||
owner=create_user
|
||||
)
|
||||
|
||||
# Verify it was created
|
||||
assert workspace.id is not None
|
||||
assert workspace.name == "Test Workspace"
|
||||
assert workspace.slug == "test-workspace"
|
||||
assert workspace.owner == create_user
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_workspace_member_creation(self, create_user):
|
||||
"""Test creating a workspace member"""
|
||||
# Create a workspace
|
||||
workspace = Workspace.objects.create(
|
||||
name="Test Workspace",
|
||||
slug="test-workspace",
|
||||
id=uuid4(),
|
||||
owner=create_user
|
||||
)
|
||||
|
||||
# Create a workspace member
|
||||
workspace_member = WorkspaceMember.objects.create(
|
||||
workspace=workspace,
|
||||
member=create_user,
|
||||
role=20 # Admin role
|
||||
)
|
||||
|
||||
# Verify it was created
|
||||
assert workspace_member.id is not None
|
||||
assert workspace_member.workspace == workspace
|
||||
assert workspace_member.member == create_user
|
||||
assert workspace_member.role == 20
|
||||
@@ -0,0 +1,71 @@
|
||||
import pytest
|
||||
from uuid import uuid4
|
||||
|
||||
from plane.api.serializers import WorkspaceLiteSerializer
|
||||
from plane.db.models import Workspace, User
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestWorkspaceLiteSerializer:
|
||||
"""Test the WorkspaceLiteSerializer"""
|
||||
|
||||
def test_workspace_lite_serializer_fields(self, db):
|
||||
"""Test that the serializer includes the correct fields"""
|
||||
# Create a user to be the owner
|
||||
owner = User.objects.create(
|
||||
email="test@example.com",
|
||||
first_name="Test",
|
||||
last_name="User"
|
||||
)
|
||||
|
||||
# Create a workspace with explicit ID to test serialization
|
||||
workspace_id = uuid4()
|
||||
workspace = Workspace.objects.create(
|
||||
name="Test Workspace",
|
||||
slug="test-workspace",
|
||||
id=workspace_id,
|
||||
owner=owner
|
||||
)
|
||||
|
||||
# Serialize the workspace
|
||||
serialized_data = WorkspaceLiteSerializer(workspace).data
|
||||
|
||||
# Check fields are present and correct
|
||||
assert "name" in serialized_data
|
||||
assert "slug" in serialized_data
|
||||
assert "id" in serialized_data
|
||||
|
||||
assert serialized_data["name"] == "Test Workspace"
|
||||
assert serialized_data["slug"] == "test-workspace"
|
||||
assert str(serialized_data["id"]) == str(workspace_id)
|
||||
|
||||
def test_workspace_lite_serializer_read_only(self, db):
|
||||
"""Test that the serializer fields are read-only"""
|
||||
# Create a user to be the owner
|
||||
owner = User.objects.create(
|
||||
email="test2@example.com",
|
||||
first_name="Test",
|
||||
last_name="User"
|
||||
)
|
||||
|
||||
# Create a workspace
|
||||
workspace = Workspace.objects.create(
|
||||
name="Test Workspace",
|
||||
slug="test-workspace",
|
||||
id=uuid4(),
|
||||
owner=owner
|
||||
)
|
||||
|
||||
# Try to update via serializer
|
||||
serializer = WorkspaceLiteSerializer(
|
||||
workspace,
|
||||
data={"name": "Updated Name", "slug": "updated-slug"}
|
||||
)
|
||||
|
||||
# Serializer should be valid (since read-only fields are ignored)
|
||||
assert serializer.is_valid()
|
||||
|
||||
# Save should not update the read-only fields
|
||||
updated_workspace = serializer.save()
|
||||
assert updated_workspace.name == "Test Workspace"
|
||||
assert updated_workspace.slug == "test-workspace"
|
||||
@@ -0,0 +1,49 @@
|
||||
import uuid
|
||||
import pytest
|
||||
from plane.utils.uuid import is_valid_uuid, convert_uuid_to_integer
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestUUIDUtils:
|
||||
"""Test the UUID utilities"""
|
||||
|
||||
def test_is_valid_uuid_with_valid_uuid(self):
|
||||
"""Test is_valid_uuid with a valid UUID"""
|
||||
# Generate a valid UUID
|
||||
valid_uuid = str(uuid.uuid4())
|
||||
assert is_valid_uuid(valid_uuid) is True
|
||||
|
||||
def test_is_valid_uuid_with_invalid_uuid(self):
|
||||
"""Test is_valid_uuid with invalid UUID strings"""
|
||||
# Test with different invalid formats
|
||||
assert is_valid_uuid("not-a-uuid") is False
|
||||
assert is_valid_uuid("123456789") is False
|
||||
assert is_valid_uuid("") is False
|
||||
assert is_valid_uuid("00000000-0000-0000-0000-000000000000") is False # This is a valid UUID but version 1
|
||||
|
||||
def test_convert_uuid_to_integer(self):
|
||||
"""Test convert_uuid_to_integer function"""
|
||||
# Create a known UUID
|
||||
test_uuid = uuid.UUID("f47ac10b-58cc-4372-a567-0e02b2c3d479")
|
||||
|
||||
# Convert to integer
|
||||
result = convert_uuid_to_integer(test_uuid)
|
||||
|
||||
# Check that the result is an integer
|
||||
assert isinstance(result, int)
|
||||
|
||||
# Ensure consistent results with the same input
|
||||
assert convert_uuid_to_integer(test_uuid) == result
|
||||
|
||||
# Different UUIDs should produce different integers
|
||||
different_uuid = uuid.UUID("550e8400-e29b-41d4-a716-446655440000")
|
||||
assert convert_uuid_to_integer(different_uuid) != result
|
||||
|
||||
def test_convert_uuid_to_integer_string_input(self):
|
||||
"""Test convert_uuid_to_integer handles string UUID"""
|
||||
# Test with a UUID string
|
||||
test_uuid_str = "f47ac10b-58cc-4372-a567-0e02b2c3d479"
|
||||
test_uuid = uuid.UUID(test_uuid_str)
|
||||
|
||||
# Should get the same result whether passing UUID or string
|
||||
assert convert_uuid_to_integer(test_uuid) == convert_uuid_to_integer(test_uuid_str)
|
||||
@@ -2,12 +2,10 @@
|
||||
|
||||
from django.conf import settings
|
||||
from django.urls import include, path, re_path
|
||||
from django.views.generic import TemplateView
|
||||
|
||||
handler404 = "plane.app.views.error_404.custom_404_view"
|
||||
|
||||
urlpatterns = [
|
||||
path("", TemplateView.as_view(template_name="index.html")),
|
||||
path("api/", include("plane.app.urls")),
|
||||
path("api/public/", include("plane.space.urls")),
|
||||
path("api/instances/", include("plane.license.urls")),
|
||||
|
||||
@@ -182,9 +182,7 @@ def burndown_plot(queryset, slug, project_id, plot_type, cycle_id=None, module_i
|
||||
# Get all dates between the two dates
|
||||
date_range = [
|
||||
(queryset.start_date + timedelta(days=x))
|
||||
for x in range(
|
||||
(queryset.target_date - queryset.start_date).days + 1
|
||||
)
|
||||
for x in range((queryset.target_date - queryset.start_date).days + 1)
|
||||
]
|
||||
|
||||
chart_data = {str(date): 0 for date in date_range}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
from typing import Dict, Any, Tuple, Optional, List, Union
|
||||
|
||||
|
||||
# Django imports
|
||||
from django.db.models import (
|
||||
Count,
|
||||
F,
|
||||
QuerySet,
|
||||
Aggregate,
|
||||
)
|
||||
|
||||
from plane.db.models import Issue
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
|
||||
x_axis_mapper = {
|
||||
"STATES": "STATES",
|
||||
"STATE_GROUPS": "STATE_GROUPS",
|
||||
"LABELS": "LABELS",
|
||||
"ASSIGNEES": "ASSIGNEES",
|
||||
"ESTIMATE_POINTS": "ESTIMATE_POINTS",
|
||||
"CYCLES": "CYCLES",
|
||||
"MODULES": "MODULES",
|
||||
"PRIORITY": "PRIORITY",
|
||||
"START_DATE": "START_DATE",
|
||||
"TARGET_DATE": "TARGET_DATE",
|
||||
"CREATED_AT": "CREATED_AT",
|
||||
"COMPLETED_AT": "COMPLETED_AT",
|
||||
"CREATED_BY": "CREATED_BY",
|
||||
}
|
||||
|
||||
|
||||
def get_y_axis_filter(y_axis: str) -> Dict[str, Any]:
|
||||
filter_mapping = {
|
||||
"WORK_ITEM_COUNT": {"id": F("id")},
|
||||
}
|
||||
return filter_mapping.get(y_axis, {})
|
||||
|
||||
|
||||
def get_x_axis_field() -> Dict[str, Tuple[str, str, Optional[Dict[str, Any]]]]:
|
||||
return {
|
||||
"STATES": ("state__id", "state__name", None),
|
||||
"STATE_GROUPS": ("state__group", "state__group", None),
|
||||
"LABELS": (
|
||||
"labels__id",
|
||||
"labels__name",
|
||||
{"label_issue__deleted_at__isnull": True},
|
||||
),
|
||||
"ASSIGNEES": (
|
||||
"assignees__id",
|
||||
"assignees__display_name",
|
||||
{"issue_assignee__deleted_at__isnull": True},
|
||||
),
|
||||
"ESTIMATE_POINTS": ("estimate_point__value", "estimate_point__key", None),
|
||||
"CYCLES": (
|
||||
"issue_cycle__cycle_id",
|
||||
"issue_cycle__cycle__name",
|
||||
{"issue_cycle__deleted_at__isnull": True},
|
||||
),
|
||||
"MODULES": (
|
||||
"issue_module__module_id",
|
||||
"issue_module__module__name",
|
||||
{"issue_module__deleted_at__isnull": True},
|
||||
),
|
||||
"PRIORITY": ("priority", "priority", None),
|
||||
"START_DATE": ("start_date", "start_date", None),
|
||||
"TARGET_DATE": ("target_date", "target_date", None),
|
||||
"CREATED_AT": ("created_at__date", "created_at__date", None),
|
||||
"COMPLETED_AT": ("completed_at__date", "completed_at__date", None),
|
||||
"CREATED_BY": ("created_by_id", "created_by__display_name", None),
|
||||
}
|
||||
|
||||
|
||||
def process_grouped_data(
|
||||
data: List[Dict[str, Any]],
|
||||
) -> Tuple[List[Dict[str, Any]], Dict[str, str]]:
|
||||
response = {}
|
||||
schema = {}
|
||||
|
||||
for item in data:
|
||||
key = item["key"]
|
||||
if key not in response:
|
||||
response[key] = {
|
||||
"key": key if key else "none",
|
||||
"name": (
|
||||
item.get("display_name", key)
|
||||
if item.get("display_name", key)
|
||||
else "None"
|
||||
),
|
||||
"count": 0,
|
||||
}
|
||||
group_key = str(item["group_key"]) if item["group_key"] else "none"
|
||||
schema[group_key] = item.get("group_name", item["group_key"])
|
||||
schema[group_key] = schema[group_key] if schema[group_key] else "None"
|
||||
response[key][group_key] = response[key].get(group_key, 0) + item["count"]
|
||||
response[key]["count"] += item["count"]
|
||||
|
||||
return list(response.values()), schema
|
||||
|
||||
|
||||
def build_number_chart_response(
|
||||
queryset: QuerySet[Issue],
|
||||
y_axis_filter: Dict[str, Any],
|
||||
y_axis: str,
|
||||
aggregate_func: Aggregate,
|
||||
) -> List[Dict[str, Any]]:
|
||||
count = (
|
||||
queryset.filter(**y_axis_filter).aggregate(total=aggregate_func).get("total", 0)
|
||||
)
|
||||
return [{"key": y_axis, "name": y_axis, "count": count}]
|
||||
|
||||
|
||||
def build_grouped_chart_response(
|
||||
queryset: QuerySet[Issue],
|
||||
id_field: str,
|
||||
name_field: str,
|
||||
group_field: str,
|
||||
group_name_field: str,
|
||||
aggregate_func: Aggregate,
|
||||
) -> Tuple[List[Dict[str, Any]], Dict[str, str]]:
|
||||
data = (
|
||||
queryset.annotate(
|
||||
key=F(id_field),
|
||||
group_key=F(group_field),
|
||||
group_name=F(group_name_field),
|
||||
display_name=F(name_field) if name_field else F(id_field),
|
||||
)
|
||||
.values("key", "group_key", "group_name", "display_name")
|
||||
.annotate(count=aggregate_func)
|
||||
.order_by("-count")
|
||||
)
|
||||
return process_grouped_data(data)
|
||||
|
||||
|
||||
def build_simple_chart_response(
|
||||
queryset: QuerySet, id_field: str, name_field: str, aggregate_func: Aggregate
|
||||
) -> List[Dict[str, Any]]:
|
||||
data = (
|
||||
queryset.annotate(
|
||||
key=F(id_field), display_name=F(name_field) if name_field else F(id_field)
|
||||
)
|
||||
.values("key", "display_name")
|
||||
.annotate(count=aggregate_func)
|
||||
.order_by("key")
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"key": item["key"] if item["key"] else "None",
|
||||
"name": item["display_name"] if item["display_name"] else "None",
|
||||
"count": item["count"],
|
||||
}
|
||||
for item in data
|
||||
]
|
||||
|
||||
|
||||
def build_analytics_chart(
|
||||
queryset: QuerySet[Issue],
|
||||
x_axis: str,
|
||||
group_by: Optional[str] = None,
|
||||
date_filter: Optional[str] = None,
|
||||
) -> Dict[str, Union[List[Dict[str, Any]], Dict[str, str]]]:
|
||||
# Validate x_axis
|
||||
if x_axis not in x_axis_mapper:
|
||||
raise ValidationError(f"Invalid x_axis field: {x_axis}")
|
||||
|
||||
# Validate group_by
|
||||
if group_by and group_by not in x_axis_mapper:
|
||||
raise ValidationError(f"Invalid group_by field: {group_by}")
|
||||
|
||||
field_mapping = get_x_axis_field()
|
||||
|
||||
id_field, name_field, additional_filter = field_mapping.get(
|
||||
x_axis, (None, None, {})
|
||||
)
|
||||
group_field, group_name_field, group_additional_filter = field_mapping.get(
|
||||
group_by, (None, None, {})
|
||||
)
|
||||
|
||||
# Apply additional filters if they exist
|
||||
if additional_filter or {}:
|
||||
queryset = queryset.filter(**additional_filter)
|
||||
|
||||
if group_additional_filter or {}:
|
||||
queryset = queryset.filter(**group_additional_filter)
|
||||
|
||||
aggregate_func = Count("id", distinct=True)
|
||||
|
||||
if group_field:
|
||||
response, schema = build_grouped_chart_response(
|
||||
queryset,
|
||||
id_field,
|
||||
name_field,
|
||||
group_field,
|
||||
group_name_field,
|
||||
aggregate_func,
|
||||
)
|
||||
else:
|
||||
response = build_simple_chart_response(
|
||||
queryset, id_field, name_field, aggregate_func
|
||||
)
|
||||
schema = {}
|
||||
|
||||
return {"data": response, "schema": schema}
|
||||
@@ -0,0 +1,201 @@
|
||||
from datetime import datetime, timedelta, date
|
||||
from django.utils import timezone
|
||||
from typing import Dict, Optional, List, Union, Tuple, Any
|
||||
|
||||
from plane.db.models import User
|
||||
|
||||
|
||||
def get_analytics_date_range(
|
||||
date_filter: Optional[str] = None,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Dict[str, datetime]]]:
|
||||
"""
|
||||
Get date range for analytics with current and previous periods for comparison.
|
||||
Returns a dictionary with current and previous date ranges.
|
||||
|
||||
Args:
|
||||
date_filter (str): The type of date filter to apply
|
||||
start_date (str): Start date for custom range (format: YYYY-MM-DD)
|
||||
end_date (str): End date for custom range (format: YYYY-MM-DD)
|
||||
|
||||
Returns:
|
||||
dict: Dictionary containing current and previous date ranges
|
||||
"""
|
||||
if not date_filter:
|
||||
return None
|
||||
|
||||
today = timezone.now().date()
|
||||
|
||||
if date_filter == "yesterday":
|
||||
yesterday = today - timedelta(days=1)
|
||||
return {
|
||||
"current": {
|
||||
"gte": datetime.combine(yesterday, datetime.min.time()),
|
||||
"lte": datetime.combine(yesterday, datetime.max.time()),
|
||||
}
|
||||
}
|
||||
elif date_filter == "last_7_days":
|
||||
return {
|
||||
"current": {
|
||||
"gte": datetime.combine(today - timedelta(days=7), datetime.min.time()),
|
||||
"lte": datetime.combine(today, datetime.max.time()),
|
||||
},
|
||||
"previous": {
|
||||
"gte": datetime.combine(
|
||||
today - timedelta(days=14), datetime.min.time()
|
||||
),
|
||||
"lte": datetime.combine(today - timedelta(days=8), datetime.max.time()),
|
||||
},
|
||||
}
|
||||
elif date_filter == "last_30_days":
|
||||
return {
|
||||
"current": {
|
||||
"gte": datetime.combine(
|
||||
today - timedelta(days=30), datetime.min.time()
|
||||
),
|
||||
"lte": datetime.combine(today, datetime.max.time()),
|
||||
},
|
||||
"previous": {
|
||||
"gte": datetime.combine(
|
||||
today - timedelta(days=60), datetime.min.time()
|
||||
),
|
||||
"lte": datetime.combine(
|
||||
today - timedelta(days=31), datetime.max.time()
|
||||
),
|
||||
},
|
||||
}
|
||||
elif date_filter == "last_3_months":
|
||||
return {
|
||||
"current": {
|
||||
"gte": datetime.combine(
|
||||
today - timedelta(days=90), datetime.min.time()
|
||||
),
|
||||
"lte": datetime.combine(today, datetime.max.time()),
|
||||
},
|
||||
"previous": {
|
||||
"gte": datetime.combine(
|
||||
today - timedelta(days=180), datetime.min.time()
|
||||
),
|
||||
"lte": datetime.combine(
|
||||
today - timedelta(days=91), datetime.max.time()
|
||||
),
|
||||
},
|
||||
}
|
||||
elif date_filter == "custom" and start_date and end_date:
|
||||
try:
|
||||
start = datetime.strptime(start_date, "%Y-%m-%d").date()
|
||||
end = datetime.strptime(end_date, "%Y-%m-%d").date()
|
||||
return {
|
||||
"current": {
|
||||
"gte": datetime.combine(start, datetime.min.time()),
|
||||
"lte": datetime.combine(end, datetime.max.time()),
|
||||
}
|
||||
}
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def get_chart_period_range(
|
||||
date_filter: Optional[str] = None,
|
||||
) -> Optional[Tuple[date, date]]:
|
||||
"""
|
||||
Get date range for chart visualization.
|
||||
Returns a tuple of (start_date, end_date) for the specified period.
|
||||
|
||||
Args:
|
||||
date_filter (str): The type of date filter to apply. Options are:
|
||||
- "yesterday": Yesterday's date
|
||||
- "last_7_days": Last 7 days
|
||||
- "last_30_days": Last 30 days
|
||||
- "last_3_months": Last 90 days
|
||||
Defaults to "last_7_days" if not specified or invalid.
|
||||
|
||||
Returns:
|
||||
tuple: A tuple containing (start_date, end_date) as date objects
|
||||
"""
|
||||
if not date_filter:
|
||||
return None
|
||||
|
||||
today = timezone.now().date()
|
||||
period_ranges = {
|
||||
"yesterday": (
|
||||
today - timedelta(days=1),
|
||||
today - timedelta(days=1),
|
||||
),
|
||||
"last_7_days": (today - timedelta(days=7), today),
|
||||
"last_30_days": (today - timedelta(days=30), today),
|
||||
"last_3_months": (today - timedelta(days=90), today),
|
||||
}
|
||||
|
||||
return period_ranges.get(date_filter, None)
|
||||
|
||||
|
||||
def get_analytics_filters(
|
||||
slug: str,
|
||||
user: User,
|
||||
type: str,
|
||||
date_filter: Optional[str] = None,
|
||||
project_ids: Optional[Union[str, List[str]]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get combined project and date filters for analytics endpoints
|
||||
|
||||
Args:
|
||||
slug: The workspace slug
|
||||
user: The current user
|
||||
type: The type of filter ("analytics" or "chart")
|
||||
date_filter: Optional date filter string
|
||||
project_ids: Optional list of project IDs or comma-separated string of project IDs
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing:
|
||||
- base_filters: Base filters for the workspace and user
|
||||
- project_filters: Project-specific filters
|
||||
- analytics_date_range: Date range filters for analytics comparison
|
||||
- chart_period_range: Date range for chart visualization
|
||||
"""
|
||||
# Get project IDs from request
|
||||
if project_ids and isinstance(project_ids, str):
|
||||
project_ids = [str(project_id) for project_id in project_ids.split(",")]
|
||||
|
||||
# Base filters for workspace and user
|
||||
base_filters = {
|
||||
"workspace__slug": slug,
|
||||
"project__project_projectmember__member": user,
|
||||
"project__project_projectmember__is_active": True,
|
||||
"project__deleted_at__isnull": True,
|
||||
"project__archived_at__isnull": True,
|
||||
}
|
||||
|
||||
# Project filters
|
||||
project_filters = {
|
||||
"workspace__slug": slug,
|
||||
"project_projectmember__member": user,
|
||||
"project_projectmember__is_active": True,
|
||||
"deleted_at__isnull": True,
|
||||
"archived_at__isnull": True,
|
||||
}
|
||||
|
||||
# Add project IDs to filters if provided
|
||||
if project_ids:
|
||||
base_filters["project_id__in"] = project_ids
|
||||
project_filters["id__in"] = project_ids
|
||||
|
||||
# Initialize date range variables
|
||||
analytics_date_range = None
|
||||
chart_period_range = None
|
||||
|
||||
# Get date range filters based on type
|
||||
if type == "analytics":
|
||||
analytics_date_range = get_analytics_date_range(date_filter)
|
||||
elif type == "chart":
|
||||
chart_period_range = get_chart_period_range(date_filter)
|
||||
|
||||
return {
|
||||
"base_filters": base_filters,
|
||||
"project_filters": project_filters,
|
||||
"analytics_date_range": analytics_date_range,
|
||||
"chart_period_range": chart_period_range,
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user