ntc-dev-kit 1.2.0__tar.gz → 1.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ntc_dev_kit-1.2.0/ntc_dev_kit.egg-info → ntc_dev_kit-1.2.1}/PKG-INFO +1 -1
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/__init__.py +1 -1
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/auth_app/migrations/0001_initial.py +7 -6
- ntc_dev_kit-1.2.1/ntc/templates/auth_app/models.py +54 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/auth_app/serializers.py +8 -1
- ntc_dev_kit-1.2.1/ntc/templates/auth_app/tasks.py +28 -0
- ntc_dev_kit-1.2.1/ntc/templates/auth_app/templates/authentication/password_reset.html +5 -0
- ntc_dev_kit-1.2.1/ntc/templates/auth_app/templates/authentication/password_reset.txt +9 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/auth_app/tests.py +51 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/auth_app/views.py +25 -11
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/main.py +6 -2
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/structure.json +26 -1
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/.entry/start_tasks_backend.sh +2 -1
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/CLAUDE.md +19 -1
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/apps/common_apps.py +3 -0
- ntc_dev_kit-1.2.1/ntc/templates/drf_structure/templates/apps/common_checks.py +17 -0
- ntc_dev_kit-1.2.1/ntc/templates/drf_structure/templates/ci/github_ci.yml +28 -0
- ntc_dev_kit-1.2.1/ntc/templates/drf_structure/templates/ci/pre_commit.yaml +7 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/core/exceptions/handler.py +5 -8
- ntc_dev_kit-1.2.1/ntc/templates/drf_structure/templates/core/health.py +68 -0
- ntc_dev_kit-1.2.1/ntc/templates/drf_structure/templates/core/renderers.py +15 -0
- ntc_dev_kit-1.2.1/ntc/templates/drf_structure/templates/core/response_wrapper.py +27 -0
- ntc_dev_kit-1.2.1/ntc/templates/drf_structure/templates/core/schema.py +39 -0
- ntc_dev_kit-1.2.1/ntc/templates/drf_structure/templates/core/tests.py +60 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/django_project/django_settings.txt +50 -6
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/django_project/prod_settings.py +5 -0
- ntc_dev_kit-1.2.1/ntc/templates/drf_structure/templates/django_project/schema.py +19 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/django_project/test_settings.py +2 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/django_project/urls.py +4 -1
- ntc_dev_kit-1.2.1/ntc/templates/drf_structure/templates/docker/docker-compose.yml +65 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/knowledge/knowledge_description.md +1 -1
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/knowledge/knowledge_design_pattern.md +12 -19
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/root/README.md +13 -11
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/root/django_requirements.txt +1 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_admin/src/services/api.service.ts +5 -5
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_admin/src/services/base.service.ts +1 -1
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/utils/prompts.py +13 -4
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/wizard.py +49 -23
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1/ntc_dev_kit.egg-info}/PKG-INFO +1 -1
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc_dev_kit.egg-info/SOURCES.txt +10 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/pyproject.toml +1 -1
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/setup.py +1 -1
- ntc_dev_kit-1.2.0/ntc/templates/auth_app/models.py +0 -30
- ntc_dev_kit-1.2.0/ntc/templates/drf_structure/templates/core/response_wrapper.py +0 -12
- ntc_dev_kit-1.2.0/ntc/templates/drf_structure/templates/django_project/schema.py +0 -8
- ntc_dev_kit-1.2.0/ntc/templates/drf_structure/templates/docker/docker-compose.yml +0 -11
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/LICENSE +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/MANIFEST.in +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/README.md +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/app_creator.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/cli.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/cli_enhanced.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/composer/__init__.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/composer/main.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/deployment/__init__.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/main.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/__init__.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/auth_app/__init__.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/auth_app/admin.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/auth_app/apps.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/auth_app/urls.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/__init__.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/drf_structure.md +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/.entry/start_core_backend.sh +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/README.md +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/apps/app_admin.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/apps/app_apps.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/apps/app_models.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/apps/app_urls.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/apps/app_views.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/apps/common_models.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/config/gunicorn_config.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/config/http.conf +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/core/formatter.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/core/logger.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/core/pagination.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/core/services/azure/email/backend.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/core/services/azure/email/client.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/core/services/azure/email/notifier.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/core/services/object_storage.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/core/utils/request.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/django_project/asgi.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/django_project/celery.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/django_project/dev_settings.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/django_project/project_init.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/django_project/wsgi.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/docker/Dockerfile +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/docker/captain-definition +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/knowledge/knowledge_structure.md +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/root/django_env.txt +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/root/django_requirements_dev.txt +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/root/dockerignore.txt +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/root/gitattributes.txt +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/root/gitignore.txt +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/root/manage.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/root/pytest_ini.txt +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/drf_structure/templates/root/ruff_toml.txt +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/flutter/.gitignore +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/flutter/.knowledge/knowledge_description.md +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/flutter/.knowledge/knowledge_design_pattern.md +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/flutter/.knowledge/knowledge_structure.md +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/flutter/CLAUDE.md +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/flutter/__init__.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/flutter/analysis_options.yaml +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/flutter/lib/core/config/app_config.dart +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/flutter/lib/core/config/environment.dart +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/flutter/lib/core/constants/app_constants.dart +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/flutter/lib/core/network/dio_client.dart +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/flutter/lib/features/home/presentation/home_screen.dart +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/flutter/lib/l10n/app_localizations.dart +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/flutter/lib/main.dart +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/flutter/lib/services/api_service.dart +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/flutter/lib/shared/localization/locale_provider.dart +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/flutter/lib/shared/theme/app_theme.dart +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/flutter/lib/shared/theme/theme_provider.dart +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/flutter/main.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/flutter/pubspec.yaml +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/flutter/structure.json +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/nextjs/.env.example +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/nextjs/.gitignore +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/nextjs/CLAUDE.md +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/nextjs/README.md +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/nextjs/__init__.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/nextjs/eslint.config.mjs +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/nextjs/knowledge/knowledge_description.md +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/nextjs/knowledge/knowledge_design_pattern.md +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/nextjs/knowledge/knowledge_structure.md +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/nextjs/main.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/nextjs/next.config.js +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/nextjs/package.json +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/nextjs/src/app/globals.css +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/nextjs/src/app/layout.tsx +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/nextjs/src/app/page.tsx +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/nextjs/src/components/Header.tsx +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/nextjs/src/services/api.ts +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/nextjs/src/services/server.ts +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/nextjs/src/utils/constants.ts +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/nextjs/structure.json +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/nextjs/tsconfig.json +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_admin/.env.example +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_admin/.gitignore +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_admin/CLAUDE.md +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_admin/README.md +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_admin/__init__.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_admin/eslint.config.js +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_admin/index.html +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_admin/knowledge/knowledge_description.md +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_admin/knowledge/knowledge_design_pattern.md +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_admin/knowledge/knowledge_structure.md +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_admin/main.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_admin/package.json +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_admin/src/App.tsx +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_admin/src/components/ProtectedRoute.tsx +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_admin/src/components/Sidebar.tsx +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_admin/src/components/TopNav.tsx +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_admin/src/context/AuthContext.tsx +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_admin/src/context/ThemeContext.tsx +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_admin/src/context/WorkspaceContext.tsx +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_admin/src/hooks/useApi.ts +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_admin/src/index.css +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_admin/src/layouts/AuthLayout.tsx +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_admin/src/layouts/DashboardLayout.tsx +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_admin/src/main.tsx +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_admin/src/pages/DashboardPage.tsx +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_admin/src/pages/auth/LoginPage.tsx +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_admin/src/services/token.ts +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_admin/src/vite-env.d.ts +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_admin/structure.json +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_admin/tsconfig.app.json +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_admin/tsconfig.json +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_admin/tsconfig.node.json +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_admin/vite.config.ts +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_structure/App.css +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_structure/CLAUDE.md +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_structure/__init__.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_structure/index.css +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_structure/index.html +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_structure/knowledge/knowledge_description.md +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_structure/knowledge/knowledge_design_pattern.md +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_structure/knowledge/knowledge_structure.md +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_structure/main.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_structure/package.json +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_structure/src/App.tsx +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_structure/src/components/Header.tsx +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_structure/src/main.tsx +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_structure/src/services/api.ts +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_structure/src/utils/constants.ts +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_structure/src/vite-env.d.ts +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_structure/structure.json +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_structure/tsconfig.app.json +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_structure/tsconfig.json +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_structure/tsconfig.node.json +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/templates/react_structure/vite.config.ts +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/ui/__init__.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/ui/app.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/utils/__init__.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/utils/environment.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/utils/output.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/utils/shell.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc/utils/validation.py +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc_dev_kit.egg-info/dependency_links.txt +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc_dev_kit.egg-info/entry_points.txt +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc_dev_kit.egg-info/requires.txt +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/ntc_dev_kit.egg-info/top_level.txt +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/requirements.txt +0 -0
- {ntc_dev_kit-1.2.0 → ntc_dev_kit-1.2.1}/setup.cfg +0 -0
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
# Generated by Django 5.2.7 on 2026-10-20
|
|
1
|
+
# Generated by Django 5.2.7 on 2026-10-20 16:26
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import apps.authentication.models
|
|
4
4
|
import django.contrib.auth.validators
|
|
5
|
+
import django.db.models.functions.text
|
|
5
6
|
import django.utils.timezone
|
|
6
7
|
from django.db import migrations, models
|
|
7
8
|
|
|
@@ -23,12 +24,12 @@ class Migration(migrations.Migration):
|
|
|
23
24
|
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
|
24
25
|
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
|
25
26
|
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
|
27
|
+
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
|
28
|
+
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
|
26
29
|
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
|
27
30
|
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
|
28
31
|
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
|
29
32
|
('email', models.EmailField(max_length=254, unique=True)),
|
|
30
|
-
('first_name', models.CharField(blank=True, max_length=30)),
|
|
31
|
-
('last_name', models.CharField(blank=True, max_length=30)),
|
|
32
33
|
('phone_number', models.CharField(blank=True, max_length=15)),
|
|
33
34
|
('date_of_birth', models.DateField(blank=True, null=True)),
|
|
34
35
|
('profile_picture', models.ImageField(blank=True, null=True, upload_to='profile_pics/')),
|
|
@@ -41,10 +42,10 @@ class Migration(migrations.Migration):
|
|
|
41
42
|
options={
|
|
42
43
|
'verbose_name': 'User',
|
|
43
44
|
'verbose_name_plural': 'Users',
|
|
44
|
-
'
|
|
45
|
+
'constraints': [models.UniqueConstraint(django.db.models.functions.text.Lower('email'), name='authentication_user_email_ci_unique')],
|
|
45
46
|
},
|
|
46
47
|
managers=[
|
|
47
|
-
('objects',
|
|
48
|
+
('objects', apps.authentication.models.UserManager()),
|
|
48
49
|
],
|
|
49
50
|
),
|
|
50
51
|
]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from django.contrib.auth.models import AbstractUser, UserManager as DjangoUserManager
|
|
2
|
+
from django.db import models
|
|
3
|
+
from django.db.models.functions import Lower
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def normalize_email(email):
|
|
7
|
+
"""Emails are stored lowercase: A@x.com and a@x.com are the same person, and sign-in and
|
|
8
|
+
password resets look them up exactly."""
|
|
9
|
+
return (email or '').strip().lower()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class UserManager(DjangoUserManager):
|
|
13
|
+
def _create_user(self, username, email, password, **extra_fields):
|
|
14
|
+
return super()._create_user(username, normalize_email(email), password, **extra_fields)
|
|
15
|
+
|
|
16
|
+
def get_by_natural_key(self, username):
|
|
17
|
+
# USERNAME_FIELD is the email: sign-in matches it whatever the case typed.
|
|
18
|
+
return self.get(email=normalize_email(username))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class User(AbstractUser):
|
|
22
|
+
"""Custom User model extending AbstractUser. first_name/last_name keep AbstractUser's 150 characters."""
|
|
23
|
+
email = models.EmailField(unique=True)
|
|
24
|
+
phone_number = models.CharField(max_length=15, blank=True)
|
|
25
|
+
date_of_birth = models.DateField(null=True, blank=True)
|
|
26
|
+
# Stored in R2 when it's configured (STORAGES in settings), on disk in development.
|
|
27
|
+
profile_picture = models.ImageField(upload_to='profile_pics/', null=True, blank=True)
|
|
28
|
+
is_verified = models.BooleanField(default=False)
|
|
29
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
30
|
+
updated_at = models.DateTimeField(auto_now=True)
|
|
31
|
+
|
|
32
|
+
objects = UserManager()
|
|
33
|
+
|
|
34
|
+
USERNAME_FIELD = 'email'
|
|
35
|
+
REQUIRED_FIELDS = ['username']
|
|
36
|
+
|
|
37
|
+
class Meta:
|
|
38
|
+
verbose_name = 'User'
|
|
39
|
+
verbose_name_plural = 'Users'
|
|
40
|
+
constraints = [
|
|
41
|
+
# Belt and braces with the lowercase normalization: no two accounts differ only by case.
|
|
42
|
+
models.UniqueConstraint(Lower('email'), name='authentication_user_email_ci_unique'),
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
def save(self, *args, **kwargs):
|
|
46
|
+
self.email = normalize_email(self.email)
|
|
47
|
+
super().save(*args, **kwargs)
|
|
48
|
+
|
|
49
|
+
def __str__(self):
|
|
50
|
+
return self.email
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def full_name(self) -> str:
|
|
54
|
+
return f"{self.first_name} {self.last_name}".strip()
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from rest_framework import serializers
|
|
2
2
|
from django.contrib.auth import authenticate
|
|
3
3
|
from django.contrib.auth.password_validation import validate_password
|
|
4
|
-
from .models import User
|
|
4
|
+
from .models import User, normalize_email
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
class UserRegistrationSerializer(serializers.ModelSerializer):
|
|
@@ -13,6 +13,13 @@ class UserRegistrationSerializer(serializers.ModelSerializer):
|
|
|
13
13
|
model = User
|
|
14
14
|
fields = ('email', 'username', 'password', 'password_confirm', 'first_name', 'last_name')
|
|
15
15
|
|
|
16
|
+
def validate_email(self, value):
|
|
17
|
+
# Case-insensitive, like the database constraint: A@x.com is already taken by a@x.com.
|
|
18
|
+
value = normalize_email(value)
|
|
19
|
+
if User.objects.filter(email=value).exists():
|
|
20
|
+
raise serializers.ValidationError('An account with this email already exists.')
|
|
21
|
+
return value
|
|
22
|
+
|
|
16
23
|
def validate(self, attrs):
|
|
17
24
|
if attrs['password'] != attrs['password_confirm']:
|
|
18
25
|
raise serializers.ValidationError({'password_confirm': "Passwords don't match."})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from celery import shared_task
|
|
4
|
+
|
|
5
|
+
from core.services.azure.email.notifier import send_template_email
|
|
6
|
+
from .models import User
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@shared_task(bind=True, max_retries=3, default_retry_delay=60)
|
|
12
|
+
def send_password_reset_email(self, user_id, reset_url):
|
|
13
|
+
"""The password-reset email, sent by a worker so the request never waits on the mail server.
|
|
14
|
+
Retried a few times if sending fails (the link stays valid for PASSWORD_RESET_TIMEOUT)."""
|
|
15
|
+
user = User.objects.filter(pk=user_id, is_active=True).first()
|
|
16
|
+
if user is None:
|
|
17
|
+
return
|
|
18
|
+
try:
|
|
19
|
+
send_template_email(
|
|
20
|
+
subject='Reset your password',
|
|
21
|
+
to=user.email,
|
|
22
|
+
template='authentication/password_reset',
|
|
23
|
+
context={'user': user, 'reset_url': reset_url},
|
|
24
|
+
sender='no_reply',
|
|
25
|
+
)
|
|
26
|
+
except Exception as exc: # noqa: BLE001 - any mail failure: try again later
|
|
27
|
+
logger.warning('Password reset email to user %s failed: %s', user_id, exc)
|
|
28
|
+
raise self.retry(exc=exc)
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<p>Hi {{ user.first_name|default:user.email }},</p>
|
|
2
|
+
<p>Someone asked to reset the password for your account. If it was you, choose a new password here:</p>
|
|
3
|
+
<p><a href="{{ reset_url }}">Choose a new password</a></p>
|
|
4
|
+
<p>The link works once and expires in a few days. If you didn't ask for this, ignore this email - your
|
|
5
|
+
password stays as it is.</p>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{% autoescape off %}{# plain text: no HTML escaping, or the link shows & #}Hi {{ user.first_name|default:user.email }},
|
|
2
|
+
|
|
3
|
+
Someone asked to reset the password for your account. If it was you, choose a new password here:
|
|
4
|
+
|
|
5
|
+
{{ reset_url }}
|
|
6
|
+
|
|
7
|
+
The link works once and expires in a few days. If you didn't ask for this, ignore this email - your
|
|
8
|
+
password stays as it is.
|
|
9
|
+
{% endautoescape %}
|
|
@@ -104,3 +104,54 @@ def test_sign_in_is_rate_limited_with_a_readable_429(client, user, monkeypatch):
|
|
|
104
104
|
assert response.status_code == 429
|
|
105
105
|
assert response.data['code'] == 'throttled'
|
|
106
106
|
assert 'Retry-After' in response
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_the_client_gets_the_session_inside_the_envelope(client, user):
|
|
110
|
+
body = login(client).json()
|
|
111
|
+
assert body['status'] == 'success'
|
|
112
|
+
assert body['data']['access']
|
|
113
|
+
assert body['data']['user']['email'] == 'ada@example.com'
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_email_is_case_insensitive(client, user):
|
|
117
|
+
response = client.post('/api/v1/auth/register/', {
|
|
118
|
+
'email': ' ADA@Example.com', 'username': 'ada2', 'password': PASSWORD, 'password_confirm': PASSWORD,
|
|
119
|
+
}, format='json')
|
|
120
|
+
assert response.status_code == 400
|
|
121
|
+
assert 'email' in response.json()['errors']
|
|
122
|
+
signed_in = client.post('/api/v1/auth/login/', {'email': 'Ada@EXAMPLE.com', 'password': PASSWORD}, format='json')
|
|
123
|
+
assert signed_in.status_code == 200
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_registration_stores_the_email_lowercase(client, db):
|
|
127
|
+
client.post('/api/v1/auth/register/', {
|
|
128
|
+
'email': 'Grace@Example.com', 'username': 'grace', 'password': PASSWORD, 'password_confirm': PASSWORD,
|
|
129
|
+
}, format='json')
|
|
130
|
+
assert User.objects.filter(email='grace@example.com').exists()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_changing_the_password_signs_out_other_sessions_but_not_this_one(client, user):
|
|
134
|
+
other_device = login(APIClient()).cookies[settings.AUTH_REFRESH_COOKIE].value
|
|
135
|
+
access = login(client).data['access']
|
|
136
|
+
client.credentials(HTTP_AUTHORIZATION=f'Bearer {access}')
|
|
137
|
+
new_password = PASSWORD + '-new'
|
|
138
|
+
response = client.post('/api/v1/auth/change-password/', {
|
|
139
|
+
'old_password': PASSWORD, 'new_password': new_password, 'new_password_confirm': new_password,
|
|
140
|
+
}, format='json')
|
|
141
|
+
assert response.status_code == 200
|
|
142
|
+
assert response.data['access'] # this device stays signed in, with a fresh session
|
|
143
|
+
client.credentials()
|
|
144
|
+
assert client.post('/api/v1/auth/refresh/').status_code == 200
|
|
145
|
+
stolen = APIClient().post('/api/v1/auth/refresh/', {'refresh': other_device}, format='json')
|
|
146
|
+
assert stolen.status_code == 401
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_resetting_the_password_signs_out_every_session(client, user):
|
|
150
|
+
old_session = login(APIClient()).cookies[settings.AUTH_REFRESH_COOKIE].value
|
|
151
|
+
client.post('/api/v1/auth/reset-password/', {'email': 'ada@example.com'}, format='json')
|
|
152
|
+
uid, token = re.search(r'uid=([^&\s]+)&token=([^\s]+)', mail.outbox[0].body).groups()
|
|
153
|
+
new_password = PASSWORD + '-new'
|
|
154
|
+
client.post('/api/v1/auth/reset-password-confirm/', {
|
|
155
|
+
'uid': uid, 'token': token, 'new_password': new_password, 'new_password_confirm': new_password,
|
|
156
|
+
}, format='json')
|
|
157
|
+
assert client.post('/api/v1/auth/refresh/', {'refresh': old_session}, format='json').status_code == 401
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
1
3
|
from django.conf import settings
|
|
2
4
|
from django.contrib.auth.models import update_last_login
|
|
3
5
|
from django.contrib.auth.tokens import default_token_generator
|
|
4
|
-
from django.core.mail import send_mail
|
|
5
6
|
from django.utils.encoding import force_bytes, force_str
|
|
6
7
|
from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
|
|
7
8
|
from drf_spectacular.utils import extend_schema, inline_serializer
|
|
@@ -16,11 +17,14 @@ from rest_framework_simplejwt.tokens import RefreshToken
|
|
|
16
17
|
|
|
17
18
|
from core.response_wrapper import error_response
|
|
18
19
|
from .models import User
|
|
20
|
+
from .tasks import send_password_reset_email
|
|
19
21
|
from .serializers import (
|
|
20
22
|
ChangePasswordSerializer, ResetPasswordConfirmSerializer, ResetPasswordSerializer,
|
|
21
23
|
UpdateProfileSerializer, UserLoginSerializer, UserProfileSerializer, UserRegistrationSerializer,
|
|
22
24
|
)
|
|
23
25
|
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
24
28
|
|
|
25
29
|
class AuthRateThrottle(AnonRateThrottle):
|
|
26
30
|
"""Anonymous sign-in, registration and reset attempts per IP (THROTTLE_AUTH): against password guessing."""
|
|
@@ -162,16 +166,26 @@ def update_profile(request):
|
|
|
162
166
|
}, status=status.HTTP_200_OK)
|
|
163
167
|
|
|
164
168
|
|
|
165
|
-
|
|
169
|
+
def revoke_all_sessions(user):
|
|
170
|
+
"""Blacklist every refresh token the user has, so each session ends at its next refresh. Called
|
|
171
|
+
when the password changes: a stolen refresh token must not outlive the password it came with."""
|
|
172
|
+
from rest_framework_simplejwt.token_blacklist.models import BlacklistedToken, OutstandingToken
|
|
173
|
+
tokens = OutstandingToken.objects.filter(user=user).exclude(blacklistedtoken__isnull=False)
|
|
174
|
+
BlacklistedToken.objects.bulk_create([BlacklistedToken(token=t) for t in tokens], ignore_conflicts=True)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@extend_schema(request=ChangePasswordSerializer, responses=SessionSerializer)
|
|
166
178
|
@api_view(['POST'])
|
|
167
179
|
@permission_classes([IsAuthenticated])
|
|
168
180
|
def change_password(request):
|
|
169
|
-
"""Change the password, given the current one.
|
|
181
|
+
"""Change the password, given the current one. Every other session is signed out; this one gets
|
|
182
|
+
a fresh session so the user stays signed in here."""
|
|
170
183
|
serializer = ChangePasswordSerializer(data=request.data, context={'request': request})
|
|
171
184
|
serializer.is_valid(raise_exception=True)
|
|
172
185
|
request.user.set_password(serializer.validated_data['new_password'])
|
|
173
186
|
request.user.save(update_fields=['password'])
|
|
174
|
-
|
|
187
|
+
revoke_all_sessions(request.user)
|
|
188
|
+
return _session(request.user, 'Password changed successfully')
|
|
175
189
|
|
|
176
190
|
|
|
177
191
|
@extend_schema(request=ResetPasswordSerializer, responses=MessageSerializer)
|
|
@@ -188,13 +202,12 @@ def reset_password(request):
|
|
|
188
202
|
uid = urlsafe_base64_encode(force_bytes(user.pk))
|
|
189
203
|
token = default_token_generator.make_token(user)
|
|
190
204
|
reset_url = f"{settings.FRONTEND_URL}/reset-password-confirm?uid={uid}&token={token}"
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
)
|
|
205
|
+
# Sent by a worker (tasks.py): the request doesn't wait on the mail server, and neither a
|
|
206
|
+
# mail outage nor an unreachable queue turns this into a 500 - the answer is the same either way.
|
|
207
|
+
try:
|
|
208
|
+
send_password_reset_email.delay(user.pk, reset_url)
|
|
209
|
+
except Exception: # noqa: BLE001
|
|
210
|
+
logger.exception('Could not queue the password reset email for user %s', user.pk)
|
|
198
211
|
return Response({'message': 'If that email has an account, a reset link is on its way.'},
|
|
199
212
|
status=status.HTTP_200_OK)
|
|
200
213
|
|
|
@@ -215,4 +228,5 @@ def reset_password_confirm(request):
|
|
|
215
228
|
return error_response('This link is invalid or has expired. Please request a new one.', 400)
|
|
216
229
|
user.set_password(serializer.validated_data['new_password'])
|
|
217
230
|
user.save(update_fields=['password'])
|
|
231
|
+
revoke_all_sessions(user) # whoever had the old password is signed out everywhere
|
|
218
232
|
return Response({'message': 'Password reset successful'}, status=status.HTTP_200_OK)
|
|
@@ -293,7 +293,9 @@ def get_pip_path(env_dir):
|
|
|
293
293
|
|
|
294
294
|
# The initial migration ships with the app, so a new project's first `migrate` creates the users
|
|
295
295
|
# table - no makemigrations step to know about.
|
|
296
|
-
AUTH_APP_FILES = ('apps.py', 'models.py', 'serializers.py', 'views.py', 'urls.py', 'admin.py', 'tests.py',
|
|
296
|
+
AUTH_APP_FILES = ('apps.py', 'models.py', 'serializers.py', 'views.py', 'urls.py', 'admin.py', 'tests.py', 'tasks.py',
|
|
297
|
+
os.path.join('templates', 'authentication', 'password_reset.html'),
|
|
298
|
+
os.path.join('templates', 'authentication', 'password_reset.txt'),
|
|
297
299
|
os.path.join('migrations', '0001_initial.py'))
|
|
298
300
|
|
|
299
301
|
|
|
@@ -335,7 +337,9 @@ def _write_auth_app(project_root):
|
|
|
335
337
|
raise FileNotFoundError(f"Authentication template missing: {source}")
|
|
336
338
|
with open(source, "r", encoding="utf-8") as f:
|
|
337
339
|
content = f.read()
|
|
338
|
-
|
|
340
|
+
destination = os.path.join(app_dir, name)
|
|
341
|
+
os.makedirs(os.path.dirname(destination), exist_ok=True)
|
|
342
|
+
with open(destination, "w", encoding="utf-8") as f:
|
|
339
343
|
f.write(content)
|
|
340
344
|
|
|
341
345
|
|
|
@@ -23,6 +23,16 @@
|
|
|
23
23
|
"ruff.toml": {
|
|
24
24
|
"template": "templates/root/ruff_toml.txt"
|
|
25
25
|
},
|
|
26
|
+
".pre-commit-config.yaml": {
|
|
27
|
+
"template": "templates/ci/pre_commit.yaml"
|
|
28
|
+
},
|
|
29
|
+
".github": {
|
|
30
|
+
"workflows": {
|
|
31
|
+
"ci.yml": {
|
|
32
|
+
"template": "templates/ci/github_ci.yml"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
},
|
|
26
36
|
"CLAUDE.md": {
|
|
27
37
|
"template": "templates/CLAUDE.md"
|
|
28
38
|
},
|
|
@@ -83,6 +93,9 @@
|
|
|
83
93
|
},
|
|
84
94
|
"tests": {
|
|
85
95
|
"__init__.py": ""
|
|
96
|
+
},
|
|
97
|
+
"checks.py": {
|
|
98
|
+
"template": "templates/apps/common_checks.py"
|
|
86
99
|
}
|
|
87
100
|
}
|
|
88
101
|
},
|
|
@@ -162,6 +175,18 @@
|
|
|
162
175
|
"logger.py": {
|
|
163
176
|
"template": "templates/core/logger.py"
|
|
164
177
|
}
|
|
178
|
+
},
|
|
179
|
+
"renderers.py": {
|
|
180
|
+
"template": "templates/core/renderers.py"
|
|
181
|
+
},
|
|
182
|
+
"schema.py": {
|
|
183
|
+
"template": "templates/core/schema.py"
|
|
184
|
+
},
|
|
185
|
+
"health.py": {
|
|
186
|
+
"template": "templates/core/health.py"
|
|
187
|
+
},
|
|
188
|
+
"tests.py": {
|
|
189
|
+
"template": "templates/core/tests.py"
|
|
165
190
|
}
|
|
166
191
|
},
|
|
167
192
|
"{project_name}": {
|
|
@@ -199,4 +224,4 @@
|
|
|
199
224
|
}
|
|
200
225
|
}
|
|
201
226
|
}
|
|
202
|
-
}
|
|
227
|
+
}
|
|
@@ -9,7 +9,8 @@ QUEUES="${CELERY_QUEUES:-default}"
|
|
|
9
9
|
echo "Starting Celery worker (queues: $QUEUES) and beat..."
|
|
10
10
|
celery -A {project_name} worker -l INFO -Q "$QUEUES" --concurrency "${CELERY_CONCURRENCY:-2}" &
|
|
11
11
|
WORKER_PID=$!
|
|
12
|
-
|
|
12
|
+
# The schedule is in the database (CELERY_BEAT_SCHEDULER in settings: django_celery_beat).
|
|
13
|
+
celery -A {project_name} beat -l INFO &
|
|
13
14
|
BEAT_PID=$!
|
|
14
15
|
|
|
15
16
|
shutdown() {
|
|
@@ -168,7 +168,7 @@ from core.services.object_storage import ObjectStorageService
|
|
|
168
168
|
return success_response(data={"id": 123}, message="Created successfully")
|
|
169
169
|
|
|
170
170
|
# Error response
|
|
171
|
-
return error_response(message="Invalid input",
|
|
171
|
+
return error_response(message="Invalid input", status=400)
|
|
172
172
|
|
|
173
173
|
# Send email
|
|
174
174
|
send_template_email(
|
|
@@ -195,3 +195,21 @@ public_url, key = storage.upload_file(uploaded_file, "path/to/file")
|
|
|
195
195
|
|
|
196
196
|
**Last Updated**: 2026-05-09
|
|
197
197
|
**Template Version**: Enhanced with Docker, Azure Email, Sentry, and R2 Storage
|
|
198
|
+
|
|
199
|
+
## API conventions and built-ins
|
|
200
|
+
|
|
201
|
+
- **One response shape.** Every success is `{"status": "success", "message", "data"}` - a plain `Response(data)`,
|
|
202
|
+
serializer data and paginated lists included (`core/renderers.py`). Errors are
|
|
203
|
+
`{"status": "error", "message", "code"?, "errors"?}` (`core/exceptions/handler.py`). In both helpers `status=` is
|
|
204
|
+
the HTTP status; `code=` is only the machine-readable reason in an error body.
|
|
205
|
+
- **Health check:** `GET /api/v1/health/` - database and Redis reachability, for the platform's probe.
|
|
206
|
+
- **API docs:** `/api/v1/docs/` is public in development only; in production staff sign in at `/admin/` first, or set
|
|
207
|
+
`API_DOCS_PUBLIC=True`.
|
|
208
|
+
- **Uploaded files** (`FileField`/`ImageField`) go to R2 once the `R2_*` variables are set (`STORAGES`); without them
|
|
209
|
+
they are kept on the server's disk and `manage.py check` warns (they would be lost on redeploy).
|
|
210
|
+
- **Email:** send with `core.services.azure.email.notifier.send_template_email` from a Celery task (see
|
|
211
|
+
`apps/authentication/tasks.py`), never inside the request.
|
|
212
|
+
- **Periodic jobs:** `CELERY_BEAT_SCHEDULE` is loaded into the database scheduler (`django_celery_beat`); jobs can also
|
|
213
|
+
be added or paused in the admin.
|
|
214
|
+
- **Checks:** `ruff check .`, `pytest`, `python manage.py makemigrations --check` - the same as CI
|
|
215
|
+
(`.github/workflows/ci.yml`) and the pre-commit hook.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""System checks that run on `manage.py check`, `migrate` and at startup."""
|
|
2
|
+
from django.conf import settings
|
|
3
|
+
from django.core.checks import Warning, register
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@register()
|
|
7
|
+
def media_storage(app_configs, **kwargs):
|
|
8
|
+
"""Uploaded files (e.g. profile pictures) need R2 outside development: a container's own disk is
|
|
9
|
+
wiped on every deploy, isn't shared between instances, and nginx doesn't serve /media/."""
|
|
10
|
+
if settings.DEBUG or settings.MEDIA_IN_R2:
|
|
11
|
+
return []
|
|
12
|
+
return [Warning(
|
|
13
|
+
'Uploaded files are being stored on this server\'s disk.',
|
|
14
|
+
hint='Set R2_ACCOUNT_ID (or R2_ENDPOINT), R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET and '
|
|
15
|
+
'R2_PUBLIC_BASE_URL, or uploads will be lost on the next deploy and return 404 meanwhile.',
|
|
16
|
+
id='common.W001',
|
|
17
|
+
)]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Lint, tests and Django's checks on every push and pull request.
|
|
2
|
+
name: CI
|
|
3
|
+
|
|
4
|
+
on:
|
|
5
|
+
push:
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
env:
|
|
12
|
+
DJANGO_SETTINGS_MODULE: {project_name}.settings.test
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
- uses: actions/setup-python@v5
|
|
16
|
+
with:
|
|
17
|
+
python-version: "3.12"
|
|
18
|
+
cache: pip
|
|
19
|
+
cache-dependency-path: requirements*.txt
|
|
20
|
+
- run: pip install -r requirements-dev.txt
|
|
21
|
+
- name: Lint
|
|
22
|
+
run: ruff check .
|
|
23
|
+
- name: Django checks
|
|
24
|
+
run: python manage.py check
|
|
25
|
+
- name: Migrations are up to date
|
|
26
|
+
run: python manage.py makemigrations --check --dry-run
|
|
27
|
+
- name: Tests
|
|
28
|
+
run: pytest
|
|
@@ -17,9 +17,8 @@ def exception_handler(exc, context):
|
|
|
17
17
|
response = error_response(
|
|
18
18
|
f'Too many attempts. Please wait {wait} seconds and try again.' if wait
|
|
19
19
|
else 'Too many attempts. Please try again shortly.',
|
|
20
|
-
429,
|
|
20
|
+
status=429, code='throttled',
|
|
21
21
|
)
|
|
22
|
-
response.data['code'] = 'throttled'
|
|
23
22
|
if wait:
|
|
24
23
|
response['Retry-After'] = str(wait)
|
|
25
24
|
return response
|
|
@@ -30,13 +29,11 @@ def exception_handler(exc, context):
|
|
|
30
29
|
|
|
31
30
|
data = response.data
|
|
32
31
|
if isinstance(data, dict) and set(data) == {'detail'}:
|
|
33
|
-
wrapped = error_response(str(data['detail']), response.status_code
|
|
34
|
-
|
|
35
|
-
if code:
|
|
36
|
-
wrapped.data['code'] = code
|
|
32
|
+
wrapped = error_response(str(data['detail']), status=response.status_code,
|
|
33
|
+
code=getattr(data['detail'], 'code', None))
|
|
37
34
|
else:
|
|
38
|
-
wrapped = error_response('Please check the highlighted fields.', response.status_code,
|
|
39
|
-
|
|
35
|
+
wrapped = error_response('Please check the highlighted fields.', status=response.status_code,
|
|
36
|
+
errors=data, code='validation_error')
|
|
40
37
|
for header, value in response.items(): # keep WWW-Authenticate, Allow, ...
|
|
41
38
|
wrapped[header] = value
|
|
42
39
|
return wrapped
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""GET /api/v1/health/ - can the backend reach its database and Redis?
|
|
2
|
+
|
|
3
|
+
200 when both answer, 503 otherwise, with a plain reason per service (wrong password, host not found,
|
|
4
|
+
timed out...). For the platform's health check and uptime monitors. It names no hosts, ports or
|
|
5
|
+
credentials, so it is safe to leave public.
|
|
6
|
+
"""
|
|
7
|
+
import socket
|
|
8
|
+
|
|
9
|
+
import redis
|
|
10
|
+
from django.conf import settings
|
|
11
|
+
from django.db import connection
|
|
12
|
+
from drf_spectacular.utils import extend_schema, inline_serializer
|
|
13
|
+
from rest_framework import serializers
|
|
14
|
+
from rest_framework.decorators import api_view, authentication_classes, permission_classes, throttle_classes
|
|
15
|
+
from rest_framework.permissions import AllowAny
|
|
16
|
+
|
|
17
|
+
from core.response_wrapper import error_response, success_response
|
|
18
|
+
|
|
19
|
+
ServiceSerializer = inline_serializer('HealthService', {'ok': serializers.BooleanField(),
|
|
20
|
+
'problem': serializers.CharField(allow_blank=True)})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _database():
|
|
24
|
+
try:
|
|
25
|
+
with connection.cursor() as cursor:
|
|
26
|
+
cursor.execute('SELECT 1')
|
|
27
|
+
return {'ok': True, 'problem': ''}
|
|
28
|
+
except Exception as exc: # noqa: BLE001
|
|
29
|
+
return {'ok': False, 'problem': f'cannot query the database ({type(exc).__name__})'}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _redis():
|
|
33
|
+
try:
|
|
34
|
+
redis.Redis.from_url(settings.REDIS_URL, socket_connect_timeout=3, socket_timeout=3).ping()
|
|
35
|
+
return {'ok': True, 'problem': ''}
|
|
36
|
+
except redis.exceptions.AuthenticationError:
|
|
37
|
+
problem = 'Redis refused the user or password (check REDIS_USER and REDIS_PASSWORD)'
|
|
38
|
+
except redis.exceptions.ResponseError as exc:
|
|
39
|
+
problem = 'Redis needs a password (set REDIS_PASSWORD)' if 'NOAUTH' in str(exc) else 'Redis answered with an error'
|
|
40
|
+
except redis.exceptions.TimeoutError:
|
|
41
|
+
problem = 'timed out: nothing answered at REDIS_HOST:REDIS_PORT'
|
|
42
|
+
except redis.exceptions.ConnectionError as exc:
|
|
43
|
+
text = str(exc)
|
|
44
|
+
if isinstance(exc.__context__, socket.gaierror) or 'getaddrinfo' in text or 'Name or service not known' in text:
|
|
45
|
+
problem = 'host name not found (check REDIS_HOST)'
|
|
46
|
+
elif 'refused' in text.lower():
|
|
47
|
+
problem = 'connection refused (check REDIS_HOST and REDIS_PORT)'
|
|
48
|
+
else:
|
|
49
|
+
problem = 'cannot connect'
|
|
50
|
+
except Exception as exc: # noqa: BLE001
|
|
51
|
+
problem = f'unexpected error ({type(exc).__name__})'
|
|
52
|
+
return {'ok': False, 'problem': problem}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@extend_schema(responses=inline_serializer('Health', {'ok': serializers.BooleanField(),
|
|
56
|
+
'database': ServiceSerializer, 'redis': ServiceSerializer}),
|
|
57
|
+
auth=[])
|
|
58
|
+
@api_view(['GET'])
|
|
59
|
+
@authentication_classes([])
|
|
60
|
+
@permission_classes([AllowAny])
|
|
61
|
+
@throttle_classes([])
|
|
62
|
+
def health(request):
|
|
63
|
+
"""Database and Redis reachability, for deploy checks and uptime monitors."""
|
|
64
|
+
data = {'database': _database(), 'redis': _redis()}
|
|
65
|
+
data['ok'] = data['database']['ok'] and data['redis']['ok']
|
|
66
|
+
if data['ok']:
|
|
67
|
+
return success_response(data, 'Healthy')
|
|
68
|
+
return error_response('The backend cannot reach a service it needs.', status=503, errors=data, code='unhealthy')
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from rest_framework.renderers import JSONRenderer
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class EnvelopeJSONRenderer(JSONRenderer):
|
|
5
|
+
"""Wraps every successful response as {"status": "success", "message": ..., "data": ...}, the shape
|
|
6
|
+
success_response produces, so a plain Response(...), a serializer's data and a paginated list all
|
|
7
|
+
reach the client in one format. Responses already built by success_response / error_response (or
|
|
8
|
+
the exception handler) are marked `_enveloped` and pass through untouched."""
|
|
9
|
+
|
|
10
|
+
def render(self, data, accepted_media_type=None, renderer_context=None):
|
|
11
|
+
response = (renderer_context or {}).get('response')
|
|
12
|
+
if response is not None and not getattr(response, '_enveloped', False) and response.status_code < 400:
|
|
13
|
+
if response.status_code != 204:
|
|
14
|
+
data = {'status': 'success', 'message': 'Success', 'data': data}
|
|
15
|
+
return super().render(data, accepted_media_type, renderer_context)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""The API's one response shape.
|
|
2
|
+
|
|
3
|
+
success: {"status": "success", "message": str, "data": ...}
|
|
4
|
+
error: {"status": "error", "message": str, "code"?: str, "errors"?: {...}}
|
|
5
|
+
|
|
6
|
+
`status=` is always the HTTP status; `code=` is always the short machine-readable reason in the body
|
|
7
|
+
(e.g. "throttled", "validation_error"). A plain Response(...) gets the success shape from
|
|
8
|
+
core.renderers.EnvelopeJSONRenderer, so these helpers are for setting the message or the status.
|
|
9
|
+
"""
|
|
10
|
+
from rest_framework.response import Response
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def success_response(data=None, message='Success', status=200):
|
|
14
|
+
response = Response({'status': 'success', 'message': message, 'data': data}, status=status)
|
|
15
|
+
response._enveloped = True
|
|
16
|
+
return response
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def error_response(message='Error', status=400, errors=None, code=None):
|
|
20
|
+
body = {'status': 'error', 'message': message}
|
|
21
|
+
if code is not None:
|
|
22
|
+
body['code'] = code
|
|
23
|
+
if errors is not None:
|
|
24
|
+
body['errors'] = errors
|
|
25
|
+
response = Response(body, status=status)
|
|
26
|
+
response._enveloped = True
|
|
27
|
+
return response
|