diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..dc1dba1 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,16 @@ +{ + // Используйте IntelliSense, чтобы узнать о возможных атрибутах. + // Наведите указатель мыши, чтобы просмотреть описания существующих атрибутов. + // Для получения дополнительной информации посетите: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python: текущий файл", + "type": "python", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal", + "justMyCode": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..3e99ede --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "python.testing.pytestArgs": [ + "." + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true +} \ No newline at end of file diff --git a/infra/docker-compose.yaml b/infra/docker-compose.yaml index 21f6acf..c8efb8b 100644 --- a/infra/docker-compose.yaml +++ b/infra/docker-compose.yaml @@ -11,7 +11,7 @@ services: frontend: container_name: frontend image: zali1813/short_tracker_frontend:v1 - restart: always + # restart: always volumes: - ../frontend/:/app/result_build/ # depends_on: @@ -40,18 +40,28 @@ services: - media_value:/var/html/media/ # depends_on: # - backend - + redis: + image: redis:alpine + restart: on-failure + ports: + - "6379:6379" + volumes: + - redis_data:/data bot: container_name: bot - image: zali1813/short_tracker_bot:v1 + #image: zali1813/short_tracker_bot:v1 + build: + context: ../short_tracker_bot + dockerfile: Dockerfile restart: always env_file: - ./.env - # depends_on: - # - backend + depends_on: + - redis volumes: static_value: media_value: postgres_data: + redis_data: diff --git a/pyproject.toml b/pyproject.toml index bb84da0..30c519d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,4 +19,4 @@ exclude = [ [tool.ruff.isort] combine-as-imports = true -known-local-folder = ["api", "users", "tasks"] # Здесь указывать название локального модуля, для корректной сортировки импортов ["my_module"] +known-local-folder = ["api", "users", "tasks", "messages", "config", "handlers"] # Здесь указывать название локального модуля, для корректной сортировки импортов ["my_module"] diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..24d74a4 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,7 @@ +[pytest] +python_paths = short_tracker/ +DJANGO_SETTINGS_MODULE = short_tracker.settings +norecursedirs = env/* +addopts = -vv -p no:cacheprovider --disable-warnings +testpaths = tests/ +python_files = test_*.py diff --git a/requirements.txt b/requirements.txt index 57704fa..ec8b9cb 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/short_tracker/api/v1/analytics/serializers.py b/short_tracker/api/v1/analytics/serializers.py index b0d3766..e6a7f8d 100644 --- a/short_tracker/api/v1/analytics/serializers.py +++ b/short_tracker/api/v1/analytics/serializers.py @@ -9,6 +9,7 @@ class PerformerAnalyticsSerializer(serializers.Serializer): avg_time_create_date_to_done_date = serializers.CharField() avg_time_inprogress_date_to_done_date = serializers.CharField() + class TaskAnalyticsSerializer(serializers.Serializer): total_tasks_on_time = serializers.IntegerField() total_tasks_with_delay = serializers.IntegerField() @@ -27,4 +28,4 @@ def create(self, validated_data): 'total_tasks_with_delay': total_tasks_with_delay, 'performers_analytics': performers_analytics_data } - return task_analytics_instance \ No newline at end of file + return task_analytics_instance diff --git a/short_tracker/api/v1/analytics/views.py b/short_tracker/api/v1/analytics/views.py index 03c58b8..6ed05e0 100644 --- a/short_tracker/api/v1/analytics/views.py +++ b/short_tracker/api/v1/analytics/views.py @@ -49,4 +49,4 @@ def list(self, request, *args, **kwargs): ) ) serializer.is_valid(raise_exception=True) - return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.data, status=status.HTTP_200_OK) \ No newline at end of file diff --git a/short_tracker/api/v1/bot/serializer.py b/short_tracker/api/v1/bot/serializer.py index 76e0a09..5fb98df 100644 --- a/short_tracker/api/v1/bot/serializer.py +++ b/short_tracker/api/v1/bot/serializer.py @@ -1,3 +1,4 @@ +from bot.models import AllowNotification from django.contrib.auth import get_user_model from rest_framework.serializers import ModelSerializer @@ -7,13 +8,20 @@ User = get_user_model() +class AllowNotificationSerializer(ModelSerializer): + class Meta: + model = AllowNotification + fields = '__all__' + + class BotSerializer(ModelSerializer): messages = MessageSerializer(many=True) reply = ReplySerializer(many=True) tasks_for_user = TaskShowSerializer(many=True) + allow = AllowNotificationSerializer(many=True) class Meta: model = User - fields = ['messages', 'reply', 'tasks_for_user'] + fields = ['messages', 'reply', 'tasks_for_user', 'allow'] diff --git a/short_tracker/api/v1/bot/views.py b/short_tracker/api/v1/bot/views.py index 25b7e0d..667f1b7 100644 --- a/short_tracker/api/v1/bot/views.py +++ b/short_tracker/api/v1/bot/views.py @@ -14,3 +14,6 @@ def get_queryset(self): return User.objects.filter(id=user) +#class WebhookAPIView + + diff --git a/short_tracker/api/v1/filters.py b/short_tracker/api/v1/filters.py index 4161e84..a863198 100644 --- a/short_tracker/api/v1/filters.py +++ b/short_tracker/api/v1/filters.py @@ -74,6 +74,7 @@ def filter_is_expired(self, queryset, _, value): ) return queryset + class TaskAnalyticsFilter(django_filters.FilterSet): performer_id = filters.NumberFilter( field_name='performers', method='filter_by_performer') diff --git a/short_tracker/api/v1/permissions.py b/short_tracker/api/v1/permissions.py index 6be4ab6..0635e00 100644 --- a/short_tracker/api/v1/permissions.py +++ b/short_tracker/api/v1/permissions.py @@ -9,15 +9,26 @@ def has_permission(self, request, view): class IsLeadOrPerformerHimselfOnly(permissions.BasePermission): def has_permission(self, request, view): - is_lead = request.user.is_lead - return request.user.id == request.data.get('performers')[0] or is_lead - + is_lead = request.user.is_authenticated and request.user.is_team_lead + return is_lead or ( + request.user.is_authenticated + and request.user.id == request.data.get('performers')[0] + ) + + +class IsCreatorAndLidOrPerformerOnly(permissions.BasePermission): + """ + Задачу может менять создатель задачи и Лидер. + Исполнитель может менять только статус задачи. + """ + def has_object_permission(self, request, view, obj): -class IsCreatorOnly(permissions.BasePermission): + is_lead = request.user.is_team_lead + perf = [] + for performer in obj.performers.values(): + perf.append(performer.get('id')) + a = (request.user.id in perf and len(request.data) == 1 + and 'status' in request.data) + b = request.user.id == obj.creator.id - def has_permission(self, request, view): - is_lead = request.user.is_lead - return [request.user.id] == request.data.get('performers') or is_lead - - def has_object_permission(self, request, view, obj): - return request.user.id == obj.creator.id + return request.user.is_authenticated and (is_lead or a or b) diff --git a/short_tracker/api/v1/tasks/serializers.py b/short_tracker/api/v1/tasks/serializers.py index 2819168..0359234 100644 --- a/short_tracker/api/v1/tasks/serializers.py +++ b/short_tracker/api/v1/tasks/serializers.py @@ -1,7 +1,9 @@ from django.contrib.auth import get_user_model from django.utils import timezone +from message.models import Message from rest_framework import serializers +from api.v1.message.serializers import MessageSerializer from api.v1.users.serializers import ShortUserSerializer from tasks.models import Task from users.models import ROLES @@ -48,16 +50,17 @@ class TaskShowSerializer(TaskSerializer): """Serializer for show tasks.""" creator = ShortUserSerializer(read_only=True) - performers = ShortUserSerializer(many=True) + performer = ShortUserSerializer() is_expired = serializers.SerializerMethodField() resolved_status = serializers.SerializerMethodField() + message = serializers.SerializerMethodField() class Meta(TaskSerializer.Meta): fields = TaskSerializer.Meta.fields + ( - 'creator', 'performers', 'is_expired', 'resolved_status', + 'creator', 'performer', 'is_expired', 'resolved_status', 'message', ) read_only_fields = TaskSerializer.Meta.read_only_fields + ( - 'is_expired', 'resolved_status', + 'is_expired', 'resolved_status', 'message', ) def get_is_expired(self, obj): @@ -65,7 +68,7 @@ def get_is_expired(self, obj): Check if the task is expired or not. """ return ( - obj.deadline_date < timezone.now().date() + obj.deadline_date < timezone.now() and obj.status not in ('done', 'archived') ) @@ -78,12 +81,50 @@ def get_resolved_status(self, obj): return RESOLVED_STATUS.get( role, ROLES.get('employee')).get(obj.status, ()) + def get_message(self, obj): + """ + Get the messages of the task. + """ + messages = Message.objects.filter(task=obj) + return MessageSerializer(messages, many=True).data + class TaskCreateSerializer(TaskSerializer): """Serializer for create tasks.""" + performers = serializers.PrimaryKeyRelatedField( + many=True, queryset=User.objects.all(), write_only=True + ) class Meta(TaskSerializer.Meta): - fields = TaskSerializer.Meta.fields + ('performers',) + fields = TaskSerializer.Meta.fields + ('performer', 'performers',) + read_only_fields = TaskSerializer.Meta.read_only_fields + ( + 'performer',) + + def create(self, validated_data): + + performers = validated_data.pop('performers') + if len(performers) == 1: + validated_data['performer'] = performers[0] + return [Task.objects.create(**validated_data)] + + tasks = [] + for performer in performers: + tasks.append(Task(performer=performer, **validated_data)) + + Task.objects.bulk_create(tasks) + return tasks + + def to_representation(self, instance): + """ + Serialize objects. + """ + cn = {'request': self.context.get('request')} + tasks_data = { + 'tasks': [ + TaskShowSerializer(task, context=cn).data for task in instance + ], + } + return tasks_data class TaskUpdateSerializer(TaskCreateSerializer): @@ -93,10 +134,10 @@ def update(self, instance, validated_data): if 'status' in validated_data: time = STATUS_TIME.get(validated_data.get('status')) if time: - validated_data[time] = timezone.now().date() + validated_data[time] = timezone.now() if ( validated_data.get('status') == task_status.DONE - and timezone.now().date() <= instance.deadline_date + and timezone.now() <= instance.deadline_date ): validated_data['get_medals'] = True return super().update(instance, validated_data) diff --git a/short_tracker/api/v1/tasks/views.py b/short_tracker/api/v1/tasks/views.py index 9c2787e..472e256 100644 --- a/short_tracker/api/v1/tasks/views.py +++ b/short_tracker/api/v1/tasks/views.py @@ -2,8 +2,10 @@ from django.db.models import F, Q from django_filters.rest_framework import DjangoFilterBackend from rest_framework import viewsets +from rest_framework.decorators import action from rest_framework.filters import SearchFilter from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response from .serializers import ( TaskCreateSerializer, @@ -11,7 +13,10 @@ TaskUpdateSerializer, ) from api.v1.filters import TaskFilter -from api.v1.permissions import IsCreatorOnly, IsLeadOrPerformerHimselfOnly +from api.v1.permissions import ( + IsCreatorAndLidOrPerformerOnly, + IsLeadOrPerformerHimselfOnly, +) from tasks.models import Task User = get_user_model() @@ -20,24 +25,24 @@ class TaskViewSet(viewsets.ModelViewSet): filter_backends = (DjangoFilterBackend, SearchFilter,) filterset_class = TaskFilter - search_fields = ['performers__first_name', 'performers__last_name'] + search_fields = ['performer__first_name', 'performer__last_name'] permission_classes = (IsAuthenticated,) def get_permissions(self): if self.action == 'create': return (IsLeadOrPerformerHimselfOnly(),) elif self.action == 'partial_update': - return (IsCreatorOnly(),) + return (IsCreatorAndLidOrPerformerOnly(),) return super().get_permissions() def get_queryset(self): user = self.request.user if user.is_lead: queryset = Task.objects.exclude( - Q(creator=F('performers')) & ~Q(performers=user) + Q(creator=F('performer')) & ~Q(performer=user) ).distinct() else: - queryset = Task.objects.filter(performers=user) + queryset = Task.objects.filter(performer=user) return queryset def get_serializer_class(self): @@ -49,3 +54,29 @@ def get_serializer_class(self): def perform_create(self, serializer): serializer.save(creator=self.request.user) + + @action( + methods=('GET',), + detail=False, + permission_classes=(IsAuthenticated,), + url_path='get-sorted-tasks', + ) + def get_sorted_tasks(self, _): + queryset = self.get_queryset() + filter_queryset = { + Task.TaskStatus.TO_DO: [], + Task.TaskStatus.IN_PROGRESS: [], + Task.TaskStatus.DONE: [], + Task.TaskStatus.HOLD: [], + } + for value in queryset: + task = TaskShowSerializer( + value, context=self.get_serializer_context() + ).data + if value.hold: + filter_queryset.get(Task.TaskStatus.HOLD).append(task) + elif value.status == Task.TaskStatus.ARCHIVED: + continue + else: + filter_queryset.get(value.status).append(task) + return Response(filter_queryset) diff --git a/short_tracker/api/v1/urls.py b/short_tracker/api/v1/urls.py index c10adf9..627132b 100644 --- a/short_tracker/api/v1/urls.py +++ b/short_tracker/api/v1/urls.py @@ -13,6 +13,7 @@ photo ) + router_v1 = DefaultRouter() router_v1.register('tasks', TaskViewSet, basename='tasks') router_v1.register( diff --git a/short_tracker/bot/__init__.py b/short_tracker/bot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/short_tracker/bot/admin.py b/short_tracker/bot/admin.py new file mode 100644 index 0000000..93938e4 --- /dev/null +++ b/short_tracker/bot/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin + +from .models import AllowNotification + +admin.site.register(AllowNotification) diff --git a/short_tracker/bot/apps.py b/short_tracker/bot/apps.py new file mode 100644 index 0000000..5cc2892 --- /dev/null +++ b/short_tracker/bot/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class BotConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "bot" diff --git a/short_tracker/bot/migrations/0001_initial.py b/short_tracker/bot/migrations/0001_initial.py new file mode 100644 index 0000000..c120893 --- /dev/null +++ b/short_tracker/bot/migrations/0001_initial.py @@ -0,0 +1,80 @@ +# Generated by Django 4.2.8 on 2024-02-12 05:51 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Notification", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "name", + models.CharField( + max_length=100, verbose_name="Название уведомления" + ), + ), + ], + options={ + "verbose_name": "Уведомление", + "verbose_name_plural": "Уведолмления", + }, + ), + migrations.CreateModel( + name="AllowNotification", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "allow_notification", + models.BooleanField(verbose_name="Разрешить уведомления"), + ), + ( + "notification", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="allow", + to="bot.notification", + verbose_name="Уведомление", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="allow", + to=settings.AUTH_USER_MODEL, + verbose_name="Пользователь", + ), + ), + ], + options={ + "verbose_name": "Разрешения уведомлений", + "verbose_name_plural": "Разрешения уведомлений", + }, + ), + ] diff --git a/short_tracker/bot/migrations/0002_alter_allownotification_notification_and_more.py b/short_tracker/bot/migrations/0002_alter_allownotification_notification_and_more.py new file mode 100644 index 0000000..5493cba --- /dev/null +++ b/short_tracker/bot/migrations/0002_alter_allownotification_notification_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.8 on 2024-02-12 06:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("bot", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="allownotification", + name="notification", + field=models.CharField( + choices=[ + ("deadline", "Дедлайн"), + ("msg", "Сообщения"), + ("status", "Статусы"), + ("tasks", "Задачи"), + ], + max_length=10, + verbose_name="Уведомление", + ), + ), + migrations.DeleteModel( + name="Notification", + ), + ] diff --git a/short_tracker/bot/migrations/0003_alter_allownotification_allow_notification_and_more.py b/short_tracker/bot/migrations/0003_alter_allownotification_allow_notification_and_more.py new file mode 100644 index 0000000..bb0c0aa --- /dev/null +++ b/short_tracker/bot/migrations/0003_alter_allownotification_allow_notification_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.8 on 2024-02-12 14:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bot', '0002_alter_allownotification_notification_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='allownotification', + name='allow_notification', + field=models.BooleanField(default=True, verbose_name='Разрешить уведомления'), + ), + migrations.AlterField( + model_name='allownotification', + name='notification', + field=models.CharField(choices=[('tasks', 'Задачи'), ('msg', 'Сообщения'), ('status', 'Статусы'), ('deadline', 'Дедлайн')], max_length=10, verbose_name='Уведомление'), + ), + migrations.AddConstraint( + model_name='allownotification', + constraint=models.UniqueConstraint(fields=('user', 'notification'), name='unique_notification_for_user'), + ), + ] diff --git a/short_tracker/bot/migrations/0004_alter_allownotification_notification.py b/short_tracker/bot/migrations/0004_alter_allownotification_notification.py new file mode 100644 index 0000000..7871190 --- /dev/null +++ b/short_tracker/bot/migrations/0004_alter_allownotification_notification.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.8 on 2024-02-12 14:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bot', '0003_alter_allownotification_allow_notification_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='allownotification', + name='notification', + field=models.CharField(choices=[('deadline', 'Дедлайн'), ('tasks', 'Задачи'), ('status', 'Статусы'), ('msg', 'Сообщения')], max_length=10, verbose_name='Уведомление'), + ), + ] diff --git a/short_tracker/bot/migrations/0005_alter_allownotification_notification.py b/short_tracker/bot/migrations/0005_alter_allownotification_notification.py new file mode 100644 index 0000000..f96a5f3 --- /dev/null +++ b/short_tracker/bot/migrations/0005_alter_allownotification_notification.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.8 on 2024-02-13 13:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bot', '0004_alter_allownotification_notification'), + ] + + operations = [ + migrations.AlterField( + model_name='allownotification', + name='notification', + field=models.CharField(choices=[('tasks', 'Задачи'), ('status', 'Статусы'), ('msg', 'Сообщения'), ('deadline', 'Дедлайн')], max_length=10, verbose_name='Уведомление'), + ), + ] diff --git a/short_tracker/bot/migrations/__init__.py b/short_tracker/bot/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/short_tracker/bot/models.py b/short_tracker/bot/models.py new file mode 100644 index 0000000..32f3c35 --- /dev/null +++ b/short_tracker/bot/models.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.db import models + +User = get_user_model() + +NOTIFICATIONS = { + ('msg', 'Сообщения'), + ('tasks', 'Задачи'), + ('status', 'Статусы'), + ('deadline', 'Дедлайн'), +} + + +class AllowNotification(models.Model): + allow_notification = models.BooleanField( + 'Разрешить уведомления', + default=True + ) + notification = models.CharField( + choices=NOTIFICATIONS, + max_length=10, + verbose_name='Уведомление', + ) + user = models.ForeignKey( + User, + verbose_name='Пользователь', + on_delete=models.CASCADE, + related_name='allow' + ) + + class Meta: + verbose_name = 'Разрешения уведомлений' + verbose_name_plural = 'Разрешения уведомлений' + constraints = [ + models.UniqueConstraint( + fields=('user', 'notification'), + name='unique_notification_for_user' + ) + ] + + def __str__(self): + return f'{self.user} {self.notification}' + + diff --git a/short_tracker/celerybeat-schedule.bak b/short_tracker/celerybeat-schedule.bak new file mode 100644 index 0000000..6a23d66 --- /dev/null +++ b/short_tracker/celerybeat-schedule.bak @@ -0,0 +1,4 @@ +'entries', (0, 300) +'__version__', (512, 20) +'tz', (1024, 18) +'utc_enabled', (1536, 4) diff --git a/short_tracker/celerybeat-schedule.dat b/short_tracker/celerybeat-schedule.dat new file mode 100644 index 0000000..e375fc9 Binary files /dev/null and b/short_tracker/celerybeat-schedule.dat differ diff --git a/short_tracker/celerybeat-schedule.dir b/short_tracker/celerybeat-schedule.dir new file mode 100644 index 0000000..6a23d66 --- /dev/null +++ b/short_tracker/celerybeat-schedule.dir @@ -0,0 +1,4 @@ +'entries', (0, 300) +'__version__', (512, 20) +'tz', (1024, 18) +'utc_enabled', (1536, 4) diff --git a/short_tracker/db.sqlite3.bak b/short_tracker/db.sqlite3.bak deleted file mode 100644 index 3731a38..0000000 Binary files a/short_tracker/db.sqlite3.bak and /dev/null differ diff --git a/short_tracker/message/migrations/0001_initial.py b/short_tracker/message/migrations/0001_initial.py index eb27f69..4c2112f 100644 --- a/short_tracker/message/migrations/0001_initial.py +++ b/short_tracker/message/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.8 on 2024-01-22 13:42 +# Generated by Django 4.2.8 on 2024-02-12 14:37 from django.db import migrations, models @@ -16,7 +16,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('message_body', models.TextField(verbose_name='Текст сообщения')), - ('message_date', models.DateTimeField(auto_now=True, verbose_name='Дата')), + ('message_date', models.DateTimeField(auto_now_add=True, verbose_name='Дата')), ], options={ 'verbose_name': 'Сообщение', diff --git a/short_tracker/message/migrations/0002_initial.py b/short_tracker/message/migrations/0002_initial.py index 15ada0f..b55151c 100644 --- a/short_tracker/message/migrations/0002_initial.py +++ b/short_tracker/message/migrations/0002_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.8 on 2024-01-22 13:42 +# Generated by Django 4.2.8 on 2024-02-12 14:37 from django.conf import settings from django.db import migrations, models @@ -11,8 +11,8 @@ class Migration(migrations.Migration): dependencies = [ ('message', '0001_initial'), - ('tasks', '0001_initial'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('tasks', '0001_initial'), ] operations = [ diff --git a/short_tracker/message/migrations/0003_alter_message_options.py b/short_tracker/message/migrations/0003_alter_message_options.py new file mode 100644 index 0000000..d8e5f5f --- /dev/null +++ b/short_tracker/message/migrations/0003_alter_message_options.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.8 on 2024-02-13 13:31 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('message', '0002_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='message', + options={'ordering': ['-message_date'], 'verbose_name': 'Сообщение', 'verbose_name_plural': 'Сообщения'}, + ), + ] diff --git a/short_tracker/message/models.py b/short_tracker/message/models.py index b87c7d4..7643b84 100644 --- a/short_tracker/message/models.py +++ b/short_tracker/message/models.py @@ -28,7 +28,7 @@ class Message(models.Model): ) message_date = models.DateTimeField( 'Дата', - auto_now=True + auto_now_add=True ) message_status = models.ForeignKey( 'MessageStatus', @@ -42,6 +42,7 @@ def __str__(self): return self.message_body class Meta: + ordering = ['-message_date'] verbose_name = 'Сообщение' verbose_name_plural = 'Сообщения' indexes = [ diff --git a/short_tracker/message/tasks.py b/short_tracker/message/tasks.py new file mode 100644 index 0000000..ab3f7b5 --- /dev/null +++ b/short_tracker/message/tasks.py @@ -0,0 +1,21 @@ +from datetime import timedelta + +from celery import shared_task +from django.conf import settings +from django.utils import timezone + +from .models import Message + + +@shared_task +def delete_solved_queries(): + print("Delete") + threshold_time = timezone.now() - timedelta( + hours=settings.SOLVED_MESSAGE_DELETE + ) + messages_to_delete = Message.objects.filter( + message_status='solved', + message_date__lte=threshold_time + ) + messages_to_delete.delete() + print("Delete", messages_to_delete) diff --git a/short_tracker/requirements.txt b/short_tracker/requirements.txt index a23eb46..e69de29 100644 --- a/short_tracker/requirements.txt +++ b/short_tracker/requirements.txt @@ -1,64 +0,0 @@ -aiofiles==23.2.1 -aiogram==3.2.0 -aiohttp==3.9.1 -aiosignal==1.3.1 -annotated-types==0.6.0 -asgiref==3.7.2 -attrs==23.1.0 -certifi==2023.11.17 -cffi==1.16.0 -cfgv==3.4.0 -charset-normalizer==3.3.2 -coreapi==2.3.3 -coreschema==0.0.4 -cryptography==41.0.7 -defusedxml==0.8.0rc2 -distlib==0.3.8 -Django==4.2.8 -django-cors-headers==4.3.1 -django-filter==23.5 -django-templated-mail==1.1.1 -djangorestframework==3.14.0 -djangorestframework-simplejwt==4.7.2 -djoser==2.1.0 -drf-yasg==1.21.7 -filelock==3.13.1 -frozenlist==1.4.1 -gunicorn==21.2.0 -identify==2.5.33 -idna==3.6 -inflection==0.5.1 -itypes==1.2.0 -Jinja2==3.1.2 -magic-filter==1.0.12 -MarkupSafe==2.1.3 -multidict==6.0.4 -nodeenv==1.8.0 -oauthlib==3.2.2 -packaging==23.2 -platformdirs==4.1.0 -pre-commit==3.6.0 -psycopg2-binary==2.9.9 -pycparser==2.21 -pydantic==2.5.2 -pydantic_core==2.14.5 -PyJWT==2.8.0 -python-dotenv==1.0.0 -python3-openid==3.2.0 -pytz==2023.3.post1 -PyYAML==6.0.1 -redis==5.0.1 -requests==2.31.0 -requests-oauthlib==1.3.1 -ruff==0.1.7 -six==1.16.0 -social-auth-app-django==4.0.0 -social-auth-core==4.5.1 -sqlparse==0.4.4 -typing_extensions==4.9.0 -tzdata==2023.3 -uritemplate==4.1.1 -urllib3==2.1.0 -virtualenv==20.25.0 -yarl==1.9.4 -pillow==10.2.0 diff --git a/short_tracker/short_tracker/__init__.py b/short_tracker/short_tracker/__init__.py index e69de29..9e512fd 100644 --- a/short_tracker/short_tracker/__init__.py +++ b/short_tracker/short_tracker/__init__.py @@ -0,0 +1,3 @@ +from .celery import app as celery_app + +__all__ = ("celery_app",) diff --git a/short_tracker/short_tracker/celery.py b/short_tracker/short_tracker/celery.py new file mode 100644 index 0000000..3a717fd --- /dev/null +++ b/short_tracker/short_tracker/celery.py @@ -0,0 +1,21 @@ +import os + +from celery import Celery +from celery.schedules import crontab + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', + 'short_tracker.settings') + + +app = Celery('short_tracker') +app.config_from_object('django.conf:settings', + namespace='CELERY') +app.autodiscover_tasks() + +app.conf.beat_schedule = { + 'delete-messages': { + 'task': 'message.tasks.delete_solved_queries', + 'schedule': crontab(), + }, +} +app.conf.timezone = 'UTC' diff --git a/short_tracker/short_tracker/settings.py b/short_tracker/short_tracker/settings.py index 3099624..fd29ddd 100644 --- a/short_tracker/short_tracker/settings.py +++ b/short_tracker/short_tracker/settings.py @@ -48,6 +48,7 @@ 'users.apps.UsersConfig', 'tasks.apps.TasksConfig', 'message.apps.MessageConfig', + 'bot.apps.BotConfig', ] INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS @@ -102,7 +103,6 @@ } } - AUTH_PASSWORD_VALIDATORS = [ { 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', @@ -144,7 +144,8 @@ 'users.authentication.CookieJWTAuthentication', ], 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination', - 'PAGE_SIZE': 10 + 'PAGE_SIZE': 10, + 'DATETIME_FORMAT': "%d.%m.%Y %H:%M", } SIMPLE_JWT = { @@ -159,9 +160,24 @@ 'LOGIN_FIELD': 'email', 'PASSWORD_RESET_SHOW_EMAIL_NOT_FOUND': True, 'HIDE_USERS': False, + 'PERMISSIONS': {'user_create': ['api.v1.permissions.IsTeamLead']}, 'SERIALIZERS': { 'user': 'api.v1.users.serializers.UserSerializer', 'current_user': 'api.v1.users.serializers.UserSerializer', 'user_create': 'api.v1.users.serializers.UserCreateSerializer', }, } + + +REDIS_HOST = "0.0.0.0" +REDIS_PORT = "6379" + +CELERY_BROKER_URL = "redis://" + "127.0.0.1" + ":" + REDIS_PORT + "/0" +CELERY_BROKER_TRANSPORT_OPTIONS = {"visibility_timeout": 3600} +CELERY_RESULT_BACKEND = "redis://" + REDIS_HOST + ":" + REDIS_PORT + "/0" + +CELERY_ACCEPT_CONTENT = ["application/json"] +CELERY_TASK_SERIALIZER = "json" +CELERY_RESULT_SERIALIZER = "json" + +SOLVED_MESSAGE_DELETE = 72 diff --git a/short_tracker/tasks/admin.py b/short_tracker/tasks/admin.py index b6036b2..e2c5978 100644 --- a/short_tracker/tasks/admin.py +++ b/short_tracker/tasks/admin.py @@ -8,7 +8,7 @@ class TaskAdmin(admin.ModelAdmin): list_display = ( 'pk', 'creator', - 'display_performers', + 'performer', 'description', 'status', 'create_date', @@ -16,19 +16,8 @@ class TaskAdmin(admin.ModelAdmin): ) search_fields = ( 'description', - 'performers__username', + 'performer__username', ) list_filter = ( 'status', ) - - def display_performers(self, obj): - """ - Generates a string with the first name and last initial - of each performer in the given object. - """ - objs = obj.performers.all() - return ' '.join( - [f'{user.first_name} {user.last_name[:1]}.' for user in objs] - ) - display_performers.short_description = 'Performers' diff --git a/short_tracker/tasks/migrations/0001_initial.py b/short_tracker/tasks/migrations/0001_initial.py index fe8321c..c3407b4 100644 --- a/short_tracker/tasks/migrations/0001_initial.py +++ b/short_tracker/tasks/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.8 on 2024-01-22 13:42 +# Generated by Django 4.2.8 on 2024-02-12 14:37 from django.db import migrations, models diff --git a/short_tracker/tasks/migrations/0002_initial.py b/short_tracker/tasks/migrations/0002_initial.py index 8bffee5..4534478 100644 --- a/short_tracker/tasks/migrations/0002_initial.py +++ b/short_tracker/tasks/migrations/0002_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.8 on 2024-01-22 13:42 +# Generated by Django 4.2.8 on 2024-02-12 14:37 from django.conf import settings from django.db import migrations, models @@ -10,8 +10,8 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('tasks', '0001_initial'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('tasks', '0001_initial'), ] operations = [ @@ -22,7 +22,7 @@ class Migration(migrations.Migration): ), migrations.AddField( model_name='task', - name='performers', - field=models.ManyToManyField(help_text='The performers of the task', related_name='tasks_for_user', to=settings.AUTH_USER_MODEL, verbose_name='performers'), + name='performer', + field=models.ForeignKey(help_text='The performer of the task', on_delete=django.db.models.deletion.CASCADE, related_name='tasks_for_user', to=settings.AUTH_USER_MODEL, verbose_name='performer'), ), ] diff --git a/short_tracker/tasks/migrations/0003_alter_task_creator_alter_task_performers.py b/short_tracker/tasks/migrations/0003_alter_task_creator_alter_task_performers.py deleted file mode 100644 index 81d3ba4..0000000 --- a/short_tracker/tasks/migrations/0003_alter_task_creator_alter_task_performers.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 4.2.8 on 2024-01-18 17:17 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('tasks', '0002_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='task', - name='creator', - field=models.ForeignKey(help_text='The creator of the task', on_delete=django.db.models.deletion.CASCADE, related_name='created_tasks', to=settings.AUTH_USER_MODEL, verbose_name='creator'), - ), - migrations.AlterField( - model_name='task', - name='performers', - field=models.ManyToManyField(help_text='The performers of the task', related_name='tasks_for_user', to=settings.AUTH_USER_MODEL, verbose_name='performers'), - ), - ] diff --git a/short_tracker/tasks/migrations/0003_task_hold_alter_task_archive_date_and_more.py b/short_tracker/tasks/migrations/0003_task_hold_alter_task_archive_date_and_more.py new file mode 100644 index 0000000..ec4c558 --- /dev/null +++ b/short_tracker/tasks/migrations/0003_task_hold_alter_task_archive_date_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 4.2.8 on 2024-02-12 14:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0002_initial'), + ] + + operations = [ + migrations.AddField( + model_name='task', + name='hold', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='task', + name='archive_date', + field=models.DateTimeField(blank=True, help_text='The archive date of the task', null=True, verbose_name='archive date'), + ), + migrations.AlterField( + model_name='task', + name='create_date', + field=models.DateTimeField(auto_now_add=True, help_text='The create date of the task', verbose_name='create date'), + ), + migrations.AlterField( + model_name='task', + name='deadline_date', + field=models.DateTimeField(help_text='The deadline date of the task', verbose_name='deadline date'), + ), + migrations.AlterField( + model_name='task', + name='done_date', + field=models.DateTimeField(blank=True, help_text='The done date of the task', null=True, verbose_name='done date'), + ), + migrations.AlterField( + model_name='task', + name='inprogress_date', + field=models.DateTimeField(blank=True, help_text='The "in progress" date of the task', null=True, verbose_name='"in progress" date'), + ), + ] diff --git a/short_tracker/tasks/migrations/0004_task_tasks_task_create__81ab0d_idx.py b/short_tracker/tasks/migrations/0004_task_tasks_task_create__81ab0d_idx.py new file mode 100644 index 0000000..e24f463 --- /dev/null +++ b/short_tracker/tasks/migrations/0004_task_tasks_task_create__81ab0d_idx.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.8 on 2024-02-13 13:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0003_task_hold_alter_task_archive_date_and_more'), + ] + + operations = [ + migrations.AddIndex( + model_name='task', + index=models.Index(fields=['create_date', 'status', 'deadline_date', 'performer'], name='tasks_task_create__81ab0d_idx'), + ), + ] diff --git a/short_tracker/tasks/models.py b/short_tracker/tasks/models.py index b295618..a1c1c12 100644 --- a/short_tracker/tasks/models.py +++ b/short_tracker/tasks/models.py @@ -33,11 +33,12 @@ class TaskStatus(models.TextChoices): verbose_name=_('creator'), help_text=_('The creator of the task'), ) - performers = models.ManyToManyField( + performer = models.ForeignKey( User, + on_delete=models.CASCADE, related_name='tasks_for_user', - verbose_name=_('performers'), - help_text=_('The performers of the task'), + verbose_name=_('performer'), + help_text=_('The performer of the task'), ) description = models.TextField( max_length=256, @@ -57,28 +58,28 @@ class TaskStatus(models.TextChoices): verbose_name=_('status'), help_text=_('The status of the task'), ) - create_date = models.DateField( + create_date = models.DateTimeField( auto_now_add=True, verbose_name=_('create date'), help_text=_('The create date of the task'), ) - inprogress_date = models.DateField( + inprogress_date = models.DateTimeField( verbose_name=_('"in progress" date'), help_text=_('The "in progress" date of the task'), blank=True, null=True, ) - done_date = models.DateField( + done_date = models.DateTimeField( verbose_name=_('done date'), help_text=_('The done date of the task'), blank=True, null=True, ) - deadline_date = models.DateField( + deadline_date = models.DateTimeField( verbose_name=_('deadline date'), help_text=_('The deadline date of the task'), ) - archive_date = models.DateField( + archive_date = models.DateTimeField( verbose_name=_('archive date'), help_text=_('The archive date of the task'), blank=True, @@ -88,11 +89,19 @@ class TaskStatus(models.TextChoices): verbose_name=_('get medals'), default=False, ) + hold = models.BooleanField( + default=False, + ) class Meta: verbose_name = _('task') verbose_name_plural = _('tasks') ordering = ('-create_date',) + indexes = [ + models.Index(fields=[ + 'create_date', 'status', 'deadline_date', 'performer', + ]), + ] def __str__(self): return self.description[:15] diff --git a/short_tracker/users/migrations/0001_initial.py b/short_tracker/users/migrations/0001_initial.py index 641be38..9d46285 100644 --- a/short_tracker/users/migrations/0001_initial.py +++ b/short_tracker/users/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.8 on 2024-01-22 13:42 +# Generated by Django 4.2.8 on 2024-02-12 14:37 import django.core.validators from django.db import migrations, models @@ -27,7 +27,7 @@ class Migration(migrations.Migration): ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), ('first_name', models.CharField(max_length=30, validators=[django.core.validators.RegexValidator(message='Только кирилица и дефис', regex='^[а-яА-Я\\-]{2,30}\\Z')], verbose_name='Имя')), ('last_name', models.CharField(max_length=30, validators=[django.core.validators.RegexValidator(message='Только кирилица и дефис', regex='^[а-яА-Я\\-]{2,30}\\Z')], verbose_name='Фамилия')), - ('telegram_nickname', models.CharField(max_length=32, validators=[django.core.validators.RegexValidator(message='nickname содержит недопустимый символ', regex='^@[a-zA-Z0-9_]{5,32}\\Z')], verbose_name='Никнейм Телеграм')), + ('telegram_nickname', models.CharField(max_length=33, unique=True, validators=[django.core.validators.RegexValidator(message='nickname содержит недопустимый символ', regex='^@[a-zA-Z0-9_]{5,33}\\Z')], verbose_name='Никнейм Телеграм')), ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.RegexValidator(message='email содержит недопустимый символ', regex='^((?!\\.)[\\w\\-_.]*[^.])(@\\w+)(\\.\\w+(\\.\\w+)?[^.\\W])$')], verbose_name='Электронная почта')), ('is_team_lead', models.BooleanField(default=False, verbose_name='Роль')), ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), diff --git a/short_tracker/users/models.py b/short_tracker/users/models.py index 11eaece..a0fddce 100644 --- a/short_tracker/users/models.py +++ b/short_tracker/users/models.py @@ -70,11 +70,12 @@ class CustomUser(AbstractUser): )], ) telegram_nickname = models.CharField( - max_length=32, + max_length=33, verbose_name='Никнейм Телеграм', + unique=True, validators=[ RegexValidator( - regex=r'^@[a-zA-Z0-9_]{5,32}\Z', + regex=r'^@[a-zA-Z0-9_]{5,33}\Z', message='nickname содержит недопустимый символ' )], ) diff --git a/short_tracker_bot/Dockerfile b/short_tracker_bot/Dockerfile index ebfab4a..c45362d 100644 --- a/short_tracker_bot/Dockerfile +++ b/short_tracker_bot/Dockerfile @@ -3,12 +3,15 @@ FROM python:3.9 WORKDIR /app COPY short_tracker_bot/requirements.txt . +#COPY requirements.txt . RUN pip install -r requirements.txt --no-cache-dir +RUN chmod 755 . + COPY ./short_tracker_bot . -# RUN python3 short_tracker_bot/bot.py -WORKDIR /app/short_tracker_bot +WORKDIR /app/short_tracker_bot/ +#WORKDIR /app CMD ["python3", "bot.py"] \ No newline at end of file diff --git a/short_tracker_bot/requirements.txt b/short_tracker_bot/requirements.txt index 57704fa..55ebbc4 100644 Binary files a/short_tracker_bot/requirements.txt and b/short_tracker_bot/requirements.txt differ diff --git a/short_tracker_bot/short_tracker_bot/bot.py b/short_tracker_bot/short_tracker_bot/bot.py index 5a44f33..e80838e 100644 --- a/short_tracker_bot/short_tracker_bot/bot.py +++ b/short_tracker_bot/short_tracker_bot/bot.py @@ -4,18 +4,20 @@ import dotenv from aiogram import Bot, Dispatcher -from aiogram.fsm.storage.memory import MemoryStorage +from aiogram.fsm.storage.redis import RedisStorage +from redis import asyncio as aioredis from config import COMMANDS -from handlers.hello import router +from handlers.fsm import router dotenv.load_dotenv() async def main(): bot = Bot(token=os.getenv('TOKEN')) - memory = MemoryStorage() - dp = Dispatcher(memory=memory) + redis = aioredis.Redis(host='redis') + storage = RedisStorage(redis=redis) + dp = Dispatcher(storage=storage) dp.include_router(router) await bot.set_my_commands(commands=COMMANDS) await dp.start_polling(bot) diff --git a/short_tracker_bot/short_tracker_bot/config.py b/short_tracker_bot/short_tracker_bot/config.py index 9f26015..fea35ac 100644 --- a/short_tracker_bot/short_tracker_bot/config.py +++ b/short_tracker_bot/short_tracker_bot/config.py @@ -1,7 +1,11 @@ from aiogram.types import BotCommand - TOKEN = '6787434498:AAHW5Y2gUJQLqttiQ-Vj9qulyBoTCHYYVu4' URL = 'https://short-tracker.acceleratorpracticum.ru/api/v1/' COMMANDS = [BotCommand(command='/start', description='Запуск бота') - ] \ No newline at end of file + ] +HEADERS = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + ' AppleWebKit/537.36 (KHTML, like Gecko)' + ' Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0' +} diff --git a/short_tracker_bot/short_tracker_bot/handlers/api_data.py b/short_tracker_bot/short_tracker_bot/handlers/api_data.py new file mode 100644 index 0000000..b361285 --- /dev/null +++ b/short_tracker_bot/short_tracker_bot/handlers/api_data.py @@ -0,0 +1,193 @@ +import asyncio +import logging + +import aiohttp +from aiogram import Bot +from aiogram.fsm.context import FSMContext + +from config import HEADERS, URL +from handlers.redis_data import get_data_from_redis, save_data_to_redis +from handlers.requests import request_get, request_post + + +async def get_token(state, chat_id, bot, new_state): + data_fsm = await state.get_data() + data = { + 'email': data_fsm['email'], + 'password': data_fsm['password'] + } + logging.info(f'email, password {data}') + try: + request = await request_post( + URL + 'auth/login/', + data, + headers=HEADERS + ) + logging.info(f'ЗАПРОС ТОКЕНА{request}') + token = request.cookies.get('jwt_access') + if token: + await state.update_data(new_token=f'Bearer {token.value}') + data_fsm = await state.get_data() + await bot.send_message( + chat_id=chat_id, + text='Вход успешно выполнен!') + return data_fsm['new_token'] + else: + await bot.send_message( + data_fsm['chat_id'], + 'Неверный логин или пароль. Введите пароль еще раз:') + await state.set_state(new_state) + except aiohttp.ClientResponseError as e: + logging.error(f'Ошибка при получении данных: {e}') + + +async def get_messages(data, chat_id, bot: Bot): + for msg in data['messages']: + logging.info(f'MESSAGE {msg}') + message_in_redis = await get_data_from_redis( + f'{chat_id}_msg_{msg["id"]}' + ) + logging.info(f'СООБЩЕНИЕ В БАЗЕ{message_in_redis}') + if not message_in_redis: + await save_data_to_redis( + f'{chat_id}_msg_{msg["id"]}', msg['message_body'] + ) + await bot.send_message( + chat_id, + text=f'У вас новое сообщение\n\"{msg["message_body"]}\"' + ) + for reply in msg['reply']: + logging.info(f'ENTRY REPLY {reply}') + reply_in_redis = await get_data_from_redis( + f'{chat_id}_reply_{msg["id"]}' + ) + if not reply_in_redis: + await save_data_to_redis( + f'{chat_id}_reply_{msg["id"]}', + reply["reply_body"] + ) + await bot.send_message( + chat_id, + text=f'На ваш запрос к лиду поступил ответ:' + f'\n{reply["reply_body"]}' + ) + + +async def get_tasks(task, chat_id, bot: Bot): + old_task = await get_data_from_redis(f'{chat_id}_task_{task["id"]}') + logging.info('вход задачи') + + if not old_task: + logging.info('Задача отсутствует в базе') + await bot.send_message( + chat_id=chat_id, + text=f'У Вас появилась новая задача' + f' \"{task["description"]}\"' + ) + logging.info('Отправили сообщение о новой задачк') + await save_data_to_redis( + f'{chat_id}_task_{task["id"]}', + task['status'] + ) + logging.info('Сохранили в базу') + + +async def get_status(task, chat_id, bot: Bot): + logging.info('вход статус') + current_status = await get_data_from_redis( + f'{chat_id}_task_status_{task["id"]}') + logging.info(current_status) + if task['status'] != current_status: + await save_data_to_redis( + f'{chat_id}_task_status_{task["id"]}', + task["status"] + ) + await bot.send_message( + chat_id=chat_id, + text=f'Изменен статус задачи ' + f'\"{task["description"]}\" на {task["status"]}') + + +async def get_deadline(task, chat_id, bot: Bot): + old_data_deadline = await get_data_from_redis( + f'{chat_id}_status_{task["id"]}' + ) + if task['is_expired'] and not old_data_deadline: + logging.info('expired') + logging.info('dedline') + await save_data_to_redis( + f'{chat_id}_status_{task["id"]}', + f'{task["deadline_date"]}_{chat_id}' + ) + logging.info('save deadline to redis') + logging.info(task) + performers = [ + performer['first_name'] for performer in task['performers'] + ] + logging.info('performers') + await bot.send_message( + chat_id=chat_id, + text=f'Задача \"{task["description"]}\" сотрудника' + f' {", ".join(performers)} просрочена' + ) + + +async def get_allows(allows, data, chat_id, bot): + if allows['notification'] == 'msg' and allows['allow_notification']: + logging.info( + f'Entry msg {allows["notification"]} ' + f'{allows["allow_notification"]}' + ) + await get_messages(data, chat_id, bot) + if allows['notification'] == 'status' and allows['allow_notification']: + logging.info( + f'Entry status {allows["notification"]}' + f' {allows["allow_notification"]}' + ) + for task in data['tasks_for_user']: + await get_status(task, chat_id, bot) + if allows['notification'] == 'deadline' and allows['allow_notification']: + logging.info( + f'Entry deadline {allows["notification"]} ' + f'{allows["allow_notification"]}' + ) + for task in data['tasks_for_user']: + await get_deadline(task, chat_id, bot) + if allows['notification'] == 'tasks' and allows['allow_notification']: + logging.info( + f'Entry tasks {allows["notification"]} ' + f'{allows["allow_notification"]}' + ) + for task in data['tasks_for_user']: + logging.info(f'task for {task}') + await get_tasks(task, chat_id, bot) + + +async def get_data(state: FSMContext, bot: Bot): + data_fsm = await state.get_data() + token = data_fsm['new_token'] + headers = { + 'Authorization': token, + } + headers.update(HEADERS) + chat_id = data_fsm['chat_id'] + while True: + try: + data = await request_get( + URL + 'bot/', + headers=headers) + if data.get('detail'): + logging.info(f'Запрос токена при истечении срока {token}') + new_token = await get_token(state, bot, chat_id) + headers['Authorization'] = new_token + data = await request_get( + URL + 'bot/', + headers=headers) + allows = data['results'][0]['allow'] + data = data['results'][0] + for allow in allows: + await get_allows(allow, data, chat_id, bot) + except aiohttp.ClientResponseError as e: + logging.error(f'Ошибка при получении данных: {e}') + finally: + await asyncio.sleep(15) diff --git a/short_tracker_bot/short_tracker_bot/handlers/fsm.py b/short_tracker_bot/short_tracker_bot/handlers/fsm.py new file mode 100644 index 0000000..6d61c45 --- /dev/null +++ b/short_tracker_bot/short_tracker_bot/handlers/fsm.py @@ -0,0 +1,39 @@ +from aiogram import Bot, Router, types +from aiogram.filters import CommandStart +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import State, StatesGroup + +from .api_data import get_data, get_token + +router = Router() + + +class Login(StatesGroup): + email = State() + password = State() + new_token = State() + + +@router.message(CommandStart()) +async def email(message: types.Message, state: FSMContext): + await state.update_data(chat_id=message.chat.id) + await state.set_state(Login.email) + await message.answer('Введите свою почту') + + +@router.message(Login.email) +async def password(message: types.Message, state: FSMContext): + await state.update_data(email=message.text) + await state.set_state(Login.password) + await message.answer('Введите пароль') + await message.delete() + + +@router.message(Login.password) +async def main_func(message: types.Message, state: FSMContext, bot: Bot): + data_fsm = await state.get_data() + chat_id = data_fsm['chat_id'] + await state.update_data(password=message.text) + await get_token(state, chat_id, bot, Login.email) + await message.delete() + await get_data(state, bot) diff --git a/short_tracker_bot/short_tracker_bot/handlers/hello.py b/short_tracker_bot/short_tracker_bot/handlers/hello.py deleted file mode 100644 index 42b35a2..0000000 --- a/short_tracker_bot/short_tracker_bot/handlers/hello.py +++ /dev/null @@ -1,151 +0,0 @@ -import asyncio -import logging - -from aiogram import F, Router, Bot -from aiogram import types -from aiogram.filters import CommandStart -from keyboards.keyboards import start_keyboard -from aiogram.fsm.state import State, StatesGroup -from aiogram.fsm.context import FSMContext - -from .requests import request_get, request_post -from config import URL - -router = Router() -OLD_MESSAGES = [] -OLD_REPLIES = [] -TASKS_DICT = dict() -TASKS_EXPIRED = [] -HEADERS = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' - ' AppleWebKit/537.36 (KHTML, like Gecko)' - ' Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0' -} - - -class Login(StatesGroup): - email = State() - password = State() - new_token = State() - refresh_token = State() - - -async def get_token(state, bot): - data_fsm = await state.get_data() - data = { - 'email': data_fsm['email'], - 'password': data_fsm['password'] - } - logging.info(f'email, password {data}') - try: - request = await request_post( - URL + 'auth/login/', - data, - headers=HEADERS - ) - logging.info(f'ЗАПРОС ТОКЕНА{request}') - token = request.cookies.get('jwt_access') - if token: - await state.update_data(new_token=f'Bearer {token.value}') - data_fsm = await state.get_data() - logging.info(f'NEW TOKEN {data_fsm["new_token"]}') - return data_fsm['new_token'] - else: - await bot.send_message( - data_fsm['chat_id'], - 'Неверный логин или пароль. Введите пароль еще раз:') - await state.set_state(Login.email) - except Exception: - await bot.send_message(data_fsm['chat_id'], 'Нет соединения') - - -async def get_messages(data, chat_id, bot: Bot): - for msg in data['results'][0]['messages']: - if msg['id'] not in OLD_MESSAGES: - OLD_MESSAGES.append(msg['id']) - await bot.send_message( - chat_id, - text=f'У вас новое сообщение\n\"{msg["message_body"]}\"' - ) - for reply in msg['reply']: - if reply['id'] not in OLD_REPLIES: - OLD_REPLIES.append(reply['id']) - await bot.send_message( - chat_id, - text=f'На ваш запрос к лиду поступил ответ:\n{reply["reply_body"]}' - ) - - -async def get_data(state: FSMContext, bot: Bot): - data_fsm = await state.get_data() - token = data_fsm['new_token'] - headers = { - 'Authorization': token, - } - headers.update(HEADERS) - chat_id = data_fsm['chat_id'] - logging.info(f'HEADERS {headers}') - while True: - try: - data = await request_get( - URL + 'bot/', - headers=headers) - logging.info(f'ЗАПРОС ДАННЫХ {data}') - if data.get('detail'): - logging.info(f'Запрос токена при истечении срока {token}') - new_token = await get_token(state, bot) - headers['Authorization'] = new_token - logging.info(f'NEW Token {new_token}') - logging.info(f'New headers {headers}') - data = await request_get( - URL + 'bot/', - headers=headers) - logging.info('Запрос сообщений') - await get_messages(data, chat_id, bot) - for task in data['results'][0]['tasks_for_user']: - if task['id'] not in TASKS_DICT.keys(): - await bot.send_message( - chat_id=chat_id, - text=f'У Вас появилась новая задача \"{task["description"]}\"' - ) - TASKS_DICT[task['id']] = task['status'] - if task['status'] != TASKS_DICT[task['id']]: - TASKS_DICT[task['id']] = task['status'] - await bot.send_message( - chat_id=chat_id, - text=f'Изменен статус задачи \"{task["description"]}\" на {task["status"]}') - if task['is_expired'] and task['id'] not in TASKS_EXPIRED: - TASKS_EXPIRED.append(task['id']) - performers = [performer['full_name'] for performer in task['performers']] - await bot.send_message( - chat_id=chat_id, - text=f'Задача \"{task["description"]}\" сотрудника' - f' {", ".join(performers)} просрочена' - ) - except Exception: - logging.error('Не удалось получить данные') - finally: - await asyncio.sleep(600) - - -@router.message(CommandStart()) -async def email(message: types.Message, state: FSMContext): - await state.update_data(chat_id=message.chat.id) - await state.set_state(Login.email) - await message.answer('Введите свою почту') - - -@router.message(Login.email) -async def password(message: types.Message, state: FSMContext): - await state.update_data(email=message.text) - await state.set_state(Login.password) - await message.answer('Введите пароль') - await message.delete() - - -@router.message(Login.password) -async def main_func(message: types.Message, state: FSMContext, bot: Bot): - await state.update_data(password=message.text) - await get_token(state, bot) - await message.delete() - await get_data(state, bot) diff --git a/short_tracker_bot/short_tracker_bot/handlers/redis_data.py b/short_tracker_bot/short_tracker_bot/handlers/redis_data.py new file mode 100644 index 0000000..455067b --- /dev/null +++ b/short_tracker_bot/short_tracker_bot/handlers/redis_data.py @@ -0,0 +1,15 @@ + +import aioredis + +redis = aioredis.Redis(host='redis') + + +async def save_data_to_redis(key, value): + await redis.set(key, value) + + +async def get_data_from_redis(key): + data = await redis.get(key) + if data: + return data.decode('utf-8') + return False diff --git a/short_tracker_bot/short_tracker_bot/handlers/requests.py b/short_tracker_bot/short_tracker_bot/handlers/requests.py index 192966b..5311e8a 100644 --- a/short_tracker_bot/short_tracker_bot/handlers/requests.py +++ b/short_tracker_bot/short_tracker_bot/handlers/requests.py @@ -1,10 +1,15 @@ +import logging + import aiohttp async def request_get(url, headers=None): + logging.info(f'request func {url}') async with aiohttp.ClientSession() as session: async with session.get(url, headers=headers) as response: + logging.info(response) data = await response.json() + logging.info(data) return data diff --git a/short_tracker_bot/short_tracker_bot/keyboards/__init__.py b/short_tracker_bot/short_tracker_bot/keyboards/__init__.py index d62c163..e69de29 100644 --- a/short_tracker_bot/short_tracker_bot/keyboards/__init__.py +++ b/short_tracker_bot/short_tracker_bot/keyboards/__init__.py @@ -1 +0,0 @@ -from keyboards import keyboards \ No newline at end of file diff --git a/short_tracker_bot/short_tracker_bot/keyboards/keyboards.py b/short_tracker_bot/short_tracker_bot/keyboards/keyboards.py index 80ee1ee..276ac56 100644 --- a/short_tracker_bot/short_tracker_bot/keyboards/keyboards.py +++ b/short_tracker_bot/short_tracker_bot/keyboards/keyboards.py @@ -1,5 +1,4 @@ -from aiogram.utils.keyboard import ReplyKeyboardMarkup, KeyboardButton - +from aiogram.utils.keyboard import KeyboardButton, ReplyKeyboardMarkup keyboard_button = KeyboardButton(text='/войти') diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..295eee5 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,32 @@ +import os +import sys + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.append(BASE_DIR) + +root_dir_content = os.listdir(BASE_DIR) +PROJECT_DIR_NAME = 'short_tracker' + +if ( + PROJECT_DIR_NAME not in root_dir_content + or not os.path.isdir(os.path.join(BASE_DIR, PROJECT_DIR_NAME)) +): + assert False, ( + f'В директории `{BASE_DIR}` не найдена папка c проектом ' + f'`{PROJECT_DIR_NAME}`. Убедитесь, что у вас верная структура проекта.' + ) + +MANAGE_PATH = os.path.join(BASE_DIR, PROJECT_DIR_NAME) +project_dir_content = os.listdir(MANAGE_PATH) +FILENAME = 'manage.py' + +if FILENAME not in project_dir_content: + assert False, ( + f'В директории `{MANAGE_PATH}` не найден файл `{FILENAME}`. ' + 'Убедитесь, что у вас верная структура проекта.' + ) + +pytest_plugins = [ + 'tests.fixtures.fixture_user', + 'tests.fixtures.fixture_data', +] diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/fixture_data.py b/tests/fixtures/fixture_data.py new file mode 100644 index 0000000..75e3316 --- /dev/null +++ b/tests/fixtures/fixture_data.py @@ -0,0 +1,47 @@ +from datetime import date, timedelta + +import pytest + +from tasks.models import Task + + +@pytest.fixture +def create_task(user_1, team_lead_user): + def _create_task(creator, performers, has_deadline): + create_date = date.today() - timedelta(days=15) + inprogress_date = create_date + timedelta(days=4) + done_date = inprogress_date + timedelta(days=2) + creator_user = user_1 if creator == 'user_1' else team_lead_user + performers_users = [ + team_lead_user + if performer == 'team_lead' + else user_1 for performer in performers] + deadline_date = ( + done_date - timedelta(days=1) + if has_deadline else done_date + timedelta(days=3)) + task_data = Task.objects.create( + creator=creator_user, + link="https://short-tracker.acceleratorpracticum.ru/", + description=( + f'Task {"with" if has_deadline else "without"} deadline'), + status=Task.TaskStatus.DONE, + create_date=create_date, + inprogress_date=inprogress_date, + done_date=done_date, + deadline_date = deadline_date, + get_medals=True, + ) + task_data.performers.set(performers_users) + return task_data + return _create_task + + +@pytest.fixture +def tasks(create_task): + return [ + create_task('user_1', 'user_1', True), + create_task('team_lead_user', 'user_1', True), + create_task('team_lead_user', 'user_1', False), + create_task('team_lead_user', 'user_1', False), + create_task('team_lead_user', 'user_1', False), + ] diff --git a/tests/fixtures/fixture_user.py b/tests/fixtures/fixture_user.py new file mode 100644 index 0000000..34c9308 --- /dev/null +++ b/tests/fixtures/fixture_user.py @@ -0,0 +1,55 @@ +import pytest +from rest_framework.test import APIClient +from rest_framework_simplejwt.tokens import RefreshToken + + +@pytest.fixture +def user_1(django_user_model): + return django_user_model.objects.create( + email='user_1@egmail.com', + password='password123', + first_name='Иван', + last_name='Иванов', + telegram_nickname='@ivanov', + is_team_lead=False, + ) + + +@pytest.fixture +def team_lead_user(django_user_model): + return django_user_model.objects.create( + email='teamlead@gmail.com', + password='password321', + first_name='Алексей', + last_name='Алексеев', + telegram_nickname='@team_lead', + is_team_lead=True, + ) + +@pytest.fixture +def team_lead_token(team_lead_user): + refresh = RefreshToken.for_user(team_lead_user) + return { + 'refresh': str(refresh), + 'access': str(refresh.access_token), + } + +@pytest.fixture +def performer_token(user_1): + refresh = RefreshToken.for_user(user_1) + return { + 'refresh': str(refresh), + 'access': str(refresh.access_token), + } + +@pytest.fixture +def team_lead_client(team_lead_token): + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f'Bearer {team_lead_token["access"]}') + return client + +@pytest.fixture +def performer_client(performer_token): + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f'Bearer {performer_token["access"]}') + return client diff --git a/tests/test_analytics.py b/tests/test_analytics.py new file mode 100644 index 0000000..2d721cb --- /dev/null +++ b/tests/test_analytics.py @@ -0,0 +1,117 @@ +from datetime import datetime, timedelta +from http import HTTPStatus + +import pytest + +from users.models import CustomUser + + +@pytest.mark.django_db(transaction=True) +class TestTaskAnalytics: + base_url = '/api/v1/task-analytics/' + periods = [ + ("2023-02-10", "2024-02-20"), + ("", "2023-05-28"), + ("2023-12-01", ""), + ("", "") + ] + + def build_url(self, start_date=None, end_date=None): + url = f"{self.base_url}?" + if start_date: + url += f"start_date={start_date}" + if end_date: + url += f"&end_date={end_date}" + return url + + @pytest.mark.parametrize("start_date, end_date", periods) + def test_analytics_endpoint( + self, team_lead_client, tasks, start_date, end_date + ): + + custom_url = self.build_url( + start_date=start_date, end_date=end_date) + response = team_lead_client.get(custom_url) + assert response.status_code == HTTPStatus.OK, ( + 'Проверьте, что GET-запрос авторизованного пользователя к ' + f'{custom_url}` возвращает статус 200.' + ) + data =response.json() + assert isinstance(data, dict), ( + 'Проверьте, что GET-запрос тимлида возвращает словарь.' + ) + + def test_perform_access( + self, performer_client, tasks): + custom_url = self.build_url() + assert_msg = ( + 'Проверьте, что GET-запрос пользователю без прав к ' + f'`{custom_url}` возвращает ответ со статусом 403.' + ) + try: + response = performer_client.get(custom_url) + except TypeError as error: + raise AssertionError( + assert_msg + ( + f' В процессе выполнения запроса произошла ошибка: {error}' + ) + ) + assert response.status_code == HTTPStatus.FORBIDDEN, assert_msg + + def test_access_not_auth( + self, client, tasks): + custom_url = self.build_url() + assert_msg = ( + 'Проверьте, что GET-запрос неавторизованного пользователя к ' + f'`{custom_url}` возвращает ответ со статусом 401.' + ) + try: + response = client.get(custom_url) + except TypeError as error: + raise AssertionError( + assert_msg + ( + f' В процессе выполнения запроса произошла ошибка: {error}' + ) + ) + assert response.status_code == HTTPStatus.UNAUTHORIZED, assert_msg + + def test_analytics_data(self, + team_lead_client, + tasks, + performers_analytics=None): + response = team_lead_client.get(self.build_url()) + data = response.json() + expected_fields = ( + 'total_tasks_on_time', + 'total_tasks_with_delay', + 'performers_analytics') + expected_performers_analytics_data = ( + 'performer_name', + 'completed_on_time_count', + 'completed_with_delay_count', + 'avg_time_create_date_to_inprogress_date', + 'avg_time_create_date_to_done_date', + 'avg_time_inprogress_date_to_done_date' + ) + for field in expected_fields: + assert field in data, ( + 'Проверьте, что для лида ответ на ' + f' GET-запрос содержит поле `{field}`.' + ) + if performers_analytics: + for field in performers_analytics: + assert field in expected_performers_analytics_data, ( + 'Проверьте, что для лида ответ на ' + f' GET-запрос содержит поле `{field}`.') + performers_analytics_data = data.get('performers_analytics', {}) + for performer_id in performers_analytics_data: + performer = CustomUser.objects.get(id=performer_id) + assert not performer.is_lead, ( + 'Убедитесь, что в аналитике отсутствуют лиды.') + + def test_default_page(self, team_lead_client): + response = team_lead_client.get(self.build_url()) + expected_tasks = team_lead_client.get( + self.build_url( + start_date=datetime.today() - timedelta(days=7))) + assert len(response.data) == len(expected_tasks.data)