Revert reports app to standalone single-report generation #3

Merged
Ghasemi merged 1 commit from feature/restore-user-report-endpoint into master 2026-08-19 09:47:01 -04:00
9 changed files with 364 additions and 280 deletions

View file

@ -0,0 +1,8 @@
from rest_framework import serializers
from apps.reports.services.orchestrator import ReportOrchestrator
class ReportGenerateSerializer(serializers.Serializer):
report_type = serializers.ChoiceField(choices=ReportOrchestrator.available_reports())
filters = serializers.DictField(required=False, default=dict)

View file

@ -1,46 +1,41 @@
import logging from urllib.parse import urljoin
from utils.accounts_client import get_user_detailed_info import requests
from utils.advertising_client import get_crm_application_tickets from django.conf import settings
logger = logging.getLogger(__name__) from apps.reports.services.oauth import OAuth2ClientCredentials
class ReportDataSources: class ReportDataSources:
def fetch_application_tickets(self): """
tickets = [] Keep downstream calls for report generation in one place.
offset = 0
limit = 1000
while True: The concrete service endpoints are intentionally placeholders until the
response = get_crm_application_tickets(limit=limit, offset=offset) exact internal service URLs and response shapes are known.
if not response or not response.results: """
break
tickets.extend(response.results) def __init__(self, oauth_client=None):
if response.next_ is None: self.oauth_client = oauth_client or OAuth2ClientCredentials()
break
offset += limit
return tickets def fetch_users(self, filters):
return []
def fetch_users_for_tickets(self, tickets): def fetch_user_metrics(self, user_ids, filters):
users = [] return {}
seen = set()
for ticket in tickets: def fetch_tickets(self, filters):
user_id = str(ticket.user) return []
if user_id in seen:
continue
seen.add(user_id)
try: def fetch_ticket_metrics(self, ticket_ids, filters):
user = get_user_detailed_info(user_id) return {}
except Exception:
logger.exception("Failed to fetch user %s", user_id)
continue
if user is not None: def get_json(self, base_url, path, params=None):
users.append(user) url = urljoin(f"{base_url.rstrip('/')}/", path.lstrip("/"))
response = requests.get(
return users url,
params=params,
headers=self.oauth_client.authorization_header(),
timeout=settings.SERVICE_REQUEST_TIMEOUT,
)
response.raise_for_status()
return response.json()

View file

@ -41,13 +41,3 @@ def build_report_workbook(title, columns, rows):
output = BytesIO() output = BytesIO()
workbook.save(output) workbook.save(output)
return output.getvalue() return output.getvalue()
def build_report_zip(files: dict[str, bytes]) -> bytes:
from zipfile import ZipFile, ZIP_DEFLATED
output = BytesIO()
with ZipFile(output, "w", ZIP_DEFLATED) as zip_file:
for filename, content in files.items():
zip_file.writestr(filename, content)
return output.getvalue()

View file

@ -0,0 +1,69 @@
from dataclasses import dataclass
from time import monotonic
from urllib.parse import urljoin
import requests
from django.conf import settings
class OAuth2ConfigurationError(RuntimeError):
pass
@dataclass
class OAuth2Token:
access_token: str
expires_at: float
token_type: str = "Bearer"
def is_valid(self) -> bool:
return bool(self.access_token) and monotonic() < self.expires_at
class OAuth2ClientCredentials:
def __init__(self):
self._token = None
@property
def token_url(self):
provider_private_url = settings.OAUTH2_PROVIDER_PRIVATE_URL.rstrip("/")
if not provider_private_url:
raise OAuth2ConfigurationError("OAUTH2_PROVIDER_PRIVATE_URL is required.")
return urljoin(f"{provider_private_url}/", "oauth2/token")
def get_access_token(self):
if self._token and self._token.is_valid():
return self._token.access_token
if not settings.OAUTH2_CLIENT_ID or not settings.OAUTH2_CLIENT_SECRET:
raise OAuth2ConfigurationError(
"OAUTH2_CLIENT_ID and OAUTH2_CLIENT_SECRET are required."
)
response = requests.post(
self.token_url,
data={
"grant_type": "client_credentials",
"scope": settings.OAUTH2_SCOPES,
},
auth=(settings.OAUTH2_CLIENT_ID, settings.OAUTH2_CLIENT_SECRET),
timeout=settings.SERVICE_REQUEST_TIMEOUT,
)
response.raise_for_status()
payload = response.json()
access_token = payload.get("access_token")
if not access_token:
raise OAuth2ConfigurationError("OAuth2 response did not include access_token.")
expires_in = int(payload.get("expires_in", 3600))
token_type = payload.get("token_type", "Bearer")
self._token = OAuth2Token(
access_token=access_token,
expires_at=monotonic() + max(expires_in - 60, 1),
token_type=token_type,
)
return self._token.access_token
def authorization_header(self):
return {"Authorization": f"Bearer {self.get_access_token()}"}

