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": "2be557271b22cc9e723a50737ccc117709c3c8b3",
|
||||||
|
"checkout": null,
|
||||||
|
"context": {
|
||||||
|
"cookiecutter": {
|
||||||
|
"cacheops": false,
|
||||||
|
"silk": false,
|
||||||
|
"storage": false,
|
||||||
|
"channels": false,
|
||||||
|
"ckeditor": false,
|
||||||
|
"modeltranslation": false,
|
||||||
|
"parler": false,
|
||||||
|
"rosetta": false,
|
||||||
|
"project_name": "aparat",
|
||||||
|
"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": "aparat"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"directory": null
|
||||||
|
}
|
||||||
2
.dockerignore
Normal file
2
.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
venv/
|
||||||
|
resources/staticfiles/
|
||||||
75
.env.example
Normal file
75
.env.example
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
|
# !NOTE: on-storage
|
||||||
|
# # 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
|
||||||
46
Makefile
Normal file
46
Makefile
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
shell:
|
||||||
|
docker compose exec web python manage.py shell
|
||||||
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"]
|
||||||
12
config/asgi.py
Normal file
12
config/asgi.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
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"))
|
||||||
|
|
||||||
|
|
||||||
|
application = asgi_application
|
||||||
|
|
||||||
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()
|
||||||
11
config/conf/__init__.py
Normal file
11
config/conf/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
11
config/conf/apps.py
Normal file
11
config/conf/apps.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
from config.env import env
|
||||||
|
|
||||||
|
APPS = [
|
||||||
|
'django_core',
|
||||||
|
"core.apps.accounts.apps.AccountsConfig",
|
||||||
|
]
|
||||||
|
|
||||||
|
if env.bool("SILK_ENABLED", False):
|
||||||
|
APPS += [
|
||||||
|
|
||||||
|
]
|
||||||
12
config/conf/cache.py
Normal file
12
config/conf/cache.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
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")
|
||||||
|
|
||||||
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 %(pathname)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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
1
config/conf/modules.py
Normal file
1
config/conf/modules.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
MODULES = ["core.apps.shared", "core.apps.management"]
|
||||||
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
167
config/settings/common.py
Normal file
167
config/settings/common.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
#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 = [
|
||||||
|
"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 += [
|
||||||
|
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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(",")
|
||||||
|
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
}
|
||||||
36
config/urls.py
Normal file
36
config/urls.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""
|
||||||
|
All urls configurations tree
|
||||||
|
"""
|
||||||
|
|
||||||
|
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 config.env import env
|
||||||
|
|
||||||
|
def home(request):
|
||||||
|
return HttpResponse("OK")
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("health/", home),
|
||||||
|
path("", include("core.apps.accounts.urls")),
|
||||||
|
path("", include("core.apps.shared.urls")),
|
||||||
|
path("", include("core.apps.management.urls")),
|
||||||
|
]
|
||||||
|
urlpatterns += [
|
||||||
|
path("admin/", admin.site.urls),
|
||||||
|
# path("accounts/", include("django.contrib.auth.urls")),
|
||||||
|
path("i18n/", include("django.conf.urls.i18n")),
|
||||||
|
]
|
||||||
|
if env.bool("SILK_ENABLED", False):
|
||||||
|
urlpatterns += []
|
||||||
|
if env.str("PROJECT_ENV") == "debug":
|
||||||
|
urlpatterns += [
|
||||||
|
]
|
||||||
|
urlpatterns += [
|
||||||
|
re_path("static/(?P<path>.*)", serve, {"document_root": settings.STATIC_ROOT}),
|
||||||
|
re_path("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
42
core/apps/accounts/admin.py
Normal file
42
core/apps/accounts/admin.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django.contrib.auth.admin import UserAdmin
|
||||||
|
from django.contrib.auth.forms import AdminPasswordChangeForm, UserChangeForm
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from .models import User
|
||||||
|
|
||||||
|
class CustomUserAdmin(UserAdmin):
|
||||||
|
model = User
|
||||||
|
change_password_form = AdminPasswordChangeForm
|
||||||
|
form = UserChangeForm
|
||||||
|
|
||||||
|
list_display = ("id", "first_name", "last_name", "phone", "role", "region", "is_active", "is_staff")
|
||||||
|
list_filter = ("role", "region", "is_staff", "is_superuser", "is_active")
|
||||||
|
search_fields = ("phone", "first_name", "last_name", "email")
|
||||||
|
ordering = ("phone",)
|
||||||
|
autocomplete_fields = ["groups"]
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
(None, {"fields": ("phone", "password")}),
|
||||||
|
(_("Personal info"), {"fields": ("first_name", "last_name", "email", "region")}),
|
||||||
|
(_("Permissions"), {
|
||||||
|
"fields": (
|
||||||
|
"is_active",
|
||||||
|
"is_staff",
|
||||||
|
"is_superuser",
|
||||||
|
"groups",
|
||||||
|
"user_permissions",
|
||||||
|
"role",
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
(_("Important dates"), {"fields": ("last_login", "date_joined")}),
|
||||||
|
)
|
||||||
|
|
||||||
|
add_fieldsets = (
|
||||||
|
(None, {
|
||||||
|
"classes": ("wide",),
|
||||||
|
"fields": ("phone", "password1", "password2", "role", "region", "is_active", "is_staff"),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Register the custom user
|
||||||
|
admin.site.register(User, CustomUserAdmin)
|
||||||
6
core/apps/accounts/apps.py
Normal file
6
core/apps/accounts/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AccountsConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'core.apps.accounts'
|
||||||
9
core/apps/accounts/choices.py
Normal file
9
core/apps/accounts/choices.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from django.db import models
|
||||||
|
class RoleChoice(models.TextChoices):
|
||||||
|
"""
|
||||||
|
User Role Choice
|
||||||
|
"""
|
||||||
|
SUPERUSER = "superuser", "Superuser"
|
||||||
|
BUSINESSMAN = "businessman", "Businessman"
|
||||||
|
MANAGER = "manager", "Manager"
|
||||||
|
EMPLOYEE = "employee", "Employee"
|
||||||
37
core/apps/accounts/forms.py
Normal file
37
core/apps/accounts/forms.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from django import forms
|
||||||
|
from django.contrib.auth import authenticate
|
||||||
|
from django.contrib.auth.forms import AuthenticationForm
|
||||||
|
|
||||||
|
class PhoneLoginForm(forms.Form):
|
||||||
|
phone = forms.CharField(
|
||||||
|
label="Phone",
|
||||||
|
max_length=255,
|
||||||
|
widget=forms.TextInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': 'Enter phone number',
|
||||||
|
'autofocus': True
|
||||||
|
})
|
||||||
|
)
|
||||||
|
password = forms.CharField(
|
||||||
|
label="Password",
|
||||||
|
widget=forms.PasswordInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': 'Enter password'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
phone = cleaned_data.get("phone")
|
||||||
|
password = cleaned_data.get("password")
|
||||||
|
if phone and password:
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
User = get_user_model()
|
||||||
|
user = authenticate(username=phone, password=password)
|
||||||
|
if user is None:
|
||||||
|
raise forms.ValidationError("Invalid phone number or password")
|
||||||
|
self.user = user
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
|
def get_user(self):
|
||||||
|
return getattr(self, 'user', None)
|
||||||
22
core/apps/accounts/managers.py
Normal file
22
core/apps/accounts/managers.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
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)
|
||||||
43
core/apps/accounts/migrations/0001_initial.py
Normal file
43
core/apps/accounts/migrations/0001_initial.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Generated by Django 6.0.2 on 2026-02-04 12:15
|
||||||
|
|
||||||
|
import django.utils.timezone
|
||||||
|
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'), ('businessman', 'Businessman'), ('manager', 'Manager'), ('employee', 'Employee')], default='employee', 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')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'user',
|
||||||
|
'verbose_name_plural': 'users',
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
28
core/apps/accounts/migrations/0002_initial.py
Normal file
28
core/apps/accounts/migrations/0002_initial.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 6.0.2 on 2026-02-04 12:15
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0001_initial'),
|
||||||
|
('auth', '0012_alter_user_first_name_max_length'),
|
||||||
|
('management', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='region',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Only for managers', null=True, on_delete=django.db.models.deletion.SET_NULL, to='management.region'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='user_permissions',
|
||||||
|
field=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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
20
core/apps/accounts/migrations/0003_user_warehouse.py
Normal file
20
core/apps/accounts/migrations/0003_user_warehouse.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Generated by Django 6.0.2 on 2026-02-06 09:54
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0002_initial'),
|
||||||
|
('management', '0013_rename_name_device_address_remove_device_monthly_fee_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='warehouse',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Only for employees', null=True, on_delete=django.db.models.deletion.PROTECT, to='management.warehouse'),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
core/apps/accounts/migrations/__init__.py
Normal file
0
core/apps/accounts/migrations/__init__.py
Normal file
43
core/apps/accounts/models.py
Normal file
43
core/apps/accounts/models.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
from django.contrib.auth import models as auth_models
|
||||||
|
from django.db import models
|
||||||
|
from .choices import RoleChoice
|
||||||
|
from .managers import UserManager
|
||||||
|
from ..management.models import Region, Warehouse
|
||||||
|
|
||||||
|
|
||||||
|
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.EMPLOYEE,
|
||||||
|
)
|
||||||
|
|
||||||
|
region = models.ForeignKey(
|
||||||
|
Region,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Only for managers"
|
||||||
|
)
|
||||||
|
|
||||||
|
warehouse = models.ForeignKey(
|
||||||
|
Warehouse,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Only for employees"
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_full_name(self):
|
||||||
|
return f"{self.first_name} {self.last_name}".strip() or self.phone
|
||||||
|
|
||||||
|
USERNAME_FIELD = "phone"
|
||||||
|
objects = UserManager()
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.phone
|
||||||
147
core/apps/accounts/templates/auth/login.html
Normal file
147
core/apps/accounts/templates/auth/login.html
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
<!-- templates/auth/login.html -->
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Login</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary: #4f46e5;
|
||||||
|
--bg: #f4f6fb;
|
||||||
|
--text: #111827;
|
||||||
|
--muted: #6b7280;
|
||||||
|
--danger: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg);
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: env(safe-area-inset-top) 14px env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px 20px 28px;
|
||||||
|
box-shadow: 0 12px 40px rgba(0,0,0,.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 24px;
|
||||||
|
font-size: 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 14px;
|
||||||
|
font-size: 16px; /* prevents iOS zoom */
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
outline: none;
|
||||||
|
transition: border .15s, box-shadow .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(79,70,229,.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: none;
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active {
|
||||||
|
transform: scale(.99);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: var(--danger);
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
body {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 26px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h1>Sign in</h1>
|
||||||
|
|
||||||
|
{% if messages %}
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="error">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if form.non_field_errors %}
|
||||||
|
<div class="error">
|
||||||
|
{{ form.non_field_errors.0 }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" novalidate>
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form.phone.label_tag }}
|
||||||
|
{{ form.phone }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form.password.label_tag }}
|
||||||
|
{{ form.password }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn">Login</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3
core/apps/accounts/tests.py
Normal file
3
core/apps/accounts/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
8
core/apps/accounts/urls.py
Normal file
8
core/apps/accounts/urls.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from .views import logout_view, login_view, dashboard
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", login_view, name="login"),
|
||||||
|
path("logout/", logout_view, name="logout"),
|
||||||
|
path('dashboard/', dashboard, name='dashboard'),
|
||||||
|
]
|
||||||
43
core/apps/accounts/views.py
Normal file
43
core/apps/accounts/views.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
from django.shortcuts import render, redirect
|
||||||
|
from django.contrib.auth import login, logout
|
||||||
|
from .forms import PhoneLoginForm
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
|
||||||
|
def login_view(request):
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
return redirect('dashboard')
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
form = PhoneLoginForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
user = form.get_user()
|
||||||
|
login(request, user)
|
||||||
|
return redirect('dashboard')
|
||||||
|
else:
|
||||||
|
messages.error(request, "Invalid phone number or password")
|
||||||
|
else:
|
||||||
|
form = PhoneLoginForm()
|
||||||
|
|
||||||
|
return render(request, "auth/login.html", {"form": form})
|
||||||
|
|
||||||
|
|
||||||
|
def logout_view(request):
|
||||||
|
logout(request)
|
||||||
|
return redirect('login')
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def dashboard(request):
|
||||||
|
if request.user.role == "businessman":
|
||||||
|
return redirect("businessman_dashboard")
|
||||||
|
elif request.user.role == "manager":
|
||||||
|
return redirect("manager_dashboard")
|
||||||
|
elif request.user.role == "employee":
|
||||||
|
return redirect("employee_dashboard")
|
||||||
|
else:
|
||||||
|
return redirect("login")
|
||||||
|
|
||||||
|
|
||||||
|
def csrf_failure(request, reason=""):
|
||||||
|
logout(request)
|
||||||
|
return redirect("login")
|
||||||
2
core/apps/logs/.gitignore
vendored
Normal file
2
core/apps/logs/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
0
core/apps/management/__init__.py
Normal file
0
core/apps/management/__init__.py
Normal file
85
core/apps/management/admin.py
Normal file
85
core/apps/management/admin.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from .models import (
|
||||||
|
Region, District, Warehouse, Device,
|
||||||
|
ToyMovement, Income, Expense
|
||||||
|
)
|
||||||
|
from core.apps.accounts.models import User # for ForeignKey references
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# Region Admin
|
||||||
|
# -------------------------
|
||||||
|
@admin.register(Region)
|
||||||
|
class RegionAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("name",)
|
||||||
|
search_fields = ("name",)
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# District Admin
|
||||||
|
# -------------------------
|
||||||
|
@admin.register(District)
|
||||||
|
class DistrictAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("name", "region")
|
||||||
|
list_filter = ("region",)
|
||||||
|
search_fields = ("name", "region__name")
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# Warehouse Admin
|
||||||
|
# -------------------------
|
||||||
|
@admin.register(Warehouse)
|
||||||
|
class WarehouseAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("name", "region", "toys_count", "created_at")
|
||||||
|
list_filter = ("region",)
|
||||||
|
search_fields = ("name", "region__name")
|
||||||
|
readonly_fields = ("created_at",)
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# Device Admin
|
||||||
|
# -------------------------
|
||||||
|
@admin.register(Device)
|
||||||
|
class DeviceAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("address", "district", "created_at")
|
||||||
|
list_filter = ("district",)
|
||||||
|
search_fields = ("name", "district__name",)
|
||||||
|
readonly_fields = ("created_at",)
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# ToyMovement Admin
|
||||||
|
# -------------------------
|
||||||
|
@admin.register(ToyMovement)
|
||||||
|
class ToyMovementAdmin(admin.ModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
"movement_type", "from_warehouse", "to_warehouse",
|
||||||
|
"device", "quantity", "created_by", "created_at"
|
||||||
|
)
|
||||||
|
list_filter = ("movement_type", "from_warehouse", "to_warehouse")
|
||||||
|
search_fields = ("device__name", "created_by__phone")
|
||||||
|
autocomplete_fields = ["device", "created_by", "from_warehouse", "to_warehouse"]
|
||||||
|
readonly_fields = ("created_at",)
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# Income Admin
|
||||||
|
# -------------------------
|
||||||
|
@admin.register(Income)
|
||||||
|
class IncomeAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("device", "amount", "created_by", "created_at")
|
||||||
|
list_filter = ("device",)
|
||||||
|
search_fields = ("device__name", "created_by__phone")
|
||||||
|
autocomplete_fields = ["device", "created_by",]
|
||||||
|
readonly_fields = ("created_at",)
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# Expense Admin
|
||||||
|
# -------------------------
|
||||||
|
@admin.register(Expense)
|
||||||
|
class ExpenseAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("amount", "expense_type", "created_by", "confirmed_by", "is_confirmed", "created_at")
|
||||||
|
list_filter = ("expense_type", "is_confirmed")
|
||||||
|
search_fields = ("expense_type__name", "created_by__phone", "confirmed_by__phone")
|
||||||
|
autocomplete_fields = ["created_by", "confirmed_by"]
|
||||||
|
readonly_fields = ("created_at",)
|
||||||
6
core/apps/management/apps.py
Normal file
6
core/apps/management/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.management'
|
||||||
5
core/apps/management/choice/ToyMovementType.py
Normal file
5
core/apps/management/choice/ToyMovementType.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
|
||||||
|
TOY_MOVEMENT_TYPE = [
|
||||||
|
("from_warehouse", "Ombordan → Aparatga"),
|
||||||
|
("between_warehouses", "Ombordan → Omborga"),
|
||||||
|
]
|
||||||
1
core/apps/management/choice/__init__.py
Normal file
1
core/apps/management/choice/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from .ToyMovementType import *
|
||||||
16
core/apps/management/decorators.py
Normal file
16
core/apps/management/decorators.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
|
|
||||||
|
def role_required(allowed_roles):
|
||||||
|
"""
|
||||||
|
Usage:
|
||||||
|
@role_required(["manager", "businessman"])
|
||||||
|
def view(request):
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
def decorator(view_func):
|
||||||
|
def _wrapped_view(request, *args, **kwargs):
|
||||||
|
if request.user.role not in allowed_roles:
|
||||||
|
raise PermissionDenied
|
||||||
|
return view_func(request, *args, **kwargs)
|
||||||
|
return _wrapped_view
|
||||||
|
return decorator
|
||||||
19
core/apps/management/forms/DeviceForm.py
Normal file
19
core/apps/management/forms/DeviceForm.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from django import forms
|
||||||
|
from ..models import Device, District
|
||||||
|
|
||||||
|
class DeviceForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Device
|
||||||
|
fields = ["address", "district"]
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
user = kwargs.pop('user', None) # get the user from kwargs
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
if user is not None:
|
||||||
|
if user.role == "manager":
|
||||||
|
# Manager: only districts in the same region
|
||||||
|
self.fields['district'].queryset = District.objects.filter(region=user.region)
|
||||||
|
else:
|
||||||
|
# Businessman: show all districts
|
||||||
|
self.fields['district'].queryset = District.objects.all()
|
||||||
62
core/apps/management/forms/ExpenseForm.py
Normal file
62
core/apps/management/forms/ExpenseForm.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
from django import forms
|
||||||
|
from ..models import Expense, Device
|
||||||
|
from core.apps.accounts.models import User
|
||||||
|
|
||||||
|
# Base form
|
||||||
|
class BaseExpenseForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Expense
|
||||||
|
fields = ["amount", "expense_type", "employee", "device"]
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
# Show all devices
|
||||||
|
self.fields["device"].queryset = Device.objects.all()
|
||||||
|
# Show all employees
|
||||||
|
self.fields["employee"].queryset = User.objects.filter(role="employee")
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
expense_type = cleaned_data.get("expense_type")
|
||||||
|
employee = cleaned_data.get("employee")
|
||||||
|
device = cleaned_data.get("device")
|
||||||
|
|
||||||
|
# Salary requires employee
|
||||||
|
if expense_type == Expense.ExpenseType.SALARY and not employee:
|
||||||
|
self.add_error("employee", "Employee must be set for Salary expenses.")
|
||||||
|
|
||||||
|
# Device required for rent/maintenance
|
||||||
|
if expense_type in [Expense.ExpenseType.RENT, Expense.ExpenseType.MAINTENANCE] and not device:
|
||||||
|
self.add_error("device", "Device must be set for this type of expense.")
|
||||||
|
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
|
|
||||||
|
# Employee form: cannot create Salary or Buy Toys
|
||||||
|
class ExpenseFormEmployee(BaseExpenseForm):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
# Remove forbidden types for employee
|
||||||
|
forbidden = [Expense.ExpenseType.SALARY, Expense.ExpenseType.BUY_TOYS]
|
||||||
|
self.fields["expense_type"].choices = [
|
||||||
|
(value, label)
|
||||||
|
for value, label in Expense.ExpenseType.choices
|
||||||
|
if value not in forbidden
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Manager form: cannot create Buy Toys
|
||||||
|
class ExpenseFormManager(BaseExpenseForm):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
forbidden = [Expense.ExpenseType.BUY_TOYS]
|
||||||
|
self.fields["expense_type"].choices = [
|
||||||
|
(value, label)
|
||||||
|
for value, label in Expense.ExpenseType.choices
|
||||||
|
if value not in forbidden
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Businessman form: full access
|
||||||
|
class ExpenseFormBusinessman(BaseExpenseForm):
|
||||||
|
pass
|
||||||
32
core/apps/management/forms/IncomeForm.py
Normal file
32
core/apps/management/forms/IncomeForm.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from django import forms
|
||||||
|
from ..models import Income, Device
|
||||||
|
|
||||||
|
class IncomeForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Income
|
||||||
|
fields = ["device", "amount"]
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.user = kwargs.pop("user", None)
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
if self.user is not None:
|
||||||
|
# Filter devices
|
||||||
|
if self.user.role == "businessman":
|
||||||
|
self.fields["device"].queryset = Device.objects.all()
|
||||||
|
else: # manager or employee
|
||||||
|
self.fields["device"].queryset = Device.objects.filter(district__region=self.user.region)
|
||||||
|
|
||||||
|
# Remove amount for employees
|
||||||
|
if self.user.role == "employee":
|
||||||
|
self.fields.pop("amount", None)
|
||||||
|
|
||||||
|
def save(self, commit=True):
|
||||||
|
instance = super().save(commit=False)
|
||||||
|
if self.user:
|
||||||
|
instance.created_by = self.user
|
||||||
|
if getattr(self.user, "role", None) == "employee":
|
||||||
|
instance.amount = None
|
||||||
|
if commit:
|
||||||
|
instance.save()
|
||||||
|
return instance
|
||||||
11
core/apps/management/forms/RentForm.py
Normal file
11
core/apps/management/forms/RentForm.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
from django import forms
|
||||||
|
from ..models import Rent
|
||||||
|
|
||||||
|
class RentForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Rent
|
||||||
|
fields = ['address', 'district', 'device', 'due_date', 'amount']
|
||||||
|
widgets = {
|
||||||
|
'due_date': forms.DateInput(attrs={'type': 'date'}),
|
||||||
|
'amount': forms.NumberInput(attrs={'min': 0}),
|
||||||
|
}
|
||||||
21
core/apps/management/forms/ToyMovementEmployeeForm.py
Normal file
21
core/apps/management/forms/ToyMovementEmployeeForm.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from django import forms
|
||||||
|
from core.apps.management.models import ToyMovement
|
||||||
|
|
||||||
|
class ToyMovementFormEmployee(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = ToyMovement
|
||||||
|
fields = ["device", "quantity"] # remove from_warehouse
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.user = kwargs.pop("user", None) # pass user from view
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def save(self, commit=True):
|
||||||
|
instance = super().save(commit=False)
|
||||||
|
instance.movement_type = "from_warehouse"
|
||||||
|
instance.to_warehouse = None
|
||||||
|
if self.user and hasattr(self.user, "warehouse"):
|
||||||
|
instance.from_warehouse = self.user.warehouse
|
||||||
|
if commit:
|
||||||
|
instance.save()
|
||||||
|
return instance
|
||||||
30
core/apps/management/forms/ToyMovementForm.py
Normal file
30
core/apps/management/forms/ToyMovementForm.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from django import forms
|
||||||
|
from core.apps.management.models import ToyMovement, Warehouse, Device
|
||||||
|
from core.apps.management.choice import TOY_MOVEMENT_TYPE
|
||||||
|
|
||||||
|
class ToyMovementForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = ToyMovement
|
||||||
|
fields = ["movement_type", "from_warehouse", "to_warehouse", "device", "quantity"]
|
||||||
|
widgets = {
|
||||||
|
"movement_type": forms.Select(choices=TOY_MOVEMENT_TYPE),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
user = kwargs.pop("user", None)
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields["from_warehouse"].queryset = Warehouse.objects.all()
|
||||||
|
self.fields["to_warehouse"].queryset = Warehouse.objects.all()
|
||||||
|
self.fields["device"].queryset = Device.objects.all()
|
||||||
|
|
||||||
|
def save(self, commit=True):
|
||||||
|
instance = super().save(commit=False)
|
||||||
|
|
||||||
|
if instance.movement_type == "from_warehouse":
|
||||||
|
instance.to_warehouse = None
|
||||||
|
elif instance.movement_type == "between_warehouses":
|
||||||
|
instance.device = None
|
||||||
|
|
||||||
|
if commit:
|
||||||
|
instance.save()
|
||||||
|
return instance
|
||||||
78
core/apps/management/forms/UserCreateForm.py
Normal file
78
core/apps/management/forms/UserCreateForm.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# from django import forms
|
||||||
|
# from ...accounts.models import User, RoleChoice
|
||||||
|
# from ...management.models import Region
|
||||||
|
#
|
||||||
|
# class UserCreateForm(forms.ModelForm):
|
||||||
|
# role = forms.ChoiceField(
|
||||||
|
# choices=[
|
||||||
|
# (RoleChoice.MANAGER, "Manager"),
|
||||||
|
# (RoleChoice.EMPLOYEE, "Employee")
|
||||||
|
# ],
|
||||||
|
# widget=forms.Select(attrs={"class": "form-control"})
|
||||||
|
# )
|
||||||
|
#
|
||||||
|
# password = forms.CharField(
|
||||||
|
# widget=forms.PasswordInput(attrs={"class": "form-control"}),
|
||||||
|
# required=False,
|
||||||
|
# label="Parol"
|
||||||
|
# )
|
||||||
|
#
|
||||||
|
# region = forms.ModelChoiceField(
|
||||||
|
# queryset=Region.objects.none(),
|
||||||
|
# required=False,
|
||||||
|
# widget=forms.Select(attrs={"class": "form-control"})
|
||||||
|
# )
|
||||||
|
#
|
||||||
|
# class Meta:
|
||||||
|
# model = User
|
||||||
|
# fields = ["phone", "password", "role", "first_name", "last_name", "region"]
|
||||||
|
# widgets = {
|
||||||
|
# "phone": forms.TextInput(attrs={"class": "form-control"}),
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
# def __init__(self, *args, **kwargs):
|
||||||
|
# self.creator = kwargs.pop("creator", None)
|
||||||
|
# super().__init__(*args, **kwargs)
|
||||||
|
#
|
||||||
|
# # Password required only on create
|
||||||
|
# if not self.instance or not self.instance.pk:
|
||||||
|
# self.fields["password"].required = True
|
||||||
|
# else:
|
||||||
|
# self.fields["password"].help_text = "Leave blank to keep current password"
|
||||||
|
#
|
||||||
|
# # Manager logic: remove role and region from form
|
||||||
|
# if self.creator and self.creator.role == "manager":
|
||||||
|
# if "role" in self.fields:
|
||||||
|
# self.fields.pop("role")
|
||||||
|
# if "region" in self.fields:
|
||||||
|
# self.fields.pop("region")
|
||||||
|
#
|
||||||
|
#
|
||||||
|
#
|
||||||
|
# # Businessman logic: region queryset
|
||||||
|
# elif self.creator and self.creator.role == "businessman":
|
||||||
|
# self.fields["region"].queryset = Region.objects.all()
|
||||||
|
#
|
||||||
|
# def save(self, commit=True):
|
||||||
|
# user = super().save(commit=False)
|
||||||
|
#
|
||||||
|
# # Manager-created users must have region set
|
||||||
|
# if self.creator and self.creator.role == "manager":
|
||||||
|
# user.region = self.creator.region
|
||||||
|
#
|
||||||
|
# # Only force EMPLOYEE if the target user is not a manager
|
||||||
|
# if user.role != RoleChoice.MANAGER:
|
||||||
|
# user.role = RoleChoice.EMPLOYEE
|
||||||
|
#
|
||||||
|
# # Password
|
||||||
|
# password = self.cleaned_data.get("password")
|
||||||
|
# if password:
|
||||||
|
# user.set_password(password)
|
||||||
|
# else:
|
||||||
|
# if user.pk:
|
||||||
|
# old_user = User.objects.get(pk=user.pk)
|
||||||
|
# user.password = old_user.password
|
||||||
|
#
|
||||||
|
# if commit:
|
||||||
|
# user.save()
|
||||||
|
# return user
|
||||||
7
core/apps/management/forms/WarehouseForm.py
Normal file
7
core/apps/management/forms/WarehouseForm.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django import forms
|
||||||
|
from ..models import Warehouse
|
||||||
|
|
||||||
|
class WarehouseForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Warehouse
|
||||||
|
fields = ["name", "region", "toys_count"]
|
||||||
8
core/apps/management/forms/__init__.py
Normal file
8
core/apps/management/forms/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from .IncomeForm import *
|
||||||
|
from .ExpenseForm import *
|
||||||
|
from .DeviceForm import *
|
||||||
|
from .WarehouseForm import *
|
||||||
|
from .UserCreateForm import *
|
||||||
|
from .ToyMovementEmployeeForm import ToyMovementFormEmployee
|
||||||
|
from .ToyMovementForm import ToyMovementForm
|
||||||
|
from .user import *
|
||||||
50
core/apps/management/forms/user/BaseUserForm.py
Normal file
50
core/apps/management/forms/user/BaseUserForm.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# forms/base.py
|
||||||
|
from django import forms
|
||||||
|
from core.apps.accounts.models import User
|
||||||
|
from core.apps.accounts.choices import RoleChoice
|
||||||
|
|
||||||
|
class BaseUserForm(forms.ModelForm):
|
||||||
|
password = forms.CharField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.PasswordInput(attrs={"class": "form-control"})
|
||||||
|
)
|
||||||
|
role = forms.ChoiceField(
|
||||||
|
choices=[
|
||||||
|
(RoleChoice.MANAGER, "Manager"),
|
||||||
|
(RoleChoice.EMPLOYEE, "Employee"),
|
||||||
|
],
|
||||||
|
widget=forms.Select(attrs={"class": "form-control"})
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = [
|
||||||
|
"phone",
|
||||||
|
"password",
|
||||||
|
"role",
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"region",
|
||||||
|
"warehouse",
|
||||||
|
]
|
||||||
|
|
||||||
|
def clean_role(self):
|
||||||
|
role = self.cleaned_data["role"]
|
||||||
|
if role in (RoleChoice.BUSINESSMAN, RoleChoice.SUPERUSER):
|
||||||
|
raise forms.ValidationError("This role cannot be assigned.")
|
||||||
|
return role
|
||||||
|
|
||||||
|
def save(self, commit=True):
|
||||||
|
user = super().save(commit=False)
|
||||||
|
|
||||||
|
password = self.cleaned_data.get("password")
|
||||||
|
if password:
|
||||||
|
user.set_password(password)
|
||||||
|
else:
|
||||||
|
if user.pk:
|
||||||
|
old_user = User.objects.get(pk=user.pk)
|
||||||
|
user.password = old_user.password
|
||||||
|
|
||||||
|
if commit:
|
||||||
|
user.save()
|
||||||
|
return user
|
||||||
40
core/apps/management/forms/user/UserCreateFormBusinessman.py
Normal file
40
core/apps/management/forms/user/UserCreateFormBusinessman.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# forms/businessman_create.py
|
||||||
|
from .BaseUserForm import BaseUserForm
|
||||||
|
from core.apps.accounts.models import RoleChoice
|
||||||
|
from core.apps.management.models import Region, Warehouse
|
||||||
|
|
||||||
|
class UserCreateFormBusinessman(BaseUserForm):
|
||||||
|
|
||||||
|
class Meta(BaseUserForm.Meta):
|
||||||
|
fields = [
|
||||||
|
"phone",
|
||||||
|
"password",
|
||||||
|
"role",
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"region",
|
||||||
|
"warehouse",
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.fields["password"].required = True
|
||||||
|
self.fields["region"].queryset = Region.objects.all()
|
||||||
|
self.fields["warehouse"].queryset = Warehouse.objects.all()
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned = super().clean()
|
||||||
|
role = cleaned.get("role")
|
||||||
|
warehouse = cleaned.get("warehouse")
|
||||||
|
|
||||||
|
if role == RoleChoice.MANAGER:
|
||||||
|
cleaned["warehouse"] = None
|
||||||
|
|
||||||
|
if role == RoleChoice.EMPLOYEE:
|
||||||
|
if not warehouse:
|
||||||
|
self.add_error("warehouse", "Warehouse is required for employee")
|
||||||
|
else:
|
||||||
|
cleaned["region"] = warehouse.region
|
||||||
|
|
||||||
|
return cleaned
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# forms/manager_create.py
|
||||||
|
from .BaseUserForm import BaseUserForm
|
||||||
|
from core.apps.accounts.models import RoleChoice
|
||||||
|
from core.apps.management.models import Warehouse
|
||||||
|
|
||||||
|
class UserCreateFormManagerToEmployee(BaseUserForm):
|
||||||
|
class Meta(BaseUserForm.Meta):
|
||||||
|
fields = [
|
||||||
|
"phone",
|
||||||
|
"password",
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"warehouse",
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.manager = kwargs.pop("manager")
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
# Password is required for creation
|
||||||
|
self.fields["password"].required = True
|
||||||
|
|
||||||
|
# Filter warehouses to manager's region
|
||||||
|
self.fields["warehouse"].queryset = Warehouse.objects.filter(
|
||||||
|
region=self.manager.region
|
||||||
|
)
|
||||||
|
|
||||||
|
# Hide role field, manager can only create employees
|
||||||
|
self.fields.pop("role", None)
|
||||||
|
|
||||||
|
def save(self, commit=True):
|
||||||
|
user = super().save(commit=False)
|
||||||
|
|
||||||
|
# Always set role to EMPLOYEE
|
||||||
|
user.role = RoleChoice.EMPLOYEE
|
||||||
|
|
||||||
|
# Set region from selected warehouse
|
||||||
|
if user.warehouse:
|
||||||
|
user.region = user.warehouse.region
|
||||||
|
|
||||||
|
# Only set password if provided
|
||||||
|
password = self.cleaned_data.get("password")
|
||||||
|
if password:
|
||||||
|
user.set_password(password)
|
||||||
|
|
||||||
|
if commit:
|
||||||
|
user.save()
|
||||||
|
return user
|
||||||
31
core/apps/management/forms/user/UserEditFormBusinessman.py
Normal file
31
core/apps/management/forms/user/UserEditFormBusinessman.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# forms/businessman_edit.py
|
||||||
|
from .BaseUserForm import BaseUserForm
|
||||||
|
from core.apps.accounts.models import RoleChoice
|
||||||
|
from core.apps.management.models import Warehouse
|
||||||
|
from django import forms
|
||||||
|
|
||||||
|
|
||||||
|
class UserEditFormBusinessman(BaseUserForm):
|
||||||
|
class Meta(BaseUserForm.Meta):
|
||||||
|
fields = BaseUserForm.Meta.fields
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.fields["warehouse"].queryset = Warehouse.objects.all()
|
||||||
|
|
||||||
|
if self.instance.role == RoleChoice.MANAGER:
|
||||||
|
self.fields["warehouse"].widget = forms.HiddenInput()
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned = super().clean()
|
||||||
|
role = cleaned.get("role")
|
||||||
|
warehouse = cleaned.get("warehouse")
|
||||||
|
|
||||||
|
if role == RoleChoice.MANAGER:
|
||||||
|
cleaned["warehouse"] = None
|
||||||
|
|
||||||
|
if role == RoleChoice.EMPLOYEE and warehouse:
|
||||||
|
cleaned["region"] = warehouse.region
|
||||||
|
|
||||||
|
return cleaned
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# forms/manager_edit.py
|
||||||
|
from .BaseUserForm import BaseUserForm
|
||||||
|
from core.apps.management.models import Warehouse
|
||||||
|
from core.apps.accounts.choices import RoleChoice
|
||||||
|
|
||||||
|
class UserEditFormManagerToEmployee(BaseUserForm):
|
||||||
|
class Meta(BaseUserForm.Meta):
|
||||||
|
fields = [
|
||||||
|
"phone",
|
||||||
|
"password",
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"warehouse",
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.manager = kwargs.pop("manager")
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.fields.pop("role", None)
|
||||||
|
|
||||||
|
# 👇 decision based on edited user's role
|
||||||
|
if self.instance.role == RoleChoice.MANAGER:
|
||||||
|
# editing manager → no warehouse
|
||||||
|
self.fields.pop("warehouse", None)
|
||||||
|
else:
|
||||||
|
# editing employee → show warehouse
|
||||||
|
self.fields["warehouse"].queryset = Warehouse.objects.filter(
|
||||||
|
region=self.manager.region
|
||||||
|
)
|
||||||
|
|
||||||
|
def save(self, commit=True):
|
||||||
|
user = super().save(commit=False)
|
||||||
|
user.region = user.warehouse.region
|
||||||
|
|
||||||
|
if commit:
|
||||||
|
user.save()
|
||||||
|
return user
|
||||||
5
core/apps/management/forms/user/__init__.py
Normal file
5
core/apps/management/forms/user/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from .BaseUserForm import *
|
||||||
|
from .UserEditFormBusinessman import *
|
||||||
|
from .UserCreateFormBusinessman import *
|
||||||
|
from .UserCreateFormManagerToEmployee import *
|
||||||
|
from .UserEditFormManagerToEmployee import *
|
||||||
79
core/apps/management/migrations/0001_initial.py
Normal file
79
core/apps/management/migrations/0001_initial.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# Generated by Django 6.0.2 on 2026-02-04 12:15
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='District',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=100)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Region',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=100, unique=True)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Device',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=100)),
|
||||||
|
('toys_count', models.PositiveIntegerField(default=0)),
|
||||||
|
('monthly_fee', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('district', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='management.district')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='district',
|
||||||
|
name='region',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='districts', to='management.region'),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Warehouse',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=100, unique=True)),
|
||||||
|
('toys_count', models.PositiveIntegerField(default=0)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('region', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='warehouses', to='management.region')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ToyMovement',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('movement_type', models.CharField(choices=[('from_warehouse', 'Warehouse → Device'), ('to_device', 'Device refill'), ('between_warehouses', 'Warehouse → Warehouse')], max_length=30)),
|
||||||
|
('quantity', models.PositiveIntegerField()),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
|
||||||
|
('device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='management.device')),
|
||||||
|
('from_warehouse', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='outgoing', to='management.warehouse')),
|
||||||
|
('to_warehouse', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='incoming', to='management.warehouse')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='device',
|
||||||
|
name='warehouse',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='management.warehouse'),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='district',
|
||||||
|
unique_together={('region', 'name')},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Generated by Django 6.0.2 on 2026-02-04 12:41
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('management', '0001_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ExpenseType',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=255, unique=True)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Expense',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('amount', models.DecimalField(decimal_places=2, max_digits=12)),
|
||||||
|
('is_confirmed', models.BooleanField(default=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('confirmed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='confirmed_expenses', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
|
||||||
|
('expense_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='expenses', to='management.expensetype')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Income',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('amount', models.DecimalField(decimal_places=2, max_digits=12)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('confirmed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='confirmed_income', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('device', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='management.device')),
|
||||||
|
('reported_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='reported_income', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def load_data(apps, schema_editor):
|
||||||
|
Region = apps.get_model("management", "Region")
|
||||||
|
District = apps.get_model("management", "District")
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"Qoraqalpogʻiston Respublikasi": [
|
||||||
|
"Amudaryo tumani", "Beruniy tumani", "Chimboy tumani",
|
||||||
|
"Ellikqalʼa tumani", "Kegeyli tumani", "Moʻynoq tumani",
|
||||||
|
"Nukus tumani", "Qanlikoʻl tumani", "Qoʻngʻirot tumani",
|
||||||
|
"Qoraoʻzak tumani", "Shumanay tumani", "Taxiatosh tumani",
|
||||||
|
"Taxtakoʻpir tumani", "Toʻrtkoʻl tumani", "Xoʻjayli tumani"
|
||||||
|
],
|
||||||
|
"Andijon viloyati": [
|
||||||
|
"Andijon tumani", "Asaka tumani", "Baliqchi tumani",
|
||||||
|
"Boʻz tumani", "Buloqboshi tumani", "Izboskan tumani",
|
||||||
|
"Jalaquduq tumani", "Marhamat tumani", "Oltinkoʻl tumani",
|
||||||
|
"Paxtaobod tumani", "Qoʻrgʻontepa tumani",
|
||||||
|
"Shahrixon tumani", "Ulugʻnor tumani", "Xoʻjaobod tumani"
|
||||||
|
],
|
||||||
|
"Buxoro viloyati": [
|
||||||
|
"Buxoro tumani", "Gʻijduvon tumani", "Jondor tumani",
|
||||||
|
"Kogon tumani", "Olot tumani", "Peshku tumani",
|
||||||
|
"Qorakoʻl tumani", "Qorovulbozor tumani",
|
||||||
|
"Romitan tumani", "Shofirkon tumani", "Vobkent tumani"
|
||||||
|
],
|
||||||
|
"Fargʻona viloyati": [
|
||||||
|
"Bagʻdod tumani", "Beshariq tumani", "Dangʻara tumani",
|
||||||
|
"Fargʻona tumani", "Furqat tumani", "Oltiariq tumani",
|
||||||
|
"Qoʻshtepa tumani", "Quva tumani", "Rishton tumani",
|
||||||
|
"Soʻx tumani", "Toshloq tumani", "Uchkoʻprik tumani",
|
||||||
|
"Yozyovon tumani"
|
||||||
|
],
|
||||||
|
"Jizzax viloyati": [
|
||||||
|
"Arnasoy tumani", "Baxmal tumani", "Doʻstlik tumani",
|
||||||
|
"Forish tumani", "Gʻallaorol tumani", "Mirzachoʻl tumani",
|
||||||
|
"Paxtakor tumani", "Sharof Rashidov tumani",
|
||||||
|
"Yangiobod tumani", "Zomin tumani", "Zafarobod tumani"
|
||||||
|
],
|
||||||
|
"Xorazm viloyati": [
|
||||||
|
"Bogʻot tumani", "Gurlan tumani", "Hazorasp tumani",
|
||||||
|
"Qoʻshkoʻpir tumani", "Shovot tumani",
|
||||||
|
"Urganch tumani", "Xazorasp tumani", "Xiva tumani",
|
||||||
|
"Yangiariq tumani", "Yangibozor tumani"
|
||||||
|
],
|
||||||
|
"Namangan viloyati": [
|
||||||
|
"Chortoq tumani", "Chust tumani", "Kosonsoy tumani",
|
||||||
|
"Mingbuloq tumani", "Namangan tumani",
|
||||||
|
"Norin tumani", "Pop tumani", "Toʻraqoʻrgʻon tumani",
|
||||||
|
"Uchqoʻrgʻon tumani", "Uychi tumani", "Yangiqoʻrgʻon tumani"
|
||||||
|
],
|
||||||
|
"Navoiy viloyati": [
|
||||||
|
"Karmana tumani", "Konimex tumani", "Navbahor tumani",
|
||||||
|
"Nurota tumani", "Qiziltepa tumani",
|
||||||
|
"Tomdi tumani", "Uchquduq tumani", "Xatirchi tumani"
|
||||||
|
],
|
||||||
|
"Qashqadaryo viloyati": [
|
||||||
|
"Chiroqchi tumani", "Dehqonobod tumani",
|
||||||
|
"Gʻuzor tumani", "Kasbi tumani", "Kitob tumani",
|
||||||
|
"Koson tumani", "Mirishkor tumani", "Muborak tumani",
|
||||||
|
"Nishon tumani", "Qamashi tumani",
|
||||||
|
"Qarshi tumani", "Shahrisabz tumani",
|
||||||
|
"Yakkabogʻ tumani"
|
||||||
|
],
|
||||||
|
"Samarqand viloyati": [
|
||||||
|
"Bulungʻur tumani", "Ishtixon tumani",
|
||||||
|
"Jomboy tumani", "Kattaqoʻrgʻon tumani",
|
||||||
|
"Narpay tumani", "Nurobod tumani",
|
||||||
|
"Oqdaryo tumani", "Paxtachi tumani",
|
||||||
|
"Pastdargʻom tumani", "Payariq tumani",
|
||||||
|
"Qoʻshrabot tumani", "Samarqand tumani",
|
||||||
|
"Toyloq tumani", "Urgut tumani"
|
||||||
|
],
|
||||||
|
"Surxondaryo viloyati": [
|
||||||
|
"Angor tumani", "Bandixon tumani", "Boysun tumani",
|
||||||
|
"Denov tumani", "Jarqoʻrgʻon tumani",
|
||||||
|
"Muzrabot tumani", "Oltinsoy tumani",
|
||||||
|
"Qiziriq tumani", "Qumqoʻrgʻon tumani",
|
||||||
|
"Sariosiyo tumani", "Sherobod tumani",
|
||||||
|
"Shoʻrchi tumani", "Termiz tumani", "Uzun tumani"
|
||||||
|
],
|
||||||
|
"Sirdaryo viloyati": [
|
||||||
|
"Boyovut tumani", "Guliston tumani",
|
||||||
|
"Mirzaobod tumani", "Oqoltin tumani",
|
||||||
|
"Sayxunobod tumani", "Sardoba tumani",
|
||||||
|
"Xovos tumani"
|
||||||
|
],
|
||||||
|
"Toshkent viloyati": [
|
||||||
|
"Angren tumani", "Bekobod tumani",
|
||||||
|
"Boʻka tumani", "Boʻstonliq tumani",
|
||||||
|
"Chinoz tumani", "Ohangaron tumani",
|
||||||
|
"Oqqoʻrgʻon tumani", "Parkent tumani",
|
||||||
|
"Piskent tumani", "Quyi Chirchiq tumani",
|
||||||
|
"Yangiyoʻl tumani", "Yuqori Chirchiq tumani",
|
||||||
|
"Zangiota tumani"
|
||||||
|
],
|
||||||
|
"Toshkent shahri": [
|
||||||
|
"Bektemir tumani", "Chilonzor tumani",
|
||||||
|
"Hamza tumani", "Mirobod tumani",
|
||||||
|
"Mirzo Ulugʻbek tumani", "Olmazor tumani",
|
||||||
|
"Sergeli tumani", "Shayxontohur tumani",
|
||||||
|
"Uchtepa tumani", "Yakkasaroy tumani",
|
||||||
|
"Yashnobod tumani", "Yunusobod tumani"
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
for region_name, districts in data.items():
|
||||||
|
region, _ = Region.objects.get_or_create(name=region_name)
|
||||||
|
District.objects.bulk_create(
|
||||||
|
[District(name=d, region=region) for d in districts],
|
||||||
|
ignore_conflicts=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("management", "0002_expensetype_expense_income"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(load_data),
|
||||||
|
]
|
||||||
18
core/apps/management/migrations/0004_alter_device_name.py
Normal file
18
core/apps/management/migrations/0004_alter_device_name.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0.2 on 2026-02-05 05:50
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('management', '0003_load_uzbekistan_regions_districts'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='device',
|
||||||
|
name='name',
|
||||||
|
field=models.CharField(max_length=100, unique=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Generated by Django 6.0.2 on 2026-02-05 06:05
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('management', '0004_alter_device_name'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='toymovement',
|
||||||
|
name='from_warehouse',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='outgoing', to='management.warehouse'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='toymovement',
|
||||||
|
name='movement_type',
|
||||||
|
field=models.CharField(choices=[('from_warehouse', 'Warehouse → Device'), ('between_warehouses', 'Warehouse → Warehouse')], max_length=30),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 6.0.2 on 2026-02-05 06:16
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('management', '0005_alter_toymovement_from_warehouse_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='device',
|
||||||
|
name='warehouse',
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Generated by Django 6.0.2 on 2026-02-05 07:49
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('management', '0006_remove_device_warehouse'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='expense',
|
||||||
|
name='expense_type',
|
||||||
|
field=models.CharField(choices=[('rent', 'Rent'), ('salary', 'Salary'), ('utilities', 'Utilities'), ('maintenance', 'Maintenance'), ('other', 'Other')], default='other', max_length=20),
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name='ExpenseType',
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0.2 on 2026-02-05 07:52
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('management', '0007_alter_expense_expense_type_delete_expensetype'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='expense',
|
||||||
|
name='expense_type',
|
||||||
|
field=models.CharField(choices=[('rent', 'Ijara'), ('salary', 'Maosh'), ('utilities', 'Kommunal to‘lovlar'), ('maintenance', 'Texnik xizmat'), ('other', 'Boshqa')], default='other', max_length=20),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0.2 on 2026-02-05 07:53
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('management', '0008_alter_expense_expense_type'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='expense',
|
||||||
|
name='expense_type',
|
||||||
|
field=models.CharField(choices=[('rent', 'Ijara'), ('salary', 'Maosh'), ('utilities', 'Kommunal to‘lovlar'), ('maintenance', 'Texnik xizmat'), ('food', 'Oziq-ovqat'), ('transport', "Yo'lkira"), ('other', 'Boshqa')], default='other', max_length=20),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Generated by Django 6.0.2 on 2026-02-05 08:03
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('management', '0009_alter_expense_expense_type'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='expense',
|
||||||
|
name='device',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='management.device'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='expense',
|
||||||
|
name='employee',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='salaries', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='expense',
|
||||||
|
name='expense_type',
|
||||||
|
field=models.CharField(choices=[('rent', 'Ijara'), ('salary', 'Maosh'), ('utilities', 'Kommunal to‘lovlar'), ('maintenance', 'Texnik xizmat'), ('food', 'Oziq-ovqat'), ('transport', "Yo'lkira"), ('buy_toys', 'Oʻyinchoqlar sotib olish'), ('other', 'Boshqa')], default='other', max_length=20),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Generated by Django 6.0.2 on 2026-02-05 08:04
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('management', '0010_expense_device_expense_employee_and_more'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='expense',
|
||||||
|
name='confirmed_by',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='confirmed_expenses', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Generated by Django 6.0.2 on 2026-02-05 09:37
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('management', '0011_alter_expense_confirmed_by'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='income',
|
||||||
|
name='reported_by',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='income',
|
||||||
|
name='created_by',
|
||||||
|
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.PROTECT, related_name='created_incomes', to=settings.AUTH_USER_MODEL),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='income',
|
||||||
|
name='is_confirmed',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='income',
|
||||||
|
name='confirmed_by',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='confirmed_incomes', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='income',
|
||||||
|
name='device',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='incomes', to='management.device'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Generated by Django 6.0.2 on 2026-02-06 07:08
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('management', '0012_remove_income_reported_by_income_created_by_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='device',
|
||||||
|
old_name='name',
|
||||||
|
new_name='address',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='device',
|
||||||
|
name='monthly_fee',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='device',
|
||||||
|
name='toys_count',
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='toymovement',
|
||||||
|
name='movement_type',
|
||||||
|
field=models.CharField(choices=[('from_warehouse', 'Ombordan → Aparatga'), ('between_warehouses', 'Ombordan → Omborga')], max_length=30),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Rent',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('address', models.CharField(max_length=100, unique=True)),
|
||||||
|
('due_date', models.DateField()),
|
||||||
|
('amount', models.IntegerField()),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('device', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='device_rents', to='management.device')),
|
||||||
|
('district', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='district_rents', to='management.district')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
18
core/apps/management/migrations/0014_alter_income_amount.py
Normal file
18
core/apps/management/migrations/0014_alter_income_amount.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0.2 on 2026-02-06 12:15
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('management', '0013_rename_name_device_address_remove_device_monthly_fee_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='income',
|
||||||
|
name='amount',
|
||||||
|
field=models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 6.0.2 on 2026-02-06 12:54
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('management', '0014_alter_income_amount'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='income',
|
||||||
|
name='confirmed_by',
|
||||||
|
),
|
||||||
|
]
|
||||||
0
core/apps/management/migrations/__init__.py
Normal file
0
core/apps/management/migrations/__init__.py
Normal file
8
core/apps/management/models/__init__.py
Normal file
8
core/apps/management/models/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from .region import *
|
||||||
|
from .device import *
|
||||||
|
from .income import *
|
||||||
|
from .district import *
|
||||||
|
from .toyMovement import *
|
||||||
|
from .warehouse import *
|
||||||
|
from .expense import *
|
||||||
|
from .rent import *
|
||||||
10
core/apps/management/models/device.py
Normal file
10
core/apps/management/models/device.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from django.db import models
|
||||||
|
from .district import District
|
||||||
|
|
||||||
|
class Device(models.Model):
|
||||||
|
address = models.CharField(max_length=100, unique=True)
|
||||||
|
district = models.ForeignKey(District, on_delete=models.PROTECT)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.address
|
||||||
16
core/apps/management/models/district.py
Normal file
16
core/apps/management/models/district.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from django.db import models
|
||||||
|
from .region import Region
|
||||||
|
|
||||||
|
class District(models.Model):
|
||||||
|
region = models.ForeignKey(
|
||||||
|
Region,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="districts"
|
||||||
|
)
|
||||||
|
name = models.CharField(max_length=100)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ("region", "name")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name} — {self.region.name}"
|
||||||
47
core/apps/management/models/expense.py
Normal file
47
core/apps/management/models/expense.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
from django.db import models
|
||||||
|
from core.apps.management.models import Device
|
||||||
|
|
||||||
|
class Expense(models.Model):
|
||||||
|
class ExpenseType(models.TextChoices):
|
||||||
|
RENT = "rent", "Ijara"
|
||||||
|
SALARY = "salary", "Maosh"
|
||||||
|
UTILITIES = "utilities", "Kommunal to‘lovlar"
|
||||||
|
MAINTENANCE = "maintenance", "Texnik xizmat"
|
||||||
|
FOOD = "food", "Oziq-ovqat"
|
||||||
|
TRANSPORT = "transport", "Yo'lkira"
|
||||||
|
BUY_TOYS = "buy_toys", "Oʻyinchoqlar sotib olish"
|
||||||
|
OTHER = "other", "Boshqa"
|
||||||
|
|
||||||
|
amount = models.DecimalField(max_digits=12, decimal_places=2)
|
||||||
|
expense_type = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=ExpenseType.choices,
|
||||||
|
default=ExpenseType.OTHER,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Conditional fields
|
||||||
|
employee = models.ForeignKey("accounts.User", related_name="salaries", null=True, blank=True, on_delete=models.PROTECT)
|
||||||
|
device = models.ForeignKey(Device, null=True, blank=True, on_delete=models.PROTECT)
|
||||||
|
|
||||||
|
created_by = models.ForeignKey("accounts.User", on_delete=models.PROTECT)
|
||||||
|
confirmed_by = models.ForeignKey(
|
||||||
|
"accounts.User", on_delete=models.PROTECT,
|
||||||
|
null=True, blank=True, related_name="confirmed_expenses"
|
||||||
|
)
|
||||||
|
|
||||||
|
is_confirmed = models.BooleanField(default=False)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
|
# Salary requires employee
|
||||||
|
if self.expense_type == self.ExpenseType.SALARY and not self.employee:
|
||||||
|
raise ValidationError({"employee": "Employee must be set for Salary expenses."})
|
||||||
|
|
||||||
|
# Device required for rent/utilities/maintenance
|
||||||
|
if self.expense_type in [
|
||||||
|
self.ExpenseType.RENT,
|
||||||
|
self.ExpenseType.MAINTENANCE
|
||||||
|
] and not self.device:
|
||||||
|
raise ValidationError({"device": "Device must be set for this type of expense."})
|
||||||
10
core/apps/management/models/income.py
Normal file
10
core/apps/management/models/income.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from django.db import models
|
||||||
|
from .device import Device
|
||||||
|
|
||||||
|
class Income(models.Model):
|
||||||
|
device = models.ForeignKey(Device, related_name='incomes',on_delete=models.PROTECT)
|
||||||
|
amount = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
|
||||||
|
created_by = models.ForeignKey("accounts.User", on_delete=models.PROTECT, related_name="created_incomes")
|
||||||
|
|
||||||
|
is_confirmed = models.BooleanField(default=False)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
7
core/apps/management/models/region.py
Normal file
7
core/apps/management/models/region.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
class Region(models.Model):
|
||||||
|
name = models.CharField(max_length=100, unique=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
15
core/apps/management/models/rent.py
Normal file
15
core/apps/management/models/rent.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from django.db import models
|
||||||
|
from .district import District
|
||||||
|
from .device import Device
|
||||||
|
|
||||||
|
class Rent(models.Model):
|
||||||
|
address = models.CharField(max_length=100, unique=True)
|
||||||
|
district = models.ForeignKey(District, related_name="district_rents", on_delete=models.PROTECT)
|
||||||
|
device = models.ForeignKey(Device, related_name="device_rents", on_delete=models.PROTECT)
|
||||||
|
due_date = models.DateField()
|
||||||
|
amount = models.IntegerField()
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.address
|
||||||
23
core/apps/management/models/toyMovement.py
Normal file
23
core/apps/management/models/toyMovement.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from django.db import models
|
||||||
|
from .device import Device
|
||||||
|
from .warehouse import Warehouse
|
||||||
|
from ..choice import TOY_MOVEMENT_TYPE
|
||||||
|
|
||||||
|
class ToyMovement(models.Model):
|
||||||
|
movement_type = models.CharField(max_length=30, choices=TOY_MOVEMENT_TYPE)
|
||||||
|
from_warehouse = models.ForeignKey(
|
||||||
|
Warehouse, on_delete=models.PROTECT,
|
||||||
|
related_name="outgoing"
|
||||||
|
)
|
||||||
|
to_warehouse = models.ForeignKey(
|
||||||
|
Warehouse, on_delete=models.PROTECT,
|
||||||
|
related_name="incoming",
|
||||||
|
null=True, blank=True
|
||||||
|
)
|
||||||
|
device = models.ForeignKey(
|
||||||
|
Device, on_delete=models.PROTECT,
|
||||||
|
null=True, blank=True
|
||||||
|
)
|
||||||
|
quantity = models.PositiveIntegerField()
|
||||||
|
created_by = models.ForeignKey("accounts.User", on_delete=models.PROTECT)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
15
core/apps/management/models/warehouse.py
Normal file
15
core/apps/management/models/warehouse.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from django.db import models
|
||||||
|
from .region import Region
|
||||||
|
|
||||||
|
class Warehouse(models.Model):
|
||||||
|
name = models.CharField(max_length=100, unique=True)
|
||||||
|
region = models.ForeignKey(
|
||||||
|
Region,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name="warehouses"
|
||||||
|
)
|
||||||
|
toys_count = models.PositiveIntegerField(default=0)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
239
core/apps/management/templates/base.html
Normal file
239
core/apps/management/templates/base.html
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="uz">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>{% block title %}Xvatayka{% endblock %}</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Segoe UI', Roboto, system-ui, -apple-system, sans-serif;
|
||||||
|
background: #f4f5f7;
|
||||||
|
color: #1f2937;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
header {
|
||||||
|
background: #4f46e5;
|
||||||
|
color: #fff;
|
||||||
|
padding: 16px 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
header .logo { font-size: 22px; font-weight: 700; letter-spacing: 1px; }
|
||||||
|
|
||||||
|
#menu-btn {
|
||||||
|
position: absolute;
|
||||||
|
left: 24px; top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: #fff; border: none; padding: 6px 10px;
|
||||||
|
border-radius: 8px; cursor: pointer;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
#menu-btn svg { stroke: #4f46e5; }
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
#sidebar {
|
||||||
|
background: #fff;
|
||||||
|
border-right: 1px solid #e5e7eb;
|
||||||
|
padding: 20px;
|
||||||
|
width: 240px;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: -260px;
|
||||||
|
height: 100vh; /* full viewport height */
|
||||||
|
overflow: hidden; /* non-scrollable */
|
||||||
|
transition: left 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between; /* keeps logout at bottom */
|
||||||
|
box-shadow: 0 0 20px rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
#sidebar.active { left: 0; }
|
||||||
|
|
||||||
|
#sidebar ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
#sidebar ul li { margin-bottom: 16px; }
|
||||||
|
#sidebar ul li a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: #1f2937;
|
||||||
|
font-weight: 500;
|
||||||
|
display: block;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
background: #f9fafb;
|
||||||
|
white-space: nowrap; /* prevent wrapping */
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
#sidebar ul li a:hover {
|
||||||
|
background: #4f46e5;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 2px 8px rgba(79,70,229,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Logout button fixed at bottom */
|
||||||
|
.logout-container {
|
||||||
|
margin-top: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.logout-btn {
|
||||||
|
width: 100%;
|
||||||
|
background: #ef4444;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
box-shadow: 0 2px 6px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.logout-btn:hover {
|
||||||
|
background: #dc2626;
|
||||||
|
box-shadow: 0 3px 8px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px;
|
||||||
|
transition: margin-left 0.3s ease;
|
||||||
|
background: #f4f5f7;
|
||||||
|
min-height: calc(100vh - 64px);
|
||||||
|
}
|
||||||
|
main.shifted { margin-left: 240px; }
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.cards-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
.card:hover { transform: translateY(-2px); box-shadow: 0 6px 16px rgba(0,0,0,0.08); }
|
||||||
|
.card-row { margin-bottom: 8px; font-size: 14px; color: #374151; }
|
||||||
|
.card-row strong { color: #111827; }
|
||||||
|
.card-actions { margin-top: 12px; }
|
||||||
|
.card-actions .btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-right: 6px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.card-actions .btn.edit { background: #4f46e5; }
|
||||||
|
.card-actions .btn.confirm { background: #10b981; }
|
||||||
|
.card-actions .btn.decline { background: #ef4444; }
|
||||||
|
.card-actions .btn:hover { opacity: 0.85; transform: translateY(-1px); }
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
#sidebar { width: 220px; }
|
||||||
|
main.shifted { margin-left: 220px; }
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#sidebar { width: 200px; left: -220px; }
|
||||||
|
#sidebar.active { left: 0; box-shadow: 2px 0 12px rgba(0,0,0,0.2); }
|
||||||
|
main.shifted { margin-left: 0; } /* overlay on small screens */
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.cards-container { grid-template-columns: 1fr; }
|
||||||
|
.card { padding: 14px 16px; }
|
||||||
|
.card-row { font-size: 13px; }
|
||||||
|
.card-actions .btn { font-size: 12px; padding: 5px 8px; margin-bottom: 4px; }
|
||||||
|
}
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
header { padding: 12px 16px; }
|
||||||
|
#menu-btn { left: 16px; padding: 4px 8px; }
|
||||||
|
.logo { font-size: 18px; }
|
||||||
|
#sidebar { padding: 16px; }
|
||||||
|
.logout-btn { font-size: 13px; padding: 8px 10px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<button id="menu-btn">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M4 5h16"/>
|
||||||
|
<path d="M4 12h16"/>
|
||||||
|
<path d="M4 19h16"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="logo">Xvatayka</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav id="sidebar">
|
||||||
|
<ul>
|
||||||
|
<li><a href="{% url 'dashboard' %}">Dashboard</a></li>
|
||||||
|
<li><a href="{% url 'expense_list' %}">Xarajatlar</a></li>
|
||||||
|
<li><a href="{% url 'income_list' %}">Kirimlar</a></li>
|
||||||
|
<li><a href="{% url 'toy_movement_list' %}">Oʻyinchoq harakatlari</a></li>
|
||||||
|
{% if user.role == "manager" or user.role == "businessman" %}
|
||||||
|
<li><a href="{% url 'device_list' %}">Aparatlar</a></li>
|
||||||
|
<li><a href="{% url 'warehouse_list' %}">Omborlar</a></li>
|
||||||
|
<li><a href="{% url 'user_list' %}">Foydalanuvchilar</a></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
<div class="logout-container">
|
||||||
|
{% if user.is_authenticated %}
|
||||||
|
<form method="post" action="{% url 'logout' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="logout-btn">Chiqish</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main id="main-content">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const menuBtn = document.getElementById('menu-btn');
|
||||||
|
const sidebar = document.getElementById('sidebar');
|
||||||
|
const mainContent = document.getElementById('main-content');
|
||||||
|
|
||||||
|
menuBtn.addEventListener('click', () => {
|
||||||
|
sidebar.classList.toggle('active');
|
||||||
|
mainContent.classList.toggle('shifted');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Optional: click outside to close
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!sidebar.contains(e.target) && !menuBtn.contains(e.target)) {
|
||||||
|
sidebar.classList.remove('active');
|
||||||
|
mainContent.classList.remove('shifted');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<br>
|
||||||
|
<h2 style="text-align:center; margin-bottom:24px; color:#111827;">Businessman Paneli</h2>
|
||||||
|
|
||||||
|
<div class="dashboard-grid">
|
||||||
|
<a href="{% url 'create_user' %}" class="dashboard-card">
|
||||||
|
<div class="icon">👤</div>
|
||||||
|
<div class="label">Foydalanuvchi yaratish</div>
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'create_device' %}" class="dashboard-card">
|
||||||
|
<div class="icon">💻</div>
|
||||||
|
<div class="label">Aparat yaratish</div>
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'create_expense' %}" class="dashboard-card">
|
||||||
|
<div class="icon">💸</div>
|
||||||
|
<div class="label">Xarajat qo'shish</div>
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'create_income' %}" class="dashboard-card">
|
||||||
|
<div class="icon">📥</div>
|
||||||
|
<div class="label">Kirim qo'shish</div>
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'create_warehouse' %}" class="dashboard-card">
|
||||||
|
<div class="icon">🏬</div>
|
||||||
|
<div class="label">Ombor yaratish</div>
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'create_toy_movement' %}" class="dashboard-card">
|
||||||
|
<div class="icon">🚚</div>
|
||||||
|
<div class="label">Oʻyinchoq harakati yaratish</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.dashboard-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 20px 12px;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #111827;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s, background 0.2s;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-card .icon {
|
||||||
|
font-size: 32px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 6px 20px rgba(0,0,0,0.1);
|
||||||
|
background: #4f46e5;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-card:hover .icon {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile adjustments: keep grid layout, adjust size */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.dashboard-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.dashboard-card {
|
||||||
|
padding: 16px 10px;
|
||||||
|
}
|
||||||
|
.dashboard-card .icon {
|
||||||
|
font-size: 28px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.dashboard-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.dashboard-card {
|
||||||
|
padding: 14px 8px;
|
||||||
|
}
|
||||||
|
.dashboard-card .icon {
|
||||||
|
font-size: 24px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user