diff --git a/.env-template b/.env-template new file mode 100644 index 00000000..ae902aa9 --- /dev/null +++ b/.env-template @@ -0,0 +1,76 @@ +## SECRETS --================================== +RABBITMQ_DEFAULT_USER= +RABBITMQ_DEFAULT_PASS= +# --- +POSTGRES_USER= +POSTGRES_PASSWORD= +POSTGRES_DB= +# --- +REDIS_PASS= +REDIS_COMMANDER_USER= +REDIS_COMMANDER_PASS= +# --- +DJANGO_SUPER_USER= +DJANGO_SUPER_PASSWORD= +DJANGO_SECRET_KEY= +# --- + +## SETTINGS --================================== +RABBITMQ_WEB_UI_PORT=15672 +RABBITMQ_PORT=5672 +RABBITMQ_HOST=rabbitmq +RABBITMQ_VHOST=/ +# --- +POSTGRES_PORT=5432 +# --- +NGINX_HTTP_PORT=80 +NGINX_HTTPS_PORT=443 +# --- +REDIS_PORT=6379 +REDIS_MAXMEMORY=256mb +REDIS_COMMANDER_PORT=8081 +# --- +PORTAINER_PORT=9000 +# --- +SWAGGER_PORT=8956 +# --- +NODEJS_PORT=3000 +# --- +DJANGO_PORT=8000 +DJANGO_DB_HOST=psql +# Web debug interface [True/>False<] +DJANGO_DEBUG=True +DJANGO_ALLOWED_HOSTS=127.0.0.1,localhost,django +DJANGO_ASYNC_TIMEOUT_S=30 +# --- +# Whether to retry failed connections to the broker on startup [>TrueTrueTrueFalse<] +CELERY_TASK_ALWAYS_EAGER=False +# Worker process: +# [ +# 'solo' - single process +# >'prefork'< - multiple processes (linux only) +# ] +CELERY_WORKER_POOL=prefork +# Restart worker after each task [>422False<] +CELERY_TASK_IGNORE_RESULT=False +# Configure task logging [True/>False<] +CELERY_WORKER_REDIRECT_STDOUTS=False +# Log level for task logs [DEBUG/>INFO> .env + done + + - name: Build and run containers + run: | + docker compose -f docker-compose-build.yml up -d + sleep 20 # Wait for containers to be ready + + - name: Run tests + run: docker compose -f docker-compose-build.yml exec -T django pytest \ No newline at end of file diff --git a/.github/workflows/on-commit-main.yml b/.github/workflows/on-commit-main.yml index 5ed846de..642aec23 100644 --- a/.github/workflows/on-commit-main.yml +++ b/.github/workflows/on-commit-main.yml @@ -18,6 +18,52 @@ env: PREVIOUS_TAG: 0.0.0 LATEST_TAG: 0.0.0 TOML_VERSION: 0.0.0 + # + RABBITMQ_DEFAULT_USER: test + RABBITMQ_DEFAULT_PASS: test + POSTGRES_USER: test + POSTGRES_PASSWORD: test + POSTGRES_DB: test + REDIS_PASS: test + REDIS_COMMANDER_USER: test + REDIS_COMMANDER_PASS: test + DJANGO_SUPER_USER: test + DJANGO_SUPER_PASSWORD: test + DJANGO_SECRET_KEY: django-insecure-test + # + RABBITMQ_WEB_UI_PORT: 15672 + RABBITMQ_PORT: 5672 + RABBITMQ_HOST: rabbitmq + RABBITMQ_VHOST: / + POSTGRES_PORT: 5432 + NGINX_HTTP_PORT: 80 + NGINX_HTTPS_PORT: 443 + REDIS_PORT: 6379 + REDIS_MAXMEMORY: 256mb + REDIS_COMMANDER_PORT: 8081 + PORTAINER_PORT: 9000 + SWAGGER_PORT: 8956 + NODEJS_PORT: 3000 + DJANGO_PORT: 8000 + DJANGO_DB_HOST: psql + DJANGO_DEBUG: True + DJANGO_ALLOWED_HOSTS: 127.0.0.1,localhost,django + DJANGO_ASYNC_TIMEOUT_S: 30 + CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP: True + CELERY_BROKER_CONNECTION_RETRY: True + CELERY_BROKER_CONNECTION_MAX_RETRIES: 10 + CELERY_BROKER_HEARTBEAT: 10 + CELERY_RESULT_BACKEND: rpc:// + CELERY_TASK_ACKS_LATE: True + CELERY_TASK_ALWAYS_EAGER: False + CELERY_WORKER_POOL: prefork + CELERY_WORKER_MAX_TASKS_PER_CHILD: 4 + CELERY_WORKER_CONCURRENCY: 2 + CELERY_WORKER_PREFETCH_MULTIPLIER: 2 + CELERY_TASK_IGNORE_RESULT: False + CELERY_WORKER_REDIRECT_STDOUTS: False + CELERY_WORKER_REDIRECT_STDOUTS_LEVEL: INFO + CELERY_WORKER_TASK_LOG_FORMAT: "[%(asctime)s: %(levelname)s] Task %(task_name)s[%(task_id)s]: %(message)s" jobs: fetch-commit-message: @@ -60,8 +106,45 @@ jobs: - name: Run Spellchecker run: codespell --skip "./frontend/yarn.lock,./papers" + pytest: + # clone whole repo, run docker-compose-build.yml and run tests + runs-on: ubuntu-latest + needs: check-no-ci + if: needs.check-no-ci.outputs.no_ci == 'false' + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + + - name: Install Docker Compose + run: | + sudo apt-get update + sudo apt-get install -y docker-compose + + - name: Login to GitHub Container Registry + run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin + + - name: Create env file from GitHub Environment + # use loop to create env file from env variables + run: | + for key in $(env | grep -E '^[A-Z_]+=' | cut -d= -f1); do + echo "$key=${!key}" >> .env + done + + - name: Build and run containers + run: | + docker compose -f docker-compose-build.yml up -d + sleep 20 # Wait for containers to be ready + + - name: Run tests + run: docker compose -f docker-compose-build.yml exec -T django pytest + update-release-draft: - needs: spellcheck + needs: + - spellcheck + - pytest permissions: # write permission is required to create a github release contents: write diff --git a/.github/workflows/on-release.yml b/.github/workflows/on-release.yml index da4472e6..d5d71f76 100644 --- a/.github/workflows/on-release.yml +++ b/.github/workflows/on-release.yml @@ -2,22 +2,32 @@ name: Create and publish a Docker images to GitHub Packages on: - workflow_run: - workflows: ["CI-main"] - types: - - completed - release: - types: [created, published] - push: - tags: - - v* + # workflow_run: + # workflows: ["CI-main"] + # types: + # - completed + # release: + # types: [created, published] + # push: + # tags: + # - v* workflow_dispatch: + inputs: + type: + description: "Release type" + required: true + type: choice + options: + - "Stable" + - "Pre-release" + skip_build: + description: "Skip build/test step" + type: boolean + default: false env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} - # IS_RELEASE: ${{ github.event.pull_request.merged }} - IS_RELEASE: True DJANGO_SUFFIX: -django FRONTEND_SUFFIX: -frontend NGINX_SUFFIX: -nginx @@ -25,25 +35,45 @@ env: # There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu. jobs: + set-prefix: + runs-on: ubuntu-latest + outputs: + PREFIX: ${{ steps.set_prefix.outputs.PREFIX }} + steps: + - name: Set PREFIX + id: set_prefix + run: | + if [ '${{ github.event.inputs.type }}' == 'Stable' ]; then + echo "PREFIX=" >> $GITHUB_OUTPUT + else + echo "PREFIX=pre-" >> $GITHUB_OUTPUT + fi + print-conf: runs-on: ubuntu-latest + needs: + - set-prefix steps: - name: Print configuration run: | echo "GITHUB_EVENT_NAME: ${{ github.event_name }}" - echo "IS_RELEASE: ${{ env.IS_RELEASE }}" echo "REGISTRY: ${{ env.REGISTRY }}" echo "IMAGE_NAME: ${{ env.IMAGE_NAME }}" echo "GITHUB_REPOSITORY: ${{ github.repository }}" + echo "GITHUB_REF: ${{ github.ref }}" echo "GITHUB_ACTOR: ${{ github.actor }}" echo "DJANGO_SUFFIX: ${{ env.DJANGO_SUFFIX }}" echo "FRONTEND_SUFFIX: ${{ env.FRONTEND_SUFFIX }}" echo "NGINX_SUFFIX: ${{ env.NGINX_SUFFIX }}" - echo "PSQL_SUFFIX: ${{ env.PSQL_SUFFIX }}" - echo "PULL_REQUEST_MERGED: ${{ github.event.pull_request.merged }}" - + echo "RELEASE_PREFIX: ${{ needs.set-prefix.outputs.PREFIX }}" + echo "SKIP_BUILD: ${{ github.event.inputs.skip_build }}" + build-and-push-image-django: runs-on: ubuntu-latest + # run if not skip + if: ${{ github.event.inputs.skip_build }} == false + needs: + - set-prefix # Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job. permissions: contents: read @@ -88,22 +118,24 @@ jobs: push-to-registry: true - name: Tag image as latest if release - if: ${{ env.IS_RELEASE }} + if: ${{ needs.set-prefix.outputs.PREFIX }} != 'pre-' run: | docker tag ${{ steps.meta.outputs.tags }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ env.DJANGO_SUFFIX }}:latest docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ env.DJANGO_SUFFIX }}:latest - name: Tag image with latest release number - if: ${{ env.IS_RELEASE }} run: | RELEASE_TAG=$(curl -s https://api.github.com/repos/${{ github.repository }}/releases/latest | jq -r .tag_name) STRIPPED_TAG=${RELEASE_TAG#v} - docker tag ${{ steps.meta.outputs.tags }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ env.DJANGO_SUFFIX }}:$STRIPPED_TAG - docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ env.DJANGO_SUFFIX }}:$STRIPPED_TAG + docker tag ${{ steps.meta.outputs.tags }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ env.DJANGO_SUFFIX }}:${{ needs.set-prefix.outputs.PREFIX }}$STRIPPED_TAG + docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ env.DJANGO_SUFFIX }}:${{ needs.set-prefix.outputs.PREFIX }}$STRIPPED_TAG build-and-push-image-frontend: runs-on: ubuntu-latest - + # run if not skip + if: ${{ github.event.inputs.skip_build }} == false + needs: + - set-prefix permissions: contents: read packages: write @@ -144,22 +176,24 @@ jobs: push-to-registry: true - name: Tag image as latest if release - if: ${{ env.IS_RELEASE }} + if: ${{ needs.set-prefix.outputs.PREFIX }} != 'pre-' run: | docker tag ${{ steps.meta.outputs.tags }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ env.FRONTEND_SUFFIX }}:latest docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ env.FRONTEND_SUFFIX }}:latest - name: Tag image with latest release number - if: ${{ env.IS_RELEASE }} run: | RELEASE_TAG=$(curl -s https://api.github.com/repos/${{ github.repository }}/releases/latest | jq -r .tag_name) STRIPPED_TAG=${RELEASE_TAG#v} - docker tag ${{ steps.meta.outputs.tags }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ env.FRONTEND_SUFFIX }}:$STRIPPED_TAG - docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ env.FRONTEND_SUFFIX }}:$STRIPPED_TAG + docker tag ${{ steps.meta.outputs.tags }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ env.FRONTEND_SUFFIX }}:${{ needs.set-prefix.outputs.PREFIX }}$STRIPPED_TAG + docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ env.FRONTEND_SUFFIX }}:${{ needs.set-prefix.outputs.PREFIX }}$STRIPPED_TAG build-and-push-image-nginx: runs-on: ubuntu-latest - + # run if not skip + if: ${{ github.event.inputs.skip_build }} == false + needs: + - set-prefix permissions: contents: read packages: write @@ -200,71 +234,14 @@ jobs: push-to-registry: true - name: Tag image as latest if release - if: ${{ env.IS_RELEASE }} + if: ${{ needs.set-prefix.outputs.PREFIX }} != 'pre-' run: | docker tag ${{ steps.meta.outputs.tags }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ env.NGINX_SUFFIX }}:latest docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ env.NGINX_SUFFIX }}:latest - name: Tag image with latest release number - if: ${{ env.IS_RELEASE }} - run: | - RELEASE_TAG=$(curl -s https://api.github.com/repos/${{ github.repository }}/releases/latest | jq -r .tag_name) - STRIPPED_TAG=${RELEASE_TAG#v} - docker tag ${{ steps.meta.outputs.tags }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ env.NGINX_SUFFIX }}:$STRIPPED_TAG - docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ env.NGINX_SUFFIX }}:$STRIPPED_TAG - - build-and-push-image-psql: - runs-on: ubuntu-latest - - permissions: - contents: read - packages: write - attestations: write - id-token: write - # - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Log in to the Container registry - uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata (tags, labels) for Docker - id: meta - uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ env.PSQL_SUFFIX }} - - - name: Build and push Docker image - id: push - uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 - with: - context: ./psql - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - - - name: Generate artifact attestation - uses: actions/attest-build-provenance@v1 - with: - subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}${{ env.PSQL_SUFFIX }} - subject-digest: ${{ steps.push.outputs.digest }} - push-to-registry: true - - - name: Tag image as latest if release - if: ${{ env.IS_RELEASE }} - run: | - docker tag ${{ steps.meta.outputs.tags }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ env.PSQL_SUFFIX }}:latest - docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ env.PSQL_SUFFIX }}:latest - - - name: Tag image with latest release number - if: ${{ env.IS_RELEASE }} run: | RELEASE_TAG=$(curl -s https://api.github.com/repos/${{ github.repository }}/releases/latest | jq -r .tag_name) STRIPPED_TAG=${RELEASE_TAG#v} - docker tag ${{ steps.meta.outputs.tags }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ env.PSQL_SUFFIX }}:$STRIPPED_TAG - docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ env.PSQL_SUFFIX }}:$STRIPPED_TAG + docker tag ${{ steps.meta.outputs.tags }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ env.NGINX_SUFFIX }}:${{ needs.set-prefix.outputs.PREFIX }}$STRIPPED_TAG + docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ env.NGINX_SUFFIX }}:${{ needs.set-prefix.outputs.PREFIX }}$STRIPPED_TAG \ No newline at end of file diff --git a/.gitignore b/.gitignore index b347d1f5..53c9ab48 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ django/*static/ coverage.html cov.xml style.css +.pytest_cache/ + # Created by .ignore support plugin (hsz.mobi) ### Python template # Byte-compiled / optimized / DLL files diff --git a/cleanup.ps1 b/cleanup.ps1 deleted file mode 100644 index e67ca58b..00000000 --- a/cleanup.ps1 +++ /dev/null @@ -1,6 +0,0 @@ -# delete build folders -Write-Host "Deleting nginx build-container folder..." -Remove-Item -Path .\nginx\build-container -Recurse -Force - -Write-Host "Deleting psql build-container folder..." -Remove-Item -Path .\psql\build-container -Recurse -Force diff --git a/cleanup.sh b/cleanup.sh deleted file mode 100644 index b79bf0d7..00000000 --- a/cleanup.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/sh -# delete build folders -@echo "Deleting nginx build-container folder..." -rm -rf ./nginx/build-container - -@echo "Deleting psql build-container folder..." -rm -rf ./psql/build-container \ No newline at end of file diff --git a/django-createsuperuser.ps1 b/django-createsuperuser.ps1 new file mode 100644 index 00000000..8a54aa5d --- /dev/null +++ b/django-createsuperuser.ps1 @@ -0,0 +1,3 @@ +./env-inject.ps1 +python ./django/tamprog/manage.py migrate +python ./django/tamprog/manage.py createsuperuser \ No newline at end of file diff --git a/django-makemigrations.ps1 b/django-makemigrations.ps1 new file mode 100644 index 00000000..80956cd5 --- /dev/null +++ b/django-makemigrations.ps1 @@ -0,0 +1,3 @@ +./env-inject.ps1 +python ./django/tamprog/manage.py migrate +python ./django/tamprog/manage.py makemigrations \ No newline at end of file diff --git a/django-runserver.ps1 b/django-runserver.ps1 index f43ff4d1..687f4915 100644 --- a/django-runserver.ps1 +++ b/django-runserver.ps1 @@ -1 +1,13 @@ -./env-inject.ps1 python ./django/tamprog/manage.py runserver \ No newline at end of file +$originalLocation = Get-Location + +try { + ./env-inject.ps1 + + Set-Location -Path "django" + + ./entrypoint.ps1 +} +finally { + Set-Location -Path $originalLocation + Write-Host "[DJANGO-RUNSERVER] Restored location to: $originalLocation" +} \ No newline at end of file diff --git a/django/Dockerfile b/django/Dockerfile index 663c4377..0b2c8cff 100644 --- a/django/Dockerfile +++ b/django/Dockerfile @@ -30,5 +30,7 @@ EXPOSE 8000 # Add health check HEALTHCHECK --interval=10s --timeout=5s --retries=5 CMD curl --fail http://localhost:8000/admin || exit 1 +WORKDIR /usr/src/app/tamprog + # Run migrations and start the Django development server -CMD ["sh", "-c", "python tamprog/manage.py migrate && python tamprog/manage.py runserver 0.0.0.0:8000"] \ No newline at end of file +ENTRYPOINT ["/bin/sh", "../entrypoint.sh"] \ No newline at end of file diff --git a/django/entrypoint.ps1 b/django/entrypoint.ps1 new file mode 100644 index 00000000..5195aa3c --- /dev/null +++ b/django/entrypoint.ps1 @@ -0,0 +1,15 @@ + +# Navigate to Django project directory +Set-Location -Path "tamprog" + +# Run migrations +python manage.py migrate + +# Create superuser +python manage.py auto_createsuperuser + +# Start Celery worker in background +Start-Process -FilePath "celery" -ArgumentList "-A tamprog worker -l INFO" -NoNewWindow + +# Start Django server +python manage.py runserver 0.0.0.0:8000 \ No newline at end of file diff --git a/django/entrypoint.sh b/django/entrypoint.sh new file mode 100644 index 00000000..378c2fa4 --- /dev/null +++ b/django/entrypoint.sh @@ -0,0 +1,5 @@ +#!/bin/sh +python manage.py migrate --verbosity 2 || true +python manage.py auto_createsuperuser || true +celery -A tamprog worker & +python manage.py runserver 0.0.0.0:8000 \ No newline at end of file diff --git a/django/requirements.txt b/django/requirements.txt index a7955782..1b36d57e 100644 Binary files a/django/requirements.txt and b/django/requirements.txt differ diff --git a/django/tamprog/conftest.py b/django/tamprog/conftest.py new file mode 100644 index 00000000..d4c0bb4e --- /dev/null +++ b/django/tamprog/conftest.py @@ -0,0 +1,106 @@ +import pytest +from garden.models import Field, Bed +from fertilizer.models import Fertilizer, BedPlant, BedPlantFertilizer +from mixer.backend.django import mixer +from rest_framework.test import APIClient +from django.contrib.auth import get_user_model +from django.utils import timezone + +User = get_user_model() + +@pytest.fixture +def celery_settings(settings): + """Настройка тестового окружения для Celery""" + settings.CELERY_TASK_ALWAYS_EAGER = True + settings.CELERY_TASK_EAGER_PROPAGATES = True + +@pytest.fixture +def api_client(): + return APIClient() + +@pytest.fixture +def user(): + user = mixer.blend(User, username='testuser') + user.set_password('testpassword') + user.save() + return user + +@pytest.fixture +def person(): + return mixer.blend('user.Person') + +@pytest.fixture +def fields(): + # Создаем 5 участков, каждый из которых имеет уникальное имя и цену + return mixer.cycle(5).blend('garden.Field', + name=lambda: mixer.faker.city(), + price=lambda: mixer.faker.random_number(digits=3) * 1.0, + count_beds=10) + +@pytest.fixture +def beds(fields, person): + # Создаем 10 грядок, которые могут быть арендованы или не арендованы + # Для арендованных грядок будет назначен пользователь как арендатор + return mixer.cycle(10).blend( + 'garden.Bed', + field=lambda: mixer.faker.random_element(fields), # связываем грядки с участками + is_rented=lambda: mixer.faker.boolean(), # случайным образом определяем арендуемая ли грядка + rented_by=lambda: person if mixer.faker.boolean() else None # если арендована, назначаем арендатора + ) + +@pytest.fixture +def fertilizers(): + # Создаем несколько удобрений с уникальными составами + return mixer.cycle(5).blend('fertilizer.Fertilizer', + name=lambda: f"Fertilizer {mixer.faker.word()}", + boost=lambda: mixer.faker.random_int(min=1, max=10), + compound=lambda: mixer.faker.sentence(nb_words=3)) + +@pytest.fixture +def plants(): + # Создаем 10 растений с уникальными именами, описанием и стоимостью + return mixer.cycle(10).blend('plants.Plant', + name=lambda: mixer.faker.word(), + growth_time=lambda: mixer.faker.random_int(min=5, max=30), + price=lambda: mixer.faker.random_number(digits=2) * 1.0, + description=lambda: mixer.faker.text(max_nb_chars=100)) + +@pytest.fixture +def bed_plants(beds, plants): + # Создаем посадки растений на грядках + bed_plants = mixer.cycle(10).blend('plants.BedPlant', + bed=lambda: mixer.faker.random_element(beds), + plant=lambda: mixer.faker.random_element(plants), + fertilizer_applied=mixer.faker.boolean(), + growth_time=lambda: mixer.faker.random_int(min=5, max=30)) + return bed_plants + +@pytest.fixture +def bed_plant_fertilizers(bed_plants, fertilizers): + # Применяем удобрения к посадкам + bed_plant_fertilizers = mixer.cycle(10).blend('fertilizer.BedPlantFertilizer', + bed_plant=lambda: mixer.faker.random_element(bed_plants), + fertilizer=lambda: mixer.faker.random_element(fertilizers)) + return bed_plant_fertilizers + + +@pytest.fixture +def workers(): + # Создаем 10 рабочих с уникальными именами и стоимостью услуг + return mixer.cycle(10).blend('user.Worker', + name=lambda: mixer.faker.name(), + price=lambda: mixer.faker.random_number(digits=2) * 1.0, + description=lambda: mixer.faker.text(max_nb_chars=100)) + +@pytest.fixture +def orders(user, workers, beds, plants): + # Создаем 10 заказов для пользователя, связанных с рабочими, грядками и растениями + return mixer.cycle(10).blend('orders.Order', + user=user, + worker=lambda: mixer.faker.random_element(workers), + bed=lambda: mixer.faker.random_element(beds), + plant=lambda: mixer.faker.random_element(plants), + action=lambda: mixer.faker.word(), + completed_at=lambda: None if mixer.faker.boolean() else timezone.now(), + total_cost=lambda: mixer.faker.random_number(digits=3) * 1.0) + diff --git a/django/tamprog/fertilizer/__init__.py b/django/tamprog/fertilizer/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/django/tamprog/fertilizer/admin.py b/django/tamprog/fertilizer/admin.py new file mode 100644 index 00000000..8d3ce983 --- /dev/null +++ b/django/tamprog/fertilizer/admin.py @@ -0,0 +1,8 @@ +from django.contrib import admin + +from .models import * + +admin.site.register(Fertilizer) +admin.site.register(BedPlantFertilizer) + +# Register your models here. diff --git a/django/tamprog/fertilizer/apps.py b/django/tamprog/fertilizer/apps.py new file mode 100644 index 00000000..1b56f230 --- /dev/null +++ b/django/tamprog/fertilizer/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class FertilizerConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'fertilizer' diff --git a/django/tamprog/fertilizer/migrations/0001_initial.py b/django/tamprog/fertilizer/migrations/0001_initial.py new file mode 100644 index 00000000..8ecfee6d --- /dev/null +++ b/django/tamprog/fertilizer/migrations/0001_initial.py @@ -0,0 +1,31 @@ +# Generated by Django 4.2.16 on 2024-10-27 13:27 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='BedPlantFertilizer', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('applied_at', models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name='Fertilizer', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('boost', models.IntegerField(validators=[django.core.validators.MinValueValidator(0)])), + ('compound', models.CharField(max_length=1024)), + ], + ), + ] diff --git a/django/tamprog/fertilizer/migrations/0002_initial.py b/django/tamprog/fertilizer/migrations/0002_initial.py new file mode 100644 index 00000000..c279c1db --- /dev/null +++ b/django/tamprog/fertilizer/migrations/0002_initial.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.16 on 2024-10-27 13:27 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('plants', '0001_initial'), + ('fertilizer', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='bedplantfertilizer', + name='bed_plant', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='plants.bedplant'), + ), + migrations.AddField( + model_name='bedplantfertilizer', + name='fertilizer', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='fertilizer.fertilizer'), + ), + ] diff --git a/django/tamprog/fertilizer/migrations/__init__.py b/django/tamprog/fertilizer/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/django/tamprog/fertilizer/models.py b/django/tamprog/fertilizer/models.py new file mode 100644 index 00000000..7461e681 --- /dev/null +++ b/django/tamprog/fertilizer/models.py @@ -0,0 +1,14 @@ +from django.db import models +from plants.models import BedPlant +from django.core.validators import EmailValidator, RegexValidator, MinValueValidator + + +class Fertilizer(models.Model): + name = models.CharField(max_length=100) + boost = models.IntegerField(validators=[MinValueValidator(0)]) + compound = models.CharField(max_length=1024) + +class BedPlantFertilizer(models.Model): + bed_plant = models.ForeignKey(BedPlant, on_delete=models.CASCADE) + fertilizer = models.ForeignKey(Fertilizer, on_delete=models.CASCADE) + applied_at = models.DateTimeField(auto_now_add=True) diff --git a/django/tamprog/fertilizer/permission.py b/django/tamprog/fertilizer/permission.py new file mode 100644 index 00000000..ac035b34 --- /dev/null +++ b/django/tamprog/fertilizer/permission.py @@ -0,0 +1,24 @@ +from rest_framework.permissions import BasePermission +import re + +class AgronomistPermission(BasePermission): + def has_permission(self, request, view): + if request.user and request.user.is_authenticated: + username = request.user.username + if re.match(r'^agronom\d+$', username): + return True + if request.user.is_superuser: + return True + return request.method in ['GET', 'HEAD', 'OPTIONS'] + return False + + +class BedPlantF(BasePermission): + def has_permission(self, request, view): + if request.user: + username = request.user.username + if re.match(r'^agronom\d+$', username): + return True + if request.user.is_superuser: + return True + return False diff --git a/django/tamprog/fertilizer/serializers.py b/django/tamprog/fertilizer/serializers.py new file mode 100644 index 00000000..9eee7e98 --- /dev/null +++ b/django/tamprog/fertilizer/serializers.py @@ -0,0 +1,12 @@ +from rest_framework import serializers +from .models import Fertilizer, BedPlantFertilizer + +class FertilizerSerializer(serializers.ModelSerializer): + class Meta: + model = Fertilizer + fields = "__all__" + +class BedPlantFertilizerSerializer(serializers.ModelSerializer): + class Meta: + model = BedPlantFertilizer + fields = "__all__" diff --git a/django/tamprog/fertilizer/services.py b/django/tamprog/fertilizer/services.py new file mode 100644 index 00000000..f3182f2e --- /dev/null +++ b/django/tamprog/fertilizer/services.py @@ -0,0 +1,12 @@ +from .models import Fertilizer, BedPlantFertilizer + + +class FertilizerService: + @staticmethod + def create_fertilizer(name, boost, compound): + fertilizer = Fertilizer.objects.create(name=name, boost=boost, compound=compound) + return fertilizer + + @staticmethod + def get_fertilizers_for_plant(bed_plant): + return BedPlantFertilizer.objects.filter(bed_plant=bed_plant) diff --git a/django/tamprog/fertilizer/tests.py b/django/tamprog/fertilizer/tests.py new file mode 100644 index 00000000..8c297485 --- /dev/null +++ b/django/tamprog/fertilizer/tests.py @@ -0,0 +1,34 @@ +import pytest +from .services import FertilizerService +from .models import Fertilizer + +@pytest.mark.django_db +def test_create_fertilizer(): + # Проверяем создание удобрения через сервисный метод + fertilizer = FertilizerService.create_fertilizer(name="Compost", boost=3, compound="Organic") + assert fertilizer.name == "Compost" + assert fertilizer.boost == 3 + assert fertilizer.compound == "Organic" + assert Fertilizer.objects.filter(name="Compost").exists() + + +@pytest.mark.django_db +def test_get_fertilizers_for_all_plants(bed_plants, bed_plant_fertilizers): + for bed_plant in bed_plants: + fertilizers = FertilizerService.get_fertilizers_for_plant(bed_plant) + expected_fertilizers = [ + bpf.fertilizer for bpf in bed_plant_fertilizers if bpf.bed_plant == bed_plant + ] + assert fertilizers.count() == len(expected_fertilizers), ( + f"Количество удобрений не совпадает для BedPlant с id {bed_plant.id}" + ) + assert all(f.fertilizer in expected_fertilizers for f in fertilizers), ( + f"Некорректные удобрения для BedPlant с id {bed_plant.id}" + ) + + + + + + + diff --git a/django/tamprog/fertilizer/urls.py b/django/tamprog/fertilizer/urls.py new file mode 100644 index 00000000..13ecd4c9 --- /dev/null +++ b/django/tamprog/fertilizer/urls.py @@ -0,0 +1,11 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import FertilizerViewSet, BedPlantFertilizerViewSet + +router = DefaultRouter() +router.register(r'fertilizer', FertilizerViewSet) +router.register(r'bedfertilizer', BedPlantFertilizerViewSet) + +urlpatterns = [ + path('', include(router.urls)), +] diff --git a/django/tamprog/fertilizer/views.py b/django/tamprog/fertilizer/views.py new file mode 100644 index 00000000..540a6ad5 --- /dev/null +++ b/django/tamprog/fertilizer/views.py @@ -0,0 +1,208 @@ +from rest_framework import viewsets, status +from rest_framework.decorators import action +from rest_framework.response import Response +from .models import Fertilizer, BedPlantFertilizer +from .permission import * +from .serializers import FertilizerSerializer, BedPlantFertilizerSerializer +from .services import FertilizerService + +from drf_spectacular.utils import extend_schema, extend_schema_view, \ + OpenApiResponse, OpenApiParameter, OpenApiExample + +def FertilizerParameters(required=False): + return [ + OpenApiParameter( + name="name", + description="Fertilizer name", + type=str, + required=required, + ), + OpenApiParameter( + name="boost", + description="Fertilizer boost", + type=int, + required=required, + ), + OpenApiParameter( + name="compound", + description="Fertilizer compound", + type=str, + required=required, + ), + ] + +@extend_schema(tags=['Fertilizer']) +class FertilizerViewSet(viewsets.ModelViewSet): + queryset = Fertilizer.objects.all() + serializer_class = FertilizerSerializer + permission_classes = [AgronomistPermission] + + @extend_schema( + summary='Get all fertilizers', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description='Successful response', + response=FertilizerSerializer(many=True) + ) + }, + ) + def list(self, request, *args, **kwargs): + return super().list(request, *args, **kwargs) + + @extend_schema( + summary='Get fertilizer by ID', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description='Successful response', + response=FertilizerSerializer + ) + }, + ) + def retrieve(self, request, *args, **kwargs): + return super().retrieve(request, *args, **kwargs) + + @extend_schema( + summary='Create fertilizer', + responses={ + status.HTTP_201_CREATED: OpenApiResponse( + description='Successful response', + ) + }, + parameters=FertilizerParameters(required=True), + ) + def create(self, request, *args, **kwargs): + return super().create(request, *args, **kwargs) + + @extend_schema( + summary='Update fertilizer', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description='Successful response', + ) + }, + parameters=FertilizerParameters(required=True), + ) + def update(self, request, *args, **kwargs): + return super().update(request, *args, **kwargs) + + @extend_schema( + summary='Partially update fertilizer', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description='Successful response', + ) + }, + parameters=FertilizerParameters(), + ) + def partial_update(self, request, *args, **kwargs): + return super().partial_update(request, *args, **kwargs) + + @extend_schema( + summary='Delete fertilizer', + responses={ + status.HTTP_204_NO_CONTENT: OpenApiResponse( + description='Successful response', + ) + }, + ) + def destroy(self, request, *args, **kwargs): + return super().destroy(request, *args, **kwargs) + + def perform_create(self, serializer): + name = serializer.validated_data['name'] + boost = serializer.validated_data['boost'] + compound = serializer.validated_data['compound'] + FertilizerService.create_fertilizer(name, boost, compound) + +def BedPlantFertilizerParameters(required=False): + return [ + OpenApiParameter( + name="bed_plant", + description="Bed plant ID", + type=int, + required=required, + ), + OpenApiParameter( + name="fertilizer", + description="Fertilizer ID", + type=int, + required=required, + ), + ] + +@extend_schema(tags=['Fertilizer', 'Plant']) +class BedPlantFertilizerViewSet(viewsets.ModelViewSet): + queryset = BedPlantFertilizer.objects.all() + serializer_class = BedPlantFertilizerSerializer + permission_classes = [BedPlantF] + + @extend_schema( + summary='Get fertilizers applied to all plants', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description='Successful response', + response=BedPlantFertilizerSerializer(many=True) + ) + }, + ) + def list(self, request, *args, **kwargs): + return super().list(request, *args, **kwargs) + + @extend_schema( + summary='Apply fertilizer to a plant', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description='Successful response', + ) + }, + parameters=BedPlantFertilizerParameters(required=True), + ) + def create(self, request, *args, **kwargs): + return super().create(request, *args, **kwargs) + + @extend_schema( + summary='Update fertilizer applied to a plant', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description='Successful response', + ) + }, + parameters=BedPlantFertilizerParameters(), + ) + def partial_update(self, request, *args, **kwargs): + return super().partial_update(request, *args, **kwargs) + + @extend_schema( + summary='Delete fertilizer applied to a plant', + responses={ + status.HTTP_204_NO_CONTENT: OpenApiResponse( + description='Successful response', + ) + }, + ) + def destroy(self, request, *args, **kwargs): + return super().destroy(request, *args, **kwargs) + + @extend_schema( + summary='Get fertilizer applied to a plant', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description='Successful response', + response=BedPlantFertilizerSerializer + ) + }, + ) + def retrieve(self, request, *args, **kwargs): + return super().retrieve(request, *args, **kwargs) + + @extend_schema( + summary='Update fertilizer applied to a plant', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description='Successful response', + ) + }, + parameters=BedPlantFertilizerParameters(required=True), + ) + def update(self, request, *args, **kwargs): + return super().update(request, *args, **kwargs) \ No newline at end of file diff --git a/django/tamprog/garden/admin.py b/django/tamprog/garden/admin.py index 8602d7b2..c65f65e6 100644 --- a/django/tamprog/garden/admin.py +++ b/django/tamprog/garden/admin.py @@ -1,15 +1,8 @@ from django.contrib import admin from .models import * -admin.site.register(Agronomist) -admin.site.register(Supplier) -admin.site.register(Worker) -admin.site.register(GardenBed) -admin.site.register(Fertilizer) -admin.site.register(User) -admin.site.register(Plant) -admin.site.register(Plot) -admin.site.register(Order) -admin.site.register(AvailablePlants) +admin.site.register(Field) +admin.site.register(Bed) + diff --git a/django/tamprog/garden/migrations/0001_initial.py b/django/tamprog/garden/migrations/0001_initial.py index 7960d701..f4905c58 100644 --- a/django/tamprog/garden/migrations/0001_initial.py +++ b/django/tamprog/garden/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.16 on 2024-09-28 10:48 +# Generated by Django 4.2.16 on 2024-10-27 13:27 from django.db import migrations, models import django.db.models.deletion @@ -13,87 +13,19 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='Agronomist', + name='Field', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('salary', models.IntegerField()), - ('days_work', models.CharField(max_length=1024)), - ('work_schedule', models.CharField(max_length=1024)), + ('name', models.CharField(max_length=100)), + ('count_beds', models.IntegerField(default=0)), ], ), migrations.CreateModel( - name='Fertilizer', + name='Bed', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('compound', models.CharField(max_length=1024)), - ], - ), - migrations.CreateModel( - name='GardenBed', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('state', models.CharField(max_length=1024)), - ('size', models.FloatField()), - ], - ), - migrations.CreateModel( - name='Supplier', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('email', models.CharField(max_length=1024)), - ('account_number', models.CharField(max_length=1024)), - ], - ), - migrations.CreateModel( - name='Worker', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('job_title', models.CharField(max_length=1024)), - ('salary', models.IntegerField()), - ('days_work', models.CharField(max_length=1024)), - ('work_schedule', models.CharField(max_length=1024)), - ], - ), - migrations.CreateModel( - name='User', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('password', models.CharField(max_length=1024)), - ('login', models.CharField(max_length=1024)), - ('role', models.CharField(max_length=1024)), - ('email', models.CharField(max_length=1024)), - ('phone', models.CharField(max_length=18)), - ('agronomist_id', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='garden.agronomist', verbose_name='agronomist_id')), - ('worker_id', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='garden.worker', verbose_name='worker_id')), - ], - ), - migrations.CreateModel( - name='Plot', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('size', models.FloatField()), - ('garden_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='garden.gardenbed', verbose_name='garden_id')), - ], - ), - migrations.CreateModel( - name='Plant', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=1024)), - ('growth_conditions', models.CharField(max_length=1024)), - ('nutrients', models.CharField(max_length=1024)), - ('fertilizer_id', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='garden.fertilizer', verbose_name='fertilizer_id')), - ('garden_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='garden.gardenbed', verbose_name='garden_id')), - ], - ), - migrations.CreateModel( - name='Order', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('deadline', models.DateField()), - ('garden_id', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='garden.gardenbed', verbose_name='garden_id')), - ('plant_id', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='garden.plant', verbose_name='plant_id')), - ('worker_id', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='garden.worker', verbose_name='worker_id')), + ('is_rented', models.BooleanField(default=False)), + ('field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='garden.field')), ], ), ] diff --git a/django/tamprog/garden/migrations/0002_initial.py b/django/tamprog/garden/migrations/0002_initial.py new file mode 100644 index 00000000..932cea96 --- /dev/null +++ b/django/tamprog/garden/migrations/0002_initial.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.16 on 2024-10-27 13:27 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('garden', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='bed', + name='rented_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/django/tamprog/garden/migrations/0002_remove_plant_fertilizer_id_order_fertilizer_id.py b/django/tamprog/garden/migrations/0002_remove_plant_fertilizer_id_order_fertilizer_id.py deleted file mode 100644 index a47322ad..00000000 --- a/django/tamprog/garden/migrations/0002_remove_plant_fertilizer_id_order_fertilizer_id.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 4.2.16 on 2024-09-28 12:18 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('garden', '0001_initial'), - ] - - operations = [ - migrations.RemoveField( - model_name='plant', - name='fertilizer_id', - ), - migrations.AddField( - model_name='order', - name='fertilizer_id', - field=models.ForeignKey(default=0, on_delete=django.db.models.deletion.PROTECT, to='garden.fertilizer', verbose_name='fertilizer_id'), - preserve_default=False, - ), - ] diff --git a/django/tamprog/garden/migrations/0003_bed_price.py b/django/tamprog/garden/migrations/0003_bed_price.py new file mode 100644 index 00000000..581a3614 --- /dev/null +++ b/django/tamprog/garden/migrations/0003_bed_price.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.16 on 2024-10-27 15:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('garden', '0002_initial'), + ] + + operations = [ + migrations.AddField( + model_name='bed', + name='price', + field=models.FloatField(default=0.0), + ), + ] diff --git a/django/tamprog/garden/migrations/0003_fertilizer_name.py b/django/tamprog/garden/migrations/0003_fertilizer_name.py deleted file mode 100644 index 209c1981..00000000 --- a/django/tamprog/garden/migrations/0003_fertilizer_name.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 4.2.16 on 2024-10-16 10:57 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('garden', '0002_remove_plant_fertilizer_id_order_fertilizer_id'), - ] - - operations = [ - migrations.AddField( - model_name='fertilizer', - name='name', - field=models.CharField(default=1, max_length=1024), - preserve_default=False, - ), - ] diff --git a/django/tamprog/garden/migrations/0004_remove_bed_price_field_price.py b/django/tamprog/garden/migrations/0004_remove_bed_price_field_price.py new file mode 100644 index 00000000..6d621a1e --- /dev/null +++ b/django/tamprog/garden/migrations/0004_remove_bed_price_field_price.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.16 on 2024-10-27 16:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('garden', '0003_bed_price'), + ] + + operations = [ + migrations.RemoveField( + model_name='bed', + name='price', + ), + migrations.AddField( + model_name='field', + name='price', + field=models.FloatField(default=0.0), + ), + ] diff --git a/django/tamprog/garden/migrations/0004_rename_growth_conditions_plant_description_and_more.py b/django/tamprog/garden/migrations/0004_rename_growth_conditions_plant_description_and_more.py deleted file mode 100644 index 3062ea0c..00000000 --- a/django/tamprog/garden/migrations/0004_rename_growth_conditions_plant_description_and_more.py +++ /dev/null @@ -1,53 +0,0 @@ -# Generated by Django 4.2.16 on 2024-10-19 10:51 - -import datetime -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('garden', '0003_fertilizer_name'), - ] - - operations = [ - migrations.RenameField( - model_name='plant', - old_name='growth_conditions', - new_name='description', - ), - migrations.RemoveField( - model_name='plant', - name='nutrients', - ), - migrations.AddField( - model_name='fertilizer', - name='boost', - field=models.IntegerField(default=3), - preserve_default=False, - ), - migrations.AddField( - model_name='fertilizer', - name='price', - field=models.FloatField(default=0), - preserve_default=False, - ), - migrations.AddField( - model_name='plant', - name='growth_time', - field=models.IntegerField(default=0), - preserve_default=False, - ), - migrations.AddField( - model_name='plant', - name='landing_data', - field=models.DateField(default=datetime.datetime(2024, 10, 19, 10, 51, 33, 167403, tzinfo=datetime.timezone.utc)), - preserve_default=False, - ), - migrations.AddField( - model_name='plant', - name='price', - field=models.FloatField(default=0), - preserve_default=False, - ), - ] diff --git a/django/tamprog/garden/migrations/0005_availableplants_gardenbed_price_and_more.py b/django/tamprog/garden/migrations/0005_availableplants_gardenbed_price_and_more.py deleted file mode 100644 index b96e5e7a..00000000 --- a/django/tamprog/garden/migrations/0005_availableplants_gardenbed_price_and_more.py +++ /dev/null @@ -1,97 +0,0 @@ -# Generated by Django 4.2.16 on 2024-10-19 11:58 - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('garden', '0004_rename_growth_conditions_plant_description_and_more'), - ] - - operations = [ - migrations.CreateModel( - name='AvailablePlants', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=1024)), - ('price', models.FloatField(validators=[django.core.validators.MinValueValidator(0.0)])), - ('growth_time', models.IntegerField(validators=[django.core.validators.MinValueValidator(0)])), - ('description', models.CharField(max_length=1024)), - ('landing_data', models.DateField()), - ('garden_id', models.IntegerField(default=0)), - ], - ), - migrations.AddField( - model_name='gardenbed', - name='price', - field=models.FloatField(default=0, validators=[django.core.validators.MinValueValidator(0.0)]), - preserve_default=False, - ), - migrations.AlterField( - model_name='agronomist', - name='salary', - field=models.IntegerField(validators=[django.core.validators.MinValueValidator(0)]), - ), - migrations.AlterField( - model_name='fertilizer', - name='boost', - field=models.IntegerField(validators=[django.core.validators.MinValueValidator(0)]), - ), - migrations.AlterField( - model_name='fertilizer', - name='price', - field=models.FloatField(validators=[django.core.validators.MinValueValidator(0.0)]), - ), - migrations.AlterField( - model_name='gardenbed', - name='size', - field=models.FloatField(validators=[django.core.validators.MinValueValidator(0.0)]), - ), - migrations.AlterField( - model_name='plant', - name='growth_time', - field=models.IntegerField(validators=[django.core.validators.MinValueValidator(0)]), - ), - migrations.AlterField( - model_name='plant', - name='price', - field=models.FloatField(validators=[django.core.validators.MinValueValidator(0.0)]), - ), - migrations.AlterField( - model_name='plot', - name='size', - field=models.FloatField(validators=[django.core.validators.MinValueValidator(0.0)]), - ), - migrations.AlterField( - model_name='supplier', - name='account_number', - field=models.CharField(max_length=20, unique=True), - ), - migrations.AlterField( - model_name='supplier', - name='email', - field=models.CharField(max_length=1024, unique=True, validators=[django.core.validators.EmailValidator(allowlist=['mail.ru', 'gmail.com', 'yahoo.com', 'yandex.ru'])]), - ), - migrations.AlterField( - model_name='user', - name='email', - field=models.CharField(max_length=1024, unique=True, validators=[django.core.validators.EmailValidator(allowlist=['mail.ru', 'gmail.com', 'yahoo.com', 'yandex.ru'])]), - ), - migrations.AlterField( - model_name='user', - name='login', - field=models.CharField(max_length=1024, unique=True), - ), - migrations.AlterField( - model_name='user', - name='phone', - field=models.CharField(max_length=18, unique=True, validators=[django.core.validators.RegexValidator(regex='^+7d{10}$')]), - ), - migrations.AlterField( - model_name='worker', - name='salary', - field=models.IntegerField(validators=[django.core.validators.MinValueValidator(0)]), - ), - ] diff --git a/django/tamprog/garden/migrations/0006_rename_garden_id_availableplants_garden_num.py b/django/tamprog/garden/migrations/0006_rename_garden_id_availableplants_garden_num.py deleted file mode 100644 index 79339d15..00000000 --- a/django/tamprog/garden/migrations/0006_rename_garden_id_availableplants_garden_num.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.16 on 2024-10-19 12:04 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('garden', '0005_availableplants_gardenbed_price_and_more'), - ] - - operations = [ - migrations.RenameField( - model_name='availableplants', - old_name='garden_id', - new_name='garden_num', - ), - ] diff --git a/django/tamprog/garden/migrations/0007_rename_garden_num_availableplants_garden_id.py b/django/tamprog/garden/migrations/0007_rename_garden_num_availableplants_garden_id.py deleted file mode 100644 index a049d7c1..00000000 --- a/django/tamprog/garden/migrations/0007_rename_garden_num_availableplants_garden_id.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.16 on 2024-10-19 12:08 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('garden', '0006_rename_garden_id_availableplants_garden_num'), - ] - - operations = [ - migrations.RenameField( - model_name='availableplants', - old_name='garden_num', - new_name='garden_id', - ), - ] diff --git a/django/tamprog/garden/models.py b/django/tamprog/garden/models.py index 6f245c0e..4ec16092 100644 --- a/django/tamprog/garden/models.py +++ b/django/tamprog/garden/models.py @@ -1,85 +1,14 @@ from django.db import models -from django.core.validators import EmailValidator, RegexValidator, MinValueValidator +from user.models import Person -#агроном, поставщик, пользователь, грядка, растение, удобрение, работник, участок, заказ +class Field(models.Model): + name = models.CharField(max_length=100) + count_beds = models.IntegerField(default=0) + price = models.FloatField(default=0.00) -class Agronomist(models.Model): - #зп, дни работы, график работы - salary = models.IntegerField(validators=[MinValueValidator(0)]) - days_work = models.CharField(max_length=1024) - work_schedule = models.CharField(max_length=1024) +class Bed(models.Model): + field = models.ForeignKey(Field, on_delete=models.CASCADE) + is_rented = models.BooleanField(default=False) + rented_by = models.ForeignKey(Person, null=True, blank=True, on_delete=models.SET_NULL) -class Supplier(models.Model): - #почта, данные для оплаты-номер счета - email_validator = EmailValidator(allowlist=["mail.ru", "gmail.com", "yahoo.com", "yandex.ru"]) - email = models.CharField(max_length=1024, unique=True, validators=[email_validator]) - account_number = models.CharField(max_length=20, unique=True) -class Worker(models.Model): - #должность, зп, дни работы, график работы - job_title = models.CharField(max_length=1024) - salary = models.IntegerField(validators=[MinValueValidator(0)]) - days_work = models.CharField(max_length=1024) - work_schedule = models.CharField(max_length=1024) - -class GardenBed(models.Model): - #состояние, размер, цена - state =models.CharField(max_length=1024) - size = models.FloatField(validators=[MinValueValidator(0.0)]) - price = models.FloatField(validators=[MinValueValidator(0.0)]) - -class Fertilizer(models.Model): - #название, цена, то на сколько ускоряет рост, состав - name = models.CharField(max_length=1024) - price = models.FloatField(validators=[MinValueValidator(0.0)]) - boost =models.IntegerField(validators=[MinValueValidator(0)]) - compound = models.CharField(max_length=1024) - -class User(models.Model): - #хеш пароля, логин, роль, почта, номер телефона, внешний ключ на id агронома, внешний ключ на id работника - password = models.CharField(max_length=1024) - login = models.CharField(max_length=1024, unique=True) - role = models.CharField(max_length=1024) - - email_validator = EmailValidator(allowlist = ["mail.ru", "gmail.com", "yahoo.com", "yandex.ru"]) - email = models.CharField(max_length=1024, unique=True, validators=[email_validator]) - - phone_validator = RegexValidator(regex=r'^\+7\d{10}$') - phone = models.CharField(max_length=18, unique=True, validators=[phone_validator]) - - agronomist_id = models.ForeignKey(Agronomist,verbose_name='agronomist_id', on_delete=models.PROTECT) - worker_id = models.ForeignKey(Worker, verbose_name='worker_id', on_delete=models.PROTECT) - -class Plant(models.Model): - #название, цена, время роста, описание, дата посадки, внешний ключ на id грядки - name = models.CharField(max_length=1024) - price = models.FloatField(validators=[MinValueValidator(0.0)]) - growth_time = models.IntegerField(validators=[MinValueValidator(0)]) - description = models.CharField(max_length=1024) - landing_data = models.DateField() - garden_id = models.ForeignKey(GardenBed, verbose_name='garden_id', on_delete=models.CASCADE) - - -class Plot(models.Model): - #внешний ключ на id грядку, размер - size = models.FloatField(validators=[MinValueValidator(0.0)]) - garden_id = models.ForeignKey(GardenBed, verbose_name='garden_id', on_delete=models.CASCADE) - - -class Order(models.Model): - #срок,внешний ключ на id грядку, внешний ключ на id растение, внешний ключ на id работник, внешний ключ на id удобрение - deadline = models.DateField() - garden_id = models.ForeignKey(GardenBed, verbose_name='garden_id', on_delete=models.PROTECT) - plant_id = models.ForeignKey(Plant, verbose_name='plant_id', on_delete=models.PROTECT) - worker_id = models.ForeignKey(Worker, verbose_name='worker_id', on_delete=models.PROTECT) - fertilizer_id = models.ForeignKey(Fertilizer, verbose_name='fertilizer_id', on_delete=models.PROTECT) - - -class AvailablePlants(models.Model): - #название, цена, время роста, описание, дата посадки, грядка - name = models.CharField(max_length=1024) - price = models.FloatField(validators=[MinValueValidator(0.0)]) - growth_time = models.IntegerField(validators=[MinValueValidator(0)]) - description = models.CharField(max_length=1024) - landing_data = models.DateField() - garden_id = models.IntegerField(default=0) \ No newline at end of file diff --git a/django/tamprog/garden/permission.py b/django/tamprog/garden/permission.py new file mode 100644 index 00000000..49b830eb --- /dev/null +++ b/django/tamprog/garden/permission.py @@ -0,0 +1,14 @@ +from rest_framework.permissions import BasePermission +import re + +class AgronomistPermission(BasePermission): + def has_permission(self, request, view): + if request.user and request.user.is_authenticated: + username = request.user.username + if re.match(r'^agronom\d+$', username): + return True + if request.user.is_superuser: + return True + return request.method in ['GET', 'HEAD', 'OPTIONS'] + return False + diff --git a/django/tamprog/garden/queries.py b/django/tamprog/garden/queries.py new file mode 100644 index 00000000..bbbf6c62 --- /dev/null +++ b/django/tamprog/garden/queries.py @@ -0,0 +1,31 @@ +from .models import Field + +class GetFieldsSortedByID: + def __init__(self, ascending: bool = True): + self.ascending = ascending + + def execute(self): + return Field.objects.order_by('id' if self.ascending else '-id') + + +class GetFieldsSortedByName: + def __init__(self, ascending: bool = True): + self.ascending = ascending + + def execute(self): + return Field.objects.order_by('name' if self.ascending else '-name') + + +class GetFieldsSortedByCountBeds: + def __init__(self, ascending: bool = True): + self.ascending = ascending + + def execute(self): + return Field.objects.order_by('count_beds' if self.ascending else '-count_beds') + +class GetFieldsSortedByPrice: + def __init__(self, ascending: bool = True): + self.ascending = ascending + + def execute(self): + return Field.objects.order_by('price' if self.ascending else '-price') \ No newline at end of file diff --git a/django/tamprog/garden/serializers.py b/django/tamprog/garden/serializers.py index f07a138b..3df8d9cb 100644 --- a/django/tamprog/garden/serializers.py +++ b/django/tamprog/garden/serializers.py @@ -1,67 +1,13 @@ from rest_framework import serializers -from .models import * -from django.utils import timezone +from .models import Field, Bed +import re -class AgronomistSerializer(serializers.ModelSerializer): +class FieldSerializer(serializers.ModelSerializer): class Meta: - model = Agronomist - fields = "__all__" + model = Field + fields = '__all__' -class SupplierSerializer(serializers.ModelSerializer): +class BedSerializer(serializers.ModelSerializer): class Meta: - model = Supplier - fields = "__all__" - - -class WorkerSerializer(serializers.ModelSerializer): - class Meta: - model = Worker - fields = "__all__" - - -class GardenBedSerializer(serializers.ModelSerializer): - class Meta: - model = GardenBed - fields = "__all__" - - -class FertilizerSerializer(serializers.ModelSerializer): - class Meta: - model = Fertilizer - fields = "__all__" - - -class UserSerializer(serializers.ModelSerializer): - class Meta: - model = User - fields = "__all__" - - -class PlantSerializer(serializers.ModelSerializer): - class Meta: - model = Plant - fields = "__all__" - def create(self, validated_data): - validated_data['landing_data'] = timezone.now().date() # Устанавливаем текущую дату - return super().create(validated_data) - - -class PlotSerializer(serializers.ModelSerializer): - class Meta: - model = Plot - fields = "__all__" - - -class OrderSerializer(serializers.ModelSerializer): - class Meta: - model = Order - fields = "__all__" - - -class AvailablePlantsSerializer(serializers.ModelSerializer): - class Meta: - model = AvailablePlants - fields = "__all__" - def create(self, validated_data): - validated_data['landing_data'] = timezone.now().date() # Устанавливаем текущую дату - return super().create(validated_data) \ No newline at end of file + model = Bed + fields = '__all__' diff --git a/django/tamprog/garden/services.py b/django/tamprog/garden/services.py new file mode 100644 index 00000000..0673c2f0 --- /dev/null +++ b/django/tamprog/garden/services.py @@ -0,0 +1,96 @@ +from user.models import Person +from .models import Bed +from .queries import * +from rest_framework.response import Response +from rest_framework import status +# These \/ imports for the Celery +from celery import shared_task +from celery.result import AsyncResult +from django.forms.models import model_to_dict +from django.conf import settings + +@shared_task +def get_sorted_fields_task(sort_by: str = 'id', ascending: bool = True): + if sort_by == 'id': + query = GetFieldsSortedByID(ascending) + elif sort_by == 'name': + query = GetFieldsSortedByName(ascending) + elif sort_by == 'count_beds': + query = GetFieldsSortedByCountBeds(ascending) + elif sort_by == 'price': + query = GetFieldsSortedByPrice(ascending) + else: + return [] + queryset = query.execute() + return [model_to_dict(field) for field in queryset] + +class FieldService: + @staticmethod + def get_sorted_fields(sort_by: str = 'price', ascending: bool = True): + task = get_sorted_fields_task.delay(sort_by, ascending) + result = AsyncResult(task.id) + return result.get(timeout=settings.DJANGO_ASYNC_TIMEOUT_S) + +class BedService: + @staticmethod + def rent_bed(bed_id: int, person: Person): + try: + bed = Bed.objects.get(id=bed_id) + if bed.is_rented: + return Response( + {"error": "This bed is already rented."}, + status=status.HTTP_400_BAD_REQUEST + ) + bed.is_rented = True + bed.rented_by = person + bed.save() + + field = bed.field + field.count_beds -= 1 + field.save() + return Response( + {"message": "Bed successfully rented."}, + status=status.HTTP_200_OK + ) + except Bed.DoesNotExist: + return Response( + {"error": "Bed with the given ID does not exist."}, + status=status.HTTP_404_NOT_FOUND + ) + + @staticmethod + def release_bed(bed_id: int): + try: + bed = Bed.objects.get(id=bed_id) + field = bed.field + if not bed.is_rented: + return Response( + {"error": "This bed is not currently rented."}, + status=status.HTTP_400_BAD_REQUEST + ) + bed.is_rented = False + bed.rented_by = None + bed.save() + + field.count_beds += 1 + field.save() + return Response( + {"message": "Bed successfully released."}, + status=status.HTTP_200_OK + ) + except Bed.DoesNotExist: + return Response( + {"error": "Bed with the given ID does not exist."}, + status=status.HTTP_404_NOT_FOUND + ) + + + @staticmethod + def get_user_beds(user): + return Bed.objects.filter(rented_by=user) + + @staticmethod + def filter_beds(is_rented=None): + if is_rented is not None: + return Bed.objects.filter(is_rented=is_rented) + return Bed.objects.all() diff --git a/django/tamprog/garden/tests.py b/django/tamprog/garden/tests.py new file mode 100644 index 00000000..cb4b60b0 --- /dev/null +++ b/django/tamprog/garden/tests.py @@ -0,0 +1,141 @@ +import pytest +from .services import FieldService,BedService +from .models import Field,Bed +from mixer.backend.django import mixer +from celery.result import AsyncResult +from unittest.mock import patch + +def test_get_sorted_fields_success(celery_settings, mocker): + mocked_task = mocker.patch('garden.services.get_sorted_fields_task.delay') + mocked_task.return_value = AsyncResult('fake-task-id') + mocker.patch.object(AsyncResult, 'get', return_value=[{'id': 1, 'price': 100}]) + result = FieldService.get_sorted_fields(sort_by='price', ascending=True) + mocked_task.assert_called_once_with('price', True) + assert result == [{'id': 1, 'price': 100}] + +def test_get_sorted_fields_timeout(celery_settings, mocker): + mocked_task = mocker.patch('garden.services.get_sorted_fields_task.delay') + mocked_task.return_value = AsyncResult('fake-task-id') + mocker.patch.object(AsyncResult, 'get', side_effect=Exception('Timeout')) + with pytest.raises(Exception, match='Timeout'): + FieldService.get_sorted_fields(sort_by='price', ascending=True) + +@pytest.mark.django_db +def test_filter_beds(api_client, user, beds): + api_client.force_authenticate(user=user) + url = '/api/v1/bed/' + response = api_client.get(url, {'is_rented': 'true'}) + assert response.status_code == 200 + + rented_beds = [bed for bed in beds if bed.is_rented] + response_rented_status = [bed['is_rented'] for bed in response.data] + assert all(response_rented_status) + assert len(response_rented_status) == len(rented_beds) + +@pytest.mark.django_db +def test_rent_bed_already_rented(beds, person): + for bed in beds: + bed.is_rented = True + bed.save() + result = BedService.rent_bed(bed_id=bed.id, person=person) + assert result.status_code == 400 + + +@pytest.mark.django_db +def test_rent_bed_success(beds, person): + bed = next(b for b in beds if not b.is_rented) + initial_count = bed.field.count_beds + result = BedService.rent_bed(bed_id=bed.id, person=person) + bed.refresh_from_db() + assert result.status_code == 200 + assert bed.is_rented is True + assert bed.rented_by == person + assert bed.field.count_beds == initial_count - 1 + + +@pytest.mark.django_db +def test_release_bed_success(beds, person): + bed = beds[0] + bed.is_rented = True + bed.rented_by = person + bed.save() + initial_count = bed.field.count_beds + result = BedService.release_bed(bed_id=bed.id) + bed.refresh_from_db() + assert result.status_code == 200 + assert bed.is_rented is False + assert bed.rented_by is None + assert bed.field.count_beds == initial_count + 1 + + +@pytest.mark.django_db +def test_release_bed_not_rented(beds): + for bed in beds: + bed.is_rented = False + bed.save() + result = BedService.release_bed(bed_id=bed.id) + assert result.status_code == 400 + +@pytest.mark.django_db +def test_get_user_beds(beds, person): + for bed in beds: + bed.rented_by = person + bed.is_rented = True + bed.save() + user_beds = BedService.get_user_beds(user=person) + assert len(user_beds) == len(beds) + for bed in user_beds: + assert bed.rented_by == person + assert bed.is_rented is True + + +@pytest.mark.django_db +def test_filter_beds_is_rented(beds): + for bed in beds: + bed.is_rented = True + bed.save() + rented_beds = BedService.filter_beds(is_rented=True) + assert rented_beds.count() == len(beds) + for bed in rented_beds: + assert bed.is_rented is True + + +@pytest.mark.django_db +def test_filter_beds_not_rented(beds): + for bed in beds: + bed.is_rented = False + bed.save() + not_rented_beds = BedService.filter_beds(is_rented=False) + assert not_rented_beds.count() == len(beds) + for bed in not_rented_beds: + assert bed.is_rented is False + + +@pytest.mark.django_db +def test_get_sorted_fields_by_price(celery_settings, mocker,fields): + mocker.patch('garden.services.get_sorted_fields_task.delay') + mocker.patch.object(AsyncResult, 'get', + return_value=[{'price': 30}, + {'price': 31}, + {'price': 32}, + {'price': 33}, + {'price': 34}]) + + fields = FieldService.get_sorted_fields(sort_by='price', ascending=True) + assert [field['price'] for field in fields] == [30, 31, 32, 33, 34] + + +@pytest.mark.django_db +def test_get_sorted_fields_by_beds(celery_settings, mocker): + mocker.patch('garden.services.get_sorted_fields_task.delay') + mocker.patch.object(AsyncResult, 'get', + return_value=[{'count_beds': 10}, + {'count_beds': 11}, + {'count_beds': 12}, + {'count_beds': 13}, + {'count_beds': 14}]) + fields = FieldService.get_sorted_fields(sort_by='count_beds', ascending=True) + assert [field['count_beds'] for field in fields] == [10, 11, 12, 13, 14] + + + diff --git a/django/tamprog/garden/tests/conftest.py b/django/tamprog/garden/tests/conftest.py deleted file mode 100644 index 1df2d386..00000000 --- a/django/tamprog/garden/tests/conftest.py +++ /dev/null @@ -1,60 +0,0 @@ -import pytest -from garden.models import Agronomist, Supplier, Worker, GardenBed, Fertilizer, User, Plant, Plot, Order, AvailablePlants -from django.utils import timezone - -@pytest.fixture -def agronomist(): - return Agronomist.objects.create(salary=50000, days_work="Пн-Пт", work_schedule="9:00-18:00") - -@pytest.fixture -def supplier(): - return Supplier.objects.create(email="supplier@example.com", account_number="123456789") - -@pytest.fixture -def worker(): - return Worker.objects.create(job_title="Рабочий", salary=30000, days_work="Пн-Ср", work_schedule="8:00-17:00") - -@pytest.fixture -def garden_bed(): - return GardenBed.objects.create(state="Хорошее", size=10.5, price=10) - -@pytest.fixture -def fertilizer(): - return Fertilizer.objects.create(compound="Азот, Фосфор",price = 10, - boost = 4, - name = "Навоз") - -@pytest.fixture -def user(agronomist, worker): - return User.objects.create(password="hashed_password", login="user123", role="admin", - email="user@mail.ru", phone="+72345678908", - agronomist_id=agronomist, worker_id=worker) - -@pytest.fixture -def plant(garden_bed): - return Plant.objects.create(name="Помидор", - growth_time=24, - description="Великое", - garden_id=garden_bed, - price=10, - landing_data = "2024-09-03") - -@pytest.fixture -def plot(garden_bed): - return Plot.objects.create(size=20.0, garden_id=garden_bed) - -@pytest.fixture -def order(garden_bed, plant, worker, fertilizer): - return Order.objects.create(deadline="2024-10-19", garden_id=garden_bed, - plant_id=plant, worker_id=worker, fertilizer_id=fertilizer) - -@pytest.fixture -def available_plants(garden_bed): - return AvailablePlants.objects.create( - name="Помидор", - price=15.5, - growth_time=90, - description="Красный сочный овощ", - landing_data="2024-05-10", - garden_id=1 - ) \ No newline at end of file diff --git a/django/tamprog/garden/tests/test_models.py b/django/tamprog/garden/tests/test_models.py deleted file mode 100644 index 01ce4422..00000000 --- a/django/tamprog/garden/tests/test_models.py +++ /dev/null @@ -1,153 +0,0 @@ - - -import pytest -from yaml import compose - -from garden.models import Agronomist, Supplier, Worker, GardenBed, Fertilizer, User, Plant, Plot, Order, AvailablePlants -from django.utils import timezone - -# Тесты для модели Agronomist -@pytest.mark.django_db -def test_create_agronomist(): - agronomist = Agronomist.objects.create( - salary=50000, - days_work="Пн-Пт", - work_schedule="9:00-18:00" - ) - assert agronomist.salary == 50000 - assert agronomist.days_work == "Пн-Пт" - assert agronomist.work_schedule == "9:00-18:00" - -# Тесты для модели Supplier -@pytest.mark.django_db -def test_create_supplier(): - supplier = Supplier.objects.create( - email="supplier@example.com", - account_number="+79994447208" - ) - assert supplier.email == "supplier@example.com" - assert supplier.account_number == "+79994447208" - -# Тесты для модели Worker -@pytest.mark.django_db -def test_create_worker(): - worker = Worker.objects.create( - job_title="Рабочий", - salary=30000, - days_work="Пн-Ср", - work_schedule="8:00-17:00" - ) - assert worker.job_title == "Рабочий" - assert worker.salary == 30000 - assert worker.days_work == "Пн-Ср" - assert worker.work_schedule == "8:00-17:00" - -# Тесты для модели GardenBed -@pytest.mark.django_db -def test_create_gardenbed(): - garden_bed = GardenBed.objects.create( - state="Чернозем", - size=10.5, - price = 10 - ) - assert garden_bed.state == "Чернозем" - assert garden_bed.size == 10.5 - assert garden_bed.price == 10 - -# Тесты для модели Fertilizer -@pytest.mark.django_db -def test_create_fertilizer(): - fertilizer = Fertilizer.objects.create( - compound="Азот, Фосфор", - price = 10, - boost = 4, - name = "Навоз" - ) - assert fertilizer.compound == "Азот, Фосфор" - assert fertilizer.price == 10 - assert fertilizer.boost == 4 - assert fertilizer.name == "Навоз" - - -# Тесты для модели User -@pytest.mark.django_db -def test_create_user(agronomist, worker): - user = User.objects.create( - password="hashed_password", - login="user123", - role="admin", - email="user@example.com", - phone="+1234567890", - agronomist_id=agronomist, - worker_id=worker - ) - assert user.password == "hashed_password" - assert user.login == "user123" - assert user.role == "admin" - assert user.email == "user@example.com" - assert user.phone == "+1234567890" - assert user.agronomist_id == agronomist - assert user.worker_id == worker - -# Тесты для модели Plant -@pytest.mark.django_db -def test_create_plant(garden_bed): - plant = Plant.objects.create( - name="Помидор", - growth_time=24, - description="Великое", - garden_id=garden_bed, - price=10, - landing_data = "2024-09-03" - ) - assert plant.name == "Помидор" - assert plant.growth_time == 24 - assert plant.description == "Великое" - assert plant.garden_id == garden_bed - assert plant.price == 10 - assert plant.landing_data == "2024-09-03" - -# Тесты для модели Plot -@pytest.mark.django_db -def test_create_plot(garden_bed): - plot = Plot.objects.create( - size=20.0, - garden_id=garden_bed - ) - assert plot.size == 20.0 - assert plot.garden_id == garden_bed - -# Тесты для модели Order -@pytest.mark.django_db -def test_create_order(garden_bed, plant, worker, fertilizer): - order = Order.objects.create( - deadline=timezone.now().date(), - garden_id=garden_bed, - plant_id=plant, - worker_id=worker, - fertilizer_id=fertilizer - ) - assert order.deadline == timezone.now().date() - assert order.garden_id == garden_bed - assert order.plant_id == plant - assert order.worker_id == worker - assert order.fertilizer_id == fertilizer - - -@pytest.mark.django_db -def test_create_available_plant(): - plant = AvailablePlants.objects.create( - name="Помидор", - price=15.5, - growth_time=90, - description="Красный сочный овощ", - landing_data="2024-05-10", - garden_id=1 - ) - - assert plant.name == "Помидор" - assert plant.price == 15.5 - assert plant.growth_time == 90 - assert plant.description == "Красный сочный овощ" - assert str(plant.landing_data) == "2024-05-10" - assert plant.garden_id == 1 diff --git a/django/tamprog/garden/tests/test_serializers.py b/django/tamprog/garden/tests/test_serializers.py deleted file mode 100644 index 5b82c2a4..00000000 --- a/django/tamprog/garden/tests/test_serializers.py +++ /dev/null @@ -1,159 +0,0 @@ -import pytest -from garden.serializers import * -from django.utils import timezone -# Тест для сериализатора Agronomist -@pytest.mark.django_db -def test_agronomist_serializer(): - data = { - "salary": 50000, - "days_work": "Пн-Пт", - "work_schedule": "9:00-18:00" - } - serializer = AgronomistSerializer(data=data) - assert serializer.is_valid() - agronomist = serializer.save() - assert agronomist.salary == 50000 - assert agronomist.days_work == "Пн-Пт" - assert agronomist.work_schedule == "9:00-18:00" - -# Тест для сериализатора Supplier -@pytest.mark.django_db -def test_supplier_serializer(): - data = { - "email": "supplier@example.com", - "account_number": "123456789" - } - serializer = SupplierSerializer(data=data) - assert serializer.is_valid() - supplier = serializer.save() - assert supplier.email == "supplier@example.com" - assert supplier.account_number == "123456789" - -# Тест для сериализатора Worker -@pytest.mark.django_db -def test_worker_serializer(): - data = { - "job_title": "Рабочий", - "salary": 30000, - "days_work": "Пн-Ср", - "work_schedule": "8:00-17:00" - } - serializer = WorkerSerializer(data=data) - assert serializer.is_valid() - worker = serializer.save() - assert worker.job_title == "Рабочий" - assert worker.salary == 30000 - assert worker.days_work == "Пн-Ср" - assert worker.work_schedule == "8:00-17:00" - -# Тест для сериализатора GardenBed -@pytest.mark.django_db -def test_garden_bed_serializer(): - data = { - "state": "Хорошее", - "size": 10.5, - "price": 10 - } - serializer = GardenBedSerializer(data=data) - assert serializer.is_valid() - garden_bed = serializer.save() - assert garden_bed.state == "Хорошее" - assert garden_bed.size == 10.5 - assert garden_bed.price == 10 - -# Тест для сериализатора Fertilizer -@pytest.mark.django_db -def test_fertilizer_serializer(): - data = { - "compound": "Азот, Фосфор", - "price": 10, - "boost" : 4, - "name" : "Навоз" - } - serializer = FertilizerSerializer(data=data) - assert serializer.is_valid() - fertilizer = serializer.save() - assert fertilizer.compound == "Азот, Фосфор" - assert fertilizer.price == 10 - assert fertilizer.boost == 4 - assert fertilizer.name == "Навоз" - -# Тест для сериализатора User -@pytest.mark.django_db -def test_user_serializer(agronomist, worker): - data = { - "password": "hashed_password", - "login": "user123", - "role": "admin", - "email": "user@mail.ru", - "phone": "+72345678908", - "agronomist_id": agronomist.id, - "worker_id": worker.id - } - serializer = UserSerializer(data=data) - assert serializer.is_valid(), serializer.errors # Добавлено для отладки - user = serializer.save() - assert user.password == "hashed_password" - assert user.login == "user123" - assert user.role == "admin" - assert user.email == "user@mail.ru" - assert user.phone == "+72345678908" - assert user.agronomist_id == agronomist - assert user.worker_id == worker - -@pytest.mark.django_db -def test_plant_serializer(garden_bed): - data = { - "name": "Помидор", - "growth_time": 24, - "description": "Великое", - "garden_id": garden_bed.id, - "price": 10, - "landing_data": timezone.now().date().isoformat() - - } - - serializer = PlantSerializer(data=data) - assert serializer.is_valid(), serializer.errors - plant = serializer.save() - - - assert plant.name == "Помидор" - assert plant.growth_time == 24 - assert plant.description == "Великое" - assert plant.garden_id == garden_bed - assert plant.price == 10 - assert plant.landing_data == timezone.now().date() - - -# Тест для сериализатора Plot -@pytest.mark.django_db -def test_plot_serializer(garden_bed): - data = { - "size": 20.0, - "garden_id": garden_bed.id - } - serializer = PlotSerializer(data=data) - assert serializer.is_valid() - plot = serializer.save() - assert plot.size == 20.0 - assert plot.garden_id == garden_bed - -# Тест для сериализатора Order -@pytest.mark.django_db -def test_order_serializer(garden_bed, plant, worker, fertilizer): - data = { - "deadline": timezone.now().date(), - "garden_id": garden_bed.id, - "plant_id": plant.id, - "worker_id": worker.id, - "fertilizer_id": fertilizer.id - } - serializer = OrderSerializer(data=data) - assert serializer.is_valid() - order = serializer.save() - assert order.deadline == timezone.now().date() - assert order.garden_id == garden_bed - assert order.plant_id == plant - assert order.worker_id == worker - assert order.fertilizer_id == fertilizer diff --git a/django/tamprog/garden/tests/test_urls.py b/django/tamprog/garden/tests/test_urls.py deleted file mode 100644 index 4c52e043..00000000 --- a/django/tamprog/garden/tests/test_urls.py +++ /dev/null @@ -1,69 +0,0 @@ -import pytest -from django.urls import reverse -from rest_framework import status -from rest_framework.test import APIClient -from garden.models import Agronomist, Supplier, Worker, GardenBed, Fertilizer, User, Plant, Plot, Order - -@pytest.fixture -def api_client(): - return APIClient() - -@pytest.mark.django_db -def test_agronomist_url(api_client): - url = reverse('agronomist-list') - response = api_client.get(url) - assert response.status_code == status.HTTP_200_OK - -@pytest.mark.django_db -def test_supplier_url(api_client): - url = reverse('supplier-list') - response = api_client.get(url) - assert response.status_code == status.HTTP_200_OK - -@pytest.mark.django_db -def test_worker_url(api_client): - url = reverse('worker-list') - response = api_client.get(url) - assert response.status_code == status.HTTP_200_OK - -@pytest.mark.django_db -def test_garden_bed_url(api_client): - url = reverse('gardenbed-list') - response = api_client.get(url) - assert response.status_code == status.HTTP_200_OK - -@pytest.mark.django_db -def test_fertilizer_url(api_client): - url = reverse('fertilizer-list') - response = api_client.get(url) - assert response.status_code == status.HTTP_200_OK - -@pytest.mark.django_db -def test_user_url(api_client): - url = reverse('user-list') - response = api_client.get(url) - assert response.status_code == status.HTTP_200_OK - -@pytest.mark.django_db -def test_plant_url(api_client): - url = reverse('plant-list') - response = api_client.get(url) - assert response.status_code == status.HTTP_200_OK - -@pytest.mark.django_db -def test_plot_url(api_client): - url = reverse('plot-list') - response = api_client.get(url) - assert response.status_code == status.HTTP_200_OK - -@pytest.mark.django_db -def test_order_url(api_client): - url = reverse('order-list') - response = api_client.get(url) - assert response.status_code == status.HTTP_200_OK - -@pytest.mark.django_db -def test_available_plants_url(api_client): - url = reverse('availableplants-list') - response = api_client.get(url) - assert response.status_code == status.HTTP_200_OK \ No newline at end of file diff --git a/django/tamprog/garden/tests/test_views.py b/django/tamprog/garden/tests/test_views.py deleted file mode 100644 index 16616cd6..00000000 --- a/django/tamprog/garden/tests/test_views.py +++ /dev/null @@ -1,253 +0,0 @@ -import pytest -from rest_framework import status -from rest_framework.reverse import reverse -from rest_framework.test import APIClient -from garden.models import Agronomist, Supplier, Worker, GardenBed, Fertilizer, User, Plant, Plot, Order, AvailablePlants - -import datetime -@pytest.fixture -def api_client(): - return APIClient() - - -@pytest.mark.django_db -class TestViewSets: - - @pytest.mark.django_db - def test_create_agronomist(self, api_client): - data = {"salary": 60000, "days_work": "Пн-Сб", "work_schedule": "10:00-19:00"} - response = api_client.post(reverse('agronomist-list'), data, format='json') - assert response.status_code == status.HTTP_201_CREATED - assert Agronomist.objects.count() == 1 - assert Agronomist.objects.get().salary == 60000 - - @pytest.mark.django_db - def test_update_agronomist(self, api_client, agronomist): - data = {"salary": 65000} - response = api_client.patch(reverse('agronomist-detail', args=[agronomist.id]), data, format='json') - assert response.status_code == status.HTTP_200_OK - agronomist.refresh_from_db() - assert agronomist.salary == 65000 - - @pytest.mark.django_db - def test_delete_agronomist(self, api_client, agronomist): - response = api_client.delete(reverse('agronomist-detail', args=[agronomist.id])) - assert response.status_code == status.HTTP_204_NO_CONTENT - assert Agronomist.objects.count() == 0 - - @pytest.mark.django_db - def test_create_supplier(self, api_client): - data = {"email": "new_supplier@example.com", "account_number": "987654321"} - response = api_client.post(reverse('supplier-list'), data, format='json') - assert response.status_code == status.HTTP_201_CREATED - assert Supplier.objects.count() == 1 - - @pytest.mark.django_db - def test_update_supplier(self, api_client, supplier): - data = {"email": "updated_supplier@example.com"} - response = api_client.patch(reverse('supplier-detail', args=[supplier.id]), data, format='json') - assert response.status_code == status.HTTP_200_OK - supplier.refresh_from_db() - assert supplier.email == "updated_supplier@example.com" - - @pytest.mark.django_db - def test_delete_supplier(self, api_client, supplier): - response = api_client.delete(reverse('supplier-detail', args=[supplier.id])) - assert response.status_code == status.HTTP_204_NO_CONTENT - assert Supplier.objects.count() == 0 - - @pytest.mark.django_db - def test_create_worker(self, api_client): - data = {"job_title": "Новый рабочий", "salary": 25000, "days_work": "Пн-Пт", "work_schedule": "9:00-18:00"} - response = api_client.post(reverse('worker-list'), data, format='json') - assert response.status_code == status.HTTP_201_CREATED - assert Worker.objects.count() == 1 - - @pytest.mark.django_db - def test_update_worker(self, api_client, worker): - data = {"salary": 32000} - response = api_client.patch(reverse('worker-detail', args=[worker.id]), data, format='json') - assert response.status_code == status.HTTP_200_OK - worker.refresh_from_db() - assert worker.salary == 32000 - - @pytest.mark.django_db - def test_delete_worker(self, api_client, worker): - response = api_client.delete(reverse('worker-detail', args=[worker.id])) - assert response.status_code == status.HTTP_204_NO_CONTENT - assert Worker.objects.count() == 0 - - @pytest.mark.django_db - def test_create_garden_bed(self, api_client): - data = {"state": "Отличное", "size": 15.0, "price": 10} - response = api_client.post(reverse('gardenbed-list'), data, format='json') - assert response.status_code == status.HTTP_201_CREATED - assert GardenBed.objects.count() == 1 - - @pytest.mark.django_db - def test_update_garden_bed(self, api_client, garden_bed): - data = {"size": 12.0} - response = api_client.patch(reverse('gardenbed-detail', args=[garden_bed.id]), data, format='json') - assert response.status_code == status.HTTP_200_OK - garden_bed.refresh_from_db() - assert garden_bed.size == 12.0 - - @pytest.mark.django_db - def test_delete_garden_bed(self, api_client, garden_bed): - response = api_client.delete(reverse('gardenbed-detail', args=[garden_bed.id])) - assert response.status_code == status.HTTP_204_NO_CONTENT - assert GardenBed.objects.count() == 0 - - @pytest.mark.django_db - def test_create_fertilizer(self, api_client): - data = {"compound": "Калий, Азот","price": 10,"boost" : 4,"name" : "Навоз"} - response = api_client.post(reverse('fertilizer-list'), data, format='json') - assert response.status_code == status.HTTP_201_CREATED - assert Fertilizer.objects.count() == 1 - - @pytest.mark.django_db - def test_update_fertilizer(self, api_client, fertilizer): - data = {"compound": "Новый состав"} - response = api_client.patch(reverse('fertilizer-detail', args=[fertilizer.id]), data, format='json') - assert response.status_code == status.HTTP_200_OK - fertilizer.refresh_from_db() - assert fertilizer.compound == "Новый состав" - - @pytest.mark.django_db - def test_delete_fertilizer(self, api_client, fertilizer): - response = api_client.delete(reverse('fertilizer-detail', args=[fertilizer.id])) - assert response.status_code == status.HTTP_204_NO_CONTENT - assert Fertilizer.objects.count() == 0 - - @pytest.mark.django_db - def test_create_user(self, api_client, agronomist, worker): - data = { - "password": "new_password", - "login": "new_user", - "role": "user", - "email": "new_user@mail.ru", - "phone": "+72345678908", - "agronomist_id": agronomist.id, - "worker_id": worker.id - } - response = api_client.post(reverse('user-list'), data, format='json') - assert response.status_code == status.HTTP_201_CREATED - assert User.objects.count() == 1 - - @pytest.mark.django_db - def test_update_user(self, api_client, user): - data = {"email": "updated_user@mail.ru"} - response = api_client.patch(reverse('user-detail', args=[user.id]), data, format='json') - assert response.status_code == status.HTTP_200_OK - user.refresh_from_db() - assert user.email == "updated_user@mail.ru" - - @pytest.mark.django_db - def test_delete_user(self, api_client, user): - response = api_client.delete(reverse('user-detail', args=[user.id])) - assert response.status_code == status.HTTP_204_NO_CONTENT - assert User.objects.count() == 0 - - @pytest.mark.django_db - def test_create_plant(self, api_client, garden_bed): - data = { - "name": "Огурец", - "growth_time": 24, - "description": "Великое", - "garden_id": garden_bed.id, - "price": 10, - "landing_data": "2024-09-03" - } - response = api_client.post(reverse('plant-list'), data, format='json') - assert response.status_code == status.HTTP_201_CREATED - assert Plant.objects.count() == 1 - - @pytest.mark.django_db - def test_update_plant(self, api_client, plant): - data = {"name": "Новый огурец"} - response = api_client.patch(reverse('plant-detail', args=[plant.id]), data, format='json') - assert response.status_code == status.HTTP_200_OK - plant.refresh_from_db() - assert plant.name == "Новый огурец" - - @pytest.mark.django_db - def test_delete_plant(self, api_client, plant): - response = api_client.delete(reverse('plant-detail', args=[plant.id])) - assert response.status_code == status.HTTP_204_NO_CONTENT - assert Plant.objects.count() == 0 - - @pytest.mark.django_db - def test_create_plot(self, api_client, garden_bed): - data = {"size": 30.0, "garden_id": garden_bed.id} - response = api_client.post(reverse('plot-list'), data, format='json') - assert response.status_code == status.HTTP_201_CREATED - assert Plot.objects.count() == 1 - - @pytest.mark.django_db - def test_update_plot(self, api_client, plot): - data = {"size": 25.0} - response = api_client.patch(reverse('plot-detail', args=[plot.id]), data, format='json') - assert response.status_code == status.HTTP_200_OK - plot.refresh_from_db() - assert plot.size == 25.0 - - @pytest.mark.django_db - def test_delete_plot(self, api_client, plot): - response = api_client.delete(reverse('plot-detail', args=[plot.id])) - assert response.status_code == status.HTTP_204_NO_CONTENT - assert Plot.objects.count() == 0 - - @pytest.mark.django_db - def test_create_order(self, api_client, garden_bed, plant, worker, fertilizer): - data = { - "deadline": "2024-10-19", - "garden_id": garden_bed.id, - "plant_id": plant.id, - "worker_id": worker.id, - "fertilizer_id": fertilizer.id - } - response = api_client.post(reverse('order-list'), data, format='json') - assert response.status_code == status.HTTP_201_CREATED - assert Order.objects.count() == 1 - - @pytest.mark.django_db - def test_update_order(self, api_client, order): - data = {"deadline": "2024-10-20"} - response = api_client.patch(reverse('order-detail', args=[order.id]), data, format='json') - assert response.status_code == status.HTTP_200_OK - order.refresh_from_db() - assert order.deadline == datetime.date(2024, 10, 20) - - @pytest.mark.django_db - def test_delete_order(self, api_client, order): - response = api_client.delete(reverse('order-detail', args=[order.id])) - assert response.status_code == status.HTTP_204_NO_CONTENT - assert Order.objects.count() == 0 - - @pytest.mark.django_db - def test_create_available_plant(self,api_client, garden_bed): - data = { - "name": "Огурец", - "price": 20.0, - "growth_time": 75, - "description": "Зеленый овощ", - "landing_data": "2024-05-15", - "garden_id": garden_bed.id - } - response = api_client.post(reverse('availableplants-list'), data, format='json') - assert response.status_code == status.HTTP_201_CREATED - assert AvailablePlants.objects.count() == 1 - - @pytest.mark.django_db - def test_update_available_plant(self,api_client, available_plants): - data = {"price": 18.0} - response = api_client.patch(reverse('availableplants-detail', args=[available_plants.id]), data, format='json') - assert response.status_code == status.HTTP_200_OK - available_plants.refresh_from_db() - assert available_plants.price == 18.0 - - @pytest.mark.django_db - def test_delete_available_plant(self,api_client, available_plants): - response = api_client.delete(reverse('availableplants-detail', args=[available_plants.id])) - assert response.status_code == status.HTTP_204_NO_CONTENT - assert AvailablePlants.objects.count() == 0 \ No newline at end of file diff --git a/django/tamprog/garden/urls.py b/django/tamprog/garden/urls.py index d671194a..d1783f89 100644 --- a/django/tamprog/garden/urls.py +++ b/django/tamprog/garden/urls.py @@ -1,109 +1,12 @@ -from django.urls import path, include -from rest_framework import routers -from .views import * -from django.conf import settings -from django.conf.urls.static import static -from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView - -schema_view = SpectacularAPIView.as_view() - -router_agronomist = routers.SimpleRouter() -router_agronomist.register(r'agronomist', AgronomistViewSet) - -router_supplier = routers.SimpleRouter() -router_supplier.register(r'supplier', SupplierViewSet) - -router_worker = routers.SimpleRouter() -router_worker.register(r'worker', WorkerViewSet) - -router_garden = routers.SimpleRouter() -router_garden.register(r'garden', GardenBedViewSet) - -router_fertilizer = routers.SimpleRouter() -router_fertilizer.register(r'fertilizer', FertilizerViewSet) - -router_user = routers.SimpleRouter() -router_user.register(r'user', UserViewSet) - -router_plant = routers.SimpleRouter() -router_plant.register(r'plant', PlantViewSet) - -router_plot = routers.SimpleRouter() -router_plot.register(r'plot', PlotViewSet) - -router_order = routers.SimpleRouter() -router_order.register(r'order', OrderViewSet) - -router_available_plants = routers.SimpleRouter() -router_available_plants.register(r'availableplants', AvailablePlantsViewSet) -urlpatterns = [ - path('', include(router_agronomist.urls)), - path('', include(router_supplier.urls)), - path('', include(router_worker.urls)), - path('', include(router_garden.urls)), - path('', include(router_fertilizer.urls)), - path('', include(router_user.urls)), - path('', include(router_plant.urls)), - path('', include(router_plot.urls)), - path('', include(router_order.urls)), - path('', include(router_available_plants.urls)), - path('api/schema/', schema_view, name='schema'), # URL для схемы - path('swagger/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), # Swagger UI - path('redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'), # ReDoc -] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) from django.urls import path, include -from rest_framework import routers - -from .views import * - -from django.conf import settings -from django.conf.urls.static import static -from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView - -schema_view = SpectacularAPIView.as_view() - -router_agronomist = routers.SimpleRouter() -router_agronomist.register(r'agronomist', AgronomistViewSet) - -router_supplier = routers.SimpleRouter() -router_supplier.register(r'supplier', SupplierViewSet) - -router_worker = routers.SimpleRouter() -router_worker.register(r'worker', WorkerViewSet) - -router_garden = routers.SimpleRouter() -router_garden.register(r'garden', GardenBedViewSet) - -router_fertilizer = routers.SimpleRouter() -router_fertilizer.register(r'fertilizer', FertilizerViewSet) - -router_user = routers.SimpleRouter() -router_user.register(r'user', UserViewSet) - -router_plant = routers.SimpleRouter() -router_plant.register(r'plant', PlantViewSet) - -router_plot = routers.SimpleRouter() -router_plot.register(r'plot', PlotViewSet) +from rest_framework.routers import DefaultRouter +from .views import FieldViewSet, BedViewSet -router_order = routers.SimpleRouter() -router_order.register(r'order', OrderViewSet) +router = DefaultRouter() +router.register(r'field', FieldViewSet) +router.register(r'bed', BedViewSet) -router_available_plants = routers.SimpleRouter() -router_available_plants.register(r'availableplants', AvailablePlantsViewSet) urlpatterns = [ - path('', include(router_agronomist.urls)), - path('', include(router_supplier.urls)), - path('', include(router_worker.urls)), - path('', include(router_garden.urls)), - path('', include(router_fertilizer.urls)), - path('', include(router_user.urls)), - path('', include(router_plant.urls)), - path('', include(router_plot.urls)), - path('', include(router_order.urls)), - path('', include(router_available_plants.urls)), - path('api/schema/', schema_view, name='schema'), # URL для схемы - path('swagger/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), # Swagger UI - path('redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'), # ReDoc -] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) \ No newline at end of file + path('', include(router.urls)), +] diff --git a/django/tamprog/garden/views.py b/django/tamprog/garden/views.py index c8f50b94..69f236f8 100644 --- a/django/tamprog/garden/views.py +++ b/django/tamprog/garden/views.py @@ -1,147 +1,322 @@ -from django.shortcuts import render -from rest_framework import viewsets +from rest_framework import viewsets, status +from rest_framework.permissions import IsAuthenticated, AllowAny +from rest_framework.decorators import action from rest_framework.response import Response -from rest_framework.views import APIView -from rest_framework import generics - - -from .models import * -from .serializers import * - -methods = ['get', 'post', 'head', - 'put', 'patch', 'delete', 'update', 'destroy'] - -class CORSMixin: - def finalize_response(self, request, response, *args, **kwargs): - response = super().finalize_response(request, response, *args, **kwargs) - response["Access-Control-Allow-Origin"] = "http://localhost:3000" - return response - - -class AgronomistViewSet(CORSMixin, viewsets.ModelViewSet): - queryset = Agronomist.objects.all() - serializer_class = AgronomistSerializer - - def update(self, request, *args, **kwargs): - partial = kwargs.pop('partial', False) - instance = self.get_object() - serializer = self.get_serializer(instance, data=request.data, partial=partial) - serializer.is_valid(raise_exception=True) - self.perform_update(serializer) - return Response() - - -class SupplierViewSet(CORSMixin, viewsets.ModelViewSet): - queryset = Supplier.objects.all() - serializer_class = SupplierSerializer - +from .permission import * +from .models import Field, Bed +from .serializers import FieldSerializer, BedSerializer +from .services import * + +from drf_spectacular.utils import extend_schema, extend_schema_view, \ + OpenApiResponse, OpenApiParameter, OpenApiExample + +def FieldParameters(required=False): + return [ + OpenApiParameter( + name="name", + description="Field name", + type=str, + required=required, + ), + OpenApiParameter( + name="count_beds", + description="Count of beds", + type=int, + required=required, + ), + OpenApiParameter( + name="price", + description="Field price", + type=float, + required=required, + ), + ] + +@extend_schema(tags=['Field']) +class FieldViewSet(viewsets.ModelViewSet): + queryset = Field.objects.all() + serializer_class = FieldSerializer + permission_classes = [AgronomistPermission] + + def perform_create(self, serializer): + count_beds = self.request.data.get('count_beds', 0) + serializer.save(count_beds=count_beds) + + @extend_schema( + summary='List all fields', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description='Successful response with list of fields', + response=FieldSerializer(many=True), + ), + }, + parameters=[ + OpenApiParameter( + name='sort', + type=str, + description='Sort by field', + required=False, + enum=['id', 'name', 'count_beds', 'price'], + ), + OpenApiParameter( + name='asc', + type=bool, + description='Ascending order', + required=False, + ), + ], + ) + def list(self, request, *args, **kwargs): + sort_by = request.query_params.get('sort', 'price') + ascending = request.query_params.get('asc', 'true').lower() == 'true' + fields = FieldService.get_sorted_fields(sort_by, ascending) + serializer = self.get_serializer(fields, many=True) + return Response(serializer.data) + + @extend_schema( + summary='Retrieve field', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description='Successful response with field', + response=FieldSerializer, + ), + }, + ) + def retrieve(self, request, *args, **kwargs): + return super().retrieve(request, *args, **kwargs) + + @extend_schema( + summary='Update field', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description='Successful response with updated field', + ), + }, + parameters=FieldParameters(required=True), + ) def update(self, request, *args, **kwargs): - partial = kwargs.pop('partial', False) - instance = self.get_object() - serializer = self.get_serializer(instance, data=request.data, partial=partial) - serializer.is_valid(raise_exception=True) - self.perform_update(serializer) - return Response() - - -class WorkerViewSet(CORSMixin, viewsets.ModelViewSet): - queryset = Worker.objects.all() - serializer_class = WorkerSerializer - - def update(self, request, *args, **kwargs): - partial = kwargs.pop('partial', False) - instance = self.get_object() - serializer = self.get_serializer(instance, data=request.data, partial=partial) - serializer.is_valid(raise_exception=True) - self.perform_update(serializer) - return Response() - - -class GardenBedViewSet(CORSMixin, viewsets.ModelViewSet): - queryset = GardenBed.objects.all() - serializer_class = GardenBedSerializer - - def update(self, request, *args, **kwargs): - partial = kwargs.pop('partial', False) - instance = self.get_object() - serializer = self.get_serializer(instance, data=request.data, partial=partial) - serializer.is_valid(raise_exception=True) - self.perform_update(serializer) - return Response() - - -class FertilizerViewSet(CORSMixin, viewsets.ModelViewSet): - queryset = Fertilizer.objects.all() - serializer_class = FertilizerSerializer - - def update(self, request, *args, **kwargs): - partial = kwargs.pop('partial', False) - instance = self.get_object() - serializer = self.get_serializer(instance, data=request.data, partial=partial) - serializer.is_valid(raise_exception=True) - self.perform_update(serializer) - return Response() - - -class UserViewSet(CORSMixin, viewsets.ModelViewSet): - queryset = User.objects.all() - serializer_class = UserSerializer - - def update(self, request, *args, **kwargs): - partial = kwargs.pop('partial', False) - instance = self.get_object() - serializer = self.get_serializer(instance, data=request.data, partial=partial) - serializer.is_valid(raise_exception=True) - self.perform_update(serializer) - return Response() - - -class PlantViewSet(CORSMixin, viewsets.ModelViewSet): - queryset = Plant.objects.all() - serializer_class = PlantSerializer - - def update(self, request, *args, **kwargs): - partial = kwargs.pop('partial', False) - instance = self.get_object() - serializer = self.get_serializer(instance, data=request.data, partial=partial) - serializer.is_valid(raise_exception=True) - self.perform_update(serializer) - return Response() - - -class PlotViewSet(CORSMixin, viewsets.ModelViewSet): - queryset = Plot.objects.all() - serializer_class = PlotSerializer - - def update(self, request, *args, **kwargs): - partial = kwargs.pop('partial', False) - instance = self.get_object() - serializer = self.get_serializer(instance, data=request.data, partial=partial) - serializer.is_valid(raise_exception=True) - self.perform_update(serializer) - return Response() - - -class OrderViewSet(CORSMixin, viewsets.ModelViewSet): - queryset = Order.objects.all() - serializer_class = OrderSerializer - - def update(self, request, *args, **kwargs): - partial = kwargs.pop('partial', False) - instance = self.get_object() - serializer = self.get_serializer(instance, data=request.data, partial=partial) - serializer.is_valid(raise_exception=True) - self.perform_update(serializer) - return Response() - -class AvailablePlantsViewSet(CORSMixin, viewsets.ModelViewSet): - queryset = AvailablePlants.objects.all() - serializer_class = AvailablePlantsSerializer - + return super().update(request, *args, **kwargs) + + @extend_schema( + summary='Partial update field', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description='Successful response with updated field', + ), + }, + parameters=FieldParameters(), + ) + def partial_update(self, request, *args, **kwargs): + return super().partial_update(request, *args, **kwargs) + + @extend_schema( + summary='Destroy field', + responses={ + status.HTTP_204_NO_CONTENT: OpenApiResponse( + description='Successful response', + ), + }, + ) + def destroy(self, request, *args, **kwargs): + return super().destroy(request, *args, **kwargs) + + @extend_schema( + summary='Create field', + responses={ + status.HTTP_201_CREATED: OpenApiResponse( + description='Successful response with created field', + ), + }, + parameters=FieldParameters(required=True), + ) + def create(self, request, *args, **kwargs): + return super().create(request, *args, **kwargs) + +def BedParameters(required=False): + return [ + OpenApiParameter( + name="is_rented", + description="Is bed rented", + type=bool, + required=required, + ), + OpenApiParameter( + name="field", + description="Field ID", + type=int, + required=required, + ), + OpenApiParameter( + name="rented_by", + description="User ID", + type=int, + required=required, + ), + ] + +@extend_schema(tags=['Bed']) +class BedViewSet(viewsets.ModelViewSet): + queryset = Bed.objects.all() + serializer_class = BedSerializer + permission_classes = [IsAuthenticated] + + @extend_schema( + summary='Create bed', + responses={ + status.HTTP_201_CREATED: OpenApiResponse( + description='Successful response with created bed', + ), + } + ) + def create(self, request, *args, **kwargs): + return super().create(request, *args, **kwargs) + + @extend_schema( + summary='List all beds', + description='List all beds', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description='Successful response with list of beds', + response=BedSerializer(many=True), + ) + }, + ) + def list(self, request, *args, **kwargs): + return super().list(request, *args, **kwargs) + + @extend_schema( + summary='Retrieve bed', + description='Retrieve bed by ID', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description='Successful response with bed', + response=BedSerializer, + ) + }, + ) + def retrieve(self, request, *args, **kwargs): + return super().retrieve(request, *args, **kwargs) + + @extend_schema( + summary='Update bed', + description='Update bed by ID', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description='Successful response with updated bed', + ) + }, + parameters=BedParameters(required=True), + ) def update(self, request, *args, **kwargs): - partial = kwargs.pop('partial', False) - instance = self.get_object() - serializer = self.get_serializer(instance, data=request.data, partial=partial) - serializer.is_valid(raise_exception=True) - self.perform_update(serializer) - return Response() + return super().update(request, *args, **kwargs) + + @extend_schema( + summary='Destroy bed', + description='Destroy bed by ID', + responses={ + status.HTTP_204_NO_CONTENT: OpenApiResponse( + description='Successful response', + ) + }, + ) + def destroy(self, request, *args, **kwargs): + return super().destroy(request, *args, **kwargs) + + @extend_schema( + summary='Partial update bed', + description='Partial update bed by ID', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description='Successful response with updated bed', + ) + }, + parameters=BedParameters(), + ) + def partial_update(self, request, *args, **kwargs): + return super().partial_update(request, *args, **kwargs) + + @extend_schema( + summary='List all beds for current user', + description='List all beds that are rented by the current user', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description='Successful response with list of beds', + response=BedSerializer(many=True), + ) + }, + ) + @action(detail=False, methods=['get']) + def my_beds(self, request): + beds = BedService.get_user_beds(request.user) + serializer = self.get_serializer(beds, many=True) + return Response(serializer.data) + + @extend_schema( + summary='Rent bed', + description='Rent bed', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description='Bed successfully rented.', + ), + status.HTTP_400_BAD_REQUEST: OpenApiResponse( + description='Bad request: Bed is already rented.', + ), + status.HTTP_404_NOT_FOUND: OpenApiResponse( + description='Bed not found.', + ), + }, + parameters=BedParameters(required=True), + examples=[ + OpenApiExample( + name='Rent bed for user', + value={ + "is_rented": True, + "field": 1, + "rented_by": 1 + } + ) + ], + ) + @action(detail=True, methods=['post']) + def rent(self, request, pk=None): + bed = self.get_object() + person = request.user + return BedService.rent_bed(bed.id, person) + + @extend_schema( + summary='Release bed', + description='Release bed', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description='Bed successfully released.', + ), + status.HTTP_400_BAD_REQUEST: OpenApiResponse( + description='Bad request: Bed is not rented.', + ), + status.HTTP_404_NOT_FOUND: OpenApiResponse( + description='Bed not found.', + ), + }, + parameters=BedParameters(required=True), + examples=[ + OpenApiExample( + name='Release bed for user', + value={ + "is_rented": False, + "field": 1, + "rented_by": 1 + } + ) + ], + ) + @action(detail=True, methods=['post']) + def release(self, request, pk=None): + bed = self.get_object() + return BedService.release_bed(bed.id) + + + def get_queryset(self): + is_rented = self.request.query_params.get('is_rented', None) + if is_rented is not None: + return BedService.filter_beds(is_rented=is_rented.lower() == 'true') + return Bed.objects.all() diff --git a/django/tamprog/orders/__init__.py b/django/tamprog/orders/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/django/tamprog/orders/admin.py b/django/tamprog/orders/admin.py new file mode 100644 index 00000000..6b1c02d7 --- /dev/null +++ b/django/tamprog/orders/admin.py @@ -0,0 +1,4 @@ +from django.contrib import admin +from .models import Order + +admin.site.register(Order) diff --git a/django/tamprog/orders/apps.py b/django/tamprog/orders/apps.py new file mode 100644 index 00000000..8ae0375c --- /dev/null +++ b/django/tamprog/orders/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class OrdersConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'orders' diff --git a/django/tamprog/orders/migrations/0001_initial.py b/django/tamprog/orders/migrations/0001_initial.py new file mode 100644 index 00000000..9dc9b5e9 --- /dev/null +++ b/django/tamprog/orders/migrations/0001_initial.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.16 on 2024-10-27 13:27 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('garden', '0001_initial'), + ('plants', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Order', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('action', models.CharField(max_length=100)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('completed_at', models.DateTimeField(blank=True, null=True)), + ('bed', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='garden.bed')), + ('plant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='plants.plant')), + ], + ), + ] diff --git a/django/tamprog/orders/migrations/0002_initial.py b/django/tamprog/orders/migrations/0002_initial.py new file mode 100644 index 00000000..b773e460 --- /dev/null +++ b/django/tamprog/orders/migrations/0002_initial.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.16 on 2024-10-27 13:27 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('orders', '0001_initial'), + ('user', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='order', + name='worker', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='user.worker'), + ), + ] diff --git a/django/tamprog/orders/migrations/0003_order_total_cost.py b/django/tamprog/orders/migrations/0003_order_total_cost.py new file mode 100644 index 00000000..6a410e48 --- /dev/null +++ b/django/tamprog/orders/migrations/0003_order_total_cost.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.16 on 2024-10-31 15:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('orders', '0002_initial'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='total_cost', + field=models.DecimalField(decimal_places=2, default=0, editable=False, max_digits=10), + preserve_default=False, + ), + ] diff --git a/django/tamprog/orders/migrations/0004_alter_order_total_cost.py b/django/tamprog/orders/migrations/0004_alter_order_total_cost.py new file mode 100644 index 00000000..197e0e31 --- /dev/null +++ b/django/tamprog/orders/migrations/0004_alter_order_total_cost.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.16 on 2024-11-01 10:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('orders', '0003_order_total_cost'), + ] + + operations = [ + migrations.AlterField( + model_name='order', + name='total_cost', + field=models.FloatField(default=0.0), + ), + ] diff --git a/django/tamprog/orders/migrations/0005_alter_order_completed_at.py b/django/tamprog/orders/migrations/0005_alter_order_completed_at.py new file mode 100644 index 00000000..fab1e468 --- /dev/null +++ b/django/tamprog/orders/migrations/0005_alter_order_completed_at.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.16 on 2024-11-01 11:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('orders', '0004_alter_order_total_cost'), + ] + + operations = [ + migrations.AlterField( + model_name='order', + name='completed_at', + field=models.DateTimeField(null=True), + ), + ] diff --git a/django/tamprog/orders/migrations/0006_alter_order_completed_at.py b/django/tamprog/orders/migrations/0006_alter_order_completed_at.py new file mode 100644 index 00000000..6036c6e8 --- /dev/null +++ b/django/tamprog/orders/migrations/0006_alter_order_completed_at.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.16 on 2024-11-01 16:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('orders', '0005_alter_order_completed_at'), + ] + + operations = [ + migrations.AlterField( + model_name='order', + name='completed_at', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/django/tamprog/orders/migrations/__init__.py b/django/tamprog/orders/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/django/tamprog/orders/models.py b/django/tamprog/orders/models.py new file mode 100644 index 00000000..3130f903 --- /dev/null +++ b/django/tamprog/orders/models.py @@ -0,0 +1,14 @@ +from django.db import models +from user.models import Person, Worker +from garden.models import Bed +from plants.models import Plant + +class Order(models.Model): + user = models.ForeignKey(Person, on_delete=models.CASCADE) + worker = models.ForeignKey(Worker, on_delete=models.CASCADE) + bed = models.ForeignKey(Bed, on_delete=models.CASCADE) + plant = models.ForeignKey(Plant, on_delete=models.CASCADE) + action = models.CharField(max_length=100) + created_at = models.DateTimeField(auto_now_add=True) + completed_at = models.DateTimeField(null=True, blank=True) + total_cost = models.FloatField(default=0.00) \ No newline at end of file diff --git a/django/tamprog/orders/serializer.py b/django/tamprog/orders/serializer.py new file mode 100644 index 00000000..adc1fc6d --- /dev/null +++ b/django/tamprog/orders/serializer.py @@ -0,0 +1,10 @@ +from rest_framework import serializers +from .models import Order + +class OrderSerializer(serializers.ModelSerializer): + class Meta: + model = Order + fields = ['id', 'worker','user', 'bed', 'plant', 'action', 'created_at', 'completed_at', 'total_cost'] + read_only_fields = ['total_cost', 'created_at', 'user', 'worker'] + + diff --git a/django/tamprog/orders/services.py b/django/tamprog/orders/services.py new file mode 100644 index 00000000..b391c114 --- /dev/null +++ b/django/tamprog/orders/services.py @@ -0,0 +1,58 @@ +import random +from rest_framework.response import Response +from rest_framework import status +from django.utils import timezone +from user.models import Worker +from user.services import PersonService +from .models import Order + + +class OrderService: + @staticmethod + def calculate_total_cost(bed, plant, worker): + field_price = bed.field.price + plant_price = plant.price + worker_price = worker.price + return field_price + plant_price + worker_price + + @staticmethod + def create_order(user, bed, plant, action): + available_workers = Worker.objects.all() + if not available_workers.exists(): + return Response( + {'error': 'No available workers'}, + status=status.HTTP_400_BAD_REQUEST + ) + worker = random.choice(available_workers) + total_cost = OrderService.calculate_total_cost(bed, plant, worker) + wallet_response = PersonService.update_wallet_balance(user, total_cost) + if wallet_response.status_code != status.HTTP_200_OK: + return wallet_response + + order = Order.objects.create( + user=user, + worker=worker, + bed=bed, + plant=plant, + action=action, + total_cost=total_cost + ) + return Response( + {'status': 'Order created successfully', 'order_id': order.id}, + status=status.HTTP_201_CREATED + ) + + @staticmethod + def complete_order(order): + order.completed_at = timezone.now() + order.save() + return order + + @staticmethod + def filter_orders(is_completed=None): + if is_completed is not None: + if is_completed: + return Order.objects.filter(completed_at__isnull=False) + else: + return Order.objects.filter(completed_at__isnull=True) + return Order.objects.all() diff --git a/django/tamprog/orders/tests.py b/django/tamprog/orders/tests.py new file mode 100644 index 00000000..c7baf147 --- /dev/null +++ b/django/tamprog/orders/tests.py @@ -0,0 +1,98 @@ +import pytest +from django.utils import timezone +from .services import OrderService +from orders.models import Order +from rest_framework import status +from rest_framework.response import Response + +@pytest.mark.django_db +def test_filter_orders(api_client, user, orders): + api_client.force_authenticate(user=user) + url = '/api/v1/order/' + response = api_client.get(url, {'is_completed': 'true'}) + assert response.status_code == 200 + response_data = response.data + assert all(order['completed_at'] is not None for order in response_data) + assert len(response_data) == sum(1 for order in orders if order.completed_at is not None) + response_incomplete = api_client.get(url, {'is_completed': 'false'}) + assert response_incomplete.status_code == 200 + response_data_incomplete = response_incomplete.data + assert all(order['completed_at'] is None for order in response_data_incomplete) + assert len(response_data_incomplete) == sum(1 for order in orders if order.completed_at is None) + + +@pytest.mark.django_db +def test_calculate_total_cost(beds, plants, workers): + for bed, plant, worker in zip(beds, plants, workers): + total_cost = OrderService.calculate_total_cost(bed, plant, worker) + assert total_cost == bed.field.price + plant.price + worker.price + + +@pytest.mark.django_db +def test_create_order_success(user, workers, beds, plants, mocker): + mocker.patch( + "user.services.PersonService.update_wallet_balance", + return_value=Response(status=status.HTTP_200_OK) + ) + action = "planting" + for worker, bed, plant in zip(workers, beds, plants): + response = OrderService.create_order(user, bed, plant, action) + assert response.status_code == status.HTTP_201_CREATED + order_id = response.data['order_id'] + order = Order.objects.get(id=order_id) + assert order.user == user + assert order.worker is not None + assert order.bed == bed + assert order.plant == plant + assert order.total_cost == bed.field.price + plant.price + order.worker.price + + +@pytest.mark.django_db +def test_create_order_insufficient_funds(user, workers, beds, plants, mocker): + mocker.patch( + "user.services.PersonService.update_wallet_balance", + return_value=Response(status=status.HTTP_400_BAD_REQUEST, data={"error": "Insufficient funds"}) + ) + action = "planting" + for worker, bed, plant in zip(workers, beds, plants): + response = OrderService.create_order(user, bed, plant, action) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data['error'] == "Insufficient funds" + + +@pytest.mark.django_db +def test_complete_order(orders): + for order in orders: + completed_order = OrderService.complete_order(order) + assert completed_order.completed_at is not None + assert completed_order.completed_at <= timezone.now() + + + +@pytest.mark.django_db +def test_filter_orders_completed(orders, mocker): + mock_time = timezone.now() + mocker.patch("django.utils.timezone.now", return_value=mock_time) + for order in orders: + OrderService.complete_order(order) + completed_orders = OrderService.filter_orders(is_completed=True) + assert all(order.completed_at is not None for order in completed_orders) + assert len(completed_orders) == sum(1 for order in orders if order.completed_at is not None) + + +@pytest.mark.django_db +def test_filter_orders_not_completed(orders): + not_completed_orders = OrderService.filter_orders(is_completed=False) + assert all(order.completed_at is None for order in not_completed_orders) + assert len(not_completed_orders) == sum(1 for order in orders if order.completed_at is None) + + +@pytest.mark.django_db +def test_filter_orders_all(orders): + all_orders = OrderService.filter_orders() + + # Проверяем, что все заказы из базы данных получены + assert len(all_orders) == len(orders) + for order in all_orders: + assert order in orders + diff --git a/django/tamprog/orders/urls.py b/django/tamprog/orders/urls.py new file mode 100644 index 00000000..edd6c736 --- /dev/null +++ b/django/tamprog/orders/urls.py @@ -0,0 +1,10 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import OrderViewSet + +router = DefaultRouter() +router.register(r'order', OrderViewSet) + +urlpatterns = [ + path('', include(router.urls)), +] \ No newline at end of file diff --git a/django/tamprog/orders/views.py b/django/tamprog/orders/views.py new file mode 100644 index 00000000..920ffe6c --- /dev/null +++ b/django/tamprog/orders/views.py @@ -0,0 +1,162 @@ +from rest_framework import viewsets, status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from .serializer import * +from .models import Order +from .services import OrderService + +from drf_spectacular.utils import extend_schema, extend_schema_view, \ + OpenApiResponse, OpenApiParameter, OpenApiExample + +def OrderParameters(required=False): + return [ + OpenApiParameter( + name="bed", + description="Bed ID", + type=int, + required=required, + ), + OpenApiParameter( + name="plant", + description="Plant ID", + type=int, + required=required, + ), + OpenApiParameter( + name="action", + description="Action to perform", + type=str, + required=required, + ), + OpenApiParameter( + name="completed_at", + description="Completion time", + type=str, + required=required, + ), + ] + +@extend_schema(tags=['Order']) +class OrderViewSet(viewsets.ModelViewSet): + queryset = Order.objects.all() + serializer_class = OrderSerializer + permission_classes = [IsAuthenticated] + + @extend_schema( + summary='Place order', + description='Place an order for a worker to perform an action on a bed with a plant', + request=OrderSerializer, + responses={ + status.HTTP_201_CREATED: OpenApiResponse( + description="Order created successfully", + ), + status.HTTP_400_BAD_REQUEST: OpenApiResponse( + description="Not enough money on the account", + ) + }, + parameters=OrderParameters(required=True), + ) + def create(self, request, *args, **kwargs): + return super().create(request, *args, **kwargs) + + @extend_schema( + summary='Get all orders', + request=OrderSerializer, + responses={ + status.HTTP_200_OK: OpenApiResponse( + description="Orders retrieved successfully", + response=OrderSerializer(many=True), + ), + }, + ) + def list(self, request, *args, **kwargs): + return super().list(request, *args, **kwargs) + + @extend_schema( + summary='Get order by id', + request=OrderSerializer, + responses={ + status.HTTP_200_OK: OpenApiResponse( + description="Order retrieved successfully", + response=OrderSerializer, + ), + }, + ) + def retrieve(self, request, *args, **kwargs): + return super().retrieve(request, *args, **kwargs) + + @extend_schema( + summary='Update order', + request=OrderSerializer, + responses={ + status.HTTP_200_OK: OpenApiResponse( + description="Order updated successfully", + ), + }, + parameters=OrderParameters(required=True), + ) + def update(self, request, *args, **kwargs): + return super().update(request, *args, **kwargs) + + @extend_schema( + summary='Partial update order', + request=OrderSerializer, + responses={ + status.HTTP_200_OK: OpenApiResponse( + description="Order updated successfully", + ), + }, + parameters=OrderParameters(), + examples=[ + OpenApiExample( + name="Update completion time", + value={ + "completed_at": "2022-01-01T00:00:00Z" + }, + request_only=True, + ), + OpenApiExample( + name="Full update order", + description="Update all fields. Prefferebly use PUT method for this operation", + value={ + "bed": 1, + "plant": 1, + "action": "water", + "completed_at": "2022-01-01T00:00:00Z" + }, + request_only=True, + ), + ] + ) + def partial_update(self, request, *args, **kwargs): + return super().partial_update(request, *args, **kwargs) + + @extend_schema( + summary='Delete order', + request=OrderSerializer, + responses={ + status.HTTP_204_NO_CONTENT: OpenApiResponse( + description="Order deleted successfully", + ), + }, + ) + def destroy(self, request, *args, **kwargs): + return super().destroy(request, *args, **kwargs) + + def perform_create(self, serializer): + user = self.request.user + bed = serializer.validated_data['bed'] + plant = serializer.validated_data['plant'] + action = serializer.validated_data['action'] + return OrderService.create_order(user, bed, plant, action) + + def perform_update(self, serializer): + order = serializer.save() + if order.completed_at: + OrderService.complete_order(order) + + def get_queryset(self): + is_completed = self.request.query_params.get('is_completed', None) + if is_completed is not None: + return OrderService.filter_orders(is_completed=is_completed.lower() == 'true') + return Order.objects.all() diff --git a/django/tamprog/plants/__init__.py b/django/tamprog/plants/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/django/tamprog/plants/admin.py b/django/tamprog/plants/admin.py new file mode 100644 index 00000000..64dbff95 --- /dev/null +++ b/django/tamprog/plants/admin.py @@ -0,0 +1,4 @@ +from django.contrib import admin +from .models import * + +admin.site.register(Plant) diff --git a/django/tamprog/plants/apps.py b/django/tamprog/plants/apps.py new file mode 100644 index 00000000..90a2903d --- /dev/null +++ b/django/tamprog/plants/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PlantsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'plants' diff --git a/django/tamprog/plants/migrations/0001_initial.py b/django/tamprog/plants/migrations/0001_initial.py new file mode 100644 index 00000000..49d33a92 --- /dev/null +++ b/django/tamprog/plants/migrations/0001_initial.py @@ -0,0 +1,36 @@ +# Generated by Django 4.2.16 on 2024-10-27 13:27 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('garden', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Plant', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('growth_time', models.IntegerField()), + ('price', models.DecimalField(decimal_places=2, max_digits=10)), + ('description', models.TextField()), + ], + ), + migrations.CreateModel( + name='BedPlant', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('planted_at', models.DateTimeField(auto_now_add=True)), + ('fertilizer_applied', models.BooleanField(default=False)), + ('bed', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='garden.bed')), + ('plant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='plants.plant')), + ], + ), + ] diff --git a/django/tamprog/plants/migrations/0002_bedplant_growth_time_alter_plant_growth_time.py b/django/tamprog/plants/migrations/0002_bedplant_growth_time_alter_plant_growth_time.py new file mode 100644 index 00000000..f2bc44c5 --- /dev/null +++ b/django/tamprog/plants/migrations/0002_bedplant_growth_time_alter_plant_growth_time.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.16 on 2024-10-30 11:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('plants', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='bedplant', + name='growth_time', + field=models.IntegerField(default=0), + ), + migrations.AlterField( + model_name='plant', + name='growth_time', + field=models.IntegerField(default=0), + ), + ] diff --git a/django/tamprog/plants/migrations/0003_alter_bedplant_growth_time.py b/django/tamprog/plants/migrations/0003_alter_bedplant_growth_time.py new file mode 100644 index 00000000..967b39cd --- /dev/null +++ b/django/tamprog/plants/migrations/0003_alter_bedplant_growth_time.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.16 on 2024-10-31 12:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('plants', '0002_bedplant_growth_time_alter_plant_growth_time'), + ] + + operations = [ + migrations.AlterField( + model_name='bedplant', + name='growth_time', + field=models.IntegerField(), + ), + ] diff --git a/django/tamprog/plants/migrations/0004_remove_bedplant_growth_time.py b/django/tamprog/plants/migrations/0004_remove_bedplant_growth_time.py new file mode 100644 index 00000000..c9127184 --- /dev/null +++ b/django/tamprog/plants/migrations/0004_remove_bedplant_growth_time.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.16 on 2024-10-31 13:10 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('plants', '0003_alter_bedplant_growth_time'), + ] + + operations = [ + migrations.RemoveField( + model_name='bedplant', + name='growth_time', + ), + ] diff --git a/django/tamprog/plants/migrations/0005_bedplant_growth_time.py b/django/tamprog/plants/migrations/0005_bedplant_growth_time.py new file mode 100644 index 00000000..f3f9094a --- /dev/null +++ b/django/tamprog/plants/migrations/0005_bedplant_growth_time.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.16 on 2024-10-31 13:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('plants', '0004_remove_bedplant_growth_time'), + ] + + operations = [ + migrations.AddField( + model_name='bedplant', + name='growth_time', + field=models.IntegerField(default=1089), + preserve_default=False, + ), + ] diff --git a/django/tamprog/plants/migrations/0006_alter_bedplant_growth_time.py b/django/tamprog/plants/migrations/0006_alter_bedplant_growth_time.py new file mode 100644 index 00000000..9c75c70a --- /dev/null +++ b/django/tamprog/plants/migrations/0006_alter_bedplant_growth_time.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.16 on 2024-10-31 13:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('plants', '0005_bedplant_growth_time'), + ] + + operations = [ + migrations.AlterField( + model_name='bedplant', + name='growth_time', + field=models.IntegerField(default=1), + ), + ] diff --git a/django/tamprog/plants/migrations/0007_alter_plant_price.py b/django/tamprog/plants/migrations/0007_alter_plant_price.py new file mode 100644 index 00000000..5e691038 --- /dev/null +++ b/django/tamprog/plants/migrations/0007_alter_plant_price.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.16 on 2024-10-31 15:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('plants', '0006_alter_bedplant_growth_time'), + ] + + operations = [ + migrations.AlterField( + model_name='plant', + name='price', + field=models.FloatField(default=0.0), + ), + ] diff --git a/django/tamprog/plants/migrations/__init__.py b/django/tamprog/plants/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/django/tamprog/plants/models.py b/django/tamprog/plants/models.py new file mode 100644 index 00000000..c0130461 --- /dev/null +++ b/django/tamprog/plants/models.py @@ -0,0 +1,15 @@ +from django.db import models +from garden.models import Bed + +class Plant(models.Model): + name = models.CharField(max_length=100) + growth_time = models.IntegerField(default=0) + price = models.FloatField(default=0.00) + description = models.TextField() + +class BedPlant(models.Model): + bed = models.ForeignKey(Bed, on_delete=models.CASCADE) + plant = models.ForeignKey(Plant, on_delete=models.CASCADE) + planted_at = models.DateTimeField(auto_now_add=True) + fertilizer_applied = models.BooleanField(default=False) + growth_time = models.IntegerField(default=1) \ No newline at end of file diff --git a/django/tamprog/plants/permissions.py b/django/tamprog/plants/permissions.py new file mode 100644 index 00000000..1f6ac65a --- /dev/null +++ b/django/tamprog/plants/permissions.py @@ -0,0 +1,28 @@ +from rest_framework.permissions import BasePermission +import re + +class AgronomistPermission(BasePermission): + def has_permission(self, request, view): + if request.user and request.user.is_authenticated: + username = request.user.username + if re.match(r'^agronom\d+$', username): + return True + if request.user.is_superuser: + return True + return request.method in ['GET', 'HEAD', 'OPTIONS'] + return False + + +class AgronomistOrRenterPermission(BasePermission): + def has_permission(self, request, view): + if not request.user or not request.user.is_authenticated: + return False + if re.match(r'^agronom\d+$', request.user.username): + return True + if request.user.is_superuser: + return True + return request.method in ['GET', 'POST'] + def has_object_permission(self, request, view, obj): + if re.match(r'^agronom\d+$', request.user.username): + return True + return obj.bed.rented_by == request.user diff --git a/django/tamprog/plants/queries.py b/django/tamprog/plants/queries.py new file mode 100644 index 00000000..fc4d5310 --- /dev/null +++ b/django/tamprog/plants/queries.py @@ -0,0 +1,8 @@ +from .models import Plant + +class GetPlantsSortedByPrice: + def __init__(self, ascending: bool = True): + self.ascending = ascending + + def execute(self): + return Plant.objects.order_by('price' if self.ascending else '-price') \ No newline at end of file diff --git a/django/tamprog/plants/serializers.py b/django/tamprog/plants/serializers.py new file mode 100644 index 00000000..b99da4ea --- /dev/null +++ b/django/tamprog/plants/serializers.py @@ -0,0 +1,12 @@ +from rest_framework import serializers +from .models import Plant, BedPlant + +class PlantSerializer(serializers.ModelSerializer): + class Meta: + model = Plant + fields = '__all__' + +class BedPlantSerializer(serializers.ModelSerializer): + class Meta: + model = BedPlant + fields = '__all__' diff --git a/django/tamprog/plants/services.py b/django/tamprog/plants/services.py new file mode 100644 index 00000000..8b3504ce --- /dev/null +++ b/django/tamprog/plants/services.py @@ -0,0 +1,96 @@ +from .models import BedPlant +from fertilizer.models import BedPlantFertilizer +from .queries import GetPlantsSortedByPrice +from fuzzywuzzy import fuzz +from rest_framework.response import Response +from rest_framework import status +from .models import Plant + +class PlantService: + + @staticmethod + def get_sorted_plants(ascending: bool = True): + query = GetPlantsSortedByPrice(ascending) + return query.execute() + + @staticmethod + def fuzzy_search(query, threshold=70): + results = [] + plants = Plant.objects.all() + for plant in plants: + similarity = fuzz.ratio(query.lower(), plant.name.lower()) + if similarity >= threshold: + results.append(plant) + return results + + @staticmethod + def get_suggestions(query): + return Plant.objects.filter(name__istartswith=query).values_list('name', flat=True).order_by('name')[:10] + + +class BedPlantService: + + @staticmethod + def plant_in_bed(bed, plant): + growth_time = plant.growth_time + bed_plant = BedPlant.objects.create(bed=bed, plant=plant, growth_time=growth_time) + return bed_plant + + @staticmethod + def harvest_plant(bed_plant): + if not bed_plant: + return Response( + {'error': 'Plant not found'}, + status=status.HTTP_400_BAD_REQUEST + ) + + bed_plant.delete() + return Response( + {'status': 'plant dug up'}, + status=status.HTTP_200_OK + ) + + + @staticmethod + def fertilize_plant(bed_plant, fertilizer): + if not fertilizer: + return Response( + {'error': 'No suitable fertilizer found'}, + status=status.HTTP_400_BAD_REQUEST + ) + new_growth_time = bed_plant.growth_time - fertilizer.boost + bed_plant.growth_time = new_growth_time + BedPlantFertilizer.objects.create(bed_plant=bed_plant, fertilizer=fertilizer) + bed_plant.fertilizer_applied = True + bed_plant.save(update_fields=["fertilizer_applied", "growth_time"]) + bed_plant.save() + return Response( + {'status': 'plant fertilized'}, + status=status.HTTP_200_OK + ) + + + @staticmethod + def water_plant(bed_plant): + pass + + @staticmethod + def dig_up_plant(bed_plant): + if not bed_plant: + return Response( + {'error': 'Plant not found'}, + status=status.HTTP_400_BAD_REQUEST + ) + + bed_plant.delete() + return Response( + {'status': 'plant dug up'}, + status=status.HTTP_200_OK + ) + + + @staticmethod + def filter_bed_plants(fertilizer_applied=None): + if fertilizer_applied is not None: + return BedPlant.objects.filter(fertilizer_applied=fertilizer_applied) + return BedPlant.objects.all() diff --git a/django/tamprog/plants/tests.py b/django/tamprog/plants/tests.py new file mode 100644 index 00000000..bc5e7dfa --- /dev/null +++ b/django/tamprog/plants/tests.py @@ -0,0 +1,114 @@ +import pytest +from plants.services import PlantService, BedPlantService +from fertilizer.models import BedPlantFertilizer +from plants.models import BedPlant +from django.urls import reverse +from fuzzywuzzy import fuzz + +@pytest.mark.django_db +def test_sort_bed_plants(api_client, user, plants): + api_client.force_authenticate(user=user) + url = '/api/v1/plant/?asc=true' + response = api_client.get(url) + assert response.status_code == 200 + response_plant_prices = [bp['price'] for bp in response.data] + sorted_plants = sorted(plants, key=lambda bp: bp.price) + sorted_plant_prices = [bp.price for bp in sorted_plants] + assert response_plant_prices == sorted_plant_prices + + +@pytest.mark.django_db +def test_filter_bed_plants(api_client, user, bed_plants): + api_client.force_authenticate(user=user) + + url = '/api/v1/bedplant/?fertilizer_applied=true' + response = api_client.get(url) + assert response.status_code == 200 + assert all(bp['fertilizer_applied'] for bp in response.data) + + url = '/api/v1/bedplant/?fertilizer_applied=false' + response = api_client.get(url) + assert response.status_code == 200 + assert all(not bp['fertilizer_applied'] for bp in response.data) + + +@pytest.mark.django_db +def test_plant_in_bed(beds, plants): + for bed, plant in zip(beds, plants): # Используем zip для объединения элементов + bed_plant = BedPlantService.plant_in_bed(bed, plant) + assert bed_plant.bed == bed + assert bed_plant.plant == plant + assert bed_plant.growth_time == plant.growth_time + + + + +@pytest.mark.django_db +def test_harvest_plant(bed_plants): + for bed_plant in bed_plants: + BedPlantService.harvest_plant(bed_plant) + assert not BedPlant.objects.filter(id=bed_plant.id).exists() + + + +@pytest.mark.django_db +def test_fertilize_plant_multiple(bed_plants, fertilizers): + for bed_plant in bed_plants: + initial_growth_time = bed_plant.growth_time + # Применяем удобрение + BedPlantService.fertilize_plant(bed_plant, fertilizers[0]) + bed_plant.refresh_from_db() + + # Проверяем правильность обновления данных + assert bed_plant.growth_time == initial_growth_time - fertilizers[0].boost + assert bed_plant.fertilizer_applied is True + + # Проверка на наличие записи в модели BedPlantFertilizer + assert BedPlantFertilizer.objects.filter(bed_plant=bed_plant, fertilizer=fertilizers[0]).exists() + +@pytest.mark.django_db +def test_filter_bed_plants(bed_plants, fertilizers): + # Удобрим все растения + for bed_plant in bed_plants: + BedPlantService.fertilize_plant(bed_plant, fertilizers[0]) + + # Проверка на фильтрацию удобренных растений + fertilized_plants = BedPlantService.filter_bed_plants(fertilizer_applied=True) + non_fertilized_plants = BedPlantService.filter_bed_plants(fertilizer_applied=False) + + for bed_plant in bed_plants: + if bed_plant.fertilizer_applied: + assert bed_plant in fertilized_plants + assert bed_plant not in non_fertilized_plants + else: + assert bed_plant not in fertilized_plants + assert bed_plant in non_fertilized_plants + + +@pytest.mark.django_db +def test_fuzzy_search(api_client, plants, user): + api_client.force_authenticate(user=user) + url = '/api/v1/plant/search/' + plant_name = plants[0].name[:3] + response = api_client.get(url, {'q': plant_name}) + assert response.status_code == 200 + expected_matches = [ + plant.name for plant in plants + if fuzz.ratio(plant_name.lower(), plant.name.lower()) >= 70 + ] + response_names = [plant['name'] for plant in response.data] + assert len(response_names) == len(expected_matches) + assert all(name in expected_matches for name in response_names) + + +@pytest.mark.django_db +def test_get_suggestions(api_client, plants, user): + api_client.force_authenticate(user=user) + url = '/api/v1/plant/suggestions/' + suggestion_query = plants[0].name[:1] + response = api_client.get(url, {'q': suggestion_query}) + assert response.status_code == 200 + assert len(response.data) > 0 + assert all(suggestion_query in suggestion for suggestion in response.data) + + diff --git a/django/tamprog/plants/urls.py b/django/tamprog/plants/urls.py new file mode 100644 index 00000000..6dfba606 --- /dev/null +++ b/django/tamprog/plants/urls.py @@ -0,0 +1,11 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import PlantViewSet, BedPlantViewSet + +router = DefaultRouter() +router.register(r'plant', PlantViewSet) +router.register(r'bedplant', BedPlantViewSet) + +urlpatterns = [ + path('', include(router.urls)), +] diff --git a/django/tamprog/plants/views.py b/django/tamprog/plants/views.py new file mode 100644 index 00000000..7b17cf48 --- /dev/null +++ b/django/tamprog/plants/views.py @@ -0,0 +1,355 @@ +from rest_framework import viewsets, status +from rest_framework.permissions import IsAuthenticated +from rest_framework.decorators import action +from rest_framework.response import Response +from .permissions import * +from .models import Plant, BedPlant +from .serializers import PlantSerializer, BedPlantSerializer +from .services import * +from fertilizer.models import Fertilizer + +from drf_spectacular.utils import extend_schema, extend_schema_view, \ + OpenApiResponse, OpenApiParameter, OpenApiExample + +def PlantParameters(required=False): + return [ + OpenApiParameter( + name='name', + type=str, + description='Plant name', + required=required, + ), + OpenApiParameter( + name='growth_time', + type=int, + description='Growth time of plant', + required=required, + ), + OpenApiParameter( + name='price', + type=float, + description='Plant price', + required=required, + ), + OpenApiParameter( + name='description', + type=str, + description='Plant description', + required=required, + ), + ] + +@extend_schema(tags=['Plant']) +class PlantViewSet(viewsets.ModelViewSet): + queryset = Plant.objects.all() + serializer_class = PlantSerializer + permission_classes = [AgronomistPermission] + + @extend_schema( + summary='List all available plants', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description='Successful response', + response=PlantSerializer(many=True) + ) + }, + ) + def list(self, request, *args, **kwargs): + ascending = request.query_params.get('asc', 'true').lower() == 'true' + plants = PlantService.get_sorted_plants(ascending) + serializer = self.get_serializer(plants, many=True) + return Response(serializer.data) + + @action(detail=False, methods=['get']) + def search(self, request): + query = request.query_params.get('q', '').lower() + if not query: + return Response([]) + + plants = PlantService.fuzzy_search(query) + serializer = self.get_serializer(plants, many=True) + return Response(serializer.data) + + @action(detail=False, methods=['get']) + def suggestions(self, request): + query = request.query_params.get('q', '').lower() + if not query: + return Response([]) + + suggestions = PlantService.get_suggestions(query) + return Response(suggestions) + + @extend_schema( + summary='Create a new plant', + responses={ + status.HTTP_201_CREATED: OpenApiResponse( + description='Plant created successfully', + ), + status.HTTP_400_BAD_REQUEST: OpenApiResponse( + description='Bad request', + ), + }, + parameters=PlantParameters(required=True), + ) + def create(self, request, *args, **kwargs): + return super().create(request, *args, **kwargs) + + @extend_schema( + summary='Get a plant by ID', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description='Successful response', + response=PlantSerializer + ) + }, + ) + def retrieve(self, request, *args, **kwargs): + return super().retrieve(request, *args, **kwargs) + + @extend_schema( + summary='Partially update a plant', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description='Successful update', + ) + }, + parameters=PlantParameters(), + ) + def partial_update(self, request, *args, **kwargs): + return super().partial_update(request, *args, **kwargs) + + @extend_schema( + summary='Update a plant', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description='Successful update', + ) + }, + parameters=PlantParameters(required=True), + ) + def update(self, request, *args, **kwargs): + return super().update(request, *args, **kwargs) + + @extend_schema( + summary='Delete a plant', + responses={ + status.HTTP_204_NO_CONTENT: OpenApiResponse( + description='Plant deleted successfully', + ) + }, + ) + def destroy(self, request, *args, **kwargs): + return super().destroy(request, *args, **kwargs) + +def BedPlantParameters(required=False): + return [ + OpenApiParameter( + name='ferilizer_applied', + type=bool, + description='Is created bed plant fertilized', + required=required, + ), + OpenApiParameter( + name='growth_time', + type=int, + description='Growth time of plant', + required=required, + ), + OpenApiParameter( + name='bed', + type=int, + description='Bed ID', + required=required, + ), + OpenApiParameter( + name='plant', + type=int, + description='Plant ID', + required=required, + ), + ] + +@extend_schema(tags=['Plant']) +class BedPlantViewSet(viewsets.ModelViewSet): + queryset = BedPlant.objects.all() + serializer_class = BedPlantSerializer + permission_classes = [AgronomistOrRenterPermission, IsAuthenticated] + + @extend_schema(exclude=True) + def destroy(self, request, *args, **kwargs): + return super().destroy(request, *args, **kwargs) + + @extend_schema( + summary='Plant a new plant in a bed', + responses={ + status.HTTP_201_CREATED: OpenApiResponse( + description='Plant planted successfully', + ), + status.HTTP_400_BAD_REQUEST: OpenApiResponse( + description='Bad request', + ), + }, + parameters=BedPlantParameters(required=True), + ) + def create(self, request, *args, **kwargs): + return super().create(request, *args, **kwargs) + + @extend_schema( + summary='Get all planted plants', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description='Successful response', + response=BedPlantSerializer(many=True) + ) + }, + ) + def list(self, request, *args, **kwargs): + return super().list(request, *args, **kwargs) + + @extend_schema( + summary='Get a planted plant by ID', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description='Successful response', + response=BedPlantSerializer + ) + }, + ) + def retrieve(self, request, *args, **kwargs): + return super().retrieve(request, *args, **kwargs) + + @extend_schema( + summary='Update a planted plant', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description='Successful update', + ) + }, + parameters=BedPlantParameters(required=True), + ) + def update(self, request, *args, **kwargs): + return super().update(request, *args, **kwargs) + + @extend_schema( + summary='Partially update a planted plant', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description='Successful update', + ) + }, + parameters=BedPlantParameters(), + ) + def partial_update(self, request, *args, **kwargs): + return super().partial_update(request, *args, **kwargs) + + def perform_create(self, serializer): + bed = serializer.validated_data['bed'] + plant = serializer.validated_data['plant'] + BedPlantService.plant_in_bed(bed, plant) + + @extend_schema( + summary='Harvest a plant', + description='Harvest a plant from a bed', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description='Plant harvested', + ), + status.HTTP_400_BAD_REQUEST: OpenApiResponse( + description='Plant not found', + ), + }, + ) + @action(detail=True, methods=['post']) + def harvest(self, request, pk=None): + bed_plant = self.get_object() + BedPlantService.harvest_plant(bed_plant) + return Response({'status': 'plant harvested'}) + + + @extend_schema( + summary='Fertilize a plant', + description='Fertilize a plant in a bed', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description='Plant fertilized', + examples=[ + OpenApiExample( + name='Fertilized plant', + value={'status': 'plant fertilized'}, + ) + ] + ), + status.HTTP_400_BAD_REQUEST: OpenApiResponse( + description='No suitable fertilizer found', + examples=[ + OpenApiExample( + name='No suitable fertilizer', + value={'error': 'No suitable fertilizer found'}, + ) + ] + ), + }, + ) + @action(detail=True, methods=['post']) + def fertilize(self, request, pk=None): + bed_plant = self.get_object() + plant_name = bed_plant.plant.name + fertilizer = Fertilizer.objects.filter(compound__icontains=plant_name).first() + return BedPlantService.fertilize_plant(bed_plant, fertilizer) + + @extend_schema( + summary='Water a plant', + description='Water a plant in a bed', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description='Plant watered', + examples=[ + OpenApiExample( + name='Watered plant', + value={'status': 'plant watered'}, + ) + ] + ) + }, + ) + @action(detail=True, methods=['post']) + def water(self, request, pk=None): + bed_plant = self.get_object() + BedPlantService.water_plant(bed_plant) + return Response({'status': 'plant watered'}) + + @extend_schema( + summary='Dig up a plant', + description='Dig up a plant from a bed', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description='Plant dug up', + examples=[ + OpenApiExample( + name='Dug up plant', + value={'status': 'plant dug up'}, + ) + ] + ), + status.HTTP_400_BAD_REQUEST: OpenApiResponse( + description='Plant not found', + examples=[ + OpenApiExample( + name='Plant not found', + value={'error': 'Plant not found'} + ) + ] + ), + }, + ) + @action(detail=True, methods=['post']) + def dig_up(self, request, pk=None): + bed_plant = self.get_object() + return BedPlantService.dig_up_plant(bed_plant) + + + def get_queryset(self): + fertilizer_applied = self.request.query_params.get('fertilizer_applied', None) + if fertilizer_applied is not None: + return BedPlantService.filter_bed_plants(fertilizer_applied=fertilizer_applied.lower() == 'true') + return BedPlant.objects.all() \ No newline at end of file diff --git a/django/tamprog/pytest.ini b/django/tamprog/pytest.ini index 38025ec4..dc8eb2a3 100644 --- a/django/tamprog/pytest.ini +++ b/django/tamprog/pytest.ini @@ -1,3 +1,5 @@ [pytest] DJANGO_SETTINGS_MODULE = tamprog.settings python_files = tests.py test_*.py *_tests.py +usefixtures = django_db_setup +addopts = --tb=short --strict-markers \ No newline at end of file diff --git a/django/tamprog/tamprog/__init__.py b/django/tamprog/tamprog/__init__.py index e69de29b..742da6a9 100644 --- a/django/tamprog/tamprog/__init__.py +++ b/django/tamprog/tamprog/__init__.py @@ -0,0 +1,3 @@ +from .celery import app as celery_app + +__all__ = ['celery_app'] \ No newline at end of file diff --git a/django/tamprog/tamprog/asgi.py b/django/tamprog/tamprog/asgi.py deleted file mode 100644 index 5ebc46ba..00000000 --- a/django/tamprog/tamprog/asgi.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -ASGI config for tamprog project. - -It exposes the ASGI callable as a module-level variable named ``application``. - -For more information on this file, see -https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/ -""" - -import os - -from django.core.asgi import get_asgi_application - -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tamprog.settings') - -application = get_asgi_application() diff --git a/django/tamprog/tamprog/celery.py b/django/tamprog/tamprog/celery.py new file mode 100644 index 00000000..b71a2ab2 --- /dev/null +++ b/django/tamprog/tamprog/celery.py @@ -0,0 +1,8 @@ +import os +from celery import Celery + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tamprog.settings') + +app = Celery('tamprog') +app.config_from_object('django.conf:settings', namespace='CELERY') +app.autodiscover_tasks() \ No newline at end of file diff --git a/django/tamprog/tamprog/settings.py b/django/tamprog/tamprog/settings.py index 9f67c875..8412a494 100644 --- a/django/tamprog/tamprog/settings.py +++ b/django/tamprog/tamprog/settings.py @@ -11,6 +11,7 @@ """ import os +from datetime import timedelta from pathlib import Path from dotenv import load_dotenv import re @@ -21,6 +22,8 @@ # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent +IS_IN_CONTAINER = bool(os.getenv('IS_IN_CONTAINER', 'false').lower() == 'true') + # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ @@ -32,38 +35,9 @@ ALLOWED_HOSTS = (os.getenv('DJANGO_ALLOWED_HOSTS', '127.0.0.1,localhost').split(',')) -CORS_ALLOW_ALL_ORIGINS = False -CORS_ALLOW_CREDENTIALS = True -CORS_ALLOWED_ORIGINS = [ - "http://localhost:3000", - "http://localhost", -] - -CORS_ALLOW_HEADERS = [ - 'accept', - 'accept-encoding', - 'authorization', - 'content-type', - 'dnt', - 'origin', - 'user-agent', - 'x-csrftoken', - 'x-requested-with', -] - -CORS_ALLOW_METHODS = [ - 'GET', - 'POST', - 'PUT', - 'DELETE', - 'OPTIONS', - 'PATCH', - 'UPDATE', - 'DESTROY' -] # Application definition - +AUTH_USER_MODEL = 'user.Person' INSTALLED_APPS = [ 'corsheaders', 'django.contrib.admin', @@ -73,8 +47,14 @@ 'django.contrib.messages', 'django.contrib.staticfiles', 'rest_framework', + 'rest_framework_simplejwt', + 'user', 'garden', + 'orders', + 'plants', + 'fertilizer', 'drf_spectacular', + 'rest_framework_simplejwt.token_blacklist', ] MIDDLEWARE = [ @@ -115,7 +95,9 @@ 'default': { 'ENGINE': 'django.db.backends.postgresql_psycopg2', 'NAME': os.getenv('POSTGRES_DB', 'garden'), - 'HOST': os.getenv('DJANGO_DB_HOST', '127.0.0.1'), + 'HOST': os.getenv('DJANGO_DB_HOST', '127.0.0.1') \ + if IS_IN_CONTAINER \ + else '127.0.0.1', 'PORT': os.getenv('POSTGRES_PORT', '5432'), 'PASSWORD': os.getenv('POSTGRES_PASSWORD', 'root'), 'USER': os.getenv('POSTGRES_USER', 'agronom'), @@ -173,8 +155,159 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +CORS_ALLOW_ALL_ORIGINS = False +CORS_ALLOW_CREDENTIALS = True +CORS_ALLOWED_ORIGINS = [ + "http://localhost:3000", + "http://localhost:8000", + "http://localhost", + "http://homelab.kerasi.ru", +] + +CORS_ALLOW_HEADERS = [ + 'accept', + 'accept-encoding', + 'authorization', + 'content-type', + 'dnt', + 'origin', + 'user-agent', + 'x-csrftoken', + 'x-requested-with', +] + +CORS_ALLOW_METHODS = [ + 'GET', + 'POST', + 'PUT', + 'DELETE', + 'OPTIONS', + 'PATCH', + 'UPDATE', + 'DESTROY' +] + +# Content Security Policy (CSP) +CSP_SCRIPT_SRC = ( + "'self'", + "'unsafe-eval'", + "https://mc.yandex.ru", +) +CSP_FRAME_SRC = ( + "'self'", + "https://example.com", +) + +CSP_CONNECT_SRC = ( + "'self'", + 'https://example.com', + "https://mc.yandex.ru", + "http://localhost:3000", + "http://localhost:8000", + "http://homelab.kerasi.ru", +) + +CSRF_TRUSTED_ORIGINS = [ + 'https://example.com', + "http://localhost:3000", + "http://localhost:8000", + "http://homelab.kerasi.ru", +] + +SPECTACULAR_SETTINGS = { + 'TITLE': 'Tamprog API', + 'DESCRIPTION': 'API documentation for Tamprog', + 'VERSION': '1.0.0', + "SERVE_INCLUDE_SCHEMA": True, # исключить эндпоинт /schema + "SWAGGER_UI_SETTINGS": { + "filter": True, # включить поиск по тегам + }, + "COMPONENT_SPLIT_REQUEST": True, +} + + REST_FRAMEWORK = { 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', + + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework_simplejwt.authentication.JWTAuthentication', + 'rest_framework.authentication.SessionAuthentication', + ), + 'DEFAULT_PERMISSION_CLASSES': ( + 'rest_framework.permissions.IsAuthenticated', + ), #'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination', #'PAGE_SIZE': 30 } + +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=150), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=7), + 'ROTATE_REFRESH_TOKENS': True, + 'BLACKLIST_AFTER_ROTATION': True, + 'UPDATE_LAST_LOGIN': True, +} + +DJANGO_ASYNC_TIMEOUT_S = float(os.getenv('DJANGO_ASYNC_TIMEOUT_S', '30')) + +RABBITMQ_USER = os.getenv('RABBITMQ_DEFAULT_USER', 'guest') +RABBITMQ_PASSWORD = os.getenv('RABBITMQ_DEFAULT_PASS', 'guest') +RABBITMQ_HOST = os.getenv('RABBITMQ_HOST', 'localhost') \ + if IS_IN_CONTAINER \ + else 'localhost' +RABBITMQ_PORT = os.getenv('RABBITMQ_PORT', '5672') +RABBITMQ_VHOST = os.getenv('RABBITMQ_VHOST', '/') + +CELERY_BROKER_URL = f'amqp://{RABBITMQ_USER}:{RABBITMQ_PASSWORD}@{RABBITMQ_HOST}:{RABBITMQ_PORT}/{RABBITMQ_VHOST}' + +CELERY_BROKER_CONNECTION_RETRY = bool(os.getenv( + 'CELERY_BROKER_CONNECTION_RETRY', 'True').lower() == 'true') +CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = bool(os.getenv( + 'CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP', 'True').lower() == 'true') +CELERY_BROKER_CONNECTION_MAX_RETRIES = int(os.getenv( + 'CELERY_BROKER_CONNECTION_MAX_RETRIES', '10')) +CELERY_BROKER_HEARTBEAT = int(os.getenv( + 'CELERY_BROKER_HEARTBEAT', '60')) +CELERY_WORKER_PREFETCH_MULTIPLIER = int(os.getenv( + 'CELERY_WORKER_PREFETCH_MULTIPLIER', '1') \ + if IS_IN_CONTAINER \ + else '1') + +CELERY_RESULT_BACKEND = os.getenv( + 'CELERY_RESULT_BACKEND', 'rpc://') +# Acknowledge tasks after they are done [True/False] +CELERY_TASK_ACKS_LATE = bool(os.getenv( + 'CELERY_TASK_ACKS_LATE', 'True').lower() == 'true') +# Run tasks synchronously [True/False] +CELERY_TASK_ALWAYS_EAGER = bool(os.getenv( + 'CELERY_TASK_ALWAYS_EAGER', 'False').lower() == 'true') +# Use a single worker process ['solo', 'prefork'] +CELERY_WORKER_POOL = os.getenv( + 'CELERY_WORKER_POOL', 'solo') \ + if IS_IN_CONTAINER \ + else 'solo' +# Restart worker after each task +CELERY_WORKER_MAX_TASKS_PER_CHILD = int(os.getenv( + 'CELERY_WORKER_MAX_TASKS_PER_CHILD', '1') \ + if IS_IN_CONTAINER \ + else '1') +# Number of worker processes +CELERY_WORKER_CONCURRENCY = int(os.getenv( + 'CELERY_WORKER_CONCURRENCY', '1') \ + if IS_IN_CONTAINER \ + else '1') +# Disable result printing +CELERY_TASK_IGNORE_RESULT = bool(os.getenv( + 'CELERY_TASK_IGNORE_RESULT', 'False').lower() == 'true') +# Configure task logging +CELERY_WORKER_REDIRECT_STDOUTS = bool(os.getenv( + 'CELERY_WORKER_REDIRECT_STDOUTS', 'True').lower() == 'true') +CELERY_WORKER_REDIRECT_STDOUTS_LEVEL = os.getenv( + 'CELERY_WORKER_REDIRECT_STDOUTS_LEVEL', 'INFO') +# Custom logging format for tasks +CELERY_WORKER_TASK_LOG_FORMAT = ( + os.getenv('CELERY_WORKER_TASK_LOG_FORMAT', '%(asctime)s - %(message)s') +) + +DJANGO_SUPER_USER = os.environ.get('DJANGO_SUPER_USER', 'admin') +DJANGO_SUPER_PASSWORD = os.environ.get('DJANGO_SUPER_PASSWORD', 'admin') \ No newline at end of file diff --git a/django/tamprog/tamprog/urls.py b/django/tamprog/tamprog/urls.py index ca2d751a..07249e4d 100644 --- a/django/tamprog/tamprog/urls.py +++ b/django/tamprog/tamprog/urls.py @@ -16,9 +16,27 @@ """ from django.contrib import admin from django.urls import path, include +from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView + +from drf_spectacular.utils import extend_schema + +@extend_schema( + tags=['API'], + summary='API schema', +) +class CustomSchemaView(SpectacularAPIView): + pass + +schema_view = CustomSchemaView.as_view() urlpatterns = [ path('admin/', admin.site.urls), path('log/', include('rest_framework.urls')), + path('api/v1/', include('fertilizer.urls')), path('api/v1/', include('garden.urls')), -] + path('api/v1/', include('orders.urls')), + path('api/v1/', include('plants.urls')), + path('api/v1/', include('user.urls')), + path('api/v1/schema/', schema_view, name='schema'), + path('api/v1/swagger/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), +] \ No newline at end of file diff --git a/django/tamprog/user/__init__.py b/django/tamprog/user/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/django/tamprog/user/admin.py b/django/tamprog/user/admin.py new file mode 100644 index 00000000..01e63faf --- /dev/null +++ b/django/tamprog/user/admin.py @@ -0,0 +1,11 @@ +from django.contrib import admin +from .models import * + + + +admin.site.register(Worker) +class PersonAdmin(admin.ModelAdmin): + list_display = ('username', 'full_name', 'phone_number', 'wallet_balance', 'is_active', 'is_staff') + search_fields = ('username', 'full_name', 'phone_number') + list_filter = ('is_staff', 'is_active') +admin.site.register(Person, PersonAdmin) \ No newline at end of file diff --git a/django/tamprog/user/apps.py b/django/tamprog/user/apps.py new file mode 100644 index 00000000..36cce4c8 --- /dev/null +++ b/django/tamprog/user/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class UserConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'user' diff --git a/django/tamprog/user/management/commands/__init__.py b/django/tamprog/user/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/django/tamprog/user/management/commands/auto_createsuperuser.py b/django/tamprog/user/management/commands/auto_createsuperuser.py new file mode 100644 index 00000000..5a083d7c --- /dev/null +++ b/django/tamprog/user/management/commands/auto_createsuperuser.py @@ -0,0 +1,43 @@ +import os + +from django.core.management.base import BaseCommand +from django.contrib.auth.models import User +from django.core.validators import validate_email +from django.conf import settings +from django.contrib.auth import get_user_model + +class Command(BaseCommand): + help = 'Create a superuser with predefined credentials' + + def add_arguments(self, parser): + pass + + def handle(self, *args, **options): + User = get_user_model() + + # Store original REQUIRED_FIELDS + original_required = User.REQUIRED_FIELDS + + try: + User.REQUIRED_FIELDS = [] + + username = settings.DJANGO_SUPER_USER + password = settings.DJANGO_SUPER_PASSWORD + + if User.objects.filter(username=username).exists(): + self.stdout.write(self.style.WARNING('Superuser already exists')) + return + + user = User._default_manager.create( + username=username, + is_staff=True, + is_superuser=True, + ) + user.set_password(password) + user.save() + # User.objects.create_superuser(username=username, email=email, password=password) + self.stdout.write(self.style.SUCCESS('Superuser created successfully')) + except AttributeError as e: + self.stdout.write(self.style.ERROR('Missing required settings. Please check DJANGO_SUPER_USER, DJANGO_SUPER_EMAIL, and DJANGO_SUPER_PASSWORD in settings.py')) + finally: + User.REQUIRED_FIELDS = original_required \ No newline at end of file diff --git a/django/tamprog/user/migrations/0001_initial.py b/django/tamprog/user/migrations/0001_initial.py new file mode 100644 index 00000000..23f9fbac --- /dev/null +++ b/django/tamprog/user/migrations/0001_initial.py @@ -0,0 +1,50 @@ +# Generated by Django 4.2.16 on 2024-10-27 13:27 + +import django.core.validators +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='Agronomist', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ], + ), + migrations.CreateModel( + name='Worker', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ], + ), + migrations.CreateModel( + name='Person', + 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(max_length=255, unique=True)), + ('wallet_balance', models.DecimalField(decimal_places=2, default=0.0, max_digits=10)), + ('full_name', models.CharField(max_length=255)), + ('phone_number', models.CharField(max_length=15, validators=[django.core.validators.RegexValidator(regex='^\\+?1?\\d{9,15}$')])), + ('is_active', models.BooleanField(default=True)), + ('is_staff', models.BooleanField(default=False)), + ('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': 'auth_user', + }, + ), + ] diff --git a/django/tamprog/user/migrations/0002_alter_person_table.py b/django/tamprog/user/migrations/0002_alter_person_table.py new file mode 100644 index 00000000..2646b218 --- /dev/null +++ b/django/tamprog/user/migrations/0002_alter_person_table.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.16 on 2024-10-27 13:48 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('user', '0001_initial'), + ] + + operations = [ + migrations.AlterModelTable( + name='person', + table=None, + ), + ] diff --git a/django/tamprog/user/migrations/0003_alter_person_wallet_balance.py b/django/tamprog/user/migrations/0003_alter_person_wallet_balance.py new file mode 100644 index 00000000..c29b57ae --- /dev/null +++ b/django/tamprog/user/migrations/0003_alter_person_wallet_balance.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.16 on 2024-10-27 13:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('user', '0002_alter_person_table'), + ] + + operations = [ + migrations.AlterField( + model_name='person', + name='wallet_balance', + field=models.FloatField(default=0.0), + ), + ] diff --git a/django/tamprog/user/migrations/0004_worker_description_worker_price.py b/django/tamprog/user/migrations/0004_worker_description_worker_price.py new file mode 100644 index 00000000..a05c5b9d --- /dev/null +++ b/django/tamprog/user/migrations/0004_worker_description_worker_price.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.16 on 2024-10-31 15:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('user', '0003_alter_person_wallet_balance'), + ] + + operations = [ + migrations.AddField( + model_name='worker', + name='description', + field=models.TextField(default=0), + preserve_default=False, + ), + migrations.AddField( + model_name='worker', + name='price', + field=models.FloatField(default=0.0), + ), + ] diff --git a/django/tamprog/user/migrations/__init__.py b/django/tamprog/user/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/django/tamprog/user/models.py b/django/tamprog/user/models.py new file mode 100644 index 00000000..97813a8b --- /dev/null +++ b/django/tamprog/user/models.py @@ -0,0 +1,56 @@ +from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin +from django.db import models +from django.core.validators import RegexValidator +from django.core.exceptions import ValidationError +import re + +class PersonManager(BaseUserManager): + def create_user(self, username, full_name, phone_number, password=None, **extra_fields): + if not username: + raise ValueError("The Username field must be set") + if not phone_number: + raise ValueError("The Phone Number field must be set") + + if not extra_fields.get("is_superuser", False): + if re.match(r'^agronom\d$', username): + raise ValueError("Usernames starting with 'agronom' followed by a digit are reserved for superusers.") + + user = self.model(username=username, full_name=full_name, phone_number=phone_number, **extra_fields) + user.set_password(password) + user.save(using=self._db) + return user + + def create_superuser(self, username, full_name, phone_number, password=None, **extra_fields): + extra_fields.setdefault("is_staff", True) + extra_fields.setdefault("is_superuser", True) + return self.create_user(username, full_name, phone_number, password, **extra_fields) + +class Person(AbstractBaseUser, PermissionsMixin): + username = models.CharField(max_length=255, unique=True) + wallet_balance = models.FloatField(default=0.00) + full_name = models.CharField(max_length=255) + phone_number = models.CharField( + max_length=15, + validators=[RegexValidator(regex=r'^\+?1?\d{9,15}$')] + ) + is_active = models.BooleanField(default=True) + is_staff = models.BooleanField(default=False) + + objects = PersonManager() + + USERNAME_FIELD = 'username' + REQUIRED_FIELDS = ['full_name', 'phone_number'] + + class Meta: + pass + + def __str__(self): + return self.username + +class Agronomist(models.Model): + name = models.CharField(max_length=255) + +class Worker(models.Model): + name = models.CharField(max_length=255) + price = models.FloatField(default=0.00) + description = models.TextField() diff --git a/django/tamprog/user/permission.py b/django/tamprog/user/permission.py new file mode 100644 index 00000000..3fee41a3 --- /dev/null +++ b/django/tamprog/user/permission.py @@ -0,0 +1,25 @@ +from rest_framework.permissions import BasePermission +import re + +class PostOnly(BasePermission): + def has_permission(self, request, view): + if request.method == 'POST': + return True + return request.user and request.user.is_authenticated + +class AgronomistPermission(BasePermission): + def has_permission(self, request, view): + if request.user and request.user.is_authenticated: + username = request.user.username + if re.match(r'^agronom\d+$', username): + return True + if request.user.is_superuser: + return True + return request.method in ['GET', 'HEAD', 'OPTIONS'] + return False + +class NoPostAllowed(BasePermission): + def has_permission(self, request, view): + if request.method == "POST": + return False + return True \ No newline at end of file diff --git a/django/tamprog/user/queries.py b/django/tamprog/user/queries.py new file mode 100644 index 00000000..3c0080be --- /dev/null +++ b/django/tamprog/user/queries.py @@ -0,0 +1,33 @@ +from .models import Worker + + +class GetWorkersSortedByID: + def __init__(self, ascending: bool = True): + self.ascending = ascending + + def execute(self): + return Worker.objects.order_by('id' if self.ascending else '-id') + + +class GetWorkersSortedByName: + def __init__(self, ascending: bool = True): + self.ascending = ascending + + def execute(self): + return Worker.objects.order_by('name' if self.ascending else '-name') + + +class GetWorkersSortedByPrice: + def __init__(self, ascending: bool = True): + self.ascending = ascending + + def execute(self): + return Worker.objects.order_by('price' if self.ascending else '-price') + + +class GetWorkersSortedByDescription: + def __init__(self, ascending: bool = True): + self.ascending = ascending + + def execute(self): + return Worker.objects.order_by('description' if self.ascending else '-description') \ No newline at end of file diff --git a/django/tamprog/user/serializers.py b/django/tamprog/user/serializers.py new file mode 100644 index 00000000..e478348e --- /dev/null +++ b/django/tamprog/user/serializers.py @@ -0,0 +1,33 @@ +from rest_framework import serializers +from .models import Person, Worker +from rest_framework import serializers +from django.contrib.auth import get_user_model +from rest_framework_simplejwt.tokens import RefreshToken + +User = get_user_model() + +class PersonSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ['id', 'wallet_balance', 'full_name', 'phone_number'] + +class RegisterSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ('username', 'full_name', 'phone_number', 'password', 'wallet_balance') + extra_kwargs = {'password': {'write_only': True}} + + def validate_phone_number(self, value): + if User.objects.filter(phone_number=value).exists(): + raise serializers.ValidationError("User with this phone number already exists.") + return value + +class LoginSerializer(serializers.Serializer): + username = serializers.CharField() + password = serializers.CharField(write_only=True) + + +class WorkerSerializer(serializers.ModelSerializer): + class Meta: + model = Worker + fields = '__all__' diff --git a/django/tamprog/user/services.py b/django/tamprog/user/services.py new file mode 100644 index 00000000..4cfe5bbb --- /dev/null +++ b/django/tamprog/user/services.py @@ -0,0 +1,65 @@ +from django.contrib.auth import get_user_model +from .queries import * +from rest_framework import status +from rest_framework.response import Response +# These \/ imports for the Celery +from celery import shared_task +from celery.result import AsyncResult +from django.forms.models import model_to_dict +from django.conf import settings + +User = get_user_model() + +class PersonService: + @staticmethod + def create_user(username, full_name, phone_number, password, wallet_balance=0.00): + user = User.objects.create_user( + username=username, + full_name=full_name, + phone_number=phone_number, + password=password, + wallet_balance=wallet_balance + ) + return user + + @staticmethod + def update_wallet_balance(user, amount): + if user.wallet_balance is None: + return Response( + {'error': 'Wallet balance is not set for this user'}, + status=status.HTTP_400_BAD_REQUEST + ) + if user.wallet_balance < amount: + return Response( + {'error': 'Insufficient funds in the wallet'}, + status=status.HTTP_400_BAD_REQUEST + ) + user.wallet_balance -= amount + user.save(update_fields=['wallet_balance']) + return Response( + {'status': 'Wallet balance updated successfully'}, + status=status.HTTP_200_OK + ) + +@shared_task +def get_sorted_workers_task(sort_by: str = 'id', ascending: bool = True): + if sort_by == 'id': + query = GetWorkersSortedByID(ascending) + elif sort_by == 'name': + query = GetWorkersSortedByName(ascending) + elif sort_by == 'price': + query = GetWorkersSortedByPrice(ascending) + elif sort_by == 'description': + query = GetWorkersSortedByDescription(ascending) + else: + return [] + # Convert QuerySet to list of dictionaries + queryset = query.execute() + return [model_to_dict(field) for field in queryset] + +class WorkerService: + @staticmethod + def get_sorted_workers(sort_by: str = 'price', ascending: bool = True): + task = get_sorted_workers_task.delay('price', ascending) + result = AsyncResult(task.id) + return result.get(timeout=settings.DJANGO_ASYNC_TIMEOUT_S) \ No newline at end of file diff --git a/django/tamprog/user/tests.py b/django/tamprog/user/tests.py new file mode 100644 index 00000000..e2f2ebe1 --- /dev/null +++ b/django/tamprog/user/tests.py @@ -0,0 +1,144 @@ +import pytest +from rest_framework import status +from unittest.mock import patch, MagicMock +from user.models import Worker +from user.services import WorkerService +from django.contrib.auth import get_user_model + +User = get_user_model() + +@pytest.mark.django_db +def test_register_user(api_client): + """Тест успешной регистрации нового пользователя.""" + url = '/api/v1/register/' + data = { + 'username': 'newuser', + 'phone_number': '+1234567890', + 'full_name': 'New User', + 'password': 'newpassword', + 'wallet_balance': 100.00 + } + response = api_client.post(url, data, format='json') + assert response.status_code == status.HTTP_201_CREATED + assert User.objects.filter(username='newuser').exists() + +@pytest.mark.django_db +def test_register_user_existing_username(api_client, user): + """Тест на регистрацию пользователя с уже существующим именем.""" + url = '/api/v1/register/' + data = { + 'username': user.username, + 'phone_number': '+1234567890', + 'full_name': 'New User', + 'password': 'newpassword', + 'wallet_balance': 50.00 + } + response = api_client.post(url, data, format='json') + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "username" in response.data + +@pytest.mark.django_db +def test_register_user_missing_fields(api_client): + """Тест на регистрацию без обязательных полей.""" + url = '/api/v1/register/' + data = { + 'username': 'incompleteuser' + # Пропущены обязательные поля + } + response = api_client.post(url, data, format='json') + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "phone_number" in response.data + assert "password" in response.data + +@pytest.mark.django_db +def test_register_user_invalid_phone_number(api_client): + """Тест на регистрацию с неверным форматом номера телефона.""" + url = '/api/v1/register/' + data = { + 'username': 'user_invalid_phone', + 'phone_number': 'invalid_phone', + 'full_name': 'New User', + 'password': 'newpassword', + 'wallet_balance': 100.00 + } + response = api_client.post(url, data, format='json') + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "phone_number" in response.data + +@pytest.mark.django_db +def test_login_user(api_client, user): + """Тест успешного входа с корректными данными.""" + user.set_password('testpassword') + user.save() + url = '/api/v1/login/' + data = { + 'username': user.username, + 'password': 'testpassword' + } + response = api_client.post(url, data, format='json') + assert response.status_code == status.HTTP_200_OK + assert 'access' in response.data + assert 'refresh' in response.data + assert 'wallet_balance' in response.data + +@pytest.mark.django_db +def test_login_user_invalid_credentials(api_client): + """Тест на вход с неверными данными.""" + url = '/api/v1/login/' + data = { + 'username': 'nonexistentuser', + 'password': 'wrongpassword' + } + response = api_client.post(url, data, format='json') + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "detail" in response.data + assert response.data["detail"] == "Invalid credentials" + +@pytest.mark.django_db +def test_login_user_missing_fields(api_client): + """Тест на вход с отсутствующими обязательными полями.""" + url = '/api/v1/login/' + data = { + 'username': 'testuser' + } + response = api_client.post(url, data, format='json') + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "password" in response.data + +@pytest.mark.django_db +@patch('user.services.get_sorted_workers_task') +@patch('user.services.AsyncResult') +def test_get_sorted_workers_ascending(mock_async_result, mock_task, workers): + mock_task_instance = MagicMock() + mock_task_instance.id = 'task_id' + mock_task.delay.return_value = mock_task_instance + + mock_async_result_instance = MagicMock() + mock_async_result_instance.get.return_value = sorted(workers, key=lambda w: w.price) + mock_async_result.return_value = mock_async_result_instance + + sorted_workers = WorkerService.get_sorted_workers(ascending=True) + mock_task.delay.assert_called_once_with('price', True) + mock_async_result.assert_called_once_with('task_id') + mock_async_result_instance.get.assert_called_once() + assert [worker.price for worker in sorted_workers] == sorted([worker.price for worker in workers]) + +@pytest.mark.django_db +@patch('user.services.get_sorted_workers_task') +@patch('user.services.AsyncResult') +def test_get_sorted_workers_descending(mock_async_result, mock_task, workers): + mock_task_instance = MagicMock() + mock_task_instance.id = 'task_id' + mock_task.delay.return_value = mock_task_instance + + mock_async_result_instance = MagicMock() + mock_async_result_instance.get.return_value = sorted(workers, key=lambda w: w.price, reverse=True) + mock_async_result.return_value = mock_async_result_instance + + sorted_workers = WorkerService.get_sorted_workers(ascending=False) + mock_task.delay.assert_called_once_with('price', False) + mock_async_result.assert_called_once_with('task_id') + mock_async_result_instance.get.assert_called_once() + assert [worker.price for worker in sorted_workers] == sorted([worker.price for worker in workers], reverse=True) + + diff --git a/django/tamprog/user/urls.py b/django/tamprog/user/urls.py new file mode 100644 index 00000000..2dc96db6 --- /dev/null +++ b/django/tamprog/user/urls.py @@ -0,0 +1,19 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import PersonViewSet, WorkerViewSet +from django.urls import path +from rest_framework_simplejwt.views import TokenRefreshView +from .views import RegisterViewSet, LoginView, LogoutView + +router = DefaultRouter() +router.register(r'person', PersonViewSet) +router.register(r'worker', WorkerViewSet) +router_reg = DefaultRouter() +router_reg.register(r'register', RegisterViewSet) +urlpatterns = [ + path('', include(router.urls)), + path('', include(router_reg.urls)), + path('login/', LoginView.as_view(), name='login'), + path('logout/', LogoutView.as_view(), name='logout'), + path('refresh/', TokenRefreshView.as_view(), name='token_refresh'), +] diff --git a/django/tamprog/user/views.py b/django/tamprog/user/views.py new file mode 100644 index 00000000..960c338f --- /dev/null +++ b/django/tamprog/user/views.py @@ -0,0 +1,406 @@ +from rest_framework import viewsets +from .permission import * +from .models import * +from .serializers import * +from rest_framework import generics, status +from django.contrib.auth import authenticate +from rest_framework_simplejwt.tokens import RefreshToken +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework.permissions import AllowAny, IsAdminUser, IsAuthenticated +from .services import * +from django.contrib.auth import get_user_model + +from drf_spectacular.utils import extend_schema, extend_schema_view, \ + OpenApiResponse, OpenApiParameter, OpenApiExample + +User = get_user_model() + +def LoginParameters(required=False): + return [ + OpenApiParameter( + name="username", + description="Username", + type=str, + required=required, + ), + OpenApiParameter( + name="password", + description="Password", + type=str, + required=required, + ), + ] + +@extend_schema(tags=['User']) +class LoginView(generics.GenericAPIView): + permission_classes = (AllowAny,) + serializer_class = LoginSerializer + + @extend_schema( + summary='Login user', + request=LoginSerializer, + responses={ + status.HTTP_200_OK: OpenApiResponse( + description="User logged in successfully", + examples=[ + OpenApiExample( + name="Successful login", + value={ + "refresh": "string", + "access": "string", + "wallet_balance": 0.00, + }, + ) + ], + response=LoginSerializer, + ), + status.HTTP_400_BAD_REQUEST: OpenApiResponse( + description="Invalid credentials", + examples=[ + OpenApiExample( + name="Invalid credentials", + value={ + "detail": "Invalid credentials" + }, + ) + ], + response=LoginSerializer, + ) + }, + parameters=LoginParameters(required=True), + ) + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = authenticate( + username=serializer.validated_data['username'], + password=serializer.validated_data['password'] + ) + if user is not None: + refresh = RefreshToken.for_user(user) + return Response({ + 'refresh': str(refresh), + 'access': str(refresh.access_token), + 'wallet_balance': user.wallet_balance, + }) + return Response({"detail": "Invalid credentials"}, status=400) + + +class LogoutView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request, *args, **kwargs): + try: + refresh_token = RefreshToken.for_user(request.user) + refresh_token.blacklist() + return Response({"message": "Exit successful"}, status=status.HTTP_200_OK) + except Exception as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + +def RegisterParameters(required=False): + return [ + OpenApiParameter( + name="username", + description="Username", + type=str, + required=required, + ), + OpenApiParameter( + name="full_name", + description="Full name", + type=str, + required=required, + ), + OpenApiParameter( + name="phone_number", + description="Phone number", + type=str, + required=required, + ), + OpenApiParameter( + name="password", + description="Password", + type=str, + required=required, + ), + OpenApiParameter( + name="wallet_balance", + description="Wallet balance", + type=float, + ), + ] + +@extend_schema(tags=['User']) +@extend_schema(methods=['PUT', 'GET', 'PATCH', 'DELETE'], exclude=True) +class RegisterViewSet(viewsets.ModelViewSet): + queryset = Person.objects.all() + serializer_class = RegisterSerializer + permission_classes = (PostOnly,) + + @extend_schema( + summary='Register new user', + request=LoginSerializer, + responses={ + status.HTTP_200_OK: OpenApiResponse( + description="User registered successfully", + examples=[ + OpenApiExample( + name="Successful registration", + value={ + "message": "User created successfully", + "user_id": 0, + "username": "string", + }, + ) + ], + response=LoginSerializer, + ), + }, + parameters=RegisterParameters(required=True), + examples=[ + OpenApiExample( + name="Register new user", + value={ + "username": "string", + "full_name": "string", + "phone_number": "string", + "password": "string", + "wallet_balance": 0.00 + }, + request_only=True, + ), + ] + ) + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + user = PersonService.create_user( + username=serializer.validated_data['username'], + full_name=serializer.validated_data['full_name'], + phone_number=serializer.validated_data['phone_number'], + password=serializer.validated_data['password'], + wallet_balance=serializer.validated_data.get('wallet_balance', 0.00) + ) + except ValueError as e: + return Response( + {'error': str(e)}, + status=status.HTTP_400_BAD_REQUEST + ) + headers = self.get_success_headers(serializer.data) + return Response( + {"message": "User created successfully", "user_id": user.id, "username": user.username}, + status=status.HTTP_201_CREATED, + headers=headers + ) + +def PersonParameters(required=False): + return [ + OpenApiParameter( + name="wallet_balance", + description="User wallet balance", + type=float, + required=required, + ), + OpenApiParameter( + name="full_name", + description="User full name", + type=str, + required=required, + ), + OpenApiParameter( + name="phone_number", + description="User phone number", + type=str, + required=required, + ), + ] + +@extend_schema(tags=['User']) +class PersonViewSet(viewsets.ModelViewSet): + queryset = Person.objects.all() + serializer_class = PersonSerializer + permission_classes = [IsAdminUser, NoPostAllowed] + + @extend_schema( + summary='Get all users', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description="Successful response", + response=PersonSerializer(many=True), + ), + }, + ) + def list(self, request, *args, **kwargs): + return super().list(request, *args, **kwargs) + + @extend_schema( + summary='Get user by ID', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description="Successful response", + response=PersonSerializer, + ), + }, + ) + def retrieve(self, request, *args, **kwargs): + return super().retrieve(request, *args, **kwargs) + + @extend_schema( + summary='Update user', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description="User updated successfully", + ), + }, + parameters=PersonParameters(required=True), + ) + def update(self, request, *args, **kwargs): + return super().update(request, *args, **kwargs) + + @extend_schema( + summary='Partial update user', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description="User updated successfully", + ), + }, + parameters=PersonParameters(), + ) + def partial_update(self, request, *args, **kwargs): + return super().partial_update(request, *args, **kwargs) + + @extend_schema( + summary='Delete user', + responses={ + status.HTTP_204_NO_CONTENT: OpenApiResponse( + description="User deleted successfully", + ), + }, + ) + def destroy(self, request, *args, **kwargs): + return super().destroy(request, *args, **kwargs) + + @extend_schema(exclude=True) + def create(self, request, *args, **kwargs): + return super().create(request, *args, **kwargs) + +def WorkerParameters(required=False): + return [ + OpenApiParameter( + name="name", + description="Worker full name", + type=str, + required=required, + ), + OpenApiParameter( + name="price", + description="Worker salary", + type=float, + required=required, + ), + OpenApiParameter( + name="description", + description="Worker description", + type=str, + required=required, + ), + ] + +@extend_schema(tags=['User']) +class WorkerViewSet(viewsets.ModelViewSet): + queryset = Worker.objects.all() + serializer_class = WorkerSerializer + permission_classes = [AgronomistPermission] + + @extend_schema( + summary='Get all workers', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description="Successful response", + response=WorkerSerializer(many=True), + ), + }, + parameters=[ + OpenApiParameter( + name='sort', + type=str, + description='Sort by field', + required=False, + enum=['id', 'name', 'price', 'description'], + ), + OpenApiParameter( + name='asc', + type=bool, + description='Ascending order', + required=False, + ), + ], + ) + def list(self, request, *args, **kwargs): + sort_by = request.query_params.get('sort', 'id') + ascending = request.query_params.get('asc', 'true').lower() == 'true' + workers = WorkerService.get_sorted_workers('price', ascending) + serializer = self.get_serializer(workers, many=True) + return Response(serializer.data) + + @extend_schema( + summary='Get worker by ID', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description="Successful response", + response=WorkerSerializer, + ), + }, + ) + def retrieve(self, request, *args, **kwargs): + return super().retrieve(request, *args, **kwargs) + + @extend_schema( + summary='Update worker', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description="Worker updated successfully", + ), + }, + parameters=WorkerParameters(required=True), + ) + def create(self, request, *args, **kwargs): + return super().create(request, *args, **kwargs) + + @extend_schema( + summary='Update worker', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description="Worker updated successfully", + ), + }, + parameters=WorkerParameters(required=True), + ) + def update(self, request, *args, **kwargs): + return super().update(request, *args, **kwargs) + + @extend_schema( + summary='Partial update worker', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description="Worker updated successfully", + ), + }, + parameters=WorkerParameters(), + ) + def partial_update(self, request, *args, **kwargs): + return super().partial_update(request, *args, **kwargs) + + @extend_schema( + summary='Delete worker', + responses={ + status.HTTP_204_NO_CONTENT: OpenApiResponse( + description="Worker deleted successfully", + ), + }, + ) + def destroy(self, request, *args, **kwargs): + return super().destroy(request, *args, **kwargs) + \ No newline at end of file diff --git a/docker-compose-build-up.ps1 b/docker-compose-build-up.ps1 index 123b37f8..9113f520 100644 --- a/docker-compose-build-up.ps1 +++ b/docker-compose-build-up.ps1 @@ -1,2 +1,3 @@ - -./env-inject.ps1 docker-compose -f docker-compose-build.yml up -d \ No newline at end of file +# $command = $args -join " " +./env-inject.ps1 +docker-compose --env-file ./.env -f docker-compose-build.yml up -d $args \ No newline at end of file diff --git a/docker-compose-build-up.sh b/docker-compose-build-up.sh index d39c8d82..93889ede 100644 --- a/docker-compose-build-up.sh +++ b/docker-compose-build-up.sh @@ -1,2 +1,3 @@ #!/bin/sh -./env-inject.sh docker-compose -f docker-compose-build.yml up -d \ No newline at end of file +./env-inject.sh +docker-compose --env-file ./.env -f docker-compose-build.yml up -d \ No newline at end of file diff --git a/docker-compose-build.yml b/docker-compose-build.yml index 62a75a68..d054c31f 100644 --- a/docker-compose-build.yml +++ b/docker-compose-build.yml @@ -9,19 +9,23 @@ services: - '${NGINX_HTTP_PORT}:80' - '${NGINX_HTTPS_PORT}:443' restart: always + depends_on: + - frontend + - redis-commander + - django + - rabbitmq psql: container_name: psql - build: ./psql - image: tam-prog-psql:latest + image: postgres:14-alpine environment: POSTGRES_DB: ${POSTGRES_DB} POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} PGDATA: "/var/lib/postgresql/data/pgdata" volumes: - - /usr/tamprog/db-init:/docker-entrypoint-initdb.d - - /usr/tamprog/db:/var/lib/postgresql/data + - db-data:/docker-entrypoint-initdb.d + - db-data:/var/lib/postgresql/data ports: - "${POSTGRES_PORT}:5432" restart: always @@ -44,7 +48,7 @@ services: - RABBITMQ_DEFAULT_PASS=${RABBITMQ_DEFAULT_PASS} - RABBITMQ_SERVER_ADDITIONAL_ERL_ARGS=-rabbit log_levels [{connection,error},{default,error}] disk_free_limit 2147483648 volumes: - - /usr/tamprog/rabbitmq:/var/lib/rabbitmq + - rabbit-data:/var/lib/rabbitmq ports: - ${RABBITMQ_WEB_UI_PORT}:15672 - ${RABBITMQ_PORT}:5672 @@ -67,7 +71,7 @@ services: - REDIS_MAXMEMORY_POLICY="allkeys-lru" - REDIS_MAXMEMORY="${REDIS_MAXMEMORY}" volumes: - - /usr/tamprog/redis:/data + - redis-data:/data ports: - ${REDIS_PORT}:6379 networks: @@ -115,24 +119,23 @@ services: ports: - "${DJANGO_PORT}:8000" restart: always + env_file: + - .env environment: - - DJANGO_SETTINGS_MODULE=tamprog.settings - - DJANGO_ALLOWED_HOSTS=${DJANGO_ALLOWED_HOSTS} - - DJANGO_SECRET_KEY=${DJANGO_SECRET_KEY} - - DJANGO_DEBUG=${DJANGO_DEBUG} - - POSTGRES_DB=${POSTGRES_DB} - - DJANGO_DB_HOST=${DJANGO_DB_HOST} - - POSTGRES_PORT=${POSTGRES_PORT} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - - POSTGRES_USER=${POSTGRES_USER} + - IS_IN_CONTAINER=true depends_on: - redis - psql - - # # for celery: - # depends_on: - # django: - # condition: service_healthy + - rabbitmq networks: - gateway: {} \ No newline at end of file + gateway: + driver: bridge + +volumes: + db-data: + driver: local + rabbit-data: + driver: local + redis-data: + driver: local \ No newline at end of file diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml deleted file mode 100644 index 8b75235d..00000000 --- a/docker-compose-dev.yml +++ /dev/null @@ -1,17 +0,0 @@ -services: - portainer: - container_name: portainer - build: ./portainer - image: tam-prog-portainer:latest - restart: always - ports: - - "${PORTAINER_PORT}:9000" - volumes: - - /var/run/docker.sock:/var/run/docker.sock - - /usr/tamprog/portainer:/data - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:9000"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 30s \ No newline at end of file diff --git a/docker-compose-up.ps1 b/docker-compose-up.ps1 index af1c45ba..8ba0004a 100644 --- a/docker-compose-up.ps1 +++ b/docker-compose-up.ps1 @@ -1 +1,2 @@ -./env-inject.ps1 docker-compose up -d \ No newline at end of file +./env-inject.ps1 +docker-compose --env-file ./.env up -d $args \ No newline at end of file diff --git a/docker-compose-up.sh b/docker-compose-up.sh index 19c36e53..7874bef3 100644 --- a/docker-compose-up.sh +++ b/docker-compose-up.sh @@ -1,2 +1,3 @@ #!/bin/sh -./env-inject.sh docker-compose up -d \ No newline at end of file +./env-inject.sh +docker-compose --env-file ./.env up -d \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 00199687..d66a6aa1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,18 +8,23 @@ services: - '${NGINX_HTTP_PORT}:80' - '${NGINX_HTTPS_PORT}:443' restart: always + depends_on: + - frontend + - redis-commander + - django + - rabbitmq psql: container_name: psql - image: ghcr.io/mikeiken/tam-prog-psql:latest + image: postgres:14-alpine environment: POSTGRES_DB: ${POSTGRES_DB} POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} PGDATA: "/var/lib/postgresql/data/pgdata" volumes: - - /usr/tamprog/db-init:/docker-entrypoint-initdb.d - - /usr/tamprog/db:/var/lib/postgresql/data + - db-data:/docker-entrypoint-initdb.d + - db-data:/var/lib/postgresql/data ports: - "${POSTGRES_PORT}:5432" restart: always @@ -42,7 +47,7 @@ services: - RABBITMQ_DEFAULT_PASS=${RABBITMQ_DEFAULT_PASS} - RABBITMQ_SERVER_ADDITIONAL_ERL_ARGS=-rabbit log_levels [{connection,error},{default,error}] disk_free_limit 2147483648 volumes: - - /usr/tamprog/rabbitmq:/var/lib/rabbitmq + - rabbit-data:/var/lib/rabbitmq ports: - ${RABBITMQ_WEB_UI_PORT}:15672 - ${RABBITMQ_PORT}:5672 @@ -65,7 +70,7 @@ services: - REDIS_MAXMEMORY_POLICY="allkeys-lru" - REDIS_MAXMEMORY="${REDIS_MAXMEMORY}" volumes: - - /usr/tamprog/redis:/data + - redis-data:/data ports: - ${REDIS_PORT}:6379 networks: @@ -111,24 +116,23 @@ services: ports: - "${DJANGO_PORT}:8000" restart: always + env_file: + - .env environment: - - DJANGO_SETTINGS_MODULE=tamprog.settings - - DJANGO_ALLOWED_HOSTS=${DJANGO_ALLOWED_HOSTS} - - DJANGO_SECRET_KEY=${DJANGO_SECRET_KEY} - - DJANGO_DEBUG=${DJANGO_DEBUG} - - POSTGRES_DB=${POSTGRES_DB} - - DJANGO_DB_HOST=${DJANGO_DB_HOST} - - POSTGRES_PORT=${POSTGRES_PORT} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - - POSTGRES_USER=${POSTGRES_USER} + - IS_IN_CONTAINER=true depends_on: - redis - psql - - # # for celery: - # depends_on: - # django: - # condition: service_healthy + - rabbitmq networks: - gateway: {} \ No newline at end of file + gateway: + driver: bridge + +volumes: + db-data: + driver: local + rabbit-data: + driver: local + redis-data: + driver: local \ No newline at end of file diff --git a/dotenv-template b/dotenv-template deleted file mode 100644 index fd685e06..00000000 --- a/dotenv-template +++ /dev/null @@ -1,39 +0,0 @@ -## SECRETS --================================== -RABBITMQ_DEFAULT_USER= -RABBITMQ_DEFAULT_PASS= -# --- -POSTGRES_USER= -POSTGRES_PASSWORD= -POSTGRES_DB= -# --- -REDIS_PASS= -REDIS_COMMANDER_USER= -REDIS_COMMANDER_PASS= -# --- -DJANGO_SECRET_KEY= -# --- - -## SETTINGS --================================== -RABBITMQ_WEB_UI_PORT=15672 -RABBITMQ_PORT=5672 -# --- -POSTGRES_PORT=5432 -# --- -NGINX_HTTP_PORT=80 -NGINX_HTTPS_PORT=443 -# --- -REDIS_PORT=6379 -REDIS_MAXMEMORY=256mb -REDIS_COMMANDER_PORT=8081 -# --- -PORTAINER_PORT=9000 -# --- -SWAGGER_PORT=8956 -# --- -NODEJS_PORT=3000 -# --- -DJANGO_PORT=8000 -DJANGO_DB_HOST=psql -DJANGO_DEBUG=True -DJANGO_ALLOWED_HOSTS=127.0.0.1,localhost,django -# --- diff --git a/env-inject.ps1 b/env-inject.ps1 index 685e8888..e81102e4 100644 --- a/env-inject.ps1 +++ b/env-inject.ps1 @@ -15,7 +15,7 @@ function Load-DotEnv { } } } else { - Write-Error "The .env file does not exist at path: $envFilePath" + Write-Error "[ENV-INJECT] The .env file does not exist at path: $envFilePath" } } @@ -24,10 +24,9 @@ Load-DotEnv # Check if a command is provided as arguments if ($args.Count -eq 0) { - Write-Error "No command provided. Please provide a command to run." - exit 1 + Write-Host "[ENV-INJECT] No command provided. Ran as standalone script." +} else { + # Join all arguments into a single command string + $command = $args -join " " + Invoke-Expression $command } - -# Join all arguments into a single command string -$command = $args -join " " -Invoke-Expression $command \ No newline at end of file diff --git a/env-inject.sh b/env-inject.sh index 0754116a..5bc35f86 100644 --- a/env-inject.sh +++ b/env-inject.sh @@ -1,18 +1,25 @@ -#!/bin/sh - -# Load .env file and run a specified command +#!/bin/bash # Function to load .env file and set environment variables load_dotenv() { - env_file_path=".env" - if [ -f "$env_file_path" ]; then - while IFS='=' read -r name value; do - if [ -n "$name" ] && [ "${name:0:1}" != "#" ]; then - export "$name"="$value" + local env_file="${1:-.env}" + + if [ -f "$env_file" ]; then + while IFS= read -r line || [ -n "$line" ]; do + # Skip comments and empty lines + if [[ $line =~ ^[[:space:]]*#.*$ ]] || [[ -z $line ]]; then + continue fi - done < "$env_file_path" + + # Extract variable name and value + if [[ $line =~ ^[[:space:]]*([^#][^=]*)[[:space:]]*=[[:space:]]*(.*)[[:space:]]*$ ]]; then + local name="${BASH_REMATCH[1]}" + local value="${BASH_REMATCH[2]}" + export "$name=$value" + fi + done < "$env_file" else - echo "The .env file does not exist at path: $env_file_path" >&2 + echo "[ENV-INJECT] The .env file does not exist at path: $env_file" >&2 exit 1 fi } @@ -21,11 +28,9 @@ load_dotenv() { load_dotenv # Check if a command is provided as arguments -if [ "$#" -eq 0 ]; then - echo "No command provided. Please provide a command to run." >&2 - exit 1 -fi - -# Join all arguments into a single command string -command="$*" -eval "$command" \ No newline at end of file +if [ $# -eq 0 ]; then + echo "[ENV-INJECT] No command provided. Ran as standalone script." +else + # Execute the command with all arguments + eval "$@" +fi \ No newline at end of file diff --git a/frontend/public/email.png b/frontend/public/email.png new file mode 100644 index 00000000..ed47207a Binary files /dev/null and b/frontend/public/email.png differ diff --git a/frontend/public/exclamation-mark.png b/frontend/public/exclamation-mark.png new file mode 100644 index 00000000..ec00bcba Binary files /dev/null and b/frontend/public/exclamation-mark.png differ diff --git a/frontend/public/field1.jpg b/frontend/public/field1.jpg new file mode 100644 index 00000000..719b7f94 Binary files /dev/null and b/frontend/public/field1.jpg differ diff --git a/frontend/public/field2.jpg b/frontend/public/field2.jpg new file mode 100644 index 00000000..ae7c3aaf Binary files /dev/null and b/frontend/public/field2.jpg differ diff --git a/frontend/public/key-chain.png b/frontend/public/key-chain.png new file mode 100644 index 00000000..58ce63e2 Binary files /dev/null and b/frontend/public/key-chain.png differ diff --git a/frontend/public/user-interface-login.png b/frontend/public/user-interface-login.png new file mode 100644 index 00000000..646e5e9a Binary files /dev/null and b/frontend/public/user-interface-login.png differ diff --git a/frontend/public/user.png b/frontend/public/user.png index d90b189d..d5db2f58 100644 Binary files a/frontend/public/user.png and b/frontend/public/user.png differ diff --git a/frontend/src/App.css b/frontend/src/App.css index 892dfe2c..331170a8 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -2,7 +2,7 @@ * { font-family: "Nunito", sans-serif; - + box-sizing: border-box; } *::-webkit-scrollbar-track diff --git a/frontend/src/App.js b/frontend/src/App.js index 4af782cd..eacc7108 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -1,16 +1,37 @@ import './App.css'; import MainPage from './components/main-page/MainPage'; -import AuthForm from './components/auth/auth'; +import AuthForm from './components/auth/login/auth'; import { Routes, Route } from 'react-router-dom'; -import Header from './components/header/Header'; // Импортируем Header +import Header from './components/header/Header'; +import NotFound from './components/not-found/NotFound'; +import RegisterForm from './components/auth/register/RegisterForm'; +import Garden from './components/main-page/Garden/Garden'; +import Contractor from './components/main-page/Contractor/Contractor'; +import License from './components/main-page/License/License'; +import About from './components/main-page/About/about'; +import PrivateRoute from './components/auth/private-route/PrivateRoute'; function App() { return (
ID: {item.id}
-Название: {item.name}
-Описание: {item.description}
+Название: {item.state}
+Площадь: {item.size}
+Цена: {item.price}
{item.name}
{item.description}
Название: {item.name}
Кол-во грядок:{item.count_beds}
Цена: {item.price}
Sing In
+Sing Up
+ +Упс! Вы потерялись.
+
Мы искали эту страницу повсюду, но не смогли её найти.
+
Может, вернёмся на главную и попробуем ещё раз?