diff --git a/.env.example b/.env.example index e0a7cfe..f8bbf23 100644 --- a/.env.example +++ b/.env.example @@ -25,3 +25,4 @@ ACCOUNTS_BASE_PUBLIC_URL=https://accounts-staging.gooyal.ir WALLET_BASE_PUBLIC_URL=https://wallet-staging.gooyal.ir WALLET_RIAL=af7d967f-30c0-409b-9066-2549f2da5e5e WALLET_REWARD=e7c9d4d1-4d1f-43b2-96f7-4d4a168f480d +ADVERTISING_BASE_PUBLIC_URL=https://advertising-staging.gooyal.ir diff --git a/apps/core/views.py b/apps/core/views.py index 16caac7..d04de7a 100644 --- a/apps/core/views.py +++ b/apps/core/views.py @@ -1,3 +1,4 @@ +from drf_spectacular.utils import extend_schema from rest_framework.permissions import AllowAny from rest_framework.response import Response from rest_framework.views import APIView @@ -7,5 +8,11 @@ class HealthCheckView(APIView): authentication_classes = [] 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): return Response({"status": "ok"}) diff --git a/apps/reports/serializers.py b/apps/reports/serializers.py deleted file mode 100644 index 69d4d6f..0000000 --- a/apps/reports/serializers.py +++ /dev/null @@ -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) diff --git a/apps/reports/services/data_sources.py b/apps/reports/services/data_sources.py index 293c942..6d39ea4 100644 --- a/apps/reports/services/data_sources.py +++ b/apps/reports/services/data_sources.py @@ -1,41 +1,46 @@ -from urllib.parse import urljoin +import logging -import requests -from django.conf import settings +from utils.accounts_client import get_user_detailed_info +from utils.advertising_client import get_crm_application_tickets -from apps.reports.services.oauth import OAuth2ClientCredentials +logger = logging.getLogger(__name__) class ReportDataSources: - """ - Keep downstream calls for report generation in one place. + def fetch_application_tickets(self): + tickets = [] + offset = 0 + limit = 1000 - The concrete service endpoints are intentionally placeholders until the - exact internal service URLs and response shapes are known. - """ + while True: + response = get_crm_application_tickets(limit=limit, offset=offset) + if not response or not response.results: + break - def __init__(self, oauth_client=None): - self.oauth_client = oauth_client or OAuth2ClientCredentials() + tickets.extend(response.results) + if response.next_ is None: + break + offset += limit - def fetch_users(self, filters): - return [] + return tickets - def fetch_user_metrics(self, user_ids, filters): - return {} + def fetch_users_for_tickets(self, tickets): + users = [] + seen = set() - def fetch_tickets(self, filters): - return [] + for ticket in tickets: + user_id = str(ticket.user) + if user_id in seen: + continue + seen.add(user_id) - def fetch_ticket_metrics(self, ticket_ids, filters): - return {} + try: + 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): - 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() + if user is not None: + users.append(user) + + return users diff --git a/apps/reports/services/excel.py b/apps/reports/services/excel.py index 569efa6..dd7ea79 100644 --- a/apps/reports/services/excel.py +++ b/apps/reports/services/excel.py @@ -41,3 +41,13 @@ def build_report_workbook(title, columns, rows): output = BytesIO() workbook.save(output) 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() diff --git a/apps/reports/services/oauth.py b/apps/reports/services/oauth.py deleted file mode 100644 index 368d382..0000000 --- a/apps/reports/services/oauth.py +++ /dev/null @@ -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()}"} diff --git a/apps/reports/services/orchestrator.py b/apps/reports/services/orchestrator.py deleted file mode 100644 index 5aadd4c..0000000 --- a/apps/reports/services/orchestrator.py +++ /dev/null @@ -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 {}) diff --git a/apps/reports/services/report_builder.py b/apps/reports/services/report_builder.py new file mode 100644 index 0000000..3815b84 --- /dev/null +++ b/apps/reports/services/report_builder.py @@ -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 diff --git a/apps/reports/services/report_types.py b/apps/reports/services/report_types.py deleted file mode 100644 index 451294d..0000000 --- a/apps/reports/services/report_types.py +++ /dev/null @@ -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, -} diff --git a/apps/reports/tests.py b/apps/reports/tests.py index 8c8b5dd..0828d70 100644 --- a/apps/reports/tests.py +++ b/apps/reports/tests.py @@ -1,151 +1,138 @@ +from datetime import datetime 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 rest_framework import status 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): - @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}, - } + 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 - response = self.client.post( - "/api/reports/generate/", - { - "report_type": "user", - "filters": {"status": "active"}, - }, - format="json", - ) + 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 - 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( + @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, mock_fetch_tickets, - mock_fetch_ticket_metrics, + mock_fetch_users, ): - 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 = {} + ticket = self._build_ticket() + user = self._build_user(ticket.user) + mock_fetch_tickets.return_value = [ticket] + mock_fetch_users.return_value = [user] - response = self.client.post( - "/api/reports/generate/", - {"report_type": "tickets"}, - format="json", - ) + response = self.client.post("/api/reports/generate/") self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response["Content-Type"], "application/zip") self.assertEqual( response["Content-Disposition"], - 'attachment; filename="tickets-report.xlsx"', + 'attachment; filename="crm-reports.zip"', ) - 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", - ], - ) + with ZipFile(BytesIO(response.content)) as archive: + self.assertEqual( + set(archive.namelist()), + {"users-report.xlsx", "tickets-report.xlsx"}, + ) - def test_unknown_report_type_returns_validation_error(self): - response = self.client.post( - "/api/reports/generate/", - {"report_type": "unknown"}, - format="json", - ) + users_workbook = load_workbook(BytesIO(archive.read("users-report.xlsx"))) + users_sheet = users_workbook.active + self.assertEqual(users_sheet["A1"].value, "Users Report") + self.assertEqual( + [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): - @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 + response = self.client.post("/api/reports/generate/") - 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, - ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + with ZipFile(BytesIO(response.content)) as archive: + users_workbook = load_workbook(BytesIO(archive.read("users-report.xlsx"))) + self.assertIsNone(users_workbook.active["A3"].value) diff --git a/apps/reports/views.py b/apps/reports/views.py index 9fc640f..36257fb 100644 --- a/apps/reports/views.py +++ b/apps/reports/views.py @@ -1,36 +1,60 @@ 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.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 +from apps.reports.services.data_sources import ReportDataSources +from apps.reports.services.excel import build_report_workbook, build_report_zip +from apps.reports.services.report_builder import ( + TICKETS_COLUMNS, + USERS_COLUMNS, + build_ticket_rows, + build_user_rows, +) class GenerateReportView(APIView): authentication_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): - serializer = ReportGenerateSerializer(data=request.data) - serializer.is_valid(raise_exception=True) + data_sources = ReportDataSources() + tickets = data_sources.fetch_application_tickets() + users = data_sources.fetch_users_for_tickets(tickets) - 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"], - ) + files = { + "users-report.xlsx": build_report_workbook( + title="Users Report", + columns=USERS_COLUMNS, + rows=build_user_rows(users), + ), + "tickets-report.xlsx": build_report_workbook( + title="Tickets Report", + columns=TICKETS_COLUMNS, + rows=build_ticket_rows(tickets), + ), + } response = HttpResponse( - workbook, - content_type=( - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" - ), + build_report_zip(files), + content_type="application/zip", status=status.HTTP_200_OK, ) - response["Content-Disposition"] = ( - f'attachment; filename="{report_data["filename"]}"' - ) + response["Content-Disposition"] = 'attachment; filename="crm-reports.zip"' return response diff --git a/config/settings/base.py b/config/settings/base.py index 8feca6c..bfdfdac 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -37,6 +37,7 @@ DJANGO_APPS = [ THIRD_PARTY_APPS = [ "corsheaders", "rest_framework", + "drf_spectacular", ] LOCAL_APPS = [ @@ -123,6 +124,7 @@ CORS_ALLOW_CREDENTIALS = env.bool("CORS_ALLOW_CREDENTIALS", default=True) REST_FRAMEWORK = { + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", "DEFAULT_RENDERER_CLASSES": [ "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) OAUTH2_PROVIDER_PUBLIC_URL = env("OAUTH2_PROVIDER_PUBLIC_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_SECRET = env("OAUTH2_CLIENT_SECRET", 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="") diff --git a/config/urls.py b/config/urls.py index 87471ff..e819a8e 100644 --- a/config/urls.py +++ b/config/urls.py @@ -1,9 +1,17 @@ from django.contrib import admin from django.urls import include, path +from drf_spectacular.views import ( + SpectacularAPIView, + SpectacularRedocView, + SpectacularSwaggerView, +) urlpatterns = [ 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/reports/", include("apps.reports.urls")), ] diff --git a/requirements.txt b/requirements.txt index a5f89a1..13b5baf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ Django>=4.2,<5.0 djangorestframework>=3.15,<4.0 +drf-spectacular>=0.27,<1.0 django-environ>=0.11,<1.0 django-cors-headers>=4.3,<5.0 whitenoise>=6.6,<7.0 @@ -7,3 +8,4 @@ psycopg[binary]>=3.1,<4.0 requests>=2.32,<3.0 urllib3>=1.26,<2.0 openpyxl>=3.1,<4.0 +openapi-python-client