Compare commits
5 commits
feature/re
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 1cbe86ffc6 | |||
| ad1b731b6e | |||
| 5db0af558f | |||
| 125fc90aca | |||
| 7683dfd310 |
7 changed files with 184 additions and 240 deletions
|
|
@ -54,7 +54,7 @@ def merge_upstream_schemas(result, generator, request, public):
|
|||
"""drf-spectacular POSTPROCESSING_HOOKS entry point.
|
||||
|
||||
Folds every proxied service's own OpenAPI document into this service's
|
||||
schema under `/<service>/...` paths (mirroring apps.proxy.views), so the
|
||||
schema under `/api/<service>/...` paths (mirroring apps.proxy.views), so the
|
||||
combined swagger covers every endpoint this proxy forwards to, DTOs
|
||||
included. Schemas are cached per service; a service that's unconfigured
|
||||
or unreachable is skipped rather than failing the whole document.
|
||||
|
|
@ -109,6 +109,6 @@ def merge_upstream_schemas(result, generator, request, public):
|
|||
operation["tags"] = [label]
|
||||
if "operationId" in operation:
|
||||
operation["operationId"] = f"{service}_{operation['operationId']}"
|
||||
result["paths"][f"/{service}{path}"] = proxied_operations
|
||||
result["paths"][f"/api/{service}{path}"] = proxied_operations
|
||||
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -1,75 +1,41 @@
|
|||
import logging
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from utils.accounts_client import get_user_detailed_info, list_users
|
||||
from utils.advertising_client import get_crm_application_tickets
|
||||
import requests
|
||||
from django.conf import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from apps.reports.services.oauth import OAuth2ClientCredentials
|
||||
|
||||
|
||||
class ReportDataSources:
|
||||
def __init__(self):
|
||||
self._tickets_cache = None
|
||||
"""
|
||||
Keep downstream calls for report generation in one place.
|
||||
|
||||
def fetch_tickets(self, filters):
|
||||
if self._tickets_cache is not None:
|
||||
return self._tickets_cache
|
||||
The concrete service endpoints are intentionally placeholders until the
|
||||
exact internal service URLs and response shapes are known.
|
||||
"""
|
||||
|
||||
tickets = []
|
||||
offset = 0
|
||||
limit = 1000
|
||||
|
||||
while True:
|
||||
response = get_crm_application_tickets(limit=limit, offset=offset)
|
||||
if not response or not response.results:
|
||||
break
|
||||
|
||||
tickets.extend(response.results)
|
||||
if response.next_ is None:
|
||||
break
|
||||
offset += limit
|
||||
|
||||
self._tickets_cache = tickets
|
||||
return tickets
|
||||
def __init__(self, oauth_client=None):
|
||||
self.oauth_client = oauth_client or OAuth2ClientCredentials()
|
||||
|
||||
def fetch_users(self, filters):
|
||||
users = []
|
||||
limit = 100
|
||||
offset = 0
|
||||
|
||||
while True:
|
||||
page = list_users(limit=limit, offset=offset)
|
||||
results = page.get("results") or []
|
||||
if not results:
|
||||
break
|
||||
|
||||
for entry in results:
|
||||
user_id = entry.get("uuid")
|
||||
if not user_id:
|
||||
continue
|
||||
|
||||
try:
|
||||
user = get_user_detailed_info(user_id)
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch user %s", user_id)
|
||||
continue
|
||||
|
||||
if user is not None:
|
||||
users.append(user)
|
||||
|
||||
if not page.get("next"):
|
||||
break
|
||||
offset += limit
|
||||
|
||||
return users
|
||||
return []
|
||||
|
||||
def fetch_user_metrics(self, user_ids, filters):
|
||||
tickets = self.fetch_tickets(filters)
|
||||
counts = {}
|
||||
for ticket in tickets:
|
||||
user_id = str(ticket.user)
|
||||
counts[user_id] = counts.get(user_id, 0) + 1
|
||||
return {}
|
||||
|
||||
return {user_id: {"ticket_count": count} for user_id, count in counts.items()}
|
||||
def fetch_tickets(self, filters):
|
||||
return []
|
||||
|
||||
def fetch_ticket_metrics(self, ticket_ids, filters):
|
||||
return {}
|
||||
|
||||
def get_json(self, base_url, path, params=None):
|
||||
url = urljoin(f"{base_url.rstrip('/')}/", path.lstrip("/"))
|
||||
response = requests.get(
|
||||
url,
|
||||
params=params,
|
||||
headers=self.oauth_client.authorization_header(),
|
||||
timeout=settings.SERVICE_REQUEST_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
|
|
|||
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()}"}
|
||||
|
|
@ -1,28 +1,5 @@
|
|||
from apps.reports.services.data_sources import ReportDataSources
|
||||
|
||||
TICKET_STATE_LABELS = {
|
||||
1: "INIT",
|
||||
10: "CLOSED",
|
||||
}
|
||||
|
||||
|
||||
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))
|
||||
|
||||
|
||||
class BaseReport:
|
||||
report_type = None
|
||||
|
|
@ -50,32 +27,28 @@ class UserReport(BaseReport):
|
|||
title = "User Report"
|
||||
filename = "user-report.xlsx"
|
||||
columns = [
|
||||
{"key": "user_uuid", "label": "User UUID"},
|
||||
{"key": "username", "label": "Username"},
|
||||
{"key": "first_name", "label": "First Name"},
|
||||
{"key": "last_name", "label": "Last Name"},
|
||||
{"key": "user_id", "label": "User ID"},
|
||||
{"key": "full_name", "label": "Full Name"},
|
||||
{"key": "email", "label": "Email"},
|
||||
{"key": "phone_number", "label": "Phone Number"},
|
||||
{"key": "status", "label": "Status"},
|
||||
{"key": "ticket_count", "label": "Ticket Count"},
|
||||
]
|
||||
|
||||
def build_rows(self, filters):
|
||||
users = self.data_sources.fetch_users(filters)
|
||||
user_ids = [str(user.uuid) for user in users]
|
||||
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 = str(user.uuid)
|
||||
user_id = user.get("id")
|
||||
metrics = metrics_by_user_id.get(user_id, {})
|
||||
rows.append(
|
||||
{
|
||||
"user_uuid": user_id,
|
||||
"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),
|
||||
"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),
|
||||
}
|
||||
)
|
||||
|
|
@ -87,33 +60,34 @@ class TicketsReport(BaseReport):
|
|||
title = "Tickets Report"
|
||||
filename = "tickets-report.xlsx"
|
||||
columns = [
|
||||
{"key": "ticket_uuid", "label": "Ticket UUID"},
|
||||
{"key": "user_uuid", "label": "User UUID"},
|
||||
{"key": "ticket_id", "label": "Ticket ID"},
|
||||
{"key": "title", "label": "Title"},
|
||||
{"key": "description", "label": "Description"},
|
||||
{"key": "state", "label": "State"},
|
||||
{"key": "status", "label": "Status"},
|
||||
{"key": "priority", "label": "Priority"},
|
||||
{"key": "assignee", "label": "Assignee"},
|
||||
{"key": "created_at", "label": "Created At"},
|
||||
{"key": "updated_at", "label": "Updated At"},
|
||||
]
|
||||
|
||||
def build_rows(self, filters):
|
||||
tickets = self.data_sources.fetch_tickets(filters)
|
||||
ticket_ids = [str(ticket.uuid) for ticket in tickets]
|
||||
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 = str(ticket.uuid)
|
||||
ticket_id = ticket.get("id")
|
||||
metrics = metrics_by_ticket_id.get(ticket_id, {})
|
||||
rows.append(
|
||||
{
|
||||
"ticket_uuid": ticket_id,
|
||||
"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 "",
|
||||
"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,
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,78 +1,15 @@
|
|||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import TestCase
|
||||
from django.test import override_settings
|
||||
from openpyxl import load_workbook
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from apps.reports.services.data_sources import ReportDataSources
|
||||
from utils.clients.gooyal_advertising_client.models.ticket_state_enum import TicketStateEnum
|
||||
|
||||
|
||||
class ReportDataSourcesFetchUsersTests(TestCase):
|
||||
@patch("apps.reports.services.data_sources.get_user_detailed_info")
|
||||
@patch("apps.reports.services.data_sources.list_users")
|
||||
def test_fetch_users_paginates_full_user_list(self, mock_list_users, mock_get_user):
|
||||
first_uuid, second_uuid = str(uuid4()), str(uuid4())
|
||||
mock_list_users.side_effect = [
|
||||
{"results": [{"uuid": first_uuid}], "next": "http://accounts/users/api/users/?limit=1&offset=1"},
|
||||
{"results": [{"uuid": second_uuid}], "next": None},
|
||||
]
|
||||
mock_get_user.side_effect = lambda uuid: MagicMock(uuid=uuid)
|
||||
|
||||
users = ReportDataSources().fetch_users({})
|
||||
|
||||
self.assertEqual([user.uuid for user in users], [first_uuid, second_uuid])
|
||||
mock_list_users.assert_any_call(limit=100, offset=0)
|
||||
mock_list_users.assert_any_call(limit=100, offset=100)
|
||||
mock_get_user.assert_any_call(first_uuid)
|
||||
mock_get_user.assert_any_call(second_uuid)
|
||||
|
||||
@patch("apps.reports.services.data_sources.get_user_detailed_info")
|
||||
@patch("apps.reports.services.data_sources.list_users")
|
||||
def test_fetch_users_skips_user_whose_details_call_fails(self, mock_list_users, mock_get_user):
|
||||
ok_uuid, broken_uuid = str(uuid4()), str(uuid4())
|
||||
mock_list_users.return_value = {
|
||||
"results": [{"uuid": broken_uuid}, {"uuid": ok_uuid}],
|
||||
"next": None,
|
||||
}
|
||||
def get_user(uuid):
|
||||
if uuid == broken_uuid:
|
||||
raise Exception("boom")
|
||||
return MagicMock(uuid=uuid)
|
||||
|
||||
mock_get_user.side_effect = get_user
|
||||
|
||||
users = ReportDataSources().fetch_users({})
|
||||
|
||||
self.assertEqual([user.uuid for user in users], [ok_uuid])
|
||||
from apps.reports.services.oauth import OAuth2ClientCredentials
|
||||
|
||||
|
||||
class GenerateReportTests(APITestCase):
|
||||
def _build_ticket(self, user_uuid=None):
|
||||
ticket = MagicMock()
|
||||
ticket.uuid = uuid4()
|
||||
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"
|
||||
return user
|
||||
|
||||
@patch("apps.reports.services.data_sources.ReportDataSources.fetch_user_metrics")
|
||||
@patch("apps.reports.services.data_sources.ReportDataSources.fetch_users")
|
||||
def test_generate_user_report_is_public_and_returns_excel(
|
||||
|
|
@ -80,9 +17,17 @@ class GenerateReportTests(APITestCase):
|
|||
mock_fetch_users,
|
||||
mock_fetch_user_metrics,
|
||||
):
|
||||
user = self._build_user(uuid4())
|
||||
mock_fetch_users.return_value = [user]
|
||||
mock_fetch_user_metrics.return_value = {str(user.uuid): {"ticket_count": 3}}
|
||||
mock_fetch_users.return_value = [
|
||||
{
|
||||
"id": 1,
|
||||
"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/",
|
||||
|
|
@ -108,27 +53,11 @@ class GenerateReportTests(APITestCase):
|
|||
self.assertEqual(worksheet["A1"].value, "User Report")
|
||||
self.assertEqual(
|
||||
[cell.value for cell in worksheet[2]],
|
||||
[
|
||||
"User UUID",
|
||||
"Username",
|
||||
"First Name",
|
||||
"Last Name",
|
||||
"Email",
|
||||
"Phone Number",
|
||||
"Ticket Count",
|
||||
],
|
||||
["User ID", "Full Name", "Email", "Status", "Ticket Count"],
|
||||
)
|
||||
self.assertEqual(
|
||||
[cell.value for cell in worksheet[3]],
|
||||
[
|
||||
str(user.uuid),
|
||||
"alice",
|
||||
"Alice",
|
||||
"Doe",
|
||||
"alice@example.com",
|
||||
"09120000000",
|
||||
3,
|
||||
],
|
||||
[1, "Alice Doe", "alice@example.com", "active", 3],
|
||||
)
|
||||
mock_fetch_users.assert_called_once_with({"status": "active"})
|
||||
|
||||
|
|
@ -139,8 +68,16 @@ class GenerateReportTests(APITestCase):
|
|||
mock_fetch_tickets,
|
||||
mock_fetch_ticket_metrics,
|
||||
):
|
||||
ticket = self._build_ticket()
|
||||
mock_fetch_tickets.return_value = [ticket]
|
||||
mock_fetch_tickets.return_value = [
|
||||
{
|
||||
"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(
|
||||
|
|
@ -160,26 +97,17 @@ class GenerateReportTests(APITestCase):
|
|||
self.assertEqual(worksheet["A1"].value, "Tickets Report")
|
||||
self.assertEqual(
|
||||
[cell.value for cell in worksheet[2]],
|
||||
[
|
||||
"Ticket UUID",
|
||||
"User UUID",
|
||||
"Title",
|
||||
"Description",
|
||||
"State",
|
||||
"Created At",
|
||||
"Updated At",
|
||||
],
|
||||
["Ticket ID", "Title", "Status", "Priority", "Assignee", "Created At"],
|
||||
)
|
||||
self.assertEqual(
|
||||
[cell.value for cell in worksheet[3]],
|
||||
[
|
||||
str(ticket.uuid),
|
||||
str(ticket.user),
|
||||
11,
|
||||
"Login issue",
|
||||
"Cannot login",
|
||||
"CLOSED",
|
||||
"2026-05-20 10:00:00",
|
||||
"2026-05-21 10:00:00",
|
||||
"open",
|
||||
"high",
|
||||
"Support Agent",
|
||||
"2026-05-20T10:00:00Z",
|
||||
],
|
||||
)
|
||||
|
||||
|
|
@ -191,3 +119,33 @@ class GenerateReportTests(APITestCase):
|
|||
)
|
||||
|
||||
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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -16,5 +16,5 @@ urlpatterns = [
|
|||
path("api/reports/", include("apps.reports.urls")),
|
||||
# Catch-all reverse proxy: thisprojectbaseurl/<service>/<rest of url>.
|
||||
# Must stay last so it never shadows the routes above.
|
||||
path("", include("apps.proxy.urls")),
|
||||
path("api/", include("apps.proxy.urls")),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -74,26 +74,3 @@ def get_user_detailed_info(uuid):
|
|||
return response
|
||||
|
||||
|
||||
def list_users(limit=100, offset=0):
|
||||
"""Fetch one page of the accounts user list.
|
||||
|
||||
The generated OpenAPI client (users_api_users_list) only supports
|
||||
`page`, and that param is silently ignored by the upstream endpoint --
|
||||
every page returns the same first results. Its real pagination is
|
||||
limit/offset, so this bypasses the generated wrapper with a raw request.
|
||||
"""
|
||||
access_token = login_as_client_credentials()['access_token']
|
||||
base_url = settings.ACCOUNTS_BASE_PUBLIC_URL
|
||||
url = f"{base_url.rstrip('/')}/users/api/users/"
|
||||
|
||||
response = request_with_ssl_fallback(
|
||||
'GET',
|
||||
url,
|
||||
params={"limit": limit, "offset": offset},
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
verify=resolve_verify_ssl(base_url),
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue