From d5ade7b5c99b553920dd655cfabb14ee3f9f5ee1 Mon Sep 17 00:00:00 2001 From: Brunowar12 <128008317+Brunowar12@users.noreply.github.com> Date: Tue, 20 May 2025 12:52:47 +0300 Subject: [PATCH 01/12] fix and feat: + correct flat tasks output + pep standart for tasks app --- tasks/admin.py | 1 + tasks/mixins.py | 4 ++++ tasks/models.py | 6 +++--- tasks/serializers.py | 10 ++++++--- tasks/services.py | 25 ++++++++++++++++++++-- tasks/urls.py | 6 ++---- tasks/views.py | 51 ++++++++++++++++++++++++-------------------- 7 files changed, 68 insertions(+), 35 deletions(-) diff --git a/tasks/admin.py b/tasks/admin.py index c7a909c..3a4f1c8 100644 --- a/tasks/admin.py +++ b/tasks/admin.py @@ -1,4 +1,5 @@ from django.contrib import admin + from .models import Task, Category admin.site.register([Task, Category]) \ No newline at end of file diff --git a/tasks/mixins.py b/tasks/mixins.py index 69d4d25..08d3775 100644 --- a/tasks/mixins.py +++ b/tasks/mixins.py @@ -1,4 +1,5 @@ import logging + from django.core.exceptions import PermissionDenied from rest_framework.permissions import BasePermission, SAFE_METHODS @@ -11,13 +12,16 @@ class IsOwner(BasePermission): """ Checking whether the user is the owner of the object """ + def has_object_permission(self, request, view, obj): user_field = getattr(obj, "user", None) or getattr(obj, "owner", None) + if user_field is None: logger.error("Object has no ownership attribute") raise PermissionDenied( "Access denied: missing ownership information" ) + return user_field == request.user diff --git a/tasks/models.py b/tasks/models.py index 9f5842b..d07b81b 100644 --- a/tasks/models.py +++ b/tasks/models.py @@ -1,6 +1,7 @@ # mypy: disable-error-code=var-annotated import logging + from django.conf import settings from django.db import models from django.dispatch import receiver @@ -86,9 +87,8 @@ def save(self, *args, **kwargs): super().save(*args, **kwargs) def __str__(self): - return ( - f"{self.title} - {self.user.username if self.user else 'No user'}" - ) + user_display = self.user.username if self.user else "No user" + return f"{self.title} - {user_display}" @receiver(models.signals.post_save, sender=Task) diff --git a/tasks/serializers.py b/tasks/serializers.py index 0046584..d0cc90c 100644 --- a/tasks/serializers.py +++ b/tasks/serializers.py @@ -1,5 +1,6 @@ from django.utils import timezone from rest_framework import serializers + from .models import Task, Category @@ -15,13 +16,16 @@ class TaskSerializer(serializers.ModelSerializer): class Meta: model = Task - fields = ["id", "title", "description", "category", "category_name", + fields = [ + "id", "title", "description", "category", "category_name", "due_date", "priority", "completed", "is_favorite", "user", "user_name", "created_at", "updated_at", "completed_at", "completed_by", "completed_by_name" ] - read_only_fields = [ "id", "created_at", "updated_at", "user", - "completed_at", "user_name", "completed_by", "completed_by_name"] + read_only_fields = [ + "id", "created_at", "updated_at", "user", + "completed_at", "user_name", "completed_by", "completed_by_name" + ] def validate_due_date(self, value): if value is None: diff --git a/tasks/services.py b/tasks/services.py index ba7b3e0..25371cf 100644 --- a/tasks/services.py +++ b/tasks/services.py @@ -11,23 +11,35 @@ class TaskService: """ - A service for task operations - Responsible for business logic related to tasks + Service for task operations + + Handles business logic for toggling favorite/completion status + and moving tasks between projects """ @staticmethod def is_today_filter(request): + """ + Return True if 'today' query parameter is 'true' (case-insensitive) + """ today = request.query_params.get("today") return today and today.lower() == "true" @staticmethod def toggle_favorite(task): + """ + Toggle the is_favorite flag on a task and save it + """ task.is_favorite = not task.is_favorite task.save() return task @staticmethod def toggle_completed(task: 'Task', user: 'User') -> 'Task': + """ + Toggle the completed flag on a task, update timestamps, + and record the user who completed it + """ task.completed = not task.completed task.update_completed_at() @@ -41,6 +53,12 @@ def toggle_completed(task: 'Task', user: 'User') -> 'Task': @staticmethod def move_task_to_project(task, project_id, user): + """ + Move a task to a different project if the user has access + + Raises: + ValueError: If the project does not exist or access is denied + """ from projects.models import Project try: @@ -67,4 +85,7 @@ class CategoryService: @staticmethod def get_tasks_for_category(category): + """ + Return all tasks associated with a given category + """ return category.tasks.all() diff --git a/tasks/urls.py b/tasks/urls.py index bacf4df..cb86033 100644 --- a/tasks/urls.py +++ b/tasks/urls.py @@ -1,10 +1,8 @@ from django.urls import path, include from rest_framework.routers import SimpleRouter -from .views import ( - TaskViewSet, CategoryViewSet -) -# ViewSet routers +from .views import TaskViewSet, CategoryViewSet + router = SimpleRouter() router.register(r'', TaskViewSet, basename='task') diff --git a/tasks/views.py b/tasks/views.py index 10eae2e..498f746 100644 --- a/tasks/views.py +++ b/tasks/views.py @@ -1,11 +1,4 @@ import logging -from rest_framework import viewsets, status -from rest_framework.decorators import action -from rest_framework.exceptions import NotFound, PermissionDenied -from rest_framework.filters import OrderingFilter, SearchFilter -from rest_framework.permissions import IsAuthenticated, SAFE_METHODS -from rest_framework.response import Response - from django.contrib.auth import get_user_model from django.db.models import Q from django_filters.rest_framework import DjangoFilterBackend @@ -15,32 +8,41 @@ from django.views.decorators.cache import cache_page from drf_yasg.utils import swagger_auto_schema +from rest_framework import viewsets, status +from rest_framework.decorators import action +from rest_framework.exceptions import NotFound, PermissionDenied +from rest_framework.filters import OrderingFilter, SearchFilter +from rest_framework.permissions import IsAuthenticated, SAFE_METHODS +from rest_framework.response import Response + from api.mixins import UserQuerysetMixin from api.utils import error_response, status_response from projects.permissions import IsProjectMinRole +from .mixins import IsOwner, ProjectTaskPermission +from .models import Task, Category +from .services import TaskService, CategoryService from .serializers import ( TaskSerializer, CategorySerializer, ToggleCompletedResponseSerializer, ToggleFavoriteResponseSerializer, MoveTaskResponseSerializer, MoveTaskSerializer, ) -from .services import TaskService, CategoryService -from .mixins import IsOwner, ProjectTaskPermission -from .models import Task, Category logger = logging.getLogger(__name__) User = get_user_model() class TaskViewSet(UserQuerysetMixin, viewsets.ModelViewSet): """ - ViewSet for operations with tasks - - Allows you to view, create, edit, and delete tasks - Includes additional methods for changing the favorite status and completion + ViewSet for operations with tasks. + + Allows viewing, creating, editing, and deleting tasks. + Includes extra actions for toggling favorite/completed status + and moving tasks between projects. """ + + queryset = Task.objects.all() serializer_class = TaskSerializer permission_classes = [IsAuthenticated, IsOwner] - queryset = Task.objects.all() filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] search_fields = ["title", "description"] filterset_fields = ["completed", "priority", "is_favorite", "category"] @@ -56,12 +58,14 @@ def get_permissions(self): def get_queryset(self): qs = super().get_queryset() - filters = Q(user=self.request.user) project_id = self.kwargs.get("project_pk") if project_id is not None: - return Task.objects.filter(project_id=project_id) - + # Nested: all tasks in given project + return qs.filter(project_id=project_id) + + # Nested: all tasks in given project + filters = Q(user=self.request.user, project__isnull=True) if TaskService.is_today_filter(self.request): filters &= Q(due_date__date=now().date()) @@ -210,14 +214,15 @@ def nested_move_task(self, request, project_pk=None, pk=None): class CategoryViewSet(UserQuerysetMixin, viewsets.ModelViewSet): """ - ViewSet for operations with categories - - Allows you to view, create, edit, and delete categories - Includes an additional method for getting tasks in a category + ViewSet for operations with categories. + + Allows viewing, creating, editing, and deleting categories. + Includes an extra action to list tasks within a category. """ + + queryset = Category.objects.all() serializer_class = CategorySerializer permission_classes = [IsAuthenticated, IsOwner] - queryset = Category.objects.all() @action(detail=True, methods=["get"]) def tasks(self, request, pk=None): From 1ec39cbbb9ecba21086a9e64ca253bdd128970e7 Mon Sep 17 00:00:00 2001 From: Brunowar12 <128008317+Brunowar12@users.noreply.github.com> Date: Tue, 20 May 2025 14:03:40 +0300 Subject: [PATCH 02/12] fix & feat: + added more docstrings and comms in user app + email field now have help_text --- tasks/models.py | 24 +++++-- users/migrations/0003_alter_user_email.py | 18 +++++ users/models.py | 87 +++++++++++++++++------ users/serializers.py | 19 +++-- users/urls.py | 2 +- users/views.py | 12 +++- 6 files changed, 129 insertions(+), 33 deletions(-) create mode 100644 users/migrations/0003_alter_user_email.py diff --git a/tasks/models.py b/tasks/models.py index d07b81b..84d25a6 100644 --- a/tasks/models.py +++ b/tasks/models.py @@ -14,6 +14,7 @@ class Category(models.Model): + """A category of tasks linked to a specific user""" name = models.CharField( max_length=20, validators=[TEXT_FIELD_VALIDATOR] @@ -33,6 +34,12 @@ def __str__(self): class Task(models.Model): + """ + User tasks: + - tracking of deadline, priority, category and project + - automatic update of the completion time + - logs of completed tasks (via signal) + """ PRIORITY_CHOICES = [ ('L', 'Low'), ('M', 'Medium'), @@ -40,22 +47,26 @@ class Task(models.Model): ] title = models.CharField(max_length=64, validators=[TEXT_FIELD_VALIDATOR]) - description = models.TextField(blank=True, - validators=[TEXT_FIELD_VALIDATOR]) + description = models.TextField( + blank=True, validators=[TEXT_FIELD_VALIDATOR] + ) due_date = models.DateTimeField() + user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="tasks") category = models.ForeignKey( Category, on_delete=models.SET_NULL, null=True, blank=True, - related_name="tasks") + related_name="tasks" + ) project = models.ForeignKey( Project, on_delete=models.CASCADE, related_name="tasks", null=True, blank=True ) - priority = models.CharField(max_length=1, choices=PRIORITY_CHOICES, - default='M') + priority = models.CharField( + max_length=1, choices=PRIORITY_CHOICES, default='M' + ) is_favorite = models.BooleanField(default=False, verbose_name="Favorite") created_at = models.DateTimeField(auto_now_add=True) @@ -77,12 +88,14 @@ class Meta: ordering = ['id'] def update_completed_at(self): + """Update the completed_at field when the task is marked as completed""" if self.completed and not self.completed_at: self.completed_at = timezone.now() elif not self.completed and self.completed_at: self.completed_at = None def save(self, *args, **kwargs): + """Before saving, update completed_at depending on completed""" self.update_completed_at() super().save(*args, **kwargs) @@ -93,6 +106,7 @@ def __str__(self): @receiver(models.signals.post_save, sender=Task) def update_user_last_task_completed(sender, instance, created, **kwargs): + """Signal: if the task is just completed, update user.last_task_completed_at""" if instance.completed and instance.user: instance.user.last_task_completed_at = timezone.now() instance.user.save(update_fields=["last_task_completed_at"]) diff --git a/users/migrations/0003_alter_user_email.py b/users/migrations/0003_alter_user_email.py new file mode 100644 index 0000000..18eccc2 --- /dev/null +++ b/users/migrations/0003_alter_user_email.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.9 on 2025-05-20 13:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0002_alter_user_place_of_work'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='email', + field=models.EmailField(help_text='Email must be unique and is used for login', max_length=254, unique=True, verbose_name='email'), + ), + ] diff --git a/users/models.py b/users/models.py index 168fb8e..93b5306 100644 --- a/users/models.py +++ b/users/models.py @@ -2,48 +2,93 @@ from django.core.validators import MinValueValidator, MaxValueValidator from django.db import models, transaction from django.utils.crypto import get_random_string -from api.validators import USERNAME_VALIDATOR, TEXT_FIELD_VALIDATOR, PHONE_NUMBER_VALIDATOR + +from api.validators import ( + USERNAME_VALIDATOR, + TEXT_FIELD_VALIDATOR, + PHONE_NUMBER_VALIDATOR, +) class User(AbstractUser): - username = models.CharField(max_length=80, + """ + Custom user model extending AbstractUser: + - uses email as USERNAME_FIELD + - automatically generates a unique username if not specified + - contains additional dates and profile fields + """ + + username = models.CharField( + max_length=80, + unique=True, + verbose_name="username", + validators=[USERNAME_VALIDATOR], + ) + email = models.EmailField( unique=True, - verbose_name="username", - validators=[USERNAME_VALIDATOR]) + verbose_name="email", + help_text="Email must be unique and is used for login", + ) age = models.PositiveIntegerField( + blank=True, + null=True, validators=[MinValueValidator(6), MaxValueValidator(100)], - blank=True, null=True, verbose_name="age", - help_text="Age must be between 6 and 100 years") - email = models.EmailField(unique=True, verbose_name="email") - place_of_work = models.CharField(max_length=256, blank=True, + verbose_name="age", + help_text="Age must be between 6 and 100 years", + ) + place_of_work = models.CharField( + max_length=256, + blank=True, validators=[TEXT_FIELD_VALIDATOR], - verbose_name="place of work",) - phone_number = models.CharField(max_length=15, blank=True, - verbose_name="phone number", validators=[PHONE_NUMBER_VALIDATOR]) - last_login_at = models.DateTimeField(blank=True, null=True, - verbose_name="last login") - last_profile_edit_at = models.DateTimeField(blank=True, null=True, - verbose_name="last profile edit") - last_task_completed_at = models.DateTimeField(blank=True, null=True, - verbose_name="last task completed") + verbose_name="place of work", + ) + phone_number = models.CharField( + max_length=15, + blank=True, + validators=[PHONE_NUMBER_VALIDATOR], + verbose_name="phone number", + ) + last_login_at = models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ) + last_profile_edit_at = models.DateTimeField( + blank=True, null=True, verbose_name="last profile edit" + ) + last_task_completed_at = models.DateTimeField( + blank=True, null=True, verbose_name="last task completed" + ) USERNAME_FIELD = "email" REQUIRED_FIELDS = ["username"] def save(self, *args, **kwargs): + """ + When creating a user, if the username is not specified, + we generate it based on email + """ + if self.email: + self.email = self.email.lower() + if not self.pk and not self.username: self.username = self.generate_username() + super().save(*args, **kwargs) @transaction.atomic def generate_username(self): - base_username = self.email.split('@')[0] - max_base_length = self._meta.get_field('username').max_length - 10 # reserve for random suffix + """ + Forms the base part from email (up to "@"), shortens it to max_length-10 + If such a username exists, adds a random suffix of length 10 + """ + base_username = self.email.split("@", 1)[0] + max_base_length = self._meta.get_field('username').max_length - 10 base_username = base_username[:max_base_length] - new_username = base_username - + + new_username = base_username + while User.objects.filter(username=new_username).select_for_update().exists(): random_suffix = get_random_string(length=10) new_username = f"{base_username}{random_suffix}" + return new_username def __str__(self): diff --git a/users/serializers.py b/users/serializers.py index 11227e6..6ae9670 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -4,6 +4,7 @@ from rest_framework import serializers from rest_framework.validators import UniqueValidator from rest_framework_simplejwt.tokens import RefreshToken + from .models import User User = get_user_model() @@ -23,6 +24,7 @@ class Meta: fields = ["email", "password"] def create(self, validated_data): + """ Creates a user instance with hashed password """ user = User(email=validated_data["email"],) user.set_password(validated_data["password"]) user.save() @@ -30,17 +32,25 @@ def create(self, validated_data): class UserLoginSerializer(serializers.Serializer): email = serializers.EmailField(help_text="User email") - password = serializers.CharField(write_only=True, help_text="User password") + password = serializers.CharField( + write_only=True, help_text="User password" + ) def validate(self, data): + """ Validates user credentials and returns JWT tokens """ user = authenticate(email=data["email"], password=data["password"]) + if not user: - raise serializers.ValidationError("Invalid credentials") - + raise serializers.ValidationError( + {"detail": "Invalid email or password"} + ) + + # Update last login time user.last_login_at = now() user.save(update_fields=["last_login_at"]) - + token = RefreshToken.for_user(user) + return { "username": user.username, "email": user.email, @@ -75,5 +85,6 @@ class Meta: "last_task_completed_at"] def update(self, instance, validated_data): + """ Automatically update 'last_profile_edit_at' timestamp on profile update """ instance.last_profile_edit_at = now() return super().update(instance, validated_data) diff --git a/users/urls.py b/users/urls.py index 7bf144a..c905be8 100644 --- a/users/urls.py +++ b/users/urls.py @@ -12,7 +12,7 @@ urlpatterns = [ path('', include(router.urls)), - # Token URLs remain separate + # JWT Token endpoints path("token/", include([ path("refresh/", TokenRefreshView.as_view(), name="token_refresh"), path('blacklist/', TokenBlacklistView.as_view(), name='token_blacklist'), diff --git a/users/views.py b/users/views.py index 8bfdbd9..8d63db2 100644 --- a/users/views.py +++ b/users/views.py @@ -5,7 +5,6 @@ from rest_framework.throttling import AnonRateThrottle from api.utils import error_response - from .serializers import ( UserRegistrationSerializer, UserLoginSerializer, UserProfileSerializer @@ -16,12 +15,15 @@ class AuthViewSet(viewsets.GenericViewSet): """ Handles user registration, login, and logout """ + throttle_classes = [AnonRateThrottle] def get_serializer_class(self): + # Used for schema generation if getattr(self, 'swagger_fake_view', False): return serializers.Serializer + # Dynamically choose serializer based on action if self.action == 'register': return UserRegistrationSerializer elif self.action == 'login': @@ -34,16 +36,19 @@ def get_permissions(self): @action(detail=False, methods=['post']) def register(self, request): + """ Registers a new user """ data = UserService.register_user(request.data) return Response(data, status=status.HTTP_201_CREATED) @action(detail=False, methods=['post']) def login(self, request): + """ Logs in a user and returns tokens """ data = UserService.login_user(request.data) return Response(data, status=status.HTTP_200_OK) @action(detail=False, methods=['post']) def logout(self, request): + """ Logs out a user by blacklisting the refresh token """ refresh_token = request.data.get("refresh") error_messages_map = { "Invalid token format": "Incorrect token format", @@ -53,6 +58,7 @@ def logout(self, request): if not refresh_token: return error_response("Refresh token is required") + try: data = UserService.logout_user(refresh_token) return Response(data, status=status.HTTP_200_OK) @@ -69,17 +75,19 @@ def logout(self, request): class UserViewSet(viewsets.GenericViewSet): """ - Handles viewing and updating user profile + Handles viewing and updating the authenticated user's profile """ permission_classes = [IsAuthenticated] serializer_class = UserProfileSerializer @action(detail=False, methods=['get']) def profile(self, request): + """ Returns the authenticated user's profile """ serializer = self.get_serializer(request.user) return Response(serializer.data) @action(detail=False, methods=['put', 'patch']) def update_profile(self, request): + """ Updates the authenticated user's profile """ data = UserService.update_profile(request.user, request.data) return Response(data) From ac2411fddb0a64269b0e95d001cd63ef3c221101 Mon Sep 17 00:00:00 2001 From: Brunowar12 <128008317+Brunowar12@users.noreply.github.com> Date: Tue, 20 May 2025 14:04:13 +0300 Subject: [PATCH 03/12] feat: + commas in text field validators --- api/validators.py | 4 +-- .../migrations/0005_alter_project_name.py | 19 ++++++++++++ ...ry_name_alter_task_description_and_more.py | 29 +++++++++++++++++++ .../0004_alter_user_place_of_work.py | 19 ++++++++++++ 4 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 projects/migrations/0005_alter_project_name.py create mode 100644 tasks/migrations/0005_alter_category_name_alter_task_description_and_more.py create mode 100644 users/migrations/0004_alter_user_place_of_work.py diff --git a/api/validators.py b/api/validators.py index 550d656..32bcf78 100644 --- a/api/validators.py +++ b/api/validators.py @@ -1,8 +1,8 @@ from django.core.validators import RegexValidator TEXT_FIELD_VALIDATOR = RegexValidator( - r"^[a-zA-Zа-яА-ЯёЁіІїЇєЄґҐ0-9_. -]+$", - "Text can contain letters (latin/cyrillic), numbers, underscores, dots, dashes and spaces", + r"^[a-zA-Zа-яА-ЯёЁіІїЇєЄґҐ0-9_. , -]+$", + "Text can contain letters (latin/cyrillic), numbers, underscores, dots, dashes, commas and spaces", ) USERNAME_VALIDATOR = RegexValidator( diff --git a/projects/migrations/0005_alter_project_name.py b/projects/migrations/0005_alter_project_name.py new file mode 100644 index 0000000..90fea68 --- /dev/null +++ b/projects/migrations/0005_alter_project_name.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1.9 on 2025-05-20 14:01 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0004_projectsharelink_is_active_alter_project_name'), + ] + + operations = [ + migrations.AlterField( + model_name='project', + name='name', + field=models.CharField(max_length=128, validators=[django.core.validators.RegexValidator('^[a-zA-Zа-яА-ЯёЁіІїЇєЄґҐ0-9_. , -]+$', 'Text can contain letters (latin/cyrillic), numbers, underscores, dots, dashes, commas and spaces')]), + ), + ] diff --git a/tasks/migrations/0005_alter_category_name_alter_task_description_and_more.py b/tasks/migrations/0005_alter_category_name_alter_task_description_and_more.py new file mode 100644 index 0000000..4a4ba1c --- /dev/null +++ b/tasks/migrations/0005_alter_category_name_alter_task_description_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 5.1.9 on 2025-05-20 14:01 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0004_task_completed_by_alter_task_created_at'), + ] + + operations = [ + migrations.AlterField( + model_name='category', + name='name', + field=models.CharField(max_length=20, validators=[django.core.validators.RegexValidator('^[a-zA-Zа-яА-ЯёЁіІїЇєЄґҐ0-9_. , -]+$', 'Text can contain letters (latin/cyrillic), numbers, underscores, dots, dashes, commas and spaces')]), + ), + migrations.AlterField( + model_name='task', + name='description', + field=models.TextField(blank=True, validators=[django.core.validators.RegexValidator('^[a-zA-Zа-яА-ЯёЁіІїЇєЄґҐ0-9_. , -]+$', 'Text can contain letters (latin/cyrillic), numbers, underscores, dots, dashes, commas and spaces')]), + ), + migrations.AlterField( + model_name='task', + name='title', + field=models.CharField(max_length=64, validators=[django.core.validators.RegexValidator('^[a-zA-Zа-яА-ЯёЁіІїЇєЄґҐ0-9_. , -]+$', 'Text can contain letters (latin/cyrillic), numbers, underscores, dots, dashes, commas and spaces')]), + ), + ] diff --git a/users/migrations/0004_alter_user_place_of_work.py b/users/migrations/0004_alter_user_place_of_work.py new file mode 100644 index 0000000..25c33b9 --- /dev/null +++ b/users/migrations/0004_alter_user_place_of_work.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1.9 on 2025-05-20 14:01 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0003_alter_user_email'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='place_of_work', + field=models.CharField(blank=True, max_length=256, validators=[django.core.validators.RegexValidator('^[a-zA-Zа-яА-ЯёЁіІїЇєЄґҐ0-9_. , -]+$', 'Text can contain letters (latin/cyrillic), numbers, underscores, dots, dashes, commas and spaces')], verbose_name='place of work'), + ), + ] From 935cbb9b3dc6dd17b5ab49b94181af0c8f3860f6 Mon Sep 17 00:00:00 2001 From: Brunowar12 <128008317+Brunowar12@users.noreply.github.com> Date: Tue, 20 May 2025 14:16:01 +0300 Subject: [PATCH 04/12] feat and fix: + updated logout user service exceptions + validation error in assign role + fix tests --- api/tests/test_users.py | 15 ++++++++++----- projects/views.py | 3 +++ users/admin.py | 19 ++++++++++++------- users/services.py | 24 ++++++++++++++++-------- 4 files changed, 41 insertions(+), 20 deletions(-) diff --git a/api/tests/test_users.py b/api/tests/test_users.py index c24364a..c0f3bfb 100644 --- a/api/tests/test_users.py +++ b/api/tests/test_users.py @@ -120,8 +120,8 @@ def test_user_login_invalid_credentials(self): "Login with invalid credentials did not fail", ) self.assertIn( - "Invalid credentials", - response.data.get("non_field_errors", ""), + "Invalid email or password", + response.data.get("detail", ""), "Invalid credentials error not included in response", ) @@ -152,9 +152,14 @@ def test_user_logout_invalid_token(self): status.HTTP_400_BAD_REQUEST, "Logout with invalid token did not fail", ) - self.assertIn( - "Token is invalid or expired", - response.data.get("error", ""), + error_text = response.data.get("error", "").lower() + self.assertTrue( + any(msg in error_text for msg in [ + "incorrect token format", + "expired token", + "already been revoked", + "token is invalid or expired", + ]), "Invalid token error not included in response", ) diff --git a/projects/views.py b/projects/views.py index 370deb9..9c28449 100644 --- a/projects/views.py +++ b/projects/views.py @@ -1,5 +1,6 @@ import logging from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError from django.db import transaction from django.db.models import Q, Count from django.shortcuts import get_object_or_404 @@ -130,6 +131,8 @@ def assign_role(self, request, pk=None): try: ProjectMembershipService.assign_role(project, target, role) + except ValidationError as e: + return error_response(e.messages[0], exc=e) except Exception as e: message = getattr(e, 'detail', str(e)) return error_response(message, exc=e) diff --git a/users/admin.py b/users/admin.py index 31d7d9d..afe1840 100644 --- a/users/admin.py +++ b/users/admin.py @@ -1,9 +1,12 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin + from .models import User + class CustomUserAdmin(UserAdmin): - fieldsets = UserAdmin.fieldsets + ( + """Advanced user admin with additional profile fields""" + fieldsets = list(UserAdmin.fieldsets) + [ ( None, { @@ -11,16 +14,18 @@ class CustomUserAdmin(UserAdmin): "age", "place_of_work", "phone_number", - "last_login_at", - "last_profile_edit_at", + "last_login_at", + "last_profile_edit_at", "last_task_completed_at", ) }, ), - ) + ] readonly_fields = ( - "last_login_at", - "last_profile_edit_at", - "last_task_completed_at") + "last_login_at", + "last_profile_edit_at", + "last_task_completed_at", + ) + admin.site.register(User, CustomUserAdmin) diff --git a/users/services.py b/users/services.py index b2a0e6c..4d22663 100644 --- a/users/services.py +++ b/users/services.py @@ -12,40 +12,48 @@ class UserService: @staticmethod def register_user(data): """ - Processing of user registration. Validated data is transferred through the serializer. + Handle user registration + + Validates and saves the user via serializer """ serializer = UserRegistrationSerializer(data=data) serializer.is_valid(raise_exception=True) - serializer.save() - + serializer.save() return serializer.data @staticmethod def login_user(data): """ - User login processing. Authentication and return of JWT tokens are performed. + Handle user login + + Validates credentials and returns JWT token payload """ serializer = UserLoginSerializer(data=data) serializer.is_valid(raise_exception=True) - return serializer.validated_data @staticmethod def logout_user(refresh_token): """ - User logout processing (blacklist refresh-token). + Handle user logout by blacklisting the refresh token """ try: token = RefreshToken(refresh_token) token.blacklist() return {"message": "Successfully logged out"} except TokenError as e: - raise ValueError(f"Invalid token: {str(e)}") + msg = str(e).lower() + if "token is invalid or expired" in msg: + raise ValueError("Token expired") + elif "token is already blacklisted" in msg: + raise ValueError("Token already revoked") + else: + raise ValueError("Invalid token format") @staticmethod def update_profile(user, data): """ - Update your user profile. + Handle profile update for the authenticated user """ serializer = UserProfileSerializer(user, data=data, partial=True) serializer.is_valid(raise_exception=True) From 313c501fb2809613482a3f2d575d467407ed533a Mon Sep 17 00:00:00 2001 From: Brunowar12 <128008317+Brunowar12@users.noreply.github.com> Date: Wed, 21 May 2025 12:21:26 +0300 Subject: [PATCH 05/12] feat & fix: + remove token/blacklist endpoint + advanced log in tasks mixins + micro-updated tests --- api/tests/test_projects.py | 2 +- api/tests/test_tasks.py | 3 --- api/tests/test_users.py | 4 ++-- tasks/mixins.py | 3 ++- users/urls.py | 3 +-- users/views.py | 2 +- 6 files changed, 7 insertions(+), 10 deletions(-) diff --git a/api/tests/test_projects.py b/api/tests/test_projects.py index 87af44a..7d1bda1 100644 --- a/api/tests/test_projects.py +++ b/api/tests/test_projects.py @@ -12,7 +12,7 @@ User = get_user_model() -FIXED_ROLES = ["Admin", "Moderator", "Member", "Viewer"] +FIXED_ROLES = ("Admin", "Moderator", "Member", "Viewer") class ProjectsAPITests(BaseAPITestCase): @classmethod diff --git a/api/tests/test_tasks.py b/api/tests/test_tasks.py index 13bedeb..f438bba 100644 --- a/api/tests/test_tasks.py +++ b/api/tests/test_tasks.py @@ -360,7 +360,6 @@ def test_filter_by_status(self): self.future_task.save() response = self.client.get(self.task_list_ep, {"completed": "True"}) - print(response.data) self.assertEqual( response.status_code, @@ -384,7 +383,6 @@ def test_filter_by_priority(self): self.future_task.save() response = self.client.get(self.task_list_ep, {"priority": "M"}) - print(response.data) self.assertEqual( response.status_code, @@ -413,7 +411,6 @@ def test_filter_by_today(self): self.today_task.save() response = self.client.get(self.task_list_ep, {"today": "true"}) - print(response.data) self.assertEqual( response.status_code, diff --git a/api/tests/test_users.py b/api/tests/test_users.py index c0f3bfb..6adb32d 100644 --- a/api/tests/test_users.py +++ b/api/tests/test_users.py @@ -149,7 +149,7 @@ def test_user_logout_invalid_token(self): self.assertEqual( response.status_code, - status.HTTP_400_BAD_REQUEST, + status.HTTP_401_UNAUTHORIZED, "Logout with invalid token did not fail", ) error_text = response.data.get("error", "").lower() @@ -160,7 +160,7 @@ def test_user_logout_invalid_token(self): "already been revoked", "token is invalid or expired", ]), - "Invalid token error not included in response", + f"Expected error message not found in response: {error_text}", ) def test_user_registration_duplicate_email(self): diff --git a/tasks/mixins.py b/tasks/mixins.py index 08d3775..ca04c1f 100644 --- a/tasks/mixins.py +++ b/tasks/mixins.py @@ -17,7 +17,7 @@ def has_object_permission(self, request, view, obj): user_field = getattr(obj, "user", None) or getattr(obj, "owner", None) if user_field is None: - logger.error("Object has no ownership attribute") + logger.error(f"Object {type(obj).__name__} has no ownership attribute") raise PermissionDenied( "Access denied: missing ownership information" ) @@ -31,6 +31,7 @@ class ProjectTaskPermission(BasePermission): """ def has_permission(self, request, view): project_pk = view.kwargs.get('project_pk') + if not project_pk: return True diff --git a/users/urls.py b/users/urls.py index c905be8..b3f03d2 100644 --- a/users/urls.py +++ b/users/urls.py @@ -1,6 +1,6 @@ from django.urls import path, include from rest_framework.routers import SimpleRouter -from rest_framework_simplejwt.views import TokenRefreshView, TokenBlacklistView +from rest_framework_simplejwt.views import TokenRefreshView from .views import UserViewSet, AuthViewSet @@ -15,6 +15,5 @@ # JWT Token endpoints path("token/", include([ path("refresh/", TokenRefreshView.as_view(), name="token_refresh"), - path('blacklist/', TokenBlacklistView.as_view(), name='token_blacklist'), ])), ] \ No newline at end of file diff --git a/users/views.py b/users/views.py index 8d63db2..8d45a89 100644 --- a/users/views.py +++ b/users/views.py @@ -64,7 +64,7 @@ def logout(self, request): return Response(data, status=status.HTTP_200_OK) except ValueError as e: user_message = error_messages_map.get(str(e), "Unknown token error") - return error_response(user_message, exc=e) + return error_response(user_message, status.HTTP_401_UNAUTHORIZED, exc=e) except Exception as e: return error_response( "Internal server error", From 28c4fbd1624179bdcf08048cd953f0c439fde1ba Mon Sep 17 00:00:00 2001 From: Brunowar12 <128008317+Brunowar12@users.noreply.github.com> Date: Wed, 21 May 2025 12:22:16 +0300 Subject: [PATCH 06/12] feat: + updated version of IsProjectAdmin and IsProjectMinrole --- projects/permissions.py | 78 +++++++++++++++++++++++++++-------------- 1 file changed, 52 insertions(+), 26 deletions(-) diff --git a/projects/permissions.py b/projects/permissions.py index a60e74f..f7af088 100644 --- a/projects/permissions.py +++ b/projects/permissions.py @@ -2,11 +2,27 @@ from django.conf import settings from rest_framework.permissions import BasePermission -from .models import ProjectMembership, Role +from .models import Project, ProjectMembership logger = logging.getLogger(__name__) +def _get_project_from_obj(obj): + """Gets a project object from any related object""" + return obj if isinstance(obj, Project) else getattr(obj, "project", None) + +def _user_is_owner(user, project): + """Shortcut for project owner""" + return project.owner == user + +def _user_has_role(user, project, roles): + """Checks if the user has one of the specified roles in the project""" + return ProjectMembership.objects.filter( + project=project, + user=user, + role__name__in=roles + ).exists() + class IsProjectAdmin(BasePermission): """ Permission class to check if a user is an admin of a project @@ -17,51 +33,61 @@ def has_permission(self, request, view): return request.user and request.user.is_authenticated def has_object_permission(self, request, view, obj): + project = _get_project_from_obj(obj) user = request.user - if hasattr(obj, "owner") and hasattr(obj, "id"): - return self._check_access(user, obj) - - if isinstance(obj, Role): - for membership in ProjectMembership.objects.filter(role=obj): - if self._check_access(user, membership.project): - return True - logger.warning(f"User {user.id} denied access to role {obj.id}") + if project is None: return False - return False + if _user_is_owner(user, project): + return True - def _check_access(self, user, project): - is_admin = ProjectMembership.objects.filter( - user=user, project=project, role__name__in=settings.ADMIN_ROLE_NAMES - ).exists() - is_owner = project.owner == user + is_admin = _user_has_role(user, project, settings.ADMIN_ROLE_NAMES) - if not (is_admin or is_owner): - logger.warning(f"User {user.id} denied access to project {project.id}") + if not is_admin: + logger.warning(f"User {user} lacks admin role for project {project.id}") - return is_admin or is_owner + return is_admin class IsProjectMinRole(BasePermission): """ Permission class to check if a user has a certain role in a project """ - ROLE_ORDER = ['Viewer', 'Member', 'Moderator', 'Admin', 'Owner'] - - def __init__(self, min_role: str): + ROLE_ORDER = ('Viewer', 'Member', 'Moderator', 'Admin') + + def __init__(self, min_role): + if isinstance(min_role, (list, tuple)): + if len(min_role) != 1: + raise ValueError(f"Invalid role format: {min_role}") + min_role = min_role[0] + + if not isinstance(min_role, str) or min_role not in self.ROLE_ORDER: + raise ValueError(f"Unknown role: {min_role}") + self.min_role = min_role + def has_permission(self, request, view): + return True + def has_object_permission(self, request, view, obj): + project = _get_project_from_obj(obj) user = request.user - if getattr(obj, 'owner', None) == user: + + if project is None: + return False + + if _user_is_owner(user, project): return True try: - user_role = ProjectMembership.objects.get( + membership = ProjectMembership.objects.select_related("role").get( project=obj, user=user - ).role.name + ) except ProjectMembership.DoesNotExist: return False - - return self.ROLE_ORDER.index(user_role) >= self.ROLE_ORDER.index(self.min_role) \ No newline at end of file + + user_rank = self.ROLE_ORDER.index(membership.role.name) + min_rank = self.ROLE_ORDER.index(self.min_role) + + return user_rank >= min_rank \ No newline at end of file From 7446fdbeab1eb82a6fd4eb34009337b5d088ec8c Mon Sep 17 00:00:00 2001 From: Brunowar12 <128008317+Brunowar12@users.noreply.github.com> Date: Wed, 21 May 2025 16:14:53 +0300 Subject: [PATCH 07/12] feat: + use ROLE_ORDER from settings file --- TaskManagerSystem/settings.py | 1 + api/tests/test_projects.py | 5 ++--- projects/models.py | 7 ++++--- projects/permissions.py | 2 +- users/models.py | 2 ++ 5 files changed, 10 insertions(+), 7 deletions(-) diff --git a/TaskManagerSystem/settings.py b/TaskManagerSystem/settings.py index 28fa839..7419a2c 100644 --- a/TaskManagerSystem/settings.py +++ b/TaskManagerSystem/settings.py @@ -55,6 +55,7 @@ "view_task", ], } +ROLE_ORDER = tuple(reversed(ROLE_PERMISSIONS.keys())) DATA_UPLOAD_MAX_MEMORY_SIZE = 1024 * 1024 * 25 # 25 Mb restriction API_VERSION = '1.0.0' diff --git a/api/tests/test_projects.py b/api/tests/test_projects.py index 7d1bda1..38472c9 100644 --- a/api/tests/test_projects.py +++ b/api/tests/test_projects.py @@ -1,4 +1,5 @@ from datetime import timedelta +from django.conf import settings from django.contrib.auth import get_user_model from django.urls import reverse from django.utils import timezone @@ -12,8 +13,6 @@ User = get_user_model() -FIXED_ROLES = ("Admin", "Moderator", "Member", "Viewer") - class ProjectsAPITests(BaseAPITestCase): @classmethod def setUpTestData(cls): @@ -30,7 +29,7 @@ def create_user(cls, email, password): @classmethod def create_role(cls, name): - assert name in FIXED_ROLES, f"Role '{name}' is not fixed" + assert name in settings.ROLE_ORDER, f"Role '{name}' is not fixed" return Role.objects.get_or_create(name=name)[0] @classmethod diff --git a/projects/models.py b/projects/models.py index 1fd2d89..08381b1 100644 --- a/projects/models.py +++ b/projects/models.py @@ -28,8 +28,7 @@ def __str__(self): class Role(models.Model): - ADMIN, MODERATOR, MEMBER, VIEWER = "Admin", "Moderator", "Member", "Viewer" - FIXED_ROLES = [ADMIN, MODERATOR, MEMBER, VIEWER] + FIXED_ROLES = settings.ROLE_ORDER name = models.CharField(max_length=64, unique=True) permissions = models.ManyToManyField(Permission, blank=True) @@ -95,7 +94,9 @@ class ProjectShareLink(models.Model): def clean(self): errors = {} if self.max_uses is not None and self.max_uses <= 0: - errors["max_uses"] = "The max_uses value must be a positive number or left blank" + errors["max_uses"] = ( + "The max_uses value must be a positive number or left blank" + ) if self.max_uses is not None and self.used_count > self.max_uses: errors["used_count"] = "The number of uses exceeds the set limit" if self.expires_at <= timezone.now(): diff --git a/projects/permissions.py b/projects/permissions.py index f7af088..e769658 100644 --- a/projects/permissions.py +++ b/projects/permissions.py @@ -54,7 +54,7 @@ class IsProjectMinRole(BasePermission): """ Permission class to check if a user has a certain role in a project """ - ROLE_ORDER = ('Viewer', 'Member', 'Moderator', 'Admin') + ROLE_ORDER = settings.ROLE_ORDER def __init__(self, min_role): if isinstance(min_role, (list, tuple)): diff --git a/users/models.py b/users/models.py index 93b5306..4c51ded 100644 --- a/users/models.py +++ b/users/models.py @@ -1,3 +1,5 @@ +# mypy: disable-error-code=var-annotated + from django.contrib.auth.models import AbstractUser from django.core.validators import MinValueValidator, MaxValueValidator from django.db import models, transaction From 8ae7b4bb8badc61972006c25e2aa90f847c26fdd Mon Sep 17 00:00:00 2001 From: Brunowar12 <128008317+Brunowar12@users.noreply.github.com> Date: Wed, 21 May 2025 16:18:18 +0300 Subject: [PATCH 08/12] feat: + sharelink serializer gives token instead of share_link --- projects/serializers.py | 12 ++++-------- projects/views.py | 27 ++++++++++++++------------- 2 files changed, 18 insertions(+), 21 deletions(-) diff --git a/projects/serializers.py b/projects/serializers.py index 66e7210..8be7e87 100644 --- a/projects/serializers.py +++ b/projects/serializers.py @@ -90,22 +90,18 @@ class ProjectShareLinkSerializer(serializers.ModelSerializer): source="created_by.username", read_only=True ) ) - token = serializers.CharField(write_only=True) - share_url = serializers.SerializerMethodField() class Meta: model = ProjectShareLink fields = [ - "id", "token", "share_url", "role_name", + "id", "token", "role_name", "max_uses", "expires_at", "is_active", "created_by", "created_at", ] - read_only_fields = ["share_url", "role_name", "created_by", "created_at"] + read_only_fields = [ + "role_name", "created_by", "created_at" + ] - def get_share_url(self, obj): - request = self.context.get("request") - url = reverse("join-project", kwargs={"token": obj.token}, request=request) - return url class KickUserSerializer(serializers.Serializer): user_id = serializers.IntegerField() diff --git a/projects/views.py b/projects/views.py index 9c28449..8841818 100644 --- a/projects/views.py +++ b/projects/views.py @@ -1,4 +1,5 @@ import logging +from django.conf import settings from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError from django.db import transaction @@ -108,11 +109,10 @@ def assign_role(self, request, pk=None): """ project = self._get_project(pk) target = get_object_or_404(User, id=request.data.get("user_id")) - role = get_object_or_404(Role, id=request.data.get("role_id")) - + new_role = get_object_or_404(Role, id=request.data.get("role_id")) + if target == project.owner: return error_response("Cannot assign role to the project owner") - if target == request.user: return self._forbidden("You cannot change your own role") @@ -120,17 +120,18 @@ def assign_role(self, request, pk=None): return error_response("User must join via ShareLink") assigner = self._get_membership(project, request.user) - assigner_role = assigner.role.name if assigner else "Owner" - order = IsProjectMinRole.ROLE_ORDER - + assigner_role = assigner.role.name if assigner else "Owner" + + hierarchy = settings.ROLE_ORDER if assigner_role not in ("Admin", "Owner"): - if order.index(role.name) >= order.index(assigner_role): - return self._forbidden( - f"Cannot assign '{role.name}' ≥ your '{assigner_role}'" + if hierarchy.index(new_role.name) >= hierarchy.index(assigner_role): + return error_response( + f"Cannot assign role '{new_role.name}'", + status.HTTP_403_FORBIDDEN, ) try: - ProjectMembershipService.assign_role(project, target, role) + ProjectMembershipService.assign_role(project, target, new_role) except ValidationError as e: return error_response(e.messages[0], exc=e) except Exception as e: @@ -154,7 +155,7 @@ def kick(self, request, pk=None): ) if membership.user == project.owner: return error_response("Cannot kick project owner") - + membership.delete() return status_response("Member excluded") @@ -166,11 +167,11 @@ def leave_project(self, request, pk=None): project = self._get_project(pk) if project.owner == request.user: return self._forbidden("Owner cannot leave project") - + membership = self._get_membership(project, request.user) if not membership: return error_response("Not a member of this project") - + membership.delete() return status_response("You left the project") From 7fae3838e27068a2f19dfc54f584fa920581fd12 Mon Sep 17 00:00:00 2001 From: Brunowar12 <128008317+Brunowar12@users.noreply.github.com> Date: Wed, 21 May 2025 16:32:03 +0300 Subject: [PATCH 09/12] feat: + improved project task permission + mixins to permissions --- projects/serializers.py | 1 - tasks/{mixins.py => permissions.py} | 29 +++++++++++++++-------------- tasks/views.py | 2 +- 3 files changed, 16 insertions(+), 16 deletions(-) rename tasks/{mixins.py => permissions.py} (70%) diff --git a/projects/serializers.py b/projects/serializers.py index 8be7e87..3f875a1 100644 --- a/projects/serializers.py +++ b/projects/serializers.py @@ -1,5 +1,4 @@ from rest_framework import serializers -from rest_framework.reverse import reverse from .models import Project, ProjectShareLink, Role, ProjectMembership diff --git a/tasks/mixins.py b/tasks/permissions.py similarity index 70% rename from tasks/mixins.py rename to tasks/permissions.py index ca04c1f..fc6642b 100644 --- a/tasks/mixins.py +++ b/tasks/permissions.py @@ -29,20 +29,21 @@ class ProjectTaskPermission(BasePermission): """ Checking whether the user has the required permission for the project task """ + def _get_min_role(self, method): + if method in SAFE_METHODS: + return 'Viewer' + elif method in ('PUT', 'PATCH'): + return 'Member' + return 'Moderator' + def has_permission(self, request, view): - project_pk = view.kwargs.get('project_pk') - - if not project_pk: - return True - - if request.method in SAFE_METHODS: - min_role = 'Viewer' - elif request.method in ('PUT', 'PATCH'): - min_role = 'Member' - else: - min_role = 'Moderator' - - return IsProjectMinRole(min_role).has_permission(request, view) + return True def has_object_permission(self, request, view, obj): - return self.has_permission(request, view) \ No newline at end of file + project = getattr(obj, 'project', None) + if project is None: + return True + + min_role = self._get_min_role(request.method) + + return IsProjectMinRole(min_role).has_object_permission(request, view, obj) \ No newline at end of file diff --git a/tasks/views.py b/tasks/views.py index 498f746..dd4ad3f 100644 --- a/tasks/views.py +++ b/tasks/views.py @@ -19,8 +19,8 @@ from api.utils import error_response, status_response from projects.permissions import IsProjectMinRole -from .mixins import IsOwner, ProjectTaskPermission from .models import Task, Category +from .permissions import IsOwner, ProjectTaskPermission from .services import TaskService, CategoryService from .serializers import ( TaskSerializer, CategorySerializer, From 36502e61f9019864c9931f6c1482f8b6cf397c77 Mon Sep 17 00:00:00 2001 From: Brunowar12 <128008317+Brunowar12@users.noreply.github.com> Date: Wed, 21 May 2025 18:23:08 +0300 Subject: [PATCH 10/12] feat: + docstrings in projects + micro improvements --- projects/models.py | 17 ++++++--- projects/permissions.py | 26 ++++++------- projects/serializers.py | 49 ++++++++---------------- projects/services.py | 83 ++++++++++++++++++++++++++--------------- projects/urls.py | 14 +++---- projects/views.py | 6 +-- 6 files changed, 100 insertions(+), 95 deletions(-) diff --git a/projects/models.py b/projects/models.py index 08381b1..a479f95 100644 --- a/projects/models.py +++ b/projects/models.py @@ -11,6 +11,7 @@ class Project(models.Model): + """Project with name, description, and owner""" name = models.CharField(max_length=128, validators=[TEXT_FIELD_VALIDATOR]) description = models.TextField(blank=True) owner = models.ForeignKey( @@ -28,15 +29,15 @@ def __str__(self): class Role(models.Model): - FIXED_ROLES = settings.ROLE_ORDER - + """Static project roles; prohibition to create custom ones""" name = models.CharField(max_length=64, unique=True) permissions = models.ManyToManyField(Permission, blank=True) def clean(self): - if self.name not in self.FIXED_ROLES: + fixed = settings.ROLE_ORDER + if self.name not in fixed: raise ValidationError( - f"Custom roles are not allowed. Use one of: {', '.join(self.FIXED_ROLES)}" + f"Custom roles are not allowed. Use one of: {', '.join(fixed)}" ) def save(self, *args, **kwargs): @@ -48,6 +49,7 @@ def __str__(self): class ProjectMembership(models.Model): + """The relationship 'user --> project --> role' """ user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, @@ -68,6 +70,7 @@ def __str__(self): class ProjectShareLink(models.Model): + """Token for invitation to the project with a limits""" token = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) project = models.ForeignKey( Project, on_delete=models.CASCADE, related_name="share_links" @@ -112,7 +115,11 @@ def is_usage_exceeded(self): return self.max_uses is not None and self.used_count >= self.max_uses def is_valid(self): - return self.is_active and not self.is_expired() and not self.is_usage_exceeded() + return ( + self.is_active + and not self.is_expired() + and not self.is_usage_exceeded() + ) def __str__(self): return f"Link to {self.project.name} ({self.role.name})" diff --git a/projects/permissions.py b/projects/permissions.py index e769658..3ae76bb 100644 --- a/projects/permissions.py +++ b/projects/permissions.py @@ -1,4 +1,5 @@ import logging + from django.conf import settings from rest_framework.permissions import BasePermission @@ -9,11 +10,13 @@ def _get_project_from_obj(obj): """Gets a project object from any related object""" - return obj if isinstance(obj, Project) else getattr(obj, "project", None) + if isinstance(obj, Project): + return obj + return getattr(obj, "project", None) def _user_is_owner(user, project): """Shortcut for project owner""" - return project.owner == user + return project.owner_id == user.id def _user_has_role(user, project, roles): """Checks if the user has one of the specified roles in the project""" @@ -29,10 +32,10 @@ class IsProjectAdmin(BasePermission): Checks if the user is the owner of the project or has an admin role """ - def has_permission(self, request, view): - return request.user and request.user.is_authenticated + def has_permission(self, request, view) -> bool: + return bool(request.user and request.user.is_authenticated) - def has_object_permission(self, request, view, obj): + def has_object_permission(self, request, view, obj) -> bool: project = _get_project_from_obj(obj) user = request.user @@ -45,7 +48,7 @@ def has_object_permission(self, request, view, obj): is_admin = _user_has_role(user, project, settings.ADMIN_ROLE_NAMES) if not is_admin: - logger.warning(f"User {user} lacks admin role for project {project.id}") + logger.warning(f"User {user.id} lacks admin role for project {project.id}") return is_admin @@ -67,10 +70,7 @@ def __init__(self, min_role): self.min_role = min_role - def has_permission(self, request, view): - return True - - def has_object_permission(self, request, view, obj): + def has_object_permission(self, request, view, obj) -> bool: project = _get_project_from_obj(obj) user = request.user @@ -86,8 +86,8 @@ def has_object_permission(self, request, view, obj): ) except ProjectMembership.DoesNotExist: return False - + user_rank = self.ROLE_ORDER.index(membership.role.name) min_rank = self.ROLE_ORDER.index(self.min_role) - - return user_rank >= min_rank \ No newline at end of file + + return user_rank >= min_rank diff --git a/projects/serializers.py b/projects/serializers.py index 3f875a1..8c1a647 100644 --- a/projects/serializers.py +++ b/projects/serializers.py @@ -3,7 +3,7 @@ from .models import Project, ProjectShareLink, Role, ProjectMembership class ProjectSerializer(serializers.ModelSerializer): - tasks_count = serializers.SerializerMethodField() + tasks_count = serializers.SerializerMethodField(read_only=True) class Meta: model = Project @@ -11,15 +11,11 @@ class Meta: "id", "name", "description", "owner", "tasks_count", "created_at" ] read_only_fields = ["id", "owner", "tasks_count", "created_at"] - + def get_tasks_count(self, obj): return getattr(obj, "tasks_count", 0) def validate_name(self, value): - if not value: - raise serializers.ValidationError( - "The name of the project cannot be empty" - ) if len(value) < 3: raise serializers.ValidationError( "The project name must contain at least 3 characters" @@ -27,15 +23,14 @@ def validate_name(self, value): return value def validate(self, attrs): - request = self.context.get("request") - owner = request.user if request else None - project = self.instance - existing = Project.objects.filter(owner=owner, name=attrs.get("name")) - if project: - existing = existing.exclude(id=project.id) - if existing.exists(): + user = self.context["request"].user + name = attrs.get("name") + qs = Project.objects.filter(owner=user, name__iexact=name) + if self.instance: + qs = qs.exclude(pk=self.instance.pk) + if qs.exists(): raise serializers.ValidationError( - "You already have a project with this name" + "You already have a project with this name." ) return attrs @@ -47,12 +42,8 @@ class Meta: class ProjectMembershipSerializer(serializers.ModelSerializer): - role_name: serializers.StringRelatedField = serializers.StringRelatedField( - source="role.name", read_only=True - ) - user_name: serializers.StringRelatedField = serializers.StringRelatedField( - source="user.username", read_only=True - ) + user_name = serializers.ReadOnlyField(source="user.username") + role_name = serializers.ReadOnlyField(source="role.name") user_details = serializers.SerializerMethodField() class Meta: @@ -71,9 +62,7 @@ def get_user_details(self, obj): class ShareLinkCreateSerializer(serializers.Serializer): - role_id = serializers.IntegerField( - required=False, default=4, min_value=1, max_value=4 - ) + role_id = serializers.IntegerField(default=4, min_value=1, max_value=4) max_uses = serializers.IntegerField( required=False, allow_null=True, min_value=1 ) @@ -81,14 +70,8 @@ class ShareLinkCreateSerializer(serializers.Serializer): class ProjectShareLinkSerializer(serializers.ModelSerializer): - role_name: serializers.StringRelatedField = serializers.StringRelatedField( - source="role.name", read_only=True - ) - created_by: serializers.StringRelatedField = ( - serializers.StringRelatedField( - source="created_by.username", read_only=True - ) - ) + role_name = serializers.ReadOnlyField(source="role.name") + created_by = serializers.ReadOnlyField(source="created_by.username") class Meta: model = ProjectShareLink @@ -98,7 +81,7 @@ class Meta: "created_by", "created_at", ] read_only_fields = [ - "role_name", "created_by", "created_at" + "id", "token" ,"role_name", "created_by", "created_at" ] @@ -108,4 +91,4 @@ class KickUserSerializer(serializers.Serializer): class AssignRoleSerializer(serializers.Serializer): user_id = serializers.IntegerField() - role_id = serializers.IntegerField() \ No newline at end of file + role_id = serializers.IntegerField() diff --git a/projects/services.py b/projects/services.py index 5bd5663..abb3850 100644 --- a/projects/services.py +++ b/projects/services.py @@ -1,3 +1,4 @@ +from datetime import timedelta from django.core.exceptions import ValidationError from django.db import transaction from django.shortcuts import get_object_or_404 @@ -9,13 +10,17 @@ class ProjectService: """ - Service for operations with projects + Service for working with projects: + - creating a project with automatic assignment of the Admin role + - receiving a project with access verification """ @staticmethod @transaction.atomic def create_project(owner, **data): + """Creates a new project and adds an owner with the Admin role""" project = Project.objects.create(owner=owner, **data) admin_role = Role.objects.get(name="Admin") + ProjectMembership.objects.get_or_create( user=owner, project=project, @@ -25,19 +30,27 @@ def create_project(owner, **data): @staticmethod def get_project_or_404(pk, user): + """ + Returns the project by pk if the user is the owner + or member; otherwise calls PermissionDenied + """ project = get_object_or_404( Project.objects.prefetch_related("memberships__role"), pk=pk ) - if not ( - project.owner == user - or project.memberships.filter(user=user).exists() - ): + + is_member = project.memberships.filter(user=user).exists() + if project.owner != user and not is_member: raise PermissionError("You do not have acces to this project") + return project class ProjectShareLinkService: + """ + Service for validation and creation of links to join the project + """ @staticmethod - def validate_share_link(link): + def validate_share_link(link: ProjectShareLink): + """Checks whether the link is valid, not expired, and not expired""" if not link.is_valid(): if link.is_expired(): raise PermissionDenied("Link has expired") @@ -46,9 +59,13 @@ def validate_share_link(link): raise PermissionDenied("Link is inactive") @staticmethod - def create_share_link(project, role_id, user, max_uses, expires_in): + def create_share_link(project: Project, role_id: int, user, max_uses: int | None, expires_in: int): + """ + Creates a new active link with the specified parameters: + - role, lifetime in minutes, maximum number of uses + """ role = get_object_or_404(Role, id=role_id) - expires_at = timezone.now() + timezone.timedelta(minutes=expires_in) + expires_at = timezone.now() + timedelta(minutes=expires_in) share_link = ProjectShareLink.objects.create( project=project, @@ -61,34 +78,38 @@ def create_share_link(project, role_id, user, max_uses, expires_in): class ProjectMembershipService: + """ + Service for working with project membership: + - assigning and changing roles + """ @staticmethod + @transaction.atomic def assign_role(project, user, role): """ - Assigns a role to a user in the project. If the user already - has another role, it updates it - Throws a ValidationError if the rules are violated + Assigns or updates a user role in the project. + Throws a ValidationError if: + - the user is the project owner + - the role has not changed """ if user == project.owner: raise ValidationError("Cannot assign role to the project owner") + + membership = ( + ProjectMembership.objects + .select_for_update() + .filter(user=user, project=project) + .first() + ) - with transaction.atomic(): - existing_membership = ( - ProjectMembership.objects.select_for_update() - .filter(user=user, project=project) - .first() - ) - - if existing_membership: - if existing_membership.role == role: - raise ValidationError( - "User already has this role in the project" - ) + if membership: + if membership.role == role: + raise ValidationError("User already has this role in project") - existing_membership.role = role - existing_membership.save() - return existing_membership, False - else: - membership = ProjectMembership.objects.create( - user=user, project=project, role=role - ) - return membership, True + membership.role = role + membership.save(update_fields=['role']) + else: + membership = ProjectMembership.objects.create( + user=user, project=project, role=role + ) + + return membership diff --git a/projects/urls.py b/projects/urls.py index 6d9dca6..7320cd6 100644 --- a/projects/urls.py +++ b/projects/urls.py @@ -8,19 +8,15 @@ ProjectMembershipViewSet, join_project ) -# ViewSet routers +# Main ViewSet routers router = routers.DefaultRouter() router.register(r"roles", RoleViewSet, basename="role") -router.register( - r"project-memberships", - ProjectMembershipViewSet, - basename="project-membership", -) +router.register(r"project-memberships", ProjectMembershipViewSet, + basename="project-membership") router.register(r"", ProjectViewSet, basename="project") -projects_router = routers.NestedDefaultRouter( - router, r"", lookup="project" -) +# Nested router for tasks and share-links +projects_router = routers.NestedDefaultRouter(router, r"", lookup="project") projects_router.register(r"tasks", TaskViewSet, basename="project-tasks") projects_router.register(r"share_links", ProjectShareLinkViewSet, basename="project-share-links") diff --git a/projects/views.py b/projects/views.py index 8841818..45daa47 100644 --- a/projects/views.py +++ b/projects/views.py @@ -213,13 +213,11 @@ class ProjectShareLinkViewSet(viewsets.ModelViewSet): Read-only viewset for viewing project share links """ - serializer_class = ProjectShareLinkSerializer - permission_classes = [IsAuthenticated] lookup_field = "id" + permission_classes = [IsAuthenticated] def get_permissions(self): - perms = [permission() for permission in self.permission_classes] - + perms = super().get_permissions() if self.action in ("list", "retrieve", "create", "destroy"): perms.append(IsProjectMinRole("Moderator")) return perms From fa063bc9ab34e441c12136d15fbe5482e0a5deca Mon Sep 17 00:00:00 2001 From: Brunowar12 <128008317+Brunowar12@users.noreply.github.com> Date: Wed, 21 May 2025 18:24:04 +0300 Subject: [PATCH 11/12] feat: + improved readability in settings --- TaskManagerSystem/settings.py | 124 ++++++++++++++++++---------------- 1 file changed, 66 insertions(+), 58 deletions(-) diff --git a/TaskManagerSystem/settings.py b/TaskManagerSystem/settings.py index 7419a2c..3c7fe24 100644 --- a/TaskManagerSystem/settings.py +++ b/TaskManagerSystem/settings.py @@ -7,31 +7,29 @@ import os from pathlib import Path from datetime import timedelta -from decouple import config # type: ignore +from decouple import config -# Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent -# Secret Key for production +# SECURITY SECRET_KEY = config( "SECRET_KEY", default=os.environ.get("SECRET_KEY", "fallback-secret-key") ) - -# Debugging mode (change to False in production) DEBUG = config("DEBUG", default=True, cast=bool) - -# Security settings -SECURE_BROWSER_XSS_FILTER = True # enable X-XSS-Protection -SECURE_CONTENT_TYPE_NOSNIFF = True # enable X-Content-Type-Options -X_FRAME_OPTIONS = 'DENY' # prevent clickjacking - ALLOWED_HOSTS = config( "ALLOWED_HOSTS", default="localhost,127.0.0.1", cast=lambda v: [s.strip() for s in v.split(",")], ) + AUTH_USER_MODEL = 'users.User' +# Security settings +SECURE_BROWSER_XSS_FILTER = True # enable X-XSS-Protection +SECURE_CONTENT_TYPE_NOSNIFF = True # enable X-Content-Type-Options +X_FRAME_OPTIONS = 'DENY' # prevent clickjacking + + ADMIN_ROLE_NAMES = ["Admin",] ROLE_PERMISSIONS = { "Admin": [ @@ -60,25 +58,35 @@ DATA_UPLOAD_MAX_MEMORY_SIZE = 1024 * 1024 * 25 # 25 Mb restriction API_VERSION = '1.0.0' -# Application definition -INSTALLED_APPS = [ +DJANGO_APPS = [ "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", - "django.test", + "django.test" +] + +THIRD_PARTY_APPS = [ + "django_extensions", "django_filters", + "drf_yasg", "rest_framework", "rest_framework_simplejwt.token_blacklist", - "drf_yasg", + "corsheaders", + "flowchart_visualizer" +] + +LOCAL_APPS = [ "users", "tasks", - "projects", - "corsheaders", + "projects" ] +# Application definition +INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS + # List of middleware classes to use MIDDLEWARE = [ 'TaskManagerSystem.middleware.RejectLargeRequestsMiddleware', @@ -92,6 +100,38 @@ 'django.middleware.clickjacking.XFrameOptionsMiddleware', # clickjacking protection middleware ] +# Root URL configuration +WSGI_APPLICATION = 'TaskManagerSystem.wsgi.application' +ROOT_URLCONF = 'TaskManagerSystem.urls' + +# Templates configuration +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], # dir where templates are + 'APP_DIRS': True, # automatically discover templates in each app's + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +# WSGI +LANGUAGE_CODE = 'en-us' +TIME_ZONE = 'UTC' +USE_I18N = True +USE_TZ = False + +# Static +STATIC_URL = 'static/' +STATIC_ROOT = BASE_DIR / "staticfiles" +# STATICFILES_DIRS = [BASE_DIR / "static"] + CORS_ALLOWED_ORIGINS = [ "http://127.0.0.1:3000", # Allow Next.js to the client "http://localhost:3000", # Additionally for other options @@ -137,6 +177,9 @@ "AUTH_HEADER_TYPES": ("Bearer",), } +# Default primary key +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + # Database settings DATABASES = { 'default': { @@ -149,11 +192,9 @@ } } -# Logging configuration -LOG_DIR = os.path.join(BASE_DIR, "logs") -if not os.path.exists(LOG_DIR): - os.makedirs(LOG_DIR) - +# Logging +LOG_DIR = BASE_DIR / 'logs' +LOG_DIR.mkdir(exist_ok=True) LOGGING = { 'version': 1, # version of the logging configuration 'disable_existing_loggers': False, # dont disable existing loggers @@ -178,14 +219,14 @@ }, 'file': { 'class': 'logging.handlers.RotatingFileHandler', # log to a file - 'filename': os.path.join(LOG_DIR, 'tms.log'), - 'maxBytes': 1024 * 1024 * 5, # rotate logs every 5MB + 'filename': LOG_DIR / 'tms.log', + 'maxBytes': 5 * 1024 * 1024, # rotate logs every 5MB 'backupCount': 3, 'formatter': 'verbose', }, 'error_file': { 'class': 'logging.FileHandler', - 'filename': os.path.join(LOG_DIR, 'errors.log'), + 'filename': LOG_DIR / 'errors.log', 'formatter': 'verbose', 'level': 'ERROR', # only log ERROR and above level messages }, @@ -224,36 +265,3 @@ 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', }, ] - -# Root URL configuration -ROOT_URLCONF = 'TaskManagerSystem.urls' - -# Template configuration -TEMPLATES = [ - { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], # dir where templates are - 'APP_DIRS': True, # automatically discover templates in each app's - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', - ], - }, - }, -] - -WSGI_APPLICATION = 'TaskManagerSystem.wsgi.application' - -LANGUAGE_CODE = 'en-us' -TIME_ZONE = 'UTC' -USE_I18N = True -USE_TZ = False - -STATIC_URL = 'static/' -STATIC_ROOT = BASE_DIR / "staticfiles" -# STATICFILES_DIRS = [BASE_DIR / "static"] - -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' \ No newline at end of file From 9dc6c5ad57216d3612e248b78cc82d8f8fde6c01 Mon Sep 17 00:00:00 2001 From: Brunowar12 <128008317+Brunowar12@users.noreply.github.com> Date: Wed, 21 May 2025 18:30:37 +0300 Subject: [PATCH 12/12] Update settings.py --- TaskManagerSystem/settings.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/TaskManagerSystem/settings.py b/TaskManagerSystem/settings.py index 3c7fe24..6d0fc22 100644 --- a/TaskManagerSystem/settings.py +++ b/TaskManagerSystem/settings.py @@ -69,13 +69,11 @@ ] THIRD_PARTY_APPS = [ - "django_extensions", "django_filters", "drf_yasg", "rest_framework", "rest_framework_simplejwt.token_blacklist", "corsheaders", - "flowchart_visualizer" ] LOCAL_APPS = [