base
This commit is contained in:
@@ -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),
|
||||
}
|
||||
Reference in New Issue
Block a user