Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions backend/apps/mentorship/api/internal/nodes/export_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""GraphQL types for export functionality."""

import strawberry


@strawberry.enum
class ExportFormatEnum(strawberry.Enum):
"""Supported export formats for issue data."""

CSV = "csv"
JSON = "json"


@strawberry.type
class ExportResult:
"""Result of an export operation containing the file content and metadata."""

content: str
filename: str
mime_type: str
count: int
57 changes: 57 additions & 0 deletions backend/apps/mentorship/api/internal/nodes/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,18 @@
from apps.github.models.pull_request import PullRequest
from apps.github.models.user import User
from apps.mentorship.api.internal.nodes.enum import ExperienceLevelEnum
from apps.mentorship.api.internal.nodes.export_types import ExportFormatEnum, ExportResult
from apps.mentorship.api.internal.nodes.mentor import MentorNode
from apps.mentorship.api.internal.nodes.program import ProgramNode
from apps.mentorship.models.issue_user_interest import IssueUserInterest
from apps.mentorship.models.task import Task
from apps.mentorship.utils.export import (
MAX_EXPORT_LIMIT,
ExportFormat,
generate_export_filename,
serialize_issues_to_csv,
serialize_issues_to_json,
)


@strawberry.type
Expand Down Expand Up @@ -171,6 +179,55 @@ def recent_pull_requests(self, limit: int = 5) -> list[PullRequestNode]:
.order_by("-created_at")[:limit]
)

@strawberry.field
def export_issues(
self,
format: ExportFormatEnum,
label: str | None = None,
limit: int = MAX_EXPORT_LIMIT,
) -> ExportResult:
"""Export issues in CSV or JSON format.

Args:
format: Export format (CSV or JSON).
label: Optional label filter.
limit: Maximum number of issues to export (default: 1000).

Returns:
ExportResult with content, filename, and MIME type.
"""
# Enforce maximum limit
effective_limit = min(limit, MAX_EXPORT_LIMIT)

# Reuse existing issue query logic
queryset = self.issues.select_related("repository", "author").prefetch_related(
"assignees", "labels"
)

if label and label != "all":
queryset = queryset.filter(labels__name=label)

issues = list(queryset.order_by("-updated_at")[:effective_limit])

# Serialize based on format
if format == ExportFormatEnum.CSV:
content = serialize_issues_to_csv(issues)
mime_type = "text/csv; charset=utf-8"
export_format = ExportFormat.CSV
else:
content = serialize_issues_to_json(issues, self.key)
mime_type = "application/json; charset=utf-8"
export_format = ExportFormat.JSON

filename = generate_export_filename(self.key, export_format)

return ExportResult(
content=content,
filename=filename,
mime_type=mime_type,
count=len(issues),
)


@strawberry.input
class CreateModuleInput:
Expand Down
12 changes: 11 additions & 1 deletion backend/apps/mentorship/api/internal/queries/program.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ def my_programs(
search: str = "",
page: int = 1,
limit: int = 24,
sort_by: str = "default",
order: str = "desc",
) -> PaginatedPrograms:
"""Get paginated programs where the current user is admin or mentor."""
user = info.context.request.user
Expand All @@ -63,7 +65,15 @@ def my_programs(
page = max(1, min(page, total_pages))
offset = (page - 1) * limit

paginated_programs = queryset.order_by("-nest_created_at")[offset : offset + limit]
sort_field_map = {
"name": "name",
"started_at": "started_at",
"ended_at": "ended_at",
"default": "nest_created_at",
}
sort_field = sort_field_map.get(sort_by, "nest_created_at")
order_prefix = "" if order == "asc" else "-"
paginated_programs = queryset.order_by(f"{order_prefix}{sort_field}")[offset : offset + limit]

results = []
mentor_id = mentor.id
Expand Down
9 changes: 9 additions & 0 deletions backend/apps/mentorship/utils/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""Mentorship utility modules."""

from apps.mentorship.utils.export import ExportFormat, serialize_issues_to_csv, serialize_issues_to_json

__all__ = [
"ExportFormat",
"serialize_issues_to_csv",
"serialize_issues_to_json",
]
187 changes: 187 additions & 0 deletions backend/apps/mentorship/utils/export.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
"""Export utilities for mentorship issues.

This module provides serialization functions for exporting issue data
in CSV and JSON formats with proper escaping and formatting.
"""

import csv
import json
from datetime import date, datetime
from enum import Enum
from io import StringIO


class ExportFormat(str, Enum):
"""Supported export formats."""

CSV = "csv"
JSON = "json"


# Fields to export from issues
EXPORT_FIELDS = [
"number",
"title",
"state",
"labels",
"assignees",
"author",
"created_at",
"url",
"repository_name",
]

# Maximum issues per export request (prevents memory issues)
MAX_EXPORT_LIMIT = 1000


def _format_datetime(dt: datetime | None) -> str:
"""Format datetime to ISO string or empty string if None."""
if dt is None:
return ""
return dt.isoformat()


def _format_list(items: list | None, separator: str = "; ") -> str:
"""Format a list to a separated string."""
if not items:
return ""
return separator.join(str(item) for item in items)


def _issue_to_dict(issue) -> dict:
"""Convert an issue object to a dictionary for export.

Args:
issue: Issue model instance with related data prefetched.

Returns:
Dictionary containing export-ready issue data.
"""
# Get assignee logins
assignees = []
if hasattr(issue, "assignees"):
assignees = [a.login for a in issue.assignees.all() if hasattr(a, "login")]

# Get label names
labels = []
if hasattr(issue, "labels"):
labels = [label.name for label in issue.labels.all() if hasattr(label, "name")]

# Get author login
author = ""
if hasattr(issue, "author") and issue.author:
author = issue.author.login if hasattr(issue.author, "login") else str(issue.author)

# Get repository name
repository_name = ""
if hasattr(issue, "repository") and issue.repository:
repository_name = issue.repository.name if hasattr(issue.repository, "name") else ""

return {
"number": issue.number,
"title": issue.title,
"state": issue.state,
"labels": labels,
"assignees": assignees,
"author": author,
"created_at": getattr(issue, "created_at", None),
"url": getattr(issue, "url", ""),
"repository_name": repository_name,
}


def serialize_issues_to_csv(issues: list) -> str:
"""Serialize issues to CSV format.

Args:
issues: List of Issue model instances.

Returns:
CSV string with UTF-8 BOM for Excel compatibility.
"""
output = StringIO()

# CSV headers
headers = [
"Number",
"Title",
"State",
"Labels",
"Assignees",
"Author",
"Created At",
"URL",
"Repository",
]

writer = csv.writer(output, quoting=csv.QUOTE_ALL)
writer.writerow(headers)

for issue in issues:
data = _issue_to_dict(issue)
writer.writerow([
data["number"],
data["title"],
data["state"],
_format_list(data["labels"]),
_format_list(data["assignees"]),
data["author"],
_format_datetime(data["created_at"]),
data["url"],
data["repository_name"],
])

# Add UTF-8 BOM for Excel compatibility
return "\ufeff" + output.getvalue()


def serialize_issues_to_json(issues: list, module_key: str = "") -> str:
"""Serialize issues to JSON format.

Args:
issues: List of Issue model instances.
module_key: Optional module key for metadata.

Returns:
JSON string with metadata and issues array.
"""
issues_data = []

for issue in issues:
data = _issue_to_dict(issue)
issues_data.append({
"number": data["number"],
"title": data["title"],
"state": data["state"],
"labels": data["labels"],
"assignees": data["assignees"],
"author": data["author"],
"createdAt": _format_datetime(data["created_at"]),
"url": data["url"],
"repositoryName": data["repository_name"],
})

result = {
"exportedAt": date.today().isoformat(),
"moduleKey": module_key,
"count": len(issues_data),
"issues": issues_data,
}

return json.dumps(result, indent=2, ensure_ascii=False)


def generate_export_filename(module_key: str, export_format: ExportFormat) -> str:
"""Generate a filename for the export.

Args:
module_key: The module key for identification.
export_format: The export format (CSV or JSON).

Returns:
Filename string with date stamp.
"""
date_str = date.today().strftime("%Y-%m-%d")
extension = export_format.value
return f"nest-{module_key}-issues-{date_str}.{extension}"
1 change: 1 addition & 0 deletions backend/tests/apps/mentorship/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Test package for mentorship app."""
1 change: 1 addition & 0 deletions backend/tests/apps/mentorship/utils/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Test package for mentorship utils."""
Loading