View file

@ -0,0 +1,11 @@
from apps.reports.services.report_types import REPORT_TYPES
class ReportOrchestrator:
@classmethod
def available_reports(cls):
return [(report_type, report_type) for report_type in REPORT_TYPES.keys()]
def generate(self, payload):
report = REPORT_TYPES[payload["report_type"]]()
return report.generate(payload.get("filters") or {})

View file

@ -1,78 +0,0 @@
TICKET_STATE_LABELS = {
1: "INIT",
10: "CLOSED",
}
USERS_COLUMNS = [
{"key": "user_uuid", "label": "accounts_user_uuid"},
{"key": "username", "label": "accounts_username"},
{"key": "first_name", "label": "accounts_first_name"},
{"key": "last_name", "label": "accounts_last_name"},
{"key": "email", "label": "accounts_email"},
{"key": "phone_number", "label": "accounts_phone_number"},
{"key": "address", "label": "accounts_address"},
]
TICKETS_COLUMNS = [
{"key": "ticket_uuid", "label": "advertising_ticket_uuid"},
{"key": "user_uuid", "label": "advertising_user_uuid"},
{"key": "title", "label": "advertising_title"},
{"key": "description", "label": "advertising_description"},
{"key": "state", "label": "advertising_state"},
{"key": "created_at", "label": "advertising_created_at"},
{"key": "updated_at", "label": "advertising_updated_at"},
]
def _format_value(value):
if value is None:
return ""
if type(value).__name__ == "Unset":
return ""
if hasattr(value, "value") and not isinstance(value, (str, bytes)):
value = value.value
if value is None:
return ""
return value
def _format_ticket_state(state):
if not state:
return ""
return TICKET_STATE_LABELS.get(state.value, str(state.value))
def build_user_rows(users):
rows = []
for user in users:
if user is None:
continue
rows.append(
{
"user_uuid": str(user.uuid),
"username": _format_value(user.username),
"first_name": _format_value(user.first_name),
"last_name": _format_value(user.last_name),
"email": _format_value(user.email),
"phone_number": _format_value(user.phone_number),
"address": _format_value(user.address),
}
)
return rows
def build_ticket_rows(tickets):
rows = []
for ticket in tickets:
rows.append(
{
"ticket_uuid": str(ticket.uuid),
"user_uuid": str(ticket.user),
"title": _format_value(ticket.title),
"description": _format_value(ticket.description),
"state": _format_ticket_state(ticket.state),
"created_at": str(ticket.created_at) if ticket.created_at else "",
"updated_at": str(ticket.updated_at) if ticket.updated_at else "",
}
)
return rows

View file

