Fetch all accounts users for the user report, not just ticket owners

fetch_users previously only surfaced users who appeared as ticket
owners. Add list_users(limit, offset) to hit the accounts user-list
endpoint directly with raw limit/offset params -- the generated OpenAPI
wrapper only supports page, which this endpoint silently ignores.
fetch_users now paginates that list and hydrates each entry with
get_user_detailed_info to keep the existing email/phone/address columns.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Ali Asadi 2026-08-19 17:29:21 +03:30
parent 4946ec8c8f
commit 6aa925992f
3 changed files with 88 additions and 15 deletions

View file

@ -1,6 +1,6 @@
import logging
from utils.accounts_client import get_user_detailed_info
from utils.accounts_client import get_user_detailed_info, list_users
from utils.advertising_client import get_crm_application_tickets
logger = logging.getLogger(__name__)
@ -32,24 +32,33 @@ class ReportDataSources:
return tickets
def fetch_users(self, filters):
tickets = self.fetch_tickets(filters)
users = []
seen = set()
limit = 100
offset = 0
for ticket in tickets:
user_id = str(ticket.user)
if user_id in seen:
continue
seen.add(user_id)
while True:
page = list_users(limit=limit, offset=offset)
results = page.get("results") or []
if not results:
break
try:
user = get_user_detailed_info(user_id)
except Exception:
logger.exception("Failed to fetch user %s", user_id)
continue
for entry in results:
user_id = entry.get("uuid")
if not user_id:
continue
if user is not None:
users.append(user)
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

View file

@ -3,13 +3,54 @@ from io import BytesIO
from unittest.mock import MagicMock, patch
from uuid import uuid4
from django.test import TestCase
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])
class GenerateReportTests(APITestCase):
def _build_ticket(self, user_uuid=None):
ticket = MagicMock()

View file

@ -74,3 +74,26 @@ 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()