first commit
This commit is contained in:
28
.cruft.json
Normal file
28
.cruft.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"template": "https://github.com/JscorpTech/django",
|
||||
"commit": "38afe9dd67ae4b080fcc6e5da47b494c448214b6",
|
||||
"checkout": null,
|
||||
"context": {
|
||||
"cookiecutter": {
|
||||
"cacheops": true,
|
||||
"silk": true,
|
||||
"storage": true,
|
||||
"channels": true,
|
||||
"ckeditor": true,
|
||||
"modeltranslation": true,
|
||||
"parler": false,
|
||||
"rosetta": false,
|
||||
"project_name": "uzxarid",
|
||||
"settings_module": "config.settings.local",
|
||||
"runner": "wsgi",
|
||||
"script": "entrypoint.sh",
|
||||
"key": "django-insecure-change-this-in-production",
|
||||
"port": "8081",
|
||||
"phone": "998000000000",
|
||||
"password": "admin123",
|
||||
"max_line_length": "120",
|
||||
"project_slug": "uzxarid"
|
||||
}
|
||||
},
|
||||
"directory": null
|
||||
}
|
||||
2
.dockerignore
Normal file
2
.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
venv/
|
||||
resources/staticfiles/
|
||||
74
.env.example
Normal file
74
.env.example
Normal file
@@ -0,0 +1,74 @@
|
||||
# Django configs
|
||||
# WARNING: Change DJANGO_SECRET_KEY in production! Use a long, random string.
|
||||
DJANGO_SECRET_KEY=django-insecure-change-this-in-production
|
||||
DEBUG=True
|
||||
DJANGO_SETTINGS_MODULE=config.settings.local
|
||||
COMMAND=sh ./resources/scripts/entrypoint.sh
|
||||
PORT=8081
|
||||
#! debug | prod
|
||||
PROJECT_ENV=debug
|
||||
PROTOCOL_HTTPS=False
|
||||
SCRIPT=entrypoint.sh
|
||||
|
||||
# OTP configs
|
||||
OTP_SIZE=4
|
||||
OTP_PROD=false
|
||||
OTP_DEFAULT=1111
|
||||
|
||||
# Database configs
|
||||
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases
|
||||
# WARNING: Change DB_PASSWORD in production! Use a strong, unique password.
|
||||
DB_ENGINE=django.db.backends.postgresql_psycopg2
|
||||
DB_NAME=django
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=2309
|
||||
DB_HOST=db
|
||||
DB_PORT=5432
|
||||
|
||||
# Cache
|
||||
CACHE_BACKEND=django.core.cache.backends.redis.RedisCache
|
||||
REDIS_URL=redis://redis:6379
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
|
||||
|
||||
CACHE_ENABLED=False
|
||||
|
||||
CACHE_TIMEOUT=120
|
||||
|
||||
# Vite settings
|
||||
VITE_LIVE=False
|
||||
VITE_PORT=5173
|
||||
VITE_HOST=127.0.0.1
|
||||
|
||||
# Sms service
|
||||
SMS_API_URL=https://notify.eskiz.uz/api
|
||||
SMS_LOGIN=admin@gmail.com
|
||||
SMS_PASSWORD=key
|
||||
|
||||
# Addition
|
||||
|
||||
ALLOWED_HOSTS=127.0.0.1,web
|
||||
CSRF_TRUSTED_ORIGINS=http://127.0.0.1:8081
|
||||
|
||||
|
||||
OTP_MODULE=core.services.otp
|
||||
OTP_SERVICE=EskizService
|
||||
|
||||
|
||||
# Storage
|
||||
STORAGE_ID=id
|
||||
STORAGE_KEY=key
|
||||
STORAGE_URL=example.com
|
||||
|
||||
#! MINIO | AWS | FILE
|
||||
STORAGE_DEFAULT=FILE
|
||||
|
||||
#! MINIO | AWS | STATIC
|
||||
STORAGE_STATIC=STATIC
|
||||
|
||||
STORAGE_BUCKET_MEDIA=name
|
||||
STORAGE_BUCKET_STATIC=name
|
||||
STORAGE_PATH=127.0.0.1:8081/bucket/
|
||||
STORAGE_PROTOCOL=http:
|
||||
|
||||
3
.flake8
Normal file
3
.flake8
Normal file
@@ -0,0 +1,3 @@
|
||||
[flake8]
|
||||
max-line-length = 120
|
||||
ignore = E701, E704, W503
|
||||
158
.gitignore
vendored
Normal file
158
.gitignore
vendored
Normal file
@@ -0,0 +1,158 @@
|
||||
node_modules
|
||||
|
||||
# OS ignores
|
||||
*.DS_Store
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
poetry.lock
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
.idea/
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode
|
||||
188
Jenkinsfile
vendored
Normal file
188
Jenkinsfile
vendored
Normal file
@@ -0,0 +1,188 @@
|
||||
pipeline {
|
||||
agent any
|
||||
|
||||
environment {
|
||||
PROD_ENV = "/opt/env/.env.uzxarid"
|
||||
IMAGE_NAME = "uzxarid"
|
||||
TEST_TAG = "test"
|
||||
PROD_TAG = "latest"
|
||||
CONTAINER_DB = "uzxarid_db_test"
|
||||
CONTAINER_WEB = "uzxarid_web_test"
|
||||
CONTAINER_REDIS = "uzxarid_redis_test"
|
||||
STACK_NAME = "uzxarid"
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Check Commit Message') {
|
||||
steps {
|
||||
script {
|
||||
def commitMsg = sh(
|
||||
script: "git log -1 --pretty=%B",
|
||||
returnStdout: true
|
||||
).trim()
|
||||
|
||||
if (commitMsg.contains("[ci skip]")) {
|
||||
echo "Commit message contains [ci skip], aborting pipeline 🚫"
|
||||
currentBuild.result = 'ABORTED'
|
||||
error("Pipeline aborted because of [ci skip]")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
stage('Checkout Code') {
|
||||
steps {
|
||||
git branch: 'main', credentialsId: 'ssh', url: 'git@github.com:JscorpTech/uzxarid.git'
|
||||
}
|
||||
}
|
||||
stage('Prepare') {
|
||||
steps {
|
||||
script {
|
||||
env.GIT_MESSAGE = sh(
|
||||
script: "git log -1 --pretty=%B ${env.GIT_COMMIT}",
|
||||
returnStdout: true
|
||||
).trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
stage("Update files") {
|
||||
when {
|
||||
expression { currentBuild.currentResult == "SUCCESS" }
|
||||
}
|
||||
steps {
|
||||
withCredentials([usernamePassword(credentialsId: 'dockerhub', usernameVariable: 'DOCKER_USER', passwordVariable: 'DOCKER_PASS')]) {
|
||||
sh """
|
||||
sed -i 's|image: ${DOCKER_USER}/${IMAGE_NAME}:.*|image: ${DOCKER_USER}/${IMAGE_NAME}:${BUILD_NUMBER}|' stack.yaml
|
||||
sed -i 's/return HttpResponse("OK.*"/return HttpResponse("OK: #${GIT_COMMIT}"/' config/urls.py
|
||||
"""
|
||||
// git config --global user.email "admin@jscorp.uz"
|
||||
// git config --global user.name "Jenkins"
|
||||
// if ! git diff --quiet stack.yaml; then
|
||||
// git add stack.yaml
|
||||
// git commit -m "feat(swarm) Update image tag to ${BUILD_NUMBER} [ci skip]"
|
||||
// git push origin main
|
||||
// else
|
||||
// echo "No changes in stack.yaml"
|
||||
// fi
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
stage('Build Image') {
|
||||
steps {
|
||||
sh '''
|
||||
if [ -e ${PROD_ENV} ]; then
|
||||
echo env exists
|
||||
else
|
||||
mkdir -p $(dirname ${PROD_ENV})
|
||||
cp ./.env.example ${PROD_ENV}
|
||||
fi
|
||||
cp ${PROD_ENV} ./.env
|
||||
'''
|
||||
sh """
|
||||
docker build -t ${IMAGE_NAME}:${PROD_TAG} --build-arg SCRIPT=entrypoint-server.sh -f ./docker/Dockerfile.web .
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
stage('Start Test DB') {
|
||||
steps {
|
||||
sh """
|
||||
docker run -d --rm --name ${CONTAINER_DB} -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=testdb postgres:16
|
||||
docker run -d --rm --name ${CONTAINER_REDIS} redis
|
||||
echo "⏳ Waiting for database..."
|
||||
for i in {1..30}; do
|
||||
if docker exec ${CONTAINER_DB} pg_isready -U postgres >/dev/null 2>&1; then
|
||||
echo "✅ Database ready"
|
||||
break
|
||||
fi
|
||||
echo "Database not ready yet... retrying..."
|
||||
sleep 2
|
||||
done
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
stage('Run Migrations & Tests') {
|
||||
steps {
|
||||
sh """
|
||||
docker run --rm --name ${CONTAINER_WEB} --link ${CONTAINER_DB}:db --link ${CONTAINER_REDIS}:redis \
|
||||
-e DB_HOST=db \
|
||||
-e DB_PORT=5432 \
|
||||
-e DB_NAME=testdb \
|
||||
-e DB_USER=postgres \
|
||||
-e DB_PASSWORD=postgres \
|
||||
-e DJANGO_SETTINGS_MODULE=config.settings.test \
|
||||
${IMAGE_NAME}:${PROD_TAG} \
|
||||
sh -c "python manage.py migrate && pytest -v"
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
stage('Publish to DockerHub') {
|
||||
when {
|
||||
expression { currentBuild.currentResult == "SUCCESS" }
|
||||
}
|
||||
steps {
|
||||
withCredentials([usernamePassword(credentialsId: 'dockerhub', usernameVariable: 'DOCKER_USER', passwordVariable: 'DOCKER_PASS')]) {
|
||||
sh '''
|
||||
echo "${DOCKER_PASS}" | docker login -u "${DOCKER_USER}" --password-stdin
|
||||
docker tag ${IMAGE_NAME}:${PROD_TAG} ${DOCKER_USER}/${IMAGE_NAME}:${BUILD_NUMBER}
|
||||
docker tag ${IMAGE_NAME}:${PROD_TAG} ${DOCKER_USER}/${IMAGE_NAME}:${PROD_TAG}
|
||||
docker push ${DOCKER_USER}/${IMAGE_NAME}:${BUILD_NUMBER}
|
||||
docker push ${DOCKER_USER}/${IMAGE_NAME}:${PROD_TAG}
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
stage('Deploy stack') {
|
||||
when {
|
||||
expression { currentBuild.currentResult == "SUCCESS" }
|
||||
}
|
||||
steps {
|
||||
withCredentials([usernamePassword(credentialsId: 'dockerhub', usernameVariable: 'DOCKER_USER', passwordVariable: 'DOCKER_PASS')]) {
|
||||
sh '''
|
||||
docker stack deploy -c stack.yaml ${STACK_NAME}
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
post {
|
||||
always {
|
||||
sh """
|
||||
docker stop ${CONTAINER_DB} || true
|
||||
docker stop ${CONTAINER_REDIS} || true
|
||||
"""
|
||||
echo "Pipeline finished: ${currentBuild.currentResult}"
|
||||
}
|
||||
|
||||
success {
|
||||
withCredentials([
|
||||
string(credentialsId: 'bot-token', variable: 'BOT_TOKEN'),
|
||||
string(credentialsId: 'chat-id', variable: 'CHAT_ID')
|
||||
]) {
|
||||
sh '''
|
||||
curl -s -X POST https://api.telegram.org/bot${BOT_TOKEN}/sendMessage \
|
||||
-d chat_id=${CHAT_ID} \
|
||||
-d text="✅ SUCCESS: ${JOB_NAME} #${BUILD_NUMBER}\nRepo: ${GIT_URL}\nBranch: ${GIT_BRANCH}\nCommit: ${GIT_COMMIT}\nMessage: ${GIT_MESSAGE}"
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
failure {
|
||||
withCredentials([
|
||||
string(credentialsId: 'bot-token', variable: 'BOT_TOKEN'),
|
||||
string(credentialsId: 'chat-id', variable: 'CHAT_ID')
|
||||
]) {
|
||||
sh '''
|
||||
curl -s -X POST https://api.telegram.org/bot${BOT_TOKEN}/sendMessage \
|
||||
-d chat_id=${CHAT_ID} \
|
||||
-d text="🚨 FAILED: ${JOB_NAME} #${BUILD_NUMBER}\nRepo: ${GIT_URL}\nBranch: ${GIT_BRANCH}\nCommit: ${GIT_COMMIT}\nMessage: ${GIT_MESSAGE}"
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
43
Makefile
Normal file
43
Makefile
Normal file
@@ -0,0 +1,43 @@
|
||||
|
||||
start: up seed
|
||||
|
||||
up:
|
||||
docker compose up -d
|
||||
|
||||
down:
|
||||
docker compose down
|
||||
|
||||
build:
|
||||
docker compose build
|
||||
|
||||
rebuild: down build up
|
||||
|
||||
deploy: down build up migrations
|
||||
|
||||
deploy-prod:
|
||||
docker compose -f docker-compose.prod.yml down
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
docker compose -f docker-compose.prod.yml exec web python manage.py makemigrations --noinput
|
||||
docker compose -f docker-compose.prod.yml exec web python manage.py migrate
|
||||
|
||||
logs:
|
||||
docker compose logs -f
|
||||
|
||||
makemigrations:
|
||||
docker compose exec web python manage.py makemigrations --noinput
|
||||
|
||||
migrate:
|
||||
docker compose exec web python manage.py migrate
|
||||
|
||||
seed:
|
||||
docker compose exec web python manage.py seed
|
||||
|
||||
reset_db:
|
||||
docker compose exec web python manage.py reset_db --no-input
|
||||
|
||||
migrations: makemigrations migrate
|
||||
|
||||
fresh: reset_db migrations seed
|
||||
|
||||
test:
|
||||
docker compose exec web pytest -v
|
||||
246
README.MD
Normal file
246
README.MD
Normal file
@@ -0,0 +1,246 @@
|
||||
# JST-Django Template Documentation
|
||||
|
||||
**Language:** [O'zbek](README.MD) | English
|
||||
|
||||
Welcome! This is a comprehensive Django project template designed to streamline Django application development with pre-configured architecture, best practices, and powerful CLI tools.
|
||||
|
||||
## Overview
|
||||
|
||||
This template consists of two main components:
|
||||
|
||||
1. **CLI Tool** - Command-line interface for generating Django apps and modules
|
||||
2. **Architecture Template** - Production-ready Django project structure with Docker, pre-configured packages, and best practices
|
||||
|
||||
> **Note:** While these components can be used independently, using them together provides the best development experience.
|
||||
|
||||
## Key Features
|
||||
|
||||
- 🚀 Production-ready Django project structure
|
||||
- 🐳 Docker & Docker Compose configuration
|
||||
- 📦 Pre-configured popular packages (DRF, Celery, Redis, etc.)
|
||||
- 🔧 CLI tool for rapid app/module generation
|
||||
- 🌐 Multi-language support (modeltranslation/parler)
|
||||
- 🔒 Security best practices included
|
||||
- 📝 API documentation with Swagger/ReDoc
|
||||
- ✅ Testing setup with pytest
|
||||
|
||||
## Installation
|
||||
|
||||
Install the CLI tool via pip:
|
||||
|
||||
```bash
|
||||
pip install -U jst-django
|
||||
```
|
||||
|
||||
> **Important:** Always use the latest version of the CLI tool for compatibility with the template.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Create a New Project
|
||||
|
||||
```bash
|
||||
jst create
|
||||
```
|
||||
|
||||
You will be prompted for:
|
||||
|
||||
- **Template**: Choose "django" (default)
|
||||
- **Project Name**: Your project name (used throughout the project)
|
||||
- **Settings File**: Keep default
|
||||
- **Packages**: Select additional packages you need:
|
||||
- modeltranslation or parler (choose one for translations)
|
||||
- silk (performance profiling)
|
||||
- channels (WebSocket support)
|
||||
- ckeditor (rich text editor)
|
||||
- and more...
|
||||
- **Runner**: wsgi or asgi (choose asgi for WebSocket/async features)
|
||||
- **Django Secret Key**: Change in production!
|
||||
- **Port**: Default 8081
|
||||
- **Admin Password**: Set a strong password
|
||||
- **Flake8**: Code style enforcement (recommended)
|
||||
|
||||
### 2. Start the Project
|
||||
|
||||
**Requirements:** Docker must be installed on your system.
|
||||
|
||||
Navigate to your project directory:
|
||||
|
||||
```bash
|
||||
cd your_project_name
|
||||
```
|
||||
|
||||
Start the project using Make:
|
||||
|
||||
```bash
|
||||
make up
|
||||
```
|
||||
|
||||
Or manually:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
docker compose exec web python manage.py seed
|
||||
```
|
||||
|
||||
The project will be available at `http://localhost:8081`
|
||||
|
||||
### 3. Run Tests
|
||||
|
||||
```bash
|
||||
make test
|
||||
```
|
||||
|
||||
## Creating Applications
|
||||
|
||||
### Create a New App
|
||||
|
||||
```bash
|
||||
jst make:app <app_name>
|
||||
```
|
||||
|
||||
Choose a module type:
|
||||
- **default**: Empty app structure
|
||||
- **bot**: Telegram bot integration
|
||||
- **authbot**: Telegram authentication
|
||||
- **authv2**: New authentication system
|
||||
- **websocket**: WebSocket support
|
||||
|
||||
The app will be automatically created and registered.
|
||||
|
||||
## Generating Modules
|
||||
|
||||
The most powerful feature of JST-Django is module generation:
|
||||
|
||||
```bash
|
||||
jst make:module
|
||||
```
|
||||
|
||||
You will be prompted for:
|
||||
|
||||
1. **File Name**: Basename for generated files (e.g., "post")
|
||||
2. **Module Names**: List of models to generate (e.g., "post, tag, category")
|
||||
3. **App**: Target application
|
||||
4. **Components**: Select what to generate:
|
||||
- Model
|
||||
- Serializer
|
||||
- View (ViewSet)
|
||||
- Admin
|
||||
- Permissions
|
||||
- Filters
|
||||
- Tests
|
||||
- URLs
|
||||
|
||||
This generates complete CRUD APIs with all selected components!
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
├── config/ # Configuration files
|
||||
│ ├── settings/ # Environment-specific settings
|
||||
│ │ ├── common.py # Shared settings
|
||||
│ │ ├── local.py # Development settings
|
||||
│ │ ├── production.py # Production settings
|
||||
│ │ └── test.py # Test settings
|
||||
│ ├── conf/ # Package configurations
|
||||
│ ├── urls.py
|
||||
│ └── wsgi.py / asgi.py
|
||||
├── core/
|
||||
│ ├── apps/ # Django applications
|
||||
│ │ ├── accounts/ # Pre-configured auth system
|
||||
│ │ └── shared/ # Shared utilities
|
||||
│ ├── services/ # Business logic services
|
||||
│ └── utils/ # Utility functions
|
||||
├── docker/ # Docker configurations
|
||||
├── resources/ # Static resources, scripts
|
||||
├── Makefile # Convenience commands
|
||||
├── docker-compose.yml # Docker Compose config
|
||||
├── requirements.txt # Python dependencies
|
||||
└── manage.py
|
||||
```
|
||||
|
||||
## Available Make Commands
|
||||
|
||||
```bash
|
||||
make up # Start containers
|
||||
make down # Stop containers
|
||||
make build # Build containers
|
||||
make rebuild # Rebuild and restart
|
||||
make logs # View logs
|
||||
make makemigrations # Create migrations
|
||||
make migrate # Apply migrations
|
||||
make migrations # Make and apply migrations
|
||||
make seed # Seed database with initial data
|
||||
make fresh # Reset DB, migrate, and seed
|
||||
make test # Run tests
|
||||
make deploy # Deploy (local)
|
||||
make deploy-prod # Deploy (production)
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
⚠️ **Important:** See [SECURITY.md](SECURITY.md) for detailed security guidelines.
|
||||
|
||||
**Quick checklist:**
|
||||
- ✅ Change `DJANGO_SECRET_KEY` in production
|
||||
- ✅ Change default admin password
|
||||
- ✅ Set `DEBUG=False` in production
|
||||
- ✅ Configure proper `ALLOWED_HOSTS`
|
||||
- ✅ Use HTTPS (`PROTOCOL_HTTPS=True`)
|
||||
- ✅ Change database password
|
||||
- ✅ Never commit `.env` file
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Key environment variables in `.env`:
|
||||
|
||||
- `DJANGO_SECRET_KEY`: Django secret key (change in production!)
|
||||
- `DEBUG`: Debug mode (False in production)
|
||||
- `DB_PASSWORD`: Database password (change in production!)
|
||||
- `DJANGO_SETTINGS_MODULE`: Settings module to use
|
||||
- `PROJECT_ENV`: debug | prod
|
||||
- `SILK_ENABLED`: Enable Silk profiling (optional)
|
||||
|
||||
See `.env.example` for all available options.
|
||||
|
||||
## Additional Packages
|
||||
|
||||
The template supports optional packages:
|
||||
|
||||
- **modeltranslation**: Model field translation
|
||||
- **parler**: Alternative translation solution
|
||||
- **silk**: Performance profiling
|
||||
- **channels**: WebSocket/async support
|
||||
- **ckeditor**: Rich text editor
|
||||
- **rosetta**: Translation management
|
||||
- **cacheops**: Advanced caching
|
||||
|
||||
## Testing
|
||||
|
||||
Tests are written using pytest-django:
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
make test
|
||||
|
||||
# Run specific tests
|
||||
docker compose exec web pytest path/to/test.py -v
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit issues and pull requests.
|
||||
|
||||
## License
|
||||
|
||||
See [LICENSE](LICENSE) file for details.
|
||||
|
||||
## Support
|
||||
|
||||
For issues and questions:
|
||||
- Create an issue on GitHub
|
||||
- Check existing documentation
|
||||
|
||||
---
|
||||
|
||||
**Happy Coding! 🚀**
|
||||
|
||||
79
SECURITY.md
Normal file
79
SECURITY.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Security Best Practices / Xavfsizlik bo'yicha eng yaxshi amaliyotlar
|
||||
|
||||
## English
|
||||
|
||||
### Important Security Notes
|
||||
|
||||
1. **Change Default Credentials**
|
||||
- Never use the default password `2309` in production
|
||||
- Change the admin phone number from the default value
|
||||
- Generate a strong SECRET_KEY for production
|
||||
|
||||
2. **Environment Variables**
|
||||
- Never commit `.env` file to version control
|
||||
- Keep production credentials secure and separate from development
|
||||
- Use strong passwords for database and admin accounts
|
||||
|
||||
3. **Database Security**
|
||||
- Change default database password in production
|
||||
- Use strong passwords for PostgreSQL
|
||||
- Restrict database access to specific IP addresses
|
||||
|
||||
4. **Django Security Settings**
|
||||
- Set `DEBUG=False` in production
|
||||
- Configure proper `ALLOWED_HOSTS`
|
||||
- Use HTTPS in production (`PROTOCOL_HTTPS=True`)
|
||||
- Keep `SECRET_KEY` secret and unique per environment
|
||||
|
||||
5. **API Security**
|
||||
- Configure proper CORS settings
|
||||
- Use CSRF protection
|
||||
- Implement rate limiting
|
||||
- Use JWT tokens with appropriate expiration times
|
||||
|
||||
6. **Docker Security**
|
||||
- Don't expose unnecessary ports
|
||||
- Use docker secrets for sensitive data
|
||||
- Keep Docker images updated
|
||||
|
||||
## O'zbekcha
|
||||
|
||||
### Muhim xavfsizlik eslatmalari
|
||||
|
||||
1. **Standart parollarni o'zgartiring**
|
||||
- Production muhitida hech qachon standart parol `2309` dan foydalanmang
|
||||
- Admin telefon raqamini standart qiymatdan o'zgartiring
|
||||
- Production uchun kuchli SECRET_KEY yarating
|
||||
|
||||
2. **Environment o'zgaruvchilari**
|
||||
- Hech qachon `.env` faylini git repozitoriyasiga commit qilmang
|
||||
- Production ma'lumotlarini xavfsiz va developmentdan alohida saqlang
|
||||
- Ma'lumotlar bazasi va admin akkountlari uchun kuchli parollar ishlating
|
||||
|
||||
3. **Ma'lumotlar bazasi xavfsizligi**
|
||||
- Production muhitida standart parolni o'zgartiring
|
||||
- PostgreSQL uchun kuchli parollar ishlating
|
||||
- Ma'lumotlar bazasiga kirishni muayyan IP manzillarga cheklang
|
||||
|
||||
4. **Django xavfsizlik sozlamalari**
|
||||
- Production muhitida `DEBUG=False` qiling
|
||||
- To'g'ri `ALLOWED_HOSTS` sozlang
|
||||
- Production muhitida HTTPS dan foydalaning (`PROTOCOL_HTTPS=True`)
|
||||
- `SECRET_KEY` ni maxfiy va har bir muhitda noyob qiling
|
||||
|
||||
5. **API xavfsizligi**
|
||||
- To'g'ri CORS sozlamalarini o'rnating
|
||||
- CSRF himoyasidan foydalaning
|
||||
- Rate limiting ni amalga oshiring
|
||||
- JWT tokenlarni to'g'ri muddatda ishlating
|
||||
|
||||
6. **Docker xavfsizligi**
|
||||
- Keraksiz portlarni ochib qo'ymang
|
||||
- Maxfiy ma'lumotlar uchun docker secrets dan foydalaning
|
||||
- Docker imagelarni yangilab turing
|
||||
|
||||
## Reporting Security Issues / Xavfsizlik muammolarini xabar qilish
|
||||
|
||||
If you discover a security vulnerability, please email the maintainers directly instead of using the issue tracker.
|
||||
|
||||
Agar xavfsizlik zaifligini topsangiz, iltimos issue tracker o'rniga to'g'ridan-to'g'ri maintainerlar ga email yuboring.
|
||||
3
config/__init__.py
Normal file
3
config/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .celery import app
|
||||
|
||||
__all__ = ["app"]
|
||||
23
config/asgi.py
Normal file
23
config/asgi.py
Normal file
@@ -0,0 +1,23 @@
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
asgi_application = get_asgi_application()
|
||||
from config.env import env # noqa
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", env("DJANGO_SETTINGS_MODULE"))
|
||||
|
||||
|
||||
from channels.routing import ProtocolTypeRouter # noqa
|
||||
from channels.routing import URLRouter # noqa
|
||||
|
||||
# from core.apps.websocket.urls import websocket_urlpatterns # noqa
|
||||
# from core.apps.websocket.middlewares import JWTAuthMiddlewareStack # noqa
|
||||
|
||||
application = ProtocolTypeRouter(
|
||||
{
|
||||
"http": asgi_application,
|
||||
# "websocket": JWTAuthMiddlewareStack(URLRouter(websocket_urlpatterns)),
|
||||
}
|
||||
)
|
||||
|
||||
16
config/celery.py
Normal file
16
config/celery.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
Celery configurations
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import celery
|
||||
from config.env import env
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", env("DJANGO_SETTINGS_MODULE"))
|
||||
|
||||
app = celery.Celery("config")
|
||||
|
||||
app.config_from_object("django.conf:settings", namespace="CELERY")
|
||||
|
||||
app.autodiscover_tasks()
|
||||
12
config/conf/__init__.py
Normal file
12
config/conf/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from .cache import * # noqa
|
||||
from .celery import * # noqa
|
||||
from .cron import * # noqa
|
||||
from .jwt import * # noqa
|
||||
from .logs import * # noqa
|
||||
from .rest_framework import * # noqa
|
||||
from .unfold import * # noqa
|
||||
from .spectacular import * # noqa
|
||||
|
||||
from .ckeditor import * # noqa
|
||||
from .storage import * # noqa
|
||||
from .channels import * # noqa
|
||||
22
config/conf/apps.py
Normal file
22
config/conf/apps.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from config.env import env
|
||||
|
||||
APPS = [
|
||||
"channels",
|
||||
"cacheops",
|
||||
|
||||
"django_ckeditor_5",
|
||||
|
||||
"drf_spectacular",
|
||||
"rest_framework",
|
||||
"corsheaders",
|
||||
"django_filters",
|
||||
"django_redis",
|
||||
"rest_framework_simplejwt",
|
||||
"django_core",
|
||||
"core.apps.accounts.apps.AccountsConfig",
|
||||
]
|
||||
|
||||
if env.bool("SILK_ENABLED", False):
|
||||
APPS += [
|
||||
"silk",
|
||||
]
|
||||
26
config/conf/cache.py
Normal file
26
config/conf/cache.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from config.env import env
|
||||
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": env.str("CACHE_BACKEND"),
|
||||
"LOCATION": env.str("REDIS_URL"),
|
||||
"TIMEOUT": env.str("CACHE_TIMEOUT"),
|
||||
},
|
||||
}
|
||||
|
||||
CACHE_MIDDLEWARE_SECONDS = env("CACHE_TIMEOUT")
|
||||
|
||||
|
||||
CACHEOPS_REDIS = env.str("REDIS_URL")
|
||||
CACHEOPS_DEFAULTS = {
|
||||
"timeout": env.str("CACHE_TIMEOUT"),
|
||||
}
|
||||
CACHEOPS = {
|
||||
# !NOTE: api => "you app name"
|
||||
# "api.*": {
|
||||
# "ops": "all", # Barcha turdagi so'rovlarni keshga olish
|
||||
# "timeout": 60 * 5, # 5 daqiqa davomida saqlash
|
||||
# },
|
||||
}
|
||||
CACHEOPS_DEGRADE_ON_FAILURE = True
|
||||
CACHEOPS_ENABLED = env.bool("CACHE_ENABLED", False)
|
||||
7
config/conf/celery.py
Normal file
7
config/conf/celery.py
Normal file
@@ -0,0 +1,7 @@
|
||||
CELERY_BEAT_SCHEDULE = {
|
||||
# "test": {
|
||||
# "task": "core.apps.home.tasks.demo.add",
|
||||
# "schedule": 5.0,
|
||||
# "args": (1, 2)
|
||||
# },
|
||||
}
|
||||
12
config/conf/channels.py
Normal file
12
config/conf/channels.py
Normal file
@@ -0,0 +1,12 @@
|
||||
# type: ignore
|
||||
from config.env import env
|
||||
|
||||
CHANNEL_LAYERS = {
|
||||
"default": {
|
||||
"BACKEND": "channels_redis.core.RedisChannelLayer",
|
||||
"CONFIG": {
|
||||
"hosts": [(env.str("REDIS_HOST", "redis"), env.int("REDIS_PORT", 6379))],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
147
config/conf/ckeditor.py
Normal file
147
config/conf/ckeditor.py
Normal file
@@ -0,0 +1,147 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
STATIC_URL = "/resources/static/"
|
||||
MEDIA_URL = "/resources/media/"
|
||||
MEDIA_ROOT = os.path.join(Path().parent.parent, "media")
|
||||
|
||||
customColorPalette = [
|
||||
{"color": "hsl(4, 90%, 58%)", "label": "Red"},
|
||||
{"color": "hsl(340, 82%, 52%)", "label": "Pink"},
|
||||
{"color": "hsl(291, 64%, 42%)", "label": "Purple"},
|
||||
{"color": "hsl(262, 52%, 47%)", "label": "Deep Purple"},
|
||||
{"color": "hsl(231, 48%, 48%)", "label": "Indigo"},
|
||||
{"color": "hsl(207, 90%, 54%)", "label": "Blue"},
|
||||
]
|
||||
|
||||
CKEDITOR_5_CONFIGS = {
|
||||
"default": {
|
||||
"toolbar": [
|
||||
"heading",
|
||||
"|",
|
||||
"bold",
|
||||
"italic",
|
||||
"link",
|
||||
"bulletedList",
|
||||
"numberedList",
|
||||
"blockQuote",
|
||||
"imageUpload",
|
||||
],
|
||||
},
|
||||
"extends": {
|
||||
"blockToolbar": [
|
||||
"paragraph",
|
||||
"heading1",
|
||||
"heading2",
|
||||
"heading3",
|
||||
"|",
|
||||
"bulletedList",
|
||||
"numberedList",
|
||||
"|",
|
||||
"blockQuote",
|
||||
],
|
||||
"toolbar": [
|
||||
"heading",
|
||||
"|",
|
||||
"outdent",
|
||||
"indent",
|
||||
"|",
|
||||
"bold",
|
||||
"italic",
|
||||
"link",
|
||||
"underline",
|
||||
"strikethrough",
|
||||
"code",
|
||||
"subscript",
|
||||
"superscript",
|
||||
"highlight",
|
||||
"|",
|
||||
"codeBlock",
|
||||
"sourceEditing",
|
||||
"insertImage",
|
||||
"bulletedList",
|
||||
"numberedList",
|
||||
"todoList",
|
||||
"|",
|
||||
"blockQuote",
|
||||
"imageUpload",
|
||||
"|",
|
||||
"fontSize",
|
||||
"fontFamily",
|
||||
"fontColor",
|
||||
"fontBackgroundColor",
|
||||
"mediaEmbed",
|
||||
"removeFormat",
|
||||
"insertTable",
|
||||
],
|
||||
"image": {
|
||||
"toolbar": [
|
||||
"imageTextAlternative",
|
||||
"|",
|
||||
"imageStyle:alignLeft",
|
||||
"imageStyle:alignRight",
|
||||
"imageStyle:alignCenter",
|
||||
"imageStyle:side",
|
||||
"|",
|
||||
],
|
||||
"styles": [
|
||||
"full",
|
||||
"side",
|
||||
"alignLeft",
|
||||
"alignRight",
|
||||
"alignCenter",
|
||||
],
|
||||
},
|
||||
"table": {
|
||||
"contentToolbar": [
|
||||
"tableColumn",
|
||||
"tableRow",
|
||||
"mergeTableCells",
|
||||
"tableProperties",
|
||||
"tableCellProperties",
|
||||
],
|
||||
"tableProperties": {
|
||||
"borderColors": customColorPalette,
|
||||
"backgroundColors": customColorPalette,
|
||||
},
|
||||
"tableCellProperties": {
|
||||
"borderColors": customColorPalette,
|
||||
"backgroundColors": customColorPalette,
|
||||
},
|
||||
},
|
||||
"heading": {
|
||||
"options": [
|
||||
{
|
||||
"model": "paragraph",
|
||||
"title": "Paragraph",
|
||||
"class": "ck-heading_paragraph",
|
||||
},
|
||||
{
|
||||
"model": "heading1",
|
||||
"view": "h1",
|
||||
"title": "Heading 1",
|
||||
"class": "ck-heading_heading1",
|
||||
},
|
||||
{
|
||||
"model": "heading2",
|
||||
"view": "h2",
|
||||
"title": "Heading 2",
|
||||
"class": "ck-heading_heading2",
|
||||
},
|
||||
{
|
||||
"model": "heading3",
|
||||
"view": "h3",
|
||||
"title": "Heading 3",
|
||||
"class": "ck-heading_heading3",
|
||||
},
|
||||
]
|
||||
},
|
||||
},
|
||||
"list": {
|
||||
"properties": {
|
||||
"styles": "true",
|
||||
"startIndex": "true",
|
||||
"reversed": "true",
|
||||
}
|
||||
},
|
||||
}
|
||||
0
config/conf/cron.py
Normal file
0
config/conf/cron.py
Normal file
36
config/conf/jwt.py
Normal file
36
config/conf/jwt.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from config.env import env
|
||||
|
||||
SIMPLE_JWT = {
|
||||
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=60),
|
||||
"REFRESH_TOKEN_LIFETIME": timedelta(days=30),
|
||||
"ROTATE_REFRESH_TOKENS": False,
|
||||
"BLACKLIST_AFTER_ROTATION": False,
|
||||
"UPDATE_LAST_LOGIN": False,
|
||||
"ALGORITHM": "HS256",
|
||||
"SIGNING_KEY": env("DJANGO_SECRET_KEY"),
|
||||
"VERIFYING_KEY": "",
|
||||
"AUDIENCE": None,
|
||||
"ISSUER": None,
|
||||
"JSON_ENCODER": None,
|
||||
"JWK_URL": None,
|
||||
"LEEWAY": 0,
|
||||
"AUTH_HEADER_TYPES": ("Bearer",),
|
||||
"AUTH_HEADER_NAME": "HTTP_AUTHORIZATION",
|
||||
"USER_ID_FIELD": "id",
|
||||
"USER_ID_CLAIM": "user_id",
|
||||
"AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",),
|
||||
"TOKEN_TYPE_CLAIM": "token_type",
|
||||
"TOKEN_USER_CLASS": "rest_framework_simplejwt.models.TokenUser",
|
||||
"JTI_CLAIM": "jti",
|
||||
"SLIDING_TOKEN_REFRESH_EXP_CLAIM": "refresh_exp",
|
||||
"SLIDING_TOKEN_LIFETIME": timedelta(minutes=60),
|
||||
"SLIDING_TOKEN_REFRESH_LIFETIME": timedelta(days=30),
|
||||
"TOKEN_OBTAIN_SERIALIZER": "rest_framework_simplejwt.serializers.TokenObtainPairSerializer",
|
||||
"TOKEN_REFRESH_SERIALIZER": "rest_framework_simplejwt.serializers.TokenRefreshSerializer",
|
||||
"TOKEN_VERIFY_SERIALIZER": "rest_framework_simplejwt.serializers.TokenVerifySerializer",
|
||||
"TOKEN_BLACKLIST_SERIALIZER": "rest_framework_simplejwt.serializers.TokenBlacklistSerializer",
|
||||
"SLIDING_TOKEN_OBTAIN_SERIALIZER": "rest_framework_simplejwt.serializers.TokenObtainSlidingSerializer",
|
||||
"SLIDING_TOKEN_REFRESH_SERIALIZER": "rest_framework_simplejwt.serializers.TokenRefreshSlidingSerializer",
|
||||
}
|
||||
60
config/conf/logs.py
Normal file
60
config/conf/logs.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
import logging
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
LOG_DIR = BASE_DIR / "resources/logs"
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
|
||||
|
||||
class ExcludeErrorsFilter:
|
||||
def filter(self, record):
|
||||
return record.levelno <= logging.ERROR
|
||||
|
||||
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"verbose": {
|
||||
"format": "%(asctime)s %(name)s %(levelname)s %(filename)s:%(lineno)d - %(message)s",
|
||||
},
|
||||
},
|
||||
"filters": {
|
||||
"exclude_errors": {
|
||||
"()": ExcludeErrorsFilter,
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"daily_rotating_file": {
|
||||
"level": "INFO",
|
||||
"class": "logging.handlers.TimedRotatingFileHandler",
|
||||
"filename": LOG_DIR / "django.log",
|
||||
"when": "midnight",
|
||||
"backupCount": 30,
|
||||
"formatter": "verbose",
|
||||
"filters": ["exclude_errors"],
|
||||
},
|
||||
"error_file": {
|
||||
"level": "ERROR",
|
||||
"class": "logging.handlers.TimedRotatingFileHandler",
|
||||
"filename": LOG_DIR / "django_error.log",
|
||||
"when": "midnight",
|
||||
"backupCount": 30,
|
||||
"formatter": "verbose",
|
||||
},
|
||||
},
|
||||
"loggers": {
|
||||
"django": {
|
||||
"handlers": ["daily_rotating_file", "error_file"],
|
||||
"level": "INFO",
|
||||
"propagate": True,
|
||||
},
|
||||
"root": {
|
||||
"handlers": ["daily_rotating_file", "error_file"],
|
||||
"level": "INFO",
|
||||
"propagate": True,
|
||||
},
|
||||
},
|
||||
}
|
||||
3
config/conf/modules.py
Normal file
3
config/conf/modules.py
Normal file
@@ -0,0 +1,3 @@
|
||||
MODULES = [
|
||||
"core.apps.shared",
|
||||
]
|
||||
31
config/conf/navigation.py
Normal file
31
config/conf/navigation.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
PAGES = [
|
||||
{
|
||||
"seperator": False,
|
||||
"items": [
|
||||
{
|
||||
"title": _("Home page"),
|
||||
"icon": "home",
|
||||
"link": reverse_lazy("admin:index"),
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": _("Auth"),
|
||||
"separator": True, # Top border
|
||||
"items": [
|
||||
{
|
||||
"title": _("Users"),
|
||||
"icon": "group",
|
||||
"link": reverse_lazy("admin:http_user_changelist"),
|
||||
},
|
||||
{
|
||||
"title": _("Group"),
|
||||
"icon": "group",
|
||||
"link": reverse_lazy("admin:auth_group_changelist"),
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
9
config/conf/rest_framework.py
Normal file
9
config/conf/rest_framework.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from typing import Any, Union
|
||||
|
||||
REST_FRAMEWORK: Union[Any] = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": ("rest_framework_simplejwt.authentication.JWTAuthentication",),
|
||||
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
||||
"DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"],
|
||||
"DEFAULT_PAGINATION_CLASS": "django_core.paginations.CustomPagination",
|
||||
"PAGE_SIZE": 10,
|
||||
}
|
||||
31
config/conf/spectacular.py
Normal file
31
config/conf/spectacular.py
Normal file
@@ -0,0 +1,31 @@
|
||||
SPECTACULAR_SETTINGS = {
|
||||
"TITLE": "Your Project API",
|
||||
"DESCRIPTION": "Your project description",
|
||||
"VERSION": "1.0.0",
|
||||
"SERVE_INCLUDE_SCHEMA": False,
|
||||
"CAMELIZE_NAMES": True,
|
||||
"POSTPROCESSING_HOOKS": ["config.conf.spectacular.custom_postprocessing_hook"],
|
||||
}
|
||||
|
||||
|
||||
def custom_postprocessing_hook(result, generator, request, public):
|
||||
"""
|
||||
Customizes the API schema to wrap all responses in a standard format.
|
||||
"""
|
||||
for path, methods in result.get("paths", {}).items():
|
||||
for method, operation in methods.items():
|
||||
if "responses" in operation:
|
||||
for status_code, response in operation["responses"].items():
|
||||
if "content" in response and status_code in ["200", "201", "202"]:
|
||||
for content_type, content in response["content"].items():
|
||||
# Wrap original schema
|
||||
original_schema = content.get("schema", {})
|
||||
response["content"][content_type]["schema"] = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {"type": "boolean", "example": True},
|
||||
"data": original_schema,
|
||||
},
|
||||
"required": ["status", "data"],
|
||||
}
|
||||
return result
|
||||
23
config/conf/storage.py
Normal file
23
config/conf/storage.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from config.env import env
|
||||
from core.utils.storage import Storage
|
||||
|
||||
AWS_ACCESS_KEY_ID = env.str("STORAGE_ID")
|
||||
AWS_SECRET_ACCESS_KEY = env.str("STORAGE_KEY")
|
||||
AWS_S3_ENDPOINT_URL = env.str("STORAGE_URL")
|
||||
AWS_S3_CUSTOM_DOMAIN = env.str("STORAGE_PATH")
|
||||
AWS_S3_URL_PROTOCOL = env.str("STORAGE_PROTOCOL", "https:")
|
||||
AWS_S3_FILE_OVERWRITE = False
|
||||
|
||||
default_storage = Storage(env.str("STORAGE_DEFAULT"), "default")
|
||||
static_storage = Storage(env.str("STORAGE_STATIC"), "static")
|
||||
|
||||
STORAGES = {
|
||||
"default": {
|
||||
"BACKEND": default_storage.get_backend(),
|
||||
"OPTIONS": default_storage.get_options(),
|
||||
},
|
||||
"staticfiles": {
|
||||
"BACKEND": static_storage.get_backend(),
|
||||
"OPTIONS": static_storage.get_options(),
|
||||
},
|
||||
}
|
||||
95
config/conf/unfold.py
Normal file
95
config/conf/unfold.py
Normal file
@@ -0,0 +1,95 @@
|
||||
from django.conf import settings
|
||||
from django.templatetags.static import static
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from . import navigation
|
||||
|
||||
|
||||
def environment_callback(request):
|
||||
if settings.DEBUG:
|
||||
return [_("Development"), "primary"]
|
||||
|
||||
return [_("Production"), "primary"]
|
||||
|
||||
|
||||
UNFOLD = {
|
||||
"DASHBOARD_CALLBACK": "django_core.views.dashboard_callback",
|
||||
"SITE_TITLE": "Django",
|
||||
"SITE_HEADER": "Django",
|
||||
"SITE_URL": "/",
|
||||
# "SITE_DROPDOWN": [
|
||||
# {"icon": "local_library", "title": "Django", "link": "https://example.com"},
|
||||
# ],
|
||||
"SITE_ICON": {
|
||||
# "light": lambda request: static("images/pedagog.svg"),
|
||||
# "dark": lambda request: static("images/pedagog.svg"),
|
||||
},
|
||||
# "SITE_FAVICONS": [
|
||||
# {
|
||||
# "rel": "icon",
|
||||
# "sizes": "32x32",
|
||||
# "type": "image/svg+xml",
|
||||
# "href": lambda request: static("images/pedagog.svg"),
|
||||
# },
|
||||
# ],
|
||||
"SITE_SYMBOL": "speed",
|
||||
"SHOW_HISTORY": True,
|
||||
"SHOW_VIEW_ON_SITE": True,
|
||||
"SHOW_BACK_BUTTON": True,
|
||||
"SHOW_LANGUAGES": True,
|
||||
"ENVIRONMENT": "core.config.unfold.environment_callback",
|
||||
# "LOGIN": {
|
||||
# "image": lambda request: static("images/login.png"),
|
||||
# },
|
||||
"BORDER_RADIUS": "10px",
|
||||
"COLORS": {
|
||||
"base": {
|
||||
"50": "250 250 250",
|
||||
"100": "244 244 245",
|
||||
"200": "228 228 231",
|
||||
"300": "212 212 216",
|
||||
"400": "161 161 170",
|
||||
"500": "113 113 122",
|
||||
"600": "82 82 91",
|
||||
"700": "63 63 70",
|
||||
"800": "39 39 42",
|
||||
"900": "24 24 27",
|
||||
"950": "9 9 11",
|
||||
},
|
||||
"font": {
|
||||
"subtle-light": "var(--color-base-500)", # text-base-500
|
||||
"subtle-dark": "var(--color-base-400)", # text-base-400
|
||||
"default-light": "var(--color-base-600)", # text-base-600
|
||||
"default-dark": "var(--color-base-300)", # text-base-300
|
||||
"important-light": "var(--color-base-900)", # text-base-900
|
||||
"important-dark": "var(--color-base-100)", # text-base-100
|
||||
},
|
||||
"primary": {
|
||||
"50": "230 245 255",
|
||||
"100": "180 225 255",
|
||||
"200": "130 205 255",
|
||||
"300": "80 185 255",
|
||||
"400": "40 165 255",
|
||||
"500": "0 145 255",
|
||||
"600": "0 115 204",
|
||||
"700": "0 85 153",
|
||||
"800": "0 55 102",
|
||||
"900": "0 30 51",
|
||||
"950": "0 15 25",
|
||||
},
|
||||
},
|
||||
"EXTENSIONS": {
|
||||
"modeltranslation": {
|
||||
"flags": {
|
||||
"uz": "🇺🇿",
|
||||
"ru": "🇷🇺",
|
||||
"en": "🇬🇧",
|
||||
},
|
||||
},
|
||||
},
|
||||
"SIDEBAR": {
|
||||
"show_search": True,
|
||||
"show_all_applications": True,
|
||||
# "navigation": navigation.PAGES,
|
||||
},
|
||||
}
|
||||
29
config/env.py
Normal file
29
config/env.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
Default value for environ variable
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import environ
|
||||
|
||||
environ.Env.read_env(os.path.join(".env"))
|
||||
|
||||
env = environ.Env(
|
||||
DEBUG=(bool, False),
|
||||
CACHE_TIME=(int, 180),
|
||||
OTP_EXPIRE_TIME=(int, 2),
|
||||
VITE_LIVE=(bool, False),
|
||||
ALLOWED_HOSTS=(str, "localhost"),
|
||||
CSRF_TRUSTED_ORIGINS=(str, "localhost"),
|
||||
DJANGO_SETTINGS_MODULE=(str, "config.settings.local"),
|
||||
CACHE_TIMEOUT=(int, 120),
|
||||
CACHE_ENABLED=(bool, False),
|
||||
VITE_PORT=(int, 5173),
|
||||
VITE_HOST=(str, "vite"),
|
||||
NGROK_AUTHTOKEN=(str, "TOKEN"),
|
||||
BOT_TOKEN=(str, "TOKEN"),
|
||||
OTP_MODULE="core.services.otp",
|
||||
OTP_SERVICE="EskizService",
|
||||
PROJECT_ENV=(str, "prod"),
|
||||
SILK_ENABLED=(bool, False),
|
||||
)
|
||||
0
config/settings/__init__.py
Normal file
0
config/settings/__init__.py
Normal file
175
config/settings/common.py
Normal file
175
config/settings/common.py
Normal file
@@ -0,0 +1,175 @@
|
||||
#type: ignore
|
||||
import os
|
||||
import pathlib
|
||||
from typing import List, Union
|
||||
|
||||
from config.conf import * # noqa
|
||||
from config.conf.apps import APPS
|
||||
from config.conf.modules import MODULES
|
||||
from config.env import env
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rich.traceback import install
|
||||
|
||||
install(show_locals=True)
|
||||
BASE_DIR = pathlib.Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
SECRET_KEY = env.str("DJANGO_SECRET_KEY")
|
||||
DEBUG = env.bool("DEBUG")
|
||||
|
||||
ALLOWED_HOSTS: Union[List[str]] = ["*"]
|
||||
|
||||
if env.bool("PROTOCOL_HTTPS", False):
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": env.str("DB_ENGINE"),
|
||||
"NAME": env.str("DB_NAME"),
|
||||
"USER": env.str("DB_USER"),
|
||||
"PASSWORD": env.str("DB_PASSWORD"),
|
||||
"HOST": env.str("DB_HOST"),
|
||||
"PORT": env.str("DB_PORT"),
|
||||
}
|
||||
}
|
||||
|
||||
PASSWORD_HASHERS = [
|
||||
"django.contrib.auth.hashers.BCryptPasswordHasher",
|
||||
]
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"modeltranslation",
|
||||
"unfold",
|
||||
"unfold.contrib.filters",
|
||||
"unfold.contrib.forms",
|
||||
"unfold.contrib.guardian",
|
||||
"unfold.contrib.simple_history",
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
] + APPS
|
||||
|
||||
MODULES = [app for app in MODULES if isinstance(app, str)]
|
||||
|
||||
for module_path in MODULES:
|
||||
INSTALLED_APPS.append("{}.apps.ModuleConfig".format(module_path))
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"corsheaders.middleware.CorsMiddleware", # Cors middleware
|
||||
"django.middleware.locale.LocaleMiddleware", # Locale middleware
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
if env.bool("SILK_ENABLED", False):
|
||||
MIDDLEWARE += [
|
||||
"silk.middleware.SilkyMiddleware",
|
||||
]
|
||||
|
||||
|
||||
ROOT_URLCONF = "config.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [os.path.join(BASE_DIR, "resources/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",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
# fmt: off
|
||||
|
||||
WSGI_APPLICATION = "config.wsgi.application"
|
||||
|
||||
# fmt: on
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.{}".format(validator)
|
||||
} for validator in [
|
||||
"UserAttributeSimilarityValidator",
|
||||
"MinimumLengthValidator",
|
||||
"CommonPasswordValidator",
|
||||
"NumericPasswordValidator"
|
||||
]
|
||||
]
|
||||
|
||||
TIME_ZONE = "Asia/Tashkent"
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
STATIC_URL = "resources/static/"
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
# Date formats
|
||||
##
|
||||
DATE_FORMAT = "d.m.y"
|
||||
TIME_FORMAT = "H:i:s"
|
||||
DATE_INPUT_FORMATS = ["%d.%m.%Y", "%Y.%d.%m", "%Y.%d.%m"]
|
||||
|
||||
|
||||
SEEDERS = ["core.apps.accounts.seeder.UserSeeder"]
|
||||
|
||||
STATICFILES_DIRS = [
|
||||
os.path.join(BASE_DIR, "resources/static"),
|
||||
]
|
||||
|
||||
CORS_ORIGIN_ALLOW_ALL = True
|
||||
|
||||
STATIC_ROOT = os.path.join(BASE_DIR, "resources/staticfiles")
|
||||
VITE_APP_DIR = os.path.join(BASE_DIR, "resources/static/vite")
|
||||
|
||||
LANGUAGES = (
|
||||
("ru", _("Russia")),
|
||||
("en", _("English")),
|
||||
("uz", _("Uzbek")),
|
||||
)
|
||||
LOCALE_PATHS = [os.path.join(BASE_DIR, "resources/locale")]
|
||||
|
||||
LANGUAGE_CODE = "uz"
|
||||
|
||||
MEDIA_ROOT = os.path.join(BASE_DIR, "resources/media") # Media files
|
||||
MEDIA_URL = "/resources/media/"
|
||||
|
||||
AUTH_USER_MODEL = "accounts.User"
|
||||
|
||||
CELERY_BROKER_URL = env("REDIS_URL")
|
||||
CELERY_RESULT_BACKEND = env("REDIS_URL")
|
||||
|
||||
ALLOWED_HOSTS += env("ALLOWED_HOSTS").split(",")
|
||||
CSRF_TRUSTED_ORIGINS = env("CSRF_TRUSTED_ORIGINS").split(",")
|
||||
SILKY_AUTHORISATION = True
|
||||
SILKY_PYTHON_PROFILER = True
|
||||
|
||||
MODELTRANSLATION_LANGUAGES = ("uz", "ru", "en")
|
||||
MODELTRANSLATION_DEFAULT_LANGUAGE = "uz"
|
||||
|
||||
|
||||
|
||||
JST_LANGUAGES = [
|
||||
{
|
||||
"code": "uz",
|
||||
"name": "Uzbek",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"code": "en",
|
||||
"name": "English",
|
||||
},
|
||||
{
|
||||
"code": "ru",
|
||||
"name": "Russia",
|
||||
}
|
||||
]
|
||||
11
config/settings/local.py
Normal file
11
config/settings/local.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from config.settings.common import * # noqa
|
||||
from config.settings.common import (ALLOWED_HOSTS, INSTALLED_APPS,
|
||||
REST_FRAMEWORK)
|
||||
|
||||
INSTALLED_APPS += ["django_extensions"]
|
||||
|
||||
ALLOWED_HOSTS += ["127.0.0.1", "192.168.100.26"]
|
||||
|
||||
REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"] = {
|
||||
"user": "60/min",
|
||||
}
|
||||
6
config/settings/production.py
Normal file
6
config/settings/production.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from config.settings.common import * # noqa
|
||||
from config.settings.common import ALLOWED_HOSTS, REST_FRAMEWORK
|
||||
|
||||
ALLOWED_HOSTS += []
|
||||
|
||||
REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"] = {"user": "60/min"}
|
||||
15
config/settings/test.py
Normal file
15
config/settings/test.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from config.settings.common import * # noqa
|
||||
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
},
|
||||
},
|
||||
"root": {
|
||||
"handlers": ["console"],
|
||||
"level": "INFO",
|
||||
},
|
||||
}
|
||||
63
config/urls.py
Normal file
63
config/urls.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
All urls configurations tree
|
||||
"""
|
||||
|
||||
from config.env import env
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
from django.http import HttpResponse
|
||||
from django.urls import include, path, re_path
|
||||
from django.views.static import serve
|
||||
from drf_spectacular.views import (SpectacularAPIView, SpectacularRedocView,
|
||||
SpectacularSwaggerView)
|
||||
|
||||
|
||||
def home(request):
|
||||
return HttpResponse("OK")
|
||||
|
||||
################
|
||||
# My apps url
|
||||
################
|
||||
urlpatterns = [
|
||||
path("health/", home),
|
||||
path("", include("core.apps.accounts.urls")),
|
||||
path("api/", include("core.apps.shared.urls")),
|
||||
]
|
||||
|
||||
|
||||
################
|
||||
# Library urls
|
||||
################
|
||||
urlpatterns += [
|
||||
path("admin/", admin.site.urls),
|
||||
path("accounts/", include("django.contrib.auth.urls")),
|
||||
path("i18n/", include("django.conf.urls.i18n")),
|
||||
|
||||
path("ckeditor5/", include("django_ckeditor_5.urls"), name="ck_editor_5_upload_file"),
|
||||
]
|
||||
|
||||
################
|
||||
# Project env debug mode
|
||||
################
|
||||
if env.bool("SILK_ENABLED", False):
|
||||
urlpatterns += [
|
||||
path('silk/', include('silk.urls', namespace='silk'))
|
||||
]
|
||||
if env.str("PROJECT_ENV") == "debug":
|
||||
|
||||
################
|
||||
# Swagger urls
|
||||
################
|
||||
urlpatterns += [
|
||||
path("schema/", SpectacularAPIView.as_view(), name="schema"),
|
||||
path("swagger/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"),
|
||||
path("redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"),
|
||||
]
|
||||
|
||||
################
|
||||
# Media urls
|
||||
################
|
||||
urlpatterns += [
|
||||
re_path(r"static/(?P<path>.*)", serve, {"document_root": settings.STATIC_ROOT}),
|
||||
re_path(r"media/(?P<path>.*)", serve, {"document_root": settings.MEDIA_ROOT}),
|
||||
]
|
||||
8
config/wsgi.py
Normal file
8
config/wsgi.py
Normal file
@@ -0,0 +1,8 @@
|
||||
import os
|
||||
|
||||
from config.env import env
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", env("DJANGO_SETTINGS_MODULE"))
|
||||
|
||||
application = get_wsgi_application()
|
||||
0
core/__init__.py
Normal file
0
core/__init__.py
Normal file
0
core/apps/__init__.py
Normal file
0
core/apps/__init__.py
Normal file
0
core/apps/accounts/__init__.py
Normal file
0
core/apps/accounts/__init__.py
Normal file
2
core/apps/accounts/admin/__init__.py
Normal file
2
core/apps/accounts/admin/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .core import * # noqa
|
||||
from .user import * # noqa
|
||||
18
core/apps/accounts/admin/core.py
Normal file
18
core/apps/accounts/admin/core.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
Admin panel register
|
||||
"""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth import models as db_models
|
||||
from django_core.models import SmsConfirm
|
||||
|
||||
from ..admin import user
|
||||
from .user import SmsConfirmAdmin
|
||||
|
||||
admin.site.unregister(db_models.Group)
|
||||
admin.site.register(db_models.Group, user.GroupAdmin)
|
||||
admin.site.register(db_models.Permission, user.PermissionAdmin)
|
||||
|
||||
admin.site.register(get_user_model(), user.CustomUserAdmin)
|
||||
admin.site.register(SmsConfirm, SmsConfirmAdmin)
|
||||
52
core/apps/accounts/admin/user.py
Normal file
52
core/apps/accounts/admin/user.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from django.contrib.auth import admin
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from unfold.admin import ModelAdmin
|
||||
from unfold.forms import AdminPasswordChangeForm # UserCreationForm,
|
||||
from unfold.forms import UserChangeForm
|
||||
|
||||
|
||||
class CustomUserAdmin(admin.UserAdmin, ModelAdmin):
|
||||
change_password_form = AdminPasswordChangeForm
|
||||
# add_form = UserCreationForm
|
||||
form = UserChangeForm
|
||||
list_display = (
|
||||
"first_name",
|
||||
"last_name",
|
||||
"phone",
|
||||
"role",
|
||||
)
|
||||
autocomplete_fields = ["groups", "user_permissions"]
|
||||
fieldsets = ((None, {"fields": ("phone",)}),) + (
|
||||
(None, {"fields": ("username", "password")}),
|
||||
(_("Personal info"), {"fields": ("first_name", "last_name", "email")}),
|
||||
(
|
||||
_("Permissions"),
|
||||
{
|
||||
"fields": (
|
||||
"is_active",
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
"groups",
|
||||
"user_permissions",
|
||||
"role",
|
||||
),
|
||||
},
|
||||
),
|
||||
(_("Important dates"), {"fields": ("last_login", "date_joined")}),
|
||||
)
|
||||
|
||||
|
||||
class PermissionAdmin(ModelAdmin):
|
||||
list_display = ("name",)
|
||||
search_fields = ("name",)
|
||||
|
||||
|
||||
class GroupAdmin(ModelAdmin):
|
||||
list_display = ["name"]
|
||||
search_fields = ["name"]
|
||||
autocomplete_fields = ("permissions",)
|
||||
|
||||
|
||||
class SmsConfirmAdmin(ModelAdmin):
|
||||
list_display = ["phone", "code", "resend_count", "try_count"]
|
||||
search_fields = ["phone", "code"]
|
||||
9
core/apps/accounts/apps.py
Normal file
9
core/apps/accounts/apps.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "core.apps.accounts"
|
||||
|
||||
def ready(self):
|
||||
from core.apps.accounts import signals # noqa
|
||||
1
core/apps/accounts/choices/__init__.py
Normal file
1
core/apps/accounts/choices/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .user import * # noqa
|
||||
12
core/apps/accounts/choices/user.py
Normal file
12
core/apps/accounts/choices/user.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class RoleChoice(models.TextChoices):
|
||||
"""
|
||||
User Role Choice
|
||||
"""
|
||||
|
||||
SUPERUSER = "superuser", _("Superuser")
|
||||
ADMIN = "admin", _("Admin")
|
||||
USER = "user", _("User")
|
||||
1
core/apps/accounts/managers/__init__.py
Normal file
1
core/apps/accounts/managers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .user import * # noqa
|
||||
23
core/apps/accounts/managers/user.py
Normal file
23
core/apps/accounts/managers/user.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from django.contrib.auth import base_user
|
||||
|
||||
|
||||
class UserManager(base_user.BaseUserManager):
|
||||
def create_user(self, phone, password=None, **extra_fields):
|
||||
if not phone:
|
||||
raise ValueError("The phone number must be set")
|
||||
|
||||
user = self.model(phone=phone, **extra_fields)
|
||||
user.set_password(password)
|
||||
user.save(using=self._db)
|
||||
return user
|
||||
|
||||
def create_superuser(self, phone, password=None, **extra_fields):
|
||||
extra_fields.setdefault("is_staff", True)
|
||||
extra_fields.setdefault("is_superuser", True)
|
||||
|
||||
if extra_fields.get("is_staff") is not True:
|
||||
raise ValueError("Superuser must have is_staff=True.")
|
||||
if extra_fields.get("is_superuser") is not True:
|
||||
raise ValueError("Superuser must have is_superuser=True.")
|
||||
|
||||
return self.create_user(phone, password, **extra_fields)
|
||||
60
core/apps/accounts/migrations/0001_initial.py
Normal file
60
core/apps/accounts/migrations/0001_initial.py
Normal file
@@ -0,0 +1,60 @@
|
||||
# Generated by Django 5.1.3 on 2024-12-13 19:04
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='User',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
||||
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
||||
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
|
||||
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
||||
('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')),
|
||||
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||
('phone', models.CharField(max_length=255, unique=True)),
|
||||
('username', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('validated_at', models.DateTimeField(blank=True, null=True)),
|
||||
('role', models.CharField(choices=[('superuser', 'Superuser'), ('admin', 'Admin'), ('user', 'User')], default='user', max_length=255)),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'user',
|
||||
'verbose_name_plural': 'users',
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ResetToken',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('token', models.CharField(max_length=255, unique=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Reset Token',
|
||||
'verbose_name_plural': 'Reset Tokens',
|
||||
},
|
||||
),
|
||||
]
|
||||
0
core/apps/accounts/migrations/__init__.py
Normal file
0
core/apps/accounts/migrations/__init__.py
Normal file
3
core/apps/accounts/models/__init__.py
Normal file
3
core/apps/accounts/models/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# isort: skip_file
|
||||
from .user import * # noqa
|
||||
from .reset_token import * # noqa
|
||||
15
core/apps/accounts/models/reset_token.py
Normal file
15
core/apps/accounts/models/reset_token.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
from django_core.models import AbstractBaseModel
|
||||
|
||||
|
||||
class ResetToken(AbstractBaseModel):
|
||||
token = models.CharField(max_length=255, unique=True)
|
||||
user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
|
||||
|
||||
def __str__(self):
|
||||
return self.token
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Reset Token"
|
||||
verbose_name_plural = "Reset Tokens"
|
||||
24
core/apps/accounts/models/user.py
Normal file
24
core/apps/accounts/models/user.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from django.contrib.auth import models as auth_models
|
||||
from django.db import models
|
||||
|
||||
from ..choices import RoleChoice
|
||||
from ..managers import UserManager
|
||||
|
||||
|
||||
class User(auth_models.AbstractUser):
|
||||
phone = models.CharField(max_length=255, unique=True)
|
||||
username = models.CharField(max_length=255, null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
validated_at = models.DateTimeField(null=True, blank=True)
|
||||
role = models.CharField(
|
||||
max_length=255,
|
||||
choices=RoleChoice,
|
||||
default=RoleChoice.USER,
|
||||
)
|
||||
|
||||
USERNAME_FIELD = "phone"
|
||||
objects = UserManager()
|
||||
|
||||
def __str__(self):
|
||||
return self.phone
|
||||
1
core/apps/accounts/seeder/__init__.py
Normal file
1
core/apps/accounts/seeder/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .core import * # noqa
|
||||
10
core/apps/accounts/seeder/core.py
Normal file
10
core/apps/accounts/seeder/core.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
Create a new user/superuser
|
||||
"""
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
|
||||
class UserSeeder:
|
||||
def run(self):
|
||||
get_user_model().objects.create_superuser("998888112309", "2309")
|
||||
4
core/apps/accounts/serializers/__init__.py
Normal file
4
core/apps/accounts/serializers/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .auth import * # noqa
|
||||
from .change_password import * # noqa
|
||||
from .set_password import * # noqa
|
||||
from .user import * # noqa
|
||||
60
core/apps/accounts/serializers/auth.py
Normal file
60
core/apps/accounts/serializers/auth.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from config.env import env
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils.translation import gettext as _
|
||||
from rest_framework import exceptions, serializers
|
||||
|
||||
OTP_SIZE = env.int("OTP_SIZE", 4)
|
||||
class LoginSerializer(serializers.Serializer):
|
||||
username = serializers.CharField(max_length=255)
|
||||
password = serializers.CharField(max_length=255)
|
||||
|
||||
|
||||
class RegisterSerializer(serializers.ModelSerializer):
|
||||
phone = serializers.CharField(max_length=255)
|
||||
|
||||
def validate_phone(self, value):
|
||||
user = get_user_model().objects.filter(phone=value, validated_at__isnull=False)
|
||||
if user.exists():
|
||||
raise exceptions.ValidationError(_("Phone number already registered."), code="unique")
|
||||
return value
|
||||
|
||||
class Meta:
|
||||
model = get_user_model()
|
||||
fields = ["first_name", "last_name", "phone", "password"]
|
||||
extra_kwargs = {
|
||||
"first_name": {
|
||||
"required": True,
|
||||
},
|
||||
"last_name": {"required": True},
|
||||
}
|
||||
|
||||
|
||||
class ConfirmSerializer(serializers.Serializer):
|
||||
code = serializers.CharField(max_length=OTP_SIZE, min_length=OTP_SIZE)
|
||||
phone = serializers.CharField(max_length=255)
|
||||
|
||||
|
||||
class ResetPasswordSerializer(serializers.Serializer):
|
||||
phone = serializers.CharField(max_length=255)
|
||||
|
||||
def validate_phone(self, value):
|
||||
user = get_user_model().objects.filter(phone=value)
|
||||
if user.exists():
|
||||
return value
|
||||
|
||||
raise serializers.ValidationError(_("User does not exist"))
|
||||
|
||||
|
||||
class ResetConfirmationSerializer(serializers.Serializer):
|
||||
code = serializers.CharField(min_length=OTP_SIZE, max_length=OTP_SIZE)
|
||||
phone = serializers.CharField(max_length=255)
|
||||
|
||||
def validate_phone(self, value):
|
||||
user = get_user_model().objects.filter(phone=value)
|
||||
if user.exists():
|
||||
return value
|
||||
raise serializers.ValidationError(_("User does not exist"))
|
||||
|
||||
|
||||
class ResendSerializer(serializers.Serializer):
|
||||
phone = serializers.CharField(max_length=255)
|
||||
6
core/apps/accounts/serializers/change_password.py
Normal file
6
core/apps/accounts/serializers/change_password.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class ChangePasswordSerializer(serializers.Serializer):
|
||||
old_password = serializers.CharField(required=True)
|
||||
new_password = serializers.CharField(required=True, min_length=8)
|
||||
6
core/apps/accounts/serializers/set_password.py
Normal file
6
core/apps/accounts/serializers/set_password.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class SetPasswordSerializer(serializers.Serializer):
|
||||
password = serializers.CharField()
|
||||
token = serializers.CharField(max_length=255)
|
||||
23
core/apps/accounts/serializers/user.py
Normal file
23
core/apps/accounts/serializers/user.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
exclude = [
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"password",
|
||||
"groups",
|
||||
"user_permissions"
|
||||
]
|
||||
model = get_user_model()
|
||||
|
||||
|
||||
class UserUpdateSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = get_user_model()
|
||||
fields = [
|
||||
"first_name",
|
||||
"last_name"
|
||||
]
|
||||
1
core/apps/accounts/signals/__init__.py
Normal file
1
core/apps/accounts/signals/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .user import * # noqa
|
||||
17
core/apps/accounts/signals/user.py
Normal file
17
core/apps/accounts/signals/user.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
|
||||
@receiver(post_save, sender=get_user_model())
|
||||
def user_signal(sender, created, instance, **kwargs):
|
||||
"""[TODO:summary]
|
||||
|
||||
Args:
|
||||
sender ([TODO:type]): [TODO:description]
|
||||
created ([TODO:type]): [TODO:description]
|
||||
instance ([TODO:type]): [TODO:description]
|
||||
"""
|
||||
if created and instance.username is None:
|
||||
instance.username = "U%(id)s" % {"id": 1000 + instance.id}
|
||||
instance.save()
|
||||
1
core/apps/accounts/tasks/__init__.py
Normal file
1
core/apps/accounts/tasks/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .sms import * # noqa
|
||||
38
core/apps/accounts/tasks/sms.py
Normal file
38
core/apps/accounts/tasks/sms.py
Normal file
@@ -0,0 +1,38 @@
|
||||
#type: ignore
|
||||
"""
|
||||
Base celery tasks
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from importlib import import_module
|
||||
|
||||
from celery import shared_task
|
||||
from config.env import env
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
|
||||
@shared_task
|
||||
def SendConfirm(phone, code):
|
||||
"""Tasdiqlash ko'dini yuborish
|
||||
|
||||
Args:
|
||||
phone (str, int): telefon no'mer
|
||||
code (str, int): tasdiqlash ko'di
|
||||
|
||||
Raises:
|
||||
Exception: [TODO:description]
|
||||
"""
|
||||
try:
|
||||
service = getattr(
|
||||
import_module(os.getenv("OTP_MODULE")), os.getenv("OTP_SERVICE")
|
||||
)()
|
||||
service.send_sms(
|
||||
phone, env.str("OTP_MESSAGE", _("Sizning Tasdiqlash ko'dingiz: %(code)s")) % {"code": code}
|
||||
)
|
||||
logging.info("Sms send: %s-%s" % (phone, code))
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
"Error: {phone}-{code}\n\n{error}".format(phone=phone, code=code, error=e)
|
||||
) # noqa
|
||||
raise Exception
|
||||
0
core/apps/accounts/tests/__init__.py
Normal file
0
core/apps/accounts/tests/__init__.py
Normal file
126
core/apps/accounts/tests/test_auth.py
Normal file
126
core/apps/accounts/tests/test_auth.py
Normal file
@@ -0,0 +1,126 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from core.apps.accounts.models import ResetToken
|
||||
from core.services import SmsService
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.urls import reverse
|
||||
from django_core.models import SmsConfirm
|
||||
from pydantic import BaseModel
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
|
||||
class TokenModel(BaseModel):
|
||||
access: str
|
||||
refresh: str
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_client():
|
||||
return APIClient()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_user(db):
|
||||
phone = "998999999999"
|
||||
password = "password"
|
||||
user = get_user_model().objects.create_user(phone=phone, first_name="John", last_name="Doe", password=password)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sms_code(test_user):
|
||||
code = "1111"
|
||||
SmsConfirm.objects.create(phone=test_user.phone, code=code)
|
||||
return code
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_reg_view(api_client):
|
||||
data = {
|
||||
"phone": "998999999991",
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"password": "password",
|
||||
}
|
||||
with patch.object(SmsService, "send_confirm", return_value=True):
|
||||
response = api_client.post(reverse("auth-register"), data=data)
|
||||
assert response.status_code == status.HTTP_202_ACCEPTED
|
||||
assert response.data["data"]["detail"] == f"Sms {data['phone']} raqamiga yuborildi"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_confirm_view(api_client, test_user, sms_code):
|
||||
data = {"phone": test_user.phone, "code": sms_code}
|
||||
response = api_client.post(reverse("auth-confirm"), data=data)
|
||||
assert response.status_code == status.HTTP_202_ACCEPTED
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_invalid_confirm_view(api_client, test_user):
|
||||
data = {"phone": test_user.phone, "code": "1112"}
|
||||
response = api_client.post(reverse("auth-confirm"), data=data)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_reset_confirmation_code_view(api_client, test_user, sms_code):
|
||||
data = {"phone": test_user.phone, "code": sms_code}
|
||||
response = api_client.post(reverse("auth-confirm"), data=data)
|
||||
assert response.status_code == status.HTTP_202_ACCEPTED
|
||||
assert "token" in response.data["data"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_reset_confirmation_code_view_invalid_code(api_client, test_user):
|
||||
data = {"phone": test_user.phone, "code": "123456"}
|
||||
response = api_client.post(reverse("auth-confirm"), data=data)
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_reset_set_password_view(api_client, test_user):
|
||||
token = ResetToken.objects.create(user=test_user, token="token")
|
||||
data = {"token": token.token, "password": "new_password"}
|
||||
response = api_client.post(reverse("reset-password-reset-password-set"), data=data)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_reset_set_password_view_invalid_token(api_client):
|
||||
token = "test_token"
|
||||
data = {"token": token, "password": "new_password"}
|
||||
with patch.object(get_user_model().objects, "filter", return_value=get_user_model().objects.none()):
|
||||
response = api_client.post(reverse("reset-password-reset-password-set"), data=data)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.data["data"]["detail"] == "Invalid token"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_resend_view(api_client, test_user):
|
||||
data = {"phone": test_user.phone}
|
||||
response = api_client.post(reverse("auth-resend"), data=data)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_reset_password_view(api_client, test_user):
|
||||
data = {"phone": test_user.phone}
|
||||
response = api_client.post(reverse("reset-password-reset-password"), data=data)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_me_view(api_client, test_user):
|
||||
api_client.force_authenticate(user=test_user)
|
||||
response = api_client.get(reverse("me-me"))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_me_update_view(api_client, test_user):
|
||||
api_client.force_authenticate(user=test_user)
|
||||
data = {"first_name": "Updated"}
|
||||
response = api_client.patch(reverse("me-user-update"), data=data)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
77
core/apps/accounts/tests/test_change_password.py
Normal file
77
core/apps/accounts/tests/test_change_password.py
Normal file
@@ -0,0 +1,77 @@
|
||||
import pytest
|
||||
from core.apps.accounts.serializers import ChangePasswordSerializer
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_client():
|
||||
return APIClient()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_user(db):
|
||||
phone = "9981111111"
|
||||
password = "12345670"
|
||||
user = get_user_model().objects.create_user(phone=phone, password=password, email="test@example.com")
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def change_password_url():
|
||||
return reverse("change-password-change-password")
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_change_password_success(api_client, test_user, change_password_url):
|
||||
api_client.force_authenticate(user=test_user)
|
||||
data = {
|
||||
"old_password": "12345670",
|
||||
"new_password": "newpassword",
|
||||
}
|
||||
response = api_client.post(change_password_url, data=data, format="json")
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["data"]["detail"] == "password changed successfully"
|
||||
|
||||
# Yangi parolni bazadan tekshiramiz
|
||||
test_user.refresh_from_db()
|
||||
assert test_user.check_password("newpassword")
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_change_password_invalid_old_password(api_client, test_user, change_password_url):
|
||||
api_client.force_authenticate(user=test_user)
|
||||
data = {
|
||||
"old_password": "wrongpassword",
|
||||
"new_password": "newpassword",
|
||||
}
|
||||
response = api_client.post(change_password_url, data=data, format="json")
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.data["data"]["detail"] == "invalida password"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_change_password_serializer_validation():
|
||||
valid_data = {
|
||||
"old_password": "12345670",
|
||||
"new_password": "newpassword",
|
||||
}
|
||||
serializer = ChangePasswordSerializer(data=valid_data)
|
||||
assert serializer.is_valid()
|
||||
|
||||
invalid_data = {
|
||||
"old_password": "12345670",
|
||||
"new_password": "123",
|
||||
}
|
||||
serializer = ChangePasswordSerializer(data=invalid_data)
|
||||
assert not serializer.is_valid()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_change_password_view_permissions(api_client, change_password_url):
|
||||
# autentifikatsiyasiz request
|
||||
api_client.force_authenticate(user=None)
|
||||
response = api_client.post(change_password_url, data={}, format="json")
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
26
core/apps/accounts/urls.py
Normal file
26
core/apps/accounts/urls.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
Accounts app urls
|
||||
"""
|
||||
|
||||
from django.urls import path, include
|
||||
from rest_framework_simplejwt import views as jwt_views
|
||||
from .views import RegisterView, ResetPasswordView, MeView, ChangePasswordView
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register("auth", RegisterView, basename="auth")
|
||||
router.register("auth", ResetPasswordView, basename="reset-password")
|
||||
router.register("auth", MeView, basename="me")
|
||||
router.register("auth", ChangePasswordView, basename="change-password")
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("", include(router.urls)),
|
||||
path("auth/token/", jwt_views.TokenObtainPairView.as_view(), name="token_obtain_pair"),
|
||||
path("auth/token/verify/", jwt_views.TokenVerifyView.as_view(), name="token_verify"),
|
||||
path(
|
||||
"auth/token/refresh/",
|
||||
jwt_views.TokenRefreshView.as_view(),
|
||||
name="token_refresh",
|
||||
),
|
||||
]
|
||||
1
core/apps/accounts/views/__init__.py
Normal file
1
core/apps/accounts/views/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .auth import * # noqa
|
||||
209
core/apps/accounts/views/auth.py
Normal file
209
core/apps/accounts/views/auth.py
Normal file
@@ -0,0 +1,209 @@
|
||||
import uuid
|
||||
from typing import Type
|
||||
|
||||
from core.services import UserService, SmsService
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_core import exceptions
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework import status, throttling, request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.viewsets import GenericViewSet
|
||||
from django_core.mixins import BaseViewSetMixin
|
||||
from rest_framework.decorators import action
|
||||
from ..serializers import (
|
||||
RegisterSerializer,
|
||||
ConfirmSerializer,
|
||||
ResendSerializer,
|
||||
ResetPasswordSerializer,
|
||||
ResetConfirmationSerializer,
|
||||
SetPasswordSerializer,
|
||||
UserSerializer,
|
||||
UserUpdateSerializer,
|
||||
)
|
||||
from rest_framework.permissions import AllowAny
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from drf_spectacular.utils import OpenApiResponse
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from ..serializers import ChangePasswordSerializer
|
||||
|
||||
from .. import models
|
||||
|
||||
|
||||
@extend_schema(tags=["register"])
|
||||
class RegisterView(BaseViewSetMixin, GenericViewSet, UserService):
|
||||
throttle_classes = [throttling.UserRateThrottle]
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def get_serializer_class(self):
|
||||
match self.action:
|
||||
case "register":
|
||||
return RegisterSerializer
|
||||
case "confirm":
|
||||
return ConfirmSerializer
|
||||
case "resend":
|
||||
return ResendSerializer
|
||||
case _:
|
||||
return RegisterSerializer
|
||||
|
||||
@action(methods=["POST"], detail=False, url_path="register")
|
||||
def register(self, request):
|
||||
ser = self.get_serializer(data=request.data)
|
||||
ser.is_valid(raise_exception=True)
|
||||
data = ser.data
|
||||
phone = data.get("phone")
|
||||
# Create pending user
|
||||
self.create_user(phone, data.get("first_name"), data.get("last_name"), data.get("password"))
|
||||
self.send_confirmation(phone) # Send confirmation code for sms eskiz.uz
|
||||
return Response(
|
||||
{"detail": _("Sms %(phone)s raqamiga yuborildi") % {"phone": phone}},
|
||||
status=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
|
||||
@extend_schema(summary="Auth confirm.", description="Auth confirm user.")
|
||||
@action(methods=["POST"], detail=False, url_path="confirm")
|
||||
def confirm(self, request):
|
||||
ser = self.get_serializer(data=request.data)
|
||||
ser.is_valid(raise_exception=True)
|
||||
data = ser.data
|
||||
phone, code = data.get("phone"), data.get("code")
|
||||
try:
|
||||
if SmsService.check_confirm(phone, code=code):
|
||||
token = self.validate_user(get_user_model().objects.filter(phone=phone).first())
|
||||
return Response(
|
||||
data={
|
||||
"detail": _("Tasdiqlash ko'di qabul qilindi"),
|
||||
"token": token,
|
||||
},
|
||||
status=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
except exceptions.SmsException as e:
|
||||
raise PermissionDenied(e) # Response exception for APIException
|
||||
except Exception as e:
|
||||
raise PermissionDenied(e) # Api exception for APIException
|
||||
|
||||
@action(methods=["POST"], detail=False, url_path="resend")
|
||||
def resend(self, rq: Type[request.Request]):
|
||||
ser = self.get_serializer(data=rq.data)
|
||||
ser.is_valid(raise_exception=True)
|
||||
phone = ser.data.get("phone")
|
||||
self.send_confirmation(phone)
|
||||
return Response({"detail": _("Sms %(phone)s raqamiga yuborildi") % {"phone": phone}})
|
||||
|
||||
|
||||
@extend_schema(tags=["reset-password"])
|
||||
class ResetPasswordView(BaseViewSetMixin, GenericViewSet, UserService):
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def get_serializer_class(self):
|
||||
match self.action:
|
||||
case "reset_password":
|
||||
return ResetPasswordSerializer
|
||||
case "reset_confirm":
|
||||
return ResetConfirmationSerializer
|
||||
case "reset_password_set":
|
||||
return SetPasswordSerializer
|
||||
case _:
|
||||
return None
|
||||
|
||||
@action(methods=["POST"], detail=False, url_path="reset-password")
|
||||
def reset_password(self, request):
|
||||
ser = self.get_serializer(data=request.data)
|
||||
ser.is_valid(raise_exception=True)
|
||||
phone = ser.data.get("phone")
|
||||
self.send_confirmation(phone)
|
||||
return Response({"detail": _("Sms %(phone)s raqamiga yuborildi") % {"phone": phone}})
|
||||
|
||||
@action(methods=["POST"], detail=False, url_path="reset-password-confirm")
|
||||
def reset_confirm(self, request):
|
||||
ser = self.get_serializer(data=request.data)
|
||||
ser.is_valid(raise_exception=True)
|
||||
|
||||
data = ser.data
|
||||
code, phone = data.get("code"), data.get("phone")
|
||||
try:
|
||||
SmsService.check_confirm(phone, code)
|
||||
token = models.ResetToken.objects.create(
|
||||
user=get_user_model().objects.filter(phone=phone).first(),
|
||||
token=str(uuid.uuid4()),
|
||||
)
|
||||
return Response(
|
||||
data={
|
||||
"token": token.token,
|
||||
"created_at": token.created_at,
|
||||
"updated_at": token.updated_at,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except exceptions.SmsException as e:
|
||||
raise PermissionDenied(str(e))
|
||||
except Exception as e:
|
||||
raise PermissionDenied(str(e))
|
||||
|
||||
@action(methods=["POST"], detail=False, url_path="reset-password-set")
|
||||
def reset_password_set(self, request):
|
||||
ser = self.get_serializer(data=request.data)
|
||||
ser.is_valid(raise_exception=True)
|
||||
data = ser.data
|
||||
token = data.get("token")
|
||||
password = data.get("password")
|
||||
token = models.ResetToken.objects.filter(token=token)
|
||||
if not token.exists():
|
||||
raise PermissionDenied(_("Invalid token"))
|
||||
phone = token.first().user.phone
|
||||
token.delete()
|
||||
self.change_password(phone, password)
|
||||
return Response({"detail": _("password updated")}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
@extend_schema(tags=["me"])
|
||||
class MeView(BaseViewSetMixin, GenericViewSet, UserService):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_serializer_class(self):
|
||||
match self.action:
|
||||
case "me":
|
||||
return UserSerializer
|
||||
case "user_update":
|
||||
return UserUpdateSerializer
|
||||
case _:
|
||||
return None
|
||||
|
||||
@action(methods=["GET", "OPTIONS"], detail=False, url_path="me")
|
||||
def me(self, request):
|
||||
return Response(self.get_serializer(request.user).data)
|
||||
|
||||
@action(methods=["PATCH", "PUT"], detail=False, url_path="user-update")
|
||||
def user_update(self, request):
|
||||
ser = self.get_serializer(instance=request.user, data=request.data, partial=True)
|
||||
ser.is_valid(raise_exception=True)
|
||||
ser.save()
|
||||
return Response({"detail": _("Malumotlar yangilandi")})
|
||||
|
||||
|
||||
@extend_schema(tags=["change-password"], description="Parolni o'zgartirish uchun")
|
||||
class ChangePasswordView(BaseViewSetMixin, GenericViewSet):
|
||||
serializer_class = ChangePasswordSerializer
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
@extend_schema(
|
||||
request=serializer_class,
|
||||
responses={200: OpenApiResponse(ChangePasswordSerializer)},
|
||||
summary="Change user password.",
|
||||
description="Change password of the authenticated user.",
|
||||
)
|
||||
@action(methods=["POST"], detail=False, url_path="change-password")
|
||||
def change_password(self, request, *args, **kwargs):
|
||||
user = self.request.user
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
if user.check_password(request.data["old_password"]):
|
||||
user.password = make_password(request.data["new_password"])
|
||||
user.save()
|
||||
return Response(
|
||||
data={"detail": "password changed successfully"},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
raise PermissionDenied(_("invalida password"))
|
||||
2
core/apps/logs/.gitignore
vendored
Normal file
2
core/apps/logs/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
0
core/apps/shared/__init__.py
Normal file
0
core/apps/shared/__init__.py
Normal file
1
core/apps/shared/admin/__init__.py
Normal file
1
core/apps/shared/admin/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .settings import * # noqa
|
||||
20
core/apps/shared/admin/settings.py
Normal file
20
core/apps/shared/admin/settings.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from django.contrib import admin
|
||||
from unfold.admin import ModelAdmin, StackedInline
|
||||
from core.apps.shared.models import SettingsModel, OptionsModel
|
||||
from unfold.contrib.forms.widgets import ArrayWidget
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
|
||||
|
||||
class OptionsInline(StackedInline):
|
||||
model = OptionsModel
|
||||
extra = 1
|
||||
formfield_overrides = {
|
||||
ArrayField: {"widget": ArrayWidget},
|
||||
}
|
||||
|
||||
|
||||
@admin.register(SettingsModel)
|
||||
class SettingsAdmin(ModelAdmin):
|
||||
list_display = ["id", "key"]
|
||||
inlines = [OptionsInline]
|
||||
|
||||
6
core/apps/shared/apps.py
Normal file
6
core/apps/shared/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ModuleConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "core.apps.shared"
|
||||
17
core/apps/shared/enums/__init__.py
Normal file
17
core/apps/shared/enums/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class BaseEnum(Enum):
|
||||
|
||||
def choices(self):
|
||||
return [(x.name, x.value) for x in self]
|
||||
|
||||
|
||||
class GenderEnum(BaseEnum):
|
||||
MALE = "male"
|
||||
FEMALE = "female"
|
||||
|
||||
|
||||
class RoleEnum(BaseEnum):
|
||||
ADMIN = "admin"
|
||||
USER = "user"
|
||||
41
core/apps/shared/migrations/0001_initial.py
Normal file
41
core/apps/shared/migrations/0001_initial.py
Normal file
@@ -0,0 +1,41 @@
|
||||
# Generated by Django 5.1.3 on 2025-07-11 15:00
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SettingsModel',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('key', models.CharField(verbose_name='key')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Settings',
|
||||
'verbose_name_plural': 'Settings',
|
||||
'db_table': 'settings',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='OptionsModel',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('key', models.CharField(verbose_name='key')),
|
||||
('value', models.CharField(verbose_name='value')),
|
||||
('settings', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='options', to='shared.settingsmodel', verbose_name='settings')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Options',
|
||||
'verbose_name_plural': 'Options',
|
||||
'db_table': 'options',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,46 @@
|
||||
# Generated by Django 5.1.3 on 2025-07-12 05:19
|
||||
|
||||
import django.contrib.postgres.fields
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shared', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='settingsmodel',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='settingsmodel',
|
||||
name='description',
|
||||
field=models.TextField(blank=True, null=True, verbose_name='description'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='settingsmodel',
|
||||
name='is_public',
|
||||
field=models.BooleanField(default=False, verbose_name='is public'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='settingsmodel',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(auto_now=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='optionsmodel',
|
||||
name='key',
|
||||
field=models.CharField(max_length=255, verbose_name='key'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='optionsmodel',
|
||||
name='value',
|
||||
field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=255, verbose_name='value'), size=None, verbose_name='value'),
|
||||
),
|
||||
]
|
||||
0
core/apps/shared/migrations/__init__.py
Normal file
0
core/apps/shared/migrations/__init__.py
Normal file
1
core/apps/shared/models/__init__.py
Normal file
1
core/apps/shared/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .settings import * # noqa
|
||||
31
core/apps/shared/models/settings.py
Normal file
31
core/apps/shared/models/settings.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_core.models import AbstractBaseModel
|
||||
|
||||
|
||||
class SettingsModel(AbstractBaseModel):
|
||||
key = models.CharField(_("key"))
|
||||
is_public = models.BooleanField(_("is public"), default=False)
|
||||
description = models.TextField(_("description"), blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "settings"
|
||||
verbose_name = _("Settings")
|
||||
verbose_name_plural = _("Settings")
|
||||
|
||||
|
||||
class OptionsModel(models.Model):
|
||||
settings = models.ForeignKey(
|
||||
"SettingsModel", verbose_name=_("settings"), on_delete=models.CASCADE, related_name="options"
|
||||
)
|
||||
key = models.CharField(_("key"), max_length=255)
|
||||
value = ArrayField(
|
||||
models.CharField(_("value"), max_length=255),
|
||||
verbose_name=_("value"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "options"
|
||||
verbose_name = _("Options")
|
||||
verbose_name_plural = _("Options")
|
||||
1
core/apps/shared/serializers/__init__.py
Normal file
1
core/apps/shared/serializers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .settings import * # noqa
|
||||
1
core/apps/shared/serializers/settings/__init__.py
Normal file
1
core/apps/shared/serializers/settings/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .settings import * # noqa
|
||||
7
core/apps/shared/serializers/settings/settings.py
Normal file
7
core/apps/shared/serializers/settings/settings.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class ListLanguageSerializer(serializers.Serializer):
|
||||
code = serializers.CharField(read_only=True)
|
||||
name = serializers.CharField(read_only=True)
|
||||
is_default = serializers.BooleanField(read_only=True, default=False)
|
||||
1
core/apps/shared/tests/__init__.py
Normal file
1
core/apps/shared/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .test_settings import * # noqa
|
||||
20
core/apps/shared/tests/test_settings.py
Normal file
20
core/apps/shared/tests/test_settings.py
Normal file
@@ -0,0 +1,20 @@
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_client():
|
||||
return APIClient()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings_urls():
|
||||
return {
|
||||
"languages": reverse("settings-languages"),
|
||||
}
|
||||
|
||||
|
||||
def test_languages(api_client, settings_urls):
|
||||
response = api_client.get(settings_urls["languages"])
|
||||
assert response.status_code == 200
|
||||
11
core/apps/shared/urls.py
Normal file
11
core/apps/shared/urls.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import SettingsView
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register("settings", SettingsView, basename="settings")
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
1
core/apps/shared/utils/__init__.py
Normal file
1
core/apps/shared/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .settings import * # noqa
|
||||
17
core/apps/shared/utils/settings.py
Normal file
17
core/apps/shared/utils/settings.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from core.apps.shared.models import OptionsModel
|
||||
from typing import Optional
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
def get_config(settings: str, key: str, default=None) -> Optional[str]:
|
||||
config = OptionsModel.objects.filter(settings__key=settings, key=key)
|
||||
if not config.exists():
|
||||
return default
|
||||
return config.first().value
|
||||
|
||||
|
||||
def get_exchange_rate():
|
||||
exchange_rate = get_config("currency", "exchange_rate")
|
||||
if exchange_rate is None:
|
||||
raise Exception(_("USD kursi kiritilmagan iltimos adminga murojat qiling"))
|
||||
return float(exchange_rate[0])
|
||||
1
core/apps/shared/views/__init__.py
Normal file
1
core/apps/shared/views/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .settings import * # noqa
|
||||
53
core/apps/shared/views/settings.py
Normal file
53
core/apps/shared/views/settings.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from django_core.mixins import BaseViewSetMixin
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.viewsets import GenericViewSet
|
||||
from django.conf import settings
|
||||
from rest_framework.response import Response
|
||||
from ..serializers import ListLanguageSerializer
|
||||
from drf_spectacular.utils import extend_schema, OpenApiResponse
|
||||
from core.apps.shared.models import SettingsModel
|
||||
|
||||
|
||||
@extend_schema(tags=["settings"])
|
||||
class SettingsView(BaseViewSetMixin, GenericViewSet):
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action in ["languages"]:
|
||||
return ListLanguageSerializer
|
||||
return ListLanguageSerializer
|
||||
|
||||
@extend_schema(responses={200: OpenApiResponse(response=ListLanguageSerializer(many=True))})
|
||||
@action(methods=["GET"], detail=False, url_path="languages", url_name="languages")
|
||||
def languages(self, request):
|
||||
return Response(self.get_serializer(settings.JST_LANGUAGES, many=True).data)
|
||||
|
||||
@extend_schema(
|
||||
summary="Get public settings",
|
||||
responses={
|
||||
200: OpenApiResponse(
|
||||
response={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"example_key": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"example_key": {"type": "array", "items": {"type": "string"}, "example": [12300.50]}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
)
|
||||
@action(methods=["GET"], detail=False, url_path="config", url_name="config")
|
||||
def config(self, request):
|
||||
config = SettingsModel.objects.filter(is_public=True)
|
||||
response = {}
|
||||
for item in config:
|
||||
config_value = {}
|
||||
for option in item.options.all():
|
||||
config_value[option.key] = option.value
|
||||
response[item.key] = config_value
|
||||
return Response(data=response)
|
||||
3
core/services/__init__.py
Normal file
3
core/services/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .otp import * # noqa
|
||||
from .sms import * # noqa
|
||||
from .user import * # noqa
|
||||
168
core/services/otp.py
Normal file
168
core/services/otp.py
Normal file
@@ -0,0 +1,168 @@
|
||||
#type: ignore
|
||||
import requests
|
||||
from config.env import env
|
||||
|
||||
|
||||
class EskizService:
|
||||
GET = "GET"
|
||||
POST = "POST"
|
||||
PATCH = "PATCH"
|
||||
CONTACT = "contact"
|
||||
|
||||
def __init__(self, api_url=None, email=None, password=None, callback_url=None):
|
||||
self.api_url = api_url or env("SMS_API_URL")
|
||||
self.email = email or env("SMS_LOGIN")
|
||||
self.password = password or env("SMS_PASSWORD")
|
||||
self.callback_url = callback_url
|
||||
self.headers = {}
|
||||
|
||||
self.methods = {
|
||||
"auth_user": "auth/user",
|
||||
"auth_login": "auth/login",
|
||||
"auth_refresh": "auth/refresh",
|
||||
"send_message": "message/sms/send",
|
||||
}
|
||||
|
||||
def request(self, api_path, data=None, method=None, headers=None):
|
||||
"""[TODO:summary]
|
||||
|
||||
[TODO:description]
|
||||
|
||||
Args:
|
||||
api_path ([TODO:type]): [TODO:description]
|
||||
data ([TODO:type]): [TODO:description]
|
||||
method ([TODO:type]): [TODO:description]
|
||||
headers ([TODO:type]): [TODO:description]
|
||||
|
||||
Raises:
|
||||
Exception: [TODO:description]
|
||||
"""
|
||||
incoming_data = {"status": "error"}
|
||||
|
||||
try:
|
||||
response = requests.request(
|
||||
method,
|
||||
f"{self.api_url}/{api_path}",
|
||||
data=data,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
if api_path == self.methods["auth_refresh"]:
|
||||
if response.status_code == 200:
|
||||
incoming_data["status"] = "success"
|
||||
else:
|
||||
incoming_data = response.json()
|
||||
except requests.RequestException as error:
|
||||
raise Exception(str(error))
|
||||
|
||||
return incoming_data
|
||||
|
||||
def auth(self):
|
||||
"""[TODO:summary]
|
||||
|
||||
[TODO:description]
|
||||
"""
|
||||
data = {"email": self.email, "password": self.password}
|
||||
|
||||
return self.request(self.methods["auth_login"], data=data, method=self.POST)
|
||||
|
||||
def refresh_token(self):
|
||||
"""[TODO:summary]
|
||||
|
||||
[TODO:description]
|
||||
"""
|
||||
token = self.auth()["data"]["token"]
|
||||
self.headers["Authorization"] = "Bearer " + token
|
||||
|
||||
context = {
|
||||
"headers": self.headers,
|
||||
"method": self.PATCH,
|
||||
"api_path": self.methods["auth_refresh"],
|
||||
}
|
||||
|
||||
return self.request(
|
||||
context["api_path"],
|
||||
method=context["method"],
|
||||
headers=context["headers"],
|
||||
)
|
||||
|
||||
def get_my_user_info(self):
|
||||
"""[TODO:summary]
|
||||
|
||||
[TODO:description]
|
||||
"""
|
||||
token = self.auth()["data"]["token"]
|
||||
self.headers["Authorization"] = "Bearer " + token
|
||||
|
||||
data = {
|
||||
"headers": self.headers,
|
||||
"method": self.GET,
|
||||
"api_path": self.methods["auth_user"],
|
||||
}
|
||||
|
||||
return self.request(data["api_path"], method=data["method"], headers=data["headers"])
|
||||
|
||||
def add_sms_contact(self, first_name, phone_number, group):
|
||||
"""[TODO:summary]
|
||||
|
||||
[TODO:description]
|
||||
|
||||
Args:
|
||||
first_name ([TODO:type]): [TODO:description]
|
||||
phone_number ([TODO:type]): [TODO:description]
|
||||
group ([TODO:type]): [TODO:description]
|
||||
"""
|
||||
token = self.auth()["data"]["token"]
|
||||
self.headers["Authorization"] = "Bearer " + token
|
||||
|
||||
data = {
|
||||
"name": first_name,
|
||||
"email": self.email,
|
||||
"group": group,
|
||||
"mobile_phone": phone_number,
|
||||
}
|
||||
|
||||
context = {
|
||||
"headers": self.headers,
|
||||
"method": self.POST,
|
||||
"api_path": self.CONTACT,
|
||||
"data": data,
|
||||
}
|
||||
|
||||
return self.request(
|
||||
context["api_path"],
|
||||
data=context["data"],
|
||||
method=context["method"],
|
||||
headers=context["headers"],
|
||||
)
|
||||
|
||||
def send_sms(self, phone_number, message):
|
||||
"""Sms yuborish
|
||||
|
||||
Args:
|
||||
phone_number (str): telefon no'mer
|
||||
message (str): xabar
|
||||
"""
|
||||
token = self.auth()["data"]["token"]
|
||||
self.headers["Authorization"] = "Bearer " + token
|
||||
|
||||
data = {
|
||||
"from": 4546,
|
||||
"mobile_phone": phone_number,
|
||||
"callback_url": self.callback_url,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
context = {
|
||||
"headers": self.headers,
|
||||
"method": self.POST,
|
||||
"api_path": self.methods["send_message"],
|
||||
"data": data,
|
||||
}
|
||||
|
||||
return self.request(
|
||||
context["api_path"],
|
||||
data=context["data"],
|
||||
method=context["method"],
|
||||
headers=context["headers"],
|
||||
)
|
||||
84
core/services/sms.py
Normal file
84
core/services/sms.py
Normal file
@@ -0,0 +1,84 @@
|
||||
#type: ignore
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from config.env import env
|
||||
from core.apps.accounts.tasks.sms import SendConfirm
|
||||
from django_core import exceptions, models
|
||||
|
||||
|
||||
class SmsService:
|
||||
@staticmethod
|
||||
def send_confirm(phone):
|
||||
"""Tasdiqlash ko'dini yuborish
|
||||
|
||||
Args:
|
||||
phone (str): telefon no'mer
|
||||
|
||||
Raises:
|
||||
exceptions.SmsException: [TODO:description]
|
||||
"""
|
||||
# TODO: Deploy this change when deploying -> code = random.randint(1000, 9999) # noqa
|
||||
if env.bool("OTP_PROD", False):
|
||||
code = "".join(str(random.randint(0, 9)) for _ in range(env.int("OTP_SIZE", 4)))
|
||||
else:
|
||||
code = env.int("OTP_DEFAULT", 1111)
|
||||
|
||||
sms_confirm, status = models.SmsConfirm.objects.get_or_create(phone=phone, defaults={"code": code})
|
||||
|
||||
sms_confirm.sync_limits()
|
||||
|
||||
if sms_confirm.resend_unlock_time is not None:
|
||||
expired = sms_confirm.interval(sms_confirm.resend_unlock_time)
|
||||
exception = exceptions.SmsException(f"Resend blocked, try again in {expired}", expired=expired)
|
||||
raise exception
|
||||
|
||||
sms_confirm.code = code
|
||||
sms_confirm.try_count = 0
|
||||
sms_confirm.resend_count += 1
|
||||
sms_confirm.phone = phone
|
||||
sms_confirm.expired_time = datetime.now() + timedelta(seconds=models.SmsConfirm.SMS_EXPIRY_SECONDS) # noqa
|
||||
sms_confirm.resend_unlock_time = datetime.now() + timedelta(
|
||||
seconds=models.SmsConfirm.SMS_EXPIRY_SECONDS
|
||||
) # noqa
|
||||
sms_confirm.save()
|
||||
|
||||
SendConfirm.delay(phone, code)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def check_confirm(phone, code):
|
||||
"""Tasdiqlash ko'dini haqiqiyligini tekshirish
|
||||
|
||||
Args:
|
||||
phone ([TODO:type]): [TODO:description]
|
||||
code ([TODO:type]): [TODO:description]
|
||||
|
||||
Raises:
|
||||
exceptions.SmsException: [TODO:description]
|
||||
exceptions.SmsException: [TODO:description]
|
||||
exceptions.SmsException: [TODO:description]
|
||||
exceptions.SmsException: [TODO:description]
|
||||
"""
|
||||
sms_confirm = models.SmsConfirm.objects.filter(phone=phone).first()
|
||||
|
||||
if sms_confirm is None:
|
||||
raise exceptions.SmsException("Invalid confirmation code")
|
||||
|
||||
sms_confirm.sync_limits()
|
||||
|
||||
if sms_confirm.is_expired():
|
||||
raise exceptions.SmsException("Time for confirmation has expired")
|
||||
|
||||
if sms_confirm.is_block():
|
||||
expired = sms_confirm.interval(sms_confirm.unlock_time)
|
||||
raise exceptions.SmsException(f"Try again in {expired}")
|
||||
|
||||
if str(sms_confirm.code) == str(code):
|
||||
sms_confirm.delete()
|
||||
return True
|
||||
|
||||
sms_confirm.try_count += 1
|
||||
sms_confirm.save()
|
||||
|
||||
raise exceptions.SmsException("Invalid confirmation code")
|
||||
64
core/services/user.py
Normal file
64
core/services/user.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from datetime import datetime
|
||||
|
||||
from core.services import sms
|
||||
from django.contrib.auth import get_user_model, hashers
|
||||
from django.utils.translation import gettext as _
|
||||
from django_core import exceptions
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework_simplejwt import tokens
|
||||
|
||||
|
||||
class UserService(sms.SmsService):
|
||||
def get_token(self, user):
|
||||
refresh = tokens.RefreshToken.for_user(user)
|
||||
|
||||
return {
|
||||
"refresh": str(refresh),
|
||||
"access": str(refresh.access_token),
|
||||
}
|
||||
|
||||
def create_user(self, phone, first_name, last_name, password):
|
||||
get_user_model().objects.update_or_create(
|
||||
phone=phone,
|
||||
defaults={
|
||||
"phone": phone,
|
||||
"first_name": first_name,
|
||||
"last_name": last_name,
|
||||
"password": hashers.make_password(password),
|
||||
},
|
||||
)
|
||||
|
||||
def send_confirmation(self, phone) -> bool:
|
||||
try:
|
||||
self.send_confirm(phone)
|
||||
return True
|
||||
except exceptions.SmsException as e:
|
||||
raise PermissionDenied(_("Qayta sms yuborish uchun kuting: {}").format(e.kwargs.get("expired")))
|
||||
except Exception:
|
||||
raise PermissionDenied(_("Serverda xatolik yuz berdi"))
|
||||
|
||||
def validate_user(self, user) -> dict:
|
||||
"""
|
||||
Create user if user not found
|
||||
"""
|
||||
if user.validated_at is None:
|
||||
user.validated_at = datetime.now()
|
||||
user.save()
|
||||
token = self.get_token(user)
|
||||
return token
|
||||
|
||||
def is_validated(self, user) -> bool:
|
||||
"""
|
||||
User is validated check
|
||||
"""
|
||||
if user.validated_at is not None:
|
||||
return True
|
||||
return False
|
||||
|
||||
def change_password(self, phone, password):
|
||||
"""
|
||||
Change password
|
||||
"""
|
||||
user = get_user_model().objects.filter(phone=phone).first()
|
||||
user.set_password(password)
|
||||
user.save()
|
||||
3
core/utils/__init__.py
Normal file
3
core/utils/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .cache import * # noqa
|
||||
from .console import * # noqa
|
||||
from .core import * # noqa
|
||||
18
core/utils/cache.py
Normal file
18
core/utils/cache.py
Normal file
@@ -0,0 +1,18 @@
|
||||
import hashlib
|
||||
|
||||
from django.core.cache import cache
|
||||
|
||||
from config.env import env
|
||||
|
||||
|
||||
class Cache:
|
||||
def remember(self, func, key: str, timeout=None, *args, **kwargs):
|
||||
cache_enabled = env.bool("CACHE_ENABLED")
|
||||
key = hashlib.md5(key.encode("utf-8")).hexdigest()
|
||||
response = cache.get(key)
|
||||
if not cache_enabled:
|
||||
return func(*args, **kwargs)
|
||||
elif response is None:
|
||||
response = func(*args, **kwargs)
|
||||
cache.set(key, response, env.int("CACHE_TIME") if timeout is None else timeout)
|
||||
return response
|
||||
78
core/utils/console.py
Normal file
78
core/utils/console.py
Normal file
@@ -0,0 +1,78 @@
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Union
|
||||
|
||||
from django.conf import settings
|
||||
from django.core import management
|
||||
|
||||
|
||||
class Console(management.BaseCommand):
|
||||
"""
|
||||
Console logging class
|
||||
"""
|
||||
|
||||
def get_stdout(self):
|
||||
base_command = management.BaseCommand()
|
||||
return base_command.stdout
|
||||
|
||||
def get_style(self):
|
||||
base_command = management.BaseCommand()
|
||||
return base_command.style
|
||||
|
||||
def success(self, message):
|
||||
logging.debug(message)
|
||||
self.get_stdout().write(self.get_style().SUCCESS(message))
|
||||
|
||||
def error(self, message):
|
||||
self.get_stdout().write(self.get_style().ERROR(message))
|
||||
|
||||
def log(self, message):
|
||||
self.get_stdout().write(
|
||||
self.get_style().ERROR(
|
||||
"\n{line}\n{message}\n{line}\n".format(
|
||||
message=message, line="=" * len(message)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class BaseMake(management.BaseCommand):
|
||||
path: str
|
||||
|
||||
def __init__(self, *args, **options):
|
||||
super().__init__(*args, **options)
|
||||
self.console = Console()
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("name")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
name = options.get("name")
|
||||
if name is None:
|
||||
name = ""
|
||||
|
||||
stub = open(os.path.join(settings.BASE_DIR, f"resources/stub/{self.path}.stub"))
|
||||
data: Union[Any] = stub.read()
|
||||
stub.close()
|
||||
|
||||
stub = data.replace("{{name}}", name or "")
|
||||
|
||||
|
||||
core_http_path = os.path.join(settings.BASE_DIR, "core/http")
|
||||
if os.path.exists(
|
||||
os.path.join(core_http_path, f"{self.path}/{name.lower()}.py")
|
||||
): # noqa
|
||||
self.console.error(f"{self.name} already exists")
|
||||
return
|
||||
|
||||
if not os.path.exists(os.path.join(core_http_path, self.path)):
|
||||
os.makedirs(os.path.join(core_http_path, self.path))
|
||||
|
||||
file = open(
|
||||
os.path.join(core_http_path, f"{self.path}/{name.lower()}.py"),
|
||||
"w+",
|
||||
)
|
||||
file.write(stub) # type: ignore
|
||||
file.close()
|
||||
|
||||
self.console.success(f"{self.name} created")
|
||||
6
core/utils/core.py
Normal file
6
core/utils/core.py
Normal file
@@ -0,0 +1,6 @@
|
||||
class Helper:
|
||||
"""
|
||||
Helper class to handle index
|
||||
"""
|
||||
|
||||
pass
|
||||
33
core/utils/storage.py
Normal file
33
core/utils/storage.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from typing import Optional, Union
|
||||
|
||||
from config.env import env
|
||||
|
||||
|
||||
class Storage:
|
||||
|
||||
storages = ["AWS", "MINIO", "FILE", "STATIC"]
|
||||
|
||||
def __init__(self, storage: Union[str], storage_type: Union[str] = "default") -> None:
|
||||
self.storage = storage
|
||||
self.sorage_type = storage_type
|
||||
if storage not in self.storages:
|
||||
raise ValueError(f"Invalid storage type: {storage}")
|
||||
|
||||
def get_backend(self) -> Optional[str]:
|
||||
match self.storage:
|
||||
case "AWS" | "MINIO":
|
||||
return "storages.backends.s3boto3.S3Boto3Storage"
|
||||
case "FILE":
|
||||
return "django.core.files.storage.FileSystemStorage"
|
||||
case "STATIC":
|
||||
return "django.contrib.staticfiles.storage.StaticFilesStorage"
|
||||
|
||||
def get_options(self) -> Optional[str]:
|
||||
match self.storage:
|
||||
case "AWS" | "MINIO":
|
||||
if self.sorage_type == "default":
|
||||
return {"bucket_name": env.str("STORAGE_BUCKET_MEDIA")}
|
||||
elif self.sorage_type == "static":
|
||||
return {"bucket_name": env.str("STORAGE_BUCKET_STATIC")}
|
||||
case _:
|
||||
return {}
|
||||
61
docker-compose.prod.yml
Normal file
61
docker-compose.prod.yml
Normal file
@@ -0,0 +1,61 @@
|
||||
networks:
|
||||
uzxarid:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
pg_data: null
|
||||
pycache: null
|
||||
media: null
|
||||
static: null
|
||||
|
||||
services:
|
||||
nginx:
|
||||
networks:
|
||||
- uzxarid
|
||||
ports:
|
||||
- ${PORT:-8001}:80
|
||||
volumes:
|
||||
- ./resources/layout/nginx.conf:/etc/nginx/nginx.conf
|
||||
- media:/usr/share/nginx/html/resources/media/:ro
|
||||
- static:/usr/share/nginx/html/resources/staticfiles/:ro
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./docker/Dockerfile.nginx
|
||||
depends_on:
|
||||
- web
|
||||
web:
|
||||
networks:
|
||||
- uzxarid
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./docker/Dockerfile.web
|
||||
restart: always
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- PYTHONPYCACHEPREFIX=/var/cache/pycache
|
||||
- SCRIPT=${SCRIPT:-entrypoint.sh}
|
||||
volumes:
|
||||
- media:/code/resources/media/
|
||||
- static:/code/resources/staticfiles/
|
||||
- pycache:/var/cache/pycache
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
db:
|
||||
networks:
|
||||
- uzxarid
|
||||
image: postgres:16
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_DB: ${DB_NAME:-django}
|
||||
POSTGRES_USER: ${DB_USER:-postgres}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:?Database password must be set in .env file}
|
||||
volumes:
|
||||
- pg_data:/var/lib/postgresql/data
|
||||
redis:
|
||||
networks:
|
||||
- uzxarid
|
||||
restart: always
|
||||
|
||||
image: redis
|
||||
46
docker-compose.test.yml
Normal file
46
docker-compose.test.yml
Normal file
@@ -0,0 +1,46 @@
|
||||
networks:
|
||||
uzxarid:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
pg_data: null
|
||||
pycache: null
|
||||
|
||||
services:
|
||||
web:
|
||||
env_file:
|
||||
- .env
|
||||
networks:
|
||||
- uzxarid
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./docker/Dockerfile.web
|
||||
restart: always
|
||||
container_name: test_web
|
||||
environment:
|
||||
- PYTHONPYCACHEPREFIX=/var/cache/pycache
|
||||
- SCRIPT=${SCRIPT:-entrypoint.sh}
|
||||
volumes:
|
||||
- pycache:/var/cache/pycache
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
db:
|
||||
networks:
|
||||
- uzxarid
|
||||
image: postgres:16
|
||||
restart: always
|
||||
container_name: test_db
|
||||
environment:
|
||||
POSTGRES_DB: ${DB_NAME:-django}
|
||||
POSTGRES_USER: ${DB_USER:-postgres}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-2309}
|
||||
volumes:
|
||||
- pg_data:/var/lib/postgresql/data
|
||||
redis:
|
||||
container_name: test_redis
|
||||
networks:
|
||||
- uzxarid
|
||||
restart: always
|
||||
|
||||
image: redis
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user