@ -0,0 +1,100 @@
from apps.reports.services.data_sources import ReportDataSources
class BaseReport:
report_type = None
title = None
filename = None
columns = []
def __init__(self, data_sources=None):
self.data_sources = data_sources or ReportDataSources()
def generate(self, filters):
return {
"title": self.title,
"filename": self.filename,
"columns": self.columns,
"rows": self.build_rows(filters),
}
def build_rows(self, filters):
raise NotImplementedError
class UserReport(BaseReport):
report_type = "user"
title = "User Report"
filename = "user-report.xlsx"
columns = [
{"key": "user_id", "label": "User ID"},
{"key": "full_name", "label": "Full Name"},
{"key": "email", "label": "Email"},
{"key": "status", "label": "Status"},
{"key": "ticket_count", "label": "Ticket Count"},
]
def build_rows(self, filters):
users = self.data_sources.fetch_users(filters)
user_ids = [user.get("id") for user in users if user.get("id")]
metrics_by_user_id = self.data_sources.fetch_user_metrics(user_ids, filters)
rows = []
for user in users:
user_id = user.get("id")
metrics = metrics_by_user_id.get(user_id, {})
rows.append(
{
"user_id": user_id,
"full_name": user.get("full_name") or user.get("name") or "",
"email": user.get("email", ""),
"status": user.get("status", ""),
"ticket_count": metrics.get("ticket_count", 0),
}
)
return rows
class TicketsReport(BaseReport):
report_type = "tickets"
title = "Tickets Report"
filename = "tickets-report.xlsx"
columns = [
{"key": "ticket_id", "label": "Ticket ID"},
{"key": "title", "label": "Title"},
{"key": "status", "label": "Status"},
{"key": "priority", "label": "Priority"},
{"key": "assignee", "label": "Assignee"},
{"key": "created_at", "label": "Created At"},
]
def build_rows(self, filters):
tickets = self.data_sources.fetch_tickets(filters)
ticket_ids = [ticket.get("id") for ticket in tickets if ticket.get("id")]
metrics_by_ticket_id = self.data_sources.fetch_ticket_metrics(ticket_ids, filters)
rows = []
for ticket in tickets:
ticket_id = ticket.get("id")
metrics = metrics_by_ticket_id.get(ticket_id, {})
rows.append(
{
"ticket_id": ticket_id,
"title": ticket.get("title", ""),
"status": ticket.get("status", ""),
"priority": ticket.get("priority", ""),
"assignee": (
ticket.get("assignee_name")
or ticket.get("assignee", {}).get("name", "")
),
"created_at": ticket.get("created_at", ""),
**metrics,
}
)
return rows
REPORT_TYPES = {
UserReport.report_type: UserReport,
TicketsReport.report_type: TicketsReport,
}

View file

