diff --git a/.env-template b/.env-template index ae902aa9..7830e248 100644 --- a/.env-template +++ b/.env-template @@ -1,4 +1,4 @@ -## SECRETS --================================== +## SECRETS -------------------------------- RABBITMQ_DEFAULT_USER= RABBITMQ_DEFAULT_PASS= # --- @@ -15,7 +15,7 @@ DJANGO_SUPER_PASSWORD= DJANGO_SECRET_KEY= # --- -## SETTINGS --================================== +## SETTINGS -------------------------------- RABBITMQ_WEB_UI_PORT=15672 RABBITMQ_PORT=5672 RABBITMQ_HOST=rabbitmq @@ -26,7 +26,10 @@ POSTGRES_PORT=5432 NGINX_HTTP_PORT=80 NGINX_HTTPS_PORT=443 # --- +REDIS_USER=default REDIS_PORT=6379 +REDIS_DB=0 +REDIS_HOST=redis REDIS_MAXMEMORY=256mb REDIS_COMMANDER_PORT=8081 # --- @@ -49,7 +52,6 @@ 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:// # Late ack means the task messages will be acknowledged after the task has been executed, not right before [>TrueFalse<] @@ -73,4 +75,4 @@ CELERY_WORKER_REDIRECT_STDOUTS=False # Log level for task logs [DEBUG/>INFOFalse<] 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/>INFOFalse<] 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= field.all_beds: + return Response( + {"error": f"There is no space left on the field '{field.name}'."}, + status=status.HTTP_400_BAD_REQUEST + ) + bed = Bed.objects.create(field=field, rented_by=rented_by) + return Response( + {"message": "Bed successfully created."}, + status=status.HTTP_201_CREATED + ) + + @staticmethod + def rent_beds(field, user, beds_count): + free_beds = Bed.objects.filter(field=field, is_rented=False).order_by('id')[:beds_count] + rented_beds = [] + if len(free_beds) < beds_count: + log.warning(f"Not enough free beds available for rent.") + return 0 + + for bed in free_beds: bed.is_rented = True - bed.rented_by = person + bed.rented_by = user bed.save() + rented_beds.append(bed) - 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 - ) + field.count_free_beds -= beds_count + field.save() + log.info(f"{beds_count} beds rented successfully.") + return beds_count @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 - ) + def release_beds(field, beds_count): + rented_beds = Bed.objects.filter(field=field, is_rented=True)[:beds_count] + for bed in rented_beds: 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 - ) - + field.count_free_beds += beds_count + field.save() + log.info(f"{beds_count} beds released successfully.") @staticmethod def get_user_beds(user): + log.debug(f"Getting beds rented by user with ID={user.id}") return Bed.objects.filter(rented_by=user) @staticmethod def filter_beds(is_rented=None): if is_rented is not None: + log.debug(f"Filtering beds by is_rented={is_rented}") return Bed.objects.filter(is_rented=is_rented) + log.debug("Getting all beds") return Bed.objects.all() diff --git a/django/tamprog/garden/tests.py b/django/tamprog/garden/tests.py index cb4b60b0..7b82c800 100644 --- a/django/tamprog/garden/tests.py +++ b/django/tamprog/garden/tests.py @@ -4,6 +4,8 @@ from mixer.backend.django import mixer from celery.result import AsyncResult from unittest.mock import patch +from rest_framework import status +from orders.models import Order def test_get_sorted_fields_success(celery_settings, mocker): mocked_task = mocker.patch('garden.services.get_sorted_fields_task.delay') @@ -18,7 +20,8 @@ def test_get_sorted_fields_timeout(celery_settings, mocker): 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) + FieldService.get_sorted_fields(sort_by='count_free_beds', ascending=True) + @pytest.mark.django_db def test_filter_beds(api_client, user, beds): @@ -37,44 +40,62 @@ 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 + field = beds[0].field + result = BedService.rent_beds(field=field, user=person, beds_count=len(beds)) + assert result == 0 @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 + free_beds = [bed for bed in beds if not bed.is_rented] + assert free_beds, "Должна быть хотя бы одна свободная грядка для теста" + field = free_beds[0].field + initial_count = field.count_free_beds + rented_count = BedService.rent_beds(field=field, user=person, beds_count=1) + field.refresh_from_db() + rented_bed = Bed.objects.filter(field=field, rented_by=person).first() + assert rented_count == 1, "Должна быть успешно арендована одна грядка" + assert rented_bed is not None, "Должна быть найдена арендованная грядка" + assert rented_bed.is_rented is True + assert rented_bed.rented_by == person + assert field.count_free_beds == initial_count - 1 @pytest.mark.django_db def test_release_bed_success(beds, person): bed = beds[0] + field = bed.field + assert bed, "Грядка должна существовать" + assert field, "У грядки должно быть связано поле" bed.is_rented = True bed.rented_by = person bed.save() - initial_count = bed.field.count_beds - result = BedService.release_bed(bed_id=bed.id) + initial_free_beds = field.count_free_beds + assert bed.is_rented is True, "Грядка должна быть арендована перед освобождением" + assert bed.rented_by == person, "Грядка должна быть связана с пользователем" + BedService.release_beds(field=field, beds_count=1) 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 + field.refresh_from_db() + assert bed.is_rented is False, "Грядка должна быть освобождена" + assert bed.rented_by is None, "У грядки не должно быть арендатора" + assert field.count_free_beds == initial_free_beds + 1, "Количество свободных грядок должно увеличиться" @pytest.mark.django_db def test_release_bed_not_rented(beds): for bed in beds: bed.is_rented = False + bed.rented_by = None bed.save() - result = BedService.release_bed(bed_id=bed.id) - assert result.status_code == 400 + result = BedService.release_beds(field=beds[0].field, beds_count=1) + assert result is None + for bed in beds: + bed.refresh_from_db() + assert bed.is_rented is False + assert bed.rented_by is None + field = beds[0].field + field.refresh_from_db() + assert field.count_free_beds == field.count_free_beds @pytest.mark.django_db def test_get_user_beds(beds, person): @@ -110,7 +131,6 @@ def test_filter_beds_not_rented(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') @@ -137,5 +157,39 @@ def test_get_sorted_fields_by_beds(celery_settings, mocker): fields = FieldService.get_sorted_fields(sort_by='count_beds', ascending=True) assert [field['count_beds'] for field in fields] == [10, 11, 12, 13, 14] +@pytest.mark.django_db +def test_get_sorted_fields_timeout(api_client, superuser): + api_client.force_authenticate(user=superuser) + url = '/api/v1/field/' + response = api_client.get(url, {'sort_by': 'price', 'ascending': 'true'}) + assert response.status_code == 200 + assert 'error' not in response.data + +@pytest.mark.django_db +def test_get_user_beds_url(api_client, superuser, beds, person): + api_client.force_authenticate(user=person) + for bed in beds: + bed.rented_by = person + bed.is_rented = True + bed.save() + url = '/api/v1/bed/my_beds/' + response = api_client.get(url) + assert response.status_code == 200 + assert len(response.data) == len(beds) + for bed in response.data: + assert bed['rented_by'] == person.id + assert bed['is_rented'] is True +@pytest.mark.django_db +def test_filter_beds_is_rented_url(api_client, superuser, beds): + api_client.force_authenticate(user=superuser) + for bed in beds: + bed.is_rented = True + bed.save() + url = '/api/v1/bed/' + response = api_client.get(url, {'is_rented': 'true'}) + assert response.status_code == 200 + assert len(response.data) == len(beds) + for bed in response.data: + assert bed['is_rented'] is True \ No newline at end of file diff --git a/django/tamprog/garden/views.py b/django/tamprog/garden/views.py index 69f236f8..1d14a75e 100644 --- a/django/tamprog/garden/views.py +++ b/django/tamprog/garden/views.py @@ -2,10 +2,14 @@ from rest_framework.permissions import IsAuthenticated, AllowAny from rest_framework.decorators import action from rest_framework.response import Response +from rest_framework.exceptions import ValidationError from .permission import * from .models import Field, Bed from .serializers import FieldSerializer, BedSerializer from .services import * +from logging import getLogger + +log = getLogger(__name__) from drf_spectacular.utils import extend_schema, extend_schema_view, \ OpenApiResponse, OpenApiParameter, OpenApiExample @@ -19,8 +23,14 @@ def FieldParameters(required=False): required=required, ), OpenApiParameter( - name="count_beds", - description="Count of beds", + name="count_free_beds", + description="Count of free beds", + type=int, + required=required, + ), + OpenApiParameter( + name="all_beds", + description="Count of all beds", type=int, required=required, ), @@ -30,6 +40,12 @@ def FieldParameters(required=False): type=float, required=required, ), + OpenApiParameter( + name="url", + description="image", + type=str, + required=required, + ), ] @extend_schema(tags=['Field']) @@ -39,8 +55,12 @@ class FieldViewSet(viewsets.ModelViewSet): permission_classes = [AgronomistPermission] def perform_create(self, serializer): - count_beds = self.request.data.get('count_beds', 0) - serializer.save(count_beds=count_beds) + log.debug(f"Creating field with data: {self.request.data}") + name = serializer.validated_data['name'] + all_beds = serializer.validated_data['all_beds'] + price = serializer.validated_data['price'] + url = serializer.validated_data['url'] + FieldService.create_field(name, all_beds, price, url) @extend_schema( summary='List all fields', @@ -56,7 +76,7 @@ def perform_create(self, serializer): type=str, description='Sort by field', required=False, - enum=['id', 'name', 'count_beds', 'price'], + enum=['id', 'name', 'count_free_beds', 'all_beds', 'price'], ), OpenApiParameter( name='asc', @@ -71,6 +91,7 @@ def list(self, request, *args, **kwargs): ascending = request.query_params.get('asc', 'true').lower() == 'true' fields = FieldService.get_sorted_fields(sort_by, ascending) serializer = self.get_serializer(fields, many=True) + log.debug(f"Returning list of fields: {serializer.data}") return Response(serializer.data) @extend_schema( @@ -83,6 +104,7 @@ def list(self, request, *args, **kwargs): }, ) def retrieve(self, request, *args, **kwargs): + log.debug(f"Retrieving field with ID={kwargs['pk']}") return super().retrieve(request, *args, **kwargs) @extend_schema( @@ -95,6 +117,7 @@ def retrieve(self, request, *args, **kwargs): parameters=FieldParameters(required=True), ) def update(self, request, *args, **kwargs): + log.debug(f"Updating field with ID={kwargs['pk']} with data: {request.data}") return super().update(request, *args, **kwargs) @extend_schema( @@ -107,6 +130,7 @@ def update(self, request, *args, **kwargs): parameters=FieldParameters(), ) def partial_update(self, request, *args, **kwargs): + log.debug(f"Partially updating field with ID={kwargs['pk']} with data: {request.data}") return super().partial_update(request, *args, **kwargs) @extend_schema( @@ -118,6 +142,7 @@ def partial_update(self, request, *args, **kwargs): }, ) def destroy(self, request, *args, **kwargs): + log.debug(f"Deleting field with ID={kwargs['pk']}") return super().destroy(request, *args, **kwargs) @extend_schema( @@ -130,6 +155,7 @@ def destroy(self, request, *args, **kwargs): parameters=FieldParameters(required=True), ) def create(self, request, *args, **kwargs): + log.debug(f"Creating field with data: {request.data}") return super().create(request, *args, **kwargs) def BedParameters(required=False): @@ -169,6 +195,7 @@ class BedViewSet(viewsets.ModelViewSet): } ) def create(self, request, *args, **kwargs): + log.debug(f"Creating bed with data: {request.data}") return super().create(request, *args, **kwargs) @extend_schema( @@ -182,6 +209,7 @@ def create(self, request, *args, **kwargs): }, ) def list(self, request, *args, **kwargs): + log.debug(f"Listing all beds") return super().list(request, *args, **kwargs) @extend_schema( @@ -195,6 +223,7 @@ def list(self, request, *args, **kwargs): }, ) def retrieve(self, request, *args, **kwargs): + log.debug(f"Retrieving bed with ID={kwargs['pk']}") return super().retrieve(request, *args, **kwargs) @extend_schema( @@ -208,6 +237,7 @@ def retrieve(self, request, *args, **kwargs): parameters=BedParameters(required=True), ) def update(self, request, *args, **kwargs): + log.debug(f"Updating bed with ID={kwargs['pk']} with data: {request.data}") return super().update(request, *args, **kwargs) @extend_schema( @@ -220,6 +250,7 @@ def update(self, request, *args, **kwargs): }, ) def destroy(self, request, *args, **kwargs): + log.debug(f"Deleting bed with ID={kwargs['pk']}") return super().destroy(request, *args, **kwargs) @extend_schema( @@ -233,8 +264,16 @@ def destroy(self, request, *args, **kwargs): parameters=BedParameters(), ) def partial_update(self, request, *args, **kwargs): + log.debug(f"Partially updating bed with ID={kwargs['pk']} with data: {request.data}") return super().partial_update(request, *args, **kwargs) + def perform_create(self, serializer): + field = serializer.validated_data['field'] + rented_by = serializer.validated_data.get('rented_by', None) + response = BedService.create_bed(field, rented_by) + if isinstance(response, Response) and response.status_code != status.HTTP_201_CREATED: + raise ValidationError(response.data["error"]) + @extend_schema( summary='List all beds for current user', description='List all beds that are rented by the current user', @@ -249,74 +288,105 @@ def partial_update(self, request, *args, **kwargs): def my_beds(self, request): beds = BedService.get_user_beds(request.user) serializer = self.get_serializer(beds, many=True) + log.debug(f"Returning list of beds rented by user: {serializer.data}") return Response(serializer.data) @extend_schema( - summary='Rent bed', - description='Rent bed', + summary='Rent beds', + description='Rent a specified number of beds from a field.', responses={ status.HTTP_200_OK: OpenApiResponse( - description='Bed successfully rented.', + description='Beds successfully rented.', ), status.HTTP_400_BAD_REQUEST: OpenApiResponse( - description='Bad request: Bed is already rented.', + description='Bad request: Not enough free beds available.', ), status.HTTP_404_NOT_FOUND: OpenApiResponse( - description='Bed not found.', + description='Field not found.', ), }, parameters=BedParameters(required=True), examples=[ OpenApiExample( - name='Rent bed for user', + name='Rent beds for user', value={ - "is_rented": True, "field": 1, - "rented_by": 1 + "beds_count": 3 } ) ], ) - @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) + @action(detail=False, methods=['post']) + def rent(self, request): + field_id = request.data.get('field') + beds_count = request.data.get('beds_count') + user = request.user + + log.debug(f"Attempting to rent {beds_count} beds for user with ID={user.id} in field with ID={field_id}") + + try: + field = Field.objects.get(id=field_id) + except Field.DoesNotExist: + return Response({'error': 'Field not found'}, status=status.HTTP_404_NOT_FOUND) + + rented_beds = BedService.rent_beds(field, user, beds_count) + + if rented_beds == 0: + return Response({'error': 'Not enough free beds available for rent.'}, status=status.HTTP_400_BAD_REQUEST) + + return Response({ + 'message': f'{beds_count} beds rented successfully.', + 'rented_beds': [{'id': bed.id, 'position': bed.position, 'is_rented': bed.is_rented} for bed in rented_beds] + }, status=status.HTTP_200_OK) @extend_schema( - summary='Release bed', - description='Release bed', + summary='Release beds', + description='Release a specified number of rented beds from a field.', responses={ status.HTTP_200_OK: OpenApiResponse( - description='Bed successfully released.', + description='Beds successfully released.', ), status.HTTP_400_BAD_REQUEST: OpenApiResponse( - description='Bad request: Bed is not rented.', + description='Bad request: Not enough rented beds.', ), status.HTTP_404_NOT_FOUND: OpenApiResponse( - description='Bed not found.', + description='Field not found.', ), }, parameters=BedParameters(required=True), examples=[ OpenApiExample( - name='Release bed for user', + name='Release beds for user', value={ - "is_rented": False, "field": 1, - "rented_by": 1 + "beds_count": 2 } ) ], ) - @action(detail=True, methods=['post']) - def release(self, request, pk=None): - bed = self.get_object() - return BedService.release_bed(bed.id) + @action(detail=False, methods=['post']) + def release(self, request): + field_id = request.data.get('field') + beds_count = request.data.get('beds_count') + + log.debug(f"Attempting to release {beds_count} beds in field with ID={field_id}") + + try: + field = Field.objects.get(id=field_id) + except Field.DoesNotExist: + return Response({'error': 'Field not found'}, status=status.HTTP_404_NOT_FOUND) + + BedService.release_beds(field, beds_count) + + return Response({ + 'message': f'{beds_count} beds released successfully.' + }, status=status.HTTP_200_OK) def get_queryset(self): is_rented = self.request.query_params.get('is_rented', None) if is_rented is not None: + log.debug(f"Filtering beds by is_rented={is_rented}") return BedService.filter_beds(is_rented=is_rented.lower() == 'true') + log.debug("Returning all beds") return Bed.objects.all() diff --git a/django/tamprog/orders/migrations/0007_alter_order_total_cost.py b/django/tamprog/orders/migrations/0007_alter_order_total_cost.py new file mode 100644 index 00000000..0be9f097 --- /dev/null +++ b/django/tamprog/orders/migrations/0007_alter_order_total_cost.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.16 on 2024-11-22 10:24 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('orders', '0006_alter_order_completed_at'), + ] + + operations = [ + migrations.AlterField( + model_name='order', + name='total_cost', + field=models.FloatField(default=0.0, validators=[django.core.validators.MinValueValidator(0)]), + ), + ] diff --git a/django/tamprog/orders/migrations/0008_rename_action_order_comments.py b/django/tamprog/orders/migrations/0008_rename_action_order_comments.py new file mode 100644 index 00000000..cc09366f --- /dev/null +++ b/django/tamprog/orders/migrations/0008_rename_action_order_comments.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.16 on 2024-11-22 21:23 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('orders', '0007_alter_order_total_cost'), + ] + + operations = [ + migrations.RenameField( + model_name='order', + old_name='action', + new_name='comments', + ), + ] diff --git a/django/tamprog/orders/migrations/0009_alter_order_comments.py b/django/tamprog/orders/migrations/0009_alter_order_comments.py new file mode 100644 index 00000000..b850d2d8 --- /dev/null +++ b/django/tamprog/orders/migrations/0009_alter_order_comments.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.16 on 2024-11-26 09:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('orders', '0008_rename_action_order_comments'), + ] + + operations = [ + migrations.AlterField( + model_name='order', + name='comments', + field=models.CharField(blank=True, max_length=100, null=True), + ), + ] diff --git a/django/tamprog/orders/migrations/0010_remove_order_bed_order_beds_count_order_field.py b/django/tamprog/orders/migrations/0010_remove_order_bed_order_beds_count_order_field.py new file mode 100644 index 00000000..b6605c4b --- /dev/null +++ b/django/tamprog/orders/migrations/0010_remove_order_bed_order_beds_count_order_field.py @@ -0,0 +1,31 @@ +# Generated by Django 4.2.16 on 2024-11-27 11:07 + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('garden', '0006_rename_count_beds_field_all_beds_and_more'), + ('orders', '0009_alter_order_comments'), + ] + + operations = [ + migrations.RemoveField( + model_name='order', + name='bed', + ), + migrations.AddField( + model_name='order', + name='beds_count', + field=models.IntegerField(default=1, validators=[django.core.validators.MinValueValidator(1)]), + ), + migrations.AddField( + model_name='order', + name='field', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='garden.field'), + preserve_default=False, + ), + ] diff --git a/django/tamprog/orders/migrations/0011_order_fertilize.py b/django/tamprog/orders/migrations/0011_order_fertilize.py new file mode 100644 index 00000000..26ba7cdb --- /dev/null +++ b/django/tamprog/orders/migrations/0011_order_fertilize.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.16 on 2024-11-27 20:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('orders', '0010_remove_order_bed_order_beds_count_order_field'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='fertilize', + field=models.BooleanField(default=False), + ), + ] diff --git a/django/tamprog/orders/models.py b/django/tamprog/orders/models.py index 3130f903..81ed1835 100644 --- a/django/tamprog/orders/models.py +++ b/django/tamprog/orders/models.py @@ -1,14 +1,17 @@ from django.db import models from user.models import Person, Worker -from garden.models import Bed +from garden.models import Field from plants.models import Plant +from django.core.validators import MinValueValidator 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) + field = models.ForeignKey(Field, on_delete=models.CASCADE) + beds_count = models.IntegerField(default=1, validators=[MinValueValidator(1)]) plant = models.ForeignKey(Plant, on_delete=models.CASCADE) - action = models.CharField(max_length=100) + comments = models.CharField(max_length=100, blank=True, null=True) 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 + total_cost = models.FloatField(default=0.00, validators=[MinValueValidator(0)]) + fertilize = models.BooleanField(default=False) \ No newline at end of file diff --git a/django/tamprog/orders/serializer.py b/django/tamprog/orders/serializer.py index adc1fc6d..5e3bc61b 100644 --- a/django/tamprog/orders/serializer.py +++ b/django/tamprog/orders/serializer.py @@ -4,7 +4,7 @@ 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'] + fields = ['id', 'worker', 'user', 'field', 'beds_count', 'plant', 'comments', 'created_at', 'completed_at', 'total_cost', 'fertilize'] + read_only_fields = ['total_cost', 'created_at', 'user', 'worker', 'completed_at'] diff --git a/django/tamprog/orders/services.py b/django/tamprog/orders/services.py index b391c114..f2cc80ac 100644 --- a/django/tamprog/orders/services.py +++ b/django/tamprog/orders/services.py @@ -2,57 +2,125 @@ from rest_framework.response import Response from rest_framework import status from django.utils import timezone +from datetime import timedelta +from conftest import fertilizers +from fertilizer.models import Fertilizer +from garden.models import Bed from user.models import Worker from user.services import PersonService from .models import Order +from logging import getLogger +from garden.services import BedService +from plants.services import BedPlantService +log = getLogger(__name__) class OrderService: @staticmethod - def calculate_total_cost(bed, plant, worker): - field_price = bed.field.price - plant_price = plant.price + def calculate_total_cost(field, plant, worker, beds_count): + field_price = field.price * beds_count + plant_price = plant.price * beds_count worker_price = worker.price - return field_price + plant_price + worker_price + total_cost = field_price + plant_price + worker_price + log.debug( + f"Total cost calculated: field={field_price}, plant={plant_price}, worker={worker_price}, total={total_cost}") + return total_cost @staticmethod - def create_order(user, bed, plant, action): + def create_order(user, field, plant, beds_count, comments, fertilize): available_workers = Worker.objects.all() if not available_workers.exists(): - return Response( - {'error': 'No available workers'}, - status=status.HTTP_400_BAD_REQUEST - ) + log.warning("No available workers") + 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 - ) + + if field.count_free_beds < beds_count: + return Response({'error': 'Not enough free beds on the field'}, status=status.HTTP_400_BAD_REQUEST) + + total_cost = OrderService.calculate_total_cost(field, plant, worker, beds_count) + if user.wallet_balance < total_cost: + return Response({'error': 'Insufficient funds'}, status=status.HTTP_400_BAD_REQUEST) + + rented_beds = [] + try: + user.wallet_balance -= total_cost + user.save() + log.debug(f"User balance updated: {user.wallet_balance}") + + rented_beds = BedService.rent_beds(field, user, beds_count) + log.debug(f"Rented beds count: {rented_beds}") + if rented_beds != beds_count: + raise ValueError("Failed to rent the required number of beds") + + fertilizer_used = fertilize + fertilizer = None + if fertilize: + fertilizer = Fertilizer.objects.filter(compound__icontains=plant.name).first() + if not fertilizer: + fertilizer_used = False + log.warning(f"No suitable fertilizer found for plant {plant.name}. Fertilization skipped.") + + plant_response = BedPlantService.plant_in_beds( + field, plant, beds_count, fertilizer if fertilizer_used else None + ) + if plant_response.status_code != 201: + raise ValueError("Failed to plant in beds") + + completion_time = timezone.now() + timedelta(days=plant.growth_time) + order = Order.objects.create( + user=user, + worker=worker, + field=field, + plant=plant, + beds_count=beds_count, + comments=comments, + total_cost=total_cost, + fertilize=fertilizer_used, + completed_at=completion_time + ) + log.debug(f"Order created successfully with ID={order.id}") + + response_message = { + 'status': 'Order created successfully', + 'order_id': order.id + } + if not fertilizer_used: + response_message['warning'] = 'Fertilization skipped as no suitable fertilizer was found.' + + return Response(response_message, status=status.HTTP_201_CREATED) + + except Exception as e: + log.error(f"Error creating order: {e}") + if rented_beds: + BedService.release_beds(field, len(rented_beds)) + log.debug(f"Released {len(rented_beds)} beds") + user.wallet_balance += total_cost + user.save() + log.debug(f"User balance restored: {user.wallet_balance}") + return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + @staticmethod def complete_order(order): order.completed_at = timezone.now() order.save() + log.debug(f"Order with ID={order.id} completed") return order + @staticmethod + def get_orders(user): + log.debug(f"Getting orders by user with ID={user.id}") + return Order.objects.filter(user=user) + @staticmethod def filter_orders(is_completed=None): if is_completed is not None: if is_completed: + log.debug("Filtering completed orders") return Order.objects.filter(completed_at__isnull=False) else: + log.debug("Filtering not completed orders") return Order.objects.filter(completed_at__isnull=True) + log.debug("Getting all orders") return Order.objects.all() diff --git a/django/tamprog/orders/tests.py b/django/tamprog/orders/tests.py index c7baf147..db579716 100644 --- a/django/tamprog/orders/tests.py +++ b/django/tamprog/orders/tests.py @@ -4,6 +4,7 @@ from orders.models import Order from rest_framework import status from rest_framework.response import Response +from decimal import Decimal @pytest.mark.django_db def test_filter_orders(api_client, user, orders): @@ -24,40 +25,118 @@ def test_filter_orders(api_client, user, orders): @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 - + beds_count = 1 + total_cost = OrderService.calculate_total_cost(bed.field, plant, worker, beds_count) + assert total_cost == (bed.field.price * beds_count) + (plant.price * beds_count) + worker.price @pytest.mark.django_db def test_create_order_success(user, workers, beds, plants, mocker): + user.wallet_balance = 100000.00 + user.save() mocker.patch( "user.services.PersonService.update_wallet_balance", return_value=Response(status=status.HTTP_200_OK) ) + mocker.patch( + "garden.services.BedService.rent_beds", + return_value=2 + ) + mocker.patch( + "plants.services.BedPlantService.plant_in_beds", + return_value=Response(status=status.HTTP_201_CREATED) + ) + field = beds[0].field + plant = plants[0] + worker = workers[0] + beds_count = 2 + total_cost = field.price * beds_count + plant.price * beds_count + worker.price 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 + response = OrderService.create_order(user, field, plant, beds_count, action, fertilize=False) + assert response.status_code == status.HTTP_201_CREATED, f"Unexpected status code: {response.status_code}" + 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.field == field + assert order.plant == plant + assert order.total_cost == total_cost @pytest.mark.django_db -def test_create_order_insufficient_funds(user, workers, beds, plants, mocker): +def test_create_order_via_url(api_client, 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"}) + 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_400_BAD_REQUEST - assert response.data['error'] == "Insufficient funds" + mocker.patch( + "garden.services.BedService.rent_beds", + return_value=2 + ) + mocker.patch( + "plants.services.BedPlantService.plant_in_beds", + return_value=Response(status=status.HTTP_201_CREATED) + ) + + + assert beds[0].field is not None, "Поле для грядки должно быть указано" + assert plants[0] is not None, "Растение должно быть указано" + assert workers[0] is not None, "Работник должен быть указан" + + initial_balance = 150000.0 + user.wallet_balance = initial_balance + user.save() + + api_client.force_authenticate(user=user) + + url = '/api/v1/order/' + data = { + "field": beds[0].field.id, + "plant": plants[0].id, + "worker": workers[0].id, + "beds_count": 2, + "comments": "planting", + "fertilize": True + } + + response = api_client.post(url, data) + + print(response.data) + assert response.status_code == status.HTTP_201_CREATED, f"Unexpected status code: {response.status_code}, Response: {response.data}" + + order = Order.objects.get( + field=beds[0].field, + plant=plants[0], + worker=workers[0], + comments="planting" + ) + + assert order.user == user + assert order.worker == workers[0] + assert order.field == beds[0].field + assert order.plant == plants[0] + + total_cost = (beds[0].field.price * data["beds_count"]) + (plants[0].price * data["beds_count"]) + workers[0].price + assert order.total_cost == total_cost + + user.refresh_from_db() + expected_balance = initial_balance - total_cost + assert user.wallet_balance == expected_balance + + +@pytest.mark.django_db +def test_create_order_insufficient_funds(user, fields, plants, workers, mocker): + field = fields[0] + plant = plants[0] + + beds_count = 3 + comments = "Тестовый заказ с недостаточными средствами" + fertilize = True + worker = workers[0] + user.wallet_balance = 50 + user.save() + response = OrderService.create_order(user, field, plant, beds_count, comments, fertilize) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data['error'] == "Insufficient funds" @pytest.mark.django_db @@ -90,9 +169,39 @@ def test_filter_orders_not_completed(orders): @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 +@pytest.mark.django_db +def test_filter_orders_completed_url(api_client, superuser, orders): + api_client.force_authenticate(user=superuser) + 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) + +@pytest.mark.django_db +def test_filter_orders_not_completed_url(api_client, superuser, orders): + api_client.force_authenticate(user=superuser) + url = '/api/v1/order/' + response = api_client.get(url, {'is_completed': 'false'}) + assert response.status_code == 200 + response_data = response.data + assert all(order['completed_at'] is None for order in response_data) + assert len(response_data) == sum(1 for order in orders if order.completed_at is None) + +@pytest.mark.django_db +def test_filter_orders_all_url(api_client, superuser, orders): + api_client.force_authenticate(user=superuser) + url = '/api/v1/order/' + response = api_client.get(url) + assert response.status_code == 200 + response_data = response.data + assert len(response_data) == len(orders) + for order in response_data: + assert order['id'] in [o.id for o in orders] + + diff --git a/django/tamprog/orders/views.py b/django/tamprog/orders/views.py index 920ffe6c..3a7984b9 100644 --- a/django/tamprog/orders/views.py +++ b/django/tamprog/orders/views.py @@ -1,9 +1,14 @@ from rest_framework import viewsets, status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response +from rest_framework.exceptions import ValidationError +from rest_framework.decorators import action from .serializer import * from .models import Order from .services import OrderService +from logging import getLogger + +log = getLogger(__name__) from drf_spectacular.utils import extend_schema, extend_schema_view, \ OpenApiResponse, OpenApiParameter, OpenApiExample @@ -23,8 +28,8 @@ def OrderParameters(required=False): required=required, ), OpenApiParameter( - name="action", - description="Action to perform", + name="comments", + description="Comment to perform", type=str, required=required, ), @@ -57,6 +62,7 @@ class OrderViewSet(viewsets.ModelViewSet): parameters=OrderParameters(required=True), ) def create(self, request, *args, **kwargs): + log.debug(f"Creating order for user with ID={request.user.id}") return super().create(request, *args, **kwargs) @extend_schema( @@ -70,6 +76,7 @@ def create(self, request, *args, **kwargs): }, ) def list(self, request, *args, **kwargs): + log.debug(f"Getting all orders for user with ID={request.user.id}") return super().list(request, *args, **kwargs) @extend_schema( @@ -83,6 +90,7 @@ def list(self, request, *args, **kwargs): }, ) def retrieve(self, request, *args, **kwargs): + log.debug(f"Getting order with ID={kwargs['pk']} for user with ID={request.user.id}") return super().retrieve(request, *args, **kwargs) @extend_schema( @@ -96,6 +104,7 @@ def retrieve(self, request, *args, **kwargs): parameters=OrderParameters(required=True), ) def update(self, request, *args, **kwargs): + log.debug(f"Updating order with ID={kwargs['pk']} for user with ID={request.user.id}") return super().update(request, *args, **kwargs) @extend_schema( @@ -121,7 +130,7 @@ def update(self, request, *args, **kwargs): value={ "bed": 1, "plant": 1, - "action": "water", + "comments": "water", "completed_at": "2022-01-01T00:00:00Z" }, request_only=True, @@ -129,6 +138,7 @@ def update(self, request, *args, **kwargs): ] ) def partial_update(self, request, *args, **kwargs): + log.debug(f"Partially updating order with ID={kwargs['pk']} for user with ID={request.user.id}") return super().partial_update(request, *args, **kwargs) @extend_schema( @@ -141,22 +151,53 @@ def partial_update(self, request, *args, **kwargs): }, ) def destroy(self, request, *args, **kwargs): + log.debug(f"Deleting order with ID={kwargs['pk']} for user with ID={request.user.id}") return super().destroy(request, *args, **kwargs) def perform_create(self, serializer): user = self.request.user - bed = serializer.validated_data['bed'] + field = serializer.validated_data['field'] plant = serializer.validated_data['plant'] - action = serializer.validated_data['action'] - return OrderService.create_order(user, bed, plant, action) + beds_count = serializer.validated_data['beds_count'] + comments = serializer.validated_data['comments'] + fertilize = serializer.validated_data.get('fertilize', False) + + log.debug(f"Creating order for user with ID={user.id}") + response = OrderService.create_order(user, field, plant, beds_count, comments, fertilize) + + if isinstance(response, Response): + if response.status_code == status.HTTP_201_CREATED: + return response + else: + raise ValidationError(response.data.get("error", "Unknown error")) def perform_update(self, serializer): order = serializer.save() if order.completed_at: + log.debug(f"Completing order with ID={order.id}") OrderService.complete_order(order) + @extend_schema( + summary='List all orders for current user', + description='List all orders for current user', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description='Successful response with list of orders', + response=OrderSerializer(many=True), + ) + }, + ) + @action(detail=False, methods=['get']) + def my_orders(self, request): + orders = OrderService.get_orders(request.user) + serializer = self.get_serializer(orders, many=True) + log.debug(f"Returning list of orders by user: {serializer.data}") + return Response(serializer.data) + def get_queryset(self): is_completed = self.request.query_params.get('is_completed', None) if is_completed is not None: + log.debug(f"Filtering orders by is_completed={is_completed}") return OrderService.filter_orders(is_completed=is_completed.lower() == 'true') + log.debug("Getting all orders") return Order.objects.all() diff --git a/django/tamprog/plants/apps.py b/django/tamprog/plants/apps.py index 90a2903d..c1c2ad6d 100644 --- a/django/tamprog/plants/apps.py +++ b/django/tamprog/plants/apps.py @@ -4,3 +4,6 @@ class PlantsConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'plants' + + def ready(self): + import plants.signals \ No newline at end of file diff --git a/django/tamprog/plants/migrations/0008_bedplant_is_harvested.py b/django/tamprog/plants/migrations/0008_bedplant_is_harvested.py new file mode 100644 index 00000000..b2538b68 --- /dev/null +++ b/django/tamprog/plants/migrations/0008_bedplant_is_harvested.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.16 on 2024-11-21 11:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('plants', '0007_alter_plant_price'), + ] + + operations = [ + migrations.AddField( + model_name='bedplant', + name='is_harvested', + field=models.BooleanField(default=False), + ), + ] diff --git a/django/tamprog/plants/migrations/0009_alter_plant_price.py b/django/tamprog/plants/migrations/0009_alter_plant_price.py new file mode 100644 index 00000000..2f4e3fbb --- /dev/null +++ b/django/tamprog/plants/migrations/0009_alter_plant_price.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.16 on 2024-11-22 10:24 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('plants', '0008_bedplant_is_harvested'), + ] + + operations = [ + migrations.AlterField( + model_name='plant', + name='price', + field=models.FloatField(default=0.0, validators=[django.core.validators.MinValueValidator(0)]), + ), + ] diff --git a/django/tamprog/plants/migrations/0010_bedplant_growth_percentage.py b/django/tamprog/plants/migrations/0010_bedplant_growth_percentage.py new file mode 100644 index 00000000..44661c71 --- /dev/null +++ b/django/tamprog/plants/migrations/0010_bedplant_growth_percentage.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.16 on 2024-11-28 07:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('plants', '0009_alter_plant_price'), + ] + + operations = [ + migrations.AddField( + model_name='bedplant', + name='growth_percentage', + field=models.IntegerField(default=0), + ), + ] diff --git a/django/tamprog/plants/migrations/0011_plant_url.py b/django/tamprog/plants/migrations/0011_plant_url.py new file mode 100644 index 00000000..33e3efc9 --- /dev/null +++ b/django/tamprog/plants/migrations/0011_plant_url.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.16 on 2024-11-28 15:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('plants', '0010_bedplant_growth_percentage'), + ] + + operations = [ + migrations.AddField( + model_name='plant', + name='url', + field=models.TextField(default=0), + preserve_default=False, + ), + ] diff --git a/django/tamprog/plants/migrations/0012_bedplant_remaining_growth_time_and_more.py b/django/tamprog/plants/migrations/0012_bedplant_remaining_growth_time_and_more.py new file mode 100644 index 00000000..ba6b8480 --- /dev/null +++ b/django/tamprog/plants/migrations/0012_bedplant_remaining_growth_time_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.16 on 2024-11-28 16:01 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('plants', '0011_plant_url'), + ] + + operations = [ + migrations.AddField( + model_name='bedplant', + name='remaining_growth_time', + field=models.IntegerField(default=1, validators=[django.core.validators.MinValueValidator(0)]), + ), + migrations.AlterField( + model_name='bedplant', + name='growth_time', + field=models.IntegerField(default=1, validators=[django.core.validators.MinValueValidator(0)]), + ), + migrations.AlterField( + model_name='plant', + name='growth_time', + field=models.IntegerField(default=0, validators=[django.core.validators.MinValueValidator(0)]), + ), + ] diff --git a/django/tamprog/plants/migrations/0013_remove_bedplant_remaining_growth_time.py b/django/tamprog/plants/migrations/0013_remove_bedplant_remaining_growth_time.py new file mode 100644 index 00000000..74602402 --- /dev/null +++ b/django/tamprog/plants/migrations/0013_remove_bedplant_remaining_growth_time.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.16 on 2024-11-28 16:11 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('plants', '0012_bedplant_remaining_growth_time_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='bedplant', + name='remaining_growth_time', + ), + ] diff --git a/django/tamprog/plants/models.py b/django/tamprog/plants/models.py index c0130461..1e3273dc 100644 --- a/django/tamprog/plants/models.py +++ b/django/tamprog/plants/models.py @@ -1,15 +1,36 @@ from django.db import models from garden.models import Bed +from django.utils import timezone +from django.core.validators import MinValueValidator class Plant(models.Model): name = models.CharField(max_length=100) - growth_time = models.IntegerField(default=0) - price = models.FloatField(default=0.00) + growth_time = models.IntegerField(default=0, validators=[MinValueValidator(0)]) + price = models.FloatField(default=0.00, validators=[MinValueValidator(0)]) description = models.TextField() + url = 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 + growth_time = models.IntegerField(default=1, validators=[MinValueValidator(0)]) + is_harvested = models.BooleanField(default=False) + growth_percentage = models.IntegerField(default=0) + + @property + def remaining_growth_time(self): + elapsed_time = (timezone.now() - self.planted_at).days + remaining_time = self.growth_time - elapsed_time + return max(0, remaining_time) + + @property + def is_grown(self): + return self.remaining_growth_time == 0 + + @property + def growth_percentage_calculated(self): + elapsed_time = (timezone.now() - self.planted_at).days + percentage = (elapsed_time / self.growth_time) * 100 + return min(max(percentage, 0), 100) \ No newline at end of file diff --git a/django/tamprog/plants/permissions.py b/django/tamprog/plants/permissions.py index 1f6ac65a..a506677d 100644 --- a/django/tamprog/plants/permissions.py +++ b/django/tamprog/plants/permissions.py @@ -1,28 +1,41 @@ from rest_framework.permissions import BasePermission import re +from logging import getLogger + +log = getLogger(__name__) 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): + log.debug(f"User {username} is an agronomist") return True if request.user.is_superuser: + log.debug(f"User {username} is a superuser") return True + log.debug(f"User {username} is not an agronomist") return request.method in ['GET', 'HEAD', 'OPTIONS'] + log.debug("User is not authenticated") return False class AgronomistOrRenterPermission(BasePermission): def has_permission(self, request, view): if not request.user or not request.user.is_authenticated: + log.debug("User is not authenticated") return False if re.match(r'^agronom\d+$', request.user.username): + log.debug(f"User {request.user.username} is an agronomist") return True if request.user.is_superuser: + log.debug(f"User {request.user.username} is a superuser") return True + log.debug(f"User {request.user.username} is not an agronomist") return request.method in ['GET', 'POST'] def has_object_permission(self, request, view, obj): if re.match(r'^agronom\d+$', request.user.username): + log.debug(f"User {request.user.username} is an agronomist") return True + log.debug(f"User {request.user.username} is not an agronomist") return obj.bed.rented_by == request.user diff --git a/django/tamprog/plants/serializers.py b/django/tamprog/plants/serializers.py index b99da4ea..30cd7df1 100644 --- a/django/tamprog/plants/serializers.py +++ b/django/tamprog/plants/serializers.py @@ -9,4 +9,5 @@ class Meta: class BedPlantSerializer(serializers.ModelSerializer): class Meta: model = BedPlant - fields = '__all__' + fields = ['id', 'bed', 'plant', 'planted_at', 'fertilizer_applied', 'growth_time', 'growth_percentage', 'remaining_growth_time', 'is_harvested'] + read_only_fields = ['growth_time', 'is_harvested', 'planted_at', 'fertilizer_applied', 'growth_percentage', 'remaining_growth_time', 'is_grown'] diff --git a/django/tamprog/plants/services.py b/django/tamprog/plants/services.py index 8b3504ce..823f9700 100644 --- a/django/tamprog/plants/services.py +++ b/django/tamprog/plants/services.py @@ -1,69 +1,172 @@ +from garden.models import Bed +from orders.models import Order +from django.utils.timezone import now +from django.db import transaction +from datetime import timedelta +from user.services import PersonService from .models import BedPlant -from fertilizer.models import BedPlantFertilizer +from fertilizer.models import BedPlantFertilizer, Fertilizer from .queries import GetPlantsSortedByPrice from fuzzywuzzy import fuzz from rest_framework.response import Response from rest_framework import status from .models import Plant +from logging import getLogger + +log = getLogger(__name__) class PlantService: @staticmethod def get_sorted_plants(ascending: bool = True): query = GetPlantsSortedByPrice(ascending) + log.debug(f"Getting plants sorted by price in {'ascending' if ascending else 'descending'} order") return query.execute() - + @staticmethod def fuzzy_search(query, threshold=70): results = [] plants = Plant.objects.all() + query_lower = query.lower() + + exact_matches = [] + sequence_matches = [] + partial_matches = [] + for plant in plants: - similarity = fuzz.ratio(query.lower(), plant.name.lower()) - if similarity >= threshold: - results.append(plant) - return results + name_lower = plant.name.lower() + + if name_lower == query_lower: + exact_matches.append((100, plant)) + continue + + if query_lower in name_lower: + sequence_matches.append((len(query_lower), plant)) + continue + + if exact_matches or sequence_matches: + exact_matches.sort(key=lambda x: (-x[0], x[1].name)) + sequence_matches.sort(key=lambda x: (-x[0], x[1].name)) + + sorted_results = ( + [plant for _, plant in exact_matches] + + [plant for _, plant in sequence_matches] + ) + else: + for plant in plants: + name_lower = plant.name.lower() + similarity = fuzz.partial_ratio(query_lower, name_lower) + if similarity >= threshold: + partial_matches.append((similarity, plant)) + + partial_matches.sort(key=lambda x: (-x[0], x[1].name)) + + sorted_results = [plant for _, plant in partial_matches] + + unique_results = [] + added_ids = set() + for plant in sorted_results: + if plant.id not in added_ids: + unique_results.append(plant) + added_ids.add(plant.id) + + log.debug(f"Found {len(unique_results)} plants for query: {query}") + return unique_results @staticmethod def get_suggestions(query): + log.debug(f"Getting suggestions for query: {query}") return Plant.objects.filter(name__istartswith=query).values_list('name', flat=True).order_by('name')[:10] class BedPlantService: + @staticmethod + def plant_in_beds(field, plant, beds_count, fertilizer=None): + rented_beds = Bed.objects.filter(field=field, is_rented=True).order_by('id')[:beds_count] + responses = [] + with transaction.atomic(): + for bed in rented_beds: + bed_plant = BedPlant.objects.create(bed=bed, plant=plant, growth_time=plant.growth_time) + log.debug(f"Plant {plant.name} planted in bed with ID={bed.id}") + if fertilizer: + if bed.rented_by: + BedPlantService.fertilize_plant(bed_plant, fertilizer, bed.rented_by) + new_growth_time = max(0, bed_plant.growth_time - fertilizer.boost) + new_completion_time = bed_plant.planted_at + timedelta(days=new_growth_time) + + try: + order = Order.objects.filter(user=bed.rented_by).latest('created_at') + order.completed_at = new_completion_time + order.save(update_fields=["completed_at"]) + except Order.DoesNotExist: + log.warning(f"No order found for user {bed.rented_by.id}") + else: + log.warning(f"Bed with ID={bed.id} has no user associated with it, skipping fertilization.") + + responses.append({"bed_id": bed.id, "status": "success"}) + + log.debug(f"Plant {plant.name} planted in {beds_count} beds.") + return Response( + {"message": "Plants successfully planted.", "details": responses}, + status=status.HTTP_201_CREATED + ) @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 + def check_plant(bed_plant): + if bed_plant.is_grown: + return True + return False + @staticmethod def harvest_plant(bed_plant): - if not bed_plant: + if not bed_plant.is_grown: + log.warning("Plant not found") return Response( - {'error': 'Plant not found'}, + {'error': 'Plant is not fully grown yet'}, status=status.HTTP_400_BAD_REQUEST ) - - bed_plant.delete() + #bed_plant.is_harvested = True + #bed_plant.save(update_fields=['is_harvested']) + log.info("Plant harvested") return Response( - {'status': 'plant dug up'}, + {'status': 'Plant harvested'}, status=status.HTTP_200_OK ) - @staticmethod - def fertilize_plant(bed_plant, fertilizer): + def fertilize_plant(bed_plant, fertilizer, user): if not fertilizer: + log.warning("No suitable fertilizer found") return Response( {'error': 'No suitable fertilizer found'}, status=status.HTTP_400_BAD_REQUEST ) + + if bed_plant.fertilizer_applied: + return Response( + {'error': 'Fertilizer can only be applied once to a plant.'}, + status=status.HTTP_400_BAD_REQUEST + ) + + required_min_growth_time = fertilizer.boost + 5 + if bed_plant.growth_time <= required_min_growth_time: + return Response( + {'error': "The plant's growth time must be at least 5 days longer than the fertilizer's boost time." }, + status=status.HTTP_400_BAD_REQUEST + ) + + balance_response = PersonService.update_wallet_balance(user, fertilizer.price) + if balance_response.status_code != status.HTTP_200_OK: + return balance_response + 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() + log.info("Plant fertilized") return Response( {'status': 'plant fertilized'}, status=status.HTTP_200_OK @@ -72,19 +175,23 @@ def fertilize_plant(bed_plant, fertilizer): @staticmethod def water_plant(bed_plant): + log.error("Watering plants is not implemented yet") pass @staticmethod def dig_up_plant(bed_plant): - if not bed_plant: + if not bed_plant.is_harvested: + log.warning("Plant not found") return Response( - {'error': 'Plant not found'}, + {'error': 'Plant must be harvested before digging up'}, status=status.HTTP_400_BAD_REQUEST ) - - bed_plant.delete() + bed_plant.is_harvested = True + bed_plant.save(update_fields=['is_harvested']) + #bed_plant.delete() + log.info("Plant dug up") return Response( - {'status': 'plant dug up'}, + {'status': 'Bed is now empty'}, status=status.HTTP_200_OK ) @@ -92,5 +199,7 @@ def dig_up_plant(bed_plant): @staticmethod def filter_bed_plants(fertilizer_applied=None): if fertilizer_applied is not None: + log.debug(f"Filtering bed plants by fertilizer_applied={fertilizer_applied}") return BedPlant.objects.filter(fertilizer_applied=fertilizer_applied) - return BedPlant.objects.all() + log.debug("Getting all bed plants") + return BedPlant.objects.all() \ No newline at end of file diff --git a/django/tamprog/plants/signals.py b/django/tamprog/plants/signals.py new file mode 100644 index 00000000..1c91a64b --- /dev/null +++ b/django/tamprog/plants/signals.py @@ -0,0 +1,24 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.utils import timezone +from garden.services import BedService +from .models import BedPlant +from datetime import timedelta + +@receiver(post_save, sender=BedPlant) +def check_growth_and_harvest_time(sender, instance, created, **kwargs): + if not created: + growth_end_date = instance.planted_at + timedelta(days=instance.growth_time) + if growth_end_date <= timezone.now() and not instance.is_grown: + instance.is_grown = True + instance.save() + print(f"Растение на грядке {instance.bed.id} завершило рост.") + +@receiver(post_save, sender=BedPlant) +def update_plant_growth(sender, instance, created, **kwargs): + if instance.is_grown and not instance.is_harvested: + instance.is_harvested = True + instance.growth_percentage = 100 + instance.save() + BedService.release_beds(instance.bed.field, 1) + print(f"Растение на грядке {instance.bed.id} собрано и грядка освобождена.") \ No newline at end of file diff --git a/django/tamprog/plants/tests.py b/django/tamprog/plants/tests.py index bc5e7dfa..487d9935 100644 --- a/django/tamprog/plants/tests.py +++ b/django/tamprog/plants/tests.py @@ -4,6 +4,7 @@ from plants.models import BedPlant from django.urls import reverse from fuzzywuzzy import fuzz +from django.utils import timezone @pytest.mark.django_db def test_sort_bed_plants(api_client, user, plants): @@ -16,63 +17,11 @@ def test_sort_bed_plants(api_client, user, plants): 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): +def test_filter_bed_plants(bed_plants, fertilizers, api_client, user): for bed_plant in bed_plants: - BedPlantService.harvest_plant(bed_plant) - assert not BedPlant.objects.filter(id=bed_plant.id).exists() + BedPlantService.fertilize_plant(bed_plant, fertilizers[0], user) - - -@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) @@ -84,6 +33,32 @@ def test_filter_bed_plants(bed_plants, fertilizers): assert bed_plant not in fertilized_plants assert bed_plant in non_fertilized_plants +@pytest.mark.django_db +def test_filter_bed_plants_via_url(api_client, bed_plants, fertilizers, user): + api_client.force_authenticate(user=user) + for bed_plant in bed_plants: + BedPlantService.fertilize_plant(bed_plant, fertilizers[0], user) + url = '/api/v1/bedplant/' + response = api_client.get(url, {'fertilizer_applied': 'true'}) + assert response.status_code == 200, f"Unexpected status code: {response.status_code}" + fertilized_data = response.json() + for bed_plant in bed_plants: + if bed_plant.fertilizer_applied: + assert any(item['id'] == bed_plant.id for item in fertilized_data) + else: + assert all(item['id'] != bed_plant.id for item in fertilized_data) + response = api_client.get(url, {'fertilizer_applied': 'false'}) + assert response.status_code == 200, f"Unexpected status code: {response.status_code}" + non_fertilized_data = response.json() + for bed_plant in bed_plants: + if not bed_plant.fertilizer_applied: + assert any(item['id'] == bed_plant.id for item in non_fertilized_data) + else: + assert all(item['id'] != bed_plant.id for item in non_fertilized_data) + + +from fuzzywuzzy import fuzz + @pytest.mark.django_db def test_fuzzy_search(api_client, plants, user): @@ -91,14 +66,21 @@ def test_fuzzy_search(api_client, plants, 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 + if fuzz.partial_ratio(plant_name.lower(), plant.name.lower()) >= 75 ] 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) + print(f"Expected matches: {expected_matches}") + print(f"Response names: {response_names}") + assert all(name in response_names for name in expected_matches), \ + f"Not all expected plants were found in the response. Missing: {set(expected_matches) - set(response_names)}" + assert all(name in expected_matches for name in response_names), \ + f"Unexpected plants found in the response: {set(response_names) - set(expected_matches)}" + assert len(response_names) == len(expected_matches), \ + f"Mismatch in count: expected {len(expected_matches)}, got {len(response_names)}" @pytest.mark.django_db @@ -112,3 +94,60 @@ def test_get_suggestions(api_client, plants, user): assert all(suggestion_query in suggestion for suggestion in response.data) +@pytest.mark.django_db +def test_growth_time_adjustment_after_fertilizer(api_client, bed_plants, fertilizers, user): + api_client.force_authenticate(user=user) + for bed_plant in bed_plants: + if bed_plant.fertilizer_applied: + continue + required_min_growth_time = fertilizers[0].boost + 5 + if bed_plant.growth_time <= required_min_growth_time: + bed_plant.growth_time = required_min_growth_time + 1 + bed_plant.save() + initial_growth_time = bed_plant.growth_time + response = BedPlantService.fertilize_plant(bed_plant, fertilizers[0], user) + assert response.status_code == 200 + bed_plant.refresh_from_db() + if bed_plant.fertilizer_applied: + assert bed_plant.growth_time < initial_growth_time + else: + assert bed_plant.growth_time == initial_growth_time + + +@pytest.mark.django_db +def test_growth_time_adjustment_after_fertilizer_api(api_client, bed_plants, fertilizers, superuser): + api_client.force_authenticate(user=superuser) + for bed_plant in bed_plants: + if bed_plant.fertilizer_applied: + continue + required_min_growth_time = fertilizers[0].boost + 5 + if bed_plant.growth_time <= required_min_growth_time: + bed_plant.growth_time = required_min_growth_time + 1 + bed_plant.save() + initial_growth_time = bed_plant.growth_time + matching_fertilizers = [fertilizer for fertilizer in fertilizers if bed_plant.plant.name in fertilizer.compound] + if not matching_fertilizers: + continue + fertilizer = matching_fertilizers[0] + url = f'/api/v1/bedplant/{bed_plant.id}/fertilize/' + payload = {'fertilizer_id': fertilizer.id} + assert fertilizer is not None, "Удобрение с указанным ID не найдено" + assert bed_plant.plant.name in fertilizer.compound, "Удобрение не соответствует растению" + response = api_client.post(url, payload, format='json') + assert response.status_code == 200 + bed_plant.refresh_from_db() + if bed_plant.fertilizer_applied: + assert bed_plant.growth_time < initial_growth_time + else: + assert bed_plant.growth_time == initial_growth_time + + +@pytest.mark.django_db +def test_plant_without_fertilizer_growth_time(api_client, bed_plants, user): + api_client.force_authenticate(user=user) + for bed_plant in bed_plants: + if not bed_plant.fertilizer_applied: + initial_growth_time = bed_plant.growth_time + bed_plant.refresh_from_db() + assert bed_plant.growth_time == initial_growth_time + diff --git a/django/tamprog/plants/views.py b/django/tamprog/plants/views.py index 7b17cf48..43df0a9c 100644 --- a/django/tamprog/plants/views.py +++ b/django/tamprog/plants/views.py @@ -3,10 +3,14 @@ from rest_framework.decorators import action from rest_framework.response import Response from .permissions import * +from rest_framework.exceptions import ValidationError from .models import Plant, BedPlant from .serializers import PlantSerializer, BedPlantSerializer from .services import * from fertilizer.models import Fertilizer +from logging import getLogger + +log = getLogger(__name__) from drf_spectacular.utils import extend_schema, extend_schema_view, \ OpenApiResponse, OpenApiParameter, OpenApiExample @@ -37,6 +41,12 @@ def PlantParameters(required=False): description='Plant description', required=required, ), + OpenApiParameter( + name='url', + type=str, + description='image', + required=required, + ), ] @extend_schema(tags=['Plant']) @@ -58,25 +68,80 @@ 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) + log.debug('Listing all plants') return Response(serializer.data) - - @action(detail=False, methods=['get']) + + @extend_schema( + summary='Search for plants by query', + description='Search for plants using a query string. The search is case-insensitive and can match any part of the plant name or description.', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description='List of plants matching the search query', + response=PlantSerializer(many=True) + ), + status.HTTP_400_BAD_REQUEST: OpenApiResponse( + description='Invalid query if the search string is empty or incorrect', + ), + }, + parameters=[ + OpenApiParameter( + name='q', + type=str, + description='Search query (plant name or description)', + required=True, + ), + ] + ) + @action(detail=False, methods=['get']) def search(self, request): query = request.query_params.get('q', '').lower() if not query: - return Response([]) + log.warning('Search query is required') + return Response({'error': 'Search query is required'}, status=status.HTTP_400_BAD_REQUEST) plants = PlantService.fuzzy_search(query) + if not plants: + log.info('No plants found matching the query') + return Response({'message': 'No plants found matching the query'}, status=status.HTTP_200_OK) + serializer = self.get_serializer(plants, many=True) + log.debug(f'Found {len(plants)} plants for query: {query}') return Response(serializer.data) + @extend_schema( + summary='Get search suggestions for plants', + description='Get plant suggestions based on a query string. This endpoint returns a list of suggestions for autocomplete or partial search.', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description='List of plant suggestions based on the query', + response=str + ), + status.HTTP_400_BAD_REQUEST: OpenApiResponse( + description='Invalid query if the search string is empty or incorrect', + ) + }, + parameters=[ + OpenApiParameter( + name='q', + type=str, + description='Query string for suggestions (partial plant name)', + required=True, + ), + ] + ) @action(detail=False, methods=['get']) def suggestions(self, request): query = request.query_params.get('q', '').lower() if not query: - return Response([]) + log.warning('Query string is required') + return Response({'error': 'Query string is required'}, status=status.HTTP_400_BAD_REQUEST) suggestions = PlantService.get_suggestions(query) + if not suggestions: + log.info('No suggestions found for the given query') + return Response({'message': 'No suggestions found for the given query'}, status=status.HTTP_200_OK) + + log.debug(f'Found {len(suggestions)} suggestions for query: {query}') return Response(suggestions) @extend_schema( @@ -92,6 +157,7 @@ def suggestions(self, request): parameters=PlantParameters(required=True), ) def create(self, request, *args, **kwargs): + log.debug('Creating a new plant') return super().create(request, *args, **kwargs) @extend_schema( @@ -104,6 +170,7 @@ def create(self, request, *args, **kwargs): }, ) def retrieve(self, request, *args, **kwargs): + log.debug('Retrieving a plant by ID') return super().retrieve(request, *args, **kwargs) @extend_schema( @@ -116,6 +183,7 @@ def retrieve(self, request, *args, **kwargs): parameters=PlantParameters(), ) def partial_update(self, request, *args, **kwargs): + log.debug('Partially updating a plant') return super().partial_update(request, *args, **kwargs) @extend_schema( @@ -128,6 +196,7 @@ def partial_update(self, request, *args, **kwargs): parameters=PlantParameters(required=True), ) def update(self, request, *args, **kwargs): + log.debug('Updating a plant') return super().update(request, *args, **kwargs) @extend_schema( @@ -139,6 +208,7 @@ def update(self, request, *args, **kwargs): }, ) def destroy(self, request, *args, **kwargs): + log.debug('Deleting a plant') return super().destroy(request, *args, **kwargs) def BedPlantParameters(required=False): @@ -177,6 +247,7 @@ class BedPlantViewSet(viewsets.ModelViewSet): @extend_schema(exclude=True) def destroy(self, request, *args, **kwargs): + log.debug('Deleting a bed plant') return super().destroy(request, *args, **kwargs) @extend_schema( @@ -192,6 +263,7 @@ def destroy(self, request, *args, **kwargs): parameters=BedPlantParameters(required=True), ) def create(self, request, *args, **kwargs): + log.debug('Planting a new plant in a bed') return super().create(request, *args, **kwargs) @extend_schema( @@ -204,6 +276,7 @@ def create(self, request, *args, **kwargs): }, ) def list(self, request, *args, **kwargs): + log.debug('Listing all planted plants') return super().list(request, *args, **kwargs) @extend_schema( @@ -216,6 +289,7 @@ def list(self, request, *args, **kwargs): }, ) def retrieve(self, request, *args, **kwargs): + log.debug('Retrieving a planted plant by ID') return super().retrieve(request, *args, **kwargs) @extend_schema( @@ -228,6 +302,7 @@ def retrieve(self, request, *args, **kwargs): parameters=BedPlantParameters(required=True), ) def update(self, request, *args, **kwargs): + log.debug('Updating a planted plant') return super().update(request, *args, **kwargs) @extend_schema( @@ -240,12 +315,41 @@ def update(self, request, *args, **kwargs): parameters=BedPlantParameters(), ) def partial_update(self, request, *args, **kwargs): + log.debug('Partially updating a planted plant') 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) + fertilizer = serializer.validated_data.get['fertilizer', None] + beds_count = serializer.validated_data['beds_count', 1] + response = BedPlantService.plant_in_beds(bed.field, plant, beds_count, fertilizer=fertilizer) + log.info(f"Plant {plant.name} planted in bed with ID={bed.id}") + if isinstance(response, Response) and response.status_code != status.HTTP_201_CREATED: + raise ValidationError(response.data["error"]) + serializer.save() + + @extend_schema( + summary='Сhecking growth status', + description='Check the plant growth status', + responses={ + status.HTTP_200_OK: OpenApiResponse( + description='Plant harvested', + response=BedPlantSerializer(many=True) + ), + status.HTTP_400_BAD_REQUEST: OpenApiResponse( + description='Plant not found', + ), + }, + ) + @action(detail=True, methods=['get']) + def check_growth(self, request, pk=None): + bed_plant = self.get_object() + result = BedPlantService.check_plant(bed_plant) + if result: + return result + return Response({'status': f'Remaining growth time: {bed_plant.remaining_growth_time} days'}) + @extend_schema( summary='Harvest a plant', @@ -263,6 +367,7 @@ def perform_create(self, serializer): def harvest(self, request, pk=None): bed_plant = self.get_object() BedPlantService.harvest_plant(bed_plant) + log.info("Plant harvested") return Response({'status': 'plant harvested'}) @@ -280,11 +385,33 @@ def harvest(self, request, pk=None): ] ), status.HTTP_400_BAD_REQUEST: OpenApiResponse( - description='No suitable fertilizer found', + description='Error with the fertilization process', examples=[ OpenApiExample( name='No suitable fertilizer', value={'error': 'No suitable fertilizer found'}, + ), + OpenApiExample( + name='Fertilizer already applied', + value={'error': 'Fertilizer can only be applied once to a plant.'}, + ), + OpenApiExample( + name='Insufficient funds', + value={'error': 'Insufficient funds in the wallet. Please top up your balance.'}, + ), + OpenApiExample( + name='Growth time is too short', + value={ + 'error': "The plant's growth time must be at least 5 days longer than the fertilizer's boost time."}, + ), + ] + ), + status.HTTP_500_INTERNAL_SERVER_ERROR: OpenApiResponse( + description='Unexpected error occurred', + examples=[ + OpenApiExample( + name='Unexpected error occurred', + value={'error': 'Unexpected error occurred'}, ) ] ), @@ -292,10 +419,19 @@ def harvest(self, request, pk=None): ) @action(detail=True, methods=['post']) def fertilize(self, request, pk=None): + user = self.request.user 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) + log.debug(f"Fertilizing plant {plant_name} with {fertilizer.name}") + response = BedPlantService.fertilize_plant(bed_plant, fertilizer, user) + + if not isinstance(response, Response): + return Response( + {'error': 'Unexpected error occurred'}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + return response @extend_schema( summary='Water a plant', @@ -316,6 +452,7 @@ def fertilize(self, request, pk=None): def water(self, request, pk=None): bed_plant = self.get_object() BedPlantService.water_plant(bed_plant) + log.debug("Watering plants is not implemented yet") return Response({'status': 'plant watered'}) @extend_schema( @@ -345,11 +482,14 @@ def water(self, request, pk=None): @action(detail=True, methods=['post']) def dig_up(self, request, pk=None): bed_plant = self.get_object() + log.debug("Digging up a plant") 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: + log.debug(f"Filtering bed plants by fertilizer_applied={fertilizer_applied}") return BedPlantService.filter_bed_plants(fertilizer_applied=fertilizer_applied.lower() == 'true') + log.debug("Listing all bed plants") return BedPlant.objects.all() \ No newline at end of file diff --git a/django/tamprog/tamprog/settings.py b/django/tamprog/tamprog/settings.py index 8412a494..91b9e12c 100644 --- a/django/tamprog/tamprog/settings.py +++ b/django/tamprog/tamprog/settings.py @@ -55,6 +55,7 @@ 'fertilizer', 'drf_spectacular', 'rest_framework_simplejwt.token_blacklist', + 'django_db_logger', ] MIDDLEWARE = [ @@ -258,7 +259,27 @@ 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}' + +# Redis settings +REDIS_HOST = os.getenv('REDIS_HOST', 'localhost') \ + if IS_IN_CONTAINER \ + else 'localhost' +REDIS_PORT = os.getenv('REDIS_PORT', '6379') +REDIS_DB = os.getenv('REDIS_DB', '0') +REDIS_USER = os.getenv('REDIS_USER', 'default') +REDIS_PASSWORD = os.getenv('REDIS_PASS', 'admin') + +# Redis connection pool settings +REDIS_CONNECTION_POOL = { + 'max_connections': 20, + 'retry_on_timeout': True, + 'socket_timeout': 5, + 'socket_connect_timeout': 5, + 'retry': 3, + 'retry_delay': 1, # Delay between retries in seconds + 'connection_class': 'redis.connection.DefaultConnection', + 'retry_on_error': [ConnectionError, TimeoutError] +} CELERY_BROKER_CONNECTION_RETRY = bool(os.getenv( 'CELERY_BROKER_CONNECTION_RETRY', 'True').lower() == 'true') @@ -268,13 +289,54 @@ 'CELERY_BROKER_CONNECTION_MAX_RETRIES', '10')) CELERY_BROKER_HEARTBEAT = int(os.getenv( 'CELERY_BROKER_HEARTBEAT', '60')) +CELERY_BROKER_CONNECTION_TIMEOUT = 10 +CELERY_BROKER_POOL_LIMIT = 10 +CELERY_BROKER_TRANSPORT_OPTIONS = { + 'visibility_timeout': 7200, + 'max_retries': 5, + 'interval_start': 0, + 'interval_step': 0.2, + 'interval_max': 0.5, + 'connect_timeout': 10, + 'read_timeout': 10, + 'write_timeout': 10, +} + +CELERY_BROKER_URL = f'amqp://{RABBITMQ_USER}:{RABBITMQ_PASSWORD}@{RABBITMQ_HOST}:{RABBITMQ_PORT}/{RABBITMQ_VHOST}?heartbeat={CELERY_BROKER_HEARTBEAT}' + 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://') +# Celery settings for proper result handling +CELERY_ACCEPT_CONTENT = ['json'] +CELERY_TASK_SERIALIZER = 'json' +CELERY_RESULT_SERIALIZER = 'json' + +# How long to keep task results (in seconds, 1 day = 86400) +CELERY_RESULT_EXPIRES = 86400 + +# CELERY_RESULT_BACKEND = os.getenv( + # 'CELERY_RESULT_BACKEND', 'rpc://') + +REDIS_URL = f'redis://{REDIS_USER}:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}' + +CELERY_RESULT_BACKEND = REDIS_URL + +# Redis-specific Celery settings +CELERY_REDIS_MAX_CONNECTIONS = 20 +CELERY_REDIS_SOCKET_TIMEOUT = 15 +CELERY_REDIS_SOCKET_CONNECT_TIMEOUT = 15 +CELERY_REDIS_RETRY_ON_TIMEOUT = True + +# Store results even if task is ignored +CELERY_TASK_IGNORE_RESULT = False + +# Enable extended task result attributes +CELERY_TASK_TRACK_STARTED = True +CELERY_TASK_TIME_LIMIT = 30 * 60 # 30 minutes + # Acknowledge tasks after they are done [True/False] CELERY_TASK_ACKS_LATE = bool(os.getenv( 'CELERY_TASK_ACKS_LATE', 'True').lower() == 'true') @@ -309,5 +371,104 @@ os.getenv('CELERY_WORKER_TASK_LOG_FORMAT', '%(asctime)s - %(message)s') ) +# Optional: Redis cache settings +CACHES = { + 'default': { + 'BACKEND': 'django_redis.cache.RedisCache', + 'LOCATION': REDIS_URL, + 'OPTIONS': { + 'CLIENT_CLASS': 'django_redis.client.DefaultClient', + 'PASSWORD': REDIS_PASSWORD, + 'CONNECTION_POOL_CLASS_KWARGS': REDIS_CONNECTION_POOL, + 'RETRY_ON_TIMEOUT': True, + 'MAX_CONNECTIONS': 50, + 'SOCKET_CONNECT_TIMEOUT': 30, + 'SOCKET_TIMEOUT': 30, + 'RETRY_ON_CONNECTION_FAILURE': True, + 'CONNECTION_POOL_CLASS': 'redis.connection.ConnectionPool', + 'PARSER_CLASS': 'redis.connection.HiredisParser', + }, + 'KEY_PREFIX': 'tamprog', + 'TIMEOUT': 300, # 5 minutes default timeout + } +} + 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 +DJANGO_SUPER_PASSWORD = os.environ.get('DJANGO_SUPER_PASSWORD', 'admin') + +DJANGO_DB_LOGGER_ENABLE_FORMATTER = True + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'verbose': { + 'format': '%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s' + }, + 'simple': { + 'format': '%(levelname)s %(asctime)s %(message)s' + }, + }, + 'handlers': { + 'db_log': { + 'level': 'DEBUG', + 'class': 'django_db_logger.db_log_handler.DatabaseLogHandler', + 'formatter': 'verbose', + }, + 'console': { + 'class': 'logging.StreamHandler', + 'formatter': 'verbose', + } + }, + 'loggers': { + 'tamprog': { + 'handlers': ['db_log', 'console'], + 'level': 'DEBUG' + }, + 'fertilizer': { + 'handlers': ['db_log', 'console'], + 'level': 'DEBUG' + }, + 'garden': { + 'handlers': ['db_log', 'console'], + 'level': 'DEBUG' + }, + 'orders': { + 'handlers': ['db_log', 'console'], + 'level': 'DEBUG' + }, + 'plans': { + 'handlers': ['db_log', 'console'], + 'level': 'DEBUG' + }, + 'user': { + 'handlers': ['db_log', 'console'], + 'level': 'DEBUG' + }, + 'django.request': { # logging 500 errors to database + 'handlers': ['db_log', 'console'], + 'level': 'DEBUG', + 'propagate': False, + }, + 'amqp': { + 'handlers': ['db_log', 'console'], + 'level': 'WARNING', + 'propagate': True, + }, + 'celery': { + 'handlers': ['db_log', 'console'], + 'level': 'WARNING', + 'propagate': True, + }, + 'redis_backend': { + 'handlers': ['db_log', 'console'], + 'level': 'DEBUG', + 'propagate': True, + }, + 'redis': { + 'handlers': ['db_log', 'console'], + 'level': 'DEBUG', + 'propagate': True, + }, + } +} \ No newline at end of file diff --git a/django/tamprog/user/management/commands/auto_createsuperuser.py b/django/tamprog/user/management/commands/auto_createsuperuser.py index 5a083d7c..5a9617ad 100644 --- a/django/tamprog/user/management/commands/auto_createsuperuser.py +++ b/django/tamprog/user/management/commands/auto_createsuperuser.py @@ -5,6 +5,9 @@ from django.core.validators import validate_email from django.conf import settings from django.contrib.auth import get_user_model +from logging import getLogger + +log = getLogger(__name__) class Command(BaseCommand): help = 'Create a superuser with predefined credentials' @@ -26,6 +29,7 @@ def handle(self, *args, **options): if User.objects.filter(username=username).exists(): self.stdout.write(self.style.WARNING('Superuser already exists')) + log.info(f"Superuser {username} already exists") return user = User._default_manager.create( @@ -37,7 +41,10 @@ def handle(self, *args, **options): user.save() # User.objects.create_superuser(username=username, email=email, password=password) self.stdout.write(self.style.SUCCESS('Superuser created successfully')) + log.info(f"Superuser {username} 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')) + log.error(f"Missing required settings. Please check DJANGO_SUPER_USER, DJANGO_SUPER_EMAIL, and DJANGO_SUPER_PASSWORD in settings.py") + log.exception(e) finally: User.REQUIRED_FIELDS = original_required \ No newline at end of file diff --git a/django/tamprog/user/migrations/0005_alter_person_wallet_balance_alter_worker_price.py b/django/tamprog/user/migrations/0005_alter_person_wallet_balance_alter_worker_price.py new file mode 100644 index 00000000..aada42cc --- /dev/null +++ b/django/tamprog/user/migrations/0005_alter_person_wallet_balance_alter_worker_price.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.16 on 2024-11-22 10:24 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('user', '0004_worker_description_worker_price'), + ] + + operations = [ + migrations.AlterField( + model_name='person', + name='wallet_balance', + field=models.FloatField(default=0.0, validators=[django.core.validators.MinValueValidator(0)]), + ), + migrations.AlterField( + model_name='worker', + name='price', + field=models.FloatField(default=0.0, validators=[django.core.validators.MinValueValidator(0)]), + ), + ] diff --git a/django/tamprog/user/models.py b/django/tamprog/user/models.py index 97813a8b..7a74b8b0 100644 --- a/django/tamprog/user/models.py +++ b/django/tamprog/user/models.py @@ -1,33 +1,41 @@ from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin from django.db import models -from django.core.validators import RegexValidator +from django.core.validators import RegexValidator, MinValueValidator from django.core.exceptions import ValidationError import re +from logging import getLogger + +log = getLogger(__name__) class PersonManager(BaseUserManager): def create_user(self, username, full_name, phone_number, password=None, **extra_fields): if not username: + log.error("The Username field must be set") raise ValueError("The Username field must be set") if not phone_number: + log.error("The Phone Number field must be set") raise ValueError("The Phone Number field must be set") if not extra_fields.get("is_superuser", False): if re.match(r'^agronom\d$', username): + log.error("Usernames starting with 'agronom' followed by a digit are reserved for superusers.") 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) + log.info(f"User {username} created successfully") 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) + log.info(f"Creating superuser {username}") 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) + wallet_balance = models.FloatField(default=0.00, validators=[MinValueValidator(0)]) full_name = models.CharField(max_length=255) phone_number = models.CharField( max_length=15, @@ -52,5 +60,5 @@ class Agronomist(models.Model): class Worker(models.Model): name = models.CharField(max_length=255) - price = models.FloatField(default=0.00) + price = models.FloatField(default=0.00, validators=[MinValueValidator(0)]) description = models.TextField() diff --git a/django/tamprog/user/permission.py b/django/tamprog/user/permission.py index 3fee41a3..c891c63c 100644 --- a/django/tamprog/user/permission.py +++ b/django/tamprog/user/permission.py @@ -1,10 +1,15 @@ from rest_framework.permissions import BasePermission import re +from logging import getLogger + +log = getLogger(__name__) class PostOnly(BasePermission): def has_permission(self, request, view): if request.method == 'POST': + log.debug("User is allowed to POST") return True + log.debug("User is not allowed to POST") return request.user and request.user.is_authenticated class AgronomistPermission(BasePermission): @@ -12,14 +17,20 @@ 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): + log.debug(f"User {username} is an agronomist") return True if request.user.is_superuser: + log.debug(f"User {username} is a superuser") return True + log.debug(f"User {username} is not an agronomist") return request.method in ['GET', 'HEAD', 'OPTIONS'] + log.debug("User is not authenticated") return False class NoPostAllowed(BasePermission): def has_permission(self, request, view): if request.method == "POST": + log.debug("User is not allowed to POST") return False + log.debug("User is allowed to POST") return True \ No newline at end of file diff --git a/django/tamprog/user/queries.py b/django/tamprog/user/queries.py index 3c0080be..ac407140 100644 --- a/django/tamprog/user/queries.py +++ b/django/tamprog/user/queries.py @@ -1,11 +1,14 @@ from .models import Worker +from logging import getLogger +log = getLogger(__name__) class GetWorkersSortedByID: def __init__(self, ascending: bool = True): self.ascending = ascending def execute(self): + log.debug('Calling GetWorkersSortedByID::execute method') return Worker.objects.order_by('id' if self.ascending else '-id') @@ -14,6 +17,7 @@ def __init__(self, ascending: bool = True): self.ascending = ascending def execute(self): + log.debug('Calling GetWorkersSortedByName::execute method') return Worker.objects.order_by('name' if self.ascending else '-name') @@ -22,6 +26,7 @@ def __init__(self, ascending: bool = True): self.ascending = ascending def execute(self): + log.debug('Calling GetWorkersSortedByPrice::execute method') return Worker.objects.order_by('price' if self.ascending else '-price') @@ -30,4 +35,5 @@ def __init__(self, ascending: bool = True): self.ascending = ascending def execute(self): + log.debug('Calling GetWorkersSortedByDescription::execute method') 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 index e478348e..8551f5aa 100644 --- a/django/tamprog/user/serializers.py +++ b/django/tamprog/user/serializers.py @@ -3,6 +3,9 @@ from rest_framework import serializers from django.contrib.auth import get_user_model from rest_framework_simplejwt.tokens import RefreshToken +from logging import getLogger + +log = getLogger(__name__) User = get_user_model() @@ -19,7 +22,10 @@ class Meta: def validate_phone_number(self, value): if User.objects.filter(phone_number=value).exists(): - raise serializers.ValidationError("User with this phone number already exists.") + e = serializers.ValidationError("User with this phone number already exists.") + log.exception(e) + raise e + log.debug("Phone number is valid") return value class LoginSerializer(serializers.Serializer): diff --git a/django/tamprog/user/services.py b/django/tamprog/user/services.py index 4cfe5bbb..0df482dd 100644 --- a/django/tamprog/user/services.py +++ b/django/tamprog/user/services.py @@ -8,6 +8,10 @@ from django.forms.models import model_to_dict from django.conf import settings +from logging import getLogger + +log = getLogger(__name__) + User = get_user_model() class PersonService: @@ -20,22 +24,26 @@ def create_user(username, full_name, phone_number, password, wallet_balance=0.00 password=password, wallet_balance=wallet_balance ) + log.info(f"User {username} created successfully") return user @staticmethod def update_wallet_balance(user, amount): if user.wallet_balance is None: + log.error("Wallet balance is not set for this user") return Response( {'error': 'Wallet balance is not set for this user'}, status=status.HTTP_400_BAD_REQUEST ) if user.wallet_balance < amount: + log.error("Insufficient funds in the wallet") return Response( {'error': 'Insufficient funds in the wallet'}, status=status.HTTP_400_BAD_REQUEST ) user.wallet_balance -= amount user.save(update_fields=['wallet_balance']) + log.info(f"Wallet balance updated successfully for user {user.username}") return Response( {'status': 'Wallet balance updated successfully'}, status=status.HTTP_200_OK @@ -52,14 +60,19 @@ def get_sorted_workers_task(sort_by: str = 'id', ascending: bool = True): elif sort_by == 'description': query = GetWorkersSortedByDescription(ascending) else: + log.error(f"Invalid sorting parameter: {sort_by}") return [] # Convert QuerySet to list of dictionaries queryset = query.execute() + log.info(f"Found {len(queryset)} workers sorted by {sort_by}") return [model_to_dict(field) for field in queryset] class WorkerService: @staticmethod def get_sorted_workers(sort_by: str = 'price', ascending: bool = True): + log.debug(f"Getting workers sorted by {sort_by}") 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 + result = result.get(timeout=settings.DJANGO_ASYNC_TIMEOUT_S) + log.info(f"Received sorted workers from Celery task") + return result \ No newline at end of file diff --git a/django/tamprog/user/tests.py b/django/tamprog/user/tests.py index e2f2ebe1..3021ac88 100644 --- a/django/tamprog/user/tests.py +++ b/django/tamprog/user/tests.py @@ -2,107 +2,84 @@ from rest_framework import status from unittest.mock import patch, MagicMock from user.models import Worker -from user.services import WorkerService +from user.services import WorkerService,PersonService from django.contrib.auth import get_user_model - +from rest_framework.test import APIClient +from rest_framework_simplejwt.tokens import RefreshToken +from user.views import LogoutView +from mixer.backend.django import mixer User = get_user_model() @pytest.mark.django_db -def test_register_user(api_client): +def test_register_user(api_client, register_data): """Тест успешной регистрации нового пользователя.""" 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') + response = api_client.post(url, register_data, format='json') assert response.status_code == status.HTTP_201_CREATED - assert User.objects.filter(username='newuser').exists() + assert User.objects.filter(username=register_data['username']).exists() + @pytest.mark.django_db -def test_register_user_existing_username(api_client, user): +def test_register_user_existing_username(api_client, register_data, user): """Тест на регистрацию пользователя с уже существующим именем.""" + register_data['username'] = user.username 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') + response = api_client.post(url, register_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') + response = api_client.post(url, {}, format='json') assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "username" in response.data assert "phone_number" in response.data assert "password" in response.data + @pytest.mark.django_db -def test_register_user_invalid_phone_number(api_client): +def test_register_user_invalid_phone_number(api_client, register_data): """Тест на регистрацию с неверным форматом номера телефона.""" + register_data['phone_number'] = 'invalid_phone' 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') + response = api_client.post(url, register_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' - } + 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' - } + 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') + response = api_client.post(url, {}, format='json') assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "username" in response.data assert "password" in response.data @pytest.mark.django_db @@ -142,3 +119,57 @@ def test_get_sorted_workers_descending(mock_async_result, mock_task, workers): assert [worker.price for worker in sorted_workers] == sorted([worker.price for worker in workers], reverse=True) +@pytest.mark.django_db +@patch('rest_framework_simplejwt.tokens.RefreshToken.for_user') +def test_logout_user_success(mock_refresh_token, api_client, user): + """Тест успешного выхода пользователя из системы.""" + mock_refresh_token_instance = MagicMock() + mock_refresh_token.return_value = mock_refresh_token_instance + api_client.force_authenticate(user=user) + url = '/api/v1/logout/' + response = api_client.post(url, format='json') + assert response.status_code == status.HTTP_200_OK + assert response.data["message"] == "Exit successful" + mock_refresh_token_instance.blacklist.assert_called_once() + +@pytest.mark.django_db +@patch('rest_framework_simplejwt.tokens.RefreshToken.for_user') +def test_logout_user_not_authenticated(mock_refresh_token, api_client): + """Тест выхода пользователя без авторизации.""" + url = '/api/v1/logout/' + response = api_client.post(url, format='json') + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert "detail" in response.data + assert response.data["detail"] == "Authentication credentials were not provided." + +@pytest.mark.django_db +@patch('rest_framework_simplejwt.tokens.RefreshToken.for_user') +def test_logout_user_exception(mock_refresh_token, api_client, user): + """Тест выхода пользователя с ошибкой при блокировке refresh токена.""" + mock_refresh_token_instance = MagicMock() + mock_refresh_token_instance.blacklist.side_effect = Exception("Some error") + mock_refresh_token.return_value = mock_refresh_token_instance + api_client.force_authenticate(user=user) + url = '/api/v1/logout/' + response = api_client.post(url, format='json') + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "error" in response.data + assert response.data["error"] == "Some error" + mock_refresh_token_instance.blacklist.assert_called_once() + +@pytest.mark.django_db +def test_update_wallet_balance_on_order_creation(api_client, user): + order = mixer.blend('orders.Order', user=user, total_cost=0.00) + initial_balance = user.wallet_balance + response = PersonService.update_wallet_balance(user, order.total_cost) + assert response.status_code == status.HTTP_200_OK + assert user.wallet_balance == initial_balance - order.total_cost + +@pytest.mark.django_db +def test_insufficient_funds_on_order_creation(api_client, user): + order = mixer.blend('orders.Order', user=user, total_cost=200.00) + response = PersonService.update_wallet_balance(user, order.total_cost) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data['error'] == 'Insufficient funds in the wallet' + + diff --git a/django/tamprog/user/views.py b/django/tamprog/user/views.py index 960c338f..dba949f0 100644 --- a/django/tamprog/user/views.py +++ b/django/tamprog/user/views.py @@ -7,9 +7,12 @@ 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 rest_framework.permissions import AllowAny, IsAdminUser, IsAuthenticated, IsAuthenticatedOrReadOnly from .services import * from django.contrib.auth import get_user_model +from logging import getLogger + +log = getLogger(__name__) from drf_spectacular.utils import extend_schema, extend_schema_view, \ OpenApiResponse, OpenApiParameter, OpenApiExample @@ -47,9 +50,12 @@ class LoginView(generics.GenericAPIView): OpenApiExample( name="Successful login", value={ + "id": 1, + "username": "example_user", "refresh": "string", "access": "string", "wallet_balance": 0.00, + "is_staff": False, }, ) ], @@ -79,11 +85,16 @@ def post(self, request, *args, **kwargs): ) if user is not None: refresh = RefreshToken.for_user(user) + log.info(f"User {user.username} logged in successfully") return Response({ + 'id': user.id, + 'username': user.username, 'refresh': str(refresh), 'access': str(refresh.access_token), 'wallet_balance': user.wallet_balance, + 'is_staff': user.is_staff, }) + log.error("Invalid credentials") return Response({"detail": "Invalid credentials"}, status=400) @@ -94,8 +105,10 @@ def post(self, request, *args, **kwargs): try: refresh_token = RefreshToken.for_user(request.user) refresh_token.blacklist() + log.info(f"User {request.user.username} logged out successfully") return Response({"message": "Exit successful"}, status=status.HTTP_200_OK) except Exception as e: + log.exception(e) return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) def RegisterParameters(required=False): @@ -151,6 +164,7 @@ class RegisterViewSet(viewsets.ModelViewSet): "message": "User created successfully", "user_id": 0, "username": "string", + 'is_staff': False, }, ) ], @@ -184,13 +198,19 @@ def create(self, request, *args, **kwargs): wallet_balance=serializer.validated_data.get('wallet_balance', 0.00) ) except ValueError as e: + log.exception(e) return Response( {'error': str(e)}, status=status.HTTP_400_BAD_REQUEST ) headers = self.get_success_headers(serializer.data) + log.info(f"User {user.username} registered successfully") return Response( - {"message": "User created successfully", "user_id": user.id, "username": user.username}, + {"message": "User created successfully", + "user_id": user.id, + "username": user.username, + 'is_staff': user.is_staff, + }, status=status.HTTP_201_CREATED, headers=headers ) @@ -221,7 +241,7 @@ def PersonParameters(required=False): class PersonViewSet(viewsets.ModelViewSet): queryset = Person.objects.all() serializer_class = PersonSerializer - permission_classes = [IsAdminUser, NoPostAllowed] + permission_classes = [IsAuthenticatedOrReadOnly] @extend_schema( summary='Get all users', @@ -233,6 +253,7 @@ class PersonViewSet(viewsets.ModelViewSet): }, ) def list(self, request, *args, **kwargs): + log.info("Getting all users") return super().list(request, *args, **kwargs) @extend_schema( @@ -245,6 +266,7 @@ def list(self, request, *args, **kwargs): }, ) def retrieve(self, request, *args, **kwargs): + log.info(f"Getting user by ID: {kwargs['pk']}") return super().retrieve(request, *args, **kwargs) @extend_schema( @@ -257,6 +279,7 @@ def retrieve(self, request, *args, **kwargs): parameters=PersonParameters(required=True), ) def update(self, request, *args, **kwargs): + log.info(f"Updating user by ID: {kwargs['pk']}") return super().update(request, *args, **kwargs) @extend_schema( @@ -269,6 +292,7 @@ def update(self, request, *args, **kwargs): parameters=PersonParameters(), ) def partial_update(self, request, *args, **kwargs): + log.info(f"Partially updating user by ID: {kwargs['pk']}") return super().partial_update(request, *args, **kwargs) @extend_schema( @@ -280,10 +304,12 @@ def partial_update(self, request, *args, **kwargs): }, ) def destroy(self, request, *args, **kwargs): + log.info(f"Deleting user by ID: {kwargs['pk']}") return super().destroy(request, *args, **kwargs) @extend_schema(exclude=True) def create(self, request, *args, **kwargs): + log.error("Method not allowed") return super().create(request, *args, **kwargs) def WorkerParameters(required=False): @@ -343,6 +369,7 @@ def list(self, request, *args, **kwargs): ascending = request.query_params.get('asc', 'true').lower() == 'true' workers = WorkerService.get_sorted_workers('price', ascending) serializer = self.get_serializer(workers, many=True) + log.info(f"Getting all workers sorted by {sort_by} in {'ascending' if ascending else 'descending'} order") return Response(serializer.data) @extend_schema( @@ -355,6 +382,7 @@ def list(self, request, *args, **kwargs): }, ) def retrieve(self, request, *args, **kwargs): + log.info(f"Getting worker by ID: {kwargs['pk']}") return super().retrieve(request, *args, **kwargs) @extend_schema( @@ -367,6 +395,7 @@ def retrieve(self, request, *args, **kwargs): parameters=WorkerParameters(required=True), ) def create(self, request, *args, **kwargs): + log.error("Method not allowed") return super().create(request, *args, **kwargs) @extend_schema( @@ -379,6 +408,7 @@ def create(self, request, *args, **kwargs): parameters=WorkerParameters(required=True), ) def update(self, request, *args, **kwargs): + log.info(f"Updating worker by ID: {kwargs['pk']}") return super().update(request, *args, **kwargs) @extend_schema( @@ -391,6 +421,7 @@ def update(self, request, *args, **kwargs): parameters=WorkerParameters(), ) def partial_update(self, request, *args, **kwargs): + log.info(f"Partially updating worker by ID: {kwargs['pk']}") return super().partial_update(request, *args, **kwargs) @extend_schema( @@ -402,5 +433,6 @@ def partial_update(self, request, *args, **kwargs): }, ) def destroy(self, request, *args, **kwargs): + log.info(f"Deleting worker by ID: {kwargs['pk']}") return super().destroy(request, *args, **kwargs) \ No newline at end of file diff --git a/docker-compose-build.yml b/docker-compose-build.yml index d054c31f..290f070a 100644 --- a/docker-compose-build.yml +++ b/docker-compose-build.yml @@ -23,9 +23,12 @@ services: POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} PGDATA: "/var/lib/postgresql/data/pgdata" + # POSTGRES_INITDB_ARGS: "--max-connections=1500" + POSTGRES_MAX_CONNECTIONS: "1500" volumes: - db-data:/docker-entrypoint-initdb.d - db-data:/var/lib/postgresql/data + command: postgres -c max_connections=1500 ports: - "${POSTGRES_PORT}:5432" restart: always @@ -54,6 +57,8 @@ services: - ${RABBITMQ_PORT}:5672 networks: - gateway + depends_on: + - redis healthcheck: test: ["CMD-SHELL", "rabbitmqctl start_app && rabbitmqctl status"] interval: 30s diff --git a/docker-compose.yml b/docker-compose.yml index d66a6aa1..2d296488 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,9 +22,12 @@ services: POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} PGDATA: "/var/lib/postgresql/data/pgdata" + # POSTGRES_INITDB_ARGS: "--max-connections=1500" + POSTGRES_MAX_CONNECTIONS: "1500" volumes: - db-data:/docker-entrypoint-initdb.d - db-data:/var/lib/postgresql/data + command: postgres -c max_connections=1500 ports: - "${POSTGRES_PORT}:5432" restart: always @@ -53,6 +56,8 @@ services: - ${RABBITMQ_PORT}:5672 networks: - gateway + depends_on: + - redis healthcheck: test: ["CMD-SHELL", "rabbitmqctl start_app && rabbitmqctl status"] interval: 30s diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 2cec4142..da0bfe5d 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,27 +1,40 @@ -# Use the official Node.js 20.17 image as the base image -FROM node:20-alpine +# Build stage +FROM node:20-alpine AS builder -# Set the working directory inside the container WORKDIR /usr/src/app -# Copy the package.json and yarn.lock files to the working directory -COPY package.json yarn.lock ./ +# Set production environment +ENV NODE_ENV=production -# Install the dependencies using yarn -RUN yarn install +# Copy package files +COPY package*.json ./ -# Install curl for healthcheck -RUN apk add --no-cache curl +# Install dependencies with production flags and clean cache in same layer +RUN npm install --production --silent && \ + npm cache clean --force -# Copy the rest of the application code to the working directory +# Copy source code COPY . . -# Expose the port the application will run on -EXPOSE 3000 +# Build production assets +RUN npm run build + +# Runtime stage +FROM nginx:alpine-slim + +# Copy nginx config +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Copy built assets from builder +COPY --from=builder /usr/src/app/build /usr/share/nginx/html -# Define the command to run the application -CMD ["yarn", "start"] +# Install curl for healthcheck in same layer as cleanup +RUN apk add --no-cache curl && \ + rm -rf /var/cache/apk/* + +EXPOSE 3000 -# Add a healthcheck to ensure the container is running correctly HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD curl -f http://localhost:3000/ || exit 1 \ No newline at end of file + CMD curl -f http://localhost/ || exit 1 + +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 00000000..35ada6c2 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,33 @@ +server { + listen 3000; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Enable gzip compression + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + # Logs + access_log /var/log/nginx/access.log; + error_log /var/log/nginx/error.log; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN"; + add_header X-Content-Type-Options "nosniff"; + + location / { + try_files $uri $uri/ /index.html =404; + include /etc/nginx/mime.types; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { + expires 1y; + add_header Cache-Control "public, no-transform"; + access_log off; + } + + # Handle 404 errors + error_page 404 /index.html; +} \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 5e88b572..4ad475d5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,13 +6,19 @@ "@testing-library/jest-dom": "^5.14.1", "@testing-library/react": "^13.0.0", "@testing-library/user-event": "^13.2.1", + "assert": "^2.1.0", "axios": "^1.7.7", "boxicons": "^2.1.4", + "chai": "^5.1.2", + "crypto-browserify": "^3.12.1", + "os-browserify": "^0.3.0", + "path-browserify": "^1.0.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.26.2", "react-scripts": "5.0.1", "react-transition-group": "^4.4.5", + "tmp": "^0.2.3", "web-vitals": "^2.1.0" }, "scripts": { @@ -39,5 +45,9 @@ "last 1 safari version" ] }, - "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e", + "devDependencies": { + "mocha": "^10.8.2", + "selenium-webdriver": "^4.26.0" + } } diff --git a/frontend/public/arrow-down-svgrepo-com.svg b/frontend/public/arrow-down-svgrepo-com.svg new file mode 100644 index 00000000..ad141996 --- /dev/null +++ b/frontend/public/arrow-down-svgrepo-com.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/frontend/public/arrow-left-svgrepo-com.svg b/frontend/public/arrow-left-svgrepo-com.svg new file mode 100644 index 00000000..c315b51c --- /dev/null +++ b/frontend/public/arrow-left-svgrepo-com.svg @@ -0,0 +1,9 @@ + + + diff --git a/frontend/public/arrow-right-svgrepo-com.svg b/frontend/public/arrow-right-svgrepo-com.svg new file mode 100644 index 00000000..536a5de3 --- /dev/null +++ b/frontend/public/arrow-right-svgrepo-com.svg @@ -0,0 +1,9 @@ + + + diff --git a/frontend/public/arrow-up-svgrepo-com.svg b/frontend/public/arrow-up-svgrepo-com.svg new file mode 100644 index 00000000..d4b503e0 --- /dev/null +++ b/frontend/public/arrow-up-svgrepo-com.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 00000000..ed474256 Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/public/ferma.jpg b/frontend/public/ferma.jpg new file mode 100644 index 00000000..7477777a Binary files /dev/null and b/frontend/public/ferma.jpg differ diff --git a/frontend/public/index.html b/frontend/public/index.html index 14d57037..ed35c7b5 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -25,7 +25,7 @@ work correctly both with client-side routing and a non-root public URL. Learn how to configure a non-root public URL by running `npm run build`. --> -