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_HOST=db
DB_PORT=3306
DB_ROOT_PASSWORD=root
# Optional: for running manage.py from host (local DB)
# 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 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 . .
+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.
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 = {
+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.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
View File
@@ -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 -14
View File
@@ -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
View File
@@ -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
+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
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