@ -1,138 +1,151 @@
from datetime import datetime
from io import BytesIO from io import BytesIO
from unittest.mock import MagicMock, patch from unittest.mock import patch
from uuid import uuid4
from zipfile import ZipFile
from django.test import override_settings
from openpyxl import load_workbook from openpyxl import load_workbook
from rest_framework import status from rest_framework import status
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from utils.clients.gooyal_advertising_client.models.ticket_state_enum import TicketStateEnum from apps.reports.services.oauth import OAuth2ClientCredentials
class GenerateReportTests(APITestCase): class GenerateReportTests(APITestCase):
def _build_ticket(self, user_uuid=None): @patch("apps.reports.services.data_sources.ReportDataSources.fetch_user_metrics")
ticket = MagicMock() @patch("apps.reports.services.data_sources.ReportDataSources.fetch_users")
ticket.uuid = uuid4() def test_generate_user_report_is_public_and_returns_excel(
ticket.user = user_uuid or uuid4()
ticket.title = "Login issue"
ticket.description = "Cannot login"
ticket.state = TicketStateEnum(10)
ticket.created_at = datetime(2026, 5, 20, 10, 0)
ticket.updated_at = datetime(2026, 5, 21, 10, 0)
return ticket
def _build_user(self, user_uuid):
user = MagicMock()
user.uuid = user_uuid
user.username = "alice"
user.first_name = "Alice"
user.last_name = "Doe"
user.email = "alice@example.com"
user.phone_number = "09120000000"
user.address = "Tehran"
return user
@patch("apps.reports.services.data_sources.ReportDataSources.fetch_users_for_tickets")
@patch("apps.reports.services.data_sources.ReportDataSources.fetch_application_tickets")
def test_generate_report_returns_zip_with_two_excel_files(
self, self,
mock_fetch_tickets,
mock_fetch_users, mock_fetch_users,
mock_fetch_user_metrics,
): ):
ticket = self._build_ticket() mock_fetch_users.return_value = [
user = self._build_user(ticket.user) {
mock_fetch_tickets.return_value = [ticket] "id": 1,
mock_fetch_users.return_value = [user] "full_name": "Alice Doe",
"email": "alice@example.com",
"status": "active",
}
]
mock_fetch_user_metrics.return_value = {
1: {"ticket_count": 3},
}
response = self.client.post("/api/reports/generate/") response = self.client.post(
"/api/reports/generate/",
self.assertEqual(response.status_code, status.HTTP_200_OK) {
self.assertEqual(response["Content-Type"], "application/zip") "report_type": "user",
self.assertEqual( "filters": {"status": "active"},
response["Content-Disposition"], },
'attachment; filename="crm-reports.zip"', format="json",
) )
with ZipFile(BytesIO(response.content)) as archive: self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual( self.assertEqual(
set(archive.namelist()), response["Content-Type"],
{"users-report.xlsx", "tickets-report.xlsx"}, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
) )
self.assertEqual(
response["Content-Disposition"],
'attachment; filename="user-report.xlsx"',
)
users_workbook = load_workbook(BytesIO(archive.read("users-report.xlsx"))) workbook = load_workbook(BytesIO(response.content))
users_sheet = users_workbook.active worksheet = workbook.active
self.assertEqual(users_sheet["A1"].value, "Users Report") self.assertEqual(worksheet["A1"].value, "User Report")
self.assertEqual( self.assertEqual(
[cell.value for cell in users_sheet[2]], [cell.value for cell in worksheet[2]],
[ ["User ID", "Full Name", "Email", "Status", "Ticket Count"],
"accounts_uuid", )
"accounts_username", self.assertEqual(
"accounts_first_name", [cell.value for cell in worksheet[3]],
"accounts_last_name", [1, "Alice Doe", "alice@example.com", "active", 3],
"accounts_email", )
"accounts_phone_number", mock_fetch_users.assert_called_once_with({"status": "active"})
"accounts_address",
],
)
self.assertEqual(
[cell.value for cell in users_sheet[3]],
[
str(user.uuid),
"alice",
"Alice",
"Doe",
"alice@example.com",
"09120000000",
"Tehran",
],
)
tickets_workbook = load_workbook( @patch("apps.reports.services.data_sources.ReportDataSources.fetch_ticket_metrics")
BytesIO(archive.read("tickets-report.xlsx")) @patch("apps.reports.services.data_sources.ReportDataSources.fetch_tickets")
) def test_generate_tickets_report_uses_static_columns(
tickets_sheet = tickets_workbook.active
self.assertEqual(tickets_sheet["A1"].value, "Tickets Report")
self.assertEqual(
[cell.value for cell in tickets_sheet[2]],
[
"advertising_uuid",
"advertising_user",
"advertising_title",
"advertising_description",
"advertising_state",
"advertising_created_at",
"advertising_updated_at",
],
)
self.assertEqual(
[cell.value for cell in tickets_sheet[3]],
[
str(ticket.uuid),
str(ticket.user),
"Login issue",
"Cannot login",
"CLOSED",
"2026-05-20 10:00:00",
"2026-05-21 10:00:00",
],
)
@patch("apps.reports.services.data_sources.ReportDataSources.fetch_users_for_tickets")
@patch("apps.reports.services.data_sources.ReportDataSources.fetch_application_tickets")
def test_generate_report_skips_missing_users(
self, self,
mock_fetch_tickets, mock_fetch_tickets,
mock_fetch_users, mock_fetch_ticket_metrics,
): ):
ticket = self._build_ticket() mock_fetch_tickets.return_value = [
mock_fetch_tickets.return_value = [ticket] {
mock_fetch_users.return_value = [None] "id": 11,
"title": "Login issue",
"status": "open",
"priority": "high",
"assignee": {"name": "Support Agent"},
"created_at": "2026-05-20T10:00:00Z",
}
]
mock_fetch_ticket_metrics.return_value = {}
response = self.client.post("/api/reports/generate/") response = self.client.post(
"/api/reports/generate/",
{"report_type": "tickets"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
with ZipFile(BytesIO(response.content)) as archive: self.assertEqual(
users_workbook = load_workbook(BytesIO(archive.read("users-report.xlsx"))) response["Content-Disposition"],
self.assertIsNone(users_workbook.active["A3"].value) 'attachment; filename="tickets-report.xlsx"',
)
workbook = load_workbook(BytesIO(response.content))
worksheet = workbook.active
self.assertEqual(worksheet["A1"].value, "Tickets Report")
self.assertEqual(
[cell.value for cell in worksheet[2]],
["Ticket ID", "Title", "Status", "Priority", "Assignee", "Created At"],
)
self.assertEqual(
[cell.value for cell in worksheet[3]],
[
11,
"Login issue",
"open",
"high",
"Support Agent",
"2026-05-20T10:00:00Z",
],
)
def test_unknown_report_type_returns_validation_error(self):
response = self.client.post(
"/api/reports/generate/",
{"report_type": "unknown"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
class OAuth2ClientCredentialsTests(APITestCase):
@override_settings(
OAUTH2_PROVIDER_PRIVATE_URL="https://auth.internal",
OAUTH2_CLIENT_ID="client-id",
OAUTH2_CLIENT_SECRET="client-secret",
OAUTH2_SCOPES="reports:read customers:read",
SERVICE_REQUEST_TIMEOUT=10,
)
@patch("apps.reports.services.oauth.requests.post")
def test_get_access_token_uses_client_credentials(self, mock_post):
mock_post.return_value.json.return_value = {
"access_token": "token-value",
"expires_in": 3600,
}
mock_post.return_value.raise_for_status.return_value = None
token = OAuth2ClientCredentials().get_access_token()
self.assertEqual(token, "token-value")
mock_post.assert_called_once_with(
"https://auth.internal/oauth2/token",
data={
"grant_type": "client_credentials",
"scope": "reports:read customers:read",
},
auth=("client-id", "client-secret"),
timeout=10,
)

View file

@ -1,60 +1,36 @@
from django.http import HttpResponse from django.http import HttpResponse
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework import status from rest_framework import status
from rest_framework.views import APIView from rest_framework.views import APIView
from apps.reports.services.data_sources import ReportDataSources from apps.reports.serializers import ReportGenerateSerializer
from apps.reports.services.excel import build_report_workbook, build_report_zip from apps.reports.services.excel import build_report_workbook
from apps.reports.services.report_builder import ( from apps.reports.services.orchestrator import ReportOrchestrator
TICKETS_COLUMNS,
USERS_COLUMNS,
build_ticket_rows,
build_user_rows,
)
class GenerateReportView(APIView): class GenerateReportView(APIView):
authentication_classes = [] authentication_classes = []
permission_classes = [] permission_classes = []
@extend_schema(
tags=["Reports"],
summary="Generate CRM reports",
description=(
"Fetches application tickets and related user profiles, then returns a "
"ZIP archive containing `users-report.xlsx` and `tickets-report.xlsx`."
),
request=None,
responses={
(200, "application/zip"): OpenApiResponse(
response=OpenApiTypes.BINARY,
description="ZIP archive with users and tickets Excel reports.",
),
},
)
def post(self, request): def post(self, request):
data_sources = ReportDataSources() serializer = ReportGenerateSerializer(data=request.data)
tickets = data_sources.fetch_application_tickets() serializer.is_valid(raise_exception=True)
users = data_sources.fetch_users_for_tickets(tickets)
files = { orchestrator = ReportOrchestrator()
"users-report.xlsx": build_report_workbook( report_data = orchestrator.generate(serializer.validated_data)
title="Users Report", workbook = build_report_workbook(
columns=USERS_COLUMNS, title=report_data["title"],
rows=build_user_rows(users), columns=report_data["columns"],
), rows=report_data["rows"],
"tickets-report.xlsx": build_report_workbook( )
title="Tickets Report",
columns=TICKETS_COLUMNS,
rows=build_ticket_rows(tickets),
),
}
response = HttpResponse( response = HttpResponse(
build_report_zip(files), workbook,
content_type="application/zip", content_type=(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
),
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
response["Content-Disposition"] = 'attachment; filename="crm-reports.zip"' response["Content-Disposition"] = (
f'attachment; filename="{report_data["filename"]}"'
)
return response return response