Revert reports app to standalone single-report generation #3
9 changed files with 364 additions and 280 deletions
8
apps/reports/serializers.py
Normal file
8
apps/reports/serializers.py
Normal 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)
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
|
||||||
69
apps/reports/services/oauth.py
Normal file
69
apps/reports/services/oauth.py
Normal 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()}"}
|
||||||
11
apps/reports/services/orchestrator.py
Normal file
11
apps/reports/services/orchestrator.py
Normal 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 {})
|
||||||
|
|
@ -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
|
|
||||||
100
apps/reports/services/report_types.py
Normal file
100
apps/reports/services/report_types.py
Normal 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,
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue