fix: responsibility change and additional fields

This commit is contained in:
Henit Chobisa
2025-12-15 15:54:53 +05:30
parent 9db6edb63c
commit c8d70ea184
4 changed files with 159 additions and 40 deletions

View File

@@ -15,11 +15,10 @@ from django.utils import timezone
from django.db.models import Prefetch
# Module imports
from plane.db.models import ExporterHistory, Issue, IssueRelation
from plane.db.models import ExporterHistory, Issue, IssueComment, IssueRelation, IssueSubscriber
from plane.utils.exception_logger import log_exception
from plane.utils.porters.exporter import DataExporter
from plane.utils.porters.serializers.issue import IssueExportSerializer
from plane.utils.porters.formatters import CSVFormatter, JSONFormatter, XLSXFormatter
def create_zip_file(files: List[tuple[str, str | bytes]]) -> io.BytesIO:
@@ -161,10 +160,16 @@ def issue_export_task(
"labels",
"issue_cycle__cycle",
"issue_module__module",
"issue_comments",
"assignees",
"issue_subscribers",
"issue_link",
Prefetch(
"issue_subscribers",
queryset=IssueSubscriber.objects.select_related("subscriber"),
),
Prefetch(
"issue_comments",
queryset=IssueComment.objects.select_related("actor").order_by("created_at"),
),
Prefetch(
"issue_relation",
queryset=IssueRelation.objects.select_related("related_issue", "related_issue__project"),
@@ -180,20 +185,9 @@ def issue_export_task(
)
)
# Map provider to formatter
formatter_map = {
"csv": CSVFormatter(),
"json": JSONFormatter(),
"xlsx": XLSXFormatter(list_joiner=", "),
}
# Create exporter for the specified format
try:
if provider not in formatter_map:
raise ValueError(f"Unsupported format: {provider}. Available: csv, json, xlsx")
formatter = formatter_map[provider]
exporter = DataExporter(IssueExportSerializer)
exporter = DataExporter(IssueExportSerializer, format_type=provider)
except ValueError as e:
# Invalid format type
exporter_instance = ExporterHistory.objects.get(token=token_id)
@@ -208,14 +202,12 @@ def issue_export_task(
for project_id in project_ids:
project_issues = workspace_issues.filter(project_id=project_id)
export_filename = f"{slug}-{project_id}"
content = exporter.to_string(project_issues, formatter)
filename = f"{export_filename}.{formatter.extension}"
filename, content = exporter.export(export_filename, project_issues)
files.append((filename, content))
else:
# Export all issues in a single file
export_filename = f"{slug}-{workspace_id}"
content = exporter.to_string(workspace_issues, formatter)
filename = f"{export_filename}.{formatter.extension}"
filename, content = exporter.export(export_filename, workspace_issues)
files.append((filename, content))
zip_buffer = create_zip_file(files)

View File

@@ -147,6 +147,11 @@ class User(AbstractBaseUser, PermissionsMixin):
return self.cover_image
return None
@property
def full_name(self):
"""Return user's full name (first + last)."""
return f"{self.first_name} {self.last_name}".strip()
def save(self, *args, **kwargs):
self.email = self.email.lower().strip()
self.mobile_number = self.mobile_number

View File

@@ -1,19 +1,57 @@
from typing import Dict, List
from .formatters import BaseFormatter
from typing import Dict, List, Union
from .formatters import BaseFormatter, CSVFormatter, JSONFormatter, XLSXFormatter
class DataExporter:
"""
Export data using DRF serializers.
Export data using DRF serializers with built-in format support.
Usage:
exporter = DataExporter(BookSerializer, exclude=['password'])
exporter.to_file(queryset, 'books.csv', CSVFormatter())
# 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())
"""
def __init__(self, serializer_class, **serializer_kwargs):
# 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"""
@@ -24,14 +62,42 @@ class DataExporter:
)
return serializer.data
def to_string(self, queryset, formatter: BaseFormatter) -> str:
"""Export to formatted string"""
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"""
"""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())

View File

@@ -16,13 +16,18 @@ class IssueExportSerializer(IssueSerializer):
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="")
created_by_name = serializers.SerializerMethodField()
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 = [
@@ -35,6 +40,7 @@ class IssueExportSerializer(IssueSerializer):
"state_name",
"priority",
"assignees",
"subscribers",
"created_by_name",
"start_date",
"target_date",
@@ -42,9 +48,13 @@ class IssueExportSerializer(IssueSerializer):
"created_at",
"updated_at",
"archived_at",
"estimate",
"labels",
"cycles",
"modules",
"links",
"relations",
"comments",
"sub_issues_count",
"link_count",
"attachment_count",
@@ -54,17 +64,12 @@ class IssueExportSerializer(IssueSerializer):
def get_identifier(self, obj):
return f"{obj.project.identifier}-{obj.sequence_id}"
def get_created_by_name(self, obj):
if not obj.created_by:
return ""
return f"{obj.created_by.first_name} {obj.created_by.last_name}".strip()
def get_assignees(self, obj):
return [
f"{u.first_name} {u.last_name}".strip()
for u in obj.assignees.all()
if u.is_active
]
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:
@@ -83,3 +88,54 @@ class IssueExportSerializer(IssueSerializer):
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()
]