base
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
USER_JOINED_WORKSPACE = "user_joined_workspace"
|
||||
USER_INVITED_TO_WORKSPACE = "user_invited_to_workspace"
|
||||
WORKSPACE_CREATED = "workspace_created"
|
||||
WORKSPACE_DELETED = "workspace_deleted"
|
||||
@@ -0,0 +1,239 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
from datetime import timedelta
|
||||
from itertools import groupby
|
||||
|
||||
# Django import
|
||||
from django.db import models
|
||||
from django.db.models import Case, CharField, Count, F, Sum, Value, When, FloatField
|
||||
from django.db.models.functions import (
|
||||
Coalesce,
|
||||
Concat,
|
||||
ExtractMonth,
|
||||
ExtractYear,
|
||||
TruncDate,
|
||||
Cast,
|
||||
)
|
||||
from django.utils import timezone
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Issue, Project
|
||||
|
||||
|
||||
def annotate_with_monthly_dimension(queryset, field_name, attribute):
|
||||
# Get the year and the months
|
||||
year = ExtractYear(field_name)
|
||||
month = ExtractMonth(field_name)
|
||||
# Concat the year and month
|
||||
dimension = Concat(year, Value("-"), month, output_field=CharField())
|
||||
# Annotate the dimension
|
||||
return queryset.annotate(**{attribute: dimension})
|
||||
|
||||
|
||||
def extract_axis(queryset, x_axis):
|
||||
# Format the dimension when the axis is in date
|
||||
if x_axis in ["created_at", "start_date", "target_date", "completed_at"]:
|
||||
queryset = annotate_with_monthly_dimension(queryset, x_axis, "dimension")
|
||||
return queryset, "dimension"
|
||||
else:
|
||||
return queryset.annotate(dimension=F(x_axis)), "dimension"
|
||||
|
||||
|
||||
def sort_data(data, temp_axis):
|
||||
# When the axis is in priority order by
|
||||
if temp_axis == "priority":
|
||||
order = ["low", "medium", "high", "urgent", "none"]
|
||||
return {key: data[key] for key in order if key in data}
|
||||
else:
|
||||
return dict(sorted(data.items(), key=lambda x: (x[0] == "none", x[0])))
|
||||
|
||||
|
||||
def build_graph_plot(queryset, x_axis, y_axis, segment=None):
|
||||
# temp x_axis
|
||||
temp_axis = x_axis
|
||||
# Extract the x_axis and queryset
|
||||
queryset, x_axis = extract_axis(queryset, x_axis)
|
||||
if x_axis == "dimension":
|
||||
queryset = queryset.exclude(dimension__isnull=True)
|
||||
|
||||
#
|
||||
if segment in ["created_at", "start_date", "target_date", "completed_at"]:
|
||||
queryset = annotate_with_monthly_dimension(queryset, segment, "segmented")
|
||||
segment = "segmented"
|
||||
|
||||
queryset = queryset.values(x_axis)
|
||||
|
||||
# Issue count
|
||||
if y_axis == "issue_count":
|
||||
queryset = queryset.annotate(
|
||||
is_null=Case(
|
||||
When(dimension__isnull=True, then=Value("None")),
|
||||
default=Value("not_null"),
|
||||
output_field=models.CharField(max_length=8),
|
||||
),
|
||||
dimension_ex=Coalesce("dimension", Value("null")),
|
||||
).values("dimension")
|
||||
queryset = queryset.annotate(segment=F(segment)) if segment else queryset
|
||||
queryset = queryset.values("dimension", "segment") if segment else queryset.values("dimension")
|
||||
queryset = queryset.annotate(count=Count("*")).order_by("dimension")
|
||||
|
||||
# Estimate
|
||||
else:
|
||||
queryset = queryset.annotate(estimate=Sum(Cast("estimate_point__value", FloatField()))).order_by(x_axis)
|
||||
queryset = queryset.annotate(segment=F(segment)) if segment else queryset
|
||||
queryset = (
|
||||
queryset.values("dimension", "segment", "estimate") if segment else queryset.values("dimension", "estimate")
|
||||
)
|
||||
|
||||
result_values = list(queryset)
|
||||
grouped_data = {str(key): list(items) for key, items in groupby(result_values, key=lambda x: x[str("dimension")])}
|
||||
|
||||
return sort_data(grouped_data, temp_axis)
|
||||
|
||||
|
||||
def burndown_plot(queryset, slug, project_id, plot_type, cycle_id=None, module_id=None):
|
||||
# Total Issues in Cycle or Module
|
||||
total_issues = queryset.total_issues
|
||||
# check whether the estimate is a point or not
|
||||
estimate_type = Project.objects.filter(
|
||||
workspace__slug=slug,
|
||||
pk=project_id,
|
||||
estimate__isnull=False,
|
||||
estimate__type="points",
|
||||
).exists()
|
||||
if estimate_type and plot_type == "points" and cycle_id:
|
||||
issue_estimates = Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
estimate_point__isnull=False,
|
||||
).values_list("estimate_point__value", flat=True)
|
||||
|
||||
issue_estimates = [float(value) for value in issue_estimates]
|
||||
total_estimate_points = sum(issue_estimates)
|
||||
|
||||
if estimate_type and plot_type == "points" and module_id:
|
||||
issue_estimates = Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
issue_module__module_id=module_id,
|
||||
issue_module__deleted_at__isnull=True,
|
||||
estimate_point__isnull=False,
|
||||
).values_list("estimate_point__value", flat=True)
|
||||
|
||||
issue_estimates = [float(value) for value in issue_estimates]
|
||||
total_estimate_points = sum(issue_estimates)
|
||||
|
||||
if cycle_id:
|
||||
if queryset.end_date and queryset.start_date:
|
||||
# Get all dates between the two dates
|
||||
date_range = [
|
||||
(queryset.start_date + timedelta(days=x)).date()
|
||||
for x in range((queryset.end_date.date() - queryset.start_date.date()).days + 1)
|
||||
]
|
||||
else:
|
||||
date_range = []
|
||||
|
||||
chart_data = {str(date): 0 for date in date_range}
|
||||
|
||||
if plot_type == "points":
|
||||
completed_issues_estimate_point_distribution = (
|
||||
Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
estimate_point__isnull=False,
|
||||
)
|
||||
.annotate(date=TruncDate("completed_at"))
|
||||
.values("date")
|
||||
.values("date", "estimate_point__value")
|
||||
.order_by("date")
|
||||
)
|
||||
else:
|
||||
completed_issues_distribution = (
|
||||
Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
)
|
||||
.annotate(date=TruncDate("completed_at"))
|
||||
.values("date")
|
||||
.annotate(total_completed=Count("id"))
|
||||
.values("date", "total_completed")
|
||||
.order_by("date")
|
||||
)
|
||||
|
||||
if module_id:
|
||||
# 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)
|
||||
]
|
||||
|
||||
chart_data = {str(date): 0 for date in date_range}
|
||||
|
||||
if plot_type == "points":
|
||||
completed_issues_estimate_point_distribution = (
|
||||
Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
issue_module__module_id=module_id,
|
||||
issue_module__deleted_at__isnull=True,
|
||||
estimate_point__isnull=False,
|
||||
)
|
||||
.annotate(date=TruncDate("completed_at"))
|
||||
.values("date")
|
||||
.values("date", "estimate_point__value")
|
||||
.order_by("date")
|
||||
)
|
||||
else:
|
||||
completed_issues_distribution = (
|
||||
Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
issue_module__module_id=module_id,
|
||||
issue_module__deleted_at__isnull=True,
|
||||
)
|
||||
.annotate(date=TruncDate("completed_at"))
|
||||
.values("date")
|
||||
.annotate(total_completed=Count("id"))
|
||||
.values("date", "total_completed")
|
||||
.order_by("date")
|
||||
)
|
||||
|
||||
if plot_type == "points":
|
||||
for date in date_range:
|
||||
cumulative_pending_issues = total_estimate_points
|
||||
total_completed = 0
|
||||
total_completed = sum(
|
||||
float(item["estimate_point__value"])
|
||||
for item in completed_issues_estimate_point_distribution
|
||||
if item["date"] is not None and item["date"] <= date
|
||||
)
|
||||
cumulative_pending_issues -= total_completed
|
||||
if date > timezone.now().date():
|
||||
chart_data[str(date)] = None
|
||||
else:
|
||||
chart_data[str(date)] = cumulative_pending_issues
|
||||
else:
|
||||
for date in date_range:
|
||||
cumulative_pending_issues = total_issues
|
||||
total_completed = 0
|
||||
total_completed = sum(
|
||||
item["total_completed"]
|
||||
for item in completed_issues_distribution
|
||||
if item["date"] is not None and item["date"] <= date
|
||||
)
|
||||
cumulative_pending_issues -= total_completed
|
||||
if date > timezone.now().date():
|
||||
chart_data[str(date)] = None
|
||||
else:
|
||||
chart_data[str(date)] = cumulative_pending_issues
|
||||
|
||||
return chart_data
|
||||
@@ -0,0 +1,194 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
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__key", "estimate_point__value", 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,88 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
from functools import wraps
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
|
||||
|
||||
def generate_cache_key(custom_path, auth_header=None):
|
||||
"""Generate a cache key with the given params"""
|
||||
if auth_header:
|
||||
key_data = f"{custom_path}:{auth_header}"
|
||||
else:
|
||||
key_data = custom_path
|
||||
return key_data
|
||||
|
||||
|
||||
def cache_response(timeout=60 * 60, path=None, user=True):
|
||||
"""decorator to create cache per user"""
|
||||
|
||||
def decorator(view_func):
|
||||
@wraps(view_func)
|
||||
def _wrapped_view(instance, request, *args, **kwargs):
|
||||
# Function to generate cache key
|
||||
auth_header = None if request.user.is_anonymous else str(request.user.id) if user else None
|
||||
custom_path = path if path is not None else request.get_full_path()
|
||||
key = generate_cache_key(custom_path, auth_header)
|
||||
cached_result = cache.get(key)
|
||||
|
||||
if cached_result is not None:
|
||||
return Response(cached_result["data"], status=cached_result["status"])
|
||||
response = view_func(instance, request, *args, **kwargs)
|
||||
if response.status_code == 200 and not settings.DEBUG:
|
||||
cache.set(
|
||||
key,
|
||||
{"data": response.data, "status": response.status_code},
|
||||
timeout,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
return _wrapped_view
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def invalidate_cache_directly(path=None, url_params=False, user=True, request=None, multiple=False):
|
||||
if url_params and path:
|
||||
path_with_values = path
|
||||
# Assuming `kwargs` could be passed directly if needed, otherwise, skip this part
|
||||
for key, value in request.resolver_match.kwargs.items():
|
||||
path_with_values = path_with_values.replace(f":{key}", str(value))
|
||||
custom_path = path_with_values
|
||||
else:
|
||||
custom_path = path if path is not None else request.get_full_path()
|
||||
auth_header = None if request and request.user.is_anonymous else str(request.user.id) if user else None
|
||||
key = generate_cache_key(custom_path, auth_header)
|
||||
|
||||
if multiple:
|
||||
cache.delete_many(keys=cache.keys(f"*{key}*"))
|
||||
else:
|
||||
cache.delete(key)
|
||||
|
||||
|
||||
def invalidate_cache(path=None, url_params=False, user=True, multiple=False):
|
||||
def decorator(view_func):
|
||||
@wraps(view_func)
|
||||
def _wrapped_view(instance, request, *args, **kwargs):
|
||||
# invalidate the cache
|
||||
invalidate_cache_directly(
|
||||
path=path,
|
||||
url_params=url_params,
|
||||
user=user,
|
||||
request=request,
|
||||
multiple=multiple,
|
||||
)
|
||||
return view_func(instance, request, *args, **kwargs)
|
||||
|
||||
return _wrapped_view
|
||||
|
||||
return decorator
|
||||
@@ -0,0 +1,13 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
import random
|
||||
import string
|
||||
|
||||
|
||||
def get_random_color():
|
||||
"""
|
||||
Get a random color in hex format
|
||||
"""
|
||||
return "#" + "".join(random.choices(string.hexdigits, k=6))
|
||||
@@ -0,0 +1,71 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
RESTRICTED_WORKSPACE_SLUGS = [
|
||||
"404",
|
||||
"accounts",
|
||||
"api",
|
||||
"create-workspace",
|
||||
"god-mode",
|
||||
"installations",
|
||||
"invitations",
|
||||
"onboarding",
|
||||
"profile",
|
||||
"spaces",
|
||||
"workspace-invitations",
|
||||
"password",
|
||||
"flags",
|
||||
"monitor",
|
||||
"monitoring",
|
||||
"ingest",
|
||||
"plane-pro",
|
||||
"plane-ultimate",
|
||||
"enterprise",
|
||||
"plane-enterprise",
|
||||
"disco",
|
||||
"silo",
|
||||
"chat",
|
||||
"calendar",
|
||||
"drive",
|
||||
"channels",
|
||||
"upgrade",
|
||||
"billing",
|
||||
"sign-in",
|
||||
"sign-up",
|
||||
"signin",
|
||||
"signup",
|
||||
"config",
|
||||
"live",
|
||||
"admin",
|
||||
"m",
|
||||
"import",
|
||||
"importers",
|
||||
"integrations",
|
||||
"integration",
|
||||
"configuration",
|
||||
"initiatives",
|
||||
"initiative",
|
||||
"config",
|
||||
"workflow",
|
||||
"workflows",
|
||||
"epics",
|
||||
"epic",
|
||||
"story",
|
||||
"mobile",
|
||||
"dashboard",
|
||||
"desktop",
|
||||
"onload",
|
||||
"real-time",
|
||||
"one",
|
||||
"pages",
|
||||
"mobile",
|
||||
"business",
|
||||
"pro",
|
||||
"settings",
|
||||
"monitor",
|
||||
"license",
|
||||
"licenses",
|
||||
"instances",
|
||||
"instance",
|
||||
]
|
||||
@@ -0,0 +1,243 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
import base64
|
||||
import nh3
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from bs4 import BeautifulSoup
|
||||
from collections import defaultdict
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("plane.api")
|
||||
|
||||
# Maximum allowed size for binary data (10MB)
|
||||
MAX_SIZE = 10 * 1024 * 1024
|
||||
|
||||
# Suspicious patterns for binary data content
|
||||
SUSPICIOUS_BINARY_PATTERNS = [
|
||||
"<html",
|
||||
"<!doctype",
|
||||
"<script",
|
||||
"javascript:",
|
||||
"data:",
|
||||
"<iframe",
|
||||
]
|
||||
|
||||
|
||||
def validate_binary_data(data):
|
||||
"""
|
||||
Validate that binary data appears to be a valid document format
|
||||
and doesn't contain malicious content.
|
||||
|
||||
Args:
|
||||
data (bytes or str): The binary data to validate, or base64-encoded string
|
||||
|
||||
Returns:
|
||||
tuple: (is_valid: bool, error_message: str or None)
|
||||
"""
|
||||
if not data:
|
||||
return True, None # Empty is OK
|
||||
|
||||
# Handle base64-encoded strings by decoding them first
|
||||
if isinstance(data, str):
|
||||
try:
|
||||
binary_data = base64.b64decode(data)
|
||||
except Exception:
|
||||
return False, "Invalid base64 encoding"
|
||||
else:
|
||||
binary_data = data
|
||||
|
||||
# Size check - 10MB limit
|
||||
if len(binary_data) > MAX_SIZE:
|
||||
return False, "Binary data exceeds maximum size limit (10MB)"
|
||||
|
||||
# Basic format validation
|
||||
if len(binary_data) < 4:
|
||||
return False, "Binary data too short to be valid document format"
|
||||
|
||||
# Check for suspicious text patterns (HTML/JS)
|
||||
try:
|
||||
decoded_text = binary_data.decode("utf-8", errors="ignore")[:200]
|
||||
if any(pattern in decoded_text.lower() for pattern in SUSPICIOUS_BINARY_PATTERNS):
|
||||
return False, "Binary data contains suspicious content patterns"
|
||||
except Exception:
|
||||
pass # Binary data might not be decodable as text, which is fine
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
# Combine custom components and editor-specific nodes into a single set of tags
|
||||
CUSTOM_TAGS = {
|
||||
# editor node/tag names
|
||||
"mention-component",
|
||||
"label",
|
||||
"input",
|
||||
"image-component",
|
||||
}
|
||||
ALLOWED_TAGS = nh3.ALLOWED_TAGS | CUSTOM_TAGS
|
||||
|
||||
# Merge nh3 defaults with all attributes used across our custom components
|
||||
ATTRIBUTES = {
|
||||
"*": {
|
||||
"class",
|
||||
"id",
|
||||
"title",
|
||||
"role",
|
||||
"aria-label",
|
||||
"aria-hidden",
|
||||
"style",
|
||||
"start",
|
||||
"type",
|
||||
"xmlns",
|
||||
# common editor data-* attributes seen in stored HTML
|
||||
# (wildcards like data-* are NOT supported by nh3; we add known keys
|
||||
# here and dynamically include all data-* seen in the input below)
|
||||
"data-tight",
|
||||
"data-node-type",
|
||||
"data-type",
|
||||
"data-checked",
|
||||
"data-background-color",
|
||||
"data-text-color",
|
||||
"data-name",
|
||||
"data-id",
|
||||
# callout attributes
|
||||
"data-icon-name",
|
||||
"data-icon-color",
|
||||
"data-background",
|
||||
"data-emoji-unicode",
|
||||
"data-emoji-url",
|
||||
"data-logo-in-use",
|
||||
"data-block-type",
|
||||
},
|
||||
"a": {"href", "target"},
|
||||
# editor node/tag attributes
|
||||
"image-component": {
|
||||
"id",
|
||||
"width",
|
||||
"height",
|
||||
"aspectRatio",
|
||||
"aspectratio",
|
||||
"src",
|
||||
"alignment",
|
||||
"status",
|
||||
},
|
||||
"img": {
|
||||
"width",
|
||||
"height",
|
||||
"aspectRatio",
|
||||
"aspectratio",
|
||||
"alignment",
|
||||
"src",
|
||||
"alt",
|
||||
"title",
|
||||
},
|
||||
"mention-component": {"id", "entity_identifier", "entity_name"},
|
||||
"th": {
|
||||
"colspan",
|
||||
"rowspan",
|
||||
"colwidth",
|
||||
"background",
|
||||
"style",
|
||||
},
|
||||
"td": {
|
||||
"colspan",
|
||||
"rowspan",
|
||||
"colwidth",
|
||||
"background",
|
||||
"textColor",
|
||||
"textcolor",
|
||||
"style",
|
||||
},
|
||||
"tr": {"background", "textColor", "textcolor", "style"},
|
||||
"pre": {"language"},
|
||||
"code": {"language", "spellcheck"},
|
||||
"input": {"type", "checked"},
|
||||
}
|
||||
|
||||
SAFE_PROTOCOLS = {"http", "https", "mailto", "tel"}
|
||||
|
||||
|
||||
def _compute_html_sanitization_diff(before_html: str, after_html: str):
|
||||
"""
|
||||
Compute a coarse diff between original and sanitized HTML.
|
||||
|
||||
Returns a dict with:
|
||||
- removed_tags: mapping[tag] -> removed_count
|
||||
- removed_attributes: mapping[tag] -> sorted list of attribute names removed
|
||||
"""
|
||||
try:
|
||||
|
||||
def collect(soup):
|
||||
tag_counts = defaultdict(int)
|
||||
attrs_by_tag = defaultdict(set)
|
||||
for el in soup.find_all(True):
|
||||
tag_name = (el.name or "").lower()
|
||||
if not tag_name:
|
||||
continue
|
||||
tag_counts[tag_name] += 1
|
||||
for attr_name in list(el.attrs.keys()):
|
||||
if isinstance(attr_name, str) and attr_name:
|
||||
attrs_by_tag[tag_name].add(attr_name.lower())
|
||||
return tag_counts, attrs_by_tag
|
||||
|
||||
soup_before = BeautifulSoup(before_html or "", "html.parser")
|
||||
soup_after = BeautifulSoup(after_html or "", "html.parser")
|
||||
|
||||
counts_before, attrs_before = collect(soup_before)
|
||||
counts_after, attrs_after = collect(soup_after)
|
||||
|
||||
removed_tags = {}
|
||||
for tag, cnt_before in counts_before.items():
|
||||
cnt_after = counts_after.get(tag, 0)
|
||||
if cnt_after < cnt_before:
|
||||
removed = cnt_before - cnt_after
|
||||
removed_tags[tag] = removed
|
||||
|
||||
removed_attributes = {}
|
||||
for tag, before_set in attrs_before.items():
|
||||
after_set = attrs_after.get(tag, set())
|
||||
removed = before_set - after_set
|
||||
if removed:
|
||||
removed_attributes[tag] = sorted(list(removed))
|
||||
|
||||
return {"removed_tags": removed_tags, "removed_attributes": removed_attributes}
|
||||
except Exception:
|
||||
# Best-effort only; if diffing fails we don't block the request
|
||||
return {"removed_tags": {}, "removed_attributes": {}}
|
||||
|
||||
|
||||
def validate_html_content(html_content: str):
|
||||
"""
|
||||
Sanitize HTML content using nh3.
|
||||
Returns a tuple: (is_valid, error_message, clean_html)
|
||||
"""
|
||||
if not html_content:
|
||||
return True, None, None
|
||||
|
||||
# Size check - 10MB limit (consistent with binary validation)
|
||||
if len(html_content.encode("utf-8")) > MAX_SIZE:
|
||||
return False, "HTML content exceeds maximum size limit (10MB)", None
|
||||
|
||||
try:
|
||||
clean_html = nh3.clean(
|
||||
html_content,
|
||||
tags=ALLOWED_TAGS,
|
||||
attributes=ATTRIBUTES,
|
||||
url_schemes=SAFE_PROTOCOLS,
|
||||
)
|
||||
# Report removals to logger (Sentry) if anything was stripped
|
||||
diff = _compute_html_sanitization_diff(html_content, clean_html)
|
||||
if diff.get("removed_tags") or diff.get("removed_attributes"):
|
||||
try:
|
||||
import json
|
||||
|
||||
summary = json.dumps(diff)
|
||||
except Exception:
|
||||
summary = str(diff)
|
||||
logger.warning(f"HTML sanitization removals: {summary}")
|
||||
return True, None, clean_html
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return False, "Failed to sanitize HTML", None
|
||||
@@ -0,0 +1,25 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""
|
||||
Core utilities for Plane database routing and request scoping.
|
||||
This package contains essential components for managing read replica routing
|
||||
and request-scoped context in the Plane application.
|
||||
"""
|
||||
|
||||
from .dbrouters import ReadReplicaRouter
|
||||
from .mixins import ReadReplicaControlMixin
|
||||
from .request_scope import (
|
||||
set_use_read_replica,
|
||||
should_use_read_replica,
|
||||
clear_read_replica_context,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ReadReplicaRouter",
|
||||
"ReadReplicaControlMixin",
|
||||
"set_use_read_replica",
|
||||
"should_use_read_replica",
|
||||
"clear_read_replica_context",
|
||||
]
|
||||
@@ -0,0 +1,75 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""
|
||||
Database router for read replica selection.
|
||||
This router determines which database to use for read/write operations
|
||||
based on the request context set by the ReadReplicaRoutingMiddleware.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Type
|
||||
|
||||
from django.db import models
|
||||
|
||||
from .request_scope import should_use_read_replica
|
||||
|
||||
logger = logging.getLogger("plane.db")
|
||||
|
||||
|
||||
class ReadReplicaRouter:
|
||||
"""
|
||||
Database router that directs read operations to replica when appropriate.
|
||||
This router works in conjunction with ReadReplicaRoutingMiddleware to:
|
||||
- Route read operations to replica database when request context allows
|
||||
- Always route write operations to primary database
|
||||
- Ensure migrations only run on primary database
|
||||
"""
|
||||
|
||||
def db_for_read(self, model: Type[models.Model], **hints) -> str:
|
||||
"""
|
||||
Determine which database to use for read operations.
|
||||
Args:
|
||||
model: The Django model class being queried
|
||||
**hints: Additional routing hints
|
||||
Returns:
|
||||
str: Database alias ('replica' or 'default')
|
||||
"""
|
||||
if should_use_read_replica():
|
||||
logger.debug(f"Routing read for {model._meta.label} to replica database")
|
||||
return "replica"
|
||||
else:
|
||||
logger.debug(f"Routing read for {model._meta.label} to primary database")
|
||||
return "default"
|
||||
|
||||
def db_for_write(self, model: Type[models.Model], **hints) -> str:
|
||||
"""
|
||||
Determine which database to use for write operations.
|
||||
All write operations always go to the primary database to ensure
|
||||
data consistency and avoid replication lag issues.
|
||||
Args:
|
||||
model: The Django model class being written to
|
||||
**hints: Additional routing hints
|
||||
Returns:
|
||||
str: Always returns 'default' (primary database)
|
||||
"""
|
||||
logger.debug(f"Routing write for {model._meta.label} to primary database")
|
||||
return "default"
|
||||
|
||||
def allow_migrate(self, db: str, app_label: str, model_name: str = None, **hints) -> bool:
|
||||
"""
|
||||
Ensure migrations only run on the primary database.
|
||||
Args:
|
||||
db: Database alias
|
||||
app_label: Application label
|
||||
model_name: Model name (optional)
|
||||
**hints: Additional routing hints
|
||||
Returns:
|
||||
bool: True if migration is allowed on this database
|
||||
"""
|
||||
# Only allow migrations on the primary database
|
||||
allowed = db == "default"
|
||||
if not allowed:
|
||||
logger.debug(f"Blocking migration for {app_label} on {db} database")
|
||||
return allowed
|
||||
@@ -0,0 +1,15 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""
|
||||
Core mixins for read replica functionality.
|
||||
This package provides mixins for different aspects of read replica management
|
||||
in Django and Django REST Framework applications.
|
||||
"""
|
||||
|
||||
from .view import ReadReplicaControlMixin
|
||||
|
||||
__all__ = [
|
||||
"ReadReplicaControlMixin",
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""
|
||||
Mixins for Django REST Framework views.
|
||||
"""
|
||||
|
||||
|
||||
class ReadReplicaControlMixin:
|
||||
"""
|
||||
Mixin to control read replica usage in DRF views.
|
||||
Set use_read_replica = True/False to route read operations to
|
||||
replica/primary database. Works with ReadReplicaRoutingMiddleware.
|
||||
Usage:
|
||||
class MyViewSet(ReadReplicaControlMixin, ModelViewSet):
|
||||
use_read_replica = True # Use replica for GET requests
|
||||
Note:
|
||||
- Only affects GET, HEAD, OPTIONS requests
|
||||
- Write operations always use primary database
|
||||
- Defaults to True for safe replica usage
|
||||
"""
|
||||
|
||||
use_read_replica: bool = True
|
||||
@@ -0,0 +1,76 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""
|
||||
Database routing utilities for read replica selection.
|
||||
This module provides request-scoped context management for database routing,
|
||||
specifically for determining when to use read replicas vs primary database.
|
||||
Used in conjunction with middleware and DRF views that set use_read_replica=True.
|
||||
The context is maintained per request to ensure proper isolation between
|
||||
concurrent requests in async environments.
|
||||
"""
|
||||
|
||||
from asgiref.local import Local
|
||||
|
||||
__all__ = [
|
||||
"set_use_read_replica",
|
||||
"should_use_read_replica",
|
||||
"clear_read_replica_context",
|
||||
]
|
||||
|
||||
# Request-scoped context storage for database routing preferences
|
||||
# Uses asgiref.local.Local which provides ContextVar under the hood
|
||||
# This ensures proper context isolation per request in async environments
|
||||
_db_routing_context = Local()
|
||||
|
||||
|
||||
def set_use_read_replica(use_replica: bool) -> None:
|
||||
"""
|
||||
Mark the current request context to use read replica database.
|
||||
This function sets a request-scoped flag that determines database routing.
|
||||
The context is isolated per request to ensure thread safety in async environments.
|
||||
This function is typically called from:
|
||||
- Middleware that detects read-only operations
|
||||
- DRF views with use_read_replica=True attribute
|
||||
- API endpoints that only perform read operations
|
||||
Args:
|
||||
use_replica (bool): True to route database queries to read replica,
|
||||
False to use primary database
|
||||
Note:
|
||||
The context is automatically isolated per request and should be
|
||||
cleared at the end of each request using clear_read_replica_context().
|
||||
"""
|
||||
_db_routing_context.use_read_replica = bool(use_replica)
|
||||
|
||||
|
||||
def should_use_read_replica() -> bool:
|
||||
"""
|
||||
Check if the current request should use read replica database.
|
||||
This function reads the request-scoped context to determine database routing.
|
||||
It's called by the database router to decide which connection to use.
|
||||
Returns:
|
||||
bool: True if queries should be routed to read replica,
|
||||
False if they should use primary database (default)
|
||||
Note:
|
||||
Returns False by default if no context is set for the current request.
|
||||
The context is automatically isolated per request.
|
||||
"""
|
||||
return getattr(_db_routing_context, "use_read_replica", False)
|
||||
|
||||
|
||||
def clear_read_replica_context() -> None:
|
||||
"""
|
||||
Clear the read replica context for the current request.
|
||||
This function should be called at the end of each request to ensure
|
||||
that context doesn't leak between requests. Typically called from
|
||||
middleware during request cleanup.
|
||||
This is important for:
|
||||
- Preventing context leakage between requests
|
||||
- Ensuring clean state for each new request
|
||||
- Proper memory management in long-running processes
|
||||
"""
|
||||
try:
|
||||
delattr(_db_routing_context, "use_read_replica")
|
||||
except AttributeError:
|
||||
pass
|
||||
@@ -0,0 +1,26 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# CSV utility functions for safe export
|
||||
# Characters that trigger formula evaluation in spreadsheet applications
|
||||
_CSV_FORMULA_TRIGGERS = frozenset(("=", "+", "-", "@", "\t", "\r", "\n"))
|
||||
|
||||
|
||||
def sanitize_csv_value(value):
|
||||
"""Sanitize a value for CSV export to prevent formula injection.
|
||||
|
||||
Prefixes string values starting with formula-triggering characters
|
||||
with a single quote so spreadsheet applications treat them as text
|
||||
instead of evaluating them as formulas.
|
||||
|
||||
See: https://owasp.org/www-community/attacks/CSV_Injection
|
||||
"""
|
||||
if isinstance(value, str) and value and value[0] in _CSV_FORMULA_TRIGGERS:
|
||||
return "'" + value
|
||||
return value
|
||||
|
||||
|
||||
def sanitize_csv_row(row):
|
||||
"""Sanitize all values in a CSV row."""
|
||||
return [sanitize_csv_value(v) for v in row]
|
||||
@@ -0,0 +1,478 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
import json
|
||||
|
||||
# Django imports
|
||||
from django.db.models import (
|
||||
Case,
|
||||
Count,
|
||||
F,
|
||||
Q,
|
||||
Sum,
|
||||
FloatField,
|
||||
Value,
|
||||
When,
|
||||
)
|
||||
from django.db import models
|
||||
from django.db.models.functions import Cast, Concat
|
||||
from django.utils import timezone
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import (
|
||||
Cycle,
|
||||
CycleIssue,
|
||||
Issue,
|
||||
Project,
|
||||
)
|
||||
from plane.utils.analytics_plot import burndown_plot
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
def transfer_cycle_issues(
|
||||
slug,
|
||||
project_id,
|
||||
cycle_id,
|
||||
new_cycle_id,
|
||||
request,
|
||||
user_id,
|
||||
):
|
||||
"""
|
||||
Transfer incomplete issues from one cycle to another and create progress snapshot.
|
||||
|
||||
Args:
|
||||
slug: Workspace slug
|
||||
project_id: Project ID
|
||||
cycle_id: Source cycle ID
|
||||
new_cycle_id: Destination cycle ID
|
||||
request: HTTP request object
|
||||
user_id: User ID performing the transfer
|
||||
|
||||
Returns:
|
||||
dict: Response data with success or error message
|
||||
"""
|
||||
# Get the new cycle
|
||||
new_cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=new_cycle_id).first()
|
||||
|
||||
# Check if new cycle is already completed
|
||||
if new_cycle.end_date is not None and new_cycle.end_date < timezone.now():
|
||||
return {
|
||||
"success": False,
|
||||
"error": "The cycle where the issues are transferred is already completed",
|
||||
}
|
||||
|
||||
# Get the old cycle with issue counts
|
||||
old_cycle = (
|
||||
Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id)
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"issue_cycle",
|
||||
filter=Q(
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
issue_cycle__issue__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="completed",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__issue__deleted_at__isnull=True,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cancelled_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="cancelled",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__issue__deleted_at__isnull=True,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="started",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__issue__deleted_at__isnull=True,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
unstarted_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="unstarted",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__issue__deleted_at__isnull=True,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
backlog_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="backlog",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__issue__deleted_at__isnull=True,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
old_cycle = old_cycle.first()
|
||||
|
||||
if old_cycle is None:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Source cycle not found",
|
||||
}
|
||||
|
||||
# Check if project uses estimates
|
||||
estimate_type = Project.objects.filter(
|
||||
workspace__slug=slug,
|
||||
pk=project_id,
|
||||
estimate__isnull=False,
|
||||
estimate__type="points",
|
||||
).exists()
|
||||
|
||||
# Initialize estimate distribution variables
|
||||
assignee_estimate_distribution = []
|
||||
label_estimate_distribution = []
|
||||
estimate_completion_chart = {}
|
||||
|
||||
if estimate_type:
|
||||
assignee_estimate_data = (
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(display_name=F("assignees__display_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.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",
|
||||
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(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("display_name")
|
||||
)
|
||||
# Assignee estimate distribution serialization
|
||||
assignee_estimate_distribution = [
|
||||
{
|
||||
"display_name": item["display_name"],
|
||||
"assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None),
|
||||
"avatar_url": item.get("avatar_url"),
|
||||
"total_estimates": item["total_estimates"],
|
||||
"completed_estimates": item["completed_estimates"],
|
||||
"pending_estimates": item["pending_estimates"],
|
||||
}
|
||||
for item in assignee_estimate_data
|
||||
]
|
||||
|
||||
label_distribution_data = (
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(label_name=F("labels__name"))
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("label_name")
|
||||
)
|
||||
|
||||
estimate_completion_chart = burndown_plot(
|
||||
queryset=old_cycle,
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
plot_type="points",
|
||||
cycle_id=cycle_id,
|
||||
)
|
||||
# Label estimate distribution serialization
|
||||
label_estimate_distribution = [
|
||||
{
|
||||
"label_name": item["label_name"],
|
||||
"color": item["color"],
|
||||
"label_id": (str(item["label_id"]) if item["label_id"] else None),
|
||||
"total_estimates": item["total_estimates"],
|
||||
"completed_estimates": item["completed_estimates"],
|
||||
"pending_estimates": item["pending_estimates"],
|
||||
}
|
||||
for item in label_distribution_data
|
||||
]
|
||||
|
||||
# Get the assignee distribution
|
||||
assignee_distribution = (
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(display_name=F("assignees__display_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.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",
|
||||
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(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False)))
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("display_name")
|
||||
)
|
||||
# Assignee distribution serialized
|
||||
assignee_distribution_data = [
|
||||
{
|
||||
"display_name": item["display_name"],
|
||||
"assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None),
|
||||
"avatar_url": item.get("avatar_url"),
|
||||
"total_issues": item["total_issues"],
|
||||
"completed_issues": item["completed_issues"],
|
||||
"pending_issues": item["pending_issues"],
|
||||
}
|
||||
for item in assignee_distribution
|
||||
]
|
||||
|
||||
# Get the label distribution
|
||||
label_distribution = (
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(label_name=F("labels__name"))
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False)))
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("label_name")
|
||||
)
|
||||
|
||||
# Label distribution serialization
|
||||
label_distribution_data = [
|
||||
{
|
||||
"label_name": item["label_name"],
|
||||
"color": item["color"],
|
||||
"label_id": (str(item["label_id"]) if item["label_id"] else None),
|
||||
"total_issues": item["total_issues"],
|
||||
"completed_issues": item["completed_issues"],
|
||||
"pending_issues": item["pending_issues"],
|
||||
}
|
||||
for item in label_distribution
|
||||
]
|
||||
|
||||
# Generate completion chart
|
||||
completion_chart = burndown_plot(
|
||||
queryset=old_cycle,
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
plot_type="issues",
|
||||
cycle_id=cycle_id,
|
||||
)
|
||||
|
||||
# Get the current cycle and save progress snapshot
|
||||
current_cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id).first()
|
||||
|
||||
current_cycle.progress_snapshot = {
|
||||
"total_issues": old_cycle.total_issues,
|
||||
"completed_issues": old_cycle.completed_issues,
|
||||
"cancelled_issues": old_cycle.cancelled_issues,
|
||||
"started_issues": old_cycle.started_issues,
|
||||
"unstarted_issues": old_cycle.unstarted_issues,
|
||||
"backlog_issues": old_cycle.backlog_issues,
|
||||
"distribution": {
|
||||
"labels": label_distribution_data,
|
||||
"assignees": assignee_distribution_data,
|
||||
"completion_chart": completion_chart,
|
||||
},
|
||||
"estimate_distribution": (
|
||||
{}
|
||||
if not estimate_type
|
||||
else {
|
||||
"labels": label_estimate_distribution,
|
||||
"assignees": assignee_estimate_distribution,
|
||||
"completion_chart": estimate_completion_chart,
|
||||
}
|
||||
),
|
||||
}
|
||||
current_cycle.save(update_fields=["progress_snapshot"])
|
||||
|
||||
# Get issues to transfer (only incomplete issues)
|
||||
cycle_issues = CycleIssue.objects.filter(
|
||||
cycle_id=cycle_id,
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
issue__archived_at__isnull=True,
|
||||
issue__is_draft=False,
|
||||
issue__state__group__in=["backlog", "unstarted", "started"],
|
||||
)
|
||||
|
||||
updated_cycles = []
|
||||
update_cycle_issue_activity = []
|
||||
for cycle_issue in cycle_issues:
|
||||
cycle_issue.cycle_id = new_cycle_id
|
||||
updated_cycles.append(cycle_issue)
|
||||
update_cycle_issue_activity.append(
|
||||
{
|
||||
"old_cycle_id": str(cycle_id),
|
||||
"new_cycle_id": str(new_cycle_id),
|
||||
"issue_id": str(cycle_issue.issue_id),
|
||||
}
|
||||
)
|
||||
|
||||
# Bulk update cycle issues
|
||||
cycle_issues = CycleIssue.objects.bulk_update(updated_cycles, ["cycle_id"], batch_size=100)
|
||||
|
||||
# Capture Issue Activity
|
||||
issue_activity.delay(
|
||||
type="cycle.activity.created",
|
||||
requested_data=json.dumps({"cycles_list": []}),
|
||||
actor_id=str(user_id),
|
||||
issue_id=None,
|
||||
project_id=str(project_id),
|
||||
current_instance=json.dumps(
|
||||
{
|
||||
"updated_cycle_issues": update_cycle_issue_activity,
|
||||
"created_cycle_issues": [],
|
||||
}
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
|
||||
return {"success": True}
|
||||
@@ -0,0 +1,191 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
# SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
|
||||
# SPDX-License-Identifier: LicenseRef-Plane-Commercial
|
||||
#
|
||||
# Licensed under the Plane Commercial License (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
# https://plane.so/legals/eula
|
||||
#
|
||||
# DO NOT remove or modify this notice.
|
||||
# NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
|
||||
|
||||
# Python imports
|
||||
import re
|
||||
|
||||
# Django imports
|
||||
from django.utils.html import strip_tags
|
||||
|
||||
|
||||
def generate_plain_text_from_html(html_content):
|
||||
"""
|
||||
Generate clean plain text from HTML email template.
|
||||
Removes all HTML tags, CSS styles, and excessive whitespace.
|
||||
|
||||
Args:
|
||||
html_content (str): The HTML content to convert to plain text
|
||||
|
||||
Returns:
|
||||
str: Clean plain text without HTML tags, styles, or excessive whitespace
|
||||
"""
|
||||
# Remove style tags and their content
|
||||
html_content = re.sub(r"<style[^>]*>.*?</style>", "", html_content, flags=re.DOTALL | re.IGNORECASE)
|
||||
|
||||
# Strip HTML tags
|
||||
text_content = strip_tags(html_content)
|
||||
|
||||
# Remove excessive empty lines
|
||||
text_content = re.sub(r"\n\s*\n\s*\n+", "\n\n", text_content)
|
||||
|
||||
# Ensure there's a leading and trailing whitespace
|
||||
text_content = "\n\n" + text_content.lstrip().rstrip() + "\n\n"
|
||||
|
||||
return text_content
|
||||
@@ -0,0 +1,14 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
ERROR_CODES = {
|
||||
# issues
|
||||
"INVALID_ARCHIVE_STATE_GROUP": 4091,
|
||||
"INVALID_ISSUE_DATES": 4100,
|
||||
"INVALID_ISSUE_START_DATE": 4101,
|
||||
"INVALID_ISSUE_TARGET_DATE": 4102,
|
||||
# pages
|
||||
"PAGE_LOCKED": 4701,
|
||||
"PAGE_ARCHIVED": 4702,
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
import logging
|
||||
import traceback
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
def log_exception(e, warning=False):
|
||||
# Log the error
|
||||
logger = logging.getLogger("plane.exception")
|
||||
|
||||
if warning:
|
||||
logger.warning(str(e))
|
||||
else:
|
||||
logger.exception(e)
|
||||
|
||||
if settings.DEBUG:
|
||||
logger.debug(traceback.format_exc())
|
||||
return
|
||||
@@ -0,0 +1,496 @@
|
||||
# 📊 Exporters
|
||||
|
||||
A flexible and extensible data export utility for exporting Django model data in multiple formats (CSV, JSON, XLSX).
|
||||
|
||||
## 🎯 Overview
|
||||
|
||||
The exporters module provides a schema-based approach to exporting data with support for:
|
||||
|
||||
- **📄 Multiple formats**: CSV, JSON, and XLSX (Excel)
|
||||
- **🔒 Type-safe field definitions**: StringField, NumberField, DateField, DateTimeField, BooleanField, ListField, JSONField
|
||||
- **⚡ Custom transformations**: Field-level transformations and custom preparer methods
|
||||
- **🔗 Dotted path notation**: Easy access to nested attributes and related models
|
||||
- **🎨 Format-specific handling**: Automatic formatting based on export format (e.g., lists as arrays in JSON, comma-separated in CSV)
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```python
|
||||
from plane.utils.exporters import Exporter, ExportSchema, StringField, NumberField
|
||||
|
||||
# Define a schema
|
||||
class UserExportSchema(ExportSchema):
|
||||
name = StringField(source="username", label="User Name")
|
||||
email = StringField(source="email", label="Email Address")
|
||||
posts_count = NumberField(label="Total Posts")
|
||||
|
||||
def prepare_posts_count(self, obj):
|
||||
return obj.posts.count()
|
||||
|
||||
# Export data - just pass the queryset!
|
||||
users = User.objects.all()
|
||||
exporter = Exporter(format_type="csv", schema_class=UserExportSchema)
|
||||
filename, content = exporter.export("users_export", users)
|
||||
```
|
||||
|
||||
### Exporting Issues
|
||||
|
||||
```python
|
||||
from plane.utils.exporters import Exporter, IssueExportSchema
|
||||
|
||||
# Get issues with prefetched relations
|
||||
issues = Issue.objects.filter(project_id=project_id).prefetch_related(
|
||||
'assignee_details',
|
||||
'label_details',
|
||||
'issue_module',
|
||||
# ... other relations
|
||||
)
|
||||
|
||||
# Export as XLSX - pass the queryset directly!
|
||||
exporter = Exporter(format_type="xlsx", schema_class=IssueExportSchema)
|
||||
filename, content = exporter.export("issues", issues)
|
||||
|
||||
# Export with custom fields only
|
||||
exporter = Exporter(format_type="json", schema_class=IssueExportSchema)
|
||||
filename, content = exporter.export("issues_filtered", issues, fields=["id", "name", "state_name", "assignees"])
|
||||
```
|
||||
|
||||
### Exporting Multiple Projects Separately
|
||||
|
||||
```python
|
||||
# Export each project to a separate file
|
||||
for project_id in project_ids:
|
||||
project_issues = issues.filter(project_id=project_id)
|
||||
exporter = Exporter(format_type="csv", schema_class=IssueExportSchema)
|
||||
filename, content = exporter.export(f"issues-{project_id}", project_issues)
|
||||
# Save or upload the file
|
||||
```
|
||||
|
||||
## 📝 Schema Definition
|
||||
|
||||
### Field Types
|
||||
|
||||
#### 📝 StringField
|
||||
|
||||
Converts values to strings.
|
||||
|
||||
```python
|
||||
name = StringField(source="name", label="Name", default="N/A")
|
||||
```
|
||||
|
||||
#### 🔢 NumberField
|
||||
|
||||
Handles numeric values (int, float).
|
||||
|
||||
```python
|
||||
count = NumberField(source="items_count", label="Count", default=0)
|
||||
```
|
||||
|
||||
#### 📅 DateField
|
||||
|
||||
Formats date objects as `%a, %d %b %Y` (e.g., "Mon, 01 Jan 2024").
|
||||
|
||||
```python
|
||||
start_date = DateField(source="start_date", label="Start Date")
|
||||
```
|
||||
|
||||
#### ⏰ DateTimeField
|
||||
|
||||
Formats datetime objects as `%a, %d %b %Y %I:%M:%S %Z%z`.
|
||||
|
||||
```python
|
||||
created_at = DateTimeField(source="created_at", label="Created At")
|
||||
```
|
||||
|
||||
#### ✅ BooleanField
|
||||
|
||||
Converts values to boolean.
|
||||
|
||||
```python
|
||||
is_active = BooleanField(source="is_active", label="Active", default=False)
|
||||
```
|
||||
|
||||
#### 📋 ListField
|
||||
|
||||
Handles list/array values. In CSV/XLSX, lists are joined with a separator (default: `", "`). In JSON, they remain as arrays.
|
||||
|
||||
```python
|
||||
tags = ListField(source="tags", label="Tags")
|
||||
assignees = ListField(label="Assignees") # Custom preparer can populate this
|
||||
```
|
||||
|
||||
#### 🗂️ JSONField
|
||||
|
||||
Handles complex JSON-serializable objects (dicts, lists of dicts). In CSV/XLSX, they're serialized as JSON strings. In JSON, they remain as objects.
|
||||
|
||||
```python
|
||||
metadata = JSONField(source="metadata", label="Metadata")
|
||||
comments = JSONField(label="Comments")
|
||||
```
|
||||
|
||||
### ⚙️ Field Parameters
|
||||
|
||||
All field types support these parameters:
|
||||
|
||||
- **`source`**: Dotted path string to the attribute (e.g., `"project.name"`)
|
||||
- **`default`**: Default value when field is None
|
||||
- **`label`**: Display name in export headers
|
||||
|
||||
### 🔗 Dotted Path Notation
|
||||
|
||||
Access nested attributes using dot notation:
|
||||
|
||||
```python
|
||||
project_name = StringField(source="project.name", label="Project")
|
||||
owner_email = StringField(source="created_by.email", label="Owner Email")
|
||||
```
|
||||
|
||||
### 🎯 Custom Preparers
|
||||
|
||||
For complex logic, define `prepare_{field_name}` methods:
|
||||
|
||||
```python
|
||||
class MySchema(ExportSchema):
|
||||
assignees = ListField(label="Assignees")
|
||||
|
||||
def prepare_assignees(self, obj):
|
||||
return [f"{u.first_name} {u.last_name}" for u in obj.assignee_details]
|
||||
```
|
||||
|
||||
Preparers take precedence over field definitions.
|
||||
|
||||
### ⚡ Custom Transformations with Preparer Methods
|
||||
|
||||
For any custom logic or transformations, use `prepare_<field_name>` methods:
|
||||
|
||||
```python
|
||||
class MySchema(ExportSchema):
|
||||
name = StringField(source="name", label="Name (Uppercase)")
|
||||
status = StringField(label="Status")
|
||||
|
||||
def prepare_name(self, obj):
|
||||
"""Transform the name field to uppercase."""
|
||||
return obj.name.upper() if obj.name else ""
|
||||
|
||||
def prepare_status(self, obj):
|
||||
"""Compute status based on model state."""
|
||||
return "Active" if obj.is_active else "Inactive"
|
||||
```
|
||||
|
||||
## 📦 Export Formats
|
||||
|
||||
### 📊 CSV Format
|
||||
|
||||
- Fields are quoted with `QUOTE_ALL`
|
||||
- Lists are joined with `", "` (customizable with `list_joiner` option)
|
||||
- JSON objects are serialized as JSON strings
|
||||
- File extension: `.csv`
|
||||
|
||||
```python
|
||||
exporter = Exporter(
|
||||
format_type="csv",
|
||||
schema_class=MySchema,
|
||||
options={"list_joiner": "; "} # Custom separator
|
||||
)
|
||||
```
|
||||
|
||||
### 📋 JSON Format
|
||||
|
||||
- Lists remain as arrays
|
||||
- Objects remain as nested structures
|
||||
- Preserves data types
|
||||
- File extension: `.json`
|
||||
|
||||
```python
|
||||
exporter = Exporter(format_type="json", schema_class=MySchema)
|
||||
filename, content = exporter.export("data", records)
|
||||
# content is a JSON string: '[{"field": "value"}, ...]'
|
||||
```
|
||||
|
||||
### 📗 XLSX Format
|
||||
|
||||
- Creates Excel-compatible files using openpyxl
|
||||
- Lists are joined with `", "` (customizable with `list_joiner` option)
|
||||
- JSON objects are serialized as JSON strings
|
||||
- File extension: `.xlsx`
|
||||
- Returns binary content (bytes)
|
||||
|
||||
```python
|
||||
exporter = Exporter(format_type="xlsx", schema_class=MySchema)
|
||||
filename, content = exporter.export("data", records)
|
||||
# content is bytes
|
||||
```
|
||||
|
||||
## 🔧 Advanced Usage
|
||||
|
||||
### 📦 Using Context for Pre-fetched Data
|
||||
|
||||
Pass context data to schemas to avoid N+1 queries. Override `get_context_data()` in your schema:
|
||||
|
||||
```python
|
||||
class MySchema(ExportSchema):
|
||||
attachment_count = NumberField(label="Attachments")
|
||||
|
||||
def prepare_attachment_count(self, obj):
|
||||
attachments_dict = self.context.get("attachments_dict", {})
|
||||
return len(attachments_dict.get(obj.id, []))
|
||||
|
||||
@classmethod
|
||||
def get_context_data(cls, queryset):
|
||||
"""Pre-fetch all attachments in one query."""
|
||||
attachments_dict = get_attachments_dict(queryset)
|
||||
return {"attachments_dict": attachments_dict}
|
||||
|
||||
# The Exporter automatically uses get_context_data() when serializing
|
||||
queryset = MyModel.objects.all()
|
||||
exporter = Exporter(format_type="csv", schema_class=MySchema)
|
||||
filename, content = exporter.export("data", queryset)
|
||||
```
|
||||
|
||||
### 🔌 Registering Custom Formatters
|
||||
|
||||
Add support for new export formats:
|
||||
|
||||
```python
|
||||
from plane.utils.exporters import Exporter, BaseFormatter
|
||||
|
||||
class XMLFormatter(BaseFormatter):
|
||||
def format(self, filename, records, schema_class, options=None):
|
||||
# Implementation
|
||||
return (f"{filename}.xml", xml_content)
|
||||
|
||||
# Register the formatter
|
||||
Exporter.register_formatter("xml", XMLFormatter)
|
||||
|
||||
# Use it
|
||||
exporter = Exporter(format_type="xml", schema_class=MySchema)
|
||||
```
|
||||
|
||||
### ✅ Checking Available Formats
|
||||
|
||||
```python
|
||||
formats = Exporter.get_available_formats()
|
||||
# Returns: ['csv', 'json', 'xlsx']
|
||||
```
|
||||
|
||||
### 🔍 Filtering Fields
|
||||
|
||||
Pass a `fields` parameter to export only specific fields:
|
||||
|
||||
```python
|
||||
# Export only specific fields
|
||||
exporter = Exporter(format_type="csv", schema_class=MySchema)
|
||||
filename, content = exporter.export(
|
||||
"filtered_data",
|
||||
queryset,
|
||||
fields=["id", "name", "email"]
|
||||
)
|
||||
```
|
||||
|
||||
### 🎯 Extending Schemas
|
||||
|
||||
Create extended schemas by inheriting from existing ones and overriding `get_context_data()`:
|
||||
|
||||
```python
|
||||
class ExtendedIssueExportSchema(IssueExportSchema):
|
||||
custom_field = JSONField(label="Custom Data")
|
||||
|
||||
def prepare_custom_field(self, obj):
|
||||
# Use pre-fetched data from context
|
||||
return self.context.get("custom_data", {}).get(obj.id, {})
|
||||
|
||||
@classmethod
|
||||
def get_context_data(cls, queryset):
|
||||
# Get parent context (attachments, etc.)
|
||||
context = super().get_context_data(queryset)
|
||||
|
||||
# Add your custom pre-fetched data
|
||||
context["custom_data"] = fetch_custom_data(queryset)
|
||||
|
||||
return context
|
||||
```
|
||||
|
||||
### 💾 Manual Serialization
|
||||
|
||||
If you need to serialize data without exporting, you can use the schema directly:
|
||||
|
||||
```python
|
||||
# Serialize a queryset to a list of dicts
|
||||
data = MySchema.serialize_queryset(queryset, fields=["id", "name"])
|
||||
|
||||
# Or serialize a single object
|
||||
schema = MySchema()
|
||||
obj_data = schema.serialize(obj)
|
||||
```
|
||||
|
||||
## 💡 Example: IssueExportSchema
|
||||
|
||||
The `IssueExportSchema` demonstrates a complete implementation:
|
||||
|
||||
```python
|
||||
from plane.utils.exporters import Exporter, IssueExportSchema
|
||||
|
||||
# Simple export - just pass the queryset!
|
||||
issues = Issue.objects.filter(project_id=project_id)
|
||||
exporter = Exporter(format_type="csv", schema_class=IssueExportSchema)
|
||||
filename, content = exporter.export("issues", issues)
|
||||
|
||||
# Export specific fields only
|
||||
filename, content = exporter.export(
|
||||
"issues_filtered",
|
||||
issues,
|
||||
fields=["id", "name", "state_name", "assignees", "labels"]
|
||||
)
|
||||
|
||||
# Export multiple projects to separate files
|
||||
for project_id in project_ids:
|
||||
project_issues = issues.filter(project_id=project_id)
|
||||
filename, content = exporter.export(f"issues-{project_id}", project_issues)
|
||||
# Save or upload each file
|
||||
```
|
||||
|
||||
Key features:
|
||||
|
||||
- 🔗 Access to related models via dotted paths
|
||||
- 🎯 Custom preparers for complex fields
|
||||
- 📎 Context-based attachment handling via `get_context_data()`
|
||||
- 📋 List and JSON field handling
|
||||
- 📅 Date/datetime formatting
|
||||
|
||||
## ✨ Best Practices
|
||||
|
||||
1. **🚄 Avoid N+1 Queries**: Override `get_context_data()` to pre-fetch related data:
|
||||
|
||||
```python
|
||||
@classmethod
|
||||
def get_context_data(cls, queryset):
|
||||
return {
|
||||
"attachments": get_attachments_dict(queryset),
|
||||
"comments": get_comments_dict(queryset),
|
||||
}
|
||||
```
|
||||
|
||||
2. **🏷️ Use Labels**: Provide descriptive labels for better export headers:
|
||||
|
||||
```python
|
||||
created_at = DateTimeField(source="created_at", label="Created At")
|
||||
```
|
||||
|
||||
3. **🛡️ Handle None Values**: Set appropriate defaults for fields that might be None:
|
||||
|
||||
```python
|
||||
count = NumberField(source="count", default=0)
|
||||
```
|
||||
|
||||
4. **🎯 Use Preparers for Complex Logic**: Keep field definitions simple and use preparers for complex transformations:
|
||||
|
||||
```python
|
||||
def prepare_assignees(self, obj):
|
||||
return [f"{u.first_name} {u.last_name}" for u in obj.assignee_details]
|
||||
```
|
||||
|
||||
5. **⚡ Pass QuerySets Directly**: Let the Exporter handle serialization:
|
||||
|
||||
```python
|
||||
# Good - Exporter handles serialization
|
||||
exporter.export("data", queryset)
|
||||
|
||||
# Avoid - Manual serialization unless needed
|
||||
data = MySchema.serialize_queryset(queryset)
|
||||
exporter.export("data", data)
|
||||
```
|
||||
|
||||
6. **📦 Filter QuerySets, Not Data**: For multiple exports, filter the queryset instead of the serialized data:
|
||||
|
||||
```python
|
||||
# Good - efficient, only serializes what's needed
|
||||
for project_id in project_ids:
|
||||
project_issues = issues.filter(project_id=project_id)
|
||||
exporter.export(f"project-{project_id}", project_issues)
|
||||
|
||||
# Avoid - serializes all data upfront
|
||||
all_data = MySchema.serialize_queryset(issues)
|
||||
for project_id in project_ids:
|
||||
project_data = [d for d in all_data if d['project_id'] == project_id]
|
||||
exporter.export(f"project-{project_id}", project_data)
|
||||
```
|
||||
|
||||
## 📚 API Reference
|
||||
|
||||
### 📊 Exporter
|
||||
|
||||
**`__init__(format_type, schema_class, options=None)`**
|
||||
|
||||
- `format_type`: Export format ('csv', 'json', 'xlsx')
|
||||
- `schema_class`: Schema class defining fields
|
||||
- `options`: Optional dict of format-specific options
|
||||
|
||||
**`export(filename, data, fields=None)`**
|
||||
|
||||
- `filename`: Filename without extension
|
||||
- `data`: Django QuerySet or list of dicts
|
||||
- `fields`: Optional list of field names to include
|
||||
- Returns: `(filename_with_extension, content)`
|
||||
- `content` is str for CSV/JSON, bytes for XLSX
|
||||
|
||||
**`get_available_formats()`** (class method)
|
||||
|
||||
- Returns: List of available format types
|
||||
|
||||
**`register_formatter(format_type, formatter_class)`** (class method)
|
||||
|
||||
- Register a custom formatter
|
||||
|
||||
### 📝 ExportSchema
|
||||
|
||||
**`__init__(context=None)`**
|
||||
|
||||
- `context`: Optional dict accessible in preparer methods via `self.context` for pre-fetched data
|
||||
|
||||
**`serialize(obj, fields=None)`**
|
||||
|
||||
- Returns: Dict of serialized field values for a single object
|
||||
|
||||
**`serialize_queryset(queryset, fields=None)`** (class method)
|
||||
|
||||
- `queryset`: QuerySet of objects to serialize
|
||||
- `fields`: Optional list of field names to include
|
||||
- Returns: List of dicts with serialized data
|
||||
|
||||
**`get_context_data(queryset)`** (class method)
|
||||
|
||||
- Override to pre-fetch related data for the queryset
|
||||
- Returns: Dict of context data
|
||||
|
||||
### 🔧 ExportField
|
||||
|
||||
Base class for all field types. Subclass to create custom field types.
|
||||
|
||||
**`get_value(obj, context)`**
|
||||
|
||||
- Returns: Formatted value for the field
|
||||
|
||||
**`_format_value(raw)`**
|
||||
|
||||
- Override in subclasses for type-specific formatting
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
```python
|
||||
# Test exporting a queryset
|
||||
queryset = MyModel.objects.all()
|
||||
exporter = Exporter(format_type="json", schema_class=MySchema)
|
||||
filename, content = exporter.export("test", queryset)
|
||||
assert filename == "test.json"
|
||||
assert isinstance(content, str)
|
||||
|
||||
# Test with field filtering
|
||||
filename, content = exporter.export("test", queryset, fields=["id", "name"])
|
||||
data = json.loads(content)
|
||||
assert all(set(item.keys()) == {"id", "name"} for item in data)
|
||||
|
||||
# Test manual serialization
|
||||
data = MySchema.serialize_queryset(queryset)
|
||||
assert len(data) == queryset.count()
|
||||
```
|
||||
@@ -0,0 +1,42 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""Export utilities for various data formats."""
|
||||
|
||||
from .exporter import Exporter
|
||||
from .formatters import BaseFormatter, CSVFormatter, JSONFormatter, XLSXFormatter
|
||||
from .schemas import (
|
||||
BooleanField,
|
||||
DateField,
|
||||
DateTimeField,
|
||||
ExportField,
|
||||
ExportSchema,
|
||||
IssueExportSchema,
|
||||
JSONField,
|
||||
ListField,
|
||||
NumberField,
|
||||
StringField,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Core Exporter
|
||||
"Exporter",
|
||||
# Schemas
|
||||
"ExportSchema",
|
||||
"ExportField",
|
||||
"StringField",
|
||||
"NumberField",
|
||||
"DateField",
|
||||
"DateTimeField",
|
||||
"BooleanField",
|
||||
"ListField",
|
||||
"JSONField",
|
||||
# Formatters
|
||||
"BaseFormatter",
|
||||
"CSVFormatter",
|
||||
"JSONFormatter",
|
||||
"XLSXFormatter",
|
||||
# Issue Schema
|
||||
"IssueExportSchema",
|
||||
]
|
||||
@@ -0,0 +1,76 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
from typing import Any, Dict, List, Type, Union
|
||||
|
||||
from django.db.models import QuerySet
|
||||
|
||||
from .formatters import CSVFormatter, JSONFormatter, XLSXFormatter
|
||||
|
||||
|
||||
class Exporter:
|
||||
"""Generic exporter class that handles data exports using different formatters."""
|
||||
|
||||
# Available formatters
|
||||
FORMATTERS = {
|
||||
"csv": CSVFormatter,
|
||||
"json": JSONFormatter,
|
||||
"xlsx": XLSXFormatter,
|
||||
}
|
||||
|
||||
def __init__(self, format_type: str, schema_class: Type, options: Dict[str, Any] = None):
|
||||
"""Initialize exporter with specified format type and schema.
|
||||
|
||||
Args:
|
||||
format_type: The export format (csv, json, xlsx)
|
||||
schema_class: The schema class to use for field definitions
|
||||
options: Optional formatting options
|
||||
"""
|
||||
if format_type not in self.FORMATTERS:
|
||||
raise ValueError(f"Unsupported format: {format_type}. Available: {list(self.FORMATTERS.keys())}")
|
||||
|
||||
self.format_type = format_type
|
||||
self.schema_class = schema_class
|
||||
self.formatter = self.FORMATTERS[format_type]()
|
||||
self.options = options or {}
|
||||
|
||||
def export(
|
||||
self,
|
||||
filename: str,
|
||||
data: Union[QuerySet, List[dict]],
|
||||
fields: List[str] = None,
|
||||
) -> tuple[str, str | bytes]:
|
||||
"""Export data using the configured formatter and return (filename, content).
|
||||
|
||||
Args:
|
||||
filename: The filename for the export (without extension)
|
||||
data: Either a Django QuerySet or a list of already-serialized dicts
|
||||
fields: Optional list of field names to include in export
|
||||
|
||||
Returns:
|
||||
Tuple of (filename_with_extension, content)
|
||||
"""
|
||||
# Serialize the queryset if needed
|
||||
if isinstance(data, QuerySet):
|
||||
records = self.schema_class.serialize_queryset(data, fields=fields)
|
||||
else:
|
||||
# Already serialized data
|
||||
records = data
|
||||
|
||||
# Merge fields into options for the formatter
|
||||
format_options = {**self.options}
|
||||
if fields:
|
||||
format_options["fields"] = fields
|
||||
|
||||
return self.formatter.format(filename, records, self.schema_class, format_options)
|
||||
|
||||
@classmethod
|
||||
def get_available_formats(cls) -> List[str]:
|
||||
"""Get list of available export formats."""
|
||||
return list(cls.FORMATTERS.keys())
|
||||
|
||||
@classmethod
|
||||
def register_formatter(cls, format_type: str, formatter_class: type) -> None:
|
||||
"""Register a new formatter for a format type."""
|
||||
cls.FORMATTERS[format_type] = formatter_class
|
||||
@@ -0,0 +1,206 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
from typing import Any, Dict, List, Type
|
||||
|
||||
from openpyxl import Workbook
|
||||
|
||||
# Module imports
|
||||
from plane.utils.csv_utils import sanitize_csv_row
|
||||
|
||||
|
||||
class BaseFormatter:
|
||||
"""Base class for export formatters."""
|
||||
|
||||
def format(
|
||||
self,
|
||||
filename: str,
|
||||
records: List[dict],
|
||||
schema_class: Type,
|
||||
options: Dict[str, Any] | None = None,
|
||||
) -> tuple[str, str | bytes]:
|
||||
"""Format records for export.
|
||||
|
||||
Args:
|
||||
filename: The filename for the export (without extension)
|
||||
records: List of records to export
|
||||
schema_class: Schema class to extract field order and labels
|
||||
options: Optional formatting options
|
||||
|
||||
Returns:
|
||||
Tuple of (filename_with_extension, content)
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@staticmethod
|
||||
def _get_field_info(schema_class: Type) -> tuple[List[str], Dict[str, str]]:
|
||||
"""Extract field order and labels from schema.
|
||||
|
||||
Args:
|
||||
schema_class: Schema class with field definitions
|
||||
|
||||
Returns:
|
||||
Tuple of (field_order, field_labels)
|
||||
"""
|
||||
if not hasattr(schema_class, "_declared_fields"):
|
||||
raise ValueError(f"Schema class {schema_class.__name__} must have _declared_fields attribute")
|
||||
|
||||
# Get order and labels from schema
|
||||
field_order = list(schema_class._declared_fields.keys())
|
||||
field_labels = {
|
||||
name: field.label if field.label else name.replace("_", " ").title()
|
||||
for name, field in schema_class._declared_fields.items()
|
||||
}
|
||||
|
||||
return field_order, field_labels
|
||||
|
||||
|
||||
class CSVFormatter(BaseFormatter):
|
||||
"""Formatter for CSV exports."""
|
||||
|
||||
@staticmethod
|
||||
def _format_field_value(value: Any, list_joiner: str = ", ") -> str:
|
||||
"""Format a field value for CSV output."""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, list):
|
||||
return list_joiner.join(str(v) for v in value)
|
||||
if isinstance(value, dict):
|
||||
# For complex objects, serialize as JSON
|
||||
return json.dumps(value)
|
||||
return str(value)
|
||||
|
||||
def _generate_table_row(
|
||||
self, record: dict, field_order: List[str], options: Dict[str, Any] | None = None
|
||||
) -> List[str]:
|
||||
"""Generate a CSV row from a record."""
|
||||
opts = options or {}
|
||||
list_joiner = opts.get("list_joiner", ", ")
|
||||
return [self._format_field_value(record.get(field, ""), list_joiner) for field in field_order]
|
||||
|
||||
def _create_csv_file(self, data: List[List[str]]) -> str:
|
||||
"""Create CSV file content from row data."""
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf, delimiter=",", quoting=csv.QUOTE_ALL)
|
||||
for row in data:
|
||||
writer.writerow(sanitize_csv_row(row))
|
||||
buf.seek(0)
|
||||
return buf.getvalue()
|
||||
|
||||
def format(self, filename, records, schema_class, options: Dict[str, Any] | None = None) -> tuple[str, str]:
|
||||
if not records:
|
||||
return (f"{filename}.csv", "")
|
||||
|
||||
# Get field order and labels from schema
|
||||
field_order, field_labels = self._get_field_info(schema_class)
|
||||
|
||||
# Filter to requested fields if specified
|
||||
opts = options or {}
|
||||
requested_fields = opts.get("fields")
|
||||
if requested_fields:
|
||||
field_order = [f for f in field_order if f in requested_fields]
|
||||
|
||||
header = [field_labels[field] for field in field_order]
|
||||
|
||||
rows = [header]
|
||||
for record in records:
|
||||
row = self._generate_table_row(record, field_order, options)
|
||||
rows.append(row)
|
||||
content = self._create_csv_file(rows)
|
||||
return (f"{filename}.csv", content)
|
||||
|
||||
|
||||
class JSONFormatter(BaseFormatter):
|
||||
"""Formatter for JSON exports."""
|
||||
|
||||
def _generate_json_row(
|
||||
self, record: dict, field_labels: Dict[str, str], field_order: List[str], options: Dict[str, Any] | None = None
|
||||
) -> dict:
|
||||
"""Generate a JSON object from a record.
|
||||
|
||||
Preserves data types - lists stay as arrays, dicts stay as objects.
|
||||
"""
|
||||
return {field_labels[field]: record.get(field) for field in field_order if field in record}
|
||||
|
||||
def format(self, filename, records, schema_class, options: Dict[str, Any] | None = None) -> tuple[str, str]:
|
||||
if not records:
|
||||
return (f"{filename}.json", "[]")
|
||||
|
||||
# Get field order and labels from schema
|
||||
field_order, field_labels = self._get_field_info(schema_class)
|
||||
|
||||
# Filter to requested fields if specified
|
||||
opts = options or {}
|
||||
requested_fields = opts.get("fields")
|
||||
if requested_fields:
|
||||
field_order = [f for f in field_order if f in requested_fields]
|
||||
|
||||
rows: List[dict] = []
|
||||
for record in records:
|
||||
row = self._generate_json_row(record, field_labels, field_order, options)
|
||||
rows.append(row)
|
||||
content = json.dumps(rows)
|
||||
return (f"{filename}.json", content)
|
||||
|
||||
|
||||
class XLSXFormatter(BaseFormatter):
|
||||
"""Formatter for XLSX (Excel) exports."""
|
||||
|
||||
@staticmethod
|
||||
def _format_field_value(value: Any, list_joiner: str = ", ") -> str:
|
||||
"""Format a field value for XLSX output."""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, list):
|
||||
return list_joiner.join(str(v) for v in value)
|
||||
if isinstance(value, dict):
|
||||
# For complex objects, serialize as JSON
|
||||
return json.dumps(value)
|
||||
return str(value)
|
||||
|
||||
def _generate_table_row(
|
||||
self, record: dict, field_order: List[str], options: Dict[str, Any] | None = None
|
||||
) -> List[str]:
|
||||
"""Generate an XLSX row from a record."""
|
||||
opts = options or {}
|
||||
list_joiner = opts.get("list_joiner", ", ")
|
||||
return [self._format_field_value(record.get(field, ""), list_joiner) for field in field_order]
|
||||
|
||||
def _create_xlsx_file(self, data: List[List[str]]) -> bytes:
|
||||
"""Create XLSX file content from row data."""
|
||||
wb = Workbook()
|
||||
sh = wb.active
|
||||
for row in data:
|
||||
sh.append(row)
|
||||
out = io.BytesIO()
|
||||
wb.save(out)
|
||||
out.seek(0)
|
||||
return out.getvalue()
|
||||
|
||||
def format(self, filename, records, schema_class, options: Dict[str, Any] | None = None) -> tuple[str, bytes]:
|
||||
if not records:
|
||||
# Create empty workbook
|
||||
content = self._create_xlsx_file([])
|
||||
return (f"{filename}.xlsx", content)
|
||||
|
||||
# Get field order and labels from schema
|
||||
field_order, field_labels = self._get_field_info(schema_class)
|
||||
|
||||
# Filter to requested fields if specified
|
||||
opts = options or {}
|
||||
requested_fields = opts.get("fields")
|
||||
if requested_fields:
|
||||
field_order = [f for f in field_order if f in requested_fields]
|
||||
|
||||
header = [field_labels[field] for field in field_order]
|
||||
|
||||
rows = [header]
|
||||
for record in records:
|
||||
row = self._generate_table_row(record, field_order, options)
|
||||
rows.append(row)
|
||||
content = self._create_xlsx_file(rows)
|
||||
return (f"{filename}.xlsx", content)
|
||||
@@ -0,0 +1,34 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""Export schemas for various data types."""
|
||||
|
||||
from .base import (
|
||||
BooleanField,
|
||||
DateField,
|
||||
DateTimeField,
|
||||
ExportField,
|
||||
ExportSchema,
|
||||
JSONField,
|
||||
ListField,
|
||||
NumberField,
|
||||
StringField,
|
||||
)
|
||||
from .issue import IssueExportSchema
|
||||
|
||||
__all__ = [
|
||||
# Base field types
|
||||
"ExportField",
|
||||
"StringField",
|
||||
"NumberField",
|
||||
"DateField",
|
||||
"DateTimeField",
|
||||
"BooleanField",
|
||||
"ListField",
|
||||
"JSONField",
|
||||
# Base schema
|
||||
"ExportSchema",
|
||||
# Issue schema
|
||||
"IssueExportSchema",
|
||||
]
|
||||
@@ -0,0 +1,238 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from django.db.models import QuerySet
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExportField:
|
||||
"""Base export field class for generic fields."""
|
||||
|
||||
source: Optional[str] = None
|
||||
default: Any = ""
|
||||
label: Optional[str] = None # Display name for export headers
|
||||
|
||||
def get_value(self, obj: Any, context: Dict[str, Any]) -> Any:
|
||||
raw: Any
|
||||
if self.source:
|
||||
raw = self._resolve_dotted_path(obj, self.source)
|
||||
else:
|
||||
raw = obj
|
||||
|
||||
return self._format_value(raw)
|
||||
|
||||
def _format_value(self, raw: Any) -> Any:
|
||||
"""Format the raw value. Override in subclasses for type-specific formatting."""
|
||||
return raw if raw is not None else self.default
|
||||
|
||||
def _resolve_dotted_path(self, obj: Any, path: str) -> Any:
|
||||
current = obj
|
||||
for part in path.split("."):
|
||||
if current is None:
|
||||
return None
|
||||
if hasattr(current, part):
|
||||
current = getattr(current, part)
|
||||
elif isinstance(current, dict):
|
||||
current = current.get(part)
|
||||
else:
|
||||
return None
|
||||
return current
|
||||
|
||||
|
||||
@dataclass
|
||||
class StringField(ExportField):
|
||||
"""Export field for string values."""
|
||||
|
||||
default: str = ""
|
||||
|
||||
def _format_value(self, raw: Any) -> str:
|
||||
if raw is None:
|
||||
return self.default
|
||||
return str(raw)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DateField(ExportField):
|
||||
"""Export field for date values with automatic conversion."""
|
||||
|
||||
default: str = ""
|
||||
|
||||
def _format_value(self, raw: Any) -> str:
|
||||
if raw is None:
|
||||
return self.default
|
||||
# Convert date to formatted string
|
||||
if hasattr(raw, "strftime"):
|
||||
return raw.strftime("%a, %d %b %Y")
|
||||
return str(raw)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DateTimeField(ExportField):
|
||||
"""Export field for datetime values with automatic conversion."""
|
||||
|
||||
default: str = ""
|
||||
|
||||
def _format_value(self, raw: Any) -> str:
|
||||
if raw is None:
|
||||
return self.default
|
||||
# Convert datetime to formatted string
|
||||
if hasattr(raw, "strftime"):
|
||||
return raw.strftime("%a, %d %b %Y %I:%M:%S %Z%z")
|
||||
return str(raw)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NumberField(ExportField):
|
||||
"""Export field for numeric values."""
|
||||
|
||||
default: Any = ""
|
||||
|
||||
def _format_value(self, raw: Any) -> Any:
|
||||
if raw is None:
|
||||
return self.default
|
||||
return raw
|
||||
|
||||
|
||||
@dataclass
|
||||
class BooleanField(ExportField):
|
||||
"""Export field for boolean values."""
|
||||
|
||||
default: bool = False
|
||||
|
||||
def _format_value(self, raw: Any) -> bool:
|
||||
if raw is None:
|
||||
return self.default
|
||||
return bool(raw)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ListField(ExportField):
|
||||
"""Export field for list/array values.
|
||||
|
||||
Returns the list as-is by default. The formatter will handle conversion to strings
|
||||
when needed (e.g., CSV/XLSX will join with separator, JSON will keep as array).
|
||||
"""
|
||||
|
||||
default: Optional[List] = field(default_factory=list)
|
||||
|
||||
def _format_value(self, raw: Any) -> List[Any]:
|
||||
if raw is None:
|
||||
return self.default if self.default is not None else []
|
||||
if isinstance(raw, (list, tuple)):
|
||||
return list(raw)
|
||||
return [raw] # Wrap single items in a list
|
||||
|
||||
|
||||
@dataclass
|
||||
class JSONField(ExportField):
|
||||
"""Export field for complex JSON-serializable values (dicts, lists of dicts, etc).
|
||||
|
||||
Preserves the structure as-is for JSON exports. For CSV/XLSX, the formatter
|
||||
will handle serialization (e.g., JSON stringify).
|
||||
"""
|
||||
|
||||
default: Any = field(default_factory=dict)
|
||||
|
||||
def _format_value(self, raw: Any) -> Any:
|
||||
if raw is None:
|
||||
return self.default
|
||||
# Return as-is - should be JSON-serializable
|
||||
return raw
|
||||
|
||||
|
||||
class ExportSchemaMeta(type):
|
||||
def __new__(mcls, name, bases, attrs):
|
||||
declared: Dict[str, ExportField] = {
|
||||
key: value for key, value in list(attrs.items()) if isinstance(value, ExportField)
|
||||
}
|
||||
for key in declared.keys():
|
||||
attrs.pop(key)
|
||||
cls = super().__new__(mcls, name, bases, attrs)
|
||||
base_fields: Dict[str, ExportField] = {}
|
||||
for base in bases:
|
||||
if hasattr(base, "_declared_fields"):
|
||||
base_fields.update(base._declared_fields)
|
||||
base_fields.update(declared)
|
||||
cls._declared_fields = base_fields
|
||||
return cls
|
||||
|
||||
|
||||
class ExportSchema(metaclass=ExportSchemaMeta):
|
||||
"""Base schema for exporting data in various formats.
|
||||
|
||||
Subclasses should define fields as class attributes and can override:
|
||||
- prepare_<field_name> methods for custom field serialization
|
||||
- get_context_data() class method to pre-fetch related data for the queryset
|
||||
"""
|
||||
|
||||
def __init__(self, context: Optional[Dict[str, Any]] = None) -> None:
|
||||
self.context = context or {}
|
||||
|
||||
def serialize(self, obj: Any, fields: Optional[List[str]] = None) -> Dict[str, Any]:
|
||||
"""Serialize a single object.
|
||||
|
||||
Args:
|
||||
obj: The object to serialize
|
||||
fields: Optional list of field names to include. If None, all fields are serialized.
|
||||
|
||||
Returns:
|
||||
Dictionary of serialized data
|
||||
"""
|
||||
output: Dict[str, Any] = {}
|
||||
# Determine which fields to process
|
||||
fields_to_process = fields if fields else list(self._declared_fields.keys())
|
||||
|
||||
for field_name in fields_to_process:
|
||||
# Skip if field doesn't exist in schema
|
||||
if field_name not in self._declared_fields:
|
||||
continue
|
||||
|
||||
export_field = self._declared_fields[field_name]
|
||||
|
||||
# Prefer explicit preparer methods if present
|
||||
preparer = getattr(self, f"prepare_{field_name}", None)
|
||||
if callable(preparer):
|
||||
output[field_name] = preparer(obj)
|
||||
continue
|
||||
|
||||
output[field_name] = export_field.get_value(obj, self.context)
|
||||
return output
|
||||
|
||||
@classmethod
|
||||
def get_context_data(cls, queryset: QuerySet) -> Dict[str, Any]:
|
||||
"""Get context data for serialization. Override in subclasses to pre-fetch related data.
|
||||
|
||||
Args:
|
||||
queryset: QuerySet of objects to be serialized
|
||||
|
||||
Returns:
|
||||
Dictionary of context data to be passed to the schema instance
|
||||
"""
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def serialize_queryset(cls, queryset: QuerySet, fields: List[str] = None) -> List[Dict[str, Any]]:
|
||||
"""Serialize a queryset of objects to export data.
|
||||
|
||||
Args:
|
||||
queryset: QuerySet of objects to serialize
|
||||
fields: Optional list of field names to include. Defaults to all fields.
|
||||
|
||||
Returns:
|
||||
List of dictionaries containing serialized data
|
||||
"""
|
||||
# Get context data (can be extended by subclasses)
|
||||
context = cls.get_context_data(queryset)
|
||||
|
||||
# Serialize each object, passing fields to only process requested fields
|
||||
schema = cls(context=context)
|
||||
data = []
|
||||
for obj in queryset:
|
||||
obj_data = schema.serialize(obj, fields=fields)
|
||||
data.append(obj_data)
|
||||
|
||||
return data
|
||||
@@ -0,0 +1,214 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from django.db.models import F, QuerySet
|
||||
|
||||
from plane.db.models import CycleIssue, FileAsset
|
||||
|
||||
from .base import (
|
||||
DateField,
|
||||
DateTimeField,
|
||||
ExportSchema,
|
||||
JSONField,
|
||||
ListField,
|
||||
NumberField,
|
||||
StringField,
|
||||
)
|
||||
|
||||
|
||||
def get_issue_attachments_dict(issues_queryset: QuerySet) -> Dict[str, List[str]]:
|
||||
"""Get attachments dictionary for the given issues queryset.
|
||||
|
||||
Args:
|
||||
issues_queryset: Queryset of Issue objects
|
||||
|
||||
Returns:
|
||||
Dictionary mapping issue IDs to lists of attachment IDs
|
||||
"""
|
||||
file_assets = FileAsset.objects.filter(
|
||||
issue_id__in=issues_queryset.values_list("id", flat=True),
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
).annotate(work_item_id=F("issue_id"), asset_id=F("id"))
|
||||
|
||||
attachment_dict = defaultdict(list)
|
||||
for asset in file_assets:
|
||||
attachment_dict[asset.work_item_id].append(asset.asset_id)
|
||||
|
||||
return attachment_dict
|
||||
|
||||
|
||||
def get_issue_last_cycles_dict(issues_queryset: QuerySet) -> Dict[str, Optional[CycleIssue]]:
|
||||
"""Get the last cycle for each issue in the given queryset.
|
||||
|
||||
Args:
|
||||
issues_queryset: Queryset of Issue objects
|
||||
|
||||
Returns:
|
||||
Dictionary mapping issue IDs to their last CycleIssue object
|
||||
"""
|
||||
# Fetch all cycle issues for the given issues, ordered by created_at descending
|
||||
# select_related is used to fetch cycle data in the same query
|
||||
cycle_issues = (
|
||||
CycleIssue.objects.filter(issue_id__in=issues_queryset.values_list("id", flat=True))
|
||||
.select_related("cycle")
|
||||
.order_by("issue_id", "-created_at")
|
||||
)
|
||||
|
||||
# Keep only the last (most recent) cycle for each issue
|
||||
last_cycles_dict = {}
|
||||
for cycle_issue in cycle_issues:
|
||||
if cycle_issue.issue_id not in last_cycles_dict:
|
||||
last_cycles_dict[cycle_issue.issue_id] = cycle_issue
|
||||
|
||||
return last_cycles_dict
|
||||
|
||||
|
||||
class IssueExportSchema(ExportSchema):
|
||||
"""Schema for exporting issue data in various formats."""
|
||||
|
||||
@staticmethod
|
||||
def _get_created_by(obj) -> str:
|
||||
"""Get the created by user for the given object."""
|
||||
try:
|
||||
if getattr(obj, "created_by", None):
|
||||
return f"{obj.created_by.first_name} {obj.created_by.last_name}"
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _format_date(date_obj) -> str:
|
||||
"""Format date object to string."""
|
||||
if date_obj and hasattr(date_obj, "strftime"):
|
||||
return date_obj.strftime("%a, %d %b %Y")
|
||||
return ""
|
||||
|
||||
# Field definitions with display labels
|
||||
id = StringField(label="ID")
|
||||
project_identifier = StringField(source="project.identifier", label="Project Identifier")
|
||||
project_name = StringField(source="project.name", label="Project")
|
||||
project_id = StringField(source="project.id", label="Project ID")
|
||||
sequence_id = NumberField(source="sequence_id", label="Sequence ID")
|
||||
name = StringField(source="name", label="Name")
|
||||
description = StringField(source="description_stripped", label="Description")
|
||||
priority = StringField(source="priority", label="Priority")
|
||||
start_date = DateField(source="start_date", label="Start Date")
|
||||
target_date = DateField(source="target_date", label="Target Date")
|
||||
state_name = StringField(label="State")
|
||||
created_at = DateTimeField(source="created_at", label="Created At")
|
||||
updated_at = DateTimeField(source="updated_at", label="Updated At")
|
||||
completed_at = DateTimeField(source="completed_at", label="Completed At")
|
||||
archived_at = DateTimeField(source="archived_at", label="Archived At")
|
||||
module_name = ListField(label="Module Name")
|
||||
created_by = StringField(label="Created By")
|
||||
labels = ListField(label="Labels")
|
||||
comments = JSONField(label="Comments")
|
||||
estimate = StringField(label="Estimate")
|
||||
link = ListField(label="Link")
|
||||
assignees = ListField(label="Assignees")
|
||||
subscribers_count = NumberField(label="Subscribers Count")
|
||||
attachment_count = NumberField(label="Attachment Count")
|
||||
attachment_links = ListField(label="Attachment Links")
|
||||
cycle_name = StringField(label="Cycle Name")
|
||||
cycle_start_date = DateField(label="Cycle Start Date")
|
||||
cycle_end_date = DateField(label="Cycle End Date")
|
||||
parent = StringField(label="Parent")
|
||||
relations = JSONField(label="Relations")
|
||||
|
||||
def prepare_id(self, i):
|
||||
return f"{i.project.identifier}-{i.sequence_id}"
|
||||
|
||||
def prepare_state_name(self, i):
|
||||
return i.state.name if i.state else None
|
||||
|
||||
def prepare_module_name(self, i):
|
||||
return [m.module.name for m in i.issue_module.all()]
|
||||
|
||||
def prepare_created_by(self, i):
|
||||
return self._get_created_by(i)
|
||||
|
||||
def prepare_labels(self, i):
|
||||
return [label.name for label in i.labels.all()]
|
||||
|
||||
def prepare_comments(self, i):
|
||||
return [
|
||||
{
|
||||
"comment": comment.comment_stripped,
|
||||
"created_at": self._format_date(comment.created_at),
|
||||
"created_by": self._get_created_by(comment),
|
||||
}
|
||||
for comment in i.issue_comments.all()
|
||||
]
|
||||
|
||||
def prepare_estimate(self, i):
|
||||
return i.estimate_point.value if i.estimate_point and i.estimate_point.value else ""
|
||||
|
||||
def prepare_link(self, i):
|
||||
return [link.url for link in i.issue_link.all()]
|
||||
|
||||
def prepare_assignees(self, i):
|
||||
return [f"{u.first_name} {u.last_name}" for u in i.assignees.all()]
|
||||
|
||||
def prepare_subscribers_count(self, i):
|
||||
return i.issue_subscribers.count()
|
||||
|
||||
def prepare_attachment_count(self, i):
|
||||
return len((self.context.get("attachments_dict") or {}).get(i.id, []))
|
||||
|
||||
def prepare_attachment_links(self, i):
|
||||
return [
|
||||
f"/api/assets/v2/workspaces/{i.workspace.slug}/projects/{i.project_id}/issues/{i.id}/attachments/{asset}/"
|
||||
for asset in (self.context.get("attachments_dict") or {}).get(i.id, [])
|
||||
]
|
||||
|
||||
def prepare_cycle_name(self, i):
|
||||
cycles_dict = self.context.get("cycles_dict") or {}
|
||||
last_cycle = cycles_dict.get(i.id)
|
||||
return last_cycle.cycle.name if last_cycle else ""
|
||||
|
||||
def prepare_cycle_start_date(self, i):
|
||||
cycles_dict = self.context.get("cycles_dict") or {}
|
||||
last_cycle = cycles_dict.get(i.id)
|
||||
if last_cycle and last_cycle.cycle.start_date:
|
||||
return self._format_date(last_cycle.cycle.start_date)
|
||||
return ""
|
||||
|
||||
def prepare_cycle_end_date(self, i):
|
||||
cycles_dict = self.context.get("cycles_dict") or {}
|
||||
last_cycle = cycles_dict.get(i.id)
|
||||
if last_cycle and last_cycle.cycle.end_date:
|
||||
return self._format_date(last_cycle.cycle.end_date)
|
||||
return ""
|
||||
|
||||
def prepare_parent(self, i):
|
||||
if not i.parent:
|
||||
return ""
|
||||
return f"{i.parent.project.identifier}-{i.parent.sequence_id}"
|
||||
|
||||
def prepare_relations(self, i):
|
||||
# Should show reverse relation as well
|
||||
from plane.db.models.issue import IssueRelationChoices
|
||||
|
||||
relations = {
|
||||
r.relation_type: f"{r.related_issue.project.identifier}-{r.related_issue.sequence_id}"
|
||||
for r in i.issue_relation.all()
|
||||
}
|
||||
reverse_relations = {}
|
||||
for relation in i.issue_related.all():
|
||||
reverse_relations[IssueRelationChoices._REVERSE_MAPPING[relation.relation_type]] = (
|
||||
f"{relation.issue.project.identifier}-{relation.issue.sequence_id}"
|
||||
)
|
||||
relations.update(reverse_relations)
|
||||
return relations
|
||||
|
||||
@classmethod
|
||||
def get_context_data(cls, queryset: QuerySet) -> Dict[str, Any]:
|
||||
"""Get context data for issue serialization."""
|
||||
return {
|
||||
"attachments_dict": get_issue_attachments_dict(queryset),
|
||||
"cycles_dict": get_issue_last_cycles_dict(queryset),
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Filters module for handling complex filtering operations
|
||||
|
||||
# Import all utilities from base modules
|
||||
from .filter_backend import ComplexFilterBackend
|
||||
from .converters import LegacyToRichFiltersConverter
|
||||
from .filterset import BaseFilterSet, IssueFilterSet
|
||||
|
||||
|
||||
# Public API exports
|
||||
__all__ = ["ComplexFilterBackend", "LegacyToRichFiltersConverter", "BaseFilterSet", "IssueFilterSet"]
|
||||
@@ -0,0 +1,424 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Union
|
||||
|
||||
from dateutil.parser import parse as dateutil_parse
|
||||
|
||||
|
||||
class LegacyToRichFiltersConverter:
|
||||
# Default mapping from legacy filter names to new rich filter field names
|
||||
DEFAULT_FIELD_MAPPINGS = {
|
||||
"state": "state_id",
|
||||
"labels": "label_id",
|
||||
"cycle": "cycle_id",
|
||||
"module": "module_id",
|
||||
"assignees": "assignee_id",
|
||||
"mentions": "mention_id",
|
||||
"created_by": "created_by_id",
|
||||
"state_group": "state_group",
|
||||
"priority": "priority",
|
||||
"project": "project_id",
|
||||
"start_date": "start_date",
|
||||
"target_date": "target_date",
|
||||
}
|
||||
|
||||
# Default fields that expect UUID values
|
||||
DEFAULT_UUID_FIELDS = {
|
||||
"state_id",
|
||||
"label_id",
|
||||
"cycle_id",
|
||||
"module_id",
|
||||
"assignee_id",
|
||||
"mention_id",
|
||||
"created_by_id",
|
||||
"project_id",
|
||||
}
|
||||
|
||||
# Default valid choices for choice fields
|
||||
DEFAULT_VALID_CHOICES = {
|
||||
"state_group": ["backlog", "unstarted", "started", "completed", "cancelled"],
|
||||
"priority": ["urgent", "high", "medium", "low", "none"],
|
||||
}
|
||||
|
||||
# Default date fields
|
||||
DEFAULT_DATE_FIELDS = {"start_date", "target_date"}
|
||||
|
||||
# Pattern for relative date strings like "2_weeks" or "3_months"
|
||||
DATE_PATTERN = re.compile(r"(\d+)_(weeks|months)$")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
field_mappings: Dict[str, str] = None,
|
||||
uuid_fields: set = None,
|
||||
valid_choices: Dict[str, List[str]] = None,
|
||||
date_fields: set = None,
|
||||
extend_defaults: bool = True,
|
||||
):
|
||||
"""
|
||||
Initialize the converter with optional custom configurations.
|
||||
|
||||
Args:
|
||||
field_mappings: Custom field mappings (legacy_key -> rich_field_name)
|
||||
uuid_fields: Set of field names that should be validated as UUIDs
|
||||
valid_choices: Dict of valid choices for choice fields
|
||||
date_fields: Set of field names that should be treated as dates
|
||||
extend_defaults: If True, merge with defaults; if False, replace defaults
|
||||
|
||||
Examples:
|
||||
# Use defaults
|
||||
converter = LegacyToRichFiltersConverter()
|
||||
|
||||
# Add custom field mapping
|
||||
converter = LegacyToRichFiltersConverter(
|
||||
field_mappings={"custom_field": "custom_field_id"}
|
||||
)
|
||||
|
||||
# Override priority choices
|
||||
converter = LegacyToRichFiltersConverter(
|
||||
valid_choices={"priority": ["critical", "high", "medium", "low"]}
|
||||
)
|
||||
|
||||
# Complete replacement (not extending defaults)
|
||||
converter = LegacyToRichFiltersConverter(
|
||||
field_mappings={"state": "status_id"},
|
||||
extend_defaults=False
|
||||
)
|
||||
"""
|
||||
if extend_defaults:
|
||||
# Merge with defaults
|
||||
self.FIELD_MAPPINGS = {**self.DEFAULT_FIELD_MAPPINGS}
|
||||
if field_mappings:
|
||||
self.FIELD_MAPPINGS.update(field_mappings)
|
||||
|
||||
self.UUID_FIELDS = {*self.DEFAULT_UUID_FIELDS}
|
||||
if uuid_fields:
|
||||
self.UUID_FIELDS.update(uuid_fields)
|
||||
|
||||
self.VALID_CHOICES = {**self.DEFAULT_VALID_CHOICES}
|
||||
if valid_choices:
|
||||
self.VALID_CHOICES.update(valid_choices)
|
||||
|
||||
self.DATE_FIELDS = {*self.DEFAULT_DATE_FIELDS}
|
||||
if date_fields:
|
||||
self.DATE_FIELDS.update(date_fields)
|
||||
else:
|
||||
# Replace defaults entirely
|
||||
self.FIELD_MAPPINGS = field_mappings or {}
|
||||
self.UUID_FIELDS = uuid_fields or set()
|
||||
self.VALID_CHOICES = valid_choices or {}
|
||||
self.DATE_FIELDS = date_fields or set()
|
||||
|
||||
def add_field_mapping(self, legacy_key: str, rich_field_name: str) -> None:
|
||||
"""Add or update a single field mapping."""
|
||||
self.FIELD_MAPPINGS[legacy_key] = rich_field_name
|
||||
|
||||
def add_uuid_field(self, field_name: str) -> None:
|
||||
"""Add a field that should be validated as UUID."""
|
||||
self.UUID_FIELDS.add(field_name)
|
||||
|
||||
def add_choice_field(self, field_name: str, choices: List[str]) -> None:
|
||||
"""Add or update valid choices for a choice field."""
|
||||
self.VALID_CHOICES[field_name] = choices
|
||||
|
||||
def add_date_field(self, field_name: str) -> None:
|
||||
"""Add a field that should be treated as a date field."""
|
||||
self.DATE_FIELDS.add(field_name)
|
||||
|
||||
def update_mappings(
|
||||
self,
|
||||
field_mappings: Dict[str, str] = None,
|
||||
uuid_fields: set = None,
|
||||
valid_choices: Dict[str, List[str]] = None,
|
||||
date_fields: set = None,
|
||||
) -> None:
|
||||
"""
|
||||
Update multiple configurations at once.
|
||||
|
||||
Args:
|
||||
field_mappings: Additional field mappings to add/update
|
||||
uuid_fields: Additional UUID fields to add
|
||||
valid_choices: Additional choice fields to add/update
|
||||
date_fields: Additional date fields to add
|
||||
"""
|
||||
if field_mappings:
|
||||
self.FIELD_MAPPINGS.update(field_mappings)
|
||||
if uuid_fields:
|
||||
self.UUID_FIELDS.update(uuid_fields)
|
||||
if valid_choices:
|
||||
self.VALID_CHOICES.update(valid_choices)
|
||||
if date_fields:
|
||||
self.DATE_FIELDS.update(date_fields)
|
||||
|
||||
def _validate_uuid(self, value: str) -> bool:
|
||||
"""Validate if a string is a valid UUID"""
|
||||
try:
|
||||
uuid.UUID(str(value))
|
||||
return True
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
def _validate_choice(self, field_name: str, value: str) -> bool:
|
||||
"""Validate if a value is valid for a choice field"""
|
||||
if field_name not in self.VALID_CHOICES:
|
||||
return True # No validation needed for this field
|
||||
return value in self.VALID_CHOICES[field_name]
|
||||
|
||||
def _validate_date(self, value: Union[str, datetime]) -> bool:
|
||||
"""Validate if a value is a valid date using dateutil parser"""
|
||||
if isinstance(value, datetime):
|
||||
return True
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
# Use dateutil for flexible date parsing
|
||||
dateutil_parse(value)
|
||||
return True
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
return False
|
||||
|
||||
def _validate_value(self, rich_field_name: str, value: Any) -> bool:
|
||||
"""Validate a single value based on field type"""
|
||||
if rich_field_name in self.UUID_FIELDS:
|
||||
return self._validate_uuid(value)
|
||||
elif rich_field_name in self.VALID_CHOICES:
|
||||
return self._validate_choice(rich_field_name, value)
|
||||
elif rich_field_name in self.DATE_FIELDS:
|
||||
return self._validate_date(value)
|
||||
return True # No specific validation needed
|
||||
|
||||
def _filter_valid_values(self, rich_field_name: str, values: List[Any]) -> List[Any]:
|
||||
"""Filter out invalid values from a list and return only valid ones"""
|
||||
valid_values = []
|
||||
for value in values:
|
||||
if self._validate_value(rich_field_name, value):
|
||||
valid_values.append(value)
|
||||
return valid_values
|
||||
|
||||
def _add_validation_error(self, strict: bool, validation_errors: List[str], message: str) -> None:
|
||||
"""Add validation error if in strict mode."""
|
||||
if strict:
|
||||
validation_errors.append(message)
|
||||
|
||||
def _add_rich_filter(self, rich_filters: Dict[str, Any], field_name: str, operator: str, value: Any) -> None:
|
||||
"""Add a rich filter with proper field name formatting."""
|
||||
# Convert lists to comma-separated strings for 'in' and 'range' operations
|
||||
if operator in ("in", "range") and isinstance(value, list):
|
||||
value = ",".join(str(v) for v in value)
|
||||
rich_filters[f"{field_name}__{operator}"] = value
|
||||
|
||||
def _handle_value_error(self, e: ValueError, strict: bool, validation_errors: List[str]) -> None:
|
||||
"""Handle ValueError with consistent strict/non-strict behavior."""
|
||||
if strict:
|
||||
validation_errors.append(str(e))
|
||||
# In non-strict mode, we just skip (no action needed)
|
||||
|
||||
def _process_date_field(
|
||||
self,
|
||||
rich_field_name: str,
|
||||
values: List[str],
|
||||
strict: bool,
|
||||
validation_errors: List[str],
|
||||
rich_filters: Dict[str, Any],
|
||||
) -> bool:
|
||||
"""Process date field with basic functionality (exact, range)."""
|
||||
if rich_field_name not in self.DATE_FIELDS:
|
||||
return False
|
||||
|
||||
try:
|
||||
date_filter_result = self._convert_date_value(rich_field_name, values, strict)
|
||||
if date_filter_result:
|
||||
rich_filters.update(date_filter_result)
|
||||
return True
|
||||
except ValueError as e:
|
||||
self._handle_value_error(e, strict, validation_errors)
|
||||
return True
|
||||
|
||||
def _convert_date_value(self, field_name: str, values: List[str], strict: bool = False) -> Dict[str, Any]:
|
||||
"""
|
||||
Convert legacy date values to rich filter format - basic implementation.
|
||||
|
||||
Supports:
|
||||
- Simple dates: "2023-01-01" -> __exact
|
||||
- Basic ranges: ["2023-01-01;after", "2023-12-31;before"] -> __range
|
||||
- Skips complex or relative date patterns
|
||||
|
||||
Args:
|
||||
field_name: Name of the rich filter field
|
||||
values: List of legacy date values
|
||||
strict: If True, raise errors for validation failures
|
||||
|
||||
Raises:
|
||||
ValueError: For malformed date patterns (strict mode)
|
||||
"""
|
||||
# Check for relative dates and skip the entire field if found
|
||||
for value in values:
|
||||
if ";" in value:
|
||||
parts = value.split(";")
|
||||
if len(parts) > 0 and self.DATE_PATTERN.match(parts[0]):
|
||||
# Skip relative date patterns entirely
|
||||
return {}
|
||||
|
||||
# Skip complex conditions (more than 2 values)
|
||||
if len(values) > 2:
|
||||
return {}
|
||||
|
||||
# Process each date value
|
||||
exact_dates = []
|
||||
after_dates = []
|
||||
before_dates = []
|
||||
|
||||
for value in values:
|
||||
if ";" not in value:
|
||||
# Simple date string
|
||||
if not self._validate_date(value):
|
||||
if strict:
|
||||
raise ValueError(f"Invalid date format: {value}")
|
||||
continue
|
||||
exact_dates.append(value)
|
||||
else:
|
||||
# Directional date - only handle basic after/before
|
||||
parts = value.split(";")
|
||||
if len(parts) < 2:
|
||||
if strict:
|
||||
raise ValueError(f"Invalid date format: {value}")
|
||||
continue
|
||||
|
||||
date_part = parts[0]
|
||||
direction = parts[1]
|
||||
|
||||
if not self._validate_date(date_part):
|
||||
if strict:
|
||||
raise ValueError(f"Invalid date format: {date_part}")
|
||||
continue
|
||||
|
||||
if direction == "after":
|
||||
after_dates.append(date_part)
|
||||
elif direction == "before":
|
||||
before_dates.append(date_part)
|
||||
# Skip unsupported directions
|
||||
|
||||
# Determine return format
|
||||
result = {}
|
||||
if len(after_dates) == 1 and len(before_dates) == 1 and len(exact_dates) == 0:
|
||||
# Simple range: one after and one before
|
||||
start_date = min(after_dates[0], before_dates[0])
|
||||
end_date = max(after_dates[0], before_dates[0])
|
||||
self._add_rich_filter(result, field_name, "range", [start_date, end_date])
|
||||
elif len(exact_dates) == 1 and len(after_dates) == 0 and len(before_dates) == 0:
|
||||
# Single exact date
|
||||
self._add_rich_filter(result, field_name, "exact", exact_dates[0])
|
||||
# Skip all other combinations
|
||||
|
||||
return result
|
||||
|
||||
def convert(self, legacy_filters: dict, strict: bool = False) -> Dict[str, Any]:
|
||||
"""
|
||||
Convert legacy filters to rich filters format with validation
|
||||
|
||||
Args:
|
||||
legacy_filters: Dictionary of legacy filters
|
||||
strict: If True, raise exception on validation errors.
|
||||
If False, skip invalid values (default behavior)
|
||||
|
||||
Returns:
|
||||
Dictionary of rich filters
|
||||
|
||||
Raises:
|
||||
ValueError: If strict=True and validation fails
|
||||
"""
|
||||
rich_filters = {}
|
||||
validation_errors = []
|
||||
|
||||
for legacy_key, value in legacy_filters.items():
|
||||
# Skip if value is None or empty
|
||||
if value is None or (isinstance(value, list) and len(value) == 0):
|
||||
continue
|
||||
|
||||
# Skip if legacy key is not in our mappings (not supported in filterset)
|
||||
if legacy_key not in self.FIELD_MAPPINGS:
|
||||
self._add_validation_error(strict, validation_errors, f"Unsupported filter key: {legacy_key}")
|
||||
continue
|
||||
|
||||
# Get the new field name
|
||||
rich_field_name = self.FIELD_MAPPINGS[legacy_key]
|
||||
|
||||
# Handle list values
|
||||
if isinstance(value, list):
|
||||
# Process date fields with helper method
|
||||
if self._process_date_field(rich_field_name, value, strict, validation_errors, rich_filters):
|
||||
continue
|
||||
|
||||
# Regular non-date field processing
|
||||
# Filter out invalid values
|
||||
valid_values = self._filter_valid_values(rich_field_name, value)
|
||||
|
||||
if not valid_values:
|
||||
self._add_validation_error(
|
||||
strict,
|
||||
validation_errors,
|
||||
f"No valid values found for {legacy_key}: {value}",
|
||||
)
|
||||
continue
|
||||
|
||||
# Check for invalid values if in strict mode
|
||||
if strict and len(valid_values) != len(value):
|
||||
invalid_values = [v for v in value if v not in valid_values]
|
||||
self._add_validation_error(
|
||||
strict,
|
||||
validation_errors,
|
||||
f"Invalid values for {legacy_key}: {invalid_values}",
|
||||
)
|
||||
|
||||
# For list values, always use __in operator for non-date fields
|
||||
self._add_rich_filter(rich_filters, rich_field_name, "in", valid_values)
|
||||
|
||||
else:
|
||||
# Handle single values
|
||||
# Process date fields with helper method
|
||||
if self._process_date_field(rich_field_name, [value], strict, validation_errors, rich_filters):
|
||||
continue
|
||||
|
||||
# For non-list values, use __exact operator for non-date fields
|
||||
if self._validate_value(rich_field_name, value):
|
||||
self._add_rich_filter(rich_filters, rich_field_name, "exact", value)
|
||||
else:
|
||||
error_msg = f"Invalid value for {legacy_key}: {value}"
|
||||
self._add_validation_error(strict, validation_errors, error_msg)
|
||||
|
||||
# Raise validation errors if in strict mode
|
||||
if strict and validation_errors:
|
||||
error_message = f"Filter validation errors: {'; '.join(validation_errors)}"
|
||||
raise ValueError(error_message)
|
||||
|
||||
# Convert flat dict to rich filter format
|
||||
return self._format_as_rich_filter(rich_filters)
|
||||
|
||||
def _format_as_rich_filter(self, flat_filters: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Convert a flat dictionary of filters to the proper rich filter format.
|
||||
|
||||
Args:
|
||||
flat_filters: Dictionary with field__lookup keys and values
|
||||
|
||||
Returns:
|
||||
Rich filter format using logical operators (and/or/not)
|
||||
"""
|
||||
if not flat_filters:
|
||||
return {}
|
||||
|
||||
# If only one filter, return as leaf node
|
||||
if len(flat_filters) == 1:
|
||||
key, value = next(iter(flat_filters.items()))
|
||||
return {key: value}
|
||||
|
||||
# Multiple filters: wrap in 'and' operator
|
||||
filter_conditions = []
|
||||
for key, value in flat_filters.items():
|
||||
filter_conditions.append({key: value})
|
||||
|
||||
return {"and": filter_conditions}
|
||||
@@ -0,0 +1,459 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
import json
|
||||
|
||||
# Django imports
|
||||
from django.db.models import Q
|
||||
from django.http import QueryDict
|
||||
|
||||
# Third party imports
|
||||
from django_filters.utils import translate_validation
|
||||
from rest_framework import filters
|
||||
from rest_framework.exceptions import ValidationError as DRFValidationError
|
||||
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
class ComplexFilterBackend(filters.BaseFilterBackend):
|
||||
"""
|
||||
Filter backend that supports complex JSON filtering.
|
||||
|
||||
For full, up-to-date examples and usage, see the package README
|
||||
at `plane/utils/filters/README.md`.
|
||||
"""
|
||||
|
||||
filter_param = "filters"
|
||||
default_max_depth = 5
|
||||
|
||||
def filter_queryset(self, request, queryset, view, filter_data=None):
|
||||
"""Normalize filter input and apply JSON-based filtering.
|
||||
|
||||
Accepts explicit `filter_data` (dict or JSON string) or reads the
|
||||
`filter` query parameter. Enforces JSON-only filtering.
|
||||
"""
|
||||
try:
|
||||
if filter_data is not None:
|
||||
normalized = self._normalize_filter_data(filter_data, "filter_data")
|
||||
return self._apply_json_filter(queryset, normalized, view)
|
||||
|
||||
filter_string = request.query_params.get(self.filter_param, None)
|
||||
if not filter_string:
|
||||
return queryset
|
||||
|
||||
normalized = self._normalize_filter_data(filter_string, "filter")
|
||||
return self._apply_json_filter(queryset, normalized, view)
|
||||
except DRFValidationError:
|
||||
# Propagate validation errors unchanged
|
||||
raise
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
raise
|
||||
|
||||
def _normalize_filter_data(self, raw_filter, source_label):
|
||||
"""Return a dict from raw filter input or raise a ValidationError.
|
||||
|
||||
- raw_filter may be a dict or a JSON string
|
||||
- source_label is used in error messages (e.g., 'filter_data' or 'filter')
|
||||
"""
|
||||
try:
|
||||
if isinstance(raw_filter, str):
|
||||
return json.loads(raw_filter)
|
||||
if isinstance(raw_filter, dict):
|
||||
return raw_filter
|
||||
raise DRFValidationError(
|
||||
{
|
||||
"message": f"'{source_label}' must be a dict or a JSON string.",
|
||||
"code": "invalid_filter_type",
|
||||
}
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
raise DRFValidationError(
|
||||
{
|
||||
"message": (f"Invalid JSON for '{source_label}'. Expected a valid JSON object."),
|
||||
"code": "invalid_json",
|
||||
}
|
||||
)
|
||||
|
||||
def _apply_json_filter(self, queryset, filter_data, view):
|
||||
"""Process a JSON filter structure using Q object composition."""
|
||||
if not filter_data:
|
||||
return queryset
|
||||
|
||||
# Validate structure and depth before field allowlist checks
|
||||
max_depth = self._get_max_depth(view)
|
||||
self._validate_structure(filter_data, max_depth=max_depth, current_depth=1)
|
||||
|
||||
# Validate against the view's FilterSet (only declared filters are allowed)
|
||||
self._validate_fields(filter_data, view)
|
||||
|
||||
# Build combined Q object from the filter tree
|
||||
combined_q = self._evaluate_node(filter_data, view, queryset)
|
||||
if combined_q is None:
|
||||
return queryset
|
||||
|
||||
# Apply the combined Q object to the queryset once
|
||||
return queryset.filter(combined_q)
|
||||
|
||||
def _validate_fields(self, filter_data, view):
|
||||
"""Validate that filtered fields are defined in the view's FilterSet."""
|
||||
filterset_class = getattr(view, "filterset_class", None)
|
||||
allowed_fields = set(filterset_class.base_filters.keys()) if filterset_class else None
|
||||
if not allowed_fields:
|
||||
# If no FilterSet is configured, reject filtering to avoid unintended exposure # noqa: E501
|
||||
raise DRFValidationError(
|
||||
{
|
||||
"message": ("Filtering is not enabled for this endpoint (missing filterset_class)"),
|
||||
"code": "filtering_not_enabled",
|
||||
}
|
||||
)
|
||||
|
||||
# Extract field names from the filter data
|
||||
fields = self._extract_field_names(filter_data)
|
||||
|
||||
# Check if all fields are allowed
|
||||
for field in fields:
|
||||
# Field keys must match FilterSet filter names (including any lookups)
|
||||
# Example: 'sequence_id__gte' should be declared in base_filters
|
||||
# Special-case __range: require the '<base>__range' filter itself
|
||||
if field not in allowed_fields:
|
||||
raise DRFValidationError(
|
||||
{
|
||||
"message": f"Filtering on field '{field}' is not allowed",
|
||||
"code": "invalid_filter_field",
|
||||
}
|
||||
)
|
||||
|
||||
def _transform_field_name_for_validation(self, field_name):
|
||||
"""Hook: Transform a field name before validation.
|
||||
|
||||
Override this in subclasses to handle special field naming conventions.
|
||||
|
||||
Args:
|
||||
field_name: The original field name from the filter data
|
||||
|
||||
Returns:
|
||||
The transformed field name to validate against the FilterSet
|
||||
"""
|
||||
return field_name
|
||||
|
||||
def _extract_field_names(self, filter_data):
|
||||
"""Extract all field names from a nested filter structure"""
|
||||
if isinstance(filter_data, dict):
|
||||
fields = []
|
||||
for key, value in filter_data.items():
|
||||
if key.lower() in ("or", "and", "not"):
|
||||
# This is a logical operator, process its children
|
||||
if key.lower() == "not":
|
||||
# 'not' has a dict as its value, not a list
|
||||
if isinstance(value, dict):
|
||||
fields.extend(self._extract_field_names(value))
|
||||
else:
|
||||
# 'or' and 'and' have lists as their values
|
||||
for item in value:
|
||||
fields.extend(self._extract_field_names(item))
|
||||
else:
|
||||
# This is a field name - apply transformation hook
|
||||
transformed_field = self._transform_field_name_for_validation(key)
|
||||
fields.append(transformed_field)
|
||||
return fields
|
||||
return []
|
||||
|
||||
def _evaluate_node(self, node, view, queryset):
|
||||
"""
|
||||
Recursively evaluate a JSON node into a combined Q object.
|
||||
|
||||
Rules:
|
||||
- leaf dict → evaluated through FilterSet to produce a Q object
|
||||
- {"or": [...]} → Q() | Q() | ... (OR of children)
|
||||
- {"and": [...]} → Q() & Q() & ... (AND of children)
|
||||
- {"not": {...}} → ~Q() (negation of child)
|
||||
|
||||
Returns a Q object that can be applied to a queryset.
|
||||
"""
|
||||
if not isinstance(node, dict):
|
||||
return None
|
||||
|
||||
# 'or' combination - OR of child Q objects
|
||||
if "or" in node:
|
||||
children = node["or"]
|
||||
if not isinstance(children, list) or not children:
|
||||
return None
|
||||
combined_q = Q()
|
||||
for child in children:
|
||||
child_q = self._evaluate_node(child, view, queryset)
|
||||
if child_q is None:
|
||||
continue
|
||||
combined_q |= child_q
|
||||
return combined_q
|
||||
|
||||
# 'and' combination - AND of child Q objects
|
||||
if "and" in node:
|
||||
children = node["and"]
|
||||
if not isinstance(children, list) or not children:
|
||||
return None
|
||||
combined_q = Q()
|
||||
for child in children:
|
||||
child_q = self._evaluate_node(child, view, queryset)
|
||||
if child_q is None:
|
||||
continue
|
||||
combined_q &= child_q
|
||||
return combined_q
|
||||
|
||||
# 'not' negation - negate the child Q object
|
||||
if "not" in node:
|
||||
child = node["not"]
|
||||
if not isinstance(child, dict):
|
||||
return None
|
||||
child_q = self._evaluate_node(child, view, queryset)
|
||||
if child_q is None:
|
||||
return None
|
||||
return ~child_q
|
||||
|
||||
# Leaf dict: evaluate via FilterSet to get a Q object
|
||||
return self._build_leaf_q(node, view, queryset)
|
||||
|
||||
def _preprocess_leaf_conditions(self, leaf_conditions, view, queryset):
|
||||
"""Hook: Preprocess leaf conditions before building Q object.
|
||||
|
||||
Override this in subclasses to transform filter keys/values.
|
||||
For example, custom property filters might need to be transformed
|
||||
from 'customproperty_<id>__<lookup>' to 'customproperty_value__<lookup>'.
|
||||
|
||||
Args:
|
||||
leaf_conditions: Dict of field filters
|
||||
view: The view instance
|
||||
queryset: The queryset being filtered
|
||||
|
||||
Returns:
|
||||
Dict of transformed field filters
|
||||
"""
|
||||
return leaf_conditions
|
||||
|
||||
def _build_leaf_q(self, leaf_conditions, view, queryset):
|
||||
"""Build a Q object from leaf filter conditions using the view's FilterSet.
|
||||
|
||||
We serialize the leaf dict into a QueryDict and let the view's
|
||||
filterset_class perform validation and build a combined Q object
|
||||
from all the field filters.
|
||||
|
||||
Returns a Q object representing all the field conditions in the leaf.
|
||||
"""
|
||||
if not leaf_conditions:
|
||||
return Q()
|
||||
|
||||
# Get the filterset class from the view
|
||||
filterset_class = getattr(view, "filterset_class", None)
|
||||
if not filterset_class:
|
||||
raise DRFValidationError(
|
||||
{
|
||||
"message": ("Filtering requires a filterset_class to be defined on the view"),
|
||||
"code": "filterset_missing",
|
||||
}
|
||||
)
|
||||
|
||||
# Apply preprocessing hook
|
||||
processed_conditions = self._preprocess_leaf_conditions(leaf_conditions, view, queryset)
|
||||
|
||||
# Build a QueryDict from the leaf conditions
|
||||
qd = QueryDict(mutable=True)
|
||||
for key, value in processed_conditions.items():
|
||||
# Default serialization to string; QueryDict expects strings
|
||||
if isinstance(value, list):
|
||||
# Repeat key for list values (e.g., __in)
|
||||
qd.setlist(key, [str(v) for v in value])
|
||||
else:
|
||||
qd[key] = "" if value is None else str(value)
|
||||
|
||||
qd = qd.copy()
|
||||
qd._mutable = False
|
||||
|
||||
# Instantiate the filterset with the actual queryset
|
||||
# Custom filter methods may need access to the queryset for filtering
|
||||
fs = filterset_class(data=qd, queryset=queryset)
|
||||
|
||||
if not fs.is_valid():
|
||||
ve = translate_validation(fs.errors)
|
||||
raise DRFValidationError(
|
||||
{
|
||||
"message": "Invalid filter parameters",
|
||||
"code": "invalid_filterset",
|
||||
"errors": ve.detail,
|
||||
}
|
||||
)
|
||||
|
||||
# Build and return the combined Q object
|
||||
if not hasattr(fs, "build_combined_q"):
|
||||
raise DRFValidationError(
|
||||
{
|
||||
"message": ("FilterSet must have build_combined_q method for complex filtering"),
|
||||
"code": "missing_build_combined_q",
|
||||
}
|
||||
)
|
||||
|
||||
return fs.build_combined_q()
|
||||
|
||||
def _get_max_depth(self, view):
|
||||
"""Return the maximum allowed nesting depth for complex filters.
|
||||
|
||||
Falls back to class default if the view does not specify it or has
|
||||
an invalid value.
|
||||
"""
|
||||
value = getattr(view, "complex_filter_max_depth", self.default_max_depth)
|
||||
try:
|
||||
value_int = int(value)
|
||||
if value_int <= 0:
|
||||
return self.default_max_depth
|
||||
return value_int
|
||||
except Exception:
|
||||
return self.default_max_depth
|
||||
|
||||
def _validate_structure(self, node, max_depth, current_depth):
|
||||
"""Validate JSON structure and enforce nesting depth.
|
||||
|
||||
Rules:
|
||||
- Each object may contain only one logical operator:
|
||||
or/and/not (case-insensitive)
|
||||
- Logical operator objects cannot contain field keys alongside the
|
||||
operator
|
||||
- or/and values must be non-empty lists of dicts
|
||||
- not value must be a dict
|
||||
- Leaf objects must only contain field keys and acceptable values
|
||||
- Depth must not exceed max_depth
|
||||
"""
|
||||
if current_depth > max_depth:
|
||||
raise DRFValidationError(
|
||||
{
|
||||
"message": (f"Filter nesting is too deep (max {max_depth}); found depth {current_depth}"),
|
||||
"code": "max_depth_exceeded",
|
||||
}
|
||||
)
|
||||
|
||||
if not isinstance(node, dict):
|
||||
raise DRFValidationError(
|
||||
{
|
||||
"message": "Each filter node must be a JSON object",
|
||||
"code": "invalid_filter_node",
|
||||
}
|
||||
)
|
||||
|
||||
if not node:
|
||||
raise DRFValidationError(
|
||||
{
|
||||
"message": "Filter objects must not be empty",
|
||||
"code": "empty_filter_object",
|
||||
}
|
||||
)
|
||||
|
||||
logical_keys = [k for k in node.keys() if isinstance(k, str) and k.lower() in ("or", "and", "not")]
|
||||
|
||||
if len(logical_keys) > 1:
|
||||
raise DRFValidationError(
|
||||
{
|
||||
"message": ("A filter object cannot contain multiple logical operators at the same level"),
|
||||
"code": "multiple_logical_operators",
|
||||
}
|
||||
)
|
||||
|
||||
if len(logical_keys) == 1:
|
||||
op_key = logical_keys[0]
|
||||
# must not mix operator with other keys
|
||||
if len(node) != 1:
|
||||
raise DRFValidationError(
|
||||
{
|
||||
"message": (f"Cannot mix logical operator '{op_key}' with field keys at the same level"),
|
||||
"code": "mixed_operator_and_fields",
|
||||
}
|
||||
)
|
||||
|
||||
op = op_key.lower()
|
||||
value = node[op_key]
|
||||
|
||||
if op in ("or", "and"):
|
||||
if not isinstance(value, list) or len(value) == 0:
|
||||
raise DRFValidationError(
|
||||
{
|
||||
"message": f"'{op}' must be a non-empty list of filter objects",
|
||||
"code": "invalid_operator_children",
|
||||
}
|
||||
)
|
||||
for child in value:
|
||||
if not isinstance(child, dict):
|
||||
raise DRFValidationError(
|
||||
{
|
||||
"message": f"All children of '{op}' must be JSON objects",
|
||||
"code": "invalid_operator_child_type",
|
||||
}
|
||||
)
|
||||
self._validate_structure(
|
||||
child,
|
||||
max_depth=max_depth,
|
||||
current_depth=current_depth + 1,
|
||||
)
|
||||
return
|
||||
|
||||
if op == "not":
|
||||
if not isinstance(value, dict):
|
||||
raise DRFValidationError(
|
||||
{
|
||||
"message": "'not' must be a single JSON object",
|
||||
"code": "invalid_not_child",
|
||||
}
|
||||
)
|
||||
self._validate_structure(value, max_depth=max_depth, current_depth=current_depth + 1)
|
||||
return
|
||||
|
||||
# Leaf node: validate fields and values
|
||||
self._validate_leaf(node)
|
||||
|
||||
def _validate_leaf(self, leaf):
|
||||
"""Validate a leaf dict containing field lookups and values."""
|
||||
if not isinstance(leaf, dict) or not leaf:
|
||||
raise DRFValidationError(
|
||||
{
|
||||
"message": "Leaf filter must be a non-empty JSON object",
|
||||
"code": "invalid_leaf",
|
||||
}
|
||||
)
|
||||
|
||||
for key, value in leaf.items():
|
||||
if isinstance(key, str) and key.lower() in ("or", "and", "not"):
|
||||
raise DRFValidationError(
|
||||
{
|
||||
"message": "Logical operators cannot appear in a leaf filter object",
|
||||
"code": "operator_in_leaf",
|
||||
}
|
||||
)
|
||||
|
||||
# Lists/Tuples must contain only scalar values
|
||||
if isinstance(value, (list, tuple)):
|
||||
if len(value) == 0:
|
||||
raise DRFValidationError(
|
||||
{
|
||||
"message": f"List value for '{key}' must not be empty",
|
||||
"code": "empty_list_value",
|
||||
}
|
||||
)
|
||||
for item in value:
|
||||
if not self._is_scalar(item):
|
||||
raise DRFValidationError(
|
||||
{
|
||||
"message": f"List value for '{key}' must contain only scalar items",
|
||||
"code": "non_scalar_list_item",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
# Scalars and None are allowed
|
||||
if not self._is_scalar(value):
|
||||
raise DRFValidationError(
|
||||
{
|
||||
"message": (f"Value for '{key}' must be a scalar, null, or list/tuple of scalars"),
|
||||
"code": "invalid_value_type",
|
||||
}
|
||||
)
|
||||
|
||||
def _is_scalar(self, value):
|
||||
return value is None or isinstance(value, (str, int, float, bool))
|
||||
@@ -0,0 +1,137 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""
|
||||
Utilities for migrating legacy filters to rich filters format.
|
||||
|
||||
This module contains helper functions for data migrations that convert
|
||||
filters fields to rich_filters fields using the LegacyToRichFiltersConverter.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Tuple
|
||||
|
||||
from .converters import LegacyToRichFiltersConverter
|
||||
|
||||
|
||||
logger = logging.getLogger("plane.api.filters.migration")
|
||||
|
||||
|
||||
def migrate_single_model_filters(
|
||||
model_class, model_name: str, converter: LegacyToRichFiltersConverter
|
||||
) -> Tuple[int, int]:
|
||||
"""
|
||||
Migrate filters to rich_filters for a single model.
|
||||
|
||||
Args:
|
||||
model_class: Django model class
|
||||
model_name: Human-readable name for logging
|
||||
converter: Instance of LegacyToRichFiltersConverter
|
||||
|
||||
Returns:
|
||||
Tuple of (updated_count, error_count)
|
||||
"""
|
||||
# Find records that need migration - have filters but empty rich_filters
|
||||
records_to_migrate = model_class.objects.exclude(filters={}).filter(rich_filters={})
|
||||
|
||||
if records_to_migrate.count() == 0:
|
||||
logger.info(f"No {model_name} records need migration")
|
||||
return 0, 0
|
||||
|
||||
logger.info(f"Found {records_to_migrate.count()} {model_name} records to migrate")
|
||||
|
||||
updated_records = []
|
||||
conversion_errors = 0
|
||||
|
||||
for record in records_to_migrate:
|
||||
try:
|
||||
if record.filters: # Double check that filters is not empty
|
||||
rich_filters = converter.convert(record.filters, strict=False)
|
||||
record.rich_filters = rich_filters
|
||||
updated_records.append(record)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to convert filters for {model_name} ID {record.id}: {str(e)}")
|
||||
conversion_errors += 1
|
||||
continue
|
||||
|
||||
# Bulk update all successfully converted records
|
||||
if updated_records:
|
||||
model_class.objects.bulk_update(updated_records, ["rich_filters"], batch_size=1000)
|
||||
logger.info(f"Successfully updated {len(updated_records)} {model_name} records")
|
||||
|
||||
return len(updated_records), conversion_errors
|
||||
|
||||
|
||||
def migrate_models_filters_to_rich_filters(
|
||||
models_to_migrate: Dict[str, Any],
|
||||
converter: LegacyToRichFiltersConverter,
|
||||
) -> Dict[str, Tuple[int, int]]:
|
||||
"""
|
||||
Migrate legacy filters to rich_filters format for provided models.
|
||||
|
||||
Args:
|
||||
models_to_migrate: Dict mapping model names to model classes
|
||||
|
||||
Returns:
|
||||
Dictionary mapping model names to (updated_count, error_count) tuples
|
||||
"""
|
||||
# Initialize the converter with default settings
|
||||
|
||||
logger.info("Starting filters to rich_filters migration for all models")
|
||||
|
||||
results = {}
|
||||
total_updated = 0
|
||||
total_errors = 0
|
||||
|
||||
for model_name, model_class in models_to_migrate.items():
|
||||
try:
|
||||
updated_count, error_count = migrate_single_model_filters(model_class, model_name, converter)
|
||||
|
||||
results[model_name] = (updated_count, error_count)
|
||||
total_updated += updated_count
|
||||
total_errors += error_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to migrate {model_name}: {str(e)}")
|
||||
results[model_name] = (0, 1)
|
||||
total_errors += 1
|
||||
continue
|
||||
|
||||
# Log final summary
|
||||
logger.info(f"Migration completed for all models. Total updated: {total_updated}, Total errors: {total_errors}")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def clear_models_rich_filters(models_to_clear: Dict[str, Any]) -> Dict[str, int]:
|
||||
"""
|
||||
Clear rich_filters field for provided models (for reverse migration).
|
||||
|
||||
Args:
|
||||
models_to_clear: Dictionary mapping model names to model classes
|
||||
|
||||
Returns:
|
||||
Dictionary mapping model names to count of cleared records
|
||||
"""
|
||||
logger.info("Starting reverse migration - clearing rich_filters for all models")
|
||||
|
||||
results = {}
|
||||
total_cleared = 0
|
||||
|
||||
for model_name, model_class in models_to_clear.items():
|
||||
try:
|
||||
# Clear rich_filters for all records that have them
|
||||
updated_count = model_class.objects.exclude(rich_filters={}).update(rich_filters={})
|
||||
results[model_name] = updated_count
|
||||
total_cleared += updated_count
|
||||
logger.info(f"Cleared rich_filters for {updated_count} {model_name} records")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to clear rich_filters for {model_name}: {str(e)}")
|
||||
results[model_name] = 0
|
||||
continue
|
||||
|
||||
logger.info(f"Reverse migration completed. Total cleared: {total_cleared}")
|
||||
return results
|
||||
@@ -0,0 +1,266 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
import copy
|
||||
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django_filters import FilterSet, filters
|
||||
|
||||
from plane.db.models import Issue
|
||||
|
||||
|
||||
class UUIDInFilter(filters.BaseInFilter, filters.UUIDFilter):
|
||||
pass
|
||||
|
||||
|
||||
class CharInFilter(filters.BaseInFilter, filters.CharFilter):
|
||||
pass
|
||||
|
||||
|
||||
class BaseFilterSet(FilterSet):
|
||||
@classmethod
|
||||
def get_filters(cls):
|
||||
"""
|
||||
Get all filters for the filterset, including dynamically created __exact filters.
|
||||
"""
|
||||
# Get the standard filters first
|
||||
filters = super().get_filters()
|
||||
|
||||
# Add __exact versions for filters that have 'exact' lookup
|
||||
exact_filters = {}
|
||||
for filter_name, filter_obj in filters.items():
|
||||
if hasattr(filter_obj, "lookup_expr") and filter_obj.lookup_expr == "exact":
|
||||
exact_field_name = f"{filter_name}__exact"
|
||||
if exact_field_name not in filters:
|
||||
# Copy the filter object as-is and assign it to the new name
|
||||
exact_filters[exact_field_name] = copy.deepcopy(filter_obj)
|
||||
|
||||
# Add the exact filters to the main filters dict
|
||||
filters.update(exact_filters)
|
||||
return filters
|
||||
|
||||
def build_combined_q(self):
|
||||
"""
|
||||
Build a combined Q object from all bound filters.
|
||||
|
||||
For filters with custom methods, we call them and expect Q objects (or wrap
|
||||
QuerySets as subqueries for backward compatibility).
|
||||
For standard field filters, we build Q objects directly from field lookups.
|
||||
|
||||
Returns:
|
||||
Q object representing all filter conditions combined.
|
||||
"""
|
||||
# Ensure form validation has occurred
|
||||
self.errors
|
||||
|
||||
combined_q = Q()
|
||||
|
||||
# Handle case where cleaned_data might be None or empty
|
||||
if not self.form.cleaned_data:
|
||||
return combined_q
|
||||
|
||||
# Only process filters that were actually provided in the request data
|
||||
# This avoids processing all declared filters with None/empty default values
|
||||
provided_filters = set(self.data.keys()) if self.data else set()
|
||||
|
||||
for name, value in self.form.cleaned_data.items():
|
||||
# Skip filters that weren't provided in the request
|
||||
if name not in provided_filters:
|
||||
continue
|
||||
|
||||
f = self.filters[name]
|
||||
|
||||
# Build the Q object for this filter
|
||||
if f.method is not None:
|
||||
# Custom filter method - call it to get Q object
|
||||
res = f.filter(self.queryset, value)
|
||||
if isinstance(res, Q):
|
||||
q_piece = res
|
||||
elif isinstance(res, models.QuerySet):
|
||||
# Backward compatibility: wrap QuerySet as subquery
|
||||
q_piece = Q(pk__in=res.values("pk"))
|
||||
else:
|
||||
raise TypeError(
|
||||
f"Filter method '{name}' must return Q object or QuerySet, got {type(res).__name__}"
|
||||
)
|
||||
else:
|
||||
# Standard field filter - build Q object directly
|
||||
lookup = f"{f.field_name}__{f.lookup_expr}"
|
||||
q_piece = Q(**{lookup: value})
|
||||
|
||||
# Apply exclude/include logic
|
||||
if getattr(f, "exclude", False):
|
||||
combined_q &= ~q_piece
|
||||
else:
|
||||
combined_q &= q_piece
|
||||
|
||||
return combined_q
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
"""
|
||||
Override to use Q-based filtering for compatibility with DjangoFilterBackend.
|
||||
|
||||
This allows the same filterset to work with both ComplexFilterBackend
|
||||
(which calls build_combined_q directly) and DjangoFilterBackend
|
||||
(which calls this method).
|
||||
"""
|
||||
# Ensure form validation
|
||||
self.errors
|
||||
|
||||
# Build combined Q and apply to queryset
|
||||
combined_q = self.build_combined_q()
|
||||
qs = queryset.filter(combined_q)
|
||||
|
||||
# Apply distinct if any filter requires it (typically for many-to-many relations)
|
||||
for f in self.filters.values():
|
||||
if getattr(f, "distinct", False):
|
||||
return qs.distinct()
|
||||
|
||||
return qs
|
||||
|
||||
|
||||
class IssueFilterSet(BaseFilterSet):
|
||||
# Custom filter methods to handle soft delete exclusion for relations
|
||||
|
||||
assignee_id = filters.UUIDFilter(method="filter_assignee_id")
|
||||
assignee_id__in = UUIDInFilter(method="filter_assignee_id_in", lookup_expr="in")
|
||||
|
||||
cycle_id = filters.UUIDFilter(method="filter_cycle_id")
|
||||
cycle_id__in = UUIDInFilter(method="filter_cycle_id_in", lookup_expr="in")
|
||||
|
||||
module_id = filters.UUIDFilter(method="filter_module_id")
|
||||
module_id__in = UUIDInFilter(method="filter_module_id_in", lookup_expr="in")
|
||||
|
||||
mention_id = filters.UUIDFilter(method="filter_mention_id")
|
||||
mention_id__in = UUIDInFilter(method="filter_mention_id_in", lookup_expr="in")
|
||||
|
||||
label_id = filters.UUIDFilter(method="filter_label_id")
|
||||
label_id__in = UUIDInFilter(method="filter_label_id_in", lookup_expr="in")
|
||||
|
||||
# Direct field lookups remain the same
|
||||
created_by_id = filters.UUIDFilter(field_name="created_by_id")
|
||||
created_by_id__in = UUIDInFilter(field_name="created_by_id", lookup_expr="in")
|
||||
|
||||
is_archived = filters.BooleanFilter(method="filter_is_archived")
|
||||
|
||||
state_group = filters.CharFilter(field_name="state__group")
|
||||
state_group__in = CharInFilter(field_name="state__group", lookup_expr="in")
|
||||
|
||||
state_id = filters.UUIDFilter(field_name="state_id")
|
||||
state_id__in = UUIDInFilter(field_name="state_id", lookup_expr="in")
|
||||
|
||||
project_id = filters.UUIDFilter(field_name="project_id")
|
||||
project_id__in = UUIDInFilter(field_name="project_id", lookup_expr="in")
|
||||
|
||||
subscriber_id = filters.UUIDFilter(method="filter_subscriber_id")
|
||||
subscriber_id__in = UUIDInFilter(method="filter_subscriber_id_in", lookup_expr="in")
|
||||
|
||||
class Meta:
|
||||
model = Issue
|
||||
fields = {
|
||||
"start_date": ["exact", "range"],
|
||||
"target_date": ["exact", "range"],
|
||||
"created_at": ["exact", "range"],
|
||||
"updated_at": ["exact", "range"],
|
||||
"is_draft": ["exact"],
|
||||
"priority": ["exact", "in"],
|
||||
}
|
||||
|
||||
def filter_is_archived(self, queryset, name, value):
|
||||
"""
|
||||
Convenience filter: archived=true -> archived_at is not null,
|
||||
archived=false -> archived_at is null
|
||||
"""
|
||||
if value in (True, "true", "True", 1, "1"):
|
||||
return Q(archived_at__isnull=False)
|
||||
if value in (False, "false", "False", 0, "0"):
|
||||
return Q(archived_at__isnull=True)
|
||||
return Q() # No filter
|
||||
|
||||
# Filter methods with soft delete exclusion for relations
|
||||
|
||||
def filter_assignee_id(self, queryset, name, value):
|
||||
"""Filter by assignee ID, excluding soft deleted users"""
|
||||
return Q(
|
||||
issue_assignee__assignee_id=value,
|
||||
issue_assignee__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
def filter_assignee_id_in(self, queryset, name, value):
|
||||
"""Filter by assignee IDs (in), excluding soft deleted users"""
|
||||
return Q(
|
||||
issue_assignee__assignee_id__in=value,
|
||||
issue_assignee__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
def filter_cycle_id(self, queryset, name, value):
|
||||
"""Filter by cycle ID, excluding soft deleted cycles"""
|
||||
return Q(
|
||||
issue_cycle__cycle_id=value,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
def filter_cycle_id_in(self, queryset, name, value):
|
||||
"""Filter by cycle IDs (in), excluding soft deleted cycles"""
|
||||
return Q(
|
||||
issue_cycle__cycle_id__in=value,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
def filter_module_id(self, queryset, name, value):
|
||||
"""Filter by module ID, excluding soft deleted modules"""
|
||||
return Q(
|
||||
issue_module__module_id=value,
|
||||
issue_module__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
def filter_module_id_in(self, queryset, name, value):
|
||||
"""Filter by module IDs (in), excluding soft deleted modules"""
|
||||
return Q(
|
||||
issue_module__module_id__in=value,
|
||||
issue_module__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
def filter_mention_id(self, queryset, name, value):
|
||||
"""Filter by mention ID, excluding soft deleted users"""
|
||||
return Q(
|
||||
issue_mention__mention_id=value,
|
||||
issue_mention__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
def filter_mention_id_in(self, queryset, name, value):
|
||||
"""Filter by mention IDs (in), excluding soft deleted users"""
|
||||
return Q(
|
||||
issue_mention__mention_id__in=value,
|
||||
issue_mention__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
def filter_label_id(self, queryset, name, value):
|
||||
"""Filter by label ID, excluding soft deleted labels"""
|
||||
return Q(
|
||||
label_issue__label_id=value,
|
||||
label_issue__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
def filter_label_id_in(self, queryset, name, value):
|
||||
"""Filter by label IDs (in), excluding soft deleted labels"""
|
||||
return Q(
|
||||
label_issue__label_id__in=value,
|
||||
label_issue__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
def filter_subscriber_id(self, queryset, name, value):
|
||||
"""Filter by subscriber ID, excluding soft deleted users"""
|
||||
return Q(
|
||||
issue_subscribers__subscriber_id=value,
|
||||
issue_subscribers__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
def filter_subscriber_id_in(self, queryset, name, value):
|
||||
"""Filter by subscriber IDs (in), excluding soft deleted users"""
|
||||
return Q(
|
||||
issue_subscribers__subscriber_id__in=value,
|
||||
issue_subscribers__deleted_at__isnull=True,
|
||||
)
|
||||
@@ -0,0 +1,87 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# python imports
|
||||
from math import ceil
|
||||
|
||||
# constants
|
||||
PAGINATOR_MAX_LIMIT = 1000
|
||||
|
||||
|
||||
class PaginateCursor:
|
||||
def __init__(self, current_page_size: int, current_page: int, offset: int):
|
||||
self.current_page_size = current_page_size
|
||||
self.current_page = current_page
|
||||
self.offset = offset
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.current_page_size}:{self.current_page}:{self.offset}"
|
||||
|
||||
@classmethod
|
||||
def from_string(self, value):
|
||||
"""Return the cursor value from string format"""
|
||||
try:
|
||||
bits = value.split(":")
|
||||
if len(bits) != 3:
|
||||
raise ValueError("Cursor must be in the format 'value:offset:is_prev'")
|
||||
return self(int(bits[0]), int(bits[1]), int(bits[2]))
|
||||
except (TypeError, ValueError) as e:
|
||||
raise ValueError(f"Invalid cursor format: {e}")
|
||||
|
||||
|
||||
def paginate(base_queryset, queryset, cursor, on_result):
|
||||
# validating for cursor
|
||||
if cursor is None:
|
||||
cursor_object = PaginateCursor(PAGINATOR_MAX_LIMIT, 0, 0)
|
||||
else:
|
||||
cursor_object = PaginateCursor.from_string(cursor)
|
||||
|
||||
# getting the issues count
|
||||
total_results = base_queryset.count()
|
||||
page_size = min(cursor_object.current_page_size, PAGINATOR_MAX_LIMIT)
|
||||
|
||||
# getting the total pages available based on the page size
|
||||
total_pages = ceil(total_results / page_size)
|
||||
|
||||
# Calculate the start and end index for the paginated data
|
||||
start_index = 0
|
||||
if cursor_object.current_page > 0:
|
||||
start_index = cursor_object.current_page * page_size
|
||||
end_index = min(start_index + page_size, total_results)
|
||||
|
||||
# Get the paginated data
|
||||
paginated_data = queryset[start_index:end_index]
|
||||
|
||||
# Create the pagination info object
|
||||
prev_cursor = f"{page_size}:{cursor_object.current_page - 1}:0"
|
||||
cursor = f"{page_size}:{cursor_object.current_page}:0"
|
||||
next_cursor = None
|
||||
if end_index < total_results:
|
||||
next_cursor = f"{page_size}:{cursor_object.current_page + 1}:0"
|
||||
|
||||
prev_page_results = False
|
||||
if cursor_object.current_page > 0:
|
||||
prev_page_results = True
|
||||
|
||||
next_page_results = False
|
||||
if next_cursor:
|
||||
next_page_results = True
|
||||
|
||||
if on_result:
|
||||
paginated_data = on_result(paginated_data)
|
||||
|
||||
# returning the result
|
||||
paginated_data = {
|
||||
"prev_cursor": prev_cursor,
|
||||
"cursor": cursor,
|
||||
"next_cursor": next_cursor,
|
||||
"prev_page_results": prev_page_results,
|
||||
"next_page_results": next_page_results,
|
||||
"page_count": len(paginated_data),
|
||||
"total_results": total_results,
|
||||
"total_pages": total_pages,
|
||||
"results": paginated_data,
|
||||
}
|
||||
|
||||
return paginated_data
|
||||
@@ -0,0 +1,217 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Django imports
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models import Q, UUIDField, Value, QuerySet, OuterRef, Subquery
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import (
|
||||
Cycle,
|
||||
Issue,
|
||||
Label,
|
||||
Module,
|
||||
Project,
|
||||
ProjectMember,
|
||||
State,
|
||||
WorkspaceMember,
|
||||
IssueAssignee,
|
||||
ModuleIssue,
|
||||
IssueLabel,
|
||||
)
|
||||
from typing import Optional, Dict, Tuple, Any, Union, List
|
||||
|
||||
|
||||
def issue_queryset_grouper(
|
||||
queryset: QuerySet[Issue],
|
||||
group_by: Optional[str],
|
||||
sub_group_by: Optional[str],
|
||||
) -> QuerySet[Issue]:
|
||||
FIELD_MAPPER: Dict[str, str] = {
|
||||
"label_ids": "labels__id",
|
||||
"assignee_ids": "assignees__id",
|
||||
"module_ids": "issue_module__module_id",
|
||||
}
|
||||
|
||||
GROUP_FILTER_MAPPER: Dict[str, Q] = {
|
||||
"assignees__id": Q(issue_assignee__deleted_at__isnull=True),
|
||||
"labels__id": Q(label_issue__deleted_at__isnull=True),
|
||||
"issue_module__module_id": Q(issue_module__deleted_at__isnull=True),
|
||||
}
|
||||
|
||||
for group_key in [group_by, sub_group_by]:
|
||||
if group_key in GROUP_FILTER_MAPPER:
|
||||
queryset = queryset.filter(GROUP_FILTER_MAPPER[group_key])
|
||||
|
||||
issue_assignee_subquery = Subquery(
|
||||
IssueAssignee.objects.filter(
|
||||
issue_id=OuterRef("pk"),
|
||||
deleted_at__isnull=True,
|
||||
)
|
||||
.values("issue_id")
|
||||
.annotate(arr=ArrayAgg("assignee_id", distinct=True))
|
||||
.values("arr")
|
||||
)
|
||||
|
||||
issue_module_subquery = Subquery(
|
||||
ModuleIssue.objects.filter(
|
||||
issue_id=OuterRef("pk"),
|
||||
deleted_at__isnull=True,
|
||||
module__archived_at__isnull=True,
|
||||
)
|
||||
.values("issue_id")
|
||||
.annotate(arr=ArrayAgg("module_id", distinct=True))
|
||||
.values("arr")
|
||||
)
|
||||
|
||||
issue_label_subquery = Subquery(
|
||||
IssueLabel.objects.filter(issue_id=OuterRef("pk"), deleted_at__isnull=True)
|
||||
.values("issue_id")
|
||||
.annotate(arr=ArrayAgg("label_id", distinct=True))
|
||||
.values("arr")
|
||||
)
|
||||
|
||||
annotations_map: Dict[str, Tuple[str, Q]] = {
|
||||
"assignee_ids": Coalesce(issue_assignee_subquery, Value([], output_field=ArrayField(UUIDField()))),
|
||||
"label_ids": Coalesce(issue_label_subquery, Value([], output_field=ArrayField(UUIDField()))),
|
||||
"module_ids": Coalesce(issue_module_subquery, Value([], output_field=ArrayField(UUIDField()))),
|
||||
}
|
||||
|
||||
default_annotations: Dict[str, Any] = {}
|
||||
|
||||
for key, expression in annotations_map.items():
|
||||
if FIELD_MAPPER.get(key) in {group_by, sub_group_by}:
|
||||
continue
|
||||
default_annotations[key] = expression
|
||||
|
||||
return queryset.annotate(**default_annotations)
|
||||
|
||||
|
||||
def issue_on_results(
|
||||
issues: QuerySet[Issue],
|
||||
group_by: Optional[str],
|
||||
sub_group_by: Optional[str],
|
||||
) -> List[Dict[str, Any]]:
|
||||
FIELD_MAPPER: Dict[str, str] = {
|
||||
"labels__id": "label_ids",
|
||||
"assignees__id": "assignee_ids",
|
||||
"issue_module__module_id": "module_ids",
|
||||
}
|
||||
|
||||
original_list: List[str] = ["assignee_ids", "label_ids", "module_ids"]
|
||||
|
||||
required_fields: List[str] = [
|
||||
"id",
|
||||
"name",
|
||||
"state_id",
|
||||
"sort_order",
|
||||
"completed_at",
|
||||
"estimate_point",
|
||||
"priority",
|
||||
"start_date",
|
||||
"target_date",
|
||||
"sequence_id",
|
||||
"project_id",
|
||||
"parent_id",
|
||||
"cycle_id",
|
||||
"sub_issues_count",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"attachment_count",
|
||||
"link_count",
|
||||
"is_draft",
|
||||
"archived_at",
|
||||
"state__group",
|
||||
]
|
||||
|
||||
if group_by in FIELD_MAPPER:
|
||||
original_list.remove(FIELD_MAPPER[group_by])
|
||||
original_list.append(group_by)
|
||||
|
||||
if sub_group_by in FIELD_MAPPER:
|
||||
original_list.remove(FIELD_MAPPER[sub_group_by])
|
||||
original_list.append(sub_group_by)
|
||||
|
||||
required_fields.extend(original_list)
|
||||
return list(issues.values(*required_fields))
|
||||
|
||||
|
||||
def issue_group_values(
|
||||
field: str,
|
||||
slug: str,
|
||||
project_id: Optional[str] = None,
|
||||
filters: Dict[str, Any] = {},
|
||||
queryset: Optional[QuerySet] = None,
|
||||
) -> List[Union[str, Any]]:
|
||||
if field == "state_id":
|
||||
queryset = State.objects.filter(is_triage=False, workspace__slug=slug).values_list("id", flat=True)
|
||||
if project_id:
|
||||
return list(queryset.filter(project_id=project_id))
|
||||
return list(queryset)
|
||||
|
||||
if field == "labels__id":
|
||||
queryset = Label.objects.filter(workspace__slug=slug).values_list("id", flat=True)
|
||||
if project_id:
|
||||
return list(queryset.filter(project_id=project_id)) + ["None"]
|
||||
return list(queryset) + ["None"]
|
||||
|
||||
if field == "assignees__id":
|
||||
if project_id:
|
||||
return list(
|
||||
ProjectMember.objects.filter(workspace__slug=slug, project_id=project_id, is_active=True).values_list(
|
||||
"member_id", flat=True
|
||||
)
|
||||
)
|
||||
return list(
|
||||
WorkspaceMember.objects.filter(workspace__slug=slug, is_active=True).values_list("member_id", flat=True)
|
||||
)
|
||||
|
||||
if field == "issue_module__module_id":
|
||||
queryset = Module.objects.filter(workspace__slug=slug).values_list("id", flat=True)
|
||||
if project_id:
|
||||
return list(queryset.filter(project_id=project_id)) + ["None"]
|
||||
return list(queryset) + ["None"]
|
||||
|
||||
if field == "cycle_id":
|
||||
queryset = Cycle.objects.filter(workspace__slug=slug).values_list("id", flat=True)
|
||||
if project_id:
|
||||
return list(queryset.filter(project_id=project_id)) + ["None"]
|
||||
return list(queryset) + ["None"]
|
||||
|
||||
if field == "project_id":
|
||||
queryset = Project.objects.filter(workspace__slug=slug).values_list("id", flat=True)
|
||||
return list(queryset)
|
||||
|
||||
if field == "priority":
|
||||
return ["low", "medium", "high", "urgent", "none"]
|
||||
|
||||
if field == "state__group":
|
||||
return ["backlog", "unstarted", "started", "completed", "cancelled"]
|
||||
|
||||
if field == "target_date":
|
||||
queryset = queryset.values_list("target_date", flat=True).distinct()
|
||||
if project_id:
|
||||
return list(queryset.filter(project_id=project_id))
|
||||
else:
|
||||
return list(queryset)
|
||||
|
||||
if field == "start_date":
|
||||
queryset = queryset.values_list("start_date", flat=True).distinct()
|
||||
if project_id:
|
||||
return list(queryset.filter(project_id=project_id))
|
||||
else:
|
||||
return list(queryset)
|
||||
|
||||
if field == "created_by":
|
||||
queryset = queryset.values_list("created_by", flat=True).distinct()
|
||||
if project_id:
|
||||
return list(queryset.filter(project_id=project_id))
|
||||
else:
|
||||
return list(queryset)
|
||||
|
||||
return []
|
||||
@@ -0,0 +1,71 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.http import HttpRequest
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.request import Request
|
||||
|
||||
# Module imports
|
||||
from plane.utils.ip_address import get_client_ip
|
||||
|
||||
|
||||
def base_host(
|
||||
request: Request | HttpRequest,
|
||||
is_admin: bool = False,
|
||||
is_space: bool = False,
|
||||
is_app: bool = False,
|
||||
) -> str:
|
||||
"""Utility function to return host / origin from the request"""
|
||||
# Calculate the base origin from request
|
||||
base_origin = settings.WEB_URL or settings.APP_BASE_URL
|
||||
|
||||
if not base_origin:
|
||||
raise ImproperlyConfigured("APP_BASE_URL or WEB_URL is not set")
|
||||
|
||||
# Admin redirection
|
||||
if is_admin:
|
||||
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("/"):
|
||||
admin_base_path += "/"
|
||||
|
||||
if settings.ADMIN_BASE_URL:
|
||||
return settings.ADMIN_BASE_URL + admin_base_path
|
||||
else:
|
||||
return base_origin + admin_base_path
|
||||
|
||||
# Space redirection
|
||||
if is_space:
|
||||
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("/"):
|
||||
space_base_path += "/"
|
||||
|
||||
if settings.SPACE_BASE_URL:
|
||||
return settings.SPACE_BASE_URL + space_base_path
|
||||
else:
|
||||
return base_origin + space_base_path
|
||||
|
||||
# App Redirection
|
||||
if is_app:
|
||||
if settings.APP_BASE_URL:
|
||||
return settings.APP_BASE_URL
|
||||
else:
|
||||
return base_origin
|
||||
|
||||
return base_origin
|
||||
|
||||
|
||||
def user_ip(request: Request | HttpRequest) -> str:
|
||||
return get_client_ip(request=request)
|
||||
@@ -0,0 +1,31 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
from io import StringIO
|
||||
from html.parser import HTMLParser
|
||||
|
||||
|
||||
class MLStripper(HTMLParser):
|
||||
"""
|
||||
Markup Language Stripper
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.reset()
|
||||
self.strict = False
|
||||
self.convert_charrefs = True
|
||||
self.text = StringIO()
|
||||
|
||||
def handle_data(self, d):
|
||||
self.text.write(d)
|
||||
|
||||
def get_data(self):
|
||||
return self.text.getvalue()
|
||||
|
||||
|
||||
def strip_tags(html):
|
||||
s = MLStripper()
|
||||
s.feed(html)
|
||||
return s.get_data()
|
||||
@@ -0,0 +1,21 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
import pkgutil
|
||||
import six
|
||||
|
||||
|
||||
def import_submodules(context, root_module, path):
|
||||
"""
|
||||
Import all submodules and register them in the ``context`` namespace.
|
||||
>>> import_submodules(locals(), __name__, __path__)
|
||||
"""
|
||||
for loader, module_name, is_pkg in pkgutil.walk_packages(path, root_module + "."):
|
||||
# this causes a Runtime error with model conflicts
|
||||
# module = loader.find_module(module_name).load_module(module_name)
|
||||
module = __import__(module_name, globals(), locals(), ["__name__"])
|
||||
for k, v in six.iteritems(vars(module)):
|
||||
if not k.startswith("_"):
|
||||
context[k] = v
|
||||
context[module_name] = module
|
||||
@@ -0,0 +1,8 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
from .core import core_config_variables
|
||||
from .extended import extended_config_variables
|
||||
|
||||
instance_config_variables = [*core_config_variables, *extended_config_variables]
|
||||
@@ -0,0 +1,261 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
import os
|
||||
|
||||
authentication_config_variables = [
|
||||
{
|
||||
"key": "ENABLE_SIGNUP",
|
||||
"value": os.environ.get("ENABLE_SIGNUP", "1"),
|
||||
"category": "AUTHENTICATION",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "ENABLE_EMAIL_PASSWORD",
|
||||
"value": os.environ.get("ENABLE_EMAIL_PASSWORD", "1"),
|
||||
"category": "AUTHENTICATION",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "ENABLE_MAGIC_LINK_LOGIN",
|
||||
"value": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "0"),
|
||||
"category": "AUTHENTICATION",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
]
|
||||
|
||||
workspace_management_config_variables = [
|
||||
{
|
||||
"key": "DISABLE_WORKSPACE_CREATION",
|
||||
"value": os.environ.get("DISABLE_WORKSPACE_CREATION", "0"),
|
||||
"category": "WORKSPACE_MANAGEMENT",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
]
|
||||
|
||||
google_config_variables = [
|
||||
{
|
||||
"key": "GOOGLE_CLIENT_ID",
|
||||
"value": os.environ.get("GOOGLE_CLIENT_ID"),
|
||||
"category": "GOOGLE",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "GOOGLE_CLIENT_SECRET",
|
||||
"value": os.environ.get("GOOGLE_CLIENT_SECRET"),
|
||||
"category": "GOOGLE",
|
||||
"is_encrypted": True,
|
||||
},
|
||||
{
|
||||
"key": "ENABLE_GOOGLE_SYNC",
|
||||
"value": os.environ.get("ENABLE_GOOGLE_SYNC", "0"),
|
||||
"category": "GOOGLE",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
]
|
||||
|
||||
github_config_variables = [
|
||||
{
|
||||
"key": "GITHUB_CLIENT_ID",
|
||||
"value": os.environ.get("GITHUB_CLIENT_ID"),
|
||||
"category": "GITHUB",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "GITHUB_CLIENT_SECRET",
|
||||
"value": os.environ.get("GITHUB_CLIENT_SECRET"),
|
||||
"category": "GITHUB",
|
||||
"is_encrypted": True,
|
||||
},
|
||||
{
|
||||
"key": "GITHUB_ORGANIZATION_ID",
|
||||
"value": os.environ.get("GITHUB_ORGANIZATION_ID"),
|
||||
"category": "GITHUB",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "ENABLE_GITHUB_SYNC",
|
||||
"value": os.environ.get("ENABLE_GITHUB_SYNC", "0"),
|
||||
"category": "GITHUB",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
gitlab_config_variables = [
|
||||
{
|
||||
"key": "GITLAB_HOST",
|
||||
"value": os.environ.get("GITLAB_HOST"),
|
||||
"category": "GITLAB",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "GITLAB_CLIENT_ID",
|
||||
"value": os.environ.get("GITLAB_CLIENT_ID"),
|
||||
"category": "GITLAB",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "GITLAB_CLIENT_SECRET",
|
||||
"value": os.environ.get("GITLAB_CLIENT_SECRET"),
|
||||
"category": "GITLAB",
|
||||
"is_encrypted": True,
|
||||
},
|
||||
{
|
||||
"key": "ENABLE_GITLAB_SYNC",
|
||||
"value": os.environ.get("ENABLE_GITLAB_SYNC", "0"),
|
||||
"category": "GITLAB",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
]
|
||||
|
||||
gitea_config_variables = [
|
||||
{
|
||||
"key": "IS_GITEA_ENABLED",
|
||||
"value": os.environ.get("IS_GITEA_ENABLED", "0"),
|
||||
"category": "GITEA",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "GITEA_HOST",
|
||||
"value": os.environ.get("GITEA_HOST"),
|
||||
"category": "GITEA",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "GITEA_CLIENT_ID",
|
||||
"value": os.environ.get("GITEA_CLIENT_ID"),
|
||||
"category": "GITEA",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "GITEA_CLIENT_SECRET",
|
||||
"value": os.environ.get("GITEA_CLIENT_SECRET"),
|
||||
"category": "GITEA",
|
||||
"is_encrypted": True,
|
||||
},
|
||||
{
|
||||
"key": "ENABLE_GITEA_SYNC",
|
||||
"value": os.environ.get("ENABLE_GITEA_SYNC", "0"),
|
||||
"category": "GITEA",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
]
|
||||
|
||||
smtp_config_variables = [
|
||||
{
|
||||
"key": "ENABLE_SMTP",
|
||||
"value": os.environ.get("ENABLE_SMTP", "0"),
|
||||
"category": "SMTP",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "EMAIL_HOST",
|
||||
"value": os.environ.get("EMAIL_HOST", ""),
|
||||
"category": "SMTP",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "EMAIL_HOST_USER",
|
||||
"value": os.environ.get("EMAIL_HOST_USER", ""),
|
||||
"category": "SMTP",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "EMAIL_HOST_PASSWORD",
|
||||
"value": os.environ.get("EMAIL_HOST_PASSWORD", ""),
|
||||
"category": "SMTP",
|
||||
"is_encrypted": True,
|
||||
},
|
||||
{
|
||||
"key": "EMAIL_PORT",
|
||||
"value": os.environ.get("EMAIL_PORT", "587"),
|
||||
"category": "SMTP",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "EMAIL_FROM",
|
||||
"value": os.environ.get("EMAIL_FROM", ""),
|
||||
"category": "SMTP",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "EMAIL_USE_TLS",
|
||||
"value": os.environ.get("EMAIL_USE_TLS", "1"),
|
||||
"category": "SMTP",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "EMAIL_USE_SSL",
|
||||
"value": os.environ.get("EMAIL_USE_SSL", "0"),
|
||||
"category": "SMTP",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
]
|
||||
|
||||
llm_config_variables = [
|
||||
{
|
||||
"key": "LLM_API_KEY",
|
||||
"value": os.environ.get("LLM_API_KEY"),
|
||||
"category": "AI",
|
||||
"is_encrypted": True,
|
||||
},
|
||||
{
|
||||
"key": "LLM_PROVIDER",
|
||||
"value": os.environ.get("LLM_PROVIDER", "openai"),
|
||||
"category": "AI",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "LLM_MODEL",
|
||||
"value": os.environ.get("LLM_MODEL", "gpt-4o-mini"),
|
||||
"category": "AI",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
# Deprecated, use LLM_MODEL
|
||||
{
|
||||
"key": "GPT_ENGINE",
|
||||
"value": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"),
|
||||
"category": "AI",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
]
|
||||
|
||||
unsplash_config_variables = [
|
||||
{
|
||||
"key": "UNSPLASH_ACCESS_KEY",
|
||||
"value": os.environ.get("UNSPLASH_ACCESS_KEY", ""),
|
||||
"category": "UNSPLASH",
|
||||
"is_encrypted": True,
|
||||
},
|
||||
]
|
||||
|
||||
intercom_config_variables = [
|
||||
{
|
||||
"key": "IS_INTERCOM_ENABLED",
|
||||
"value": os.environ.get("IS_INTERCOM_ENABLED", "1"),
|
||||
"category": "INTERCOM",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "INTERCOM_APP_ID",
|
||||
"value": os.environ.get("INTERCOM_APP_ID", ""),
|
||||
"category": "INTERCOM",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
]
|
||||
|
||||
core_config_variables = [
|
||||
*authentication_config_variables,
|
||||
*workspace_management_config_variables,
|
||||
*google_config_variables,
|
||||
*github_config_variables,
|
||||
*gitlab_config_variables,
|
||||
*gitea_config_variables,
|
||||
*smtp_config_variables,
|
||||
*llm_config_variables,
|
||||
*unsplash_config_variables,
|
||||
*intercom_config_variables,
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
extended_config_variables = []
|
||||
@@ -0,0 +1,11 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
def get_client_ip(request):
|
||||
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
|
||||
if x_forwarded_for:
|
||||
ip = x_forwarded_for.split(",")[0]
|
||||
else:
|
||||
ip = request.META.get("REMOTE_ADDR")
|
||||
return ip
|
||||
@@ -0,0 +1,463 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
# The date from pattern
|
||||
pattern = re.compile(r"\d+_(weeks|months)$")
|
||||
|
||||
|
||||
# check the valid uuids
|
||||
def filter_valid_uuids(uuid_list):
|
||||
valid_uuids = []
|
||||
for uuid_str in uuid_list:
|
||||
try:
|
||||
uuid_obj = uuid.UUID(uuid_str)
|
||||
valid_uuids.append(uuid_obj)
|
||||
except ValueError:
|
||||
# ignore the invalid uuids
|
||||
pass
|
||||
return valid_uuids
|
||||
|
||||
|
||||
# Get the 2_weeks, 3_months
|
||||
def string_date_filter(issue_filter, duration, subsequent, term, date_filter, offset):
|
||||
now = timezone.now().date()
|
||||
if term == "months":
|
||||
if subsequent == "after":
|
||||
if offset == "fromnow":
|
||||
issue_filter[f"{date_filter}__gte"] = now + timedelta(days=duration * 30)
|
||||
else:
|
||||
issue_filter[f"{date_filter}__gte"] = now - timedelta(days=duration * 30)
|
||||
else:
|
||||
if offset == "fromnow":
|
||||
issue_filter[f"{date_filter}__lte"] = now + timedelta(days=duration * 30)
|
||||
else:
|
||||
issue_filter[f"{date_filter}__lte"] = now - timedelta(days=duration * 30)
|
||||
if term == "weeks":
|
||||
if subsequent == "after":
|
||||
if offset == "fromnow":
|
||||
issue_filter[f"{date_filter}__gte"] = now + timedelta(weeks=duration)
|
||||
else:
|
||||
issue_filter[f"{date_filter}__gte"] = now - timedelta(weeks=duration)
|
||||
else:
|
||||
if offset == "fromnow":
|
||||
issue_filter[f"{date_filter}__lte"] = now + timedelta(weeks=duration)
|
||||
else:
|
||||
issue_filter[f"{date_filter}__lte"] = now - timedelta(weeks=duration)
|
||||
|
||||
|
||||
def date_filter(issue_filter, date_term, queries):
|
||||
"""
|
||||
Handle all date filters
|
||||
"""
|
||||
for query in queries:
|
||||
date_query = query.split(";")
|
||||
if date_query:
|
||||
if len(date_query) >= 2:
|
||||
match = pattern.match(date_query[0])
|
||||
if match:
|
||||
if len(date_query) == 3:
|
||||
digit, term = date_query[0].split("_")
|
||||
string_date_filter(
|
||||
issue_filter=issue_filter,
|
||||
duration=int(digit),
|
||||
subsequent=date_query[1],
|
||||
term=term,
|
||||
date_filter=date_term,
|
||||
offset=date_query[2],
|
||||
)
|
||||
else:
|
||||
if "after" in date_query:
|
||||
issue_filter[f"{date_term}__gte"] = date_query[0]
|
||||
else:
|
||||
issue_filter[f"{date_term}__lte"] = date_query[0]
|
||||
else:
|
||||
issue_filter[f"{date_term}__contains"] = date_query[0]
|
||||
|
||||
|
||||
def filter_state(params, issue_filter, method, prefix=""):
|
||||
if method == "GET":
|
||||
states = [item for item in params.get("state").split(",") if item != "null"]
|
||||
states = filter_valid_uuids(states)
|
||||
if len(states) and "" not in states:
|
||||
issue_filter[f"{prefix}state__in"] = states
|
||||
else:
|
||||
if params.get("state", None) and len(params.get("state")) and params.get("state") != "null":
|
||||
issue_filter[f"{prefix}state__in"] = params.get("state")
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_state_group(params, issue_filter, method, prefix=""):
|
||||
if method == "GET":
|
||||
state_group = [item for item in params.get("state_group").split(",") if item != "null"]
|
||||
if len(state_group) and "" not in state_group:
|
||||
issue_filter[f"{prefix}state__group__in"] = state_group
|
||||
else:
|
||||
if params.get("state_group", None) and len(params.get("state_group")) and params.get("state_group") != "null":
|
||||
issue_filter[f"{prefix}state__group__in"] = params.get("state_group")
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_estimate_point(params, issue_filter, method, prefix=""):
|
||||
if method == "GET":
|
||||
estimate_points = [item for item in params.get("estimate_point").split(",") if item != "null"]
|
||||
if len(estimate_points) and "" not in estimate_points:
|
||||
issue_filter[f"{prefix}estimate_point__in"] = estimate_points
|
||||
else:
|
||||
if (
|
||||
params.get("estimate_point", None)
|
||||
and len(params.get("estimate_point"))
|
||||
and params.get("estimate_point") != "null"
|
||||
):
|
||||
issue_filter[f"{prefix}estimate_point__in"] = params.get("estimate_point")
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_priority(params, issue_filter, method, prefix=""):
|
||||
if method == "GET":
|
||||
priorities = [item for item in params.get("priority").split(",") if item != "null"]
|
||||
if len(priorities) and "" not in priorities:
|
||||
issue_filter[f"{prefix}priority__in"] = priorities
|
||||
else:
|
||||
if params.get("priority", None) and len(params.get("priority")) and params.get("priority") != "null":
|
||||
issue_filter[f"{prefix}priority__in"] = params.get("priority")
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_parent(params, issue_filter, method, prefix=""):
|
||||
if method == "GET":
|
||||
parents = [item for item in params.get("parent").split(",") if item != "null"]
|
||||
if "None" in parents:
|
||||
issue_filter[f"{prefix}parent__isnull"] = True
|
||||
parents = filter_valid_uuids(parents)
|
||||
if len(parents) and "" not in parents:
|
||||
issue_filter[f"{prefix}parent__in"] = parents
|
||||
else:
|
||||
if params.get("parent", None) and len(params.get("parent")) and params.get("parent") != "null":
|
||||
issue_filter[f"{prefix}parent__in"] = params.get("parent")
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_labels(params, issue_filter, method, prefix=""):
|
||||
if method == "GET":
|
||||
labels = [item for item in params.get("labels").split(",") if item != "null"]
|
||||
if "None" in labels:
|
||||
issue_filter[f"{prefix}labels__isnull"] = True
|
||||
labels = filter_valid_uuids(labels)
|
||||
if len(labels) and "" not in labels:
|
||||
issue_filter[f"{prefix}labels__in"] = labels
|
||||
else:
|
||||
if params.get("labels", None) and len(params.get("labels")) and params.get("labels") != "null":
|
||||
issue_filter[f"{prefix}labels__in"] = params.get("labels")
|
||||
issue_filter[f"{prefix}label_issue__deleted_at__isnull"] = True
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_assignees(params, issue_filter, method, prefix=""):
|
||||
if method == "GET":
|
||||
assignees = [item for item in params.get("assignees").split(",") if item != "null"]
|
||||
if "None" in assignees:
|
||||
issue_filter[f"{prefix}assignees__isnull"] = True
|
||||
assignees = filter_valid_uuids(assignees)
|
||||
if len(assignees) and "" not in assignees:
|
||||
issue_filter[f"{prefix}assignees__in"] = assignees
|
||||
else:
|
||||
if params.get("assignees", None) and len(params.get("assignees")) and params.get("assignees") != "null":
|
||||
issue_filter[f"{prefix}assignees__in"] = params.get("assignees")
|
||||
issue_filter[f"{prefix}issue_assignee__deleted_at__isnull"] = True
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_mentions(params, issue_filter, method, prefix=""):
|
||||
if method == "GET":
|
||||
mentions = [item for item in params.get("mentions").split(",") if item != "null"]
|
||||
mentions = filter_valid_uuids(mentions)
|
||||
if len(mentions) and "" not in mentions:
|
||||
issue_filter[f"{prefix}issue_mention__mention__id__in"] = mentions
|
||||
else:
|
||||
if params.get("mentions", None) and len(params.get("mentions")) and params.get("mentions") != "null":
|
||||
issue_filter[f"{prefix}issue_mention__mention__id__in"] = params.get("mentions")
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_created_by(params, issue_filter, method, prefix=""):
|
||||
if method == "GET":
|
||||
created_bys = [item for item in params.get("created_by").split(",") if item != "null"]
|
||||
if "None" in created_bys:
|
||||
issue_filter[f"{prefix}created_by__isnull"] = True
|
||||
created_bys = filter_valid_uuids(created_bys)
|
||||
if len(created_bys) and "" not in created_bys:
|
||||
issue_filter[f"{prefix}created_by__in"] = created_bys
|
||||
else:
|
||||
if params.get("created_by", None) and len(params.get("created_by")) and params.get("created_by") != "null":
|
||||
issue_filter[f"{prefix}created_by__in"] = params.get("created_by")
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_name(params, issue_filter, method, prefix=""):
|
||||
if params.get("name", "") != "":
|
||||
issue_filter[f"{prefix}name__icontains"] = params.get("name")
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_created_at(params, issue_filter, method, prefix=""):
|
||||
if method == "GET":
|
||||
created_ats = params.get("created_at").split(",")
|
||||
if len(created_ats) and "" not in created_ats:
|
||||
date_filter(
|
||||
issue_filter=issue_filter,
|
||||
date_term=f"{prefix}created_at__date",
|
||||
queries=created_ats,
|
||||
)
|
||||
else:
|
||||
if params.get("created_at", None) and len(params.get("created_at")):
|
||||
date_filter(
|
||||
issue_filter=issue_filter,
|
||||
date_term=f"{prefix}created_at__date",
|
||||
queries=params.get("created_at", []),
|
||||
)
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_updated_at(params, issue_filter, method, prefix=""):
|
||||
if method == "GET":
|
||||
updated_ats = params.get("updated_at").split(",")
|
||||
if len(updated_ats) and "" not in updated_ats:
|
||||
date_filter(
|
||||
issue_filter=issue_filter,
|
||||
date_term=f"{prefix}created_at__date",
|
||||
queries=updated_ats,
|
||||
)
|
||||
else:
|
||||
if params.get("updated_at", None) and len(params.get("updated_at")):
|
||||
date_filter(
|
||||
issue_filter=issue_filter,
|
||||
date_term=f"{prefix}created_at__date",
|
||||
queries=params.get("updated_at", []),
|
||||
)
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_start_date(params, issue_filter, method, prefix=""):
|
||||
if method == "GET":
|
||||
start_dates = params.get("start_date").split(",")
|
||||
if len(start_dates) and "" not in start_dates:
|
||||
date_filter(
|
||||
issue_filter=issue_filter,
|
||||
date_term=f"{prefix}start_date",
|
||||
queries=start_dates,
|
||||
)
|
||||
else:
|
||||
if params.get("start_date", None) and len(params.get("start_date")):
|
||||
issue_filter[f"{prefix}start_date"] = params.get("start_date")
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_target_date(params, issue_filter, method, prefix=""):
|
||||
if method == "GET":
|
||||
target_dates = params.get("target_date").split(",")
|
||||
if len(target_dates) and "" not in target_dates:
|
||||
date_filter(
|
||||
issue_filter=issue_filter,
|
||||
date_term=f"{prefix}target_date",
|
||||
queries=target_dates,
|
||||
)
|
||||
else:
|
||||
if params.get("target_date", None) and len(params.get("target_date")):
|
||||
issue_filter[f"{prefix}target_date"] = params.get("target_date")
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_completed_at(params, issue_filter, method, prefix=""):
|
||||
if method == "GET":
|
||||
completed_ats = params.get("completed_at").split(",")
|
||||
if len(completed_ats) and "" not in completed_ats:
|
||||
date_filter(
|
||||
issue_filter=issue_filter,
|
||||
date_term=f"{prefix}completed_at__date",
|
||||
queries=completed_ats,
|
||||
)
|
||||
else:
|
||||
if params.get("completed_at", None) and len(params.get("completed_at")):
|
||||
date_filter(
|
||||
issue_filter=issue_filter,
|
||||
date_term=f"{prefix}completed_at__date",
|
||||
queries=params.get("completed_at", []),
|
||||
)
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_issue_state_type(params, issue_filter, method, prefix=""):
|
||||
type = params.get("type", "all")
|
||||
group = ["backlog", "unstarted", "started", "completed", "cancelled"]
|
||||
if type == "backlog":
|
||||
group = ["backlog"]
|
||||
if type == "active":
|
||||
group = ["unstarted", "started"]
|
||||
|
||||
issue_filter[f"{prefix}state__group__in"] = group
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_project(params, issue_filter, method, prefix=""):
|
||||
if method == "GET":
|
||||
projects = [item for item in params.get("project").split(",") if item != "null"]
|
||||
projects = filter_valid_uuids(projects)
|
||||
if len(projects) and "" not in projects:
|
||||
issue_filter[f"{prefix}project__in"] = projects
|
||||
else:
|
||||
if params.get("project", None) and len(params.get("project")) and params.get("project") != "null":
|
||||
issue_filter[f"{prefix}project__in"] = params.get("project")
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_cycle(params, issue_filter, method, prefix=""):
|
||||
if method == "GET":
|
||||
cycles = [item for item in params.get("cycle").split(",") if item != "null"]
|
||||
if "None" in cycles:
|
||||
issue_filter[f"{prefix}issue_cycle__cycle_id__isnull"] = True
|
||||
cycles = filter_valid_uuids(cycles)
|
||||
if len(cycles) and "" not in cycles:
|
||||
issue_filter[f"{prefix}issue_cycle__cycle_id__in"] = cycles
|
||||
else:
|
||||
if params.get("cycle", None) and len(params.get("cycle")) and params.get("cycle") != "null":
|
||||
issue_filter[f"{prefix}issue_cycle__cycle_id__in"] = params.get("cycle")
|
||||
issue_filter[f"{prefix}issue_cycle__deleted_at__isnull"] = True
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_module(params, issue_filter, method, prefix=""):
|
||||
if method == "GET":
|
||||
modules = [item for item in params.get("module").split(",") if item != "null"]
|
||||
if "None" in modules:
|
||||
issue_filter[f"{prefix}issue_module__module_id__isnull"] = True
|
||||
modules = filter_valid_uuids(modules)
|
||||
if len(modules) and "" not in modules:
|
||||
issue_filter[f"{prefix}issue_module__module_id__in"] = modules
|
||||
else:
|
||||
if params.get("module", None) and len(params.get("module")) and params.get("module") != "null":
|
||||
issue_filter[f"{prefix}issue_module__module_id__in"] = params.get("module")
|
||||
issue_filter[f"{prefix}issue_module__deleted_at__isnull"] = True
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_intake_status(params, issue_filter, method, prefix=""):
|
||||
if method == "GET":
|
||||
status = [item for item in params.get("intake_status").split(",") if item != "null"]
|
||||
if len(status) and "" not in status:
|
||||
issue_filter[f"{prefix}issue_intake__status__in"] = status
|
||||
else:
|
||||
if (
|
||||
params.get("intake_status", None)
|
||||
and len(params.get("intake_status"))
|
||||
and params.get("intake_status") != "null"
|
||||
):
|
||||
issue_filter[f"{prefix}issue_intake__status__in"] = params.get("inbox_status")
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_inbox_status(params, issue_filter, method, prefix=""):
|
||||
if method == "GET":
|
||||
status = [item for item in params.get("inbox_status").split(",") if item != "null"]
|
||||
if len(status) and "" not in status:
|
||||
issue_filter[f"{prefix}issue_intake__status__in"] = status
|
||||
else:
|
||||
if (
|
||||
params.get("inbox_status", None)
|
||||
and len(params.get("inbox_status"))
|
||||
and params.get("inbox_status") != "null"
|
||||
):
|
||||
issue_filter[f"{prefix}issue_intake__status__in"] = params.get("inbox_status")
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_sub_issue_toggle(params, issue_filter, method, prefix=""):
|
||||
if method == "GET":
|
||||
sub_issue = params.get("sub_issue", "false")
|
||||
if sub_issue == "false":
|
||||
issue_filter[f"{prefix}parent__isnull"] = True
|
||||
else:
|
||||
sub_issue = params.get("sub_issue", "false")
|
||||
if sub_issue == "false":
|
||||
issue_filter[f"{prefix}parent__isnull"] = True
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_subscribed_issues(params, issue_filter, method, prefix=""):
|
||||
if method == "GET":
|
||||
subscribers = [item for item in params.get("subscriber").split(",") if item != "null"]
|
||||
subscribers = filter_valid_uuids(subscribers)
|
||||
if len(subscribers) and "" not in subscribers:
|
||||
issue_filter[f"{prefix}issue_subscribers__subscriber_id__in"] = subscribers
|
||||
else:
|
||||
if params.get("subscriber", None) and len(params.get("subscriber")) and params.get("subscriber") != "null":
|
||||
issue_filter[f"{prefix}issue_subscribers__subscriber_id__in"] = params.get("subscriber")
|
||||
issue_filter[f"{prefix}issue_subscribers__deleted_at__isnull"] = True
|
||||
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_start_target_date_issues(params, issue_filter, method, prefix=""):
|
||||
start_target_date = params.get("start_target_date", "false")
|
||||
if start_target_date == "true":
|
||||
issue_filter[f"{prefix}target_date__isnull"] = False
|
||||
issue_filter[f"{prefix}start_date__isnull"] = False
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_logged_by(params, issue_filter, method, prefix=""):
|
||||
if method == "GET":
|
||||
logged_bys = [item for item in params.get("logged_by").split(",") if item != "null"]
|
||||
if "None" in logged_bys:
|
||||
issue_filter[f"{prefix}logged_by__isnull"] = True
|
||||
logged_bys = filter_valid_uuids(logged_bys)
|
||||
if len(logged_bys) and "" not in logged_bys:
|
||||
issue_filter[f"{prefix}logged_by__in"] = logged_bys
|
||||
else:
|
||||
if params.get("logged_by", None) and len(params.get("logged_by")) and params.get("logged_by") != "null":
|
||||
issue_filter[f"{prefix}logged_by__in"] = params.get("logged_by")
|
||||
return issue_filter
|
||||
|
||||
|
||||
def issue_filters(query_params, method, prefix=""):
|
||||
issue_filter = {}
|
||||
|
||||
ISSUE_FILTER = {
|
||||
"state": filter_state,
|
||||
"state_group": filter_state_group,
|
||||
"estimate_point": filter_estimate_point,
|
||||
"priority": filter_priority,
|
||||
"parent": filter_parent,
|
||||
"labels": filter_labels,
|
||||
"assignees": filter_assignees,
|
||||
"mentions": filter_mentions,
|
||||
"created_by": filter_created_by,
|
||||
"logged_by": filter_logged_by,
|
||||
"name": filter_name,
|
||||
"created_at": filter_created_at,
|
||||
"updated_at": filter_updated_at,
|
||||
"start_date": filter_start_date,
|
||||
"target_date": filter_target_date,
|
||||
"completed_at": filter_completed_at,
|
||||
"type": filter_issue_state_type,
|
||||
"project": filter_project,
|
||||
"cycle": filter_cycle,
|
||||
"module": filter_module,
|
||||
"intake_status": filter_intake_status,
|
||||
"inbox_status": filter_inbox_status,
|
||||
"sub_issue": filter_sub_issue_toggle,
|
||||
"subscriber": filter_subscribed_issues,
|
||||
"start_target_date": filter_start_target_date_issues,
|
||||
}
|
||||
|
||||
for key, value in ISSUE_FILTER.items():
|
||||
if key in query_params:
|
||||
func = value
|
||||
func(query_params, issue_filter, method, prefix)
|
||||
return issue_filter
|
||||
@@ -0,0 +1,32 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
def get_inverse_relation(relation_type):
|
||||
relation_mapping = {
|
||||
"start_after": "start_before",
|
||||
"finish_after": "finish_before",
|
||||
"blocked_by": "blocking",
|
||||
"blocking": "blocked_by",
|
||||
"start_before": "start_after",
|
||||
"finish_before": "finish_after",
|
||||
"implemented_by": "implements",
|
||||
"implements": "implemented_by",
|
||||
}
|
||||
return relation_mapping.get(relation_type, relation_type)
|
||||
|
||||
|
||||
def get_actual_relation(relation_type):
|
||||
# This function is used to get the actual relation type which is stored in database
|
||||
actual_relation = {
|
||||
"start_after": "start_before",
|
||||
"finish_after": "finish_before",
|
||||
"blocking": "blocked_by",
|
||||
"blocked_by": "blocked_by",
|
||||
"start_before": "start_before",
|
||||
"finish_before": "finish_before",
|
||||
"implemented_by": "implemented_by",
|
||||
"implements": "implemented_by",
|
||||
}
|
||||
|
||||
return actual_relation.get(relation_type, relation_type)
|
||||
@@ -0,0 +1,24 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
import re
|
||||
|
||||
# Django imports
|
||||
from django.db.models import Q
|
||||
|
||||
# Module imports
|
||||
|
||||
|
||||
def search_issues(query, queryset):
|
||||
fields = ["name", "sequence_id", "project__identifier"]
|
||||
q = Q()
|
||||
for field in fields:
|
||||
if field == "sequence_id" and len(query) <= 20:
|
||||
sequences = re.findall(r"\b\d+\b", query)
|
||||
for sequence_id in sequences:
|
||||
q |= Q(**{"sequence_id": sequence_id})
|
||||
else:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
return queryset.filter(q).distinct()
|
||||
@@ -0,0 +1,48 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
import logging.handlers as handlers
|
||||
import time
|
||||
|
||||
|
||||
class SizedTimedRotatingFileHandler(handlers.TimedRotatingFileHandler):
|
||||
"""
|
||||
Handler for logging to a set of files, which switches from one file
|
||||
to the next when the current file reaches a certain size, or at certain
|
||||
timed intervals
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
filename,
|
||||
maxBytes=0,
|
||||
backupCount=0,
|
||||
encoding=None,
|
||||
delay=0,
|
||||
when="h",
|
||||
interval=1,
|
||||
utc=False,
|
||||
):
|
||||
handlers.TimedRotatingFileHandler.__init__(self, filename, when, interval, backupCount, encoding, delay, utc)
|
||||
self.maxBytes = maxBytes
|
||||
|
||||
def shouldRollover(self, record):
|
||||
"""
|
||||
Determine if rollover should occur.
|
||||
|
||||
Basically, see if the supplied record would cause the file to exceed
|
||||
the size limit we have.
|
||||
"""
|
||||
if self.stream is None: # delay was set...
|
||||
self.stream = self._open()
|
||||
if self.maxBytes > 0: # are we rolling over?
|
||||
msg = "%s\n" % self.format(record)
|
||||
# due to non-posix-compliant Windows feature
|
||||
self.stream.seek(0, 2)
|
||||
if self.stream.tell() + len(msg) >= self.maxBytes:
|
||||
return 1
|
||||
t = int(time.time())
|
||||
if t >= self.rolloverAt:
|
||||
return 1
|
||||
return 0
|
||||
@@ -0,0 +1,7 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
import mistune
|
||||
|
||||
markdown = mistune.Markdown()
|
||||
@@ -0,0 +1,102 @@
|
||||
# OpenAPI Utilities Module
|
||||
|
||||
This module provides a well-organized structure for OpenAPI/drf-spectacular utilities, replacing the monolithic `openapi_spec_helpers.py` file with a more maintainable modular approach.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
plane/utils/openapi/
|
||||
├── __init__.py # Main module that re-exports everything
|
||||
├── auth.py # Authentication extensions
|
||||
├── parameters.py # Common OpenAPI parameters
|
||||
├── responses.py # Common OpenAPI responses
|
||||
├── examples.py # Common OpenAPI examples
|
||||
├── decorators.py # Helper decorators for different endpoint types
|
||||
└── hooks.py # Schema processing hooks (pre/post processing)
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Import Everything (Recommended for backwards compatibility)
|
||||
```python
|
||||
from plane.utils.openapi import (
|
||||
asset_docs,
|
||||
ASSET_ID_PARAMETER,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
# ... other imports
|
||||
)
|
||||
```
|
||||
|
||||
### Import from Specific Modules (Recommended for new code)
|
||||
```python
|
||||
from plane.utils.openapi.decorators import asset_docs
|
||||
from plane.utils.openapi.parameters import ASSET_ID_PARAMETER
|
||||
from plane.utils.openapi.responses import UNAUTHORIZED_RESPONSE
|
||||
```
|
||||
|
||||
## Module Contents
|
||||
|
||||
### auth.py
|
||||
- `APIKeyAuthenticationExtension` - X-API-Key authentication
|
||||
- `APITokenAuthenticationExtension` - Bearer token authentication
|
||||
|
||||
### parameters.py
|
||||
- Path parameters: `WORKSPACE_SLUG_PARAMETER`, `PROJECT_ID_PARAMETER`, `ISSUE_ID_PARAMETER`, `ASSET_ID_PARAMETER`
|
||||
- Query parameters: `CURSOR_PARAMETER`, `PER_PAGE_PARAMETER`
|
||||
|
||||
### responses.py
|
||||
- Auth responses: `UNAUTHORIZED_RESPONSE`, `FORBIDDEN_RESPONSE`
|
||||
- Resource responses: `NOT_FOUND_RESPONSE`, `VALIDATION_ERROR_RESPONSE`
|
||||
- Asset responses: `PRESIGNED_URL_SUCCESS_RESPONSE`, `ASSET_UPDATED_RESPONSE`, etc.
|
||||
- Generic asset responses: `GENERIC_ASSET_UPLOAD_SUCCESS_RESPONSE`, `ASSET_DOWNLOAD_SUCCESS_RESPONSE`, etc.
|
||||
|
||||
### examples.py
|
||||
- `FILE_UPLOAD_EXAMPLE`, `WORKSPACE_EXAMPLE`, `PROJECT_EXAMPLE`, `ISSUE_EXAMPLE`
|
||||
|
||||
### decorators.py
|
||||
- `workspace_docs()` - For workspace endpoints
|
||||
- `project_docs()` - For project endpoints
|
||||
- `issue_docs()` - For issue/work item endpoints
|
||||
- `asset_docs()` - For asset endpoints
|
||||
|
||||
### hooks.py
|
||||
- `preprocess_filter_api_v1_paths()` - Filters API v1 paths
|
||||
- `postprocess_assign_tags()` - Assigns tags based on URL patterns
|
||||
- `generate_operation_summary()` - Generates operation summaries
|
||||
|
||||
## Migration Status
|
||||
|
||||
✅ **FULLY COMPLETE** - All components from the legacy `openapi_spec_helpers.py` have been successfully migrated to this modular structure and the old file has been completely removed. All imports have been updated to use the new modular structure.
|
||||
|
||||
### What was migrated:
|
||||
- ✅ All authentication extensions
|
||||
- ✅ All common parameters and responses
|
||||
- ✅ All helper decorators
|
||||
- ✅ All schema processing hooks
|
||||
- ✅ All examples and reusable components
|
||||
- ✅ All asset view decorators converted to use new helpers
|
||||
- ✅ All view imports updated to new module paths
|
||||
- ✅ Legacy file completely removed
|
||||
|
||||
### Files updated:
|
||||
- `plane/api/views/asset.py` - All methods use new `@asset_docs` helpers
|
||||
- `plane/api/views/project.py` - Import updated
|
||||
- `plane/api/views/user.py` - Import updated
|
||||
- `plane/api/views/state.py` - Import updated
|
||||
- `plane/api/views/intake.py` - Import updated
|
||||
- `plane/api/views/member.py` - Import updated
|
||||
- `plane/api/views/module.py` - Import updated
|
||||
- `plane/api/views/cycle.py` - Import updated
|
||||
- `plane/api/views/issue.py` - Import updated
|
||||
- `plane/settings/common.py` - Hook paths updated
|
||||
- `plane/api/apps.py` - Auth extension import updated
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Better Organization**: Related functionality is grouped together
|
||||
2. **Easier Maintenance**: Changes to specific areas only affect relevant files
|
||||
3. **Improved Discoverability**: Clear module names make it easy to find what you need
|
||||
4. **Backwards Compatibility**: All existing imports continue to work
|
||||
5. **Reduced Coupling**: Import only what you need from specific modules
|
||||
6. **Consistent Documentation**: All endpoints now use standardized helpers
|
||||
7. **Massive Code Reduction**: ~80% reduction in decorator bloat using reusable components
|
||||
@@ -0,0 +1,341 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""
|
||||
OpenAPI utilities for drf-spectacular integration.
|
||||
|
||||
This module provides reusable components for API documentation:
|
||||
- Authentication extensions
|
||||
- Common parameters and responses
|
||||
- Helper decorators
|
||||
- Schema preprocessing hooks
|
||||
- Examples
|
||||
"""
|
||||
|
||||
# Authentication extensions
|
||||
from .auth import APIKeyAuthenticationExtension
|
||||
|
||||
# Parameters
|
||||
from .parameters import (
|
||||
WORKSPACE_SLUG_PARAMETER,
|
||||
PROJECT_ID_PARAMETER,
|
||||
PROJECT_PK_PARAMETER,
|
||||
PROJECT_IDENTIFIER_PARAMETER,
|
||||
ISSUE_IDENTIFIER_PARAMETER,
|
||||
ASSET_ID_PARAMETER,
|
||||
CYCLE_ID_PARAMETER,
|
||||
MODULE_ID_PARAMETER,
|
||||
MODULE_PK_PARAMETER,
|
||||
ISSUE_ID_PARAMETER,
|
||||
STATE_ID_PARAMETER,
|
||||
LABEL_ID_PARAMETER,
|
||||
COMMENT_ID_PARAMETER,
|
||||
LINK_ID_PARAMETER,
|
||||
ATTACHMENT_ID_PARAMETER,
|
||||
ACTIVITY_ID_PARAMETER,
|
||||
CURSOR_PARAMETER,
|
||||
PER_PAGE_PARAMETER,
|
||||
EXTERNAL_ID_PARAMETER,
|
||||
EXTERNAL_SOURCE_PARAMETER,
|
||||
ORDER_BY_PARAMETER,
|
||||
SEARCH_PARAMETER,
|
||||
SEARCH_PARAMETER_REQUIRED,
|
||||
LIMIT_PARAMETER,
|
||||
WORKSPACE_SEARCH_PARAMETER,
|
||||
PROJECT_ID_QUERY_PARAMETER,
|
||||
CYCLE_VIEW_PARAMETER,
|
||||
FIELDS_PARAMETER,
|
||||
EXPAND_PARAMETER,
|
||||
ESTIMATE_ID_PARAMETER,
|
||||
)
|
||||
|
||||
# Responses
|
||||
from .responses import (
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
FORBIDDEN_RESPONSE,
|
||||
NOT_FOUND_RESPONSE,
|
||||
VALIDATION_ERROR_RESPONSE,
|
||||
DELETED_RESPONSE,
|
||||
ARCHIVED_RESPONSE,
|
||||
UNARCHIVED_RESPONSE,
|
||||
INVALID_REQUEST_RESPONSE,
|
||||
CONFLICT_RESPONSE,
|
||||
ADMIN_ONLY_RESPONSE,
|
||||
CANNOT_DELETE_RESPONSE,
|
||||
CANNOT_ARCHIVE_RESPONSE,
|
||||
REQUIRED_FIELDS_RESPONSE,
|
||||
PROJECT_NOT_FOUND_RESPONSE,
|
||||
WORKSPACE_NOT_FOUND_RESPONSE,
|
||||
PROJECT_NAME_TAKEN_RESPONSE,
|
||||
ISSUE_NOT_FOUND_RESPONSE,
|
||||
WORK_ITEM_NOT_FOUND_RESPONSE,
|
||||
EXTERNAL_ID_EXISTS_RESPONSE,
|
||||
LABEL_NOT_FOUND_RESPONSE,
|
||||
LABEL_NAME_EXISTS_RESPONSE,
|
||||
MODULE_NOT_FOUND_RESPONSE,
|
||||
MODULE_ISSUE_NOT_FOUND_RESPONSE,
|
||||
CYCLE_CANNOT_ARCHIVE_RESPONSE,
|
||||
STATE_NAME_EXISTS_RESPONSE,
|
||||
STATE_CANNOT_DELETE_RESPONSE,
|
||||
COMMENT_NOT_FOUND_RESPONSE,
|
||||
LINK_NOT_FOUND_RESPONSE,
|
||||
ATTACHMENT_NOT_FOUND_RESPONSE,
|
||||
BAD_SEARCH_REQUEST_RESPONSE,
|
||||
PRESIGNED_URL_SUCCESS_RESPONSE,
|
||||
GENERIC_ASSET_UPLOAD_SUCCESS_RESPONSE,
|
||||
GENERIC_ASSET_VALIDATION_ERROR_RESPONSE,
|
||||
ASSET_CONFLICT_RESPONSE,
|
||||
ASSET_DOWNLOAD_SUCCESS_RESPONSE,
|
||||
ASSET_DOWNLOAD_ERROR_RESPONSE,
|
||||
ASSET_UPDATED_RESPONSE,
|
||||
ASSET_DELETED_RESPONSE,
|
||||
ASSET_NOT_FOUND_RESPONSE,
|
||||
create_paginated_response,
|
||||
)
|
||||
|
||||
# Examples
|
||||
from .examples import (
|
||||
FILE_UPLOAD_EXAMPLE,
|
||||
WORKSPACE_EXAMPLE,
|
||||
PROJECT_EXAMPLE,
|
||||
ISSUE_EXAMPLE,
|
||||
USER_EXAMPLE,
|
||||
get_sample_for_schema,
|
||||
# Request Examples
|
||||
ISSUE_CREATE_EXAMPLE,
|
||||
ISSUE_UPDATE_EXAMPLE,
|
||||
ISSUE_UPSERT_EXAMPLE,
|
||||
LABEL_CREATE_EXAMPLE,
|
||||
LABEL_UPDATE_EXAMPLE,
|
||||
ISSUE_LINK_CREATE_EXAMPLE,
|
||||
ISSUE_LINK_UPDATE_EXAMPLE,
|
||||
ISSUE_COMMENT_CREATE_EXAMPLE,
|
||||
ISSUE_COMMENT_UPDATE_EXAMPLE,
|
||||
ISSUE_ATTACHMENT_UPLOAD_EXAMPLE,
|
||||
ATTACHMENT_UPLOAD_CONFIRM_EXAMPLE,
|
||||
CYCLE_CREATE_EXAMPLE,
|
||||
CYCLE_UPDATE_EXAMPLE,
|
||||
CYCLE_ISSUE_REQUEST_EXAMPLE,
|
||||
TRANSFER_CYCLE_ISSUE_EXAMPLE,
|
||||
MODULE_CREATE_EXAMPLE,
|
||||
MODULE_UPDATE_EXAMPLE,
|
||||
MODULE_ISSUE_REQUEST_EXAMPLE,
|
||||
PROJECT_CREATE_EXAMPLE,
|
||||
PROJECT_UPDATE_EXAMPLE,
|
||||
STATE_CREATE_EXAMPLE,
|
||||
STATE_UPDATE_EXAMPLE,
|
||||
INTAKE_ISSUE_CREATE_EXAMPLE,
|
||||
INTAKE_ISSUE_UPDATE_EXAMPLE,
|
||||
ESTIMATE_CREATE_EXAMPLE,
|
||||
ESTIMATE_UPDATE_EXAMPLE,
|
||||
ESTIMATE_POINT_CREATE_EXAMPLE,
|
||||
ESTIMATE_POINT_UPDATE_EXAMPLE,
|
||||
# Response Examples
|
||||
CYCLE_EXAMPLE,
|
||||
TRANSFER_CYCLE_ISSUE_SUCCESS_EXAMPLE,
|
||||
TRANSFER_CYCLE_ISSUE_ERROR_EXAMPLE,
|
||||
TRANSFER_CYCLE_COMPLETED_ERROR_EXAMPLE,
|
||||
MODULE_EXAMPLE,
|
||||
STATE_EXAMPLE,
|
||||
LABEL_EXAMPLE,
|
||||
ISSUE_LINK_EXAMPLE,
|
||||
ISSUE_COMMENT_EXAMPLE,
|
||||
ISSUE_ATTACHMENT_EXAMPLE,
|
||||
ISSUE_ATTACHMENT_NOT_UPLOADED_EXAMPLE,
|
||||
INTAKE_ISSUE_EXAMPLE,
|
||||
MODULE_ISSUE_EXAMPLE,
|
||||
ISSUE_SEARCH_EXAMPLE,
|
||||
WORKSPACE_MEMBER_EXAMPLE,
|
||||
PROJECT_MEMBER_EXAMPLE,
|
||||
CYCLE_ISSUE_EXAMPLE,
|
||||
STICKY_EXAMPLE,
|
||||
ESTIMATE_EXAMPLE,
|
||||
ESTIMATE_POINT_EXAMPLE,
|
||||
)
|
||||
|
||||
# Helper decorators
|
||||
from .decorators import (
|
||||
workspace_docs,
|
||||
project_docs,
|
||||
issue_docs,
|
||||
intake_docs,
|
||||
asset_docs,
|
||||
user_docs,
|
||||
cycle_docs,
|
||||
work_item_docs,
|
||||
work_item_relation_docs,
|
||||
label_docs,
|
||||
issue_link_docs,
|
||||
issue_comment_docs,
|
||||
issue_activity_docs,
|
||||
issue_attachment_docs,
|
||||
module_docs,
|
||||
module_issue_docs,
|
||||
state_docs,
|
||||
estimate_docs,
|
||||
estimate_point_docs,
|
||||
)
|
||||
|
||||
# Schema processing hooks
|
||||
from .hooks import (
|
||||
preprocess_filter_api_v1_paths,
|
||||
generate_operation_summary,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Authentication
|
||||
"APIKeyAuthenticationExtension",
|
||||
# Parameters
|
||||
"WORKSPACE_SLUG_PARAMETER",
|
||||
"PROJECT_ID_PARAMETER",
|
||||
"PROJECT_PK_PARAMETER",
|
||||
"PROJECT_IDENTIFIER_PARAMETER",
|
||||
"ISSUE_IDENTIFIER_PARAMETER",
|
||||
"ASSET_ID_PARAMETER",
|
||||
"CYCLE_ID_PARAMETER",
|
||||
"MODULE_ID_PARAMETER",
|
||||
"MODULE_PK_PARAMETER",
|
||||
"ISSUE_ID_PARAMETER",
|
||||
"STATE_ID_PARAMETER",
|
||||
"LABEL_ID_PARAMETER",
|
||||
"COMMENT_ID_PARAMETER",
|
||||
"LINK_ID_PARAMETER",
|
||||
"ATTACHMENT_ID_PARAMETER",
|
||||
"ACTIVITY_ID_PARAMETER",
|
||||
"CURSOR_PARAMETER",
|
||||
"PER_PAGE_PARAMETER",
|
||||
"EXTERNAL_ID_PARAMETER",
|
||||
"EXTERNAL_SOURCE_PARAMETER",
|
||||
"ORDER_BY_PARAMETER",
|
||||
"SEARCH_PARAMETER",
|
||||
"SEARCH_PARAMETER_REQUIRED",
|
||||
"LIMIT_PARAMETER",
|
||||
"WORKSPACE_SEARCH_PARAMETER",
|
||||
"PROJECT_ID_QUERY_PARAMETER",
|
||||
"CYCLE_VIEW_PARAMETER",
|
||||
"FIELDS_PARAMETER",
|
||||
"EXPAND_PARAMETER",
|
||||
"ESTIMATE_ID_PARAMETER",
|
||||
# Responses
|
||||
"UNAUTHORIZED_RESPONSE",
|
||||
"FORBIDDEN_RESPONSE",
|
||||
"NOT_FOUND_RESPONSE",
|
||||
"VALIDATION_ERROR_RESPONSE",
|
||||
"DELETED_RESPONSE",
|
||||
"ARCHIVED_RESPONSE",
|
||||
"UNARCHIVED_RESPONSE",
|
||||
"INVALID_REQUEST_RESPONSE",
|
||||
"CONFLICT_RESPONSE",
|
||||
"ADMIN_ONLY_RESPONSE",
|
||||
"CANNOT_DELETE_RESPONSE",
|
||||
"CANNOT_ARCHIVE_RESPONSE",
|
||||
"REQUIRED_FIELDS_RESPONSE",
|
||||
"PROJECT_NOT_FOUND_RESPONSE",
|
||||
"WORKSPACE_NOT_FOUND_RESPONSE",
|
||||
"PROJECT_NAME_TAKEN_RESPONSE",
|
||||
"ISSUE_NOT_FOUND_RESPONSE",
|
||||
"WORK_ITEM_NOT_FOUND_RESPONSE",
|
||||
"EXTERNAL_ID_EXISTS_RESPONSE",
|
||||
"LABEL_NOT_FOUND_RESPONSE",
|
||||
"LABEL_NAME_EXISTS_RESPONSE",
|
||||
"MODULE_NOT_FOUND_RESPONSE",
|
||||
"MODULE_ISSUE_NOT_FOUND_RESPONSE",
|
||||
"CYCLE_CANNOT_ARCHIVE_RESPONSE",
|
||||
"STATE_NAME_EXISTS_RESPONSE",
|
||||
"STATE_CANNOT_DELETE_RESPONSE",
|
||||
"COMMENT_NOT_FOUND_RESPONSE",
|
||||
"LINK_NOT_FOUND_RESPONSE",
|
||||
"ATTACHMENT_NOT_FOUND_RESPONSE",
|
||||
"BAD_SEARCH_REQUEST_RESPONSE",
|
||||
"create_paginated_response",
|
||||
"PRESIGNED_URL_SUCCESS_RESPONSE",
|
||||
"GENERIC_ASSET_UPLOAD_SUCCESS_RESPONSE",
|
||||
"GENERIC_ASSET_VALIDATION_ERROR_RESPONSE",
|
||||
"ASSET_CONFLICT_RESPONSE",
|
||||
"ASSET_DOWNLOAD_SUCCESS_RESPONSE",
|
||||
"ASSET_DOWNLOAD_ERROR_RESPONSE",
|
||||
"ASSET_UPDATED_RESPONSE",
|
||||
"ASSET_DELETED_RESPONSE",
|
||||
"ASSET_NOT_FOUND_RESPONSE",
|
||||
# Examples
|
||||
"FILE_UPLOAD_EXAMPLE",
|
||||
"WORKSPACE_EXAMPLE",
|
||||
"PROJECT_EXAMPLE",
|
||||
"ISSUE_EXAMPLE",
|
||||
"USER_EXAMPLE",
|
||||
"get_sample_for_schema",
|
||||
# Request Examples
|
||||
"ISSUE_CREATE_EXAMPLE",
|
||||
"ISSUE_UPDATE_EXAMPLE",
|
||||
"ISSUE_UPSERT_EXAMPLE",
|
||||
"LABEL_CREATE_EXAMPLE",
|
||||
"LABEL_UPDATE_EXAMPLE",
|
||||
"ISSUE_LINK_CREATE_EXAMPLE",
|
||||
"ISSUE_LINK_UPDATE_EXAMPLE",
|
||||
"ISSUE_COMMENT_CREATE_EXAMPLE",
|
||||
"ISSUE_COMMENT_UPDATE_EXAMPLE",
|
||||
"ISSUE_ATTACHMENT_UPLOAD_EXAMPLE",
|
||||
"ATTACHMENT_UPLOAD_CONFIRM_EXAMPLE",
|
||||
"CYCLE_CREATE_EXAMPLE",
|
||||
"CYCLE_UPDATE_EXAMPLE",
|
||||
"CYCLE_ISSUE_REQUEST_EXAMPLE",
|
||||
"TRANSFER_CYCLE_ISSUE_EXAMPLE",
|
||||
"MODULE_CREATE_EXAMPLE",
|
||||
"MODULE_UPDATE_EXAMPLE",
|
||||
"MODULE_ISSUE_REQUEST_EXAMPLE",
|
||||
"PROJECT_CREATE_EXAMPLE",
|
||||
"PROJECT_UPDATE_EXAMPLE",
|
||||
"STATE_CREATE_EXAMPLE",
|
||||
"STATE_UPDATE_EXAMPLE",
|
||||
"INTAKE_ISSUE_CREATE_EXAMPLE",
|
||||
"INTAKE_ISSUE_UPDATE_EXAMPLE",
|
||||
"ESTIMATE_CREATE_EXAMPLE",
|
||||
"ESTIMATE_UPDATE_EXAMPLE",
|
||||
"ESTIMATE_POINT_CREATE_EXAMPLE",
|
||||
"ESTIMATE_POINT_UPDATE_EXAMPLE",
|
||||
# Response Examples
|
||||
"CYCLE_EXAMPLE",
|
||||
"TRANSFER_CYCLE_ISSUE_SUCCESS_EXAMPLE",
|
||||
"TRANSFER_CYCLE_ISSUE_ERROR_EXAMPLE",
|
||||
"TRANSFER_CYCLE_COMPLETED_ERROR_EXAMPLE",
|
||||
"MODULE_EXAMPLE",
|
||||
"STATE_EXAMPLE",
|
||||
"LABEL_EXAMPLE",
|
||||
"ISSUE_LINK_EXAMPLE",
|
||||
"ISSUE_COMMENT_EXAMPLE",
|
||||
"ISSUE_ATTACHMENT_EXAMPLE",
|
||||
"ISSUE_ATTACHMENT_NOT_UPLOADED_EXAMPLE",
|
||||
"INTAKE_ISSUE_EXAMPLE",
|
||||
"MODULE_ISSUE_EXAMPLE",
|
||||
"ISSUE_SEARCH_EXAMPLE",
|
||||
"WORKSPACE_MEMBER_EXAMPLE",
|
||||
"PROJECT_MEMBER_EXAMPLE",
|
||||
"CYCLE_ISSUE_EXAMPLE",
|
||||
"STICKY_EXAMPLE",
|
||||
"ESTIMATE_EXAMPLE",
|
||||
"ESTIMATE_POINT_EXAMPLE",
|
||||
# Decorators
|
||||
"workspace_docs",
|
||||
"project_docs",
|
||||
"issue_docs",
|
||||
"intake_docs",
|
||||
"asset_docs",
|
||||
"user_docs",
|
||||
"cycle_docs",
|
||||
"work_item_docs",
|
||||
"work_item_relation_docs",
|
||||
"label_docs",
|
||||
"issue_link_docs",
|
||||
"issue_comment_docs",
|
||||
"issue_activity_docs",
|
||||
"issue_attachment_docs",
|
||||
"module_docs",
|
||||
"module_issue_docs",
|
||||
"state_docs",
|
||||
"estimate_docs",
|
||||
"estimate_point_docs",
|
||||
# Hooks
|
||||
"preprocess_filter_api_v1_paths",
|
||||
"generate_operation_summary",
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""
|
||||
OpenAPI authentication extensions for drf-spectacular.
|
||||
|
||||
This module provides authentication extensions that automatically register
|
||||
custom authentication classes with the OpenAPI schema generator.
|
||||
"""
|
||||
|
||||
from drf_spectacular.extensions import OpenApiAuthenticationExtension
|
||||
|
||||
|
||||
class APIKeyAuthenticationExtension(OpenApiAuthenticationExtension):
|
||||
"""
|
||||
OpenAPI authentication extension for
|
||||
plane.api.middleware.api_authentication.APIKeyAuthentication
|
||||
"""
|
||||
|
||||
target_class = "plane.api.middleware.api_authentication.APIKeyAuthentication"
|
||||
name = "ApiKeyAuthentication"
|
||||
priority = 1
|
||||
|
||||
def get_security_definition(self, auto_schema):
|
||||
"""
|
||||
Return the security definition for API key authentication.
|
||||
"""
|
||||
return {
|
||||
"type": "apiKey",
|
||||
"in": "header",
|
||||
"name": "X-API-Key",
|
||||
"description": "API key authentication. Provide your API key in the X-API-Key header.", # noqa: E501
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""
|
||||
Helper decorators for drf-spectacular OpenAPI documentation.
|
||||
|
||||
This module provides domain-specific decorators that apply common
|
||||
parameters, responses, and tags to API endpoints based on their context.
|
||||
"""
|
||||
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from .parameters import WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER
|
||||
from .responses import UNAUTHORIZED_RESPONSE, FORBIDDEN_RESPONSE, NOT_FOUND_RESPONSE
|
||||
|
||||
|
||||
def _merge_schema_options(defaults, kwargs):
|
||||
"""Helper function to merge responses and parameters from kwargs into defaults"""
|
||||
# Merge responses
|
||||
if "responses" in kwargs:
|
||||
defaults["responses"].update(kwargs["responses"])
|
||||
kwargs = {k: v for k, v in kwargs.items() if k != "responses"}
|
||||
|
||||
# Merge parameters
|
||||
if "parameters" in kwargs:
|
||||
defaults["parameters"].extend(kwargs["parameters"])
|
||||
kwargs = {k: v for k, v in kwargs.items() if k != "parameters"}
|
||||
|
||||
defaults.update(kwargs)
|
||||
return defaults
|
||||
|
||||
|
||||
def user_docs(**kwargs):
|
||||
"""Decorator for user-related endpoints"""
|
||||
defaults = {
|
||||
"tags": ["Users"],
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}
|
||||
|
||||
return extend_schema(**_merge_schema_options(defaults, kwargs))
|
||||
|
||||
|
||||
def workspace_docs(**kwargs):
|
||||
"""Decorator for workspace-related endpoints"""
|
||||
defaults = {
|
||||
"tags": ["Workspaces"],
|
||||
"parameters": [WORKSPACE_SLUG_PARAMETER],
|
||||
"responses": {
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: FORBIDDEN_RESPONSE,
|
||||
404: NOT_FOUND_RESPONSE,
|
||||
},
|
||||
}
|
||||
|
||||
return extend_schema(**_merge_schema_options(defaults, kwargs))
|
||||
|
||||
|
||||
def project_docs(**kwargs):
|
||||
"""Decorator for project-related endpoints"""
|
||||
defaults = {
|
||||
"tags": ["Projects"],
|
||||
"parameters": [WORKSPACE_SLUG_PARAMETER],
|
||||
"responses": {
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: FORBIDDEN_RESPONSE,
|
||||
404: NOT_FOUND_RESPONSE,
|
||||
},
|
||||
}
|
||||
|
||||
return extend_schema(**_merge_schema_options(defaults, kwargs))
|
||||
|
||||
|
||||
def cycle_docs(**kwargs):
|
||||
"""Decorator for cycle-related endpoints"""
|
||||
defaults = {
|
||||
"tags": ["Cycles"],
|
||||
"parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
|
||||
"responses": {
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: FORBIDDEN_RESPONSE,
|
||||
404: NOT_FOUND_RESPONSE,
|
||||
},
|
||||
}
|
||||
|
||||
return extend_schema(**_merge_schema_options(defaults, kwargs))
|
||||
|
||||
|
||||
def issue_docs(**kwargs):
|
||||
"""Decorator for issue-related endpoints"""
|
||||
defaults = {
|
||||
"tags": ["Work Items"],
|
||||
"parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
|
||||
"responses": {
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: FORBIDDEN_RESPONSE,
|
||||
404: NOT_FOUND_RESPONSE,
|
||||
},
|
||||
}
|
||||
|
||||
return extend_schema(**_merge_schema_options(defaults, kwargs))
|
||||
|
||||
|
||||
def intake_docs(**kwargs):
|
||||
"""Decorator for intake-related endpoints"""
|
||||
defaults = {
|
||||
"tags": ["Intake"],
|
||||
"parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
|
||||
"responses": {
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: FORBIDDEN_RESPONSE,
|
||||
404: NOT_FOUND_RESPONSE,
|
||||
},
|
||||
}
|
||||
|
||||
return extend_schema(**_merge_schema_options(defaults, kwargs))
|
||||
|
||||
|
||||
def asset_docs(**kwargs):
|
||||
"""Decorator for asset-related endpoints with common defaults"""
|
||||
defaults = {
|
||||
"tags": ["Assets"],
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: FORBIDDEN_RESPONSE,
|
||||
},
|
||||
}
|
||||
|
||||
return extend_schema(**_merge_schema_options(defaults, kwargs))
|
||||
|
||||
|
||||
# Issue-related decorators for specific tags
|
||||
def work_item_docs(**kwargs):
|
||||
"""Decorator for work item endpoints (main issue operations)"""
|
||||
defaults = {
|
||||
"tags": ["Work Items"],
|
||||
"parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
|
||||
"responses": {
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: FORBIDDEN_RESPONSE,
|
||||
404: NOT_FOUND_RESPONSE,
|
||||
},
|
||||
}
|
||||
|
||||
return extend_schema(**_merge_schema_options(defaults, kwargs))
|
||||
|
||||
|
||||
def label_docs(**kwargs):
|
||||
"""Decorator for label management endpoints"""
|
||||
defaults = {
|
||||
"tags": ["Labels"],
|
||||
"parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
|
||||
"responses": {
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: FORBIDDEN_RESPONSE,
|
||||
404: NOT_FOUND_RESPONSE,
|
||||
},
|
||||
}
|
||||
|
||||
return extend_schema(**_merge_schema_options(defaults, kwargs))
|
||||
|
||||
|
||||
def issue_link_docs(**kwargs):
|
||||
"""Decorator for issue link endpoints"""
|
||||
defaults = {
|
||||
"tags": ["Work Item Links"],
|
||||
"parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
|
||||
"responses": {
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: FORBIDDEN_RESPONSE,
|
||||
404: NOT_FOUND_RESPONSE,
|
||||
},
|
||||
}
|
||||
|
||||
return extend_schema(**_merge_schema_options(defaults, kwargs))
|
||||
|
||||
|
||||
def issue_comment_docs(**kwargs):
|
||||
"""Decorator for issue comment endpoints"""
|
||||
defaults = {
|
||||
"tags": ["Work Item Comments"],
|
||||
"parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
|
||||
"responses": {
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: FORBIDDEN_RESPONSE,
|
||||
404: NOT_FOUND_RESPONSE,
|
||||
},
|
||||
}
|
||||
|
||||
return extend_schema(**_merge_schema_options(defaults, kwargs))
|
||||
|
||||
|
||||
def issue_activity_docs(**kwargs):
|
||||
"""Decorator for issue activity/search endpoints"""
|
||||
defaults = {
|
||||
"tags": ["Work Item Activity"],
|
||||
"parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
|
||||
"responses": {
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: FORBIDDEN_RESPONSE,
|
||||
404: NOT_FOUND_RESPONSE,
|
||||
},
|
||||
}
|
||||
|
||||
return extend_schema(**_merge_schema_options(defaults, kwargs))
|
||||
|
||||
|
||||
def issue_attachment_docs(**kwargs):
|
||||
"""Decorator for issue attachment endpoints"""
|
||||
defaults = {
|
||||
"tags": ["Work Item Attachments"],
|
||||
"parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
|
||||
"responses": {
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: FORBIDDEN_RESPONSE,
|
||||
404: NOT_FOUND_RESPONSE,
|
||||
},
|
||||
}
|
||||
|
||||
return extend_schema(**_merge_schema_options(defaults, kwargs))
|
||||
|
||||
|
||||
def work_item_relation_docs(**kwargs):
|
||||
"""Decorator for work item relation endpoints"""
|
||||
defaults = {
|
||||
"tags": ["Work Item Relations"],
|
||||
"parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
|
||||
"responses": {
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: FORBIDDEN_RESPONSE,
|
||||
404: NOT_FOUND_RESPONSE,
|
||||
},
|
||||
}
|
||||
|
||||
return extend_schema(**_merge_schema_options(defaults, kwargs))
|
||||
|
||||
|
||||
def module_docs(**kwargs):
|
||||
"""Decorator for module management endpoints"""
|
||||
defaults = {
|
||||
"tags": ["Modules"],
|
||||
"parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
|
||||
"responses": {
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: FORBIDDEN_RESPONSE,
|
||||
404: NOT_FOUND_RESPONSE,
|
||||
},
|
||||
}
|
||||
|
||||
return extend_schema(**_merge_schema_options(defaults, kwargs))
|
||||
|
||||
|
||||
def module_issue_docs(**kwargs):
|
||||
"""Decorator for module issue management endpoints"""
|
||||
defaults = {
|
||||
"tags": ["Modules"],
|
||||
"parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
|
||||
"responses": {
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: FORBIDDEN_RESPONSE,
|
||||
404: NOT_FOUND_RESPONSE,
|
||||
},
|
||||
}
|
||||
|
||||
return extend_schema(**_merge_schema_options(defaults, kwargs))
|
||||
|
||||
|
||||
def state_docs(**kwargs):
|
||||
"""Decorator for state management endpoints"""
|
||||
defaults = {
|
||||
"tags": ["States"],
|
||||
"parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
|
||||
"responses": {
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: FORBIDDEN_RESPONSE,
|
||||
404: NOT_FOUND_RESPONSE,
|
||||
},
|
||||
}
|
||||
|
||||
return extend_schema(**_merge_schema_options(defaults, kwargs))
|
||||
|
||||
|
||||
def sticky_docs(**kwargs):
|
||||
"""Decorator for sticky management endpoints"""
|
||||
defaults = {
|
||||
"tags": ["Stickies"],
|
||||
"summary": "Endpoints for sticky create/update/delete and fetch sticky details",
|
||||
"parameters": [WORKSPACE_SLUG_PARAMETER],
|
||||
"responses": {
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: FORBIDDEN_RESPONSE,
|
||||
404: NOT_FOUND_RESPONSE,
|
||||
},
|
||||
}
|
||||
|
||||
return extend_schema(**_merge_schema_options(defaults, kwargs))
|
||||
|
||||
def estimate_docs(**kwargs):
|
||||
"""Decorator for estimate-related endpoints"""
|
||||
defaults = {
|
||||
"tags": ["Estimates"],
|
||||
"parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
|
||||
"responses": {
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: FORBIDDEN_RESPONSE,
|
||||
404: NOT_FOUND_RESPONSE,
|
||||
},
|
||||
}
|
||||
return extend_schema(**_merge_schema_options(defaults, kwargs))
|
||||
|
||||
def estimate_point_docs(**kwargs):
|
||||
"""Decorator for estimate point-related endpoints"""
|
||||
defaults = {
|
||||
"tags": ["Estimate Points"],
|
||||
"parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
|
||||
"responses": {
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: FORBIDDEN_RESPONSE,
|
||||
404: NOT_FOUND_RESPONSE,
|
||||
},
|
||||
}
|
||||
return extend_schema(**_merge_schema_options(defaults, kwargs))
|
||||
@@ -0,0 +1,920 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""
|
||||
Common OpenAPI examples for drf-spectacular.
|
||||
|
||||
This module provides reusable example data for API responses and requests
|
||||
to make the generated documentation more helpful and realistic.
|
||||
"""
|
||||
|
||||
from drf_spectacular.utils import OpenApiExample
|
||||
|
||||
|
||||
# File Upload Examples
|
||||
FILE_UPLOAD_EXAMPLE = OpenApiExample(
|
||||
name="File Upload Success",
|
||||
value={
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"asset": "uploads/workspace_1/file_example.pdf",
|
||||
"attributes": {
|
||||
"name": "example-document.pdf",
|
||||
"size": 1024000,
|
||||
"mimetype": "application/pdf",
|
||||
},
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# Workspace Examples
|
||||
WORKSPACE_EXAMPLE = OpenApiExample(
|
||||
name="Workspace",
|
||||
value={
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "My Workspace",
|
||||
"slug": "my-workspace",
|
||||
"organization_size": "1-10",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# Project Examples
|
||||
PROJECT_EXAMPLE = OpenApiExample(
|
||||
name="Project",
|
||||
value={
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "Mobile App Development",
|
||||
"description": "Development of the mobile application",
|
||||
"identifier": "MAD",
|
||||
"network": 2,
|
||||
"project_lead": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# Issue Examples
|
||||
ISSUE_EXAMPLE = OpenApiExample(
|
||||
name="Issue",
|
||||
value={
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "Implement user authentication",
|
||||
"description": "Add OAuth 2.0 authentication flow",
|
||||
"sequence_id": 1,
|
||||
"priority": "high",
|
||||
"assignees": ["550e8400-e29b-41d4-a716-446655440001"],
|
||||
"labels": ["550e8400-e29b-41d4-a716-446655440002"],
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# User Examples
|
||||
USER_EXAMPLE = OpenApiExample(
|
||||
name="User",
|
||||
value={
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"email": "john.doe@example.com",
|
||||
"avatar": "https://example.com/avatar.jpg",
|
||||
"avatar_url": "https://example.com/avatar.jpg",
|
||||
"display_name": "John Doe",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# REQUEST EXAMPLES - Centralized examples for API requests
|
||||
# ============================================================================
|
||||
|
||||
# Work Item / Issue Examples
|
||||
ISSUE_CREATE_EXAMPLE = OpenApiExample(
|
||||
"IssueCreateSerializer",
|
||||
value={
|
||||
"name": "New Issue",
|
||||
"description": "New issue description",
|
||||
"priority": "medium",
|
||||
"state": "0ec6cfa4-e906-4aad-9390-2df0303a41cd",
|
||||
"assignees": ["0ec6cfa4-e906-4aad-9390-2df0303a41cd"],
|
||||
"labels": ["0ec6cfa4-e906-4aad-9390-2df0303a41ce"],
|
||||
"external_id": "1234567890",
|
||||
"external_source": "github",
|
||||
},
|
||||
description="Example request for creating a work item",
|
||||
)
|
||||
|
||||
ISSUE_UPDATE_EXAMPLE = OpenApiExample(
|
||||
"IssueUpdateSerializer",
|
||||
value={
|
||||
"name": "Updated Issue",
|
||||
"description": "Updated issue description",
|
||||
"priority": "medium",
|
||||
"state": "0ec6cfa4-e906-4aad-9390-2df0303a41cd",
|
||||
"assignees": ["0ec6cfa4-e906-4aad-9390-2df0303a41cd"],
|
||||
"labels": ["0ec6cfa4-e906-4aad-9390-2df0303a41ce"],
|
||||
},
|
||||
description="Example request for updating a work item",
|
||||
)
|
||||
|
||||
ISSUE_UPSERT_EXAMPLE = OpenApiExample(
|
||||
"IssueUpsertSerializer",
|
||||
value={
|
||||
"name": "Updated Issue via External ID",
|
||||
"description": "Updated issue description",
|
||||
"priority": "high",
|
||||
"state": "0ec6cfa4-e906-4aad-9390-2df0303a41cd",
|
||||
"assignees": ["0ec6cfa4-e906-4aad-9390-2df0303a41cd"],
|
||||
"labels": ["0ec6cfa4-e906-4aad-9390-2df0303a41ce"],
|
||||
"external_id": "1234567890",
|
||||
"external_source": "github",
|
||||
},
|
||||
description="Example request for upserting a work item via external ID",
|
||||
)
|
||||
|
||||
# Label Examples
|
||||
LABEL_CREATE_EXAMPLE = OpenApiExample(
|
||||
"LabelCreateUpdateSerializer",
|
||||
value={
|
||||
"name": "New Label",
|
||||
"color": "#ff0000",
|
||||
"description": "New label description",
|
||||
"external_id": "1234567890",
|
||||
"external_source": "github",
|
||||
},
|
||||
description="Example request for creating a label",
|
||||
)
|
||||
|
||||
LABEL_UPDATE_EXAMPLE = OpenApiExample(
|
||||
"LabelCreateUpdateSerializer",
|
||||
value={
|
||||
"name": "Updated Label",
|
||||
"color": "#00ff00",
|
||||
"description": "Updated label description",
|
||||
"external_id": "1234567890",
|
||||
"external_source": "github",
|
||||
},
|
||||
description="Example request for updating a label",
|
||||
)
|
||||
|
||||
# Issue Link Examples
|
||||
ISSUE_LINK_CREATE_EXAMPLE = OpenApiExample(
|
||||
"IssueLinkCreateSerializer",
|
||||
value={
|
||||
"url": "https://example.com",
|
||||
"title": "Example Link",
|
||||
},
|
||||
description="Example request for creating an issue link",
|
||||
)
|
||||
|
||||
ISSUE_LINK_UPDATE_EXAMPLE = OpenApiExample(
|
||||
"IssueLinkUpdateSerializer",
|
||||
value={
|
||||
"url": "https://example.com",
|
||||
"title": "Updated Link",
|
||||
},
|
||||
description="Example request for updating an issue link",
|
||||
)
|
||||
|
||||
# Issue Comment Examples
|
||||
ISSUE_COMMENT_CREATE_EXAMPLE = OpenApiExample(
|
||||
"IssueCommentCreateSerializer",
|
||||
value={
|
||||
"comment_html": "<p>New comment content</p>",
|
||||
"external_id": "1234567890",
|
||||
"external_source": "github",
|
||||
},
|
||||
description="Example request for creating an issue comment",
|
||||
)
|
||||
|
||||
ISSUE_COMMENT_UPDATE_EXAMPLE = OpenApiExample(
|
||||
"IssueCommentCreateSerializer",
|
||||
value={
|
||||
"comment_html": "<p>Updated comment content</p>",
|
||||
"external_id": "1234567890",
|
||||
"external_source": "github",
|
||||
},
|
||||
description="Example request for updating an issue comment",
|
||||
)
|
||||
|
||||
# Issue Attachment Examples
|
||||
ISSUE_ATTACHMENT_UPLOAD_EXAMPLE = OpenApiExample(
|
||||
"IssueAttachmentUploadSerializer",
|
||||
value={
|
||||
"name": "document.pdf",
|
||||
"type": "application/pdf",
|
||||
"size": 1024000,
|
||||
"external_id": "1234567890",
|
||||
"external_source": "github",
|
||||
},
|
||||
description="Example request for creating an issue attachment",
|
||||
)
|
||||
|
||||
ATTACHMENT_UPLOAD_CONFIRM_EXAMPLE = OpenApiExample(
|
||||
"ConfirmUpload",
|
||||
value={"is_uploaded": True},
|
||||
description="Confirm that the attachment has been successfully uploaded",
|
||||
)
|
||||
|
||||
# Cycle Examples
|
||||
CYCLE_CREATE_EXAMPLE = OpenApiExample(
|
||||
"CycleCreateSerializer",
|
||||
value={
|
||||
"name": "Cycle 1",
|
||||
"description": "Cycle 1 description",
|
||||
"start_date": "2021-01-01",
|
||||
"end_date": "2021-01-31",
|
||||
"external_id": "1234567890",
|
||||
"external_source": "github",
|
||||
},
|
||||
description="Example request for creating a cycle",
|
||||
)
|
||||
|
||||
CYCLE_UPDATE_EXAMPLE = OpenApiExample(
|
||||
"CycleUpdateSerializer",
|
||||
value={
|
||||
"name": "Updated Cycle",
|
||||
"description": "Updated cycle description",
|
||||
"start_date": "2021-01-01",
|
||||
"end_date": "2021-01-31",
|
||||
"external_id": "1234567890",
|
||||
"external_source": "github",
|
||||
},
|
||||
description="Example request for updating a cycle",
|
||||
)
|
||||
|
||||
CYCLE_ISSUE_REQUEST_EXAMPLE = OpenApiExample(
|
||||
"CycleIssueRequestSerializer",
|
||||
value={
|
||||
"issues": [
|
||||
"0ec6cfa4-e906-4aad-9390-2df0303a41cd",
|
||||
"0ec6cfa4-e906-4aad-9390-2df0303a41ce",
|
||||
],
|
||||
},
|
||||
description="Example request for adding cycle issues",
|
||||
)
|
||||
|
||||
TRANSFER_CYCLE_ISSUE_EXAMPLE = OpenApiExample(
|
||||
"TransferCycleIssueRequestSerializer",
|
||||
value={
|
||||
"new_cycle_id": "0ec6cfa4-e906-4aad-9390-2df0303a41ce",
|
||||
},
|
||||
description="Example request for transferring cycle issues",
|
||||
)
|
||||
|
||||
# Module Examples
|
||||
MODULE_CREATE_EXAMPLE = OpenApiExample(
|
||||
"ModuleCreateSerializer",
|
||||
value={
|
||||
"name": "New Module",
|
||||
"description": "New module description",
|
||||
"start_date": "2021-01-01",
|
||||
"end_date": "2021-01-31",
|
||||
"external_id": "1234567890",
|
||||
"external_source": "github",
|
||||
},
|
||||
description="Example request for creating a module",
|
||||
)
|
||||
|
||||
MODULE_UPDATE_EXAMPLE = OpenApiExample(
|
||||
"ModuleUpdateSerializer",
|
||||
value={
|
||||
"name": "Updated Module",
|
||||
"description": "Updated module description",
|
||||
"start_date": "2021-01-01",
|
||||
"end_date": "2021-01-31",
|
||||
"external_id": "1234567890",
|
||||
"external_source": "github",
|
||||
},
|
||||
description="Example request for updating a module",
|
||||
)
|
||||
|
||||
MODULE_ISSUE_REQUEST_EXAMPLE = OpenApiExample(
|
||||
"ModuleIssueRequestSerializer",
|
||||
value={
|
||||
"issues": [
|
||||
"0ec6cfa4-e906-4aad-9390-2df0303a41cd",
|
||||
"0ec6cfa4-e906-4aad-9390-2df0303a41ce",
|
||||
],
|
||||
},
|
||||
description="Example request for adding module issues",
|
||||
)
|
||||
|
||||
# Project Examples
|
||||
PROJECT_CREATE_EXAMPLE = OpenApiExample(
|
||||
"ProjectCreateSerializer",
|
||||
value={
|
||||
"name": "New Project",
|
||||
"description": "New project description",
|
||||
"identifier": "new-project",
|
||||
"project_lead": "0ec6cfa4-e906-4aad-9390-2df0303a41ce",
|
||||
},
|
||||
description="Example request for creating a project",
|
||||
)
|
||||
|
||||
PROJECT_UPDATE_EXAMPLE = OpenApiExample(
|
||||
"ProjectUpdateSerializer",
|
||||
value={
|
||||
"name": "Updated Project",
|
||||
"description": "Updated project description",
|
||||
"identifier": "updated-project",
|
||||
"project_lead": "0ec6cfa4-e906-4aad-9390-2df0303a41ce",
|
||||
},
|
||||
description="Example request for updating a project",
|
||||
)
|
||||
|
||||
# State Examples
|
||||
STATE_CREATE_EXAMPLE = OpenApiExample(
|
||||
"StateCreateSerializer",
|
||||
value={
|
||||
"name": "New State",
|
||||
"color": "#ff0000",
|
||||
"group": "backlog",
|
||||
"external_id": "1234567890",
|
||||
"external_source": "github",
|
||||
},
|
||||
description="Example request for creating a state",
|
||||
)
|
||||
|
||||
STATE_UPDATE_EXAMPLE = OpenApiExample(
|
||||
"StateUpdateSerializer",
|
||||
value={
|
||||
"name": "Updated State",
|
||||
"color": "#00ff00",
|
||||
"group": "backlog",
|
||||
"external_id": "1234567890",
|
||||
"external_source": "github",
|
||||
},
|
||||
description="Example request for updating a state",
|
||||
)
|
||||
|
||||
# Intake Examples
|
||||
INTAKE_ISSUE_CREATE_EXAMPLE = OpenApiExample(
|
||||
"IntakeIssueCreateSerializer",
|
||||
value={
|
||||
"issue": {
|
||||
"name": "New Issue",
|
||||
"description": "New issue description",
|
||||
"priority": "medium",
|
||||
}
|
||||
},
|
||||
description="Example request for creating an intake issue",
|
||||
)
|
||||
|
||||
INTAKE_ISSUE_UPDATE_EXAMPLE = OpenApiExample(
|
||||
"IntakeIssueUpdateSerializer",
|
||||
value={
|
||||
"status": 1,
|
||||
"issue": {
|
||||
"name": "Updated Issue",
|
||||
"description": "Updated issue description",
|
||||
"priority": "high",
|
||||
},
|
||||
},
|
||||
description="Example request for updating an intake issue",
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# RESPONSE EXAMPLES - Centralized examples for API responses
|
||||
# ============================================================================
|
||||
|
||||
# Cycle Response Examples
|
||||
CYCLE_EXAMPLE = OpenApiExample(
|
||||
name="Cycle",
|
||||
value={
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "Sprint 1 - Q1 2024",
|
||||
"description": "First sprint of the quarter focusing on core features",
|
||||
"start_date": "2024-01-01",
|
||||
"end_date": "2024-01-14",
|
||||
"status": "current",
|
||||
"total_issues": 15,
|
||||
"completed_issues": 8,
|
||||
"cancelled_issues": 1,
|
||||
"started_issues": 4,
|
||||
"unstarted_issues": 2,
|
||||
"backlog_issues": 0,
|
||||
"created_at": "2024-01-01T10:30:00Z",
|
||||
"updated_at": "2024-01-10T15:45:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
# Transfer Cycle Issue Response Examples
|
||||
TRANSFER_CYCLE_ISSUE_SUCCESS_EXAMPLE = OpenApiExample(
|
||||
name="Transfer Cycle Issue Success",
|
||||
value={
|
||||
"message": "Success",
|
||||
},
|
||||
description="Successful transfer of cycle issues to new cycle",
|
||||
)
|
||||
|
||||
TRANSFER_CYCLE_ISSUE_ERROR_EXAMPLE = OpenApiExample(
|
||||
name="Transfer Cycle Issue Error",
|
||||
value={
|
||||
"error": "New Cycle Id is required",
|
||||
},
|
||||
description="Error when required cycle ID is missing",
|
||||
)
|
||||
|
||||
TRANSFER_CYCLE_COMPLETED_ERROR_EXAMPLE = OpenApiExample(
|
||||
name="Transfer to Completed Cycle Error",
|
||||
value={
|
||||
"error": "The cycle where the issues are transferred is already completed",
|
||||
},
|
||||
description="Error when trying to transfer to a completed cycle",
|
||||
)
|
||||
|
||||
# Module Response Examples
|
||||
MODULE_EXAMPLE = OpenApiExample(
|
||||
name="Module",
|
||||
value={
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "Authentication Module",
|
||||
"description": "User authentication and authorization features",
|
||||
"start_date": "2024-01-01",
|
||||
"target_date": "2024-02-15",
|
||||
"status": "in-progress",
|
||||
"total_issues": 12,
|
||||
"completed_issues": 5,
|
||||
"cancelled_issues": 0,
|
||||
"started_issues": 4,
|
||||
"unstarted_issues": 3,
|
||||
"backlog_issues": 0,
|
||||
"created_at": "2024-01-01T10:30:00Z",
|
||||
"updated_at": "2024-01-10T15:45:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
# State Response Examples
|
||||
STATE_EXAMPLE = OpenApiExample(
|
||||
name="State",
|
||||
value={
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "In Progress",
|
||||
"color": "#f39c12",
|
||||
"group": "started",
|
||||
"sequence": 2,
|
||||
"default": False,
|
||||
"created_at": "2024-01-01T10:30:00Z",
|
||||
"updated_at": "2024-01-10T15:45:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
# Label Response Examples
|
||||
LABEL_EXAMPLE = OpenApiExample(
|
||||
name="Label",
|
||||
value={
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "bug",
|
||||
"color": "#ff4444",
|
||||
"description": "Issues that represent bugs in the system",
|
||||
"created_at": "2024-01-01T10:30:00Z",
|
||||
"updated_at": "2024-01-10T15:45:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
# Issue Link Response Examples
|
||||
ISSUE_LINK_EXAMPLE = OpenApiExample(
|
||||
name="IssueLink",
|
||||
value={
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"url": "https://github.com/example/repo/pull/123",
|
||||
"title": "Fix authentication bug",
|
||||
"metadata": {
|
||||
"title": "Fix authentication bug",
|
||||
"description": "Pull request to fix authentication timeout issue",
|
||||
"image": "https://github.com/example/repo/avatar.png",
|
||||
},
|
||||
"created_at": "2024-01-01T10:30:00Z",
|
||||
"updated_at": "2024-01-10T15:45:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
# Issue Comment Response Examples
|
||||
ISSUE_COMMENT_EXAMPLE = OpenApiExample(
|
||||
name="IssueComment",
|
||||
value={
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"comment_html": "<p>This issue has been resolved by implementing OAuth 2.0 flow.</p>", # noqa: E501
|
||||
"comment_json": {
|
||||
"type": "doc",
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "This issue has been resolved by implementing OAuth 2.0 flow.", # noqa: E501
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
"actor": {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"display_name": "John Doe",
|
||||
"avatar": "https://example.com/avatar.jpg",
|
||||
},
|
||||
"created_at": "2024-01-01T10:30:00Z",
|
||||
"updated_at": "2024-01-10T15:45:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
# Issue Attachment Response Examples
|
||||
ISSUE_ATTACHMENT_EXAMPLE = OpenApiExample(
|
||||
name="IssueAttachment",
|
||||
value={
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "screenshot.png",
|
||||
"size": 1024000,
|
||||
"asset_url": "https://s3.amazonaws.com/bucket/screenshot.png?signed-url",
|
||||
"attributes": {
|
||||
"name": "screenshot.png",
|
||||
"type": "image/png",
|
||||
"size": 1024000,
|
||||
},
|
||||
"created_at": "2024-01-01T10:30:00Z",
|
||||
"updated_at": "2024-01-10T15:45:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
# Issue Attachment Error Response Examples
|
||||
ISSUE_ATTACHMENT_NOT_UPLOADED_EXAMPLE = OpenApiExample(
|
||||
name="Issue Attachment Not Uploaded",
|
||||
value={
|
||||
"error": "The asset is not uploaded.",
|
||||
"status": False,
|
||||
},
|
||||
description="Error when trying to download an attachment that hasn't been uploaded yet", # noqa: E501
|
||||
)
|
||||
|
||||
# Intake Issue Response Examples
|
||||
INTAKE_ISSUE_EXAMPLE = OpenApiExample(
|
||||
name="IntakeIssue",
|
||||
value={
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"status": 0, # Pending
|
||||
"source": "in_app",
|
||||
"issue": {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"name": "Feature request: Dark mode",
|
||||
"description": "Add dark mode support to the application",
|
||||
"priority": "medium",
|
||||
"sequence_id": 124,
|
||||
},
|
||||
"created_at": "2024-01-01T10:30:00Z",
|
||||
"updated_at": "2024-01-10T15:45:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
# Module Issue Response Examples
|
||||
MODULE_ISSUE_EXAMPLE = OpenApiExample(
|
||||
name="ModuleIssue",
|
||||
value={
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"module": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"issue": "550e8400-e29b-41d4-a716-446655440002",
|
||||
"sub_issues_count": 2,
|
||||
"created_at": "2024-01-01T10:30:00Z",
|
||||
"updated_at": "2024-01-10T15:45:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
# Issue Search Response Examples
|
||||
ISSUE_SEARCH_EXAMPLE = OpenApiExample(
|
||||
name="IssueSearchResults",
|
||||
value={
|
||||
"issues": [
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "Fix authentication bug in user login",
|
||||
"sequence_id": 123,
|
||||
"project__identifier": "MAB",
|
||||
"project_id": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"workspace__slug": "my-workspace",
|
||||
},
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440002",
|
||||
"name": "Add authentication middleware",
|
||||
"sequence_id": 124,
|
||||
"project__identifier": "MAB",
|
||||
"project_id": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"workspace__slug": "my-workspace",
|
||||
},
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
# Workspace Member Response Examples
|
||||
WORKSPACE_MEMBER_EXAMPLE = OpenApiExample(
|
||||
name="WorkspaceMembers",
|
||||
value=[
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"display_name": "John Doe",
|
||||
"email": "john.doe@example.com",
|
||||
"avatar": "https://example.com/avatar.jpg",
|
||||
"role": 20,
|
||||
},
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"first_name": "Jane",
|
||||
"last_name": "Smith",
|
||||
"display_name": "Jane Smith",
|
||||
"email": "jane.smith@example.com",
|
||||
"avatar": "https://example.com/avatar2.jpg",
|
||||
"role": 15,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
# Project Member Response Examples
|
||||
PROJECT_MEMBER_EXAMPLE = OpenApiExample(
|
||||
name="ProjectMembers",
|
||||
value=[
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"display_name": "John Doe",
|
||||
"email": "john.doe@example.com",
|
||||
"avatar": "https://example.com/avatar.jpg",
|
||||
},
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"first_name": "Jane",
|
||||
"last_name": "Smith",
|
||||
"display_name": "Jane Smith",
|
||||
"email": "jane.smith@example.com",
|
||||
"avatar": "https://example.com/avatar2.jpg",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
# Cycle Issue Response Examples
|
||||
CYCLE_ISSUE_EXAMPLE = OpenApiExample(
|
||||
name="CycleIssue",
|
||||
value={
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"cycle": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"issue": "550e8400-e29b-41d4-a716-446655440002",
|
||||
"sub_issues_count": 3,
|
||||
"created_at": "2024-01-01T10:30:00Z",
|
||||
"updated_at": "2024-01-10T15:45:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
STICKY_EXAMPLE = OpenApiExample(
|
||||
name="Sticky",
|
||||
value={
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "Sticky 1",
|
||||
"description_html": "<p>Sticky 1 description</p>",
|
||||
"created_at": "2024-01-01T10:30:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
# Estimate Examples
|
||||
ESTIMATE_EXAMPLE = OpenApiExample(
|
||||
name="Estimate",
|
||||
value={
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "Estimate 1",
|
||||
"description": "Estimate 1 description",
|
||||
},
|
||||
description="Example response for an estimate",
|
||||
)
|
||||
|
||||
ESTIMATE_POINT_EXAMPLE = OpenApiExample(
|
||||
name="EstimatePoint",
|
||||
value={
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"estimate": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"key": 1,
|
||||
"value": "1",
|
||||
},
|
||||
description="Example response for an estimate point",
|
||||
)
|
||||
ESTIMATE_CREATE_EXAMPLE = OpenApiExample(
|
||||
name="EstimateCreateSerializer",
|
||||
value={
|
||||
"name": "Estimate 1",
|
||||
"description": "Estimate 1 description",
|
||||
},
|
||||
description="Example request for creating an estimate",
|
||||
)
|
||||
ESTIMATE_UPDATE_EXAMPLE = OpenApiExample(
|
||||
name="EstimateUpdateSerializer",
|
||||
value={
|
||||
"name": "Estimate 1",
|
||||
"description": "Estimate 1 description",
|
||||
},
|
||||
description="Example request for updating an estimate",
|
||||
)
|
||||
|
||||
# Estimate Point Examples
|
||||
ESTIMATE_POINT_CREATE_EXAMPLE = OpenApiExample(
|
||||
name="EstimatePointCreateSerializer",
|
||||
value=[
|
||||
{
|
||||
"value": "1",
|
||||
"description": "Estimate Point 1 description",
|
||||
},
|
||||
{
|
||||
"value": "2",
|
||||
"description": "Estimate Point 2 description",
|
||||
},
|
||||
],
|
||||
description="Example request for creating an estimate point",
|
||||
)
|
||||
ESTIMATE_POINT_UPDATE_EXAMPLE = OpenApiExample(
|
||||
name="EstimatePointUpdateSerializer",
|
||||
value={
|
||||
"value": "1",
|
||||
"description": "Estimate Point 1 description",
|
||||
},
|
||||
description="Example request for updating an estimate point",
|
||||
)
|
||||
|
||||
|
||||
# Sample data for different entity types
|
||||
SAMPLE_ISSUE = {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "Fix authentication bug in user login",
|
||||
"description": "Users are unable to log in due to authentication service timeout",
|
||||
"priority": "high",
|
||||
"sequence_id": 123,
|
||||
"state": {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"name": "In Progress",
|
||||
"group": "started",
|
||||
},
|
||||
"assignees": [],
|
||||
"labels": [],
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
}
|
||||
|
||||
SAMPLE_LABEL = {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "bug",
|
||||
"color": "#ff4444",
|
||||
"description": "Issues that represent bugs in the system",
|
||||
}
|
||||
|
||||
SAMPLE_CYCLE = {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "Sprint 1 - Q1 2024",
|
||||
"description": "First sprint of the quarter focusing on core features",
|
||||
"start_date": "2024-01-01",
|
||||
"end_date": "2024-01-14",
|
||||
"status": "current",
|
||||
}
|
||||
|
||||
SAMPLE_MODULE = {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "Authentication Module",
|
||||
"description": "User authentication and authorization features",
|
||||
"start_date": "2024-01-01",
|
||||
"target_date": "2024-02-15",
|
||||
"status": "in_progress",
|
||||
}
|
||||
|
||||
SAMPLE_PROJECT = {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "Mobile App Backend",
|
||||
"description": "Backend services for the mobile application",
|
||||
"identifier": "MAB",
|
||||
"network": 2,
|
||||
}
|
||||
|
||||
SAMPLE_STATE = {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "In Progress",
|
||||
"color": "#ffa500",
|
||||
"group": "started",
|
||||
"sequence": 2,
|
||||
}
|
||||
|
||||
SAMPLE_COMMENT = {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"comment_html": "<p>This issue needs more investigation. I'll look into the database connection timeout.</p>", # noqa: E501
|
||||
"created_at": "2024-01-15T14:20:00Z",
|
||||
"actor": {"id": "550e8400-e29b-41d4-a716-446655440002", "display_name": "John Doe"},
|
||||
}
|
||||
|
||||
SAMPLE_LINK = {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"url": "https://github.com/example/repo/pull/123",
|
||||
"title": "Fix authentication timeout issue",
|
||||
"metadata": {},
|
||||
}
|
||||
|
||||
SAMPLE_ACTIVITY = {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"field": "priority",
|
||||
"old_value": "medium",
|
||||
"new_value": "high",
|
||||
"created_at": "2024-01-15T11:45:00Z",
|
||||
"actor": {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440002",
|
||||
"display_name": "Jane Smith",
|
||||
},
|
||||
}
|
||||
|
||||
SAMPLE_INTAKE = {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"status": 0,
|
||||
"issue": {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440003",
|
||||
"name": "Feature request: Dark mode support",
|
||||
},
|
||||
"created_at": "2024-01-15T09:15:00Z",
|
||||
}
|
||||
|
||||
SAMPLE_GENERIC = {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "Sample Item",
|
||||
"created_at": "2024-01-15T12:00:00Z",
|
||||
}
|
||||
|
||||
SAMPLE_CYCLE_ISSUE = {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"cycle": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"issue": "550e8400-e29b-41d4-a716-446655440002",
|
||||
"sub_issues_count": 3,
|
||||
"created_at": "2024-01-01T10:30:00Z",
|
||||
}
|
||||
|
||||
SAMPLE_STICKY = {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "Sticky 1",
|
||||
"description_html": "<p>Sticky 1 description</p>",
|
||||
"created_at": "2024-01-01T10:30:00Z",
|
||||
}
|
||||
|
||||
SAMPLE_ESTIMATE = {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "Estimate 1",
|
||||
"description": "Estimate 1 description",
|
||||
"type": "categories",
|
||||
"last_used": False,
|
||||
"created_at": "2024-01-01T10:30:00Z",
|
||||
}
|
||||
|
||||
SAMPLE_ESTIMATE_POINT = {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"estimate": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"key": 1,
|
||||
"value": "1",
|
||||
"description": "Estimate Point 1 description",
|
||||
"created_at": "2024-01-01T10:30:00Z",
|
||||
}
|
||||
|
||||
# Mapping of schema types to sample data
|
||||
SCHEMA_EXAMPLES = {
|
||||
"Issue": SAMPLE_ISSUE,
|
||||
"WorkItem": SAMPLE_ISSUE,
|
||||
"Label": SAMPLE_LABEL,
|
||||
"Cycle": SAMPLE_CYCLE,
|
||||
"Module": SAMPLE_MODULE,
|
||||
"Project": SAMPLE_PROJECT,
|
||||
"State": SAMPLE_STATE,
|
||||
"Comment": SAMPLE_COMMENT,
|
||||
"Link": SAMPLE_LINK,
|
||||
"Activity": SAMPLE_ACTIVITY,
|
||||
"Intake": SAMPLE_INTAKE,
|
||||
"CycleIssue": SAMPLE_CYCLE_ISSUE,
|
||||
"Sticky": SAMPLE_STICKY,
|
||||
"Estimate": SAMPLE_ESTIMATE,
|
||||
"EstimatePoint": SAMPLE_ESTIMATE_POINT,
|
||||
}
|
||||
|
||||
|
||||
def get_sample_for_schema(schema_name):
|
||||
"""
|
||||
Get appropriate sample data for a schema type.
|
||||
|
||||
Args:
|
||||
schema_name (str): Name of the schema (e.g., "PaginatedIssueResponse")
|
||||
|
||||
Returns:
|
||||
dict: Sample data for the schema type
|
||||
"""
|
||||
# Extract base schema name from paginated responses
|
||||
if schema_name.startswith("Paginated"):
|
||||
base_name = schema_name.replace("Paginated", "").replace("Response", "")
|
||||
return SCHEMA_EXAMPLES.get(base_name, SAMPLE_GENERIC)
|
||||
|
||||
return SCHEMA_EXAMPLES.get(schema_name, SAMPLE_GENERIC)
|
||||
@@ -0,0 +1,56 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""
|
||||
Schema processing hooks for drf-spectacular OpenAPI generation.
|
||||
|
||||
This module provides preprocessing and postprocessing functions that modify
|
||||
the generated OpenAPI schema to apply custom filtering, tagging, and other
|
||||
transformations.
|
||||
"""
|
||||
|
||||
|
||||
def preprocess_filter_api_v1_paths(endpoints):
|
||||
"""
|
||||
Filter OpenAPI endpoints to only include /api/v1/ paths and exclude PUT methods.
|
||||
"""
|
||||
filtered = []
|
||||
for path, path_regex, method, callback in endpoints:
|
||||
# Only include paths that start with /api/v1/ and exclude PUT methods
|
||||
if path.startswith("/api/v1/") and method.upper() != "PUT" and "server" not in path.lower():
|
||||
filtered.append((path, path_regex, method, callback))
|
||||
return filtered
|
||||
|
||||
|
||||
def generate_operation_summary(method, path, tag):
|
||||
"""
|
||||
Generate a human-readable summary for an operation.
|
||||
"""
|
||||
# Extract the main resource from the path
|
||||
path_parts = [part for part in path.split("/") if part and not part.startswith("{")]
|
||||
|
||||
if len(path_parts) > 0:
|
||||
resource = path_parts[-1].replace("-", " ").title()
|
||||
else:
|
||||
resource = tag
|
||||
|
||||
# Generate summary based on method
|
||||
method_summaries = {
|
||||
"GET": f"Retrieve {resource}",
|
||||
"POST": f"Create {resource}",
|
||||
"PATCH": f"Update {resource}",
|
||||
"DELETE": f"Delete {resource}",
|
||||
}
|
||||
|
||||
# Handle specific cases
|
||||
if "archive" in path.lower():
|
||||
if method == "POST":
|
||||
return f"Archive {tag.rstrip('s')}"
|
||||
elif method == "DELETE":
|
||||
return f"Unarchive {tag.rstrip('s')}"
|
||||
|
||||
if "transfer" in path.lower():
|
||||
return f"Transfer {tag.rstrip('s')}"
|
||||
|
||||
return method_summaries.get(method, f"{method} {resource}")
|
||||
@@ -0,0 +1,505 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""
|
||||
Common OpenAPI parameters for drf-spectacular.
|
||||
|
||||
This module provides reusable parameter definitions that can be shared
|
||||
across multiple API endpoints to ensure consistency.
|
||||
"""
|
||||
|
||||
from drf_spectacular.utils import OpenApiParameter, OpenApiExample
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
|
||||
|
||||
# Path Parameters
|
||||
WORKSPACE_SLUG_PARAMETER = OpenApiParameter(
|
||||
name="slug",
|
||||
description="Workspace slug",
|
||||
required=True,
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.PATH,
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Example workspace",
|
||||
value="my-workspace",
|
||||
description="A typical workspace slug",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
PROJECT_ID_PARAMETER = OpenApiParameter(
|
||||
name="project_id",
|
||||
description="Project ID",
|
||||
required=True,
|
||||
type=OpenApiTypes.UUID,
|
||||
location=OpenApiParameter.PATH,
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Example project ID",
|
||||
value="550e8400-e29b-41d4-a716-446655440000",
|
||||
description="A typical project UUID",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
PROJECT_PK_PARAMETER = OpenApiParameter(
|
||||
name="pk",
|
||||
description="Project ID",
|
||||
required=True,
|
||||
type=OpenApiTypes.UUID,
|
||||
location=OpenApiParameter.PATH,
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Example project ID",
|
||||
value="550e8400-e29b-41d4-a716-446655440000",
|
||||
description="A typical project UUID",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
PROJECT_IDENTIFIER_PARAMETER = OpenApiParameter(
|
||||
name="project_identifier",
|
||||
description="Project identifier (unique string within workspace)",
|
||||
required=True,
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.PATH,
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Example project identifier",
|
||||
value="PROJ",
|
||||
description="A typical project identifier",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
ISSUE_IDENTIFIER_PARAMETER = OpenApiParameter(
|
||||
name="issue_identifier",
|
||||
description="Issue sequence ID (numeric identifier within project)",
|
||||
required=True,
|
||||
type=OpenApiTypes.INT,
|
||||
location=OpenApiParameter.PATH,
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Example issue identifier",
|
||||
value=123,
|
||||
description="A typical issue sequence ID",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
ASSET_ID_PARAMETER = OpenApiParameter(
|
||||
name="asset_id",
|
||||
description="Asset ID",
|
||||
required=True,
|
||||
type=OpenApiTypes.UUID,
|
||||
location=OpenApiParameter.PATH,
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Example asset ID",
|
||||
value="550e8400-e29b-41d4-a716-446655440000",
|
||||
description="A typical asset UUID",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
CYCLE_ID_PARAMETER = OpenApiParameter(
|
||||
name="cycle_id",
|
||||
description="Cycle ID",
|
||||
required=True,
|
||||
type=OpenApiTypes.UUID,
|
||||
location=OpenApiParameter.PATH,
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Example cycle ID",
|
||||
value="550e8400-e29b-41d4-a716-446655440000",
|
||||
description="A typical cycle UUID",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
MODULE_ID_PARAMETER = OpenApiParameter(
|
||||
name="module_id",
|
||||
description="Module ID",
|
||||
required=True,
|
||||
type=OpenApiTypes.UUID,
|
||||
location=OpenApiParameter.PATH,
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Example module ID",
|
||||
value="550e8400-e29b-41d4-a716-446655440000",
|
||||
description="A typical module UUID",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
MODULE_PK_PARAMETER = OpenApiParameter(
|
||||
name="pk",
|
||||
description="Module ID",
|
||||
required=True,
|
||||
type=OpenApiTypes.UUID,
|
||||
location=OpenApiParameter.PATH,
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Example module ID",
|
||||
value="550e8400-e29b-41d4-a716-446655440000",
|
||||
description="A typical module UUID",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
ISSUE_ID_PARAMETER = OpenApiParameter(
|
||||
name="issue_id",
|
||||
description="Issue ID",
|
||||
required=True,
|
||||
type=OpenApiTypes.UUID,
|
||||
location=OpenApiParameter.PATH,
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Example issue ID",
|
||||
value="550e8400-e29b-41d4-a716-446655440000",
|
||||
description="A typical issue UUID",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
STATE_ID_PARAMETER = OpenApiParameter(
|
||||
name="state_id",
|
||||
description="State ID",
|
||||
required=True,
|
||||
type=OpenApiTypes.UUID,
|
||||
location=OpenApiParameter.PATH,
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Example state ID",
|
||||
value="550e8400-e29b-41d4-a716-446655440000",
|
||||
description="A typical state UUID",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
# Additional Path Parameters
|
||||
LABEL_ID_PARAMETER = OpenApiParameter(
|
||||
name="pk",
|
||||
description="Label ID",
|
||||
required=True,
|
||||
type=OpenApiTypes.UUID,
|
||||
location=OpenApiParameter.PATH,
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Example label ID",
|
||||
value="550e8400-e29b-41d4-a716-446655440000",
|
||||
description="A typical label UUID",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
COMMENT_ID_PARAMETER = OpenApiParameter(
|
||||
name="pk",
|
||||
description="Comment ID",
|
||||
required=True,
|
||||
type=OpenApiTypes.UUID,
|
||||
location=OpenApiParameter.PATH,
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Example comment ID",
|
||||
value="550e8400-e29b-41d4-a716-446655440000",
|
||||
description="A typical comment UUID",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
LINK_ID_PARAMETER = OpenApiParameter(
|
||||
name="pk",
|
||||
description="Link ID",
|
||||
required=True,
|
||||
type=OpenApiTypes.UUID,
|
||||
location=OpenApiParameter.PATH,
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Example link ID",
|
||||
value="550e8400-e29b-41d4-a716-446655440000",
|
||||
description="A typical link UUID",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
ATTACHMENT_ID_PARAMETER = OpenApiParameter(
|
||||
name="pk",
|
||||
description="Attachment ID",
|
||||
required=True,
|
||||
type=OpenApiTypes.UUID,
|
||||
location=OpenApiParameter.PATH,
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Example attachment ID",
|
||||
value="550e8400-e29b-41d4-a716-446655440000",
|
||||
description="A typical attachment UUID",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
ACTIVITY_ID_PARAMETER = OpenApiParameter(
|
||||
name="pk",
|
||||
description="Activity ID",
|
||||
required=True,
|
||||
type=OpenApiTypes.UUID,
|
||||
location=OpenApiParameter.PATH,
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Example activity ID",
|
||||
value="550e8400-e29b-41d4-a716-446655440000",
|
||||
description="A typical activity UUID",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
# Query Parameters
|
||||
CURSOR_PARAMETER = OpenApiParameter(
|
||||
name="cursor",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Pagination cursor for getting next set of results",
|
||||
required=False,
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Next page cursor",
|
||||
value="20:1:0",
|
||||
description="Cursor format: 'page_size:page_number:offset'",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
PER_PAGE_PARAMETER = OpenApiParameter(
|
||||
name="per_page",
|
||||
type=OpenApiTypes.INT,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Number of results per page (default: 20, max: 100)",
|
||||
required=False,
|
||||
examples=[
|
||||
OpenApiExample(name="Default", value=20),
|
||||
OpenApiExample(name="Maximum", value=100),
|
||||
],
|
||||
)
|
||||
|
||||
# External Integration Parameters
|
||||
EXTERNAL_ID_PARAMETER = OpenApiParameter(
|
||||
name="external_id",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="External system identifier for filtering or lookup",
|
||||
required=False,
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="GitHub Issue",
|
||||
value="1234567890",
|
||||
description="GitHub issue number",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
EXTERNAL_SOURCE_PARAMETER = OpenApiParameter(
|
||||
name="external_source",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="External system source name for filtering or lookup",
|
||||
required=False,
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="GitHub",
|
||||
value="github",
|
||||
description="GitHub integration source",
|
||||
),
|
||||
OpenApiExample(
|
||||
name="Jira",
|
||||
value="jira",
|
||||
description="Jira integration source",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
# Ordering Parameters
|
||||
ORDER_BY_PARAMETER = OpenApiParameter(
|
||||
name="order_by",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Field to order results by. Prefix with '-' for descending order",
|
||||
required=False,
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Created date descending",
|
||||
value="-created_at",
|
||||
description="Most recent items first",
|
||||
),
|
||||
OpenApiExample(
|
||||
name="Priority ascending",
|
||||
value="priority",
|
||||
description="Order by priority (urgent, high, medium, low, none)",
|
||||
),
|
||||
OpenApiExample(
|
||||
name="State group",
|
||||
value="state__group",
|
||||
description="Order by state group (backlog, unstarted, started, completed, cancelled)", # noqa: E501
|
||||
),
|
||||
OpenApiExample(
|
||||
name="Assignee name",
|
||||
value="assignees__first_name",
|
||||
description="Order by assignee first name",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
# Search Parameters
|
||||
SEARCH_PARAMETER = OpenApiParameter(
|
||||
name="search",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Search query to filter results by name, description, or identifier",
|
||||
required=False,
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Name search",
|
||||
value="bug fix",
|
||||
description="Search for items containing 'bug fix'",
|
||||
),
|
||||
OpenApiExample(
|
||||
name="Sequence ID",
|
||||
value="123",
|
||||
description="Search by sequence ID number",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
SEARCH_PARAMETER_REQUIRED = OpenApiParameter(
|
||||
name="search",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Search query to filter results by name, description, or identifier",
|
||||
required=True,
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Name search",
|
||||
value="bug fix",
|
||||
description="Search for items containing 'bug fix'",
|
||||
),
|
||||
OpenApiExample(
|
||||
name="Sequence ID",
|
||||
value="123",
|
||||
description="Search by sequence ID number",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
LIMIT_PARAMETER = OpenApiParameter(
|
||||
name="limit",
|
||||
type=OpenApiTypes.INT,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Maximum number of results to return",
|
||||
required=False,
|
||||
examples=[
|
||||
OpenApiExample(name="Default", value=10),
|
||||
OpenApiExample(name="More results", value=50),
|
||||
],
|
||||
)
|
||||
|
||||
WORKSPACE_SEARCH_PARAMETER = OpenApiParameter(
|
||||
name="workspace_search",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Whether to search across entire workspace or within specific project",
|
||||
required=False,
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Project only",
|
||||
value="false",
|
||||
description="Search within specific project only",
|
||||
),
|
||||
OpenApiExample(
|
||||
name="Workspace wide",
|
||||
value="true",
|
||||
description="Search across entire workspace",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
PROJECT_ID_QUERY_PARAMETER = OpenApiParameter(
|
||||
name="project_id",
|
||||
description="Project ID for filtering results within a specific project",
|
||||
required=False,
|
||||
type=OpenApiTypes.UUID,
|
||||
location=OpenApiParameter.QUERY,
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Example project ID",
|
||||
value="550e8400-e29b-41d4-a716-446655440000",
|
||||
description="Filter results for this project",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
# Cycle View Parameter
|
||||
CYCLE_VIEW_PARAMETER = OpenApiParameter(
|
||||
name="cycle_view",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Filter cycles by status",
|
||||
required=False,
|
||||
examples=[
|
||||
OpenApiExample(name="All cycles", value="all"),
|
||||
OpenApiExample(name="Current cycles", value="current"),
|
||||
OpenApiExample(name="Upcoming cycles", value="upcoming"),
|
||||
OpenApiExample(name="Completed cycles", value="completed"),
|
||||
OpenApiExample(name="Draft cycles", value="draft"),
|
||||
OpenApiExample(name="Incomplete cycles", value="incomplete"),
|
||||
],
|
||||
)
|
||||
|
||||
# Field Selection Parameters
|
||||
FIELDS_PARAMETER = OpenApiParameter(
|
||||
name="fields",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Comma-separated list of fields to include in response",
|
||||
required=False,
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Basic fields",
|
||||
value="id,name,description",
|
||||
description="Include only basic fields",
|
||||
),
|
||||
OpenApiExample(
|
||||
name="With relations",
|
||||
value="id,name,assignees,state",
|
||||
description="Include fields with relationships",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
EXPAND_PARAMETER = OpenApiParameter(
|
||||
name="expand",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Comma-separated list of related fields to expand in response",
|
||||
required=False,
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Expand assignees",
|
||||
value="assignees",
|
||||
description="Include full assignee details",
|
||||
),
|
||||
OpenApiExample(
|
||||
name="Multiple expansions",
|
||||
value="assignees,labels,state",
|
||||
description="Include details for multiple relations",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
ESTIMATE_ID_PARAMETER = OpenApiParameter(
|
||||
name="estimate_id",
|
||||
description="Estimate ID",
|
||||
required=True,
|
||||
type=OpenApiTypes.UUID,
|
||||
location=OpenApiParameter.PATH,
|
||||
)
|
||||
@@ -0,0 +1,490 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""
|
||||
Common OpenAPI responses for drf-spectacular.
|
||||
|
||||
This module provides reusable response definitions for common HTTP status codes
|
||||
and scenarios that occur across multiple API endpoints.
|
||||
"""
|
||||
|
||||
from drf_spectacular.utils import OpenApiResponse, OpenApiExample, inline_serializer
|
||||
from rest_framework import serializers
|
||||
from .examples import get_sample_for_schema
|
||||
|
||||
|
||||
# Authentication & Authorization Responses
|
||||
UNAUTHORIZED_RESPONSE = OpenApiResponse(
|
||||
description="Authentication credentials were not provided or are invalid.",
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Unauthorized",
|
||||
value={
|
||||
"error": "Authentication credentials were not provided",
|
||||
"error_code": "AUTHENTICATION_REQUIRED",
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
FORBIDDEN_RESPONSE = OpenApiResponse(
|
||||
description="Permission denied. User lacks required permissions.",
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Forbidden",
|
||||
value={
|
||||
"error": "You do not have permission to perform this action",
|
||||
"error_code": "PERMISSION_DENIED",
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# Resource Responses
|
||||
NOT_FOUND_RESPONSE = OpenApiResponse(
|
||||
description="The requested resource was not found.",
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Not Found",
|
||||
value={"error": "Not found", "error_code": "RESOURCE_NOT_FOUND"},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
VALIDATION_ERROR_RESPONSE = OpenApiResponse(
|
||||
description="Validation error occurred with the provided data.",
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Validation Error",
|
||||
value={
|
||||
"error": "Validation failed",
|
||||
"details": {"field_name": ["This field is required."]},
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
# Generic Success Responses
|
||||
DELETED_RESPONSE = OpenApiResponse(
|
||||
description="Resource deleted successfully",
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Deleted Successfully",
|
||||
value={"message": "Resource deleted successfully"},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
ARCHIVED_RESPONSE = OpenApiResponse(
|
||||
description="Resource archived successfully",
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Archived Successfully",
|
||||
value={"message": "Resource archived successfully"},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
UNARCHIVED_RESPONSE = OpenApiResponse(
|
||||
description="Resource unarchived successfully",
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Unarchived Successfully",
|
||||
value={"message": "Resource unarchived successfully"},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
# Specific Error Responses
|
||||
INVALID_REQUEST_RESPONSE = OpenApiResponse(
|
||||
description="Invalid request data provided",
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Invalid Request",
|
||||
value={
|
||||
"error": "Invalid request data",
|
||||
"details": "Specific validation errors",
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
CONFLICT_RESPONSE = OpenApiResponse(
|
||||
description="Resource conflict - duplicate or constraint violation",
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Resource Conflict",
|
||||
value={
|
||||
"error": "Resource with the same identifier already exists",
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
ADMIN_ONLY_RESPONSE = OpenApiResponse(
|
||||
description="Only admin or creator can perform this action",
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Admin Only",
|
||||
value={"error": "Only admin or creator can perform this action"},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
CANNOT_DELETE_RESPONSE = OpenApiResponse(
|
||||
description="Resource cannot be deleted due to constraints",
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Cannot Delete",
|
||||
value={"error": "Resource cannot be deleted", "reason": "Has dependencies"},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
CANNOT_ARCHIVE_RESPONSE = OpenApiResponse(
|
||||
description="Resource cannot be archived in current state",
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Cannot Archive",
|
||||
value={
|
||||
"error": "Resource cannot be archived",
|
||||
"reason": "Not in valid state",
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
REQUIRED_FIELDS_RESPONSE = OpenApiResponse(
|
||||
description="Required fields are missing",
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Required Fields Missing",
|
||||
value={"error": "Required fields are missing", "fields": ["name", "type"]},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
# Project-specific Responses
|
||||
PROJECT_NOT_FOUND_RESPONSE = OpenApiResponse(
|
||||
description="Project not found",
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Project Not Found",
|
||||
value={"error": "Project not found"},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
WORKSPACE_NOT_FOUND_RESPONSE = OpenApiResponse(
|
||||
description="Workspace not found",
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Workspace Not Found",
|
||||
value={"error": "Workspace not found"},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
PROJECT_NAME_TAKEN_RESPONSE = OpenApiResponse(
|
||||
description="Project name already taken",
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Project Name Taken",
|
||||
value={"error": "Project name already taken"},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
# Issue-specific Responses
|
||||
ISSUE_NOT_FOUND_RESPONSE = OpenApiResponse(
|
||||
description="Issue not found",
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Issue Not Found",
|
||||
value={"error": "Issue not found"},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
WORK_ITEM_NOT_FOUND_RESPONSE = OpenApiResponse(
|
||||
description="Work item not found",
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Work Item Not Found",
|
||||
value={"error": "Work item not found"},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
EXTERNAL_ID_EXISTS_RESPONSE = OpenApiResponse(
|
||||
description="Resource with same external ID already exists",
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="External ID Exists",
|
||||
value={
|
||||
"error": "Resource with the same external id and external source already exists", # noqa: E501
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
# Label-specific Responses
|
||||
LABEL_NOT_FOUND_RESPONSE = OpenApiResponse(
|
||||
description="Label not found",
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Label Not Found",
|
||||
value={"error": "Label not found"},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
LABEL_NAME_EXISTS_RESPONSE = OpenApiResponse(
|
||||
description="Label with the same name already exists",
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Label Name Exists",
|
||||
value={"error": "Label with the same name already exists in the project"},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
# Module-specific Responses
|
||||
MODULE_NOT_FOUND_RESPONSE = OpenApiResponse(
|
||||
description="Module not found",
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Module Not Found",
|
||||
value={"error": "Module not found"},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
MODULE_ISSUE_NOT_FOUND_RESPONSE = OpenApiResponse(
|
||||
description="Module issue not found",
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Module Issue Not Found",
|
||||
value={"error": "Module issue not found"},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
# Cycle-specific Responses
|
||||
CYCLE_CANNOT_ARCHIVE_RESPONSE = OpenApiResponse(
|
||||
description="Cycle cannot be archived",
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Cycle Cannot Archive",
|
||||
value={"error": "Only completed cycles can be archived"},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
# State-specific Responses
|
||||
STATE_NAME_EXISTS_RESPONSE = OpenApiResponse(
|
||||
description="State with the same name already exists",
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="State Name Exists",
|
||||
value={"error": "State with the same name already exists"},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
STATE_CANNOT_DELETE_RESPONSE = OpenApiResponse(
|
||||
description="State cannot be deleted",
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="State Cannot Delete",
|
||||
value={
|
||||
"error": "State cannot be deleted",
|
||||
"reason": "Default state or has issues",
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
# Comment-specific Responses
|
||||
COMMENT_NOT_FOUND_RESPONSE = OpenApiResponse(
|
||||
description="Comment not found",
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Comment Not Found",
|
||||
value={"error": "Comment not found"},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
# Link-specific Responses
|
||||
LINK_NOT_FOUND_RESPONSE = OpenApiResponse(
|
||||
description="Link not found",
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Link Not Found",
|
||||
value={"error": "Link not found"},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
# Attachment-specific Responses
|
||||
ATTACHMENT_NOT_FOUND_RESPONSE = OpenApiResponse(
|
||||
description="Attachment not found",
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Attachment Not Found",
|
||||
value={"error": "Attachment not found"},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
# Search-specific Responses
|
||||
BAD_SEARCH_REQUEST_RESPONSE = OpenApiResponse(
|
||||
description="Bad request - invalid search parameters",
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Bad Search Request",
|
||||
value={"error": "Invalid search parameters"},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# Pagination Response Templates
|
||||
def create_paginated_response(
|
||||
item_schema,
|
||||
schema_name,
|
||||
description="Paginated results",
|
||||
example_name="Paginated Response",
|
||||
):
|
||||
"""Create a paginated response with the specified item schema"""
|
||||
|
||||
return OpenApiResponse(
|
||||
description=description,
|
||||
response=inline_serializer(
|
||||
name=schema_name,
|
||||
fields={
|
||||
"grouped_by": serializers.CharField(allow_null=True),
|
||||
"sub_grouped_by": serializers.CharField(allow_null=True),
|
||||
"total_count": serializers.IntegerField(),
|
||||
"next_cursor": serializers.CharField(),
|
||||
"prev_cursor": serializers.CharField(),
|
||||
"next_page_results": serializers.BooleanField(),
|
||||
"prev_page_results": serializers.BooleanField(),
|
||||
"count": serializers.IntegerField(),
|
||||
"total_pages": serializers.IntegerField(),
|
||||
"total_results": serializers.IntegerField(),
|
||||
"extra_stats": serializers.CharField(allow_null=True),
|
||||
"results": serializers.ListField(child=item_schema()),
|
||||
},
|
||||
),
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name=example_name,
|
||||
value={
|
||||
"grouped_by": "state",
|
||||
"sub_grouped_by": "priority",
|
||||
"total_count": 150,
|
||||
"next_cursor": "20:1:0",
|
||||
"prev_cursor": "20:0:0",
|
||||
"next_page_results": True,
|
||||
"prev_page_results": False,
|
||||
"count": 20,
|
||||
"total_pages": 8,
|
||||
"total_results": 150,
|
||||
"extra_stats": None,
|
||||
"results": [get_sample_for_schema(schema_name)],
|
||||
},
|
||||
summary=example_name,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# Asset-specific Responses
|
||||
PRESIGNED_URL_SUCCESS_RESPONSE = OpenApiResponse(description="Presigned URL generated successfully")
|
||||
|
||||
GENERIC_ASSET_UPLOAD_SUCCESS_RESPONSE = OpenApiResponse(
|
||||
description="Presigned URL generated successfully",
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Generic Asset Upload Response",
|
||||
value={
|
||||
"upload_data": {
|
||||
"url": "https://s3.amazonaws.com/bucket-name",
|
||||
"fields": {
|
||||
"key": "workspace-id/uuid-filename.pdf",
|
||||
"AWSAccessKeyId": "AKIA...",
|
||||
"policy": "eyJ...",
|
||||
"signature": "abc123...",
|
||||
},
|
||||
},
|
||||
"asset_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"asset_url": "https://cdn.example.com/workspace-id/uuid-filename.pdf",
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
GENERIC_ASSET_VALIDATION_ERROR_RESPONSE = OpenApiResponse(
|
||||
description="Validation error",
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Missing required fields",
|
||||
value={"error": "Name and size are required fields.", "status": False},
|
||||
),
|
||||
OpenApiExample(
|
||||
name="Invalid file type",
|
||||
value={"error": "Invalid file type.", "status": False},
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
ASSET_CONFLICT_RESPONSE = OpenApiResponse(
|
||||
description="Asset with same external ID already exists",
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Duplicate external asset",
|
||||
value={
|
||||
"message": "Asset with same external id and source already exists",
|
||||
"asset_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"asset_url": "https://cdn.example.com/existing-file.pdf",
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
ASSET_DOWNLOAD_SUCCESS_RESPONSE = OpenApiResponse(
|
||||
description="Presigned download URL generated successfully",
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Asset Download Response",
|
||||
value={
|
||||
"asset_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"asset_url": "https://s3.amazonaws.com/bucket/file.pdf?signed-url",
|
||||
"asset_name": "document.pdf",
|
||||
"asset_type": "application/pdf",
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
ASSET_DOWNLOAD_ERROR_RESPONSE = OpenApiResponse(
|
||||
description="Bad request",
|
||||
examples=[
|
||||
OpenApiExample(name="Asset not uploaded", value={"error": "Asset not yet uploaded"}),
|
||||
],
|
||||
)
|
||||
|
||||
ASSET_UPDATED_RESPONSE = OpenApiResponse(description="Asset updated successfully")
|
||||
|
||||
ASSET_DELETED_RESPONSE = OpenApiResponse(description="Asset deleted successfully")
|
||||
|
||||
ASSET_NOT_FOUND_RESPONSE = OpenApiResponse(
|
||||
description="Asset not found",
|
||||
examples=[OpenApiExample(name="Asset not found", value={"error": "Asset not found"})],
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
from django.db.models import Case, CharField, Min, Value, When
|
||||
|
||||
# Custom ordering for priority and state
|
||||
PRIORITY_ORDER = ["urgent", "high", "medium", "low", "none"]
|
||||
STATE_ORDER = ["backlog", "unstarted", "started", "completed", "cancelled"]
|
||||
|
||||
|
||||
def order_issue_queryset(issue_queryset, order_by_param="-created_at"):
|
||||
# Priority Ordering
|
||||
if order_by_param == "priority" or order_by_param == "-priority":
|
||||
issue_queryset = issue_queryset.annotate(
|
||||
priority_order=Case(
|
||||
*[When(priority=p, then=Value(i)) for i, p in enumerate(PRIORITY_ORDER)],
|
||||
output_field=CharField(),
|
||||
)
|
||||
).order_by("priority_order", "-created_at")
|
||||
order_by_param = "priority_order" if order_by_param.startswith("-") else "-priority_order"
|
||||
# State Ordering
|
||||
elif order_by_param in ["state__group", "-state__group"]:
|
||||
state_order = STATE_ORDER if order_by_param in ["state__name", "state__group"] else STATE_ORDER[::-1]
|
||||
issue_queryset = issue_queryset.annotate(
|
||||
state_order=Case(
|
||||
*[When(state__group=state_group, then=Value(i)) for i, state_group in enumerate(state_order)],
|
||||
default=Value(len(state_order)),
|
||||
output_field=CharField(),
|
||||
)
|
||||
).order_by("state_order", "-created_at")
|
||||
order_by_param = "-state_order" if order_by_param.startswith("-") else "state_order"
|
||||
# assignee and label ordering
|
||||
elif order_by_param in [
|
||||
"labels__name",
|
||||
"assignees__first_name",
|
||||
"issue_module__module__name",
|
||||
"-labels__name",
|
||||
"-assignees__first_name",
|
||||
"-issue_module__module__name",
|
||||
]:
|
||||
issue_queryset = issue_queryset.annotate(
|
||||
min_values=Min(order_by_param[1::] if order_by_param.startswith("-") else order_by_param)
|
||||
).order_by(
|
||||
"-min_values" if order_by_param.startswith("-") else "min_values",
|
||||
"-created_at",
|
||||
)
|
||||
order_by_param = "-min_values" if order_by_param.startswith("-") else "min_values"
|
||||
else:
|
||||
# If the order_by_param is created_at, then don't add the -created_at
|
||||
if "created_at" in order_by_param:
|
||||
issue_queryset = issue_queryset.order_by(order_by_param)
|
||||
else:
|
||||
issue_queryset = issue_queryset.order_by(order_by_param, "-created_at")
|
||||
order_by_param = order_by_param
|
||||
return issue_queryset, order_by_param
|
||||
@@ -0,0 +1,733 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
import math
|
||||
from collections import defaultdict
|
||||
from collections.abc import Sequence
|
||||
|
||||
# Django imports
|
||||
from django.db.models import Count, F, Window
|
||||
from django.db.models.functions import RowNumber
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.exceptions import ParseError
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
|
||||
|
||||
class Cursor:
|
||||
# The cursor value
|
||||
def __init__(self, value, offset=0, is_prev=False, has_results=None):
|
||||
self.value = value
|
||||
self.offset = int(offset)
|
||||
self.is_prev = bool(is_prev)
|
||||
self.has_results = has_results
|
||||
|
||||
# Return the cursor value in string format
|
||||
def __str__(self):
|
||||
return f"{self.value}:{self.offset}:{int(self.is_prev)}"
|
||||
|
||||
# Return the cursor value
|
||||
def __eq__(self, other):
|
||||
return all(
|
||||
getattr(self, attr) == getattr(other, attr) for attr in ("value", "offset", "is_prev", "has_results")
|
||||
)
|
||||
|
||||
# Return the representation of the cursor
|
||||
def __repr__(self):
|
||||
return f"{(type(self).__name__,)}: value={self.value} offset={self.offset}, is_prev={int(self.is_prev)}" # noqa: E501
|
||||
|
||||
# Return if the cursor is true
|
||||
def __bool__(self):
|
||||
return bool(self.has_results)
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, value):
|
||||
"""Return the cursor value from string format"""
|
||||
try:
|
||||
bits = value.split(":")
|
||||
if len(bits) != 3:
|
||||
raise ValueError("Cursor must be in the format 'value:offset:is_prev'")
|
||||
|
||||
value = float(bits[0]) if "." in bits[0] else int(bits[0])
|
||||
return cls(value, int(bits[1]), bool(int(bits[2])))
|
||||
except (TypeError, ValueError) as e:
|
||||
raise ValueError(f"Invalid cursor format: {e}")
|
||||
|
||||
|
||||
class CursorResult(Sequence):
|
||||
def __init__(self, results, next, prev, hits=None, max_hits=None):
|
||||
self.results = results
|
||||
self.next = next
|
||||
self.prev = prev
|
||||
self.hits = hits
|
||||
self.max_hits = max_hits
|
||||
|
||||
def __len__(self):
|
||||
# Return the length of the results
|
||||
return len(self.results)
|
||||
|
||||
def __iter__(self):
|
||||
# Return the iterator of the results
|
||||
return iter(self.results)
|
||||
|
||||
def __getitem__(self, key):
|
||||
# Return the results based on the key
|
||||
return self.results[key]
|
||||
|
||||
def __repr__(self):
|
||||
# Return the representation of the results
|
||||
return f"<{type(self).__name__}: results={len(self.results)}>"
|
||||
|
||||
|
||||
MAX_LIMIT = 1000
|
||||
|
||||
|
||||
class BadPaginationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class OffsetPaginator:
|
||||
"""
|
||||
The Offset paginator using the offset and limit
|
||||
with cursor controls
|
||||
http://example.com/api/users/?cursor=10.0.0&per_page=10
|
||||
cursor=limit,offset=page,
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
queryset,
|
||||
order_by=None,
|
||||
max_limit=MAX_LIMIT,
|
||||
max_offset=None,
|
||||
on_results=None,
|
||||
total_count_queryset=None,
|
||||
):
|
||||
# Key tuple and remove `-` if descending order by
|
||||
self.key = (
|
||||
order_by
|
||||
if order_by is None or isinstance(order_by, (list, tuple, set))
|
||||
else (order_by[1::] if order_by.startswith("-") else order_by,)
|
||||
)
|
||||
# Set desc to true when `-` exists in the order by
|
||||
self.desc = True if order_by and order_by.startswith("-") else False
|
||||
self.queryset = queryset
|
||||
self.max_limit = max_limit
|
||||
self.max_offset = max_offset
|
||||
self.on_results = on_results
|
||||
self.total_count_queryset = total_count_queryset
|
||||
|
||||
def get_result(self, limit=1000, cursor=None):
|
||||
# offset is page #
|
||||
# value is page limit
|
||||
if cursor is None:
|
||||
cursor = Cursor(0, 0, 0)
|
||||
|
||||
# Get the min from limit and max limit
|
||||
limit = min(limit, self.max_limit)
|
||||
|
||||
# queryset
|
||||
queryset = self.queryset
|
||||
if self.key:
|
||||
queryset = queryset.order_by(
|
||||
(F(*self.key).desc(nulls_last=True) if self.desc else F(*self.key).asc(nulls_last=True)),
|
||||
"-created_at",
|
||||
)
|
||||
# The current page
|
||||
page = cursor.offset
|
||||
# The offset - use limit instead of cursor.value for consistent pagination
|
||||
offset = cursor.offset * limit
|
||||
stop = offset + limit + 1
|
||||
|
||||
if self.max_offset is not None and offset >= self.max_offset:
|
||||
raise BadPaginationError("Pagination offset too large")
|
||||
if offset < 0:
|
||||
raise BadPaginationError("Pagination offset cannot be negative")
|
||||
|
||||
results = queryset[offset:stop]
|
||||
# Duplicate the queryset so it does not evaluate on any python ops
|
||||
page_results = queryset[offset:stop].values("id")
|
||||
|
||||
# Only slice from the end if we're going backwards (previous page)
|
||||
if cursor.value != limit and cursor.is_prev:
|
||||
results = results[-(limit + 1) :]
|
||||
|
||||
total_count = self.total_count_queryset.count() if self.total_count_queryset else queryset.count()
|
||||
|
||||
# Check if there are more results available after the current page
|
||||
|
||||
# Adjust cursors based on the results for pagination
|
||||
next_cursor = Cursor(limit, page + 1, False, page_results.count() > limit)
|
||||
# If the page is greater than 0, then set the previous cursor
|
||||
prev_cursor = Cursor(limit, page - 1, True, page > 0)
|
||||
|
||||
# Process the results
|
||||
results = results[:limit]
|
||||
|
||||
# Process the results
|
||||
if self.on_results:
|
||||
results = self.on_results(results)
|
||||
|
||||
# Count the queryset
|
||||
count = total_count
|
||||
|
||||
# Optionally, calculate the total count and max_hits if needed
|
||||
max_hits = math.ceil(count / limit)
|
||||
|
||||
# Return the cursor results
|
||||
return CursorResult(
|
||||
results=results,
|
||||
next=next_cursor,
|
||||
prev=prev_cursor,
|
||||
hits=count,
|
||||
max_hits=max_hits,
|
||||
)
|
||||
|
||||
def process_results(self, results):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class GroupedOffsetPaginator(OffsetPaginator):
|
||||
# Field mappers - list m2m fields here
|
||||
FIELD_MAPPER = {
|
||||
"labels__id": "label_ids",
|
||||
"assignees__id": "assignee_ids",
|
||||
"issue_module__module_id": "module_ids",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
queryset,
|
||||
group_by_field_name,
|
||||
group_by_fields,
|
||||
count_filter,
|
||||
total_count_queryset=None,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
# Initiate the parent class for all the parameters
|
||||
super().__init__(queryset, *args, **kwargs)
|
||||
|
||||
# Set the group by field name
|
||||
self.group_by_field_name = group_by_field_name
|
||||
# Set the group by fields
|
||||
self.group_by_fields = group_by_fields
|
||||
# Set the count filter - this are extra filters that need to be passed
|
||||
# to calculate the counts with the filters
|
||||
self.count_filter = count_filter
|
||||
|
||||
def get_result(self, limit=50, cursor=None):
|
||||
# offset is page #
|
||||
# value is page limit
|
||||
if cursor is None:
|
||||
cursor = Cursor(0, 0, 0)
|
||||
|
||||
limit = min(limit, self.max_limit)
|
||||
|
||||
# Adjust the initial offset and stop based on the cursor and limit
|
||||
queryset = self.queryset
|
||||
|
||||
page = cursor.offset
|
||||
offset = cursor.offset * cursor.value
|
||||
stop = offset + (cursor.value or limit) + 1
|
||||
|
||||
# Check if the offset is greater than the max offset
|
||||
if self.max_offset is not None and offset >= self.max_offset:
|
||||
raise BadPaginationError("Pagination offset too large")
|
||||
|
||||
# Check if the offset is less than 0
|
||||
if offset < 0:
|
||||
raise BadPaginationError("Pagination offset cannot be negative")
|
||||
|
||||
# Compute the results
|
||||
results = {}
|
||||
# Create window for all the groups
|
||||
queryset = queryset.annotate(
|
||||
row_number=Window(
|
||||
expression=RowNumber(),
|
||||
partition_by=[F(self.group_by_field_name)],
|
||||
order_by=(
|
||||
(
|
||||
F(*self.key).desc(nulls_last=True) # order by desc if desc is set
|
||||
if self.desc
|
||||
else F(*self.key).asc(nulls_last=True) # Order by asc if set
|
||||
),
|
||||
F("created_at").desc(),
|
||||
),
|
||||
)
|
||||
)
|
||||
# Filter the results by row number
|
||||
results = queryset.filter(row_number__gt=offset, row_number__lt=stop).order_by(
|
||||
(F(*self.key).desc(nulls_last=True) if self.desc else F(*self.key).asc(nulls_last=True)),
|
||||
F("created_at").desc(),
|
||||
)
|
||||
|
||||
# Adjust cursors based on the grouped results for pagination
|
||||
next_cursor = Cursor(limit, page + 1, False, queryset.filter(row_number__gte=stop).exists())
|
||||
|
||||
# Add previous cursors
|
||||
prev_cursor = Cursor(limit, page - 1, True, page > 0)
|
||||
|
||||
# Count the queryset
|
||||
count = queryset.count()
|
||||
|
||||
# Optionally, calculate the total count and max_hits if needed
|
||||
# This might require adjustments based on specific use cases
|
||||
if results:
|
||||
max_hits = math.ceil(
|
||||
queryset.values(self.group_by_field_name)
|
||||
.annotate(count=Count("id", filter=self.count_filter, distinct=True))
|
||||
.order_by("-count")[0]["count"]
|
||||
/ limit
|
||||
)
|
||||
else:
|
||||
max_hits = 0
|
||||
return CursorResult(
|
||||
results=results,
|
||||
next=next_cursor,
|
||||
prev=prev_cursor,
|
||||
hits=count,
|
||||
max_hits=max_hits,
|
||||
)
|
||||
|
||||
def __get_total_queryset(self):
|
||||
# Get total items for each group
|
||||
return (
|
||||
self.queryset.values(self.group_by_field_name)
|
||||
.annotate(count=Count("id", filter=self.count_filter, distinct=True))
|
||||
.order_by()
|
||||
)
|
||||
|
||||
def __get_total_dict(self):
|
||||
# Convert the total into dictionary of keys as group name and value as the total
|
||||
total_group_dict = {}
|
||||
for group in self.__get_total_queryset():
|
||||
total_group_dict[str(group.get(self.group_by_field_name))] = total_group_dict.get(
|
||||
str(group.get(self.group_by_field_name)), 0
|
||||
) + (1 if group.get("count") == 0 else group.get("count"))
|
||||
return total_group_dict
|
||||
|
||||
def __get_field_dict(self):
|
||||
# Create a field dictionary
|
||||
total_group_dict = self.__get_total_dict()
|
||||
return {
|
||||
str(field): {
|
||||
"results": [],
|
||||
"total_results": total_group_dict.get(str(field), 0),
|
||||
}
|
||||
for field in self.group_by_fields
|
||||
}
|
||||
|
||||
def __result_already_added(self, result, group):
|
||||
# Check if the result is already added then add it
|
||||
for existing_issue in group:
|
||||
if existing_issue["id"] == result["id"]:
|
||||
return True
|
||||
return False
|
||||
|
||||
def __query_multi_grouper(self, results):
|
||||
# Grouping for m2m values
|
||||
total_group_dict = self.__get_total_dict()
|
||||
|
||||
# Preparing a dict to keep track of group IDs associated with each entity ID
|
||||
result_group_mapping = defaultdict(set)
|
||||
# Preparing a dict to group result by group ID
|
||||
grouped_by_field_name = defaultdict(list)
|
||||
|
||||
# Iterate over results to fill the above dictionaries
|
||||
for result in results:
|
||||
result_id = result["id"]
|
||||
group_id = result[self.group_by_field_name]
|
||||
result_group_mapping[str(result_id)].add(str(group_id))
|
||||
|
||||
# Adding group_ids key to each issue and grouping by group_name
|
||||
for result in results:
|
||||
result_id = result["id"]
|
||||
group_ids = list(result_group_mapping[str(result_id)])
|
||||
result[self.FIELD_MAPPER.get(self.group_by_field_name)] = [] if "None" in group_ids else group_ids
|
||||
# If a result belongs to multiple groups, add it to each group
|
||||
for group_id in group_ids:
|
||||
if not self.__result_already_added(result, grouped_by_field_name[group_id]):
|
||||
grouped_by_field_name[group_id].append(result)
|
||||
|
||||
# Convert grouped_by_field_name back to a list for each group
|
||||
processed_results = {
|
||||
str(group_id): {
|
||||
"results": issues,
|
||||
"total_results": total_group_dict.get(str(group_id)),
|
||||
}
|
||||
for group_id, issues in grouped_by_field_name.items()
|
||||
}
|
||||
|
||||
return processed_results
|
||||
|
||||
def __query_grouper(self, results):
|
||||
# Grouping for values that are not m2m
|
||||
processed_results = self.__get_field_dict()
|
||||
for result in results:
|
||||
group_value = str(result.get(self.group_by_field_name))
|
||||
if group_value in processed_results:
|
||||
processed_results[str(group_value)]["results"].append(result)
|
||||
return processed_results
|
||||
|
||||
def process_results(self, results):
|
||||
# Process results
|
||||
if results:
|
||||
if self.group_by_field_name in self.FIELD_MAPPER:
|
||||
processed_results = self.__query_multi_grouper(results=results)
|
||||
else:
|
||||
processed_results = self.__query_grouper(results=results)
|
||||
else:
|
||||
processed_results = {}
|
||||
return processed_results
|
||||
|
||||
|
||||
class SubGroupedOffsetPaginator(OffsetPaginator):
|
||||
# Field mappers this are the fields that are m2m
|
||||
FIELD_MAPPER = {
|
||||
"labels__id": "label_ids",
|
||||
"assignees__id": "assignee_ids",
|
||||
"issue_module__module_id": "module_ids",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
queryset,
|
||||
group_by_field_name,
|
||||
sub_group_by_field_name,
|
||||
group_by_fields,
|
||||
sub_group_by_fields,
|
||||
count_filter,
|
||||
total_count_queryset=None,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
# Initiate the parent class for all the parameters
|
||||
super().__init__(queryset, *args, **kwargs)
|
||||
|
||||
# Set the group by field name
|
||||
self.group_by_field_name = group_by_field_name
|
||||
self.group_by_fields = group_by_fields
|
||||
|
||||
# Set the sub group by field name
|
||||
self.sub_group_by_field_name = sub_group_by_field_name
|
||||
self.sub_group_by_fields = sub_group_by_fields
|
||||
|
||||
# Set the count filter - this are extra filters that need
|
||||
# to be passed to calculate the counts with the filters
|
||||
self.count_filter = count_filter
|
||||
|
||||
def get_result(self, limit=30, cursor=None):
|
||||
# offset is page #
|
||||
# value is page limit
|
||||
if cursor is None:
|
||||
cursor = Cursor(0, 0, 0)
|
||||
|
||||
# get the minimum value
|
||||
limit = min(limit, self.max_limit)
|
||||
|
||||
# Adjust the initial offset and stop based on the cursor and limit
|
||||
queryset = self.queryset
|
||||
|
||||
# the current page
|
||||
page = cursor.offset
|
||||
|
||||
# the offset
|
||||
offset = cursor.offset * cursor.value
|
||||
|
||||
# the stop
|
||||
stop = offset + (cursor.value or limit) + 1
|
||||
|
||||
if self.max_offset is not None and offset >= self.max_offset:
|
||||
raise BadPaginationError("Pagination offset too large")
|
||||
if offset < 0:
|
||||
raise BadPaginationError("Pagination offset cannot be negative")
|
||||
|
||||
# Compute the results
|
||||
results = {}
|
||||
|
||||
# Create windows for group and sub group field name
|
||||
queryset = queryset.annotate(
|
||||
row_number=Window(
|
||||
expression=RowNumber(),
|
||||
partition_by=[
|
||||
F(self.group_by_field_name),
|
||||
F(self.sub_group_by_field_name),
|
||||
],
|
||||
order_by=(
|
||||
(F(*self.key).desc(nulls_last=True) if self.desc else F(*self.key).asc(nulls_last=True)),
|
||||
"-created_at",
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# Filter the results
|
||||
results = queryset.filter(row_number__gt=offset, row_number__lt=stop).order_by(
|
||||
(F(*self.key).desc(nulls_last=True) if self.desc else F(*self.key).asc(nulls_last=True)),
|
||||
F("created_at").desc(),
|
||||
)
|
||||
|
||||
# Adjust cursors based on the grouped results for pagination
|
||||
next_cursor = Cursor(limit, page + 1, False, queryset.filter(row_number__gte=stop).exists())
|
||||
|
||||
# Add previous cursors
|
||||
prev_cursor = Cursor(limit, page - 1, True, page > 0)
|
||||
|
||||
# Count the queryset
|
||||
count = queryset.count()
|
||||
|
||||
# Optionally, calculate the total count and max_hits if needed
|
||||
# This might require adjustments based on specific use cases
|
||||
if results:
|
||||
max_hits = math.ceil(
|
||||
queryset.values(self.group_by_field_name)
|
||||
.annotate(count=Count("id", filter=self.count_filter, distinct=True))
|
||||
.order_by("-count")[0]["count"]
|
||||
/ limit
|
||||
)
|
||||
else:
|
||||
max_hits = 0
|
||||
return CursorResult(
|
||||
results=results,
|
||||
next=next_cursor,
|
||||
prev=prev_cursor,
|
||||
hits=count,
|
||||
max_hits=max_hits,
|
||||
)
|
||||
|
||||
def __get_group_total_queryset(self):
|
||||
# Get group totals
|
||||
return (
|
||||
self.queryset.order_by(self.group_by_field_name)
|
||||
.values(self.group_by_field_name)
|
||||
.annotate(count=Count("id", filter=self.count_filter, distinct=True))
|
||||
.distinct()
|
||||
)
|
||||
|
||||
def __get_subgroup_total_queryset(self):
|
||||
# Get subgroup totals
|
||||
return (
|
||||
self.queryset.values(self.group_by_field_name, self.sub_group_by_field_name)
|
||||
.annotate(count=Count("id", filter=self.count_filter, distinct=True))
|
||||
.order_by()
|
||||
.values(self.group_by_field_name, self.sub_group_by_field_name, "count")
|
||||
)
|
||||
|
||||
def __get_total_dict(self):
|
||||
# Use the above to convert to dictionary of 2D objects
|
||||
total_group_dict = {}
|
||||
total_sub_group_dict = {}
|
||||
for group in self.__get_group_total_queryset():
|
||||
total_group_dict[str(group.get(self.group_by_field_name))] = total_group_dict.get(
|
||||
str(group.get(self.group_by_field_name)), 0
|
||||
) + (1 if group.get("count") == 0 else group.get("count"))
|
||||
|
||||
# Sub group total values
|
||||
for item in self.__get_subgroup_total_queryset():
|
||||
group = str(item[self.group_by_field_name])
|
||||
subgroup = str(item[self.sub_group_by_field_name])
|
||||
count = item["count"]
|
||||
|
||||
# Create a dictionary of group and sub group
|
||||
if group not in total_sub_group_dict:
|
||||
total_sub_group_dict[str(group)] = {}
|
||||
|
||||
# Create a dictionary of sub group
|
||||
if subgroup not in total_sub_group_dict[group]:
|
||||
total_sub_group_dict[str(group)][str(subgroup)] = {}
|
||||
|
||||
# Create a nested dictionary of group and sub group
|
||||
total_sub_group_dict[group][subgroup] = count
|
||||
|
||||
return total_group_dict, total_sub_group_dict
|
||||
|
||||
def __get_field_dict(self):
|
||||
# Create a field dictionary
|
||||
total_group_dict, total_sub_group_dict = self.__get_total_dict()
|
||||
|
||||
# Create a dictionary of group and sub group
|
||||
return {
|
||||
str(group): {
|
||||
"results": {
|
||||
str(sub_group): {
|
||||
"results": [],
|
||||
"total_results": total_sub_group_dict.get(str(group)).get(str(sub_group), 0),
|
||||
}
|
||||
for sub_group in total_sub_group_dict.get(str(group), [])
|
||||
},
|
||||
"total_results": total_group_dict.get(str(group), 0),
|
||||
}
|
||||
for group in self.group_by_fields
|
||||
}
|
||||
|
||||
def __query_multi_grouper(self, results):
|
||||
# Multi grouper
|
||||
processed_results = self.__get_field_dict()
|
||||
# Preparing a dict to keep track of group IDs associated with each label ID
|
||||
result_group_mapping = defaultdict(set)
|
||||
result_sub_group_mapping = defaultdict(set)
|
||||
|
||||
# Iterate over results to fill the above dictionaries
|
||||
if self.group_by_field_name in self.FIELD_MAPPER:
|
||||
for result in results:
|
||||
result_id = result["id"]
|
||||
group_id = result[self.group_by_field_name]
|
||||
result_group_mapping[str(result_id)].add(str(group_id))
|
||||
# Use the same calculation for the sub group
|
||||
if self.sub_group_by_field_name in self.FIELD_MAPPER:
|
||||
for result in results:
|
||||
result_id = result["id"]
|
||||
sub_group_id = result[self.sub_group_by_field_name]
|
||||
result_sub_group_mapping[str(result_id)].add(str(sub_group_id))
|
||||
|
||||
# Iterate over results
|
||||
for result in results:
|
||||
# Get the group value
|
||||
group_value = str(result.get(self.group_by_field_name))
|
||||
# Get the sub group value
|
||||
sub_group_value = str(result.get(self.sub_group_by_field_name))
|
||||
# Check if the group value is in the processed results
|
||||
result_id = result["id"]
|
||||
|
||||
if group_value in processed_results and sub_group_value in processed_results[str(group_value)]["results"]:
|
||||
if self.group_by_field_name in self.FIELD_MAPPER:
|
||||
# for multi grouper
|
||||
group_ids = list(result_group_mapping[str(result_id)])
|
||||
result[self.FIELD_MAPPER.get(self.group_by_field_name)] = [] if "None" in group_ids else group_ids
|
||||
if self.sub_group_by_field_name in self.FIELD_MAPPER:
|
||||
sub_group_ids = list(result_sub_group_mapping[str(result_id)])
|
||||
# for multi groups
|
||||
result[self.FIELD_MAPPER.get(self.sub_group_by_field_name)] = (
|
||||
[] if "None" in sub_group_ids else sub_group_ids
|
||||
)
|
||||
# If a result belongs to multiple groups, add it to each group
|
||||
processed_results[str(group_value)]["results"][str(sub_group_value)]["results"].append(result)
|
||||
|
||||
return processed_results
|
||||
|
||||
def __query_grouper(self, results):
|
||||
# Single grouper
|
||||
processed_results = self.__get_field_dict()
|
||||
for result in results:
|
||||
group_value = str(result.get(self.group_by_field_name))
|
||||
sub_group_value = str(result.get(self.sub_group_by_field_name))
|
||||
processed_results[group_value]["results"][sub_group_value]["results"].append(result)
|
||||
|
||||
return processed_results
|
||||
|
||||
def process_results(self, results):
|
||||
if results:
|
||||
if self.group_by_field_name in self.FIELD_MAPPER or self.sub_group_by_field_name in self.FIELD_MAPPER:
|
||||
# if the grouping is done through m2m then
|
||||
processed_results = self.__query_multi_grouper(results=results)
|
||||
else:
|
||||
# group it directly
|
||||
processed_results = self.__query_grouper(results=results)
|
||||
else:
|
||||
processed_results = {}
|
||||
return processed_results
|
||||
|
||||
|
||||
class BasePaginator:
|
||||
"""BasePaginator class can be inherited by any View to return a paginated view"""
|
||||
|
||||
# cursor query parameter name
|
||||
cursor_name = "cursor"
|
||||
|
||||
# get the per page parameter from request
|
||||
def get_per_page(self, request, default_per_page=1000, max_per_page=1000):
|
||||
try:
|
||||
per_page = int(request.GET.get("per_page", default_per_page))
|
||||
except ValueError:
|
||||
raise ParseError(detail="Invalid per_page parameter.")
|
||||
|
||||
max_per_page = max(max_per_page, default_per_page)
|
||||
if per_page > max_per_page:
|
||||
raise ParseError(detail=f"Invalid per_page value. Cannot exceed {max_per_page}.")
|
||||
|
||||
return per_page
|
||||
|
||||
def paginate(
|
||||
self,
|
||||
request,
|
||||
on_results=None,
|
||||
paginator=None,
|
||||
paginator_cls=OffsetPaginator,
|
||||
default_per_page=1000,
|
||||
max_per_page=1000,
|
||||
cursor_cls=Cursor,
|
||||
extra_stats=None,
|
||||
controller=None,
|
||||
group_by_field_name=None,
|
||||
group_by_fields=None,
|
||||
sub_group_by_field_name=None,
|
||||
sub_group_by_fields=None,
|
||||
count_filter=None,
|
||||
total_count_queryset=None,
|
||||
**paginator_kwargs,
|
||||
):
|
||||
"""Paginate the request"""
|
||||
per_page = self.get_per_page(request, default_per_page, max_per_page)
|
||||
# Convert the cursor value to integer and float from string
|
||||
input_cursor = None
|
||||
try:
|
||||
input_cursor = cursor_cls.from_string(request.GET.get(self.cursor_name, f"{per_page}:0:0"))
|
||||
except ValueError:
|
||||
raise ParseError(detail="Invalid cursor parameter.")
|
||||
|
||||
if not paginator:
|
||||
if group_by_field_name:
|
||||
paginator_kwargs["group_by_field_name"] = group_by_field_name
|
||||
paginator_kwargs["group_by_fields"] = group_by_fields
|
||||
paginator_kwargs["count_filter"] = count_filter
|
||||
|
||||
if sub_group_by_field_name:
|
||||
paginator_kwargs["sub_group_by_field_name"] = sub_group_by_field_name
|
||||
paginator_kwargs["sub_group_by_fields"] = sub_group_by_fields
|
||||
|
||||
paginator_kwargs["total_count_queryset"] = total_count_queryset
|
||||
|
||||
paginator = paginator_cls(**paginator_kwargs)
|
||||
|
||||
try:
|
||||
cursor_result = paginator.get_result(limit=per_page, cursor=input_cursor)
|
||||
except BadPaginationError:
|
||||
raise ParseError(detail="Error in parsing")
|
||||
|
||||
if on_results:
|
||||
results = on_results(cursor_result.results)
|
||||
else:
|
||||
results = cursor_result.results
|
||||
|
||||
if group_by_field_name:
|
||||
results = paginator.process_results(results=results)
|
||||
|
||||
# Add Manipulation functions to the response
|
||||
if controller is not None:
|
||||
results = controller(results)
|
||||
else:
|
||||
results = results
|
||||
|
||||
# Return the response
|
||||
response = Response(
|
||||
{
|
||||
"grouped_by": group_by_field_name,
|
||||
"sub_grouped_by": sub_group_by_field_name,
|
||||
"total_count": (cursor_result.hits),
|
||||
"next_cursor": str(cursor_result.next),
|
||||
"prev_cursor": str(cursor_result.prev),
|
||||
"next_page_results": cursor_result.next.has_results,
|
||||
"prev_page_results": cursor_result.prev.has_results,
|
||||
"count": cursor_result.__len__(),
|
||||
"total_pages": cursor_result.max_hits,
|
||||
"total_results": cursor_result.hits,
|
||||
"extra_stats": extra_stats,
|
||||
"results": results,
|
||||
}
|
||||
)
|
||||
|
||||
return response
|
||||
@@ -0,0 +1,145 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Django imports
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
from django.conf import settings
|
||||
|
||||
# Python imports
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def _contains_suspicious_patterns(path: str) -> bool:
|
||||
"""
|
||||
Check for suspicious patterns that might indicate malicious intent.
|
||||
|
||||
Args:
|
||||
path (str): The path to check
|
||||
|
||||
Returns:
|
||||
bool: True if suspicious patterns found, False otherwise
|
||||
"""
|
||||
suspicious_patterns = [
|
||||
r"javascript:", # JavaScript injection
|
||||
r"data:", # Data URLs
|
||||
r"vbscript:", # VBScript injection
|
||||
r"file:", # File protocol
|
||||
r"ftp:", # FTP protocol
|
||||
r"%2e%2e", # URL encoded path traversal
|
||||
r"%2f%2f", # URL encoded double slash
|
||||
r"%5c%5c", # URL encoded backslashes
|
||||
r"<script", # Script tags
|
||||
r"<iframe", # Iframe tags
|
||||
r"<object", # Object tags
|
||||
r"<embed", # Embed tags
|
||||
r"<form", # Form tags
|
||||
r"onload=", # Event handlers
|
||||
r"onerror=", # Event handlers
|
||||
r"onclick=", # Event handlers
|
||||
]
|
||||
|
||||
path_lower = path.lower()
|
||||
for pattern in suspicious_patterns:
|
||||
if pattern in path_lower:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_allowed_hosts() -> list[str]:
|
||||
"""Get the allowed hosts from the settings."""
|
||||
base_origin = settings.WEB_URL or settings.APP_BASE_URL
|
||||
|
||||
allowed_hosts = []
|
||||
if base_origin:
|
||||
host = urlparse(base_origin).netloc
|
||||
allowed_hosts.append(host)
|
||||
if settings.ADMIN_BASE_URL:
|
||||
# Get only the host
|
||||
host = urlparse(settings.ADMIN_BASE_URL).netloc
|
||||
allowed_hosts.append(host)
|
||||
if settings.SPACE_BASE_URL:
|
||||
# Get only the host
|
||||
host = urlparse(settings.SPACE_BASE_URL).netloc
|
||||
allowed_hosts.append(host)
|
||||
return allowed_hosts
|
||||
|
||||
|
||||
def validate_next_path(next_path: str) -> str:
|
||||
"""Validates that next_path is a safe relative path for redirection."""
|
||||
# Browsers interpret backslashes as forward slashes. Remove all backslashes.
|
||||
if not next_path or not isinstance(next_path, str):
|
||||
return ""
|
||||
|
||||
# Limit input length to prevent DoS attacks
|
||||
if len(next_path) > 500:
|
||||
return ""
|
||||
|
||||
next_path = next_path.replace("\\", "")
|
||||
parsed_url = urlparse(next_path)
|
||||
|
||||
# Block absolute URLs or anything with scheme/netloc
|
||||
if parsed_url.scheme or parsed_url.netloc:
|
||||
next_path = parsed_url.path # Extract only the path component
|
||||
|
||||
# Must start with a forward slash and not be empty
|
||||
if not next_path or not next_path.startswith("/"):
|
||||
return ""
|
||||
|
||||
# Prevent path traversal
|
||||
if ".." in next_path:
|
||||
return ""
|
||||
|
||||
# Additional security checks
|
||||
if _contains_suspicious_patterns(next_path):
|
||||
return ""
|
||||
|
||||
return next_path
|
||||
|
||||
|
||||
def get_safe_redirect_url(base_url: str, next_path: str = "", params: dict = {}) -> str:
|
||||
"""
|
||||
Safely construct a redirect URL with validated next_path.
|
||||
|
||||
Args:
|
||||
base_url (str): The base URL to redirect to
|
||||
next_path (str): The next path to append
|
||||
params (dict): The parameters to append
|
||||
Returns:
|
||||
str: The safe redirect URL
|
||||
"""
|
||||
from urllib.parse import urlencode
|
||||
|
||||
# Validate the next path
|
||||
validated_path = validate_next_path(next_path)
|
||||
|
||||
# Add the next path to the parameters
|
||||
base_url = base_url.rstrip("/")
|
||||
|
||||
# Prepare the query parameters
|
||||
query_parts = []
|
||||
encoded_params = ""
|
||||
|
||||
# Add the next path to the parameters
|
||||
if validated_path:
|
||||
query_parts.append(f"next_path={validated_path}")
|
||||
|
||||
# Add additional parameters
|
||||
if params:
|
||||
encoded_params = urlencode(params)
|
||||
query_parts.append(encoded_params)
|
||||
|
||||
# Construct the url query string
|
||||
if query_parts:
|
||||
query_string = "&".join(query_parts)
|
||||
url = f"{base_url}/?{query_string}"
|
||||
else:
|
||||
url = base_url
|
||||
|
||||
# Check if the URL is allowed
|
||||
if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()):
|
||||
return url
|
||||
|
||||
# Return the base URL if the URL is not allowed
|
||||
return base_url + (f"?{encoded_params}" if encoded_params else "")
|
||||
@@ -0,0 +1,21 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
from .workspace import (
|
||||
WorkSpaceBasePermission,
|
||||
WorkspaceOwnerPermission,
|
||||
WorkSpaceAdminPermission,
|
||||
WorkspaceEntityPermission,
|
||||
WorkspaceViewerPermission,
|
||||
WorkspaceUserPermission,
|
||||
)
|
||||
from .project import (
|
||||
ProjectBasePermission,
|
||||
ProjectEntityPermission,
|
||||
ProjectMemberPermission,
|
||||
ProjectLitePermission,
|
||||
ProjectAdminPermission,
|
||||
)
|
||||
from .base import allow_permission, ROLE
|
||||
from .page import ProjectPagePermission
|
||||
@@ -0,0 +1,77 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
from plane.db.models import WorkspaceMember, ProjectMember
|
||||
from functools import wraps
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ROLE(Enum):
|
||||
ADMIN = 20
|
||||
MEMBER = 15
|
||||
GUEST = 5
|
||||
|
||||
|
||||
def allow_permission(allowed_roles, level="PROJECT", creator=False, model=None):
|
||||
def decorator(view_func):
|
||||
@wraps(view_func)
|
||||
def _wrapped_view(instance, request, *args, **kwargs):
|
||||
# Check for creator if required
|
||||
if creator and model:
|
||||
obj = model.objects.filter(id=kwargs["pk"], created_by=request.user).exists()
|
||||
if obj:
|
||||
return view_func(instance, request, *args, **kwargs)
|
||||
|
||||
# Convert allowed_roles to their values if they are enum members
|
||||
allowed_role_values = [role.value if isinstance(role, ROLE) else role for role in allowed_roles]
|
||||
|
||||
# Check role permissions
|
||||
if level == "WORKSPACE":
|
||||
if WorkspaceMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=kwargs["slug"],
|
||||
role__in=allowed_role_values,
|
||||
is_active=True,
|
||||
).exists():
|
||||
return view_func(instance, request, *args, **kwargs)
|
||||
else:
|
||||
is_user_has_allowed_role = ProjectMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=kwargs["slug"],
|
||||
project_id=kwargs["project_id"],
|
||||
role__in=allowed_role_values,
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
# Return if the user has the allowed role else if they are workspace admin and part of the project regardless of the role # noqa: E501
|
||||
if is_user_has_allowed_role:
|
||||
return view_func(instance, request, *args, **kwargs)
|
||||
elif (
|
||||
ProjectMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=kwargs["slug"],
|
||||
project_id=kwargs["project_id"],
|
||||
is_active=True,
|
||||
).exists()
|
||||
and WorkspaceMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=kwargs["slug"],
|
||||
role=ROLE.ADMIN.value,
|
||||
is_active=True,
|
||||
).exists()
|
||||
):
|
||||
return view_func(instance, request, *args, **kwargs)
|
||||
|
||||
# Return permission denied if no conditions are met
|
||||
return Response(
|
||||
{"error": "You don't have the required permissions."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
return _wrapped_view
|
||||
|
||||
return decorator
|
||||
@@ -0,0 +1,125 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
from plane.db.models import ProjectMember, Page
|
||||
from plane.app.permissions import ROLE
|
||||
|
||||
|
||||
from rest_framework.permissions import BasePermission, SAFE_METHODS
|
||||
|
||||
|
||||
# Permission Mappings for workspace members
|
||||
ADMIN = ROLE.ADMIN.value
|
||||
MEMBER = ROLE.MEMBER.value
|
||||
GUEST = ROLE.GUEST.value
|
||||
|
||||
|
||||
class ProjectPagePermission(BasePermission):
|
||||
"""
|
||||
Custom permission to control access to pages within a workspace
|
||||
based on user roles, page visibility (public/private), and feature flags.
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""
|
||||
Check basic project-level permissions before checking object-level permissions.
|
||||
"""
|
||||
if request.user.is_anonymous:
|
||||
return False
|
||||
|
||||
user_id = request.user.id
|
||||
slug = view.kwargs.get("slug")
|
||||
page_id = view.kwargs.get("page_id")
|
||||
project_id = view.kwargs.get("project_id")
|
||||
|
||||
# Hook for extended validation
|
||||
extended_access, role = self._check_access_and_get_role(request, slug, project_id)
|
||||
if extended_access is False:
|
||||
return False
|
||||
|
||||
if page_id:
|
||||
page = Page.objects.get(id=page_id, workspace__slug=slug)
|
||||
|
||||
# Allow access if the user is the owner of the page
|
||||
if page.owned_by_id == user_id:
|
||||
return True
|
||||
|
||||
# Handle private page access
|
||||
if page.access == Page.PRIVATE_ACCESS:
|
||||
return self._has_private_page_action_access(request, slug, page, project_id)
|
||||
|
||||
# Handle public page access
|
||||
return self._has_public_page_action_access(request, role)
|
||||
|
||||
def _check_project_member_access(self, request, slug, project_id):
|
||||
"""
|
||||
Check if the user is a project member.
|
||||
"""
|
||||
return (
|
||||
ProjectMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
is_active=True,
|
||||
project_id=project_id,
|
||||
)
|
||||
.values_list("role", flat=True)
|
||||
.first()
|
||||
)
|
||||
|
||||
def _check_access_and_get_role(self, request, slug, project_id):
|
||||
"""
|
||||
Hook for extended access checking
|
||||
Returns: True (allow), False (deny), None (continue with normal flow)
|
||||
"""
|
||||
role = self._check_project_member_access(request, slug, project_id)
|
||||
if not role:
|
||||
return False, None
|
||||
return True, role
|
||||
|
||||
def _has_private_page_action_access(self, request, slug, page, project_id):
|
||||
"""
|
||||
Check access to private pages. Override for feature flag logic.
|
||||
"""
|
||||
# Base implementation: only owner can access private pages
|
||||
return False
|
||||
|
||||
def _check_project_action_access(self, request, role):
|
||||
method = request.method
|
||||
|
||||
# Only admins can create (POST) pages
|
||||
if method == "POST":
|
||||
if role in [ADMIN, MEMBER]:
|
||||
return True
|
||||
return False
|
||||
|
||||
# Safe methods (GET, HEAD, OPTIONS) allowed for all active roles
|
||||
if method in SAFE_METHODS:
|
||||
if role in [ADMIN, MEMBER, GUEST]:
|
||||
return True
|
||||
return False
|
||||
|
||||
# PUT/PATCH: Admins and members can update
|
||||
if method in ["PUT", "PATCH"]:
|
||||
if role in [ADMIN, MEMBER]:
|
||||
return True
|
||||
return False
|
||||
|
||||
# DELETE: Only admins can delete
|
||||
if method == "DELETE":
|
||||
if role in [ADMIN]:
|
||||
return True
|
||||
return False
|
||||
|
||||
# Deny by default
|
||||
return False
|
||||
|
||||
def _has_public_page_action_access(self, request, role):
|
||||
"""
|
||||
Check if the user has permission to access a public page
|
||||
and can perform operations on the page.
|
||||
"""
|
||||
project_member_exists = self._check_project_action_access(request, role)
|
||||
if not project_member_exists:
|
||||
return False
|
||||
return True
|
||||
@@ -0,0 +1,143 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework.permissions import SAFE_METHODS, BasePermission
|
||||
|
||||
# Module import
|
||||
from plane.db.models import ProjectMember, WorkspaceMember
|
||||
from plane.db.models.project import ROLE
|
||||
|
||||
|
||||
class ProjectBasePermission(BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
if request.user.is_anonymous:
|
||||
return False
|
||||
|
||||
## Safe Methods -> Handle the filtering logic in queryset
|
||||
if request.method in SAFE_METHODS:
|
||||
return WorkspaceMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug, member=request.user, is_active=True
|
||||
).exists()
|
||||
|
||||
## Only workspace owners or admins can create the projects
|
||||
if request.method == "POST":
|
||||
return WorkspaceMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value],
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
project_member_qs = ProjectMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
project_id=view.project_id,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
## Only project admins or workspace admin who is part of the project can access
|
||||
|
||||
if project_member_qs.filter(role=ROLE.ADMIN.value).exists():
|
||||
return True
|
||||
else:
|
||||
return (
|
||||
project_member_qs.exists()
|
||||
and WorkspaceMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=view.workspace_slug,
|
||||
role=ROLE.ADMIN.value,
|
||||
is_active=True,
|
||||
).exists()
|
||||
)
|
||||
|
||||
|
||||
class ProjectMemberPermission(BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
if request.user.is_anonymous:
|
||||
return False
|
||||
|
||||
## Safe Methods -> Handle the filtering logic in queryset
|
||||
if request.method in SAFE_METHODS:
|
||||
return ProjectMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug, member=request.user, is_active=True
|
||||
).exists()
|
||||
## Only workspace owners or admins can create the projects
|
||||
if request.method == "POST":
|
||||
return WorkspaceMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value],
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
## Only Project Admins can update project attributes
|
||||
return ProjectMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value],
|
||||
project_id=view.project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
|
||||
class ProjectEntityPermission(BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
if request.user.is_anonymous:
|
||||
return False
|
||||
|
||||
# Handle requests based on project__identifier
|
||||
if hasattr(view, "project_identifier") and view.project_identifier:
|
||||
if request.method in SAFE_METHODS:
|
||||
return ProjectMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
project__identifier=view.project_identifier,
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
## Safe Methods -> Handle the filtering logic in queryset
|
||||
if request.method in SAFE_METHODS:
|
||||
return ProjectMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
project_id=view.project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
## Only project members or admins can create and edit the project attributes
|
||||
return ProjectMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value],
|
||||
project_id=view.project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
|
||||
class ProjectAdminPermission(BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
if request.user.is_anonymous:
|
||||
return False
|
||||
|
||||
return ProjectMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
role=ROLE.ADMIN.value,
|
||||
project_id=view.project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
|
||||
class ProjectLitePermission(BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
if request.user.is_anonymous:
|
||||
return False
|
||||
|
||||
return ProjectMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
project_id=view.project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
@@ -0,0 +1,110 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework.permissions import BasePermission, SAFE_METHODS
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import WorkspaceMember
|
||||
|
||||
|
||||
# Permission Mappings
|
||||
Admin = 20
|
||||
Member = 15
|
||||
Guest = 5
|
||||
|
||||
|
||||
# TODO: Move the below logic to python match - python v3.10
|
||||
class WorkSpaceBasePermission(BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
# allow anyone to create a workspace
|
||||
if request.user.is_anonymous:
|
||||
return False
|
||||
|
||||
if request.method == "POST":
|
||||
return True
|
||||
|
||||
## Safe Methods
|
||||
if request.method in SAFE_METHODS:
|
||||
return True
|
||||
|
||||
# allow only admins and owners to update the workspace settings
|
||||
if request.method in ["PUT", "PATCH"]:
|
||||
return WorkspaceMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=view.workspace_slug,
|
||||
role__in=[Admin, Member],
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
# allow only owner to delete the workspace
|
||||
if request.method == "DELETE":
|
||||
return WorkspaceMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=view.workspace_slug,
|
||||
role=Admin,
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
|
||||
class WorkspaceOwnerPermission(BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
if request.user.is_anonymous:
|
||||
return False
|
||||
|
||||
return WorkspaceMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug, member=request.user, role=Admin
|
||||
).exists()
|
||||
|
||||
|
||||
class WorkSpaceAdminPermission(BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
if request.user.is_anonymous:
|
||||
return False
|
||||
|
||||
return WorkspaceMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=view.workspace_slug,
|
||||
role__in=[Admin, Member],
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
|
||||
class WorkspaceEntityPermission(BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
if request.user.is_anonymous:
|
||||
return False
|
||||
|
||||
## Safe Methods -> Handle the filtering logic in queryset
|
||||
if request.method in SAFE_METHODS:
|
||||
return WorkspaceMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug, member=request.user, is_active=True
|
||||
).exists()
|
||||
|
||||
return WorkspaceMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=view.workspace_slug,
|
||||
role__in=[Admin, Member],
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
|
||||
class WorkspaceViewerPermission(BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
if request.user.is_anonymous:
|
||||
return False
|
||||
|
||||
return WorkspaceMember.objects.filter(
|
||||
member=request.user, workspace__slug=view.workspace_slug, is_active=True
|
||||
).exists()
|
||||
|
||||
|
||||
class WorkspaceUserPermission(BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
if request.user.is_anonymous:
|
||||
return False
|
||||
|
||||
return WorkspaceMember.objects.filter(
|
||||
member=request.user, workspace__slug=view.workspace_slug, is_active=True
|
||||
).exists()
|
||||
@@ -0,0 +1,19 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
from .formatters import BaseFormatter, CSVFormatter, JSONFormatter, XLSXFormatter
|
||||
from .exporter import DataExporter
|
||||
from .serializers import IssueExportSerializer
|
||||
|
||||
__all__ = [
|
||||
# Formatters
|
||||
"BaseFormatter",
|
||||
"CSVFormatter",
|
||||
"JSONFormatter",
|
||||
"XLSXFormatter",
|
||||
# Exporters
|
||||
"DataExporter",
|
||||
# Export Serializers
|
||||
"IssueExportSerializer",
|
||||
]
|
||||
@@ -0,0 +1,107 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
from typing import Dict, List, Union
|
||||
from .formatters import BaseFormatter, CSVFormatter, JSONFormatter, XLSXFormatter
|
||||
|
||||
|
||||
class DataExporter:
|
||||
"""
|
||||
Export data using DRF serializers with built-in format support.
|
||||
|
||||
Usage:
|
||||
# New simplified interface
|
||||
exporter = DataExporter(BookSerializer, format_type='csv')
|
||||
filename, content = exporter.export('books_export', queryset)
|
||||
|
||||
# Legacy interface (still supported)
|
||||
exporter = DataExporter(BookSerializer)
|
||||
csv_string = exporter.to_string(queryset, CSVFormatter())
|
||||
"""
|
||||
|
||||
# Available formatters
|
||||
FORMATTERS = {
|
||||
"csv": CSVFormatter,
|
||||
"json": JSONFormatter,
|
||||
"xlsx": XLSXFormatter,
|
||||
}
|
||||
|
||||
def __init__(self, serializer_class, format_type: str = None, **serializer_kwargs):
|
||||
"""
|
||||
Initialize exporter with serializer and optional format type.
|
||||
|
||||
Args:
|
||||
serializer_class: DRF serializer class to use for data serialization
|
||||
format_type: Optional format type (csv, json, xlsx). If provided, enables export() method.
|
||||
**serializer_kwargs: Additional kwargs to pass to serializer
|
||||
"""
|
||||
self.serializer_class = serializer_class
|
||||
self.serializer_kwargs = serializer_kwargs
|
||||
self.format_type = format_type
|
||||
self.formatter = None
|
||||
|
||||
if format_type:
|
||||
if format_type not in self.FORMATTERS:
|
||||
raise ValueError(f"Unsupported format: {format_type}. Available: {list(self.FORMATTERS.keys())}")
|
||||
# Create formatter with default options
|
||||
self.formatter = self._create_formatter(format_type)
|
||||
|
||||
def _create_formatter(self, format_type: str) -> BaseFormatter:
|
||||
"""Create formatter instance with appropriate options."""
|
||||
formatter_class = self.FORMATTERS[format_type]
|
||||
|
||||
# Apply format-specific options
|
||||
if format_type == "xlsx":
|
||||
return formatter_class(list_joiner=", ")
|
||||
else:
|
||||
return formatter_class()
|
||||
|
||||
def serialize(self, queryset) -> List[Dict]:
|
||||
"""QuerySet → list of dicts"""
|
||||
serializer = self.serializer_class(
|
||||
queryset,
|
||||
many=True,
|
||||
**self.serializer_kwargs
|
||||
)
|
||||
return serializer.data
|
||||
|
||||
def export(self, filename: str, queryset) -> tuple[str, Union[str, bytes]]:
|
||||
"""
|
||||
Export queryset to file with configured format.
|
||||
|
||||
Args:
|
||||
filename: Base filename (without extension)
|
||||
queryset: Django QuerySet to export
|
||||
|
||||
Returns:
|
||||
Tuple of (filename_with_extension, content)
|
||||
|
||||
Raises:
|
||||
ValueError: If format_type was not provided during initialization
|
||||
"""
|
||||
if not self.formatter:
|
||||
raise ValueError("format_type must be provided during initialization to use export() method")
|
||||
|
||||
data = self.serialize(queryset)
|
||||
content = self.formatter.encode(data)
|
||||
full_filename = f"{filename}.{self.formatter.extension}"
|
||||
|
||||
return full_filename, content
|
||||
|
||||
def to_string(self, queryset, formatter: BaseFormatter) -> Union[str, bytes]:
|
||||
"""Export to formatted string (legacy interface)"""
|
||||
data = self.serialize(queryset)
|
||||
return formatter.encode(data)
|
||||
|
||||
def to_file(self, queryset, filepath: str, formatter: BaseFormatter) -> str:
|
||||
"""Export to file (legacy interface)"""
|
||||
content = self.to_string(queryset, formatter)
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
return filepath
|
||||
|
||||
@classmethod
|
||||
def get_available_formats(cls) -> List[str]:
|
||||
"""Get list of available export formats."""
|
||||
return list(cls.FORMATTERS.keys())
|
||||
@@ -0,0 +1,274 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""
|
||||
Import/Export System with Pluggable Formatters
|
||||
|
||||
Exporter: QuerySet → Serializer → Formatter → File/String
|
||||
Importer: File/String → Formatter → Serializer → Models
|
||||
"""
|
||||
|
||||
import csv
|
||||
import json
|
||||
from abc import ABC, abstractmethod
|
||||
from io import BytesIO, StringIO
|
||||
from typing import Any, Dict, List, Union
|
||||
|
||||
from openpyxl import Workbook, load_workbook
|
||||
|
||||
|
||||
# Module imports
|
||||
from plane.utils.csv_utils import sanitize_csv_row, sanitize_csv_value
|
||||
|
||||
|
||||
class BaseFormatter(ABC):
|
||||
@abstractmethod
|
||||
def encode(self, data: List[Dict]) -> Union[str, bytes]:
|
||||
"""Data → formatted string/bytes"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def decode(self, content: Union[str, bytes]) -> List[Dict]:
|
||||
"""Formatted string/bytes → data"""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def extension(self) -> str:
|
||||
pass
|
||||
|
||||
|
||||
class JSONFormatter(BaseFormatter):
|
||||
def __init__(self, indent: int = 2):
|
||||
self.indent = indent
|
||||
|
||||
def encode(self, data: List[Dict]) -> str:
|
||||
return json.dumps(data, indent=self.indent, default=str)
|
||||
|
||||
def decode(self, content: str) -> List[Dict]:
|
||||
return json.loads(content)
|
||||
|
||||
@property
|
||||
def extension(self) -> str:
|
||||
return "json"
|
||||
|
||||
|
||||
class CSVFormatter(BaseFormatter):
|
||||
def __init__(self, flatten: bool = True, delimiter: str = ",", prettify_headers: bool = True):
|
||||
"""
|
||||
Args:
|
||||
flatten: Whether to flatten nested dicts.
|
||||
delimiter: CSV delimiter character.
|
||||
prettify_headers: If True, transforms 'created_by_name' → 'Created By Name'.
|
||||
"""
|
||||
self.flatten = flatten
|
||||
self.delimiter = delimiter
|
||||
self.prettify_headers = prettify_headers
|
||||
|
||||
def _prettify_header(self, header: str) -> str:
|
||||
"""Transform 'created_by_name' → 'Created By Name'"""
|
||||
return header.replace("_", " ").title()
|
||||
|
||||
def _normalize_header(self, header: str) -> str:
|
||||
"""Transform 'Display Name' → 'display_name' (reverse of prettify)"""
|
||||
return header.strip().lower().replace(" ", "_")
|
||||
|
||||
def _flatten(self, row: Dict, parent_key: str = "") -> Dict:
|
||||
items = {}
|
||||
for key, value in row.items():
|
||||
new_key = f"{parent_key}__{key}" if parent_key else key
|
||||
if isinstance(value, dict):
|
||||
items.update(self._flatten(value, new_key))
|
||||
elif isinstance(value, list):
|
||||
items[new_key] = json.dumps(value)
|
||||
else:
|
||||
items[new_key] = value
|
||||
return items
|
||||
|
||||
def _unflatten(self, row: Dict) -> Dict:
|
||||
result = {}
|
||||
for key, value in row.items():
|
||||
parts = key.split("__")
|
||||
current = result
|
||||
for part in parts[:-1]:
|
||||
current = current.setdefault(part, {})
|
||||
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
if isinstance(parsed, (list, dict)):
|
||||
value = parsed
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
current[parts[-1]] = value
|
||||
return result
|
||||
|
||||
def encode(self, data: List[Dict]) -> str:
|
||||
if not data:
|
||||
return ""
|
||||
|
||||
if self.flatten:
|
||||
data = [self._flatten(row) for row in data]
|
||||
|
||||
# Collect all unique field names in order
|
||||
fieldnames = []
|
||||
for row in data:
|
||||
for key in row.keys():
|
||||
if key not in fieldnames:
|
||||
fieldnames.append(key)
|
||||
|
||||
output = StringIO()
|
||||
|
||||
if self.prettify_headers:
|
||||
# Create header mapping: original_key → Pretty Header
|
||||
header_map = {key: self._prettify_header(key) for key in fieldnames}
|
||||
pretty_headers = [header_map[key] for key in fieldnames]
|
||||
|
||||
# Write pretty headers manually, then write data rows
|
||||
writer = csv.writer(output, delimiter=self.delimiter)
|
||||
writer.writerow(pretty_headers)
|
||||
|
||||
# Write data rows in the same field order
|
||||
for row in data:
|
||||
writer.writerow(sanitize_csv_row([row.get(key, "") for key in fieldnames]))
|
||||
else:
|
||||
writer = csv.DictWriter(output, fieldnames=fieldnames, delimiter=self.delimiter)
|
||||
writer.writeheader()
|
||||
for row in data:
|
||||
writer.writerow({k: sanitize_csv_value(row.get(k, "")) for k in fieldnames})
|
||||
|
||||
return output.getvalue()
|
||||
|
||||
def decode(self, content: str, normalize_headers: bool = True) -> List[Dict]:
|
||||
"""
|
||||
Decode CSV content to list of dicts.
|
||||
|
||||
Args:
|
||||
content: CSV string
|
||||
normalize_headers: If True, converts 'Display Name' → 'display_name'
|
||||
"""
|
||||
rows = list(csv.DictReader(StringIO(content), delimiter=self.delimiter))
|
||||
|
||||
# Normalize headers: 'Email' → 'email', 'Display Name' → 'display_name'
|
||||
if normalize_headers:
|
||||
rows = [{self._normalize_header(k): v for k, v in row.items()} for row in rows]
|
||||
|
||||
if self.flatten:
|
||||
rows = [self._unflatten(row) for row in rows]
|
||||
|
||||
return rows
|
||||
|
||||
@property
|
||||
def extension(self) -> str:
|
||||
return "csv"
|
||||
|
||||
|
||||
class XLSXFormatter(BaseFormatter):
|
||||
"""Formatter for XLSX (Excel) files using openpyxl."""
|
||||
|
||||
def __init__(self, prettify_headers: bool = True, list_joiner: str = ", "):
|
||||
"""
|
||||
Args:
|
||||
prettify_headers: If True, transforms 'created_by_name' → 'Created By Name'.
|
||||
list_joiner: String to join list values (default: ", ").
|
||||
"""
|
||||
self.prettify_headers = prettify_headers
|
||||
self.list_joiner = list_joiner
|
||||
|
||||
def _prettify_header(self, header: str) -> str:
|
||||
"""Transform 'created_by_name' → 'Created By Name'"""
|
||||
return header.replace("_", " ").title()
|
||||
|
||||
def _normalize_header(self, header: str) -> str:
|
||||
"""Transform 'Display Name' → 'display_name' (reverse of prettify)"""
|
||||
return header.strip().lower().replace(" ", "_")
|
||||
|
||||
def _format_value(self, value: Any) -> Any:
|
||||
"""Format a value for XLSX cell."""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, list):
|
||||
return self.list_joiner.join(str(v) for v in value)
|
||||
if isinstance(value, dict):
|
||||
return json.dumps(value)
|
||||
return value
|
||||
|
||||
def encode(self, data: List[Dict]) -> bytes:
|
||||
"""Encode data to XLSX bytes."""
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
|
||||
if not data:
|
||||
# Return empty workbook
|
||||
output = BytesIO()
|
||||
wb.save(output)
|
||||
output.seek(0)
|
||||
return output.getvalue()
|
||||
|
||||
# Collect all unique field names in order
|
||||
fieldnames = []
|
||||
for row in data:
|
||||
for key in row.keys():
|
||||
if key not in fieldnames:
|
||||
fieldnames.append(key)
|
||||
|
||||
# Write header row
|
||||
if self.prettify_headers:
|
||||
headers = [self._prettify_header(key) for key in fieldnames]
|
||||
else:
|
||||
headers = fieldnames
|
||||
ws.append(headers)
|
||||
|
||||
# Write data rows
|
||||
for row in data:
|
||||
ws.append([self._format_value(row.get(key, "")) for key in fieldnames])
|
||||
|
||||
output = BytesIO()
|
||||
wb.save(output)
|
||||
output.seek(0)
|
||||
return output.getvalue()
|
||||
|
||||
def decode(self, content: bytes, normalize_headers: bool = True) -> List[Dict]:
|
||||
"""
|
||||
Decode XLSX bytes to list of dicts.
|
||||
|
||||
Args:
|
||||
content: XLSX file bytes
|
||||
normalize_headers: If True, converts 'Display Name' → 'display_name'
|
||||
"""
|
||||
wb = load_workbook(filename=BytesIO(content), read_only=True, data_only=True)
|
||||
ws = wb.active
|
||||
|
||||
rows = list(ws.iter_rows(values_only=True))
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
# First row is headers
|
||||
headers = list(rows[0])
|
||||
if normalize_headers:
|
||||
headers = [self._normalize_header(str(h)) if h else "" for h in headers]
|
||||
|
||||
# Convert remaining rows to dicts
|
||||
result = []
|
||||
for row in rows[1:]:
|
||||
row_dict = {}
|
||||
for i, value in enumerate(row):
|
||||
if i < len(headers) and headers[i]:
|
||||
# Try to parse JSON strings back to lists/dicts
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
if isinstance(parsed, (list, dict)):
|
||||
value = parsed
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
row_dict[headers[i]] = value
|
||||
result.append(row_dict)
|
||||
|
||||
return result
|
||||
|
||||
@property
|
||||
def extension(self) -> str:
|
||||
return "xlsx"
|
||||
@@ -0,0 +1,10 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
from .issue import IssueExportSerializer
|
||||
|
||||
__all__ = [
|
||||
# Export Serializers
|
||||
"IssueExportSerializer",
|
||||
]
|
||||
@@ -0,0 +1,145 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import serializers
|
||||
|
||||
# Module imports
|
||||
from plane.app.serializers import IssueSerializer
|
||||
|
||||
|
||||
class IssueExportSerializer(IssueSerializer):
|
||||
"""
|
||||
Export-optimized serializer that extends IssueSerializer with human-readable fields.
|
||||
|
||||
Converts UUIDs to readable values for CSV/JSON export.
|
||||
"""
|
||||
|
||||
identifier = serializers.SerializerMethodField()
|
||||
project_name = serializers.CharField(source='project.name', read_only=True, default="")
|
||||
project_identifier = serializers.CharField(source='project.identifier', read_only=True, default="")
|
||||
state_name = serializers.CharField(source='state.name', read_only=True, default="")
|
||||
created_by_name = serializers.CharField(source='created_by.full_name', read_only=True, default="")
|
||||
|
||||
assignees = serializers.SerializerMethodField()
|
||||
parent = serializers.SerializerMethodField()
|
||||
labels = serializers.SerializerMethodField()
|
||||
cycles = serializers.SerializerMethodField()
|
||||
modules = serializers.SerializerMethodField()
|
||||
comments = serializers.SerializerMethodField()
|
||||
estimate = serializers.SerializerMethodField()
|
||||
links = serializers.SerializerMethodField()
|
||||
relations = serializers.SerializerMethodField()
|
||||
subscribers = serializers.SerializerMethodField()
|
||||
|
||||
class Meta(IssueSerializer.Meta):
|
||||
fields = [
|
||||
"project_name",
|
||||
"project_identifier",
|
||||
"parent",
|
||||
"identifier",
|
||||
"sequence_id",
|
||||
"name",
|
||||
"state_name",
|
||||
"priority",
|
||||
"assignees",
|
||||
"subscribers",
|
||||
"created_by_name",
|
||||
"start_date",
|
||||
"target_date",
|
||||
"completed_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"archived_at",
|
||||
"estimate",
|
||||
"labels",
|
||||
"cycles",
|
||||
"modules",
|
||||
"links",
|
||||
"relations",
|
||||
"comments",
|
||||
"sub_issues_count",
|
||||
"link_count",
|
||||
"attachment_count",
|
||||
"is_draft",
|
||||
]
|
||||
|
||||
def get_identifier(self, obj):
|
||||
return f"{obj.project.identifier}-{obj.sequence_id}"
|
||||
|
||||
def get_assignees(self, obj):
|
||||
return [u.full_name for u in obj.assignees.all() if u.is_active]
|
||||
|
||||
def get_subscribers(self, obj):
|
||||
"""Return list of subscriber names."""
|
||||
return [sub.subscriber.full_name for sub in obj.issue_subscribers.all() if sub.subscriber]
|
||||
|
||||
def get_parent(self, obj):
|
||||
if not obj.parent:
|
||||
return ""
|
||||
return f"{obj.parent.project.identifier}-{obj.parent.sequence_id}"
|
||||
|
||||
def get_labels(self, obj):
|
||||
return [
|
||||
il.label.name
|
||||
for il in obj.label_issue.all()
|
||||
if il.deleted_at is None
|
||||
]
|
||||
|
||||
def get_cycles(self, obj):
|
||||
return [ic.cycle.name for ic in obj.issue_cycle.all()]
|
||||
|
||||
def get_modules(self, obj):
|
||||
return [im.module.name for im in obj.issue_module.all()]
|
||||
|
||||
def get_estimate(self, obj):
|
||||
"""Return estimate point value."""
|
||||
if obj.estimate_point:
|
||||
return obj.estimate_point.value if hasattr(obj.estimate_point, 'value') else str(obj.estimate_point)
|
||||
return ""
|
||||
|
||||
def get_links(self, obj):
|
||||
"""Return list of issue links with titles."""
|
||||
return [
|
||||
{
|
||||
"url": link.url,
|
||||
"title": link.title if link.title else link.url,
|
||||
}
|
||||
for link in obj.issue_link.all()
|
||||
]
|
||||
|
||||
def get_relations(self, obj):
|
||||
"""Return list of related issues."""
|
||||
relations = []
|
||||
|
||||
# Outgoing relations (this issue relates to others)
|
||||
for rel in obj.issue_relation.all():
|
||||
if rel.related_issue:
|
||||
relations.append({
|
||||
"type": rel.relation_type if hasattr(rel, 'relation_type') else "related",
|
||||
"issue": f"{rel.related_issue.project.identifier}-{rel.related_issue.sequence_id}",
|
||||
"direction": "outgoing"
|
||||
})
|
||||
|
||||
# Incoming relations (other issues relate to this one)
|
||||
for rel in obj.issue_related.all():
|
||||
if rel.issue:
|
||||
relations.append({
|
||||
"type": rel.relation_type if hasattr(rel, 'relation_type') else "related",
|
||||
"issue": f"{rel.issue.project.identifier}-{rel.issue.sequence_id}",
|
||||
"direction": "incoming"
|
||||
})
|
||||
|
||||
return relations
|
||||
|
||||
def get_comments(self, obj):
|
||||
"""Return list of comments with author and timestamp."""
|
||||
return [
|
||||
{
|
||||
"comment": comment.comment_stripped if hasattr(comment, 'comment_stripped') else comment.comment_html,
|
||||
"created_by": comment.actor.full_name if comment.actor else "",
|
||||
"created_at": comment.created_at.strftime("%Y-%m-%d %H:%M:%S") if comment.created_at else "",
|
||||
}
|
||||
for comment in obj.issue_comments.all()
|
||||
]
|
||||
@@ -0,0 +1,62 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
import os
|
||||
import atexit
|
||||
|
||||
# Third party imports
|
||||
from opentelemetry import trace
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
||||
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
from opentelemetry.instrumentation.django import DjangoInstrumentor
|
||||
|
||||
# Global variable to track initialization
|
||||
_TRACER_PROVIDER = None
|
||||
|
||||
|
||||
def init_tracer():
|
||||
"""Initialize OpenTelemetry with proper shutdown handling"""
|
||||
global _TRACER_PROVIDER
|
||||
|
||||
# If already initialized, return existing provider
|
||||
if _TRACER_PROVIDER is not None:
|
||||
return _TRACER_PROVIDER
|
||||
|
||||
# Configure the tracer provider
|
||||
service_name = os.environ.get("SERVICE_NAME", "plane-ce-api")
|
||||
resource = Resource.create({"service.name": service_name})
|
||||
tracer_provider = TracerProvider(resource=resource)
|
||||
|
||||
# Set as global tracer provider
|
||||
trace.set_tracer_provider(tracer_provider)
|
||||
|
||||
# Configure the OTLP exporter
|
||||
otel_endpoint = os.environ.get("OTLP_ENDPOINT", "https://telemetry.plane.so")
|
||||
otlp_exporter = OTLPSpanExporter(endpoint=otel_endpoint)
|
||||
span_processor = BatchSpanProcessor(otlp_exporter)
|
||||
tracer_provider.add_span_processor(span_processor)
|
||||
|
||||
# Initialize Django instrumentation
|
||||
DjangoInstrumentor().instrument()
|
||||
|
||||
# Store provider globally
|
||||
_TRACER_PROVIDER = tracer_provider
|
||||
|
||||
# Register shutdown handler
|
||||
atexit.register(shutdown_tracer)
|
||||
|
||||
return tracer_provider
|
||||
|
||||
|
||||
def shutdown_tracer():
|
||||
"""Shutdown OpenTelemetry tracers and processors"""
|
||||
global _TRACER_PROVIDER
|
||||
|
||||
if _TRACER_PROVIDER is not None:
|
||||
if hasattr(_TRACER_PROVIDER, "shutdown"):
|
||||
_TRACER_PROVIDER.shutdown()
|
||||
_TRACER_PROVIDER = None
|
||||
@@ -0,0 +1,125 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
import pytz
|
||||
from datetime import datetime, time
|
||||
from datetime import timedelta
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Project
|
||||
|
||||
|
||||
def user_timezone_converter(queryset, datetime_fields, user_timezone):
|
||||
# Create a timezone object for the user's timezone
|
||||
user_tz = pytz.timezone(user_timezone)
|
||||
|
||||
# Check if queryset is a dictionary (single item) or a list of dictionaries
|
||||
if isinstance(queryset, dict):
|
||||
queryset_values = [queryset]
|
||||
else:
|
||||
queryset_values = list(queryset)
|
||||
|
||||
# Iterate over the dictionaries in the list
|
||||
for item in queryset_values:
|
||||
# Iterate over the datetime fields
|
||||
for field in datetime_fields:
|
||||
# Convert the datetime field to the user's timezone
|
||||
if field in item and item[field]:
|
||||
item[field] = item[field].astimezone(user_tz)
|
||||
|
||||
# If queryset was a single item, return a single item
|
||||
if isinstance(queryset, dict):
|
||||
return queryset_values[0]
|
||||
else:
|
||||
return queryset_values
|
||||
|
||||
|
||||
def convert_to_utc(date, project_id, is_start_date=False):
|
||||
"""
|
||||
Converts a start date string to the project's local timezone at 12:00 AM
|
||||
and then converts it to UTC for storage.
|
||||
|
||||
Args:
|
||||
date (str): The date string in "YYYY-MM-DD" format.
|
||||
project_id (int): The project's ID to fetch the associated timezone.
|
||||
|
||||
Returns:
|
||||
datetime: The UTC datetime.
|
||||
"""
|
||||
# Retrieve the project's timezone using the project ID
|
||||
project = Project.objects.get(id=project_id)
|
||||
project_timezone = project.timezone
|
||||
if not date or not project_timezone:
|
||||
raise ValueError("Both date and timezone must be provided.")
|
||||
|
||||
# Parse the string into a date object
|
||||
start_date = datetime.strptime(date, "%Y-%m-%d").date()
|
||||
|
||||
# Get the project's timezone
|
||||
local_tz = pytz.timezone(project_timezone)
|
||||
|
||||
# Combine the date with 12:00 AM time
|
||||
local_datetime = datetime.combine(start_date, time.min)
|
||||
|
||||
# Localize the datetime to the project's timezone
|
||||
localized_datetime = local_tz.localize(local_datetime)
|
||||
|
||||
# If it's an start date, add one minute
|
||||
if is_start_date:
|
||||
localized_datetime += timedelta(minutes=0, seconds=1)
|
||||
|
||||
# Convert the localized datetime to UTC
|
||||
utc_datetime = localized_datetime.astimezone(pytz.utc)
|
||||
|
||||
current_datetime_in_project_tz = timezone.now().astimezone(local_tz)
|
||||
current_datetime_in_utc = current_datetime_in_project_tz.astimezone(pytz.utc)
|
||||
|
||||
if localized_datetime.date() == current_datetime_in_project_tz.date():
|
||||
return current_datetime_in_utc
|
||||
|
||||
return utc_datetime
|
||||
else:
|
||||
# the cycle end date is the last minute of the day
|
||||
localized_datetime += timedelta(hours=23, minutes=59, seconds=0)
|
||||
|
||||
# Convert the localized datetime to UTC
|
||||
utc_datetime = localized_datetime.astimezone(pytz.utc)
|
||||
|
||||
# Return the UTC datetime for storage
|
||||
return utc_datetime
|
||||
|
||||
|
||||
def convert_utc_to_project_timezone(utc_datetime, project_id):
|
||||
"""
|
||||
Converts a UTC datetime (stored in the database) to the project's local timezone.
|
||||
|
||||
Args:
|
||||
utc_datetime (datetime): The UTC datetime to be converted.
|
||||
project_id (int): The project's ID to fetch the associated timezone.
|
||||
|
||||
Returns:
|
||||
datetime: The datetime in the project's local timezone.
|
||||
"""
|
||||
# Retrieve the project's timezone using the project ID
|
||||
project = Project.objects.get(id=project_id)
|
||||
project_timezone = project.timezone
|
||||
if not project_timezone:
|
||||
raise ValueError("Project timezone must be provided.")
|
||||
|
||||
# Get the timezone object for the project's timezone
|
||||
local_tz = pytz.timezone(project_timezone)
|
||||
|
||||
# Convert the UTC datetime to the project's local timezone
|
||||
if utc_datetime.tzinfo is None:
|
||||
# Localize UTC datetime if it's naive (i.e., without timezone info)
|
||||
utc_datetime = pytz.utc.localize(utc_datetime)
|
||||
|
||||
# Convert to the project's local timezone
|
||||
local_datetime = utc_datetime.astimezone(local_tz)
|
||||
|
||||
return local_datetime
|
||||
@@ -0,0 +1,133 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
import re
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
# Compiled regex pattern for better performance and ReDoS protection
|
||||
# Using atomic groups and length limits to prevent excessive backtracking
|
||||
URL_PATTERN = re.compile(
|
||||
r"(?i)" # Case insensitive
|
||||
r"(?:" # Non-capturing group for alternatives
|
||||
r"https?://[^\s]+" # http:// or https:// followed by non-whitespace
|
||||
r"|"
|
||||
r"www\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*" # noqa: E501
|
||||
r"|"
|
||||
r"(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,6}" # noqa: E501
|
||||
r"|"
|
||||
r"(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)" # noqa: E501
|
||||
r")"
|
||||
)
|
||||
|
||||
|
||||
def contains_url(value: str) -> bool:
|
||||
"""
|
||||
Check if the value contains a URL.
|
||||
|
||||
This function is protected against ReDoS attacks by:
|
||||
1. Using a pre-compiled regex pattern
|
||||
2. Limiting input length to prevent excessive processing
|
||||
3. Using atomic groups and specific quantifiers to avoid backtracking
|
||||
|
||||
Args:
|
||||
value (str): The input string to check for URLs
|
||||
|
||||
Returns:
|
||||
bool: True if the string contains a URL, False otherwise
|
||||
"""
|
||||
# Prevent ReDoS by limiting input length
|
||||
if len(value) > 1000: # Reasonable limit for URL detection
|
||||
return False
|
||||
|
||||
# Additional safety: truncate very long lines that might contain URLs
|
||||
lines = value.split("\n")
|
||||
for line in lines:
|
||||
if len(line) > 500: # Process only reasonable length lines
|
||||
line = line[:500]
|
||||
if URL_PATTERN.search(line):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def is_valid_url(url: str) -> bool:
|
||||
"""
|
||||
Validates whether the given string is a well-formed URL.
|
||||
|
||||
Args:
|
||||
url (str): The URL string to validate.
|
||||
|
||||
Returns:
|
||||
bool: True if the URL is valid, False otherwise.
|
||||
|
||||
Example:
|
||||
>>> is_valid_url("https://example.com")
|
||||
True
|
||||
>>> is_valid_url("not a url")
|
||||
False
|
||||
"""
|
||||
try:
|
||||
result = urlparse(url)
|
||||
# A valid URL should have at least scheme and netloc
|
||||
return all([result.scheme, result.netloc])
|
||||
except TypeError:
|
||||
return False
|
||||
|
||||
|
||||
def get_url_components(url: str) -> Optional[dict]:
|
||||
"""
|
||||
Parses the URL and returns its components if valid.
|
||||
|
||||
Args:
|
||||
url (str): The URL string to parse.
|
||||
|
||||
Returns:
|
||||
Optional[dict]: A dictionary with URL components if valid, None otherwise.
|
||||
|
||||
Example:
|
||||
>>> get_url_components("https://example.com/path?query=1")
|
||||
{
|
||||
'scheme': 'https', 'netloc': 'example.com',
|
||||
'path': '/path', 'params': '',
|
||||
'query': 'query=1', 'fragment': ''}
|
||||
"""
|
||||
if not is_valid_url(url):
|
||||
return None
|
||||
result = urlparse(url)
|
||||
return {
|
||||
"scheme": result.scheme,
|
||||
"netloc": result.netloc,
|
||||
"path": result.path,
|
||||
"params": result.params,
|
||||
"query": result.query,
|
||||
"fragment": result.fragment,
|
||||
}
|
||||
|
||||
|
||||
def normalize_url_path(url: str) -> str:
|
||||
"""
|
||||
Normalize the path component of a URL by
|
||||
replacing multiple consecutive slashes with a single slash.
|
||||
|
||||
This function preserves the protocol, domain,
|
||||
query parameters, and fragments of the URL,
|
||||
only modifying the path portion to ensure there are no duplicate slashes.
|
||||
|
||||
Args:
|
||||
url (str): The input URL string to normalize.
|
||||
|
||||
Returns:
|
||||
str: The normalized URL with redundant slashes in the path removed.
|
||||
|
||||
Example:
|
||||
>>> normalize_url_path('https://example.com//foo///bar//baz?x=1#frag')
|
||||
'https://example.com/foo/bar/baz?x=1#frag'
|
||||
"""
|
||||
parts = urlparse(url)
|
||||
# Normalize the path
|
||||
normalized_path = re.sub(r"/+", "/", parts.path)
|
||||
# Reconstruct the URL
|
||||
return urlunparse(parts._replace(path=normalized_path))
|
||||
@@ -0,0 +1,26 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
import uuid
|
||||
import hashlib
|
||||
|
||||
|
||||
def is_valid_uuid(uuid_str):
|
||||
"""Check if a string is a valid UUID version 4"""
|
||||
try:
|
||||
uuid_obj = uuid.UUID(uuid_str)
|
||||
return uuid_obj.version == 4
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def convert_uuid_to_integer(uuid_val: uuid.UUID) -> int:
|
||||
"""Convert a UUID to a 64-bit signed integer"""
|
||||
# Ensure UUID is a string
|
||||
uuid_value: str = str(uuid_val)
|
||||
# Hash to 64-bit signed int
|
||||
h: bytes = hashlib.sha256(uuid_value.encode()).digest()
|
||||
bigint: int = int.from_bytes(h[:8], byteorder="big", signed=True)
|
||||
return bigint
|
||||
Reference in New Issue
Block a user