generate a zip file containing two reports of users and tickets

This commit is contained in:
Ali Asadi 2026-06-07 12:38:04 +03:30
parent ebffc9db61
commit b293509e20
14 changed files with 312 additions and 361 deletions

View file

@ -25,3 +25,4 @@ ACCOUNTS_BASE_PUBLIC_URL=https://accounts-staging.gooyal.ir
WALLET_BASE_PUBLIC_URL=https://wallet-staging.gooyal.ir WALLET_BASE_PUBLIC_URL=https://wallet-staging.gooyal.ir
WALLET_RIAL=af7d967f-30c0-409b-9066-2549f2da5e5e WALLET_RIAL=af7d967f-30c0-409b-9066-2549f2da5e5e
WALLET_REWARD=e7c9d4d1-4d1f-43b2-96f7-4d4a168f480d WALLET_REWARD=e7c9d4d1-4d1f-43b2-96f7-4d4a168f480d
ADVERTISING_BASE_PUBLIC_URL=https://advertising-staging.gooyal.ir

View file

@ -1,3 +1,4 @@
from drf_spectacular.utils import extend_schema
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
@ -7,5 +8,11 @@ class HealthCheckView(APIView):
authentication_classes = [] authentication_classes = []
permission_classes = [AllowAny] permission_classes = [AllowAny]
@extend_schema(
tags=["Health"],
summary="Health check",
description="Returns service availability status.",
responses={200: {"type": "object", "properties": {"status": {"type": "string"}}}},
)
def get(self, request): def get(self, request):
return Response({"status": "ok"}) return Response({"status": "ok"})

View file

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

View file

@ -41,3 +41,13 @@ 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

@ -1,69 +0,0 @@
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}/", "apis/applications/")
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

@ -1,11 +0,0 @@
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

