diff --git a/.env.example b/.env.example index e9a0485..5d8fcf0 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitea/workflows/backend.yml b/.gitea/workflows/backend.yml new file mode 100644 index 0000000..a41fefa --- /dev/null +++ b/.gitea/workflows/backend.yml @@ -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 diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml new file mode 100644 index 0000000..5dbdf91 --- /dev/null +++ b/.github/workflows/backend.yml @@ -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 diff --git a/Backend.md b/Backend.md new file mode 100644 index 0000000..33e0e8b --- /dev/null +++ b/Backend.md @@ -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** طراحی شده است که مزایای زیر را فراهم می‌کند: + +- مقیاس‌پذیری مستقل هر سرویس +- افزایش پایداری سیستم +- جداسازی مسئولیت‌ها +- مدیریت بهتر بار پردازشی +- امنیت بیشتر برای داده‌های حساس + +این معماری باعث می‌شود پلتفرم بتواند در مقیاس‌های بزرگ مزرعه و تعداد زیاد سنسورها نیز عملکرد پایدار و قابل اعتمادی داشته باشد. +::: diff --git a/Backend.txt b/Backend.txt new file mode 100644 index 0000000..33e0e8b --- /dev/null +++ b/Backend.txt @@ -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** طراحی شده است که مزایای زیر را فراهم می‌کند: + +- مقیاس‌پذیری مستقل هر سرویس +- افزایش پایداری سیستم +- جداسازی مسئولیت‌ها +- مدیریت بهتر بار پردازشی +- امنیت بیشتر برای داده‌های حساس + +این معماری باعث می‌شود پلتفرم بتواند در مقیاس‌های بزرگ مزرعه و تعداد زیاد سنسورها نیز عملکرد پایدار و قابل اعتمادی داشته باشد. +::: diff --git a/Dockerfile b/Dockerfile index 0e05676..81b56c7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 . . diff --git a/account/apps.py b/account/apps.py new file mode 100644 index 0000000..2c684a9 --- /dev/null +++ b/account/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "account" diff --git a/account/migrations/0001_initial.py b/account/migrations/0001_initial.py new file mode 100644 index 0000000..d5210d9 --- /dev/null +++ b/account/migrations/0001_initial.py @@ -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()), + ], + ), + ] diff --git a/account/migrations/__init__.py b/account/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/account/models.py b/account/models.py new file mode 100644 index 0000000..3eda4eb --- /dev/null +++ b/account/models.py @@ -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 diff --git a/account/views.py b/account/views.py index c0c0fdf..19d63be 100644 --- a/account/views.py +++ b/account/views.py @@ -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 = { diff --git a/auth/sms_service.py b/auth/sms_service.py new file mode 100644 index 0000000..3c693cf --- /dev/null +++ b/auth/sms_service.py @@ -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 diff --git a/auth/views.py b/auth/views.py index 65728f7..af2b583 100644 --- a/auth/views.py +++ b/auth/views.py @@ -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, ) - diff --git a/config/settings.py b/config/settings.py index f27d74f..7030fa6 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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 diff --git a/docker-compose-prod.yaml b/docker-compose-prod.yaml index dd0547c..f6496af 100644 --- a/docker-compose-prod.yaml +++ b/docker-compose-prod.yaml @@ -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: diff --git a/docker-compose.yaml b/docker-compose.yaml index 29ff35c..ffde23f 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -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 diff --git a/fertilization_recommendation/__init__.py b/fertilization_recommendation/__init__.py index e69de29..8b13789 100644 --- a/fertilization_recommendation/__init__.py +++ b/fertilization_recommendation/__init__.py @@ -0,0 +1 @@ + diff --git a/irrigation_recommendation/__init__.py b/irrigation_recommendation/__init__.py index e69de29..8b13789 100644 --- a/irrigation_recommendation/__init__.py +++ b/irrigation_recommendation/__init__.py @@ -0,0 +1 @@ + diff --git a/pest_detection/__init__.py b/pest_detection/__init__.py index e69de29..8b13789 100644 --- a/pest_detection/__init__.py +++ b/pest_detection/__init__.py @@ -0,0 +1 @@ + diff --git a/requirements.txt b/requirements.txt index 36cb173..1268019 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 + +