diff --git a/TaskManagerSystem/middleware.py b/TaskManagerSystem/middleware.py index ad996d8..9f704e7 100644 --- a/TaskManagerSystem/middleware.py +++ b/TaskManagerSystem/middleware.py @@ -15,4 +15,4 @@ def __call__(self, request): return HttpResponse('Payload Too Large', status=413) except (ValueError, TypeError): pass - return self.get_response(request) \ No newline at end of file + return self.get_response(request) diff --git a/TaskManagerSystem/settings.py b/TaskManagerSystem/settings.py index ff72cf1..2c151de 100644 --- a/TaskManagerSystem/settings.py +++ b/TaskManagerSystem/settings.py @@ -25,9 +25,9 @@ AUTH_USER_MODEL = 'users.User' # Security settings -SECURE_BROWSER_XSS_FILTER = True # enable X-XSS-Protection +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 +X_FRAME_OPTIONS = 'DENY' # prevent clickjacking ADMIN_ROLE_NAMES = ["Admin",] @@ -55,7 +55,7 @@ } ROLE_ORDER = tuple(reversed(ROLE_PERMISSIONS.keys())) -DATA_UPLOAD_MAX_MEMORY_SIZE = 1024 * 1024 * 25 # 25 Mb restriction +DATA_UPLOAD_MAX_MEMORY_SIZE = 1024 * 1024 * 25 # 25 Mb restriction API_VERSION = '1.0.0' DJANGO_APPS = [ @@ -65,7 +65,7 @@ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", - "django.test" + "django.test", ] THIRD_PARTY_APPS = [ @@ -76,11 +76,7 @@ "corsheaders", ] -LOCAL_APPS = [ - "users", - "tasks", - "projects" -] +LOCAL_APPS = ["users", "tasks", "projects"] # Application definition INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS @@ -154,7 +150,7 @@ 'TEST_REQUEST_DEFAULT_FORMAT': 'json', 'UNAUTHENTICATED_USER': None, 'DEFAULT_THROTTLE_CLASSES': [ - 'rest_framework.throttling.AnonRateThrottle', # not auth request + 'rest_framework.throttling.AnonRateThrottle', # not auth request 'rest_framework.throttling.UserRateThrottle' ], "DEFAULT_THROTTLE_RATES": {"anon": "10000/minute", "user": "10000/minute"}, @@ -191,8 +187,8 @@ } # Logging -LOG_DIR = BASE_DIR / 'logs' -LOG_DIR.mkdir(exist_ok=True) +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 diff --git a/TaskManagerSystem/urls.py b/TaskManagerSystem/urls.py index ae01f14..674f9eb 100644 --- a/TaskManagerSystem/urls.py +++ b/TaskManagerSystem/urls.py @@ -47,4 +47,4 @@ path('redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'), ] -urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) \ No newline at end of file +urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/TaskManagerSystem/views.py b/TaskManagerSystem/views.py index 841cd68..49350c2 100644 --- a/TaskManagerSystem/views.py +++ b/TaskManagerSystem/views.py @@ -6,6 +6,7 @@ logger = logging.getLogger(__name__) + def custom_exception_handler(exc, context): """ Custom handler for exceptions in the API @@ -23,8 +24,8 @@ def custom_exception_handler(exc, context): else: logger.error(f"Unhandled exception: {exc}", exc_info=True) return error_response( - "Internal server error. Please try again later", - status.HTTP_500_INTERNAL_SERVER_ERROR + "Internal server error. Please try again later", + status.HTTP_500_INTERNAL_SERVER_ERROR, ) return response diff --git a/api/admin.py b/api/admin.py index 8c38f3f..4185d36 100644 --- a/api/admin.py +++ b/api/admin.py @@ -1,3 +1,3 @@ -from django.contrib import admin +# from django.contrib import admin # Register your models here. diff --git a/api/apps.py b/api/apps.py index bc06421..66656fd 100644 --- a/api/apps.py +++ b/api/apps.py @@ -1,5 +1,6 @@ from django.apps import AppConfig + class ApiConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'api' diff --git a/api/mixins.py b/api/mixins.py index d3deb4c..d281feb 100644 --- a/api/mixins.py +++ b/api/mixins.py @@ -12,24 +12,28 @@ class UserQuerysetMixin: def get_queryset(self): base_queryset = super().get_queryset() - model = getattr(base_queryset, 'model', None) + model = getattr(base_queryset, "model", None) if not model: raise ImproperlyConfigured("Queryset has a model for filtering") - if hasattr(model, 'owner'): + if hasattr(model, "owner"): return base_queryset.filter(owner=self.request.user) - if hasattr(model, 'user'): + if hasattr(model, "user"): return base_queryset.filter(user=self.request.user) - raise ImproperlyConfigured(f"Model {model.__name__} does not have owner or user fields") + raise ImproperlyConfigured( + f"Model {model.__name__} does not have owner or user fields" + ) def perform_create(self, serializer): logger.debug( f"Creating object {serializer.Meta.model.__name__} by user_id={self.request.user.pk}" ) save_kwargs = {} - if hasattr(serializer.Meta.model, 'owner'): - save_kwargs['owner'] = self.request.user - elif hasattr(serializer.Meta.model, 'user'): - save_kwargs['user'] = self.request.user + if hasattr(serializer.Meta.model, "owner"): + save_kwargs["owner"] = self.request.user + elif hasattr(serializer.Meta.model, "user"): + save_kwargs["user"] = self.request.user else: - raise ImproperlyConfigured("The model does not support user-based creation") + raise ImproperlyConfigured( + "The model does not support user-based creation" + ) serializer.save(**save_kwargs) diff --git a/api/models.py b/api/models.py index 71a8362..0b4331b 100644 --- a/api/models.py +++ b/api/models.py @@ -1,3 +1,3 @@ -from django.db import models +# from django.db import models # Create your models here. diff --git a/api/tests/test_setup.py b/api/tests/test_setup.py index cbb518c..2b9277a 100644 --- a/api/tests/test_setup.py +++ b/api/tests/test_setup.py @@ -18,6 +18,7 @@ class BaseAPITestCase(APITestCase): setUp(): Configure test client api_post(): Make authenticated POST request """ + @classmethod def setUpTestData(cls): cls.user, cls.token, cls.refresh = TestHelper.create_test_user_via_orm() @@ -33,12 +34,12 @@ def setUpTestData(cls): cls.user_update_profile_ep = reverse("user-update-profile") cls.project_list_ep = reverse("project-list") cls.role_list_ep = reverse("role-list") - + def setUp(self): self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {self.token}") - + def api_post(self, endpoint: str, data: dict, token: Optional[str] = None): return self.client.post( endpoint, data, format="json", HTTP_AUTHORIZATION=f"Bearer {token or getattr(self, 'token', '')}" - ) \ No newline at end of file + ) diff --git a/api/tests/test_tasks.py b/api/tests/test_tasks.py index f438bba..02d3859 100644 --- a/api/tests/test_tasks.py +++ b/api/tests/test_tasks.py @@ -1,6 +1,6 @@ -from datetime import datetime, timedelta +from datetime import timedelta from django.urls import reverse -from django.utils.timezone import now, make_aware +from django.utils.timezone import now from rest_framework import status from projects.models import Project @@ -406,10 +406,10 @@ def test_filter_by_today(self): self.future_task.due_date = now() + timedelta(days=1) self.future_task.save() - + self.today_task.due_date = now() + timedelta(days=2) self.today_task.save() - + response = self.client.get(self.task_list_ep, {"today": "true"}) self.assertEqual( diff --git a/api/tests/utils.py b/api/tests/utils.py index 5894dea..5045d1f 100644 --- a/api/tests/utils.py +++ b/api/tests/utils.py @@ -7,6 +7,7 @@ User = get_user_model() + class TokenService: """ Utility class for generating tokens @@ -14,14 +15,16 @@ class TokenService: Methods: generate_tokens_for: Generates access and refresh tokens for the specified user """ + @staticmethod def generate_tokens_for(user): refresh_obj = RefreshToken.for_user(user) return { - 'access': str(refresh_obj.access_token), - 'refresh': str(refresh_obj) + "access": str(refresh_obj.access_token), + "refresh": str(refresh_obj), } + class TestHelper: @staticmethod def create_test_user(client, email="testuser@example.com", password="testpassword123"): @@ -30,22 +33,27 @@ def create_test_user(client, email="testuser@example.com", password="testpasswor Args: client: The test client instance. - email (str): The email address of the test user. Defaults to "testuser@example.com" - password (str): The password of the test user. Defaults to "testpassword123" + email (str): The email address of the test user. + Defaults to "testuser@example.com" + password (str): The password of the test user. + Defaults to "testpassword123" Returns: - tuple: A tuple containing the created user instance, access token, and refresh token + tuple: A tuple containing the created user instance, access token, and + refresh token Raises: AssertionError: If user registration or login fails """ user_data = {"email": email, "password": password} response = client.post(reverse("auth-register"), user_data) - assert response.status_code == status.HTTP_201_CREATED, f"User registration failed: {response.data}" + if response.status_code != status.HTTP_201_CREATED: + raise AssertionError(f"User registration failed: {response.data}") user = User.objects.get(email=email) response = client.post(reverse("auth-login"), user_data) - assert response.status_code == status.HTTP_200_OK, f"User login failed: {response.data}" + if response.status_code != status.HTTP_200_OK: + raise AssertionError(f"User login failed: {response.data}") token = response.data.get("access") refresh = response.data.get("refresh") @@ -58,18 +66,21 @@ def create_test_user_via_orm(email="testuser@example.com", password="testpasswor Creates a test user via ORM Args: - email (str): The email address of the test user. Defaults to "testuser@example.com" - password (str): The password of the test user. Defaults to "testpassword123" + email (str): The email address of the test user. + Defaults to "testuser@example.com" + password (str): The password of the test user. + Defaults to "testpassword123" Returns: - tuple: A tuple containing the created user instance, access token, and refresh token + tuple: A tuple containing the created user instance, access token, + and refresh token """ user = User.objects.create_user( username=email.split("@")[0], email=email, password=password ) tokens = TokenService.generate_tokens_for(user) - - return user, tokens['access'], tokens['refresh'] + + return user, tokens["access"], tokens["refresh"] @staticmethod def get_valid_due_date(days: int = 14): @@ -80,7 +91,8 @@ def get_valid_due_date(days: int = 14): days (int): The number of days from the current date. Defaults to 14 Returns: - str: A future date in ISO format (YYYY-MM-DDTHH:MM:SSZ) with microseconds set to 0 + str: A future date in ISO format (YYYY-MM-DDTHH:MM:SSZ) + with microseconds set to 0 """ future_date = timezone.now() + timedelta(days=days) return future_date.replace(microsecond=0).isoformat() + "Z" diff --git a/api/urls.py b/api/urls.py index a68ba73..3a865fe 100644 --- a/api/urls.py +++ b/api/urls.py @@ -5,5 +5,5 @@ path("status/", api_status), path("account/", include("users.urls")), path("tasks/", include("tasks.urls")), - path("projects/", include("projects.urls")) -] \ No newline at end of file + path("projects/", include("projects.urls")), +] diff --git a/api/views.py b/api/views.py index bbcf69a..2ce3b28 100644 --- a/api/views.py +++ b/api/views.py @@ -4,12 +4,15 @@ from rest_framework.permissions import AllowAny from rest_framework.response import Response -@api_view(['GET']) + +@api_view(["GET"]) @permission_classes([AllowAny]) def api_status(request): - return Response({ - "status": "ok", - "version": getattr(settings, 'API_VERSION', 'dev'), - "date": datetime.now(), - "message": "TaskManager API is up and running" - }) + return Response( + { + "status": "ok", + "version": getattr(settings, "API_VERSION", "dev"), + "date": datetime.now(), + "message": "TaskManager API is up and running", + } + ) diff --git a/projects/admin.py b/projects/admin.py index b32a0cc..4f71533 100644 --- a/projects/admin.py +++ b/projects/admin.py @@ -1,4 +1,4 @@ from django.contrib import admin from .models import Role, Project, ProjectMembership, ProjectShareLink -admin.site.register([Role, Project, ProjectMembership, ProjectShareLink]) \ No newline at end of file +admin.site.register([Role, Project, ProjectMembership, ProjectShareLink]) diff --git a/projects/apps.py b/projects/apps.py index 0432c4e..352e201 100644 --- a/projects/apps.py +++ b/projects/apps.py @@ -13,9 +13,9 @@ def ready(self): def create_roles_and_permissions(self, **kwargs): from django.contrib.auth.models import Permission from .models import Role - + for role_name, perm_codenames in settings.ROLE_PERMISSIONS.items(): - role, created = Role.objects.get_or_create(name=role_name) + role, _ = Role.objects.get_or_create(name=role_name) perms = Permission.objects.filter(codename__in=perm_codenames) role.permissions.set(perms) role.save() diff --git a/projects/models.py b/projects/models.py index a479f95..0671688 100644 --- a/projects/models.py +++ b/projects/models.py @@ -12,6 +12,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( @@ -30,16 +31,17 @@ def __str__(self): class Role(models.Model): """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): fixed = settings.ROLE_ORDER if self.name not in fixed: raise ValidationError( f"Custom roles are not allowed. Use one of: {', '.join(fixed)}" ) - + def save(self, *args, **kwargs): self.full_clean() super().save(*args, **kwargs) @@ -49,7 +51,8 @@ def __str__(self): class ProjectMembership(models.Model): - """The relationship 'user --> project --> role' """ + """The relationship 'user --> project --> role'""" + user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, @@ -71,6 +74,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" diff --git a/projects/permissions.py b/projects/permissions.py index 3ae76bb..5c8a34e 100644 --- a/projects/permissions.py +++ b/projects/permissions.py @@ -14,24 +14,26 @@ def _get_project_from_obj(obj): return obj return getattr(obj, "project", None) + def _user_is_owner(user, project): """Shortcut for project owner""" 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""" return ProjectMembership.objects.filter( - project=project, - user=user, - role__name__in=roles + 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 Checks if the user is the owner of the project or has an admin role """ + def has_permission(self, request, view) -> bool: return bool(request.user and request.user.is_authenticated) @@ -48,7 +50,9 @@ def has_object_permission(self, request, view, obj) -> bool: is_admin = _user_has_role(user, project, settings.ADMIN_ROLE_NAMES) if not is_admin: - logger.warning(f"User {user.id} lacks admin role for project {project.id}") + logger.warning( + f"User {user.id} lacks admin role for project {project.id}" + ) return is_admin @@ -57,6 +61,7 @@ class IsProjectMinRole(BasePermission): """ Permission class to check if a user has a certain role in a project """ + ROLE_ORDER = settings.ROLE_ORDER def __init__(self, min_role): diff --git a/projects/serializers.py b/projects/serializers.py index 8c1a647..84fec0d 100644 --- a/projects/serializers.py +++ b/projects/serializers.py @@ -2,16 +2,20 @@ from .models import Project, ProjectShareLink, Role, ProjectMembership + class ProjectSerializer(serializers.ModelSerializer): tasks_count = serializers.SerializerMethodField(read_only=True) + owner_name: serializers.StringRelatedField = ( + serializers.StringRelatedField(source="owner.username", read_only=True) + ) class Meta: model = Project fields = [ - "id", "name", "description", "owner", "tasks_count", "created_at" + "id", "name", "description", "owner", "owner_name", "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) diff --git a/projects/services.py b/projects/services.py index abb3850..97d3784 100644 --- a/projects/services.py +++ b/projects/services.py @@ -14,40 +14,41 @@ class ProjectService: - 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, - defaults={"role": admin_role} + user=owner, project=project, defaults={"role": admin_role} ) return project - + @staticmethod def get_project_or_404(pk, user): """ - Returns the project by pk if the user is the owner + 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 ) - + 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: ProjectShareLink): """Checks whether the link is valid, not expired, and not expired""" @@ -57,9 +58,15 @@ def validate_share_link(link: ProjectShareLink): if link.is_usage_exceeded(): raise PermissionDenied("Link usage limit exceeded") raise PermissionDenied("Link is inactive") - + @staticmethod - def create_share_link(project: Project, role_id: int, user, max_uses: int | None, expires_in: int): + 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 @@ -82,6 +89,7 @@ class ProjectMembershipService: Service for working with project membership: - assigning and changing roles """ + @staticmethod @transaction.atomic def assign_role(project, user, role): @@ -93,7 +101,7 @@ def assign_role(project, user, role): """ if user == project.owner: raise ValidationError("Cannot assign role to the project owner") - + membership = ( ProjectMembership.objects .select_for_update() @@ -106,10 +114,10 @@ def assign_role(project, user, role): raise ValidationError("User already has this role in project") membership.role = role - membership.save(update_fields=['role']) + membership.save(update_fields=["role"]) else: membership = ProjectMembership.objects.create( user=user, project=project, role=role ) - + return membership diff --git a/projects/views.py b/projects/views.py index 45daa47..cecdee6 100644 --- a/projects/views.py +++ b/projects/views.py @@ -31,12 +31,13 @@ class ProjectViewSet(UserQuerysetMixin, viewsets.ModelViewSet): """ ViewSet for operations with projects - + Allows you to view, create, edit, and delete projects Includes an additional method for getting tasks in a project """ + serializer_class = ProjectSerializer - queryset = Project.objects.all().prefetch_related('tasks') + queryset = Project.objects.all().prefetch_related("tasks") permission_classes = [IsAuthenticated] ACTION_PERMISSIONS = { @@ -102,7 +103,7 @@ def _forbidden(self, msg): @action(detail=True, methods=["post"]) def assign_role(self, request, pk=None): - """ + """ Assign a role to a user in a project. Prevents self-assignment, assigning to owner, or assigning role ≥ assigner's role (except Admin/Owner). @@ -120,15 +121,17 @@ 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" + assigner_role = assigner.role.name if assigner else "Owner" - hierarchy = settings.ROLE_ORDER - if assigner_role not in ("Admin", "Owner"): - if hierarchy.index(new_role.name) >= hierarchy.index(assigner_role): - return error_response( - f"Cannot assign role '{new_role.name}'", - status.HTTP_403_FORBIDDEN, - ) + hierarchy = settings.ROLE_ORDER + if ( + assigner_role not in ("Admin", "Owner") and + 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, new_role) @@ -145,7 +148,7 @@ def assign_role(self, request, pk=None): serializer_class=KickUserSerializer ) def kick(self, request, pk=None): - """ + """ Kick user from project (not owner) """ project = self._get_project(pk) @@ -179,10 +182,11 @@ def leave_project(self, request, pk=None): class RoleViewSet(viewsets.ReadOnlyModelViewSet): """ Read‑only endpoints for project roles. - + All roles are created and managed via migrations/signals, not via API. This ViewSet only allows listing and retrieving. """ + queryset = Role.objects.all() serializer_class = RoleSerializer permission_classes = [IsAuthenticated] @@ -192,6 +196,7 @@ class ProjectMembershipViewSet(viewsets.ReadOnlyModelViewSet): """ Read-only viewset for viewing project members """ + serializer_class = ProjectMembershipSerializer permission_classes = [IsAuthenticated] diff --git a/tasks/admin.py b/tasks/admin.py index 3a4f1c8..0822115 100644 --- a/tasks/admin.py +++ b/tasks/admin.py @@ -2,4 +2,4 @@ from .models import Task, Category -admin.site.register([Task, Category]) \ No newline at end of file +admin.site.register([Task, Category]) diff --git a/tasks/apps.py b/tasks/apps.py index ca2261b..3ff3ab3 100644 --- a/tasks/apps.py +++ b/tasks/apps.py @@ -1,5 +1,6 @@ from django.apps import AppConfig + class TasksConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' - name = 'tasks' \ No newline at end of file + name = 'tasks' diff --git a/tasks/models.py b/tasks/models.py index 84d25a6..cbd3657 100644 --- a/tasks/models.py +++ b/tasks/models.py @@ -15,14 +15,12 @@ class Category(models.Model): """A category of tasks linked to a specific user""" - name = models.CharField( - max_length=20, - validators=[TEXT_FIELD_VALIDATOR] - ) + + name = models.CharField(max_length=20, validators=[TEXT_FIELD_VALIDATOR]) user = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name="categories" + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="categories", ) class Meta: @@ -40,10 +38,11 @@ class Task(models.Model): - automatic update of the completion time - logs of completed tasks (via signal) """ + PRIORITY_CHOICES = [ - ('L', 'Low'), - ('M', 'Medium'), - ('H', 'High'), + ("L", "Low"), + ("M", "Medium"), + ("H", "High"), ] title = models.CharField(max_length=64, validators=[TEXT_FIELD_VALIDATOR]) @@ -65,7 +64,7 @@ class Task(models.Model): null=True, blank=True ) priority = models.CharField( - max_length=1, choices=PRIORITY_CHOICES, default='M' + max_length=1, choices=PRIORITY_CHOICES, default="M" ) is_favorite = models.BooleanField(default=False, verbose_name="Favorite") @@ -85,7 +84,7 @@ class Task(models.Model): class Meta: indexes = [models.Index(fields=["due_date"]),] - ordering = ['id'] + ordering = ["id"] def update_completed_at(self): """Update the completed_at field when the task is marked as completed""" @@ -96,7 +95,7 @@ def update_completed_at(self): def save(self, *args, **kwargs): """Before saving, update completed_at depending on completed""" - self.update_completed_at() + self.update_completed_at() super().save(*args, **kwargs) def __str__(self): @@ -106,6 +105,7 @@ def __str__(self): @receiver(models.signals.post_save, sender=Task) def update_user_last_task_completed(sender, instance, created, **kwargs): + _, _ = sender, created """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() diff --git a/tasks/permissions.py b/tasks/permissions.py index fc6642b..0708f13 100644 --- a/tasks/permissions.py +++ b/tasks/permissions.py @@ -12,38 +12,41 @@ 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(f"Object {type(obj).__name__} has no ownership attribute") + logger.error( + f"Object {type(obj).__name__} has no ownership attribute" + ) raise PermissionDenied( "Access denied: missing ownership information" ) - + return user_field == request.user - + 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' - + return "Viewer" + if method in ("PUT", "PATCH"): + return "Member" + return "Moderator" + def has_permission(self, request, view): return True def has_object_permission(self, request, view, obj): - project = getattr(obj, 'project', None) + 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 + + return IsProjectMinRole(min_role).has_object_permission(request, view, obj) diff --git a/tasks/serializers.py b/tasks/serializers.py index d0cc90c..01a00e1 100644 --- a/tasks/serializers.py +++ b/tasks/serializers.py @@ -32,7 +32,8 @@ def validate_due_date(self, value): raise serializers.ValidationError("Due date cannot be None") if value < timezone.now(): raise serializers.ValidationError( - "The due date cannot be in the past") + "The due date cannot be in the past" + ) return value def get_completed_by(self, obj): @@ -63,11 +64,14 @@ class MoveTaskResponseSerializer(serializers.Serializer): class CategorySerializer(serializers.ModelSerializer): tasks_count = serializers.SerializerMethodField() + user_name: serializers.StringRelatedField = serializers.StringRelatedField( + source="user.username", read_only=True + ) class Meta: model = Category - fields = ["id", "name", "user", "tasks_count"] - read_only_fields = ["user", "tasks_count"] + fields = ["id", "name", "user", "user_name", "tasks_count"] + read_only_fields = ["user", "user_name", "tasks_count"] def validate_name(self, value): if not value.strip(): diff --git a/tasks/services.py b/tasks/services.py index 25371cf..24ee079 100644 --- a/tasks/services.py +++ b/tasks/services.py @@ -2,6 +2,7 @@ from django.db.models import Q from typing import TYPE_CHECKING + if TYPE_CHECKING: from tasks.models import Task from users.models import User @@ -35,7 +36,7 @@ def toggle_favorite(task): return task @staticmethod - def toggle_completed(task: 'Task', user: 'User') -> 'Task': + def toggle_completed(task: "Task", user: "User") -> "Task": """ Toggle the completed flag on a task, update timestamps, and record the user who completed it @@ -78,6 +79,7 @@ def move_task_to_project(task, project_id, user): return task + class CategoryService: """ Service for operations with categories diff --git a/tasks/urls.py b/tasks/urls.py index cb86033..cc6f848 100644 --- a/tasks/urls.py +++ b/tasks/urls.py @@ -4,12 +4,12 @@ from .views import TaskViewSet, CategoryViewSet router = SimpleRouter() -router.register(r'', TaskViewSet, basename='task') +router.register(r"", TaskViewSet, basename="task") management_router = SimpleRouter() management_router.register(r"categories", CategoryViewSet, basename="category") urlpatterns = [ - path('', include(router.urls)), - path('manage/', include(management_router.urls)), -] \ No newline at end of file + path("", include(router.urls)), + path("manage/", include(management_router.urls)), +] diff --git a/tasks/views.py b/tasks/views.py index dd4ad3f..87ef7e8 100644 --- a/tasks/views.py +++ b/tasks/views.py @@ -1,13 +1,13 @@ import logging from django.contrib.auth import get_user_model from django.db.models import Q -from django_filters.rest_framework import DjangoFilterBackend +from django.shortcuts import get_object_or_404 from django.utils.decorators import method_decorator from django.utils.timezone import now -from django.shortcuts import get_object_or_404 from django.views.decorators.cache import cache_page -from drf_yasg.utils import swagger_auto_schema +from django_filters.rest_framework import DjangoFilterBackend +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 @@ -31,6 +31,7 @@ logger = logging.getLogger(__name__) User = get_user_model() + class TaskViewSet(UserQuerysetMixin, viewsets.ModelViewSet): """ ViewSet for operations with tasks. @@ -39,7 +40,7 @@ class TaskViewSet(UserQuerysetMixin, viewsets.ModelViewSet): Includes extra actions for toggling favorite/completed status and moving tasks between projects. """ - + queryset = Task.objects.all() serializer_class = TaskSerializer permission_classes = [IsAuthenticated, IsOwner] @@ -52,18 +53,18 @@ class TaskViewSet(UserQuerysetMixin, viewsets.ModelViewSet): ] def get_permissions(self): - if self.kwargs.get('project_pk'): + if self.kwargs.get("project_pk"): return [IsAuthenticated(), ProjectTaskPermission()] return [IsAuthenticated(), IsOwner()] def get_queryset(self): - qs = super().get_queryset() + qs = super().get_queryset() project_id = self.kwargs.get("project_pk") if project_id is not None: # 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): @@ -84,10 +85,10 @@ def get_queryset(self): return qs.filter(filters) def perform_create(self, serializer): - project_pk = self.kwargs.get('project_pk') - save_kwargs = {'user': self.request.user} + project_pk = self.kwargs.get("project_pk") + save_kwargs = {"user": self.request.user} if project_pk is not None: - save_kwargs['project_id'] = project_pk + save_kwargs["project_id"] = project_pk serializer.save(**save_kwargs) def get_object(self): @@ -120,13 +121,13 @@ def toggle_favorite(self, request, pk=None): return error_response( "Failed to update favorite status", status.HTTP_500_INTERNAL_SERVER_ERROR, - exc=e + exc=e, ) @action( detail=True, methods=["post"], serializer_class=ToggleCompletedResponseSerializer, - permission_classes=[IsProjectMinRole('Member')] + permission_classes=[IsProjectMinRole('Member')], ) def toggle_completed(self, request, project_pk=None, pk=None): try: @@ -152,7 +153,7 @@ def toggle_completed(self, request, project_pk=None, pk=None): return error_response( "Failed to update completion status", status.HTTP_500_INTERNAL_SERVER_ERROR, - exc=e + exc=e, ) @action( @@ -173,14 +174,11 @@ def favorites(self, request): return Response(serializer.data) @swagger_auto_schema( - method='post', + method="post", request_body=MoveTaskSerializer, - responses={200: MoveTaskResponseSerializer} - ) - @action( - detail=True, methods=["post"], - serializer_class=MoveTaskSerializer + responses={200: MoveTaskResponseSerializer}, ) + @action(detail=True, methods=["post"], serializer_class=MoveTaskSerializer) def move_task(self, request, project_pk=None, pk=None): if project_pk is not None: raise NotFound("Use /tasks/{pk}/move_task/ to move tasks") @@ -207,7 +205,7 @@ def move_task(self, request, project_pk=None, pk=None): ) @swagger_auto_schema(auto_schema=None) def nested_move_task(self, request, project_pk=None, pk=None): - if getattr(self, 'swagger_fake_view', False): + if getattr(self, "swagger_fake_view", False): raise NotFound() return self.move_task(request, pk=pk) @@ -219,7 +217,7 @@ class CategoryViewSet(UserQuerysetMixin, viewsets.ModelViewSet): 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] diff --git a/users/admin.py b/users/admin.py index afe1840..72b7262 100644 --- a/users/admin.py +++ b/users/admin.py @@ -6,6 +6,7 @@ class CustomUserAdmin(UserAdmin): """Advanced user admin with additional profile fields""" + fieldsets = list(UserAdmin.fieldsets) + [ ( None, diff --git a/users/apps.py b/users/apps.py index 8751e2f..72b1401 100644 --- a/users/apps.py +++ b/users/apps.py @@ -1,5 +1,6 @@ from django.apps import AppConfig + class UsersConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' - name = 'users' \ No newline at end of file + name = 'users' diff --git a/users/models.py b/users/models.py index 4c51ded..14a01a9 100644 --- a/users/models.py +++ b/users/models.py @@ -11,6 +11,7 @@ PHONE_NUMBER_VALIDATOR, ) + class User(AbstractUser): """ Custom user model extending AbstractUser: @@ -18,7 +19,7 @@ class User(AbstractUser): - automatically generates a unique username if not specified - contains additional dates and profile fields """ - + username = models.CharField( max_length=80, unique=True, @@ -63,16 +64,16 @@ class User(AbstractUser): REQUIRED_FIELDS = ["username"] def save(self, *args, **kwargs): - """ - When creating a user, if the username is not specified, - we generate it based on email + """ + 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 @@ -82,16 +83,21 @@ def generate_username(self): 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 + max_base_length = self._meta.get_field("username").max_length - 10 base_username = base_username[:max_base_length] - + new_username = base_username - while User.objects.filter(username=new_username).select_for_update().exists(): + 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): - return self.email \ No newline at end of file + return self.email diff --git a/users/serializers.py b/users/serializers.py index 6ae9670..aa67c0a 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -9,6 +9,7 @@ User = get_user_model() + class UserRegistrationSerializer(serializers.ModelSerializer): password = serializers.CharField( write_only=True, required=True, validators=[validate_password], @@ -24,12 +25,13 @@ class Meta: fields = ["email", "password"] def create(self, validated_data): - """ Creates a user instance with hashed password """ + """Creates a user instance with hashed password""" user = User(email=validated_data["email"],) user.set_password(validated_data["password"]) user.save() return user + class UserLoginSerializer(serializers.Serializer): email = serializers.EmailField(help_text="User email") password = serializers.CharField( @@ -37,7 +39,7 @@ class UserLoginSerializer(serializers.Serializer): ) def validate(self, data): - """ Validates user credentials and returns JWT tokens """ + """Validates user credentials and returns JWT tokens""" user = authenticate(email=data["email"], password=data["password"]) if not user: @@ -50,7 +52,7 @@ def validate(self, data): user.save(update_fields=["last_login_at"]) token = RefreshToken.for_user(user) - + return { "username": user.username, "email": user.email, @@ -58,15 +60,17 @@ def validate(self, data): "access": str(token.access_token), } + class UserUpdateSerializer(serializers.ModelSerializer): class Meta: model = User fields = ["username", "email"] read_only_fields = ["username"] + class UserProfileSerializer(serializers.ModelSerializer): last_profile_edit_at = serializers.DateTimeField(read_only=True) - + class Meta: model = User fields = [ @@ -80,11 +84,12 @@ class Meta: "last_task_completed_at", ] read_only_fields = [ - "last_login_at", - "last_profile_edit_at", - "last_task_completed_at"] - + "last_login_at", + "last_profile_edit_at", + "last_task_completed_at", + ] + def update(self, instance, validated_data): - """ Automatically update 'last_profile_edit_at' timestamp on profile update """ + """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/services.py b/users/services.py index 4d22663..079b3c8 100644 --- a/users/services.py +++ b/users/services.py @@ -8,6 +8,7 @@ User = get_user_model() + class UserService: @staticmethod def register_user(data): @@ -18,7 +19,7 @@ def register_user(data): """ serializer = UserRegistrationSerializer(data=data) serializer.is_valid(raise_exception=True) - serializer.save() + serializer.save() return serializer.data @staticmethod @@ -58,4 +59,4 @@ def update_profile(user, data): serializer = UserProfileSerializer(user, data=data, partial=True) serializer.is_valid(raise_exception=True) serializer.save() - return serializer.data \ No newline at end of file + return serializer.data diff --git a/users/urls.py b/users/urls.py index b3f03d2..7233b7f 100644 --- a/users/urls.py +++ b/users/urls.py @@ -5,15 +5,15 @@ from .views import UserViewSet, AuthViewSet router = SimpleRouter() -router.register(r'', AuthViewSet, basename='auth') -router.register(r'', UserViewSet, basename='user') +router.register(r"", AuthViewSet, basename="auth") +router.register(r"", UserViewSet, basename="user") urlpatterns = [ path('', include(router.urls)), - + # JWT Token endpoints path("token/", include([ path("refresh/", TokenRefreshView.as_view(), name="token_refresh"), ])), -] \ No newline at end of file +] diff --git a/users/views.py b/users/views.py index 8d45a89..fd23d29 100644 --- a/users/views.py +++ b/users/views.py @@ -11,60 +11,65 @@ ) from .services import UserService + 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): + if getattr(self, "swagger_fake_view", False): return serializers.Serializer # Dynamically choose serializer based on action - if self.action == 'register': + if self.action == "register": return UserRegistrationSerializer - elif self.action == 'login': + if self.action == "login": return UserLoginSerializer def get_permissions(self): - if self.action in ['register', 'login']: + if self.action in ["register", "login"]: return [AllowAny()] return [IsAuthenticated()] - @action(detail=False, methods=['post']) + @action(detail=False, methods=["post"]) def register(self, request): - """ Registers a new user """ + """Registers a new user""" data = UserService.register_user(request.data) return Response(data, status=status.HTTP_201_CREATED) - @action(detail=False, methods=['post']) + @action(detail=False, methods=["post"]) def login(self, request): - """ Logs in a user and returns tokens """ + """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']) + @action(detail=False, methods=["post"]) def logout(self, request): - """ Logs out a user by blacklisting the refresh token """ + """Logs out a user by blacklisting the refresh token""" refresh_token = request.data.get("refresh") error_messages_map = { "Invalid token format": "Incorrect token format", "Token expired": "Expired token", "Token already revoked": "The token has already been revoked", } - + 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) except ValueError as e: - user_message = error_messages_map.get(str(e), "Unknown token error") - return error_response(user_message, status.HTTP_401_UNAUTHORIZED, exc=e) + user_message = error_messages_map.get( + str(e), "Unknown token error" + ) + return error_response( + user_message, status.HTTP_401_UNAUTHORIZED, exc=e + ) except Exception as e: return error_response( "Internal server error", @@ -77,17 +82,18 @@ class UserViewSet(viewsets.GenericViewSet): """ Handles viewing and updating the authenticated user's profile """ + permission_classes = [IsAuthenticated] serializer_class = UserProfileSerializer - @action(detail=False, methods=['get']) + @action(detail=False, methods=["get"]) def profile(self, request): - """ Returns the authenticated user's profile """ + """Returns the authenticated user's profile""" serializer = self.get_serializer(request.user) return Response(serializer.data) - @action(detail=False, methods=['put', 'patch']) + @action(detail=False, methods=["put", "patch"]) def update_profile(self, request): - """ Updates the authenticated user's profile """ + """Updates the authenticated user's profile""" data = UserService.update_profile(request.user, request.data) return Response(data)