@ -0,0 +1,78 @@
TICKET_STATE_LABELS = {
1: "INIT",
10: "CLOSED",
}
USERS_COLUMNS = [
{"key": "user_uuid", "label": "User UUID"},
{"key": "username", "label": "Username"},
{"key": "first_name", "label": "First Name"},
{"key": "last_name", "label": "Last Name"},
{"key": "email", "label": "Email"},
{"key": "phone_number", "label": "Phone Number"},
{"key": "address", "label": "Address"},
]
TICKETS_COLUMNS = [
{"key": "ticket_uuid", "label": "Ticket UUID"},
{"key": "user_uuid", "label": "User UUID"},
{"key": "title", "label": "Title"},
{"key": "description", "label": "Description"},
{"key": "state", "label": "State"},
{"key": "created_at", "label": "Created At"},
{"key": "updated_at", "label": "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

@ -1,100 +0,0 @@
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,151 +1,138 @@
from datetime import datetime
from io import BytesIO from io import BytesIO
from unittest.mock import patch from unittest.mock import MagicMock, 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 apps.reports.services.oauth import OAuth2ClientCredentials from utils.clients.gooyal_advertising_client.models.ticket_state_enum import TicketStateEnum
class GenerateReportTests(APITestCase): class GenerateReportTests(APITestCase):
@patch("apps.reports.services.data_sources.ReportDataSources.fetch_user_metrics") def _build_ticket(self, user_uuid=None):
@patch("apps.reports.services.data_sources.ReportDataSources.fetch_users") ticket = MagicMock()
def test_generate_user_report_is_public_and_returns_excel( ticket.uuid = uuid4()
self, ticket.user = user_uuid or uuid4()
mock_fetch_users, ticket.title = "Login issue"
mock_fetch_user_metrics, ticket.description = "Cannot login"
): ticket.state = TicketStateEnum(10)
mock_fetch_users.return_value = [ ticket.created_at = datetime(2026, 5, 20, 10, 0)
{ ticket.updated_at = datetime(2026, 5, 21, 10, 0)
"id": 1, return ticket
"full_name": "Alice Doe",
"email": "alice@example.com",
"status": "active",
}
]
mock_fetch_user_metrics.return_value = {
1: {"ticket_count": 3},
}
response = self.client.post( def _build_user(self, user_uuid):
"/api/reports/generate/", user = MagicMock()
{ user.uuid = user_uuid
"report_type": "user", user.username = "alice"
"filters": {"status": "active"}, user.first_name = "Alice"
}, user.last_name = "Doe"
format="json", user.email = "alice@example.com"
) user.phone_number = "09120000000"
user.address = "Tehran"
return user
self.assertEqual(response.status_code, status.HTTP_200_OK) @patch("apps.reports.services.data_sources.ReportDataSources.fetch_users_for_tickets")
self.assertEqual( @patch("apps.reports.services.data_sources.ReportDataSources.fetch_application_tickets")
response["Content-Type"], def test_generate_report_returns_zip_with_two_excel_files(
"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, self,
mock_fetch_tickets, mock_fetch_tickets,
mock_fetch_ticket_metrics, mock_fetch_users,
): ):
mock_fetch_tickets.return_value = [ ticket = self._build_ticket()
{ user = self._build_user(ticket.user)
"id": 11, mock_fetch_tickets.return_value = [ticket]
"title": "Login issue", mock_fetch_users.return_value = [user]
"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( response = self.client.post("/api/reports/generate/")
"/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)
self.assertEqual(response["Content-Type"], "application/zip")
self.assertEqual( self.assertEqual(
response["Content-Disposition"], response["Content-Disposition"],
'attachment; filename="tickets-report.xlsx"', 'attachment; filename="crm-reports.zip"',
) )
workbook = load_workbook(BytesIO(response.content)) with ZipFile(BytesIO(response.content)) as archive:
worksheet = workbook.active self.assertEqual(
self.assertEqual(worksheet["A1"].value, "Tickets Report") set(archive.namelist()),
self.assertEqual( {"users-report.xlsx", "tickets-report.xlsx"},
[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): users_workbook = load_workbook(BytesIO(archive.read("users-report.xlsx")))
response = self.client.post( users_sheet = users_workbook.active
"/api/reports/generate/", self.assertEqual(users_sheet["A1"].value, "Users Report")
{"report_type": "unknown"}, self.assertEqual(
format="json", [cell.value for cell in users_sheet[2]],
) [
"User UUID",
"Username",
"First Name",
"Last Name",
"Email",
"Phone Number",
"Address",
],
)
self.assertEqual(
[cell.value for cell in users_sheet[3]],
[
str(user.uuid),
"alice",
"Alice",
"Doe",
"alice@example.com",
"09120000000",
"Tehran",
],
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) tickets_workbook = load_workbook(
BytesIO(archive.read("tickets-report.xlsx"))
)
tickets_sheet = tickets_workbook.active
self.assertEqual(tickets_sheet["A1"].value, "Tickets Report")
self.assertEqual(
[cell.value for cell in tickets_sheet[2]],
[
"Ticket UUID",
"User UUID",
"Title",
"Description",
"State",
"Created At",
"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,
mock_fetch_tickets,
mock_fetch_users,
):
ticket = self._build_ticket()
mock_fetch_tickets.return_value = [ticket]
mock_fetch_users.return_value = [None]
class OAuth2ClientCredentialsTests(APITestCase): response = self.client.post("/api/reports/generate/")
@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(response.status_code, status.HTTP_200_OK)
with ZipFile(BytesIO(response.content)) as archive:
self.assertEqual(token, "token-value") users_workbook = load_workbook(BytesIO(archive.read("users-report.xlsx")))
mock_post.assert_called_once_with( self.assertIsNone(users_workbook.active["A3"].value)
"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,36 +1,60 @@
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.serializers import ReportGenerateSerializer from apps.reports.services.data_sources import ReportDataSources
from apps.reports.services.excel import build_report_workbook from apps.reports.services.excel import build_report_workbook, build_report_zip
from apps.reports.services.orchestrator import ReportOrchestrator from apps.reports.services.report_builder import (
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):
serializer = ReportGenerateSerializer(data=request.data) data_sources = ReportDataSources()
serializer.is_valid(raise_exception=True) tickets = data_sources.fetch_application_tickets()
users = data_sources.fetch_users_for_tickets(tickets)
orchestrator = ReportOrchestrator() files = {
report_data = orchestrator.generate(serializer.validated_data) "users-report.xlsx": build_report_workbook(
workbook = build_report_workbook( title="Users Report",
title=report_data["title"], columns=USERS_COLUMNS,
columns=report_data["columns"], rows=build_user_rows(users),
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(
workbook, build_report_zip(files),
content_type=( content_type="application/zip",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
),
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
response["Content-Disposition"] = ( response["Content-Disposition"] = 'attachment; filename="crm-reports.zip"'
f'attachment; filename="{report_data["filename"]}"'
)
return response return response

View file

@ -37,6 +37,7 @@ DJANGO_APPS = [
THIRD_PARTY_APPS = [ THIRD_PARTY_APPS = [
"corsheaders", "corsheaders",
"rest_framework", "rest_framework",
"drf_spectacular",
] ]
LOCAL_APPS = [ LOCAL_APPS = [
@ -123,6 +124,7 @@ CORS_ALLOW_CREDENTIALS = env.bool("CORS_ALLOW_CREDENTIALS", default=True)
REST_FRAMEWORK = { REST_FRAMEWORK = {
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
"DEFAULT_RENDERER_CLASSES": [ "DEFAULT_RENDERER_CLASSES": [
"rest_framework.renderers.JSONRenderer", "rest_framework.renderers.JSONRenderer",
], ],
@ -148,10 +150,25 @@ REST_FRAMEWORK = {
} }
SPECTACULAR_SETTINGS = {
"TITLE": "CRM Backend API",
"DESCRIPTION": "API for CRM report generation and health checks.",
"VERSION": "1.0.0",
"SERVE_INCLUDE_SCHEMA": False,
}
SERVICE_REQUEST_TIMEOUT = env.int("SERVICE_REQUEST_TIMEOUT", default=30) SERVICE_REQUEST_TIMEOUT = env.int("SERVICE_REQUEST_TIMEOUT", default=30)
OAUTH2_PROVIDER_PUBLIC_URL = env("OAUTH2_PROVIDER_PUBLIC_URL", default="") OAUTH2_PROVIDER_PUBLIC_URL = env("OAUTH2_PROVIDER_PUBLIC_URL", default="")
OAUTH2_PROVIDER_PRIVATE_URL = env("OAUTH2_PROVIDER_PRIVATE_URL", default="") OAUTH2_PROVIDER_PRIVATE_URL = env("OAUTH2_PROVIDER_PRIVATE_URL", default="")
OAUTH2_PROVIDER_BASE_PUBLIC_URL = env("OAUTH2_PROVIDER_BASE_PUBLIC_URL", default="")
OAUTH2_PROVIDER_BASE_PRIVATE_URL = env("OAUTH2_PROVIDER_BASE_PRIVATE_URL", default="")
OAUTH2_PROVIDER_CLIENT_ID = env("OAUTH2_PROVIDER_CLIENT_ID", default="")
OAUTH2_PROVIDER_CLIENT_SECRET = env("OAUTH2_PROVIDER_CLIENT_SECRET", default="")
OAUTH2_PROVIDER_SCOPES = env("OAUTH2_PROVIDER_SCOPES", default="")
OAUTH2_CLIENT_ID = env("OAUTH2_CLIENT_ID", default="") OAUTH2_CLIENT_ID = env("OAUTH2_CLIENT_ID", default="")
OAUTH2_CLIENT_SECRET = env("OAUTH2_CLIENT_SECRET", default="") OAUTH2_CLIENT_SECRET = env("OAUTH2_CLIENT_SECRET", default="")
OAUTH2_SCOPES = env("OAUTH2_SCOPES", default="") OAUTH2_SCOPES = env("OAUTH2_SCOPES", default="")
ADVERTISING_BASE_PUBLIC_URL = env("ADVERTISING_BASE_PUBLIC_URL", default="")
ACCOUNTS_BASE_PUBLIC_URL = env("ACCOUNTS_BASE_PUBLIC_URL", default="")

View file

@ -1,9 +1,17 @@
from django.contrib import admin from django.contrib import admin
from django.urls import include, path from django.urls import include, path
from drf_spectacular.views import (
SpectacularAPIView,
SpectacularRedocView,
SpectacularSwaggerView,
)
urlpatterns = [ urlpatterns = [
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
path("api/docs/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"),
path("api/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"),
path("api/", include("apps.core.urls")), path("api/", include("apps.core.urls")),
path("api/reports/", include("apps.reports.urls")), path("api/reports/", include("apps.reports.urls")),
] ]

View file

@ -1,5 +1,6 @@
Django>=4.2,<5.0 Django>=4.2,<5.0
djangorestframework>=3.15,<4.0 djangorestframework>=3.15,<4.0
drf-spectacular>=0.27,<1.0
django-environ>=0.11,<1.0 django-environ>=0.11,<1.0
django-cors-headers>=4.3,<5.0 django-cors-headers>=4.3,<5.0
whitenoise>=6.6,<7.0 whitenoise>=6.6,<7.0
@ -7,3 +8,4 @@ psycopg[binary]>=3.1,<4.0
requests>=2.32,<3.0 requests>=2.32,<3.0
urllib3>=1.26,<2.0 urllib3>=1.26,<2.0
openpyxl>=3.1,<4.0 openpyxl>=3.1,<4.0
openapi-python-client