init project, add excel generation and mock api calls
This commit is contained in:
commit
980e0e41ca
31 changed files with 870 additions and 0 deletions
18
.env.example
Normal file
18
.env.example
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
SECRET_KEY=change-me
|
||||||
|
DEBUG=True
|
||||||
|
DJANGO_SETTINGS_MODULE=config.settings.local
|
||||||
|
ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0
|
||||||
|
CSRF_TRUSTED_ORIGINS=
|
||||||
|
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
||||||
|
CORS_ALLOW_CREDENTIALS=True
|
||||||
|
TIME_ZONE=UTC
|
||||||
|
DATABASE_URL=sqlite:///db.sqlite3
|
||||||
|
DRF_PAGE_SIZE=20
|
||||||
|
DRF_ANON_THROTTLE_RATE=100/hour
|
||||||
|
DRF_USER_THROTTLE_RATE=1000/hour
|
||||||
|
SERVICE_REQUEST_TIMEOUT=30
|
||||||
|
OAUTH2_PROVIDER_PUBLIC_URL=
|
||||||
|
OAUTH2_PROVIDER_PRIVATE_URL=
|
||||||
|
OAUTH2_CLIENT_ID=
|
||||||
|
OAUTH2_CLIENT_SECRET=
|
||||||
|
OAUTH2_SCOPES=
|
||||||
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.sqlite3
|
||||||
|
db.sqlite3
|
||||||
|
media/
|
||||||
|
staticfiles/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
70
README.md
Normal file
70
README.md
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
# CRM Backend
|
||||||
|
|
||||||
|
Django + Django REST Framework backend scaffold.
|
||||||
|
|
||||||
|
## Local setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
cp .env.example .env
|
||||||
|
python manage.py migrate
|
||||||
|
python manage.py runserver
|
||||||
|
```
|
||||||
|
|
||||||
|
Health check:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8000/api/health/
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate an Excel report:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:8000/api/reports/generate/ \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-o user-report.xlsx \
|
||||||
|
-d '{
|
||||||
|
"report_type": "user",
|
||||||
|
"filters": {
|
||||||
|
"status": "active"
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported report types:
|
||||||
|
|
||||||
|
- `user`
|
||||||
|
- `tickets`
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
The project defaults to `config.settings.local` for development. Environment variables are read from `.env` when it exists.
|
||||||
|
|
||||||
|
Use `config.settings.production` in production:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export DJANGO_SETTINGS_MODULE=config.settings.production
|
||||||
|
```
|
||||||
|
|
||||||
|
Important variables:
|
||||||
|
|
||||||
|
- `SECRET_KEY`: Django secret key. Required in production.
|
||||||
|
- `DEBUG`: `True` for local development, `False` otherwise.
|
||||||
|
- `ALLOWED_HOSTS`: comma-separated host names.
|
||||||
|
- `DATABASE_URL`: database connection URL. Defaults to local SQLite.
|
||||||
|
- `CORS_ALLOWED_ORIGINS`: comma-separated frontend origins.
|
||||||
|
- `CSRF_TRUSTED_ORIGINS`: comma-separated trusted origins for unsafe requests.
|
||||||
|
- `SERVICE_REQUEST_TIMEOUT`: timeout in seconds for downstream service calls.
|
||||||
|
- `OAUTH2_PROVIDER_PUBLIC_URL`: public OAuth2 issuer/provider URL.
|
||||||
|
- `OAUTH2_PROVIDER_PRIVATE_URL`: private OAuth2 provider URL used by the backend.
|
||||||
|
- `OAUTH2_CLIENT_ID`: OAuth2 client credentials ID.
|
||||||
|
- `OAUTH2_CLIENT_SECRET`: OAuth2 client credentials secret.
|
||||||
|
- `OAUTH2_SCOPES`: space-separated OAuth2 scopes.
|
||||||
|
|
||||||
|
The service client currently builds the client-credentials token endpoint as:
|
||||||
|
|
||||||
|
```text
|
||||||
|
{OAUTH2_PROVIDER_PRIVATE_URL}/oauth2/token
|
||||||
|
```
|
||||||
1
apps/__init__.py
Normal file
1
apps/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
1
apps/core/__init__.py
Normal file
1
apps/core/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
6
apps/core/apps.py
Normal file
6
apps/core/apps.py
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class CoreConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.core"
|
||||||
10
apps/core/tests.py
Normal file
10
apps/core/tests.py
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
|
|
||||||
|
class HealthCheckTests(APITestCase):
|
||||||
|
def test_health_check_returns_ok(self):
|
||||||
|
response = self.client.get("/api/health/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(response.json(), {"status": "ok"})
|
||||||
10
apps/core/urls.py
Normal file
10
apps/core/urls.py
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from apps.core.views import HealthCheckView
|
||||||
|
|
||||||
|
|
||||||
|
app_name = "core"
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("health/", HealthCheckView.as_view(), name="health"),
|
||||||
|
]
|
||||||
11
apps/core/views.py
Normal file
11
apps/core/views.py
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
|
||||||
|
class HealthCheckView(APIView):
|
||||||
|
authentication_classes = []
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
return Response({"status": "ok"})
|
||||||
1
apps/reports/__init__.py
Normal file
1
apps/reports/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
6
apps/reports/apps.py
Normal file
6
apps/reports/apps.py
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ReportsConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.reports"
|
||||||
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
apps/reports/services/__init__.py
Normal file
1
apps/reports/services/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
41
apps/reports/services/data_sources.py
Normal file
41
apps/reports/services/data_sources.py
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from apps.reports.services.oauth import OAuth2ClientCredentials
|
||||||
|
|
||||||
|
|
||||||
|
class ReportDataSources:
|
||||||
|
"""
|
||||||
|
Keep downstream calls for report generation in one place.
|
||||||
|
|
||||||
|
The concrete service endpoints are intentionally placeholders until the
|
||||||
|
exact internal service URLs and response shapes are known.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, oauth_client=None):
|
||||||
|
self.oauth_client = oauth_client or OAuth2ClientCredentials()
|
||||||
|
|
||||||
|
def fetch_users(self, filters):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def fetch_user_metrics(self, user_ids, filters):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
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()
|
||||||
43
apps/reports/services/excel.py
Normal file
43
apps/reports/services/excel.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from openpyxl import Workbook
|
||||||
|
from openpyxl.utils import get_column_letter
|
||||||
|
from openpyxl.styles import Font, PatternFill
|
||||||
|
|
||||||
|
|
||||||
|
def build_report_workbook(title, columns, rows):
|
||||||
|
workbook = Workbook()
|
||||||
|
worksheet = workbook.active
|
||||||
|
worksheet.title = "Report"
|
||||||
|
|
||||||
|
headers = [column["label"] for column in columns]
|
||||||
|
worksheet.append([title])
|
||||||
|
worksheet.merge_cells(
|
||||||
|
start_row=1,
|
||||||
|
start_column=1,
|
||||||
|
end_row=1,
|
||||||
|
end_column=max(len(columns), 1),
|
||||||
|
)
|
||||||
|
worksheet["A1"].font = Font(bold=True, size=14)
|
||||||
|
|
||||||
|
if columns:
|
||||||
|
worksheet.append(headers)
|
||||||
|
for cell in worksheet[2]:
|
||||||
|
cell.font = Font(bold=True)
|
||||||
|
cell.fill = PatternFill("solid", fgColor="D9EAF7")
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
worksheet.append([row.get(column["key"], "") for column in columns])
|
||||||
|
else:
|
||||||
|
worksheet.append(["No data"])
|
||||||
|
|
||||||
|
for column_index, column_cells in enumerate(worksheet.columns, start=1):
|
||||||
|
max_length = max(len(str(cell.value or "")) for cell in column_cells)
|
||||||
|
worksheet.column_dimensions[get_column_letter(column_index)].width = min(
|
||||||
|
max(max_length + 2, 12),
|
||||||
|
60,
|
||||||
|
)
|
||||||
|
|
||||||
|
output = BytesIO()
|
||||||
|
workbook.save(output)
|
||||||
|
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 {})
|
||||||
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,
|
||||||
|
}
|
||||||
151
apps/reports/tests.py
Normal file
151
apps/reports/tests.py
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
from io import BytesIO
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
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.oauth import OAuth2ClientCredentials
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateReportTests(APITestCase):
|
||||||
|
@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(
|
||||||
|
self,
|
||||||
|
mock_fetch_users,
|
||||||
|
mock_fetch_user_metrics,
|
||||||
|
):
|
||||||
|
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/",
|
||||||
|
{
|
||||||
|
"report_type": "user",
|
||||||
|
"filters": {"status": "active"},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(
|
||||||
|
response["Content-Type"],
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
response["Content-Disposition"],
|
||||||
|
'attachment; filename="user-report.xlsx"',
|
||||||
|
)
|
||||||
|
|
||||||
|
workbook = load_workbook(BytesIO(response.content))
|
||||||
|
worksheet = workbook.active
|
||||||
|
self.assertEqual(worksheet["A1"].value, "User Report")
|
||||||
|
self.assertEqual(
|
||||||
|
[cell.value for cell in worksheet[2]],
|
||||||
|
["User ID", "Full Name", "Email", "Status", "Ticket Count"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[cell.value for cell in worksheet[3]],
|
||||||
|
[1, "Alice Doe", "alice@example.com", "active", 3],
|
||||||
|
)
|
||||||
|
mock_fetch_users.assert_called_once_with({"status": "active"})
|
||||||
|
|
||||||
|
@patch("apps.reports.services.data_sources.ReportDataSources.fetch_ticket_metrics")
|
||||||
|
@patch("apps.reports.services.data_sources.ReportDataSources.fetch_tickets")
|
||||||
|
def test_generate_tickets_report_uses_static_columns(
|
||||||
|
self,
|
||||||
|
mock_fetch_tickets,
|
||||||
|
mock_fetch_ticket_metrics,
|
||||||
|
):
|
||||||
|
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(
|
||||||
|
"/api/reports/generate/",
|
||||||
|
{"report_type": "tickets"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(
|
||||||
|
response["Content-Disposition"],
|
||||||
|
'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,
|
||||||
|
)
|
||||||
10
apps/reports/urls.py
Normal file
10
apps/reports/urls.py
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from apps.reports.views import GenerateReportView
|
||||||
|
|
||||||
|
|
||||||
|
app_name = "reports"
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("generate/", GenerateReportView.as_view(), name="generate"),
|
||||||
|
]
|
||||||
36
apps/reports/views.py
Normal file
36
apps/reports/views.py
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
from apps.reports.serializers import ReportGenerateSerializer
|
||||||
|
from apps.reports.services.excel import build_report_workbook
|
||||||
|
from apps.reports.services.orchestrator import ReportOrchestrator
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateReportView(APIView):
|
||||||
|
authentication_classes = []
|
||||||
|
permission_classes = []
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
serializer = ReportGenerateSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
orchestrator = ReportOrchestrator()
|
||||||
|
report_data = orchestrator.generate(serializer.validated_data)
|
||||||
|
workbook = build_report_workbook(
|
||||||
|
title=report_data["title"],
|
||||||
|
columns=report_data["columns"],
|
||||||
|
rows=report_data["rows"],
|
||||||
|
)
|
||||||
|
|
||||||
|
response = HttpResponse(
|
||||||
|
workbook,
|
||||||
|
content_type=(
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
),
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
response["Content-Disposition"] = (
|
||||||
|
f'attachment; filename="{report_data["filename"]}"'
|
||||||
|
)
|
||||||
|
return response
|
||||||
1
config/__init__.py
Normal file
1
config/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
9
config/asgi.py
Normal file
9
config/asgi.py
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
"""ASGI config for the project."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.asgi import get_asgi_application
|
||||||
|
|
||||||
|
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local")
|
||||||
|
|
||||||
|
application = get_asgi_application()
|
||||||
1
config/settings/__init__.py
Normal file
1
config/settings/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
157
config/settings/base.py
Normal file
157
config/settings/base.py
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import environ
|
||||||
|
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||||
|
APPS_DIR = BASE_DIR / "apps"
|
||||||
|
|
||||||
|
env = environ.Env(
|
||||||
|
DEBUG=(bool, False),
|
||||||
|
ALLOWED_HOSTS=(list, []),
|
||||||
|
CSRF_TRUSTED_ORIGINS=(list, []),
|
||||||
|
CORS_ALLOWED_ORIGINS=(list, []),
|
||||||
|
)
|
||||||
|
|
||||||
|
ENV_FILE = BASE_DIR / ".env"
|
||||||
|
if ENV_FILE.exists():
|
||||||
|
environ.Env.read_env(ENV_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
SECRET_KEY = env("SECRET_KEY", default="unsafe-local-development-secret-key")
|
||||||
|
DEBUG = env("DEBUG")
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = env("ALLOWED_HOSTS")
|
||||||
|
CSRF_TRUSTED_ORIGINS = env("CSRF_TRUSTED_ORIGINS")
|
||||||
|
|
||||||
|
|
||||||
|
DJANGO_APPS = [
|
||||||
|
"django.contrib.admin",
|
||||||
|
"django.contrib.auth",
|
||||||
|
"django.contrib.contenttypes",
|
||||||
|
"django.contrib.sessions",
|
||||||
|
"django.contrib.messages",
|
||||||
|
"django.contrib.staticfiles",
|
||||||
|
]
|
||||||
|
|
||||||
|
THIRD_PARTY_APPS = [
|
||||||
|
"corsheaders",
|
||||||
|
"rest_framework",
|
||||||
|
]
|
||||||
|
|
||||||
|
LOCAL_APPS = [
|
||||||
|
"apps.core",
|
||||||
|
"apps.reports",
|
||||||
|
]
|
||||||
|
|
||||||
|
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
||||||
|
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
"django.middleware.security.SecurityMiddleware",
|
||||||
|
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||||
|
"corsheaders.middleware.CorsMiddleware",
|
||||||
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
|
"django.middleware.common.CommonMiddleware",
|
||||||
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = "config.urls"
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
|
"DIRS": [BASE_DIR / "templates"],
|
||||||
|
"APP_DIRS": True,
|
||||||
|
"OPTIONS": {
|
||||||
|
"context_processors": [
|
||||||
|
"django.template.context_processors.debug",
|
||||||
|
"django.template.context_processors.request",
|
||||||
|
"django.contrib.auth.context_processors.auth",
|
||||||
|
"django.contrib.messages.context_processors.messages",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
WSGI_APPLICATION = "config.wsgi.application"
|
||||||
|
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
"default": env.db("DATABASE_URL", default=f"sqlite:///{BASE_DIR / 'db.sqlite3'}"),
|
||||||
|
}
|
||||||
|
DATABASES["default"]["ATOMIC_REQUESTS"] = True
|
||||||
|
|
||||||
|
|
||||||
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
{
|
||||||
|
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
LANGUAGE_CODE = "en-us"
|
||||||
|
TIME_ZONE = env("TIME_ZONE", default="UTC")
|
||||||
|
USE_I18N = True
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
|
||||||
|
STATIC_URL = "static/"
|
||||||
|
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||||
|
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
||||||
|
|
||||||
|
MEDIA_URL = "media/"
|
||||||
|
MEDIA_ROOT = BASE_DIR / "media"
|
||||||
|
|
||||||
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
|
|
||||||
|
|
||||||
|
CORS_ALLOWED_ORIGINS = env("CORS_ALLOWED_ORIGINS")
|
||||||
|
CORS_ALLOW_CREDENTIALS = env.bool("CORS_ALLOW_CREDENTIALS", default=True)
|
||||||
|
|
||||||
|
|
||||||
|
REST_FRAMEWORK = {
|
||||||
|
"DEFAULT_RENDERER_CLASSES": [
|
||||||
|
"rest_framework.renderers.JSONRenderer",
|
||||||
|
],
|
||||||
|
"DEFAULT_PARSER_CLASSES": [
|
||||||
|
"rest_framework.parsers.JSONParser",
|
||||||
|
"rest_framework.parsers.FormParser",
|
||||||
|
"rest_framework.parsers.MultiPartParser",
|
||||||
|
],
|
||||||
|
"DEFAULT_AUTHENTICATION_CLASSES": [],
|
||||||
|
"DEFAULT_PERMISSION_CLASSES": [
|
||||||
|
"rest_framework.permissions.AllowAny",
|
||||||
|
],
|
||||||
|
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
|
||||||
|
"PAGE_SIZE": env.int("DRF_PAGE_SIZE", default=20),
|
||||||
|
"DEFAULT_THROTTLE_CLASSES": [
|
||||||
|
"rest_framework.throttling.AnonRateThrottle",
|
||||||
|
"rest_framework.throttling.UserRateThrottle",
|
||||||
|
],
|
||||||
|
"DEFAULT_THROTTLE_RATES": {
|
||||||
|
"anon": env("DRF_ANON_THROTTLE_RATE", default="100/hour"),
|
||||||
|
"user": env("DRF_USER_THROTTLE_RATE", default="1000/hour"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
SERVICE_REQUEST_TIMEOUT = env.int("SERVICE_REQUEST_TIMEOUT", default=30)
|
||||||
|
|
||||||
|
OAUTH2_PROVIDER_PUBLIC_URL = env("OAUTH2_PROVIDER_PUBLIC_URL", default="")
|
||||||
|
OAUTH2_PROVIDER_PRIVATE_URL = env("OAUTH2_PROVIDER_PRIVATE_URL", default="")
|
||||||
|
OAUTH2_CLIENT_ID = env("OAUTH2_CLIENT_ID", default="")
|
||||||
|
OAUTH2_CLIENT_SECRET = env("OAUTH2_CLIENT_SECRET", default="")
|
||||||
|
OAUTH2_SCOPES = env("OAUTH2_SCOPES", default="")
|
||||||
13
config/settings/local.py
Normal file
13
config/settings/local.py
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
from .base import * # noqa: F403
|
||||||
|
|
||||||
|
|
||||||
|
DEBUG = env.bool("DEBUG", default=True) # noqa: F405
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = env.list( # noqa: F405
|
||||||
|
"ALLOWED_HOSTS",
|
||||||
|
default=["localhost", "127.0.0.1", "0.0.0.0"],
|
||||||
|
)
|
||||||
|
|
||||||
|
REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"].append( # noqa: F405
|
||||||
|
"rest_framework.renderers.BrowsableAPIRenderer"
|
||||||
|
)
|
||||||
21
config/settings/production.py
Normal file
21
config/settings/production.py
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
from .base import * # noqa: F403
|
||||||
|
|
||||||
|
|
||||||
|
DEBUG = False
|
||||||
|
|
||||||
|
SECRET_KEY = env("SECRET_KEY") # noqa: F405
|
||||||
|
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS") # noqa: F405
|
||||||
|
CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS", default=[]) # noqa: F405
|
||||||
|
|
||||||
|
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||||
|
SECURE_SSL_REDIRECT = env.bool("SECURE_SSL_REDIRECT", default=True) # noqa: F405
|
||||||
|
SESSION_COOKIE_SECURE = True
|
||||||
|
CSRF_COOKIE_SECURE = True
|
||||||
|
SECURE_HSTS_SECONDS = env.int("SECURE_HSTS_SECONDS", default=60 * 60 * 24 * 30) # noqa: F405
|
||||||
|
SECURE_HSTS_INCLUDE_SUBDOMAINS = env.bool( # noqa: F405
|
||||||
|
"SECURE_HSTS_INCLUDE_SUBDOMAINS",
|
||||||
|
default=True,
|
||||||
|
)
|
||||||
|
SECURE_HSTS_PRELOAD = env.bool("SECURE_HSTS_PRELOAD", default=True) # noqa: F405
|
||||||
|
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||||
|
X_FRAME_OPTIONS = "DENY"
|
||||||
9
config/urls.py
Normal file
9
config/urls.py
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.urls import include, path
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("admin/", admin.site.urls),
|
||||||
|
path("api/", include("apps.core.urls")),
|
||||||
|
path("api/reports/", include("apps.reports.urls")),
|
||||||
|
]
|
||||||
9
config/wsgi.py
Normal file
9
config/wsgi.py
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
"""WSGI config for the project."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
|
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local")
|
||||||
|
|
||||||
|
application = get_wsgi_application()
|
||||||
22
manage.py
Normal file
22
manage.py
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
"""Django's command-line utility for administrative tasks."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Run administrative tasks."""
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local")
|
||||||
|
try:
|
||||||
|
from django.core.management import execute_from_command_line
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ImportError(
|
||||||
|
"Couldn't import Django. Is it installed and available on your "
|
||||||
|
"PYTHONPATH environment variable? Did you forget to activate a "
|
||||||
|
"virtual environment?"
|
||||||
|
) from exc
|
||||||
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
9
requirements.txt
Normal file
9
requirements.txt
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
Django>=4.2,<5.0
|
||||||
|
djangorestframework>=3.15,<4.0
|
||||||
|
django-environ>=0.11,<1.0
|
||||||
|
django-cors-headers>=4.3,<5.0
|
||||||
|
whitenoise>=6.6,<7.0
|
||||||
|
psycopg[binary]>=3.1,<4.0
|
||||||
|
requests>=2.32,<3.0
|
||||||
|
urllib3>=1.26,<2.0
|
||||||
|
openpyxl>=3.1,<4.0
|
||||||
Loading…
Add table
Reference in a new issue