From 980e0e41cad0d89fe1013906210013668432311c Mon Sep 17 00:00:00 2001 From: Ali Asadi Date: Wed, 20 May 2026 15:15:37 +0330 Subject: [PATCH] init project, add excel generation and mock api calls --- .env.example | 18 +++ .gitignore | 15 +++ README.md | 70 ++++++++++++ apps/__init__.py | 1 + apps/core/__init__.py | 1 + apps/core/apps.py | 6 + apps/core/tests.py | 10 ++ apps/core/urls.py | 10 ++ apps/core/views.py | 11 ++ apps/reports/__init__.py | 1 + apps/reports/apps.py | 6 + apps/reports/serializers.py | 8 ++ apps/reports/services/__init__.py | 1 + apps/reports/services/data_sources.py | 41 +++++++ apps/reports/services/excel.py | 43 +++++++ apps/reports/services/oauth.py | 69 +++++++++++ apps/reports/services/orchestrator.py | 11 ++ apps/reports/services/report_types.py | 100 ++++++++++++++++ apps/reports/tests.py | 151 +++++++++++++++++++++++++ apps/reports/urls.py | 10 ++ apps/reports/views.py | 36 ++++++ config/__init__.py | 1 + config/asgi.py | 9 ++ config/settings/__init__.py | 1 + config/settings/base.py | 157 ++++++++++++++++++++++++++ config/settings/local.py | 13 +++ config/settings/production.py | 21 ++++ config/urls.py | 9 ++ config/wsgi.py | 9 ++ manage.py | 22 ++++ requirements.txt | 9 ++ 31 files changed, 870 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 apps/__init__.py create mode 100644 apps/core/__init__.py create mode 100644 apps/core/apps.py create mode 100644 apps/core/tests.py create mode 100644 apps/core/urls.py create mode 100644 apps/core/views.py create mode 100644 apps/reports/__init__.py create mode 100644 apps/reports/apps.py create mode 100644 apps/reports/serializers.py create mode 100644 apps/reports/services/__init__.py create mode 100644 apps/reports/services/data_sources.py create mode 100644 apps/reports/services/excel.py create mode 100644 apps/reports/services/oauth.py create mode 100644 apps/reports/services/orchestrator.py create mode 100644 apps/reports/services/report_types.py create mode 100644 apps/reports/tests.py create mode 100644 apps/reports/urls.py create mode 100644 apps/reports/views.py create mode 100644 config/__init__.py create mode 100644 config/asgi.py create mode 100644 config/settings/__init__.py create mode 100644 config/settings/base.py create mode 100644 config/settings/local.py create mode 100644 config/settings/production.py create mode 100644 config/urls.py create mode 100644 config/wsgi.py create mode 100644 manage.py create mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ded5713 --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..76930a3 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..8233108 --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/apps/__init__.py b/apps/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/core/__init__.py b/apps/core/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/core/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/core/apps.py b/apps/core/apps.py new file mode 100644 index 0000000..ab0051e --- /dev/null +++ b/apps/core/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.core" diff --git a/apps/core/tests.py b/apps/core/tests.py new file mode 100644 index 0000000..ee72014 --- /dev/null +++ b/apps/core/tests.py @@ -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"}) diff --git a/apps/core/urls.py b/apps/core/urls.py new file mode 100644 index 0000000..41923c6 --- /dev/null +++ b/apps/core/urls.py @@ -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"), +] diff --git a/apps/core/views.py b/apps/core/views.py new file mode 100644 index 0000000..16caac7 --- /dev/null +++ b/apps/core/views.py @@ -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"}) diff --git a/apps/reports/__init__.py b/apps/reports/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/reports/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/reports/apps.py b/apps/reports/apps.py new file mode 100644 index 0000000..64e72e6 --- /dev/null +++ b/apps/reports/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ReportsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.reports" diff --git a/apps/reports/serializers.py b/apps/reports/serializers.py new file mode 100644 index 0000000..69d4d6f --- /dev/null +++ b/apps/reports/serializers.py @@ -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) diff --git a/apps/reports/services/__init__.py b/apps/reports/services/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/reports/services/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/reports/services/data_sources.py b/apps/reports/services/data_sources.py new file mode 100644 index 0000000..293c942 --- /dev/null +++ b/apps/reports/services/data_sources.py @@ -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() diff --git a/apps/reports/services/excel.py b/apps/reports/services/excel.py new file mode 100644 index 0000000..569efa6 --- /dev/null +++ b/apps/reports/services/excel.py @@ -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() diff --git a/apps/reports/services/oauth.py b/apps/reports/services/oauth.py new file mode 100644 index 0000000..9371157 --- /dev/null +++ b/apps/reports/services/oauth.py @@ -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()}"} diff --git a/apps/reports/services/orchestrator.py b/apps/reports/services/orchestrator.py new file mode 100644 index 0000000..5aadd4c --- /dev/null +++ b/apps/reports/services/orchestrator.py @@ -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 {}) diff --git a/apps/reports/services/report_types.py b/apps/reports/services/report_types.py new file mode 100644 index 0000000..451294d --- /dev/null +++ b/apps/reports/services/report_types.py @@ -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, +} diff --git a/apps/reports/tests.py b/apps/reports/tests.py new file mode 100644 index 0000000..8c8b5dd --- /dev/null +++ b/apps/reports/tests.py @@ -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, + ) diff --git a/apps/reports/urls.py b/apps/reports/urls.py new file mode 100644 index 0000000..af9c5bb --- /dev/null +++ b/apps/reports/urls.py @@ -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"), +] diff --git a/apps/reports/views.py b/apps/reports/views.py new file mode 100644 index 0000000..9fc640f --- /dev/null +++ b/apps/reports/views.py @@ -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 diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/config/__init__.py @@ -0,0 +1 @@ + diff --git a/config/asgi.py b/config/asgi.py new file mode 100644 index 0000000..9c6086f --- /dev/null +++ b/config/asgi.py @@ -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() diff --git a/config/settings/__init__.py b/config/settings/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/config/settings/__init__.py @@ -0,0 +1 @@ + diff --git a/config/settings/base.py b/config/settings/base.py new file mode 100644 index 0000000..8feca6c --- /dev/null +++ b/config/settings/base.py @@ -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="") diff --git a/config/settings/local.py b/config/settings/local.py new file mode 100644 index 0000000..b7d13a9 --- /dev/null +++ b/config/settings/local.py @@ -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" +) diff --git a/config/settings/production.py b/config/settings/production.py new file mode 100644 index 0000000..5cd646a --- /dev/null +++ b/config/settings/production.py @@ -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" diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..87471ff --- /dev/null +++ b/config/urls.py @@ -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")), +] diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..a17ebbc --- /dev/null +++ b/config/wsgi.py @@ -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() diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..d8242f3 --- /dev/null +++ b/manage.py @@ -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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a5f89a1 --- /dev/null +++ b/requirements.txt @@ -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