CI/CD
This commit is contained in:
@@ -10,6 +10,7 @@ DB_USER=croplogic
|
||||
DB_PASSWORD=changeme
|
||||
DB_HOST=db
|
||||
DB_PORT=3306
|
||||
DB_ROOT_PASSWORD=root
|
||||
|
||||
# Optional: for running manage.py from host (local DB)
|
||||
# DB_HOST=127.0.0.1
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
name: Backend Service CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [production]
|
||||
paths:
|
||||
- '**'
|
||||
- '.gitea/workflows/backend.yml'
|
||||
|
||||
pull_request:
|
||||
branches: [production]
|
||||
paths:
|
||||
- '**'
|
||||
- '.gitea/workflows/backend.yml'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Lint & Test
|
||||
runs-on: self-hosted
|
||||
container:
|
||||
image: mirror2.chabokan.net/ubuntu:22.04
|
||||
options: --add-host gitea:172.17.0.1
|
||||
|
||||
steps:
|
||||
|
||||
|
||||
- name: Setup Ubuntu apt mirrors
|
||||
run: |
|
||||
tee /etc/apt/sources.list > /dev/null <<'EOF'
|
||||
deb https://mirror-linux.runflare.com/ubuntu/ noble main restricted universe multiverse
|
||||
deb https://mirror-linux.runflare.com/ubuntu/ noble-updates main restricted universe multiverse
|
||||
deb https://mirror-linux.runflare.com/ubuntu/ noble-backports main restricted universe multiverse
|
||||
deb https://mirror-linux.runflare.com/ubuntu/ noble-security main restricted universe multiverse
|
||||
|
||||
deb [trusted=yes] https://mirror2.chabokan.net/ubuntu focal main universe
|
||||
deb [trusted=yes] https://mirror2.chabokan.net/ubuntu focal-updates main universe
|
||||
deb [trusted=yes] https://mirror2.chabokan.net/ubuntu focal-security main universe
|
||||
|
||||
|
||||
|
||||
|
||||
deb http://mirror.iranserver.com/ubuntu/ jammy main restricted
|
||||
deb-src http://mirror.iranserver.com/ubuntu/ jammy main restricted
|
||||
|
||||
|
||||
deb http://mirror.iranserver.com/ubuntu/ jammy-updates main restricted
|
||||
deb-src http://mirror.iranserver.com/ubuntu/ jammy-updates main restricted
|
||||
|
||||
|
||||
deb http://mirror.iranserver.com/ubuntu/ jammy universe
|
||||
deb-src http://mirror.iranserver.com/ubuntu/ jammy universe
|
||||
deb http://mirror.iranserver.com/ubuntu/ jammy-updates universe
|
||||
|
||||
|
||||
|
||||
deb http://mirror.iranserver.com/ubuntu/ jammy multiverse
|
||||
deb-src http://mirror.iranserver.com/ubuntu/ jammy multiverse
|
||||
deb http://mirror.iranserver.com/ubuntu/ jammy-updates multiverse
|
||||
deb-src http://mirror.iranserver.com/ubuntu/ jammy-updates multiverse
|
||||
|
||||
|
||||
deb http://mirror.iranserver.com/ubuntu/ jammy-backports main restricted universe multiverse
|
||||
deb-src http://mirror.iranserver.com/ubuntu/ jammy-backports main restricted universe multiverse
|
||||
|
||||
|
||||
EOF
|
||||
apt-get update
|
||||
- name: Install git
|
||||
run: |
|
||||
apt-get install -y git
|
||||
|
||||
- name: Checkout repository
|
||||
run: |
|
||||
git clone http://gitea:3000/sajad-dev/Backend.git .
|
||||
|
||||
- name: Install Python
|
||||
run: |
|
||||
apt-get install -y python3 python3-pip python3-venv git
|
||||
|
||||
- name: Setup Python pip mirrors
|
||||
run: |
|
||||
pip3 config --user set global.index-url https://package-mirror.liara.ir/repository/pypi/simple
|
||||
pip3 config --user set global.extra-index-url https://mirror.cdn.ir/repository/pypi/simple
|
||||
pip3 config --user set global.trusted-host "package-mirror.liara.ir mirror.cdn.ir mirror2.chabokan.net"
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
apt-get install -y \
|
||||
python3 \
|
||||
python3-pip \
|
||||
python3-venv \
|
||||
pkg-config \
|
||||
build-essential \
|
||||
default-libmysqlclient-dev
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python3 -m pip install --upgrade pip
|
||||
pip3 install -r requirements.txt
|
||||
pip3 install pytest flake8
|
||||
|
||||
- name: Run lint
|
||||
run: |
|
||||
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
|
||||
|
||||
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -p ${{secrets.SERVER_SSH_PORT}} -H ${{ secrets.SERVER_HOST }} >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Deploy
|
||||
run: |
|
||||
ssh ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }} -p ${{secrets.SERVER_SSH_PORT}}<< 'EOF'
|
||||
cd application/Backend
|
||||
git pull origin production
|
||||
docker-compose -f docker-compose-prod.yaml down
|
||||
docker-compose -f docker-compose-prod.yaml up -d
|
||||
EOF
|
||||
@@ -0,0 +1,72 @@
|
||||
name: Backend Service CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'backend/**'
|
||||
- 'backend/.github/workflows/backend.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'backend/**'
|
||||
- 'backend/.github/workflows/backend.yml'
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: backend
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Lint & Test
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
python-version: ['3.11']
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-backend-${{ hashFiles('backend/requirements.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-backend-
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install pytest flake8
|
||||
|
||||
- name: Run lint
|
||||
run: flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
|
||||
|
||||
- name: Run tests
|
||||
run: pytest --tb=short -q
|
||||
|
||||
deploy:
|
||||
name: Deploy Backend Service
|
||||
needs: test
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Deploy via SSH
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: ${{ secrets.SSH_HOST }}
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
port: ${{ secrets.SSH_PORT }}
|
||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
script: |
|
||||
cd /opt/myproject/backend
|
||||
git pull origin main
|
||||
sudo systemctl restart backend
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
## Backend Architecture – CropLogic Platform
|
||||
|
||||
پلتفرم **CropLogic** از سه سرویس کاملاً مستقل تشکیل شده است تا مقیاسپذیری، پایداری و امنیت دادهها در سطح بالایی حفظ شود. این سرویسها عبارتاند از:
|
||||
|
||||
- Backend Service
|
||||
- AI Service
|
||||
- SensorHub Service
|
||||
|
||||
جدا بودن این سرویسها باعث میشود هر بخش بتواند بهصورت مستقل توسعه داده شود، در صورت افزایش بار بهصورت جداگانه مقیاسپذیر باشد و در صورت بروز مشکل در یک سرویس، سایر سرویسها دچار اختلال نشوند.
|
||||
|
||||
---
|
||||
|
||||
## 1. Backend Service
|
||||
|
||||
سرویس **Backend** هستهی اصلی منطق سیستم محسوب میشود و نقش **Gateway** بین کلاینت و سایر سرویسهای داخلی را ایفا میکند.
|
||||
|
||||
### وظایف اصلی
|
||||
- دریافت درخواستها از سمت کلاینت
|
||||
- ارتباط با سرویسهای **AI** و **SensorHub**
|
||||
- پردازش و مدیریت خروجی سرویسها
|
||||
- آمادهسازی و ارسال پاسخ نهایی به کلاینت
|
||||
|
||||
به عبارت دیگر، Backend لایهی هماهنگکننده بین بخشهای مختلف سیستم است و تمام ارتباطات خارجی از طریق این سرویس انجام میشود.
|
||||
|
||||
### تکنولوژیها
|
||||
- **Framework:** Django
|
||||
- **Database:** MySQL
|
||||
|
||||
پروژه با استفاده از **Clean Architecture** طراحی شده است؛ بنابراین لایهی دیتابیس از منطق اصلی جدا بوده و در صورت نیاز امکان تغییر دیتابیس در آینده بهسادگی وجود دارد.
|
||||
|
||||
---
|
||||
|
||||
## 2. AI Service
|
||||
|
||||
سرویس **AI** به دلیل حجم بالای محاسبات و احتمال ایجاد بار سنگین روی سیستم، بهصورت کاملاً جدا از Backend پیادهسازی شده است. این سرویس مسئول انجام تمام پردازشهای مرتبط با **هوش مصنوعی و تحلیل دادهها** است.
|
||||
|
||||
### بخشهای اصلی این سرویس
|
||||
|
||||
#### 2.1 سیستم RAG (Retrieval-Augmented Generation)
|
||||
|
||||
این سیستم با استفاده از **Embedding مدلهای LLM** پیادهسازی شده و نقش ساخت **دستیار هوشمند مزرعه** را بر عهده دارد.
|
||||
|
||||
از این دستیار برای:
|
||||
- پاسخ به سوالات کشاورزان
|
||||
- ارائه راهنماییهای تخصصی کشاورزی
|
||||
- تحلیل شرایط مزرعه
|
||||
|
||||
استفاده میشود.
|
||||
|
||||
---
|
||||
|
||||
#### 2.2 دریافت اطلاعات خاک بر اساس مختصات جغرافیایی
|
||||
|
||||
برای دریافت دادههای علمی مربوط به خاک از API زیر استفاده میشود:
|
||||
|
||||
`https://rest.isric.org/soilgrids`
|
||||
|
||||
این سرویس اطلاعات خاک را بر اساس مختصات جغرافیایی ارائه میدهد.
|
||||
|
||||
پارامترهای اصلی دریافت شده شامل موارد زیر است:
|
||||
|
||||
- depth_label
|
||||
- bdod
|
||||
- cec
|
||||
- cfvo
|
||||
- clay
|
||||
- nitrogen
|
||||
- ocd
|
||||
- ocs
|
||||
- phh2o
|
||||
- sand
|
||||
- silt
|
||||
- soc
|
||||
- wv0010
|
||||
- wv0033
|
||||
- wv1500
|
||||
|
||||
علاوه بر این، اطلاعات خاک برای عمقهای مختلف نیز دریافت میشود، از جمله:
|
||||
|
||||
- 0–5 سانتیمتر
|
||||
- 5–10 سانتیمتر
|
||||
- 10–15 سانتیمتر
|
||||
|
||||
این تفکیک عمق خاک اهمیت زیادی دارد، زیرا برخی گیاهان نسبت به ویژگیهای خاک در عمقهای خاص حساستر هستند.
|
||||
|
||||
---
|
||||
|
||||
#### 2.3 ترکیب دادههای خاک با دادههای سنسور
|
||||
|
||||
اطلاعات دریافتی از SoilGrids با دادههای **سنسورهای مزرعه** ترکیب میشوند.
|
||||
|
||||
هدف از این مرحله:
|
||||
- تکمیل دادههای ناقص سنسورها
|
||||
- افزایش دقت مدلهای تحلیلی
|
||||
- ایجاد یک دیتاست کامل از شرایط واقعی مزرعه
|
||||
|
||||
---
|
||||
|
||||
#### 2.4 محاسبات تکمیلی ژئوفیزیک و خاک
|
||||
|
||||
در این مرحله، دادههایی که نه از طریق سنسورها و نه از طریق APIهای خارجی قابل دریافت نیستند، با استفاده از **فرمولهای علمی ژئوفیزیک و خاکشناسی کشاورزی** محاسبه میشوند.
|
||||
|
||||
این کار باعث میشود مدلهای تحلیلی تصویر کاملتری از شرایط مزرعه داشته باشند.
|
||||
|
||||
---
|
||||
|
||||
#### 2.5 تحلیل و مدلهای هوش مصنوعی
|
||||
|
||||
در مرحله نهایی، دادههای جمعآوریشده و پردازششده وارد سیستمهای تحلیلی میشوند که شامل موارد زیر است:
|
||||
|
||||
- استفاده از **سیستم RAG** برای تحلیل و تولید پاسخ
|
||||
- استفاده از **Machine Learning** در سناریوهای تخصصیتر
|
||||
- استفاده از **مدلهای از پیش آموزشدیده (Pretrained Models)**
|
||||
|
||||
خروجی این تحلیلها شامل مواردی مانند:
|
||||
|
||||
- توصیه زمان و میزان **آبیاری**
|
||||
- توصیه **کوددهی**
|
||||
- تحلیل وضعیت خاک و رشد گیاه
|
||||
- پیشنهاد اقدامات بهینه برای مدیریت مزرعه
|
||||
|
||||
### دیتابیس
|
||||
- **MySQL**
|
||||
|
||||
---
|
||||
|
||||
## 3. SensorHub Service
|
||||
|
||||
سرویس **SensorHub** مسئول دریافت، ذخیره و مدیریت دادههای سنسورهای مزرعه است.
|
||||
|
||||
به دلیل حساسیت بالای این دادهها و اهمیت **عدم از دست رفتن اطلاعات سنسورها**، این سرویس بهصورت مستقل از سایر بخشها پیادهسازی شده است.
|
||||
|
||||
### ویژگیهای این سرویس
|
||||
- دریافت دادههای سنسورها در زمان واقعی
|
||||
- ذخیرهسازی امن و پایدار دادهها
|
||||
- ارائه دادهها به سرویس Backend برای تحلیل
|
||||
|
||||
### دیتابیس
|
||||
|
||||
برای این سرویس از **Apache Cassandra** استفاده شده است.
|
||||
|
||||
دلایل انتخاب Cassandra:
|
||||
|
||||
- دیتابیس **Distributed** و مناسب برای دادههای حجیم
|
||||
- **Fault Tolerance بالا**
|
||||
- جلوگیری از از دست رفتن دادهها
|
||||
- مناسب برای **Time-Series Data** مانند دادههای سنسورها
|
||||
|
||||
---
|
||||
|
||||
## جمعبندی معماری
|
||||
|
||||
معماری CropLogic بر پایه **Microservice Architecture** طراحی شده است که مزایای زیر را فراهم میکند:
|
||||
|
||||
- مقیاسپذیری مستقل هر سرویس
|
||||
- افزایش پایداری سیستم
|
||||
- جداسازی مسئولیتها
|
||||
- مدیریت بهتر بار پردازشی
|
||||
- امنیت بیشتر برای دادههای حساس
|
||||
|
||||
این معماری باعث میشود پلتفرم بتواند در مقیاسهای بزرگ مزرعه و تعداد زیاد سنسورها نیز عملکرد پایدار و قابل اعتمادی داشته باشد.
|
||||
:::
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
## Backend Architecture – CropLogic Platform
|
||||
|
||||
پلتفرم **CropLogic** از سه سرویس کاملاً مستقل تشکیل شده است تا مقیاسپذیری، پایداری و امنیت دادهها در سطح بالایی حفظ شود. این سرویسها عبارتاند از:
|
||||
|
||||
- Backend Service
|
||||
- AI Service
|
||||
- SensorHub Service
|
||||
|
||||
جدا بودن این سرویسها باعث میشود هر بخش بتواند بهصورت مستقل توسعه داده شود، در صورت افزایش بار بهصورت جداگانه مقیاسپذیر باشد و در صورت بروز مشکل در یک سرویس، سایر سرویسها دچار اختلال نشوند.
|
||||
|
||||
---
|
||||
|
||||
## 1. Backend Service
|
||||
|
||||
سرویس **Backend** هستهی اصلی منطق سیستم محسوب میشود و نقش **Gateway** بین کلاینت و سایر سرویسهای داخلی را ایفا میکند.
|
||||
|
||||
### وظایف اصلی
|
||||
- دریافت درخواستها از سمت کلاینت
|
||||
- ارتباط با سرویسهای **AI** و **SensorHub**
|
||||
- پردازش و مدیریت خروجی سرویسها
|
||||
- آمادهسازی و ارسال پاسخ نهایی به کلاینت
|
||||
|
||||
به عبارت دیگر، Backend لایهی هماهنگکننده بین بخشهای مختلف سیستم است و تمام ارتباطات خارجی از طریق این سرویس انجام میشود.
|
||||
|
||||
### تکنولوژیها
|
||||
- **Framework:** Django
|
||||
- **Database:** MySQL
|
||||
|
||||
پروژه با استفاده از **Clean Architecture** طراحی شده است؛ بنابراین لایهی دیتابیس از منطق اصلی جدا بوده و در صورت نیاز امکان تغییر دیتابیس در آینده بهسادگی وجود دارد.
|
||||
|
||||
---
|
||||
|
||||
## 2. AI Service
|
||||
|
||||
سرویس **AI** به دلیل حجم بالای محاسبات و احتمال ایجاد بار سنگین روی سیستم، بهصورت کاملاً جدا از Backend پیادهسازی شده است. این سرویس مسئول انجام تمام پردازشهای مرتبط با **هوش مصنوعی و تحلیل دادهها** است.
|
||||
|
||||
### بخشهای اصلی این سرویس
|
||||
|
||||
#### 2.1 سیستم RAG (Retrieval-Augmented Generation)
|
||||
|
||||
این سیستم با استفاده از **Embedding مدلهای LLM** پیادهسازی شده و نقش ساخت **دستیار هوشمند مزرعه** را بر عهده دارد.
|
||||
|
||||
از این دستیار برای:
|
||||
- پاسخ به سوالات کشاورزان
|
||||
- ارائه راهنماییهای تخصصی کشاورزی
|
||||
- تحلیل شرایط مزرعه
|
||||
|
||||
استفاده میشود.
|
||||
|
||||
---
|
||||
|
||||
#### 2.2 دریافت اطلاعات خاک بر اساس مختصات جغرافیایی
|
||||
|
||||
برای دریافت دادههای علمی مربوط به خاک از API زیر استفاده میشود:
|
||||
|
||||
`https://rest.isric.org/soilgrids`
|
||||
|
||||
این سرویس اطلاعات خاک را بر اساس مختصات جغرافیایی ارائه میدهد.
|
||||
|
||||
پارامترهای اصلی دریافت شده شامل موارد زیر است:
|
||||
|
||||
- depth_label
|
||||
- bdod
|
||||
- cec
|
||||
- cfvo
|
||||
- clay
|
||||
- nitrogen
|
||||
- ocd
|
||||
- ocs
|
||||
- phh2o
|
||||
- sand
|
||||
- silt
|
||||
- soc
|
||||
- wv0010
|
||||
- wv0033
|
||||
- wv1500
|
||||
|
||||
علاوه بر این، اطلاعات خاک برای عمقهای مختلف نیز دریافت میشود، از جمله:
|
||||
|
||||
- 0–5 سانتیمتر
|
||||
- 5–10 سانتیمتر
|
||||
- 10–15 سانتیمتر
|
||||
|
||||
این تفکیک عمق خاک اهمیت زیادی دارد، زیرا برخی گیاهان نسبت به ویژگیهای خاک در عمقهای خاص حساستر هستند.
|
||||
|
||||
---
|
||||
|
||||
#### 2.3 ترکیب دادههای خاک با دادههای سنسور
|
||||
|
||||
اطلاعات دریافتی از SoilGrids با دادههای **سنسورهای مزرعه** ترکیب میشوند.
|
||||
|
||||
هدف از این مرحله:
|
||||
- تکمیل دادههای ناقص سنسورها
|
||||
- افزایش دقت مدلهای تحلیلی
|
||||
- ایجاد یک دیتاست کامل از شرایط واقعی مزرعه
|
||||
|
||||
---
|
||||
|
||||
#### 2.4 محاسبات تکمیلی ژئوفیزیک و خاک
|
||||
|
||||
در این مرحله، دادههایی که نه از طریق سنسورها و نه از طریق APIهای خارجی قابل دریافت نیستند، با استفاده از **فرمولهای علمی ژئوفیزیک و خاکشناسی کشاورزی** محاسبه میشوند.
|
||||
|
||||
این کار باعث میشود مدلهای تحلیلی تصویر کاملتری از شرایط مزرعه داشته باشند.
|
||||
|
||||
---
|
||||
|
||||
#### 2.5 تحلیل و مدلهای هوش مصنوعی
|
||||
|
||||
در مرحله نهایی، دادههای جمعآوریشده و پردازششده وارد سیستمهای تحلیلی میشوند که شامل موارد زیر است:
|
||||
|
||||
- استفاده از **سیستم RAG** برای تحلیل و تولید پاسخ
|
||||
- استفاده از **Machine Learning** در سناریوهای تخصصیتر
|
||||
- استفاده از **مدلهای از پیش آموزشدیده (Pretrained Models)**
|
||||
|
||||
خروجی این تحلیلها شامل مواردی مانند:
|
||||
|
||||
- توصیه زمان و میزان **آبیاری**
|
||||
- توصیه **کوددهی**
|
||||
- تحلیل وضعیت خاک و رشد گیاه
|
||||
- پیشنهاد اقدامات بهینه برای مدیریت مزرعه
|
||||
|
||||
### دیتابیس
|
||||
- **MySQL**
|
||||
|
||||
---
|
||||
|
||||
## 3. SensorHub Service
|
||||
|
||||
سرویس **SensorHub** مسئول دریافت، ذخیره و مدیریت دادههای سنسورهای مزرعه است.
|
||||
|
||||
به دلیل حساسیت بالای این دادهها و اهمیت **عدم از دست رفتن اطلاعات سنسورها**، این سرویس بهصورت مستقل از سایر بخشها پیادهسازی شده است.
|
||||
|
||||
### ویژگیهای این سرویس
|
||||
- دریافت دادههای سنسورها در زمان واقعی
|
||||
- ذخیرهسازی امن و پایدار دادهها
|
||||
- ارائه دادهها به سرویس Backend برای تحلیل
|
||||
|
||||
### دیتابیس
|
||||
|
||||
برای این سرویس از **Apache Cassandra** استفاده شده است.
|
||||
|
||||
دلایل انتخاب Cassandra:
|
||||
|
||||
- دیتابیس **Distributed** و مناسب برای دادههای حجیم
|
||||
- **Fault Tolerance بالا**
|
||||
- جلوگیری از از دست رفتن دادهها
|
||||
- مناسب برای **Time-Series Data** مانند دادههای سنسورها
|
||||
|
||||
---
|
||||
|
||||
## جمعبندی معماری
|
||||
|
||||
معماری CropLogic بر پایه **Microservice Architecture** طراحی شده است که مزایای زیر را فراهم میکند:
|
||||
|
||||
- مقیاسپذیری مستقل هر سرویس
|
||||
- افزایش پایداری سیستم
|
||||
- جداسازی مسئولیتها
|
||||
- مدیریت بهتر بار پردازشی
|
||||
- امنیت بیشتر برای دادههای حساس
|
||||
|
||||
این معماری باعث میشود پلتفرم بتواند در مقیاسهای بزرگ مزرعه و تعداد زیاد سنسورها نیز عملکرد پایدار و قابل اعتمادی داشته باشد.
|
||||
:::
|
||||
+25
-2
@@ -1,10 +1,24 @@
|
||||
FROM python:3.12-slim
|
||||
FROM docker.iranserver.com/python:3.10
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Debian/debian mirrors for apt
|
||||
RUN rm -f /etc/apt/sources.list /etc/apt/sources.list.d/* && \
|
||||
printf '%s\n' \
|
||||
'deb https://mirror-linux.runflare.com/debian/ bookworm main contrib non-free non-free-firmware' \
|
||||
'deb https://mirror-linux.runflare.com/debian/ bookworm-updates main contrib non-free non-free-firmware' \
|
||||
'deb https://mirror-linux.runflare.com/debian-security/ bookworm-security main contrib non-free non-free-firmware' \
|
||||
'' \
|
||||
'deb [trusted=yes] https://mirror2.chabokan.net/debian bookworm main contrib non-free non-free-firmware' \
|
||||
'deb [trusted=yes] https://mirror2.chabokan.net/debian-security bookworm-security main contrib non-free non-free-firmware' \
|
||||
'' \
|
||||
'deb http://mirror.iranserver.com/debian/ bookworm main contrib non-free non-free-firmware' \
|
||||
'deb-src http://mirror.iranserver.com/debian/ bookworm main contrib non-free non-free-firmware' \
|
||||
> /etc/apt/sources.list
|
||||
|
||||
# System deps for MySQL client (pkg-config required by mysqlclient to find libs)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
default-libmysqlclient-dev \
|
||||
@@ -13,7 +27,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Python mirrors
|
||||
RUN pip config --user set global.index-url https://package-mirror.liara.ir/repository/pypi/simple && \
|
||||
pip config --user set global.extra-index-url https://mirror.cdn.ir/repository/pypi/simple && \
|
||||
pip config --user set global.extra-index-url https://mirror2.chabokan.net/pypi/simple && \
|
||||
pip config --user set global.trusted-host package-mirror.liara.ir && \
|
||||
pip config --user set global.trusted-host mirror.cdn.ir && \
|
||||
pip config --user set global.trusted-host mirror-pypi.runflare.com
|
||||
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "account"
|
||||
@@ -0,0 +1,134 @@
|
||||
# Generated by Django 5.2.11 on 2026-03-18 14:09
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
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",
|
||||
),
|
||||
),
|
||||
(
|
||||
"username",
|
||||
models.CharField(
|
||||
error_messages={
|
||||
"unique": "A user with that username already exists."
|
||||
},
|
||||
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
|
||||
max_length=150,
|
||||
unique=True,
|
||||
validators=[
|
||||
django.contrib.auth.validators.UnicodeUsernameValidator()
|
||||
],
|
||||
verbose_name="username",
|
||||
),
|
||||
),
|
||||
(
|
||||
"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_number",
|
||||
models.CharField(db_index=True, max_length=32, unique=True),
|
||||
),
|
||||
(
|
||||
"groups",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
|
||||
related_name="user_set",
|
||||
related_query_name="user",
|
||||
to="auth.group",
|
||||
verbose_name="groups",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user_permissions",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="Specific permissions for this user.",
|
||||
related_name="user_set",
|
||||
related_query_name="user",
|
||||
to="auth.permission",
|
||||
verbose_name="user permissions",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "users",
|
||||
},
|
||||
managers=[
|
||||
("objects", django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
|
||||
|
||||
class User(AbstractUser):
|
||||
phone_number = models.CharField(
|
||||
max_length=32,
|
||||
unique=True,
|
||||
db_index=True,
|
||||
)
|
||||
|
||||
USERNAME_FIELD = "phone_number"
|
||||
REQUIRED_FIELDS = ["username"]
|
||||
|
||||
class Meta:
|
||||
db_table = "users"
|
||||
|
||||
def __str__(self):
|
||||
return self.phone_number
|
||||
+16
-17
@@ -1,7 +1,6 @@
|
||||
"""
|
||||
Account API module.
|
||||
CRUD endpoints for user account profile (first name, last name, phone numbers).
|
||||
Profile update endpoint returns UpdateProfileResponse (code, msg, data: AuthUser).
|
||||
CRUD endpoints for user account profile.
|
||||
"""
|
||||
|
||||
from rest_framework import status
|
||||
@@ -16,21 +15,13 @@ def _auth_user_to_data(user):
|
||||
"""Build AuthUser-shaped dict from Django User."""
|
||||
if user is None or not getattr(user, "pk", None):
|
||||
return None
|
||||
# return {
|
||||
# "id": user.id,
|
||||
# "username": getattr(user, "username", "") or "",
|
||||
# "email": getattr(user, "email", "") or "",
|
||||
# "first_name": getattr(user, "first_name", "") or "",
|
||||
# "last_name": getattr(user, "last_name", "") or "",
|
||||
# "phone_number": getattr(user, "phone_number", "") or "",
|
||||
# }
|
||||
return {
|
||||
"id": 1,
|
||||
"username": "testuser",
|
||||
"email": "testuser@example.com",
|
||||
"first_name": "Test",
|
||||
"last_name": "User",
|
||||
"phone_number": "09123456789",
|
||||
"id": user.id,
|
||||
"username": getattr(user, "username", "") or "",
|
||||
"email": getattr(user, "email", "") or "",
|
||||
"first_name": getattr(user, "first_name", "") or "",
|
||||
"last_name": getattr(user, "last_name", "") or "",
|
||||
"phone_number": getattr(user, "phone_number", "") or "",
|
||||
}
|
||||
|
||||
|
||||
@@ -46,8 +37,16 @@ class ProfileView(APIView):
|
||||
def patch(self, request):
|
||||
serializer = UpdateProfileSerializer(data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
# TODO: persist first_name, last_name, email via service layer
|
||||
|
||||
user = request.user
|
||||
for field in ("first_name", "last_name", "email"):
|
||||
if field in serializer.validated_data:
|
||||
setattr(user, field, serializer.validated_data[field])
|
||||
user.save(update_fields=[
|
||||
f for f in ("first_name", "last_name", "email")
|
||||
if f in serializer.validated_data
|
||||
])
|
||||
|
||||
data = _auth_user_to_data(user)
|
||||
if data is None:
|
||||
data = {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import http.client
|
||||
import json
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def send_otp_sms(phone_number: str, otp_code: str) -> bool:
|
||||
"""Send OTP code via SMS.ir bulk API.
|
||||
|
||||
Returns True on success, False on failure.
|
||||
"""
|
||||
api_key = getattr(settings, "SMS_IR_API_KEY", "")
|
||||
line_number = getattr(settings, "SMS_IR_LINE_NUMBER", 300000000000)
|
||||
|
||||
if not api_key:
|
||||
logger.error("SMS_IR_API_KEY is not configured.")
|
||||
return False
|
||||
|
||||
message_text = f"کد تایید شما: {otp_code}"
|
||||
|
||||
payload = json.dumps({
|
||||
"lineNumber": line_number,
|
||||
"messageText": message_text,
|
||||
"mobiles": [phone_number],
|
||||
"sendDateTime": None,
|
||||
})
|
||||
|
||||
headers = {
|
||||
"X-API-KEY": api_key,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
try:
|
||||
conn = http.client.HTTPSConnection("api.sms.ir")
|
||||
conn.request("POST", "/v1/send/bulk", payload, headers)
|
||||
res = conn.getresponse()
|
||||
data = res.read().decode("utf-8")
|
||||
conn.close()
|
||||
|
||||
response = json.loads(data)
|
||||
status_code = response.get("status")
|
||||
|
||||
if res.status == 200 and status_code == 1:
|
||||
logger.info("SMS sent successfully to %s", phone_number)
|
||||
return True
|
||||
|
||||
logger.warning(
|
||||
"SMS.ir returned unexpected response: HTTP %s, body: %s",
|
||||
res.status,
|
||||
data,
|
||||
)
|
||||
return False
|
||||
|
||||
except Exception:
|
||||
logger.exception("Failed to send SMS to %s", phone_number)
|
||||
return False
|
||||
+52
-33
@@ -2,12 +2,15 @@ import secrets
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.core.signing import TimestampSigner
|
||||
from django.core.signing import BadSignature, SignatureExpired, TimestampSigner
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from account.models import User
|
||||
from .serializers import RequestOTPSerializer, VerifyOTPSerializer
|
||||
from .sms_service import send_otp_sms
|
||||
|
||||
|
||||
OTP_TTL_SECONDS = 300
|
||||
@@ -15,24 +18,16 @@ OTP_SIGNER = TimestampSigner(salt="auth.otp")
|
||||
|
||||
|
||||
def _auth_user_to_data(user):
|
||||
"""Build AuthUser-shaped dict from Django User (or mock)."""
|
||||
# if user is None or not getattr(user, "pk", None):
|
||||
# return None
|
||||
# return {
|
||||
# "id": user.id,
|
||||
# "username": getattr(user, "username", "") or "",
|
||||
# "email": getattr(user, "email", "") or "",
|
||||
# "first_name": getattr(user, "first_name", "") or "",
|
||||
# "last_name": getattr(user, "last_name", "") or "",
|
||||
# "phone_number": getattr(user, "phone_number", "") or "",
|
||||
# }
|
||||
"""Build AuthUser-shaped dict from Django User."""
|
||||
if user is None or not getattr(user, "pk", None):
|
||||
return None
|
||||
return {
|
||||
"id": 1,
|
||||
"username": "testuser",
|
||||
"email": "testuser@example.com",
|
||||
"first_name": "Test",
|
||||
"last_name": "User",
|
||||
"phone_number": "09123456789",
|
||||
"id": user.id,
|
||||
"username": getattr(user, "username", "") or "",
|
||||
"email": getattr(user, "email", "") or "",
|
||||
"first_name": getattr(user, "first_name", "") or "",
|
||||
"last_name": getattr(user, "last_name", "") or "",
|
||||
"phone_number": getattr(user, "phone_number", "") or "",
|
||||
}
|
||||
|
||||
|
||||
@@ -58,9 +53,13 @@ class AuthenticationView(APIView):
|
||||
cache.set(f"otp_code:{phone_number}", otp_code, timeout=OTP_TTL_SECONDS)
|
||||
otp_token = OTP_SIGNER.sign(phone_number)
|
||||
|
||||
sms_sent = send_otp_sms(phone_number, otp_code)
|
||||
|
||||
payload = {"code": 200, "msg": "success", "token": otp_token}
|
||||
if not sms_sent:
|
||||
payload["sms_warning"] = "SMS delivery failed; OTP stored server-side."
|
||||
if settings.DEBUG:
|
||||
payload["debug_note"] = "OTP code is returned only when DEBUG=1."
|
||||
payload["debug_otp"] = otp_code
|
||||
|
||||
return Response(payload, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -68,26 +67,46 @@ class AuthenticationView(APIView):
|
||||
serializer = VerifyOTPSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
# TODO: validate token + otp_code, load or create user, issue JWT/session token
|
||||
auth_token = "1234567890"
|
||||
user_data = _auth_user_to_data(getattr(request, "user", None))
|
||||
if user_data is None:
|
||||
user_data = {
|
||||
"id": 0,
|
||||
"username": "",
|
||||
"email": "",
|
||||
"first_name": "",
|
||||
"last_name": "",
|
||||
"phone_number": "",
|
||||
}
|
||||
token = serializer.validated_data["token"]
|
||||
otp_code = serializer.validated_data["otp_code"].strip()
|
||||
|
||||
try:
|
||||
phone_number = OTP_SIGNER.unsign(
|
||||
token, max_age=OTP_TTL_SECONDS
|
||||
)
|
||||
except (BadSignature, SignatureExpired):
|
||||
return Response(
|
||||
{"code": 400, "msg": "Token is invalid or expired."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
cached_otp = cache.get(f"otp_code:{phone_number}")
|
||||
if cached_otp is None or cached_otp != otp_code:
|
||||
return Response(
|
||||
{"code": 400, "msg": "OTP code is invalid or expired."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
cache.delete(f"otp_code:{phone_number}")
|
||||
|
||||
user, created = User.objects.get_or_create(
|
||||
phone_number=phone_number,
|
||||
defaults={"username": phone_number},
|
||||
)
|
||||
|
||||
refresh = RefreshToken.for_user(user)
|
||||
|
||||
user_data = _auth_user_to_data(user)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": user_data,
|
||||
"token": auth_token,
|
||||
"token": {
|
||||
"access": str(refresh.access_token),
|
||||
"refresh": str(refresh),
|
||||
},
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
+7
-1
@@ -11,6 +11,8 @@ SECRET_KEY = os.environ.get("SECRET_KEY", "django-insecure-dev-only")
|
||||
DEBUG = os.environ.get("DEBUG", "0") == "1"
|
||||
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
|
||||
|
||||
AUTH_USER_MODEL = "account.User"
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
@@ -19,7 +21,7 @@ INSTALLED_APPS = [
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"auth.apps.AuthConfig",
|
||||
"account",
|
||||
"account.apps.AccountConfig",
|
||||
"sensor_hub",
|
||||
"dashboard",
|
||||
"crop_zoning",
|
||||
@@ -109,4 +111,8 @@ REST_FRAMEWORK = {
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
SMS_IR_API_KEY = os.environ.get("SMS_IR_API_KEY", "")
|
||||
SMS_IR_LINE_NUMBER = int(os.environ.get("SMS_IR_LINE_NUMBER", "300000000000"))
|
||||
|
||||
CORS_ALLOW_ALL_ORIGINS = DEBUG
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Production: no source mount; image contains code
|
||||
services:
|
||||
db:
|
||||
image: mysql:8.0
|
||||
image: docker.iranserver.com/mysql:8
|
||||
environment:
|
||||
MYSQL_DATABASE: ${DB_NAME}
|
||||
MYSQL_USER: ${DB_USER}
|
||||
@@ -16,19 +16,6 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
phpmyadmin:
|
||||
image: phpmyadmin:latest
|
||||
environment:
|
||||
PMA_HOST: db
|
||||
PMA_PORT: 3306
|
||||
UPLOAD_LIMIT: 64M
|
||||
ports:
|
||||
- "8081:80"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
web:
|
||||
build: .
|
||||
env_file:
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
# Development: volumes mount source so code updates apply without rebuild
|
||||
services:
|
||||
db:
|
||||
image: mysql:8.0
|
||||
image: docker-mirror.liara.ir/mysql:8.0
|
||||
environment:
|
||||
MYSQL_DATABASE: ${DB_NAME:-croplogic}
|
||||
MYSQL_USER: ${DB_USER:-croplogic}
|
||||
@@ -16,7 +16,7 @@ services:
|
||||
retries: 5
|
||||
|
||||
phpmyadmin:
|
||||
image: phpmyadmin:latest
|
||||
image: docker-mirror.liara.ir/phpmyadmin:latest
|
||||
environment:
|
||||
PMA_HOST: db
|
||||
PMA_PORT: 3306
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
|
||||
+14
-7
@@ -1,7 +1,14 @@
|
||||
Django>=5.0,<6
|
||||
djangorestframework>=3.14,<4
|
||||
djangorestframework-simplejwt>=5.3,<6
|
||||
django-cors-headers>=4.3,<5
|
||||
mysqlclient>=2.2,<3
|
||||
gunicorn>=22,<25
|
||||
python-dotenv>=1.0,<2
|
||||
Django>=5.0,<5.2
|
||||
djangorestframework>=3.14,<3.16
|
||||
djangorestframework-simplejwt>=5.3,<5.4
|
||||
django-cors-headers>=4.3,<4.5
|
||||
drf-spectacular>=0.27,<0.28
|
||||
drf-spectacular-sidecar>=2024.7.1,<2025
|
||||
celery[redis]>=5.3,<5.4
|
||||
redis>=5.0,<5.1
|
||||
|
||||
mysqlclient>=2.2,<2.3
|
||||
gunicorn>=22,<23
|
||||
python-dotenv>=1.0,<1.1
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user