This commit is contained in:
DCCONSTRUCTIONS
2026-04-18 18:39:25 +03:00
commit 3ba092b60c
4944 changed files with 497564 additions and 0 deletions
@@ -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}
+88
View File
@@ -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
+13
View File
@@ -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,
}
+42
View File
@@ -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
+217
View File
@@ -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 []
+71
View File
@@ -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()
+21
View File
@@ -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()
+48
View File
@@ -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
+733
View File
@@ -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
+133
View File
@@ -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))
+26
View File
@@ -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