Archived
feat: monorepo structure with Django backend, React frontend, and Expo mobile app
- Backend: Django 5 + DRF with accounts, documents, collections, and reading apps - Custom User model with email-based auth, JWT via SimpleJWT - Full CRUD viewsets with ModelSerializer + DRF routers - pytest, Ruff, drf-spectacular (OpenAPI), whitenoise - Dockerfile for production deployment - Frontend: React 18 + TypeScript + Vite - Lazy-loaded routes with ProtectedRoute/PublicRoute guards - Auth context with useReducer, token refresh interceptor - Pages: Login, Register, Library, Document Detail, Reader, Collections, Settings - Dark theme, responsive grid layout, Vite proxy to Django backend - Mobile: Expo SDK 51 + React Native + Expo Router - File-based routing with login, register, and library screens - AsyncStorage for token persistence, token refresh interceptor - Shared API types via @cloud-reader/shared workspace package - Shared: TypeScript types (API responses, auth, documents, etc.) - CI/CD: 3 independent GitHub Actions pipelines (backend, frontend, mobile)
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
"""Custom exception handler that returns consistent JSON error responses."""
|
||||
from rest_framework.views import exception_handler
|
||||
|
||||
|
||||
def custom_exception_handler(exc, context):
|
||||
"""Wrap DRF's default handler to always return {'detail': ..., 'code': ...}."""
|
||||
response = exception_handler(exc, context)
|
||||
if response is not None:
|
||||
data = response.data
|
||||
# Flatten validation errors into a consistent shape
|
||||
if isinstance(data, dict) and "detail" not in data:
|
||||
response.data = {"detail": "Validation error", "fields": data, "code": "validation_error"}
|
||||
return response
|
||||
@@ -0,0 +1,170 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import decouple
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Environment
|
||||
# ---------------------------------------------------------------------------
|
||||
config = decouple.AutoConfig(search_path=BASE_DIR / ".env")
|
||||
|
||||
SECRET_KEY = config("SECRET_KEY", default="django-insecure-change-me-in-production")
|
||||
DEBUG = config("DEBUG", default=False, cast=bool)
|
||||
ALLOWED_HOSTS = config("ALLOWED_HOSTS", default="localhost,127.0.0.1", cast=decouple.Csv())
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Application definition
|
||||
# ---------------------------------------------------------------------------
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
# Third-party
|
||||
"rest_framework",
|
||||
"rest_framework_simplejwt",
|
||||
"corsheaders",
|
||||
"django_filters",
|
||||
"drf_spectacular",
|
||||
# Local
|
||||
"apps.accounts",
|
||||
"apps.documents",
|
||||
"apps.collections",
|
||||
"apps.reading",
|
||||
]
|
||||
|
||||
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"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Database
|
||||
# ---------------------------------------------------------------------------
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": config("DB_ENGINE", default="django.db.backends.postgresql"),
|
||||
"NAME": config("DB_NAME", default="cloud_reader"),
|
||||
"USER": config("DB_USER", default="cloud_reader"),
|
||||
"PASSWORD": config("DB_PASSWORD", default="cloud_reader"),
|
||||
"HOST": config("DB_HOST", default="localhost"),
|
||||
"PORT": config("DB_PORT", default="5432", cast=int),
|
||||
}
|
||||
}
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth
|
||||
# ---------------------------------------------------------------------------
|
||||
AUTH_USER_MODEL = "accounts.User"
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
|
||||
]
|
||||
|
||||
LOGIN_URL = "rest_framework:login"
|
||||
LOGOUT_URL = "rest_framework:logout"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internationalization
|
||||
# ---------------------------------------------------------------------------
|
||||
LANGUAGE_CODE = "en-us"
|
||||
TIME_ZONE = "UTC"
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Static & Media files
|
||||
# ---------------------------------------------------------------------------
|
||||
STATIC_URL = "static/"
|
||||
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||
STATICFILES_DIRS = [BASE_DIR / "static"]
|
||||
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
||||
|
||||
MEDIA_URL = "media/"
|
||||
MEDIA_ROOT = BASE_DIR / "media"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CORS
|
||||
# ---------------------------------------------------------------------------
|
||||
CORS_ALLOWED_ORIGINS = config(
|
||||
"CORS_ALLOWED_ORIGINS",
|
||||
default="http://localhost:5173,http://localhost:3000",
|
||||
cast=decouple.Csv(),
|
||||
)
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REST Framework
|
||||
# ---------------------------------------------------------------------------
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||
"rest_framework_simplejwt.authentication.JWTAuthentication",
|
||||
),
|
||||
"DEFAULT_PERMISSION_CLASSES": (
|
||||
"rest_framework.permissions.IsAuthenticated",
|
||||
),
|
||||
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
|
||||
"PAGE_SIZE": 20,
|
||||
"DEFAULT_FILTER_BACKENDS": [
|
||||
"django_filters.rest_framework.DjangoFilterBackend",
|
||||
"rest_framework.filters.SearchFilter",
|
||||
"rest_framework.filters.OrderingFilter",
|
||||
],
|
||||
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
||||
"EXCEPTION_HANDLER": "config.exceptions.custom_exception_handler",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SimpleJWT
|
||||
# ---------------------------------------------------------------------------
|
||||
from datetime import timedelta # noqa: E402
|
||||
|
||||
SIMPLE_JWT = {
|
||||
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=30),
|
||||
"REFRESH_TOKEN_LIFETIME": timedelta(days=7),
|
||||
"ROTATE_REFRESH_TOKENS": True,
|
||||
"AUTH_HEADER_TYPES": ("Bearer",),
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# drf-spectacular (OpenAPI)
|
||||
# ---------------------------------------------------------------------------
|
||||
SPECTACULAR_SETTINGS = {
|
||||
"TITLE": "Cloud Reader API",
|
||||
"VERSION": "0.1.0",
|
||||
"SERVE_INCLUDE_SCHEMA": False,
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
from django.contrib import admin
|
||||
from django.urls import include, path
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
# API
|
||||
path("api/v1/auth/", include("apps.accounts.urls")),
|
||||
path("api/v1/documents/", include("apps.documents.urls")),
|
||||
path("api/v1/collections/", include("apps.collections.urls")),
|
||||
path("api/v1/reading/", include("apps.reading.urls")),
|
||||
# OpenAPI schema
|
||||
path("api/schema/", include("drf_spectacular.urls")),
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
||||
|
||||
application = get_wsgi_application()
|
||||
Reference in New Issue
Block a user