This commit is contained in:
2026-03-20 23:16:53 +03:30
parent 4c5b1298a0
commit a98189a7e9
20 changed files with 855 additions and 76 deletions
+1
View File
@@ -10,6 +10,7 @@ DB_USER=croplogic
DB_PASSWORD=changeme DB_PASSWORD=changeme
DB_HOST=db DB_HOST=db
DB_PORT=3306 DB_PORT=3306
DB_ROOT_PASSWORD=root
# Optional: for running manage.py from host (local DB) # Optional: for running manage.py from host (local DB)
# DB_HOST=127.0.0.1 # DB_HOST=127.0.0.1
+120
View File
@@ -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
+72
View File
@@ -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
View File
@@ -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
علاوه بر این، اطلاعات خاک برای عمق‌های مختلف نیز دریافت می‌شود، از جمله:
- 05 سانتی‌متر
- 510 سانتی‌متر
- 1015 سانتی‌متر
این تفکیک عمق خاک اهمیت زیادی دارد، زیرا برخی گیاهان نسبت به ویژگی‌های خاک در عمق‌های خاص حساس‌تر هستند.
---
#### 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
View File
@@ -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
علاوه بر این، اطلاعات خاک برای عمق‌های مختلف نیز دریافت می‌شود، از جمله:
- 05 سانتی‌متر
- 510 سانتی‌متر
- 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
View File
@@ -1,10 +1,24 @@
FROM python:3.12-slim FROM docker.iranserver.com/python:3.10
ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
WORKDIR /app 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) # System deps for MySQL client (pkg-config required by mysqlclient to find libs)
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
default-libmysqlclient-dev \ default-libmysqlclient-dev \
@@ -13,7 +27,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY requirements.txt . 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 . . COPY . .
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AccountConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "account"
+134
View File
@@ -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()),
],
),
]
View File
+19
View File
@@ -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
View File
@@ -1,7 +1,6 @@
""" """
Account API module. Account API module.
CRUD endpoints for user account profile (first name, last name, phone numbers). CRUD endpoints for user account profile.
Profile update endpoint returns UpdateProfileResponse (code, msg, data: AuthUser).
""" """
from rest_framework import status from rest_framework import status
@@ -16,21 +15,13 @@ def _auth_user_to_data(user):
"""Build AuthUser-shaped dict from Django User.""" """Build AuthUser-shaped dict from Django User."""
if user is None or not getattr(user, "pk", None): if user is None or not getattr(user, "pk", None):
return 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 { return {
"id": 1, "id": user.id,
"username": "testuser", "username": getattr(user, "username", "") or "",
"email": "testuser@example.com", "email": getattr(user, "email", "") or "",
"first_name": "Test", "first_name": getattr(user, "first_name", "") or "",
"last_name": "User", "last_name": getattr(user, "last_name", "") or "",
"phone_number": "09123456789", "phone_number": getattr(user, "phone_number", "") or "",
} }
@@ -46,8 +37,16 @@ class ProfileView(APIView):
def patch(self, request): def patch(self, request):
serializer = UpdateProfileSerializer(data=request.data, partial=True) serializer = UpdateProfileSerializer(data=request.data, partial=True)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
# TODO: persist first_name, last_name, email via service layer
user = request.user 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) data = _auth_user_to_data(user)
if data is None: if data is None:
data = { data = {
+59
View File
@@ -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
View File
@@ -2,12 +2,15 @@ import secrets
from django.conf import settings from django.conf import settings
from django.core.cache import cache 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 import status
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework_simplejwt.tokens import RefreshToken
from account.models import User
from .serializers import RequestOTPSerializer, VerifyOTPSerializer from .serializers import RequestOTPSerializer, VerifyOTPSerializer
from .sms_service import send_otp_sms
OTP_TTL_SECONDS = 300 OTP_TTL_SECONDS = 300
@@ -15,24 +18,16 @@ OTP_SIGNER = TimestampSigner(salt="auth.otp")
def _auth_user_to_data(user): def _auth_user_to_data(user):
"""Build AuthUser-shaped dict from Django User (or mock).""" """Build AuthUser-shaped dict from Django User."""
# if user is None or not getattr(user, "pk", None): if user is None or not getattr(user, "pk", None):
# return 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 { return {
"id": 1, "id": user.id,
"username": "testuser", "username": getattr(user, "username", "") or "",
"email": "testuser@example.com", "email": getattr(user, "email", "") or "",
"first_name": "Test", "first_name": getattr(user, "first_name", "") or "",
"last_name": "User", "last_name": getattr(user, "last_name", "") or "",
"phone_number": "09123456789", "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) cache.set(f"otp_code:{phone_number}", otp_code, timeout=OTP_TTL_SECONDS)
otp_token = OTP_SIGNER.sign(phone_number) otp_token = OTP_SIGNER.sign(phone_number)
sms_sent = send_otp_sms(phone_number, otp_code)
payload = {"code": 200, "msg": "success", "token": otp_token} 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: 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) return Response(payload, status=status.HTTP_200_OK)
@@ -68,26 +67,46 @@ class AuthenticationView(APIView):
serializer = VerifyOTPSerializer(data=request.data) serializer = VerifyOTPSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
# TODO: validate token + otp_code, load or create user, issue JWT/session token token = serializer.validated_data["token"]
auth_token = "1234567890" otp_code = serializer.validated_data["otp_code"].strip()
user_data = _auth_user_to_data(getattr(request, "user", None))
if user_data is None: try:
user_data = { phone_number = OTP_SIGNER.unsign(
"id": 0, token, max_age=OTP_TTL_SECONDS
"username": "", )
"email": "", except (BadSignature, SignatureExpired):
"first_name": "", return Response(
"last_name": "", {"code": 400, "msg": "Token is invalid or expired."},
"phone_number": "", 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( return Response(
{ {
"code": 200, "code": 200,
"msg": "success", "msg": "success",
"data": user_data, "data": user_data,
"token": auth_token, "token": {
"access": str(refresh.access_token),
"refresh": str(refresh),
},
}, },
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
+7 -1
View File
@@ -11,6 +11,8 @@ SECRET_KEY = os.environ.get("SECRET_KEY", "django-insecure-dev-only")
DEBUG = os.environ.get("DEBUG", "0") == "1" DEBUG = os.environ.get("DEBUG", "0") == "1"
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
AUTH_USER_MODEL = "account.User"
INSTALLED_APPS = [ INSTALLED_APPS = [
"django.contrib.admin", "django.contrib.admin",
"django.contrib.auth", "django.contrib.auth",
@@ -19,7 +21,7 @@ INSTALLED_APPS = [
"django.contrib.messages", "django.contrib.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"auth.apps.AuthConfig", "auth.apps.AuthConfig",
"account", "account.apps.AccountConfig",
"sensor_hub", "sensor_hub",
"dashboard", "dashboard",
"crop_zoning", "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 CORS_ALLOW_ALL_ORIGINS = DEBUG
+1 -14
View File
@@ -1,7 +1,7 @@
# Production: no source mount; image contains code # Production: no source mount; image contains code
services: services:
db: db:
image: mysql:8.0 image: docker.iranserver.com/mysql:8
environment: environment:
MYSQL_DATABASE: ${DB_NAME} MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER} MYSQL_USER: ${DB_USER}
@@ -16,19 +16,6 @@ services:
timeout: 5s timeout: 5s
retries: 5 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: web:
build: . build: .
env_file: env_file:
+2 -2
View File
@@ -1,7 +1,7 @@
# Development: volumes mount source so code updates apply without rebuild # Development: volumes mount source so code updates apply without rebuild
services: services:
db: db:
image: mysql:8.0 image: docker-mirror.liara.ir/mysql:8.0
environment: environment:
MYSQL_DATABASE: ${DB_NAME:-croplogic} MYSQL_DATABASE: ${DB_NAME:-croplogic}
MYSQL_USER: ${DB_USER:-croplogic} MYSQL_USER: ${DB_USER:-croplogic}
@@ -16,7 +16,7 @@ services:
retries: 5 retries: 5
phpmyadmin: phpmyadmin:
image: phpmyadmin:latest image: docker-mirror.liara.ir/phpmyadmin:latest
environment: environment:
PMA_HOST: db PMA_HOST: db
PMA_PORT: 3306 PMA_PORT: 3306
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+14 -7
View File
@@ -1,7 +1,14 @@
Django>=5.0,<6 Django>=5.0,<5.2
djangorestframework>=3.14,<4 djangorestframework>=3.14,<3.16
djangorestframework-simplejwt>=5.3,<6 djangorestframework-simplejwt>=5.3,<5.4
django-cors-headers>=4.3,<5 django-cors-headers>=4.3,<4.5
mysqlclient>=2.2,<3 drf-spectacular>=0.27,<0.28
gunicorn>=22,<25 drf-spectacular-sidecar>=2024.7.1,<2025
python-dotenv>=1.0,<2 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