Compare commits

..

3 Commits

27 changed files with 428 additions and 932 deletions

View File

@@ -4,7 +4,6 @@ SECTION_WORKSPACE = "workspace"
SECTION_WORKSPACE_MEMBERS = "workspace_members" SECTION_WORKSPACE_MEMBERS = "workspace_members"
SECTION_CLIENTS = "clients" SECTION_CLIENTS = "clients"
SECTION_PROJECTS = "projects" SECTION_PROJECTS = "projects"
SECTION_PROJECT_MEMBERS = "project_members"
SECTION_TAGS = "tags" SECTION_TAGS = "tags"
SECTION_TIME_ENTRIES = "time_entries" SECTION_TIME_ENTRIES = "time_entries"
SECTION_RATES = "rates" SECTION_RATES = "rates"
@@ -15,7 +14,6 @@ LOG_SECTIONS = (
SECTION_WORKSPACE_MEMBERS, SECTION_WORKSPACE_MEMBERS,
SECTION_CLIENTS, SECTION_CLIENTS,
SECTION_PROJECTS, SECTION_PROJECTS,
SECTION_PROJECT_MEMBERS,
SECTION_TAGS, SECTION_TAGS,
SECTION_TIME_ENTRIES, SECTION_TIME_ENTRIES,
SECTION_RATES, SECTION_RATES,
@@ -48,11 +46,9 @@ SECTION_BY_MODEL_LABEL = {
"workspaces.workspaceuserrate": SECTION_RATES, "workspaces.workspaceuserrate": SECTION_RATES,
"clients.client": SECTION_CLIENTS, "clients.client": SECTION_CLIENTS,
"projects.project": SECTION_PROJECTS, "projects.project": SECTION_PROJECTS,
"projects.projectmembership": SECTION_PROJECT_MEMBERS,
"tags.tag": SECTION_TAGS, "tags.tag": SECTION_TAGS,
"time_entries.timeentry": SECTION_TIME_ENTRIES, "time_entries.timeentry": SECTION_TIME_ENTRIES,
"reports.reportexportjob": SECTION_REPORT_EXPORTS, "reports.reportexportjob": SECTION_REPORT_EXPORTS,
} }
TRACKED_MODEL_LABELS = tuple(SECTION_BY_MODEL_LABEL.keys()) TRACKED_MODEL_LABELS = tuple(SECTION_BY_MODEL_LABEL.keys())

View File

@@ -1,8 +1,4 @@
from apps.notifications.services.membership_events import ( from apps.notifications.services.membership_events import (
notify_project_membership_added,
notify_project_membership_deactivated,
notify_project_membership_removed,
notify_project_membership_role_changed,
notify_workspace_membership_added, notify_workspace_membership_added,
notify_workspace_membership_deactivated, notify_workspace_membership_deactivated,
notify_workspace_membership_removed, notify_workspace_membership_removed,
@@ -16,8 +12,4 @@ __all__ = [
"notify_workspace_membership_role_changed", "notify_workspace_membership_role_changed",
"notify_workspace_membership_deactivated", "notify_workspace_membership_deactivated",
"notify_workspace_membership_removed", "notify_workspace_membership_removed",
"notify_project_membership_added",
"notify_project_membership_role_changed",
"notify_project_membership_deactivated",
"notify_project_membership_removed",
] ]

View File

@@ -31,10 +31,6 @@ def _workspace_action_url(workspace) -> str:
return f"/workspaces/{workspace.id}" return f"/workspaces/{workspace.id}"
def _project_action_url(project) -> str:
return "/projects"
def notify_workspace_membership_added(*, actor, recipient, workspace, role: str) -> None: def notify_workspace_membership_added(*, actor, recipient, workspace, role: str) -> None:
if _should_skip(actor, recipient): if _should_skip(actor, recipient):
return return
@@ -148,124 +144,3 @@ def notify_workspace_membership_removed(*, actor, recipient, workspace, role: st
}, },
}, },
) )
def notify_project_membership_added(*, actor, recipient, project, role: str) -> None:
if _should_skip(actor, recipient):
return
actor_display = _actor_name(actor)
role_label = _role_label(recipient.project_memberships.model.Role, role)
_notify_user(
recipient,
{
"type": "project_membership_added",
"title": "Added to project",
"message": f"{actor_display} added you to {project.name} as {role_label}.",
"level": "info",
"action_url": _project_action_url(project),
"entity_type": "project",
"entity_id": str(project.id),
"meta": {
"workspace_id": str(project.workspace_id),
"workspace_name": project.workspace.name,
"project_id": str(project.id),
"project_name": project.name,
"actor_id": str(actor.id),
"actor_name": actor_display,
"new_role": role,
},
},
)
def notify_project_membership_role_changed(
*, actor, recipient, project, previous_role: str, new_role: str
) -> None:
if _should_skip(actor, recipient) or previous_role == new_role:
return
actor_display = _actor_name(actor)
previous_role_label = _role_label(recipient.project_memberships.model.Role, previous_role)
new_role_label = _role_label(recipient.project_memberships.model.Role, new_role)
_notify_user(
recipient,
{
"type": "project_membership_role_changed",
"title": "Project role changed",
"message": (
f"{actor_display} changed your role in {project.name} "
f"from {previous_role_label} to {new_role_label}."
),
"level": "info",
"action_url": _project_action_url(project),
"entity_type": "project",
"entity_id": str(project.id),
"meta": {
"workspace_id": str(project.workspace_id),
"workspace_name": project.workspace.name,
"project_id": str(project.id),
"project_name": project.name,
"actor_id": str(actor.id),
"actor_name": actor_display,
"previous_role": previous_role,
"new_role": new_role,
},
},
)
def notify_project_membership_deactivated(*, actor, recipient, project, role: str) -> None:
if _should_skip(actor, recipient):
return
actor_display = _actor_name(actor)
_notify_user(
recipient,
{
"type": "project_membership_deactivated",
"title": "Project access deactivated",
"message": f"{actor_display} deactivated your access to {project.name}.",
"level": "warning",
"action_url": _project_action_url(project),
"entity_type": "project",
"entity_id": str(project.id),
"meta": {
"workspace_id": str(project.workspace_id),
"workspace_name": project.workspace.name,
"project_id": str(project.id),
"project_name": project.name,
"actor_id": str(actor.id),
"actor_name": actor_display,
"previous_role": role,
},
},
)
def notify_project_membership_removed(*, actor, recipient, project, role: str) -> None:
if _should_skip(actor, recipient):
return
actor_display = _actor_name(actor)
_notify_user(
recipient,
{
"type": "project_membership_removed",
"title": "Removed from project",
"message": f"{actor_display} removed you from {project.name}.",
"level": "warning",
"action_url": _project_action_url(project),
"entity_type": "project",
"entity_id": str(project.id),
"meta": {
"workspace_id": str(project.workspace_id),
"workspace_name": project.workspace.name,
"project_id": str(project.id),
"project_name": project.name,
"actor_id": str(actor.id),
"actor_name": actor_display,
"previous_role": role,
},
},
)

View File

@@ -4,7 +4,6 @@ from rest_framework.test import APIClient
from apps.notifications.services import store as services from apps.notifications.services import store as services
from apps.notifications.services import RedisNotificationStore from apps.notifications.services import RedisNotificationStore
from apps.notifications.tests.test_services import FakeRedis from apps.notifications.tests.test_services import FakeRedis
from apps.projects.models import Project, ProjectMembership
from apps.users.models import User from apps.users.models import User
from apps.workspaces.models import Workspace, WorkspaceMembership from apps.workspaces.models import Workspace, WorkspaceMembership
@@ -158,140 +157,3 @@ def test_workspace_membership_update_skips_self_notifications(
assert response.status_code == 403 assert response.status_code == 403
assert _notifications_for(owner) == [] assert _notifications_for(owner) == []
def test_project_create_notifies_initial_members_not_creator(
fake_redis, api_client, owner, member
):
workspace = Workspace.objects.create(name="Engineering", description="", owner=owner)
api_client.force_authenticate(user=owner)
response = api_client.post(
"/api/projects/",
{
"workspace": str(workspace.id),
"name": "API",
"description": "",
"client": None,
"members": [
{"user_id": str(member.id), "role": ProjectMembership.Role.MEMBER}
],
},
format="json",
)
assert response.status_code == 201
assert _notifications_for(owner) == []
notifications = _notifications_for(member)
assert len(notifications) == 1
assert notifications[0]["type"] == "project_membership_added"
assert notifications[0]["meta"]["project_name"] == "API"
def test_project_update_full_sync_notifies_only_real_deltas(
fake_redis, api_client, owner, member, another_member, third_member, fourth_member
):
workspace = Workspace.objects.create(name="Build", description="", owner=owner)
project = Project.objects.create(workspace=workspace, name="Platform", description="")
ProjectMembership.objects.create(
project=project,
user=owner,
role=ProjectMembership.Role.MANAGER,
is_active=True,
)
ProjectMembership.objects.create(
project=project,
user=member,
role=ProjectMembership.Role.MEMBER,
is_active=True,
)
ProjectMembership.objects.create(
project=project,
user=another_member,
role=ProjectMembership.Role.MEMBER,
is_active=True,
)
ProjectMembership.objects.create(
project=project,
user=third_member,
role=ProjectMembership.Role.MEMBER,
is_active=True,
)
api_client.force_authenticate(user=owner)
response = api_client.patch(
f"/api/projects/{project.id}/",
{
"client": None,
"members": [
{"user_id": str(member.id), "role": ProjectMembership.Role.MANAGER},
{"user_id": str(third_member.id), "role": ProjectMembership.Role.MEMBER},
{"user_id": str(fourth_member.id), "role": ProjectMembership.Role.MEMBER},
],
},
format="json",
)
assert response.status_code == 200
assert [item["type"] for item in _notifications_for(member)] == [
"project_membership_role_changed"
]
assert [item["type"] for item in _notifications_for(another_member)] == [
"project_membership_deactivated"
]
assert _notifications_for(third_member) == []
assert [item["type"] for item in _notifications_for(fourth_member)] == [
"project_membership_added"
]
assert _notifications_for(owner) == []
def test_project_membership_crud_emits_add_role_change_deactivate_and_remove(
fake_redis, api_client, owner, member
):
workspace = Workspace.objects.create(name="Data", description="", owner=owner)
project = Project.objects.create(workspace=workspace, name="Warehouse", description="")
ProjectMembership.objects.create(
project=project,
user=owner,
role=ProjectMembership.Role.MANAGER,
is_active=True,
)
api_client.force_authenticate(user=owner)
create_response = api_client.post(
"/api/memberships/",
{
"project_id": str(project.id),
"user_id": str(member.id),
"role": ProjectMembership.Role.MEMBER,
},
format="json",
)
assert create_response.status_code == 201
membership_id = create_response.data["id"]
role_response = api_client.patch(
f"/api/memberships/{membership_id}/",
{"role": ProjectMembership.Role.MANAGER},
format="json",
)
assert role_response.status_code == 200
deactivate_response = api_client.patch(
f"/api/memberships/{membership_id}/",
{"is_active": False},
format="json",
)
assert deactivate_response.status_code == 200
remove_response = api_client.delete(f"/api/memberships/{membership_id}/")
assert remove_response.status_code == 204
notifications = _notifications_for(member)
assert [item["type"] for item in notifications] == [
"project_membership_removed",
"project_membership_deactivated",
"project_membership_role_changed",
"project_membership_added",
]

View File

@@ -1,13 +1,7 @@
from django.contrib import admin from django.contrib import admin
from core.admins.base import BaseAdmin from core.admins.base import BaseAdmin
from apps.projects.models import Project, ProjectMembership from apps.projects.models import Project
class ProjectMembershipInline(admin.TabularInline):
model = ProjectMembership
extra = 0
autocomplete_fields = ("user",)
@admin.register(Project) @admin.register(Project)
@@ -33,36 +27,7 @@ class ProjectAdmin(BaseAdmin):
"client__name", "client__name",
) )
autocomplete_fields = ( autocomplete_fields = (
"workspace", "workspace",
"client", "client",
) )
inlines = (ProjectMembershipInline,)
@admin.register(ProjectMembership)
class ProjectMembershipAdmin(BaseAdmin):
list_display = (
"id",
"project",
"user",
"role",
"is_active",
)
list_filter = (
"role",
"is_active",
"is_deleted",
)
search_fields = (
"project__name",
"user__mobile",
)
autocomplete_fields = (
"project",
"user",
)

View File

@@ -1,11 +1,9 @@
from rest_framework import permissions from rest_framework import permissions
from apps.projects.models import ProjectMembership
from apps.workspaces.services import ( from apps.workspaces.services import (
PROJECTS_EDIT, PROJECTS_EDIT,
PROJECTS_VIEW, PROJECTS_VIEW,
PROJECT_MEMBERS_CHANGE_ROLE, has_workspace_capability,
has_project_capability,
) )
@@ -17,9 +15,9 @@ def get_project_from_obj(obj):
class IsProjectMember(permissions.BasePermission): class IsProjectMember(permissions.BasePermission):
""" """
Allows access only to users who have an active membership in the project. Allows access to users who can view projects in the parent workspace.
""" """
message = "شما عضو این پروژه نیستید." message = "شما عضو این پروژه نیستید."
def has_object_permission(self, request, view, obj): def has_object_permission(self, request, view, obj):
@@ -27,13 +25,13 @@ class IsProjectMember(permissions.BasePermission):
return False return False
project = get_project_from_obj(obj) project = get_project_from_obj(obj)
return has_project_capability(request.user, project, PROJECTS_VIEW) return has_workspace_capability(request.user, project.workspace, PROJECTS_VIEW)
class IsProjectManager(permissions.BasePermission): class IsProjectManager(permissions.BasePermission):
""" """
Allows access only to users who are active MANAGERs of the project. Allows access to users who can manage projects in the parent workspace.
""" """
message = "فقط مدیران پروژه مجاز به انجام این عملیات هستند." message = "فقط مدیران پروژه مجاز به انجام این عملیات هستند."
def has_object_permission(self, request, view, obj): def has_object_permission(self, request, view, obj):
@@ -41,19 +39,4 @@ class IsProjectManager(permissions.BasePermission):
return False return False
project = get_project_from_obj(obj) project = get_project_from_obj(obj)
return has_project_capability(request.user, project, PROJECTS_EDIT) return has_workspace_capability(request.user, project.workspace, PROJECTS_EDIT)
class CanManageProjectMembers(permissions.BasePermission):
message = "Only authorized users can manage project memberships."
def has_object_permission(self, request, view, obj):
if not request.user or not request.user.is_authenticated:
return False
project = get_project_from_obj(obj)
return has_project_capability(
request.user,
project,
PROJECT_MEMBERS_CHANGE_ROLE,
)

View File

@@ -1,97 +1,42 @@
from rest_framework import serializers from rest_framework import serializers
from core.serializers.base import BaseModelSerializer from core.serializers.base import BaseModelSerializer
from apps.projects.models import ( from apps.projects.models import Project
Project,
ProjectMembership,
) class ProjectSerializer(BaseModelSerializer):
from core.serializers.mini import UserMiniSerializer class Meta:
model = Project
fields = BaseModelSerializer.Meta.fields + (
class ProjectMemberInputSerializer(serializers.Serializer): "workspace",
user_id = serializers.UUIDField() "name",
role = serializers.ChoiceField(choices=ProjectMembership.Role.choices, default=ProjectMembership.Role.MEMBER) "client",
"description",
"is_archived",
class ProjectSerializer(BaseModelSerializer): "color",
my_role = serializers.SerializerMethodField() )
members = serializers.SerializerMethodField() read_only_fields = fields
class Meta: def to_representation(self, instance):
model = Project representation = super().to_representation(instance)
fields = BaseModelSerializer.Meta.fields + ( if instance.client:
"workspace", representation['client'] = {
"name",
"client",
"description",
"is_archived",
"color",
"my_role",
"members",
)
read_only_fields = fields
def get_my_role(self, obj):
request = self.context.get("request")
if not request or not request.user.is_authenticated:
return None
membership = obj.memberships.filter(user=request.user, is_active=True, is_deleted=False).first()
return getattr(membership, "role", None)
def get_members(self, obj):
"""
Returns active project members in the response
"""
active_memberships = obj.memberships.filter(is_active=True, is_deleted=False)
return ProjectMembershipSerializer(active_memberships, many=True).data
def to_representation(self, instance):
representation = super().to_representation(instance)
if instance.client:
representation['client'] = {
'id': instance.client.id, 'id': instance.client.id,
'name': instance.client.name 'name': instance.client.name
} }
return representation return representation
class ProjectCreateSerializer(serializers.Serializer): class ProjectCreateSerializer(serializers.Serializer):
workspace = serializers.UUIDField() workspace = serializers.UUIDField()
name = serializers.CharField(max_length=255) name = serializers.CharField(max_length=255)
client = serializers.UUIDField(required=False, allow_null=True) client = serializers.UUIDField(required=False, allow_null=True)
description = serializers.CharField(required=False, allow_blank=True, default="") description = serializers.CharField(required=False, allow_blank=True, default="")
color = serializers.CharField(max_length=7, required=False, allow_blank=True, default="") color = serializers.CharField(max_length=7, required=False, allow_blank=True, default="")
members = ProjectMemberInputSerializer(many=True, required=False)
class ProjectUpdateSerializer(serializers.Serializer):
class ProjectUpdateSerializer(serializers.Serializer): name = serializers.CharField(max_length=255, required=False)
name = serializers.CharField(max_length=255, required=False) client = serializers.UUIDField(required=False, allow_null=True)
client = serializers.UUIDField(required=False, allow_null=True) description = serializers.CharField(required=False, allow_blank=True)
description = serializers.CharField(required=False, allow_blank=True) color = serializers.CharField(max_length=7, required=False, allow_blank=True)
color = serializers.CharField(max_length=7, required=False, allow_blank=True) is_archived = serializers.BooleanField(required=False)
is_archived = serializers.BooleanField(required=False)
members = ProjectMemberInputSerializer(many=True, required=False)
class ProjectMembershipSerializer(BaseModelSerializer):
user_details = UserMiniSerializer(read_only=True)
class Meta:
model = ProjectMembership
fields = BaseModelSerializer.Meta.fields + (
"project",
"user",
"user_details",
"role",
"is_active",
)
read_only_fields = fields
class ProjectMembershipCreateSerializer(serializers.Serializer):
project_id = serializers.UUIDField()
user_id = serializers.UUIDField()
role = serializers.ChoiceField(choices=ProjectMembership.Role.choices)
class ProjectMembershipUpdateSerializer(serializers.Serializer):
role = serializers.ChoiceField(choices=ProjectMembership.Role.choices, required=False)
is_active = serializers.BooleanField(required=False)

View File

@@ -1,16 +1,12 @@
from django.urls import path, include from django.urls import path, include
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from apps.projects.api.views import ( from apps.projects.api.views import ProjectViewSet
ProjectViewSet,
ProjectMembershipViewSet,
)
app_name = "projects" app_name = "projects"
router = DefaultRouter() router = DefaultRouter()
router.register(r"projects", ProjectViewSet, basename="project") router.register(r"projects", ProjectViewSet, basename="project")
router.register(r"memberships", ProjectMembershipViewSet, basename="membership")
urlpatterns = [ urlpatterns = [
path("", include(router.urls)), path("", include(router.urls)),

View File

@@ -1,49 +1,33 @@
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from rest_framework import status from rest_framework import status
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.exceptions import PermissionDenied from rest_framework.permissions import IsAuthenticated
from rest_framework.permissions import IsAuthenticated from rest_framework.decorators import action
from rest_framework.decorators import action from rest_framework.filters import SearchFilter, OrderingFilter
from rest_framework.filters import SearchFilter, OrderingFilter
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from core.paginations.limit_offset import CustomLimitOffsetPagination from core.paginations.limit_offset import CustomLimitOffsetPagination
from apps.notifications.services import (
notify_project_membership_added,
notify_project_membership_deactivated,
notify_project_membership_removed,
notify_project_membership_role_changed,
)
from apps.workspaces.models import Workspace from apps.workspaces.models import Workspace
from apps.clients.models import Client from apps.clients.models import Client
from apps.projects.models import ( from apps.projects.models import Project
Project,
ProjectMembership,
)
from apps.projects.api.serializers import ( from apps.projects.api.serializers import (
ProjectSerializer, ProjectCreateSerializer, ProjectUpdateSerializer, ProjectSerializer, ProjectCreateSerializer, ProjectUpdateSerializer,
ProjectMembershipSerializer, ProjectMembershipCreateSerializer, ProjectMembershipUpdateSerializer,
) )
from apps.projects.api.permissions import IsProjectMember, IsProjectManager from apps.projects.api.permissions import IsProjectMember, IsProjectManager
from apps.projects.services.projects import ( from apps.projects.services.projects import (
create_project, create_project,
update_project, update_project,
toggle_project_archive toggle_project_archive
) )
from apps.projects.services.memberships import add_project_member, update_project_member
from apps.workspaces.services import ( from apps.workspaces.services import (
PROJECTS_ARCHIVE, PROJECTS_ARCHIVE,
PROJECTS_CREATE, PROJECTS_CREATE,
PROJECTS_DELETE, PROJECTS_DELETE,
PROJECTS_EDIT, PROJECTS_EDIT,
PROJECT_MEMBERS_ADD,
PROJECT_MEMBERS_CHANGE_ROLE,
PROJECT_MEMBERS_REMOVE,
can_delete_workspace_object, can_delete_workspace_object,
has_project_capability,
has_workspace_capability, has_workspace_capability,
) )
@@ -60,13 +44,13 @@ class ProjectViewSet(ModelViewSet):
ordering_fields = ["name", "created_at", "updated_at"] ordering_fields = ["name", "created_at", "updated_at"]
ordering = ["-updated_at", "-created_at"] ordering = ["-updated_at", "-created_at"]
def get_permissions(self): def get_permissions(self):
""" """
Instantiates and returns the list of permissions that this view requires. Instantiates and returns the list of permissions that this view requires.
- Managers can update, delete, or archive. - Workspace-authorized users can update, delete, or archive.
- Members can retrieve/view. - Workspace members can retrieve/view.
- Any authenticated user can list (filtered to their memberships) or attempt to create. - Any authenticated user can list their workspace projects or attempt to create.
""" """
if self.action in ["update", "partial_update", "destroy", "archive"]: if self.action in ["update", "partial_update", "destroy", "archive"]:
permission_classes = [IsAuthenticated, IsProjectManager] permission_classes = [IsAuthenticated, IsProjectManager]
elif self.action in ["retrieve"]: elif self.action in ["retrieve"]:
@@ -76,18 +60,24 @@ class ProjectViewSet(ModelViewSet):
return [permission() for permission in permission_classes] return [permission() for permission in permission_classes]
def get_queryset(self): def get_queryset(self):
""" """
Returns active projects where the current user is an active member. Returns active projects in workspaces where the current user is an active member.
""" """
if getattr(self, "swagger_fake_view", False) or not self.request.user.is_authenticated: if getattr(self, "swagger_fake_view", False) or not self.request.user.is_authenticated:
return Project.objects.none() return Project.objects.none()
return Project.objects.filter( queryset = Project.objects.filter(
workspace__memberships__user=self.request.user, workspace__memberships__user=self.request.user,
workspace__memberships__is_active=True, workspace__memberships__is_active=True,
is_deleted=False is_deleted=False
).distinct() ).distinct()
client_ids = [client_id for client_id in self.request.query_params.getlist("clients") if client_id]
if client_ids:
queryset = queryset.filter(client_id__in=client_ids)
return queryset
def get_serializer_class(self): def get_serializer_class(self):
""" """
@@ -100,14 +90,12 @@ class ProjectViewSet(ModelViewSet):
return ProjectSerializer return ProjectSerializer
def create(self, request, *args, **kwargs): def create(self, request, *args, **kwargs):
""" """
Creates a new project using the project service layer. Creates a new project using the project service layer.
""" """
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
members_data = serializer.validated_data.pop("members", [])
workspace = get_object_or_404(Workspace, id=serializer.validated_data["workspace"], is_deleted=False) workspace = get_object_or_404(Workspace, id=serializer.validated_data["workspace"], is_deleted=False)
if not has_workspace_capability(request.user, workspace, PROJECTS_CREATE): if not has_workspace_capability(request.user, workspace, PROJECTS_CREATE):
return Response( return Response(
@@ -122,101 +110,30 @@ class ProjectViewSet(ModelViewSet):
workspace=workspace, workspace=workspace,
name=serializer.validated_data["name"], name=serializer.validated_data["name"],
client=client, client=client,
description=serializer.validated_data.get("description", ""), description=serializer.validated_data.get("description", ""),
color=serializer.validated_data.get("color", "") color=serializer.validated_data.get("color", "")
) )
for member in members_data: output_serializer = ProjectSerializer(project)
membership = add_project_member( return Response(output_serializer.data, status=status.HTTP_201_CREATED)
project=project,
user_id=member["user_id"],
role=member["role"]
)
notify_project_membership_added(
actor=request.user,
recipient=membership.user,
project=project,
role=membership.role,
)
output_serializer = ProjectSerializer(project)
return Response(output_serializer.data, status=status.HTTP_201_CREATED)
def update(self, request, *args, **kwargs): def update(self, request, *args, **kwargs):
""" """
Updates an existing project using the project service layer. Updates an existing project using the project service layer.
""" """
partial = kwargs.pop("partial", False) partial = kwargs.pop("partial", False)
project = self.get_object() project = self.get_object()
serializer = self.get_serializer(data=request.data, partial=partial) serializer = self.get_serializer(data=request.data, partial=partial)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
members_data = serializer.validated_data.pop("members", None) updated_project = update_project(
project=project,
updated_project = update_project( **serializer.validated_data
project=project, )
**serializer.validated_data
) output_serializer = ProjectSerializer(updated_project)
return Response(output_serializer.data, status=status.HTTP_200_OK)
# Full sync of project members if array is provided
if members_data is not None:
current_memberships = {str(m.user_id): m for m in updated_project.memberships.filter(is_deleted=False)}
incoming_users = {str(m['user_id']) for m in members_data}
# Add or Update roles
for member in members_data:
user_id_str = str(member['user_id'])
if user_id_str in current_memberships:
membership = current_memberships[user_id_str]
previous_role = membership.role
previous_is_active = membership.is_active
updated_membership = update_project_member(
membership,
role=member['role'],
is_active=True
)
if not previous_is_active and updated_membership.is_active:
notify_project_membership_added(
actor=request.user,
recipient=updated_membership.user,
project=updated_project,
role=updated_membership.role,
)
elif previous_role != updated_membership.role:
notify_project_membership_role_changed(
actor=request.user,
recipient=updated_membership.user,
project=updated_project,
previous_role=previous_role,
new_role=updated_membership.role,
)
else:
membership = add_project_member(
project=updated_project,
user_id=member['user_id'],
role=member['role']
)
notify_project_membership_added(
actor=request.user,
recipient=membership.user,
project=updated_project,
role=membership.role,
)
# Deactivate omitted members
for user_id_str, membership in current_memberships.items():
if user_id_str not in incoming_users and membership.is_active:
update_project_member(membership, is_active=False)
notify_project_membership_deactivated(
actor=request.user,
recipient=membership.user,
project=updated_project,
role=membership.role,
)
output_serializer = ProjectSerializer(updated_project)
return Response(output_serializer.data, status=status.HTTP_200_OK)
def destroy(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs):
""" """
@@ -238,127 +155,7 @@ class ProjectViewSet(ModelViewSet):
Custom endpoint to toggle the archive status of a project. Custom endpoint to toggle the archive status of a project.
""" """
project = self.get_object() project = self.get_object()
updated_project = toggle_project_archive(project) updated_project = toggle_project_archive(project)
output_serializer = ProjectSerializer(updated_project) output_serializer = ProjectSerializer(updated_project)
return Response(output_serializer.data, status=status.HTTP_200_OK) return Response(output_serializer.data, status=status.HTTP_200_OK)
class BaseProjectNestedViewSet(ModelViewSet):
"""
Base ViewSet for nested project models to share common permission and queryset logic.
"""
pagination_class = CustomLimitOffsetPagination
filter_backends = [DjangoFilterBackend, OrderingFilter]
ordering = ["-updated_at", "-created_at"]
def get_permissions(self):
if self.action in ["create", "update", "partial_update", "destroy"]:
permission_classes = [IsAuthenticated, IsProjectManager]
else:
permission_classes = [IsAuthenticated, IsProjectMember]
return [permission() for permission in permission_classes]
def verify_manager_access(self, project_id):
"""Helper to verify if the requesting user is a manager of the target project."""
project = get_object_or_404(Project, id=project_id, is_deleted=False)
if not has_project_capability(self.request.user, project, PROJECT_MEMBERS_ADD):
raise PermissionDenied("You must be a project manager to perform this action.")
class ProjectMembershipViewSet(BaseProjectNestedViewSet):
filterset_fields = ["project", "user", "role", "is_active"]
def get_queryset(self):
if getattr(self, "swagger_fake_view", False) or not self.request.user.is_authenticated:
return ProjectMembership.objects.none()
return ProjectMembership.objects.filter(
project__memberships__user=self.request.user,
project__memberships__is_active=True,
is_deleted=False
).distinct()
def get_serializer_class(self):
if self.action == "create": return ProjectMembershipCreateSerializer
if self.action in ["update", "partial_update"]: return ProjectMembershipUpdateSerializer
return ProjectMembershipSerializer
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
project_id = serializer.validated_data["project_id"]
self.verify_manager_access(project_id)
project = get_object_or_404(Project, id=project_id, is_deleted=False)
membership = add_project_member(
project=project,
user_id=serializer.validated_data["user_id"],
role=serializer.validated_data["role"]
)
notify_project_membership_added(
actor=request.user,
recipient=membership.user,
project=project,
role=membership.role,
)
return Response(ProjectMembershipSerializer(membership).data, status=status.HTTP_201_CREATED)
def update(self, request, *args, **kwargs):
membership = self.get_object()
if not has_project_capability(
request.user,
membership.project,
PROJECT_MEMBERS_CHANGE_ROLE,
):
raise PermissionDenied("You do not have permission to update project members.")
serializer = self.get_serializer(data=request.data, partial=kwargs.pop("partial", False))
serializer.is_valid(raise_exception=True)
previous_role = membership.role
previous_is_active = membership.is_active
updated_membership = update_project_member(membership, **serializer.validated_data)
if not previous_is_active and updated_membership.is_active:
notify_project_membership_added(
actor=request.user,
recipient=updated_membership.user,
project=updated_membership.project,
role=updated_membership.role,
)
elif previous_is_active and not updated_membership.is_active:
notify_project_membership_deactivated(
actor=request.user,
recipient=updated_membership.user,
project=updated_membership.project,
role=previous_role,
)
elif previous_role != updated_membership.role:
notify_project_membership_role_changed(
actor=request.user,
recipient=updated_membership.user,
project=updated_membership.project,
previous_role=previous_role,
new_role=updated_membership.role,
)
return Response(ProjectMembershipSerializer(updated_membership).data, status=status.HTTP_200_OK)
def destroy(self, request, *args, **kwargs):
membership = self.get_object()
if not has_project_capability(
request.user,
membership.project,
PROJECT_MEMBERS_REMOVE,
):
raise PermissionDenied("You do not have permission to remove project members.")
recipient = membership.user
project = membership.project
role = membership.role
membership.is_deleted = True
membership.save(update_fields=["is_deleted", "updated_at"])
notify_project_membership_removed(
actor=request.user,
recipient=recipient,
project=project,
role=role,
)
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -0,0 +1,13 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("projects", "0001_initial"),
]
operations = [
migrations.DeleteModel(
name="ProjectMembership",
),
]

View File

@@ -2,7 +2,7 @@ from django.contrib.auth import get_user_model
from django.db import models from django.db import models
from apps.logs.services import build_workspace_log_metadata from apps.logs.services import build_workspace_log_metadata
from apps.logs.services.constants import SECTION_PROJECTS, SECTION_PROJECT_MEMBERS from apps.logs.services.constants import SECTION_PROJECTS
from core.models.base import BaseModel from core.models.base import BaseModel
from apps.workspaces.models import Workspace from apps.workspaces.models import Workspace
@@ -59,64 +59,6 @@ class Project(BaseModel):
) )
class ProjectMembership(BaseModel):
class Role(models.TextChoices):
MANAGER = "manager", "Manager"
MEMBER = "member", "Member"
project = models.ForeignKey(
Project,
on_delete=models.CASCADE,
related_name="memberships",
)
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="project_memberships",
)
role = models.CharField(
max_length=20,
choices=Role.choices,
default=Role.MEMBER,
)
is_active = models.BooleanField(default=True)
class Meta:
db_table = "project_membership"
ordering = ("-created_at",)
indexes = [
models.Index(fields=["project"], name="project_membership_project_idx"),
models.Index(fields=["user"], name="project_membership_user_idx"),
]
constraints = [
models.UniqueConstraint(
fields=["project", "user"],
name="unique_project_membership",
condition=models.Q(is_deleted=False),
)
]
def __str__(self):
return f"{self.user} @ {self.project}"
def get_additional_data(self):
return build_workspace_log_metadata(
section=SECTION_PROJECT_MEMBERS,
workspace_id=self.project.workspace_id,
target_id=self.id,
target_label=self.user.full_name or self.user.mobile,
extra={
"project_id": str(self.project_id),
"member_user_id": str(self.user_id),
"role": self.role,
},
)
class ProjectRate(BaseModel): class ProjectRate(BaseModel):
project = models.ForeignKey( project = models.ForeignKey(
Project, Project,

View File

@@ -1,32 +0,0 @@
from rest_framework.exceptions import ValidationError
from apps.projects.models import ProjectMembership
def add_project_member(project, user_id, role):
"""
Adds a user to a project. Ensures no duplicate active memberships exist.
"""
if ProjectMembership.objects.filter(project=project, user_id=user_id, is_deleted=False).exists():
raise ValidationError({"user_id": "This user is already a member of the project."})
return ProjectMembership.objects.create(
project=project,
user_id=user_id,
role=role,
is_active=True
)
def update_project_member(membership, **kwargs):
"""
Updates a project membership (e.g., changing role or active status).
"""
update_fields = []
for field, value in kwargs.items():
if hasattr(membership, field) and getattr(membership, field) != value:
setattr(membership, field, value)
update_fields.append(field)
if update_fields:
update_fields.append("updated_at")
membership.save(update_fields=update_fields)
return membership

View File

@@ -1,18 +1,17 @@
from django.db import transaction from django.db import transaction
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from rest_framework.exceptions import ValidationError, PermissionDenied from rest_framework.exceptions import ValidationError, PermissionDenied
from apps.clients.models import Client from apps.clients.models import Client
from apps.projects.models import Project, ProjectMembership from apps.projects.models import Project
from apps.workspaces.models import WorkspaceMembership from apps.workspaces.models import WorkspaceMembership
@transaction.atomic @transaction.atomic
def create_project(user, workspace, name, client=None, description="", color=""): def create_project(user, workspace, name, client=None, description="", color=""):
""" """
Creates a new project and automatically assigns the creator Creates a new workspace-shared project.
as an active MANAGER of that project. """
"""
workspace_member = WorkspaceMembership.objects.filter( workspace_member = WorkspaceMembership.objects.filter(
workspace=workspace, workspace=workspace,
user=user, user=user,
@@ -36,14 +35,7 @@ def create_project(user, workspace, name, client=None, description="", color="")
updated_by=user, updated_by=user,
) )
ProjectMembership.objects.create( return project
project=project,
user=user,
role=ProjectMembership.Role.MANAGER,
is_active=True
)
return project
def update_project(project, **kwargs): def update_project(project, **kwargs):

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,75 @@
import pytest
from rest_framework.test import APIClient
from apps.clients.models import Client
from apps.projects.models import Project
from apps.users.models import User
from apps.workspaces.models import Workspace, WorkspaceMembership
@pytest.fixture()
def api_client():
return APIClient()
@pytest.fixture()
def owner(db):
return User.objects.create_user(mobile="09121110001", password="secret123", first_name="Owner")
@pytest.fixture()
def workspace(owner):
return Workspace.objects.create(name="Projects", owner=owner)
@pytest.fixture()
def member(db, workspace):
user = User.objects.create_user(mobile="09121110002", password="secret123", first_name="Member")
WorkspaceMembership.objects.create(
workspace=workspace,
user=user,
role=WorkspaceMembership.Role.MEMBER,
is_active=True,
)
return user
@pytest.fixture()
def clients(workspace):
first = Client.objects.create(workspace=workspace, name="Acme")
second = Client.objects.create(workspace=workspace, name="Globex")
third = Client.objects.create(workspace=workspace, name="Initech")
return first, second, third
@pytest.fixture()
def projects(workspace, clients):
first, second, third = clients
return [
Project.objects.create(workspace=workspace, client=first, name="Alpha"),
Project.objects.create(workspace=workspace, client=second, name="Beta"),
Project.objects.create(workspace=workspace, client=third, name="Gamma"),
]
def test_project_list_supports_multi_client_filter(api_client, member, workspace, clients, projects):
api_client.force_authenticate(user=member)
first, second, _ = clients
response = api_client.get(
"/api/projects/",
[
("workspace", str(workspace.id)),
("clients", str(first.id)),
("clients", str(second.id)),
],
)
assert response.status_code == 200
items = (
response.data
if isinstance(response.data, list)
else response.data.get("results") or response.data.get("items", [])
)
result_ids = {str(item["client"]["id"]) for item in items}
assert result_ids == {str(first.id), str(second.id)}

View File

@@ -81,6 +81,15 @@ def _serialize_money_totals(values: dict[str, Decimal]) -> list[dict]:
] ]
def _serialize_rate(amount: Decimal | None, currency: str | None) -> dict | None:
if amount is None:
return None
return {
"amount": f"{Decimal(amount).quantize(Decimal('0.01'))}",
"currency": currency or "USD",
}
def _add_income(bucket: defaultdict[str, Decimal], entry: TimeEntry): def _add_income(bucket: defaultdict[str, Decimal], entry: TimeEntry):
if not entry.is_billable or not entry.hourly_rate: if not entry.is_billable or not entry.hourly_rate:
return return
@@ -438,17 +447,18 @@ def _scope_payload(filters: ReportFilters) -> dict:
def _table_report_payload(filters: ReportFilters, entries: list[TimeEntry]) -> dict: def _table_report_payload(filters: ReportFilters, entries: list[TimeEntry]) -> dict:
summary = _summary_from_entries(entries) summary = _summary_from_entries(entries)
include_latest_rate = not (filters.is_workspace_scope and not filters.user_id)
return { return {
"scope": _scope_payload(filters), "scope": _scope_payload(filters),
"summary": summary, "summary": summary,
"days": _group_daily(entries), "days": _group_daily(entries, include_latest_rate=include_latest_rate),
"clients": _build_breakdown(entries, "clients"), "clients": _build_breakdown(entries, "clients"),
"projects": _build_breakdown(entries, "projects"), "projects": _build_breakdown(entries, "projects"),
"tags": _build_breakdown(entries, "tags"), "tags": _build_breakdown(entries, "tags"),
} }
def _group_daily(entries: list[TimeEntry]) -> list[dict]: def _group_daily(entries: list[TimeEntry], *, include_latest_rate: bool) -> list[dict]:
by_day: dict[str, dict] = {} by_day: dict[str, dict] = {}
for entry in entries: for entry in entries:
local_start = _localize_datetime(entry.start_time) local_start = _localize_datetime(entry.start_time)
@@ -461,6 +471,9 @@ def _group_daily(entries: list[TimeEntry]) -> list[dict]:
"non_billable_seconds": 0, "non_billable_seconds": 0,
"total_seconds": 0, "total_seconds": 0,
"income": _money_map(), "income": _money_map(),
"latest_rate_amount": None,
"latest_rate_currency": None,
"latest_rate_timestamp": None,
}, },
) )
duration_seconds = get_entry_duration_seconds(entry) duration_seconds = get_entry_duration_seconds(entry)
@@ -470,6 +483,18 @@ def _group_daily(entries: list[TimeEntry]) -> list[dict]:
else: else:
day_bucket["non_billable_seconds"] += duration_seconds day_bucket["non_billable_seconds"] += duration_seconds
_add_income(day_bucket["income"], entry) _add_income(day_bucket["income"], entry)
if (
include_latest_rate
and entry.is_billable
and entry.hourly_rate
and (
day_bucket["latest_rate_timestamp"] is None
or local_start >= day_bucket["latest_rate_timestamp"]
)
):
day_bucket["latest_rate_amount"] = Decimal(entry.hourly_rate)
day_bucket["latest_rate_currency"] = entry.currency or "USD"
day_bucket["latest_rate_timestamp"] = local_start
rows = [] rows = []
for day_key in sorted(by_day.keys()): for day_key in sorted(by_day.keys()):
@@ -483,6 +508,10 @@ def _group_daily(entries: list[TimeEntry]) -> list[dict]:
"billable_duration": _format_duration_seconds(bucket["billable_seconds"]), "billable_duration": _format_duration_seconds(bucket["billable_seconds"]),
"non_billable_duration": _format_duration_seconds(bucket["non_billable_seconds"]), "non_billable_duration": _format_duration_seconds(bucket["non_billable_seconds"]),
"total_duration": _format_duration_seconds(bucket["total_seconds"]), "total_duration": _format_duration_seconds(bucket["total_seconds"]),
"latest_hourly_rate": _serialize_rate(
bucket["latest_rate_amount"],
bucket["latest_rate_currency"],
) if include_latest_rate else None,
"income_totals": _serialize_money_totals(bucket["income"]), "income_totals": _serialize_money_totals(bucket["income"]),
} }
) )

View File

@@ -29,12 +29,14 @@ TRANSLATIONS = {
"from_date": "From date", "from_date": "From date",
"to_date": "To date", "to_date": "To date",
"user": "User", "user": "User",
"mobile": "Mobile",
"all_users": "All users", "all_users": "All users",
"generated_at": "Generated at", "generated_at": "Generated at",
"summary": "Summary", "summary": "Summary",
"total_hours": "Total hours", "total_hours": "Total hours",
"billable_hours": "Billable hours", "billable_hours": "Billable hours",
"non_billable_hours": "Non-billable hours", "non_billable_hours": "Non-billable hours",
"hourly_rate": "Hourly rate",
"income": "Income", "income": "Income",
"daily_summary": "Daily Summary", "daily_summary": "Daily Summary",
"clients": "Clients", "clients": "Clients",
@@ -53,12 +55,14 @@ TRANSLATIONS = {
"from_date": "از تاریخ", "from_date": "از تاریخ",
"to_date": "تا تاریخ", "to_date": "تا تاریخ",
"user": "کاربر", "user": "کاربر",
"mobile": "موبایل",
"all_users": "همه کاربران", "all_users": "همه کاربران",
"generated_at": "تاریخ تولید", "generated_at": "تاریخ تولید",
"summary": "خلاصه", "summary": "خلاصه",
"total_hours": "کل ساعات", "total_hours": "کل ساعات",
"billable_hours": "ساعات کاری", "billable_hours": "ساعات کاری",
"non_billable_hours": "ساعات غیر کاری", "non_billable_hours": "ساعات غیر کاری",
"hourly_rate": "نرخ ساعتی",
"income": "درآمد", "income": "درآمد",
"daily_summary": "خلاصه روزانه", "daily_summary": "خلاصه روزانه",
"clients": "مشتریان", "clients": "مشتریان",

View File

@@ -63,6 +63,18 @@ def _money_label_excel(locale: ExportLocale, income_totals: list[dict]) -> str:
return locale.format_money_label(income_totals, ascii_digits=True) return locale.format_money_label(income_totals, ascii_digits=True)
def _rate_label(locale: ExportLocale, rate: dict | None) -> str:
if not rate:
return "-"
return f"{locale.format_amount(rate['amount'])} {locale.currency_label(rate['currency'])}"
def _rate_label_excel(locale: ExportLocale, rate: dict | None) -> str:
if not rate:
return "-"
return f"{locale.format_amount(rate['amount'], ascii_digits=True)} {locale.currency_label(rate['currency'])}"
def _section_headers(locale: ExportLocale) -> list[str]: def _section_headers(locale: ExportLocale) -> list[str]:
headers = [ headers = [
locale.t("name"), locale.t("name"),
@@ -87,7 +99,24 @@ def _append_meta_block(worksheet, *, locale: ExportLocale, report_data: dict) ->
worksheet.append(_rtl_row(locale, [locale.t("period"), locale.period_label(scope["period"])])) worksheet.append(_rtl_row(locale, [locale.t("period"), locale.period_label(scope["period"])]))
worksheet.append(_rtl_row(locale, [locale.t("from_date"), locale.format_date(scope["from_date"], ascii_digits=True)])) worksheet.append(_rtl_row(locale, [locale.t("from_date"), locale.format_date(scope["from_date"], ascii_digits=True)]))
worksheet.append(_rtl_row(locale, [locale.t("to_date"), locale.format_date(scope["to_date"], ascii_digits=True)])) worksheet.append(_rtl_row(locale, [locale.t("to_date"), locale.format_date(scope["to_date"], ascii_digits=True)]))
worksheet.append(_rtl_row(locale, [locale.t("user"), user_label(scope.get("user"), locale, ascii_digits=True)])) worksheet.append(
_rtl_row(
locale,
[
locale.t("user"),
scope["user"]["name"] if scope.get("user") else locale.t("all_users"),
],
)
)
worksheet.append(
_rtl_row(
locale,
[
locale.t("mobile"),
locale.format_number(scope["user"]["mobile"], ascii_digits=True) if scope.get("user") and scope["user"].get("mobile") else "-",
],
)
)
worksheet.append(_rtl_row(locale, [locale.t("generated_at"), locale.format_date(datetime.now().date(), ascii_digits=True)])) worksheet.append(_rtl_row(locale, [locale.t("generated_at"), locale.format_date(datetime.now().date(), ascii_digits=True)]))
worksheet.append([]) worksheet.append([])
worksheet.append([locale.t("summary")]) worksheet.append([locale.t("summary")])
@@ -99,12 +128,12 @@ def _append_meta_block(worksheet, *, locale: ExportLocale, report_data: dict) ->
for row_index in range(1, worksheet.max_row + 1): for row_index in range(1, worksheet.max_row + 1):
first_cell = worksheet.cell(row=row_index, column=1) first_cell = worksheet.cell(row=row_index, column=1)
second_cell = worksheet.cell(row=row_index, column=2) second_cell = worksheet.cell(row=row_index, column=2)
if row_index in {1, 9}: if row_index in {1, 10}:
_apply_cell_style(first_cell, bold=True, fill=HEADER_FILL, rtl=locale.is_rtl) _apply_cell_style(first_cell, bold=True, fill=HEADER_FILL, rtl=locale.is_rtl)
if second_cell.value: if second_cell.value:
_apply_cell_style(second_cell, bold=row_index == 1, fill=HEADER_FILL if row_index == 1 else None, rtl=locale.is_rtl) _apply_cell_style(second_cell, bold=row_index == 1, fill=HEADER_FILL if row_index == 1 else None, rtl=locale.is_rtl)
elif first_cell.value: elif first_cell.value:
_apply_cell_style(first_cell, bold=False, fill=SECTION_FILL if row_index == 8 else None, rtl=locale.is_rtl) _apply_cell_style(first_cell, bold=False, fill=None, rtl=locale.is_rtl)
if second_cell.value: if second_cell.value:
_apply_cell_style(second_cell, rtl=locale.is_rtl) _apply_cell_style(second_cell, rtl=locale.is_rtl)
@@ -121,6 +150,7 @@ def _append_daily_table(worksheet, *, locale: ExportLocale, report_data: dict) -
locale.t("billable_hours"), locale.t("billable_hours"),
locale.t("non_billable_hours"), locale.t("non_billable_hours"),
locale.t("total_hours"), locale.t("total_hours"),
locale.t("hourly_rate"),
locale.t("income"), locale.t("income"),
], ],
) )
@@ -142,6 +172,7 @@ def _append_daily_table(worksheet, *, locale: ExportLocale, report_data: dict) -
locale.format_duration(row["billable_duration"], ascii_digits=True), locale.format_duration(row["billable_duration"], ascii_digits=True),
locale.format_duration(row["non_billable_duration"], ascii_digits=True), locale.format_duration(row["non_billable_duration"], ascii_digits=True),
locale.format_duration(row["total_duration"], ascii_digits=True), locale.format_duration(row["total_duration"], ascii_digits=True),
_rate_label_excel(locale, row.get("latest_hourly_rate")),
_money_label_excel(locale, row["income_totals"]), _money_label_excel(locale, row["income_totals"]),
], ],
) )
@@ -157,6 +188,7 @@ def _append_daily_table(worksheet, *, locale: ExportLocale, report_data: dict) -
locale.format_duration(report_data["summary"]["billable_duration"], ascii_digits=True), locale.format_duration(report_data["summary"]["billable_duration"], ascii_digits=True),
locale.format_duration(report_data["summary"]["non_billable_duration"], ascii_digits=True), locale.format_duration(report_data["summary"]["non_billable_duration"], ascii_digits=True),
locale.format_duration(report_data["summary"]["total_duration"], ascii_digits=True), locale.format_duration(report_data["summary"]["total_duration"], ascii_digits=True),
"-",
_money_label_excel(locale, report_data["summary"]["income_totals"]), _money_label_excel(locale, report_data["summary"]["income_totals"]),
], ],
) )
@@ -261,18 +293,20 @@ def _styled_table(data: list[list[str]], *, locale: ExportLocale, column_widths:
return table return table
def _report_table_rows(locale: ExportLocale, rows: list[dict]) -> list[list[str]]: def _report_table_rows(locale: ExportLocale, rows: list[dict], *, is_daily: bool) -> list[list[str]]:
if not rows: if not rows:
return [_rtl_row(locale, [locale.t("no_data"), "", "", "", ""])] column_count = 6 if is_daily else 5
return [_rtl_row(locale, [locale.t("no_data"), *([""] * (column_count - 1))])]
return [ return [
_rtl_row( _rtl_row(
locale, locale,
[ [
locale.format_date(row.get("date")) if row.get("date") else row["name"], locale.format_date(row.get("date")) if row.get("date") else row["name"],
locale.format_duration(row["billable_duration"]), locale.format_duration(row["billable_duration"]),
locale.format_duration(row["non_billable_duration"]), locale.format_duration(row["non_billable_duration"]),
locale.format_duration(row["total_duration"]), locale.format_duration(row["total_duration"]),
_money_label(locale, row["income_totals"]), *([_rate_label(locale, row.get("latest_hourly_rate"))] if is_daily else []),
_money_label(locale, row["income_totals"]),
], ],
) )
for row in rows for row in rows
@@ -360,7 +394,8 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale) -> bytes:
[locale.t("period"), locale.period_label(scope["period"])], [locale.t("period"), locale.period_label(scope["period"])],
[locale.t("from_date"), locale.format_date(scope["from_date"])], [locale.t("from_date"), locale.format_date(scope["from_date"])],
[locale.t("to_date"), locale.format_date(scope["to_date"])], [locale.t("to_date"), locale.format_date(scope["to_date"])],
[locale.t("user"), user_label(scope.get("user"), locale)], [locale.t("user"), scope["user"]["name"] if scope.get("user") else locale.t("all_users")],
[locale.t("mobile"), locale.format_number(scope["user"]["mobile"]) if scope.get("user") and scope["user"].get("mobile") else "-"],
[locale.t("generated_at"), locale.format_date(datetime.now().date())], [locale.t("generated_at"), locale.format_date(datetime.now().date())],
] ]
if locale.is_rtl: if locale.is_rtl:
@@ -421,23 +456,35 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale) -> bytes:
header = _rtl_row( header = _rtl_row(
locale, locale,
[ [
locale.t("date") if is_daily else locale.t("name"), locale.t("date") if is_daily else locale.t("name"),
locale.t("billable_hours"), locale.t("billable_hours"),
locale.t("non_billable_hours"), locale.t("non_billable_hours"),
locale.t("total_hours"), locale.t("total_hours"),
locale.t("income"), *( [locale.t("hourly_rate")] if is_daily else [] ),
locale.t("income"),
], ],
) )
table = _styled_table( table = _styled_table(
[header, *_report_table_rows(locale, rows)], [header, *_report_table_rows(locale, rows, is_daily=is_daily)],
locale=locale, locale=locale,
column_widths=[ column_widths=(
doc.width * 0.26, [
doc.width * 0.15, doc.width * 0.21,
doc.width * 0.17, doc.width * 0.13,
doc.width * 0.14, doc.width * 0.15,
doc.width * 0.28, doc.width * 0.13,
], doc.width * 0.16,
doc.width * 0.22,
]
if is_daily
else [
doc.width * 0.26,
doc.width * 0.15,
doc.width * 0.17,
doc.width * 0.14,
doc.width * 0.28,
]
),
) )
story.extend([table, Spacer(1, 5 * mm)]) story.extend([table, Spacer(1, 5 * mm)])

View File

@@ -171,6 +171,53 @@ def test_generate_excel_export_adds_per_user_sheets_for_all_users_scope(
assert len(workbook.sheetnames) == 3 assert len(workbook.sheetnames) == 3
def test_generate_excel_export_includes_daily_rate_column_and_split_user_meta(
fake_redis,
workspace,
owner,
time_entry,
):
job = ReportExportJob.objects.create(
requesting_user=owner,
workspace=workspace,
export_type=ReportExportJob.ExportType.EXCEL,
filters={
"workspace": str(workspace.id),
"period": "this_month",
"from_date": "2026-04-01",
"to_date": "2026-04-30",
"user": str(owner.id),
"client": None,
"project": None,
"tags": [],
"language": "en",
},
)
generate_report_export_task(str(job.id))
job.refresh_from_db()
workbook = load_workbook(BytesIO(job.file.read()))
worksheet = workbook.active
values = list(worksheet.iter_rows(values_only=True))
assert any(row[:2] == ("User", "Owner User") for row in values if row)
assert any(row[:2] == ("Mobile", "09129990001") for row in values if row)
daily_header = next(row[:6] for row in values if row and row[0] == "Date")
assert daily_header == (
"Date",
"Billable hours",
"Non-billable hours",
"Total hours",
"Hourly rate",
"Income",
)
daily_row = next(row[:6] for row in values if row and row[0] == "2026/04/12")
assert daily_row[4] == "15 USD"
def test_generate_pdf_export_supports_persian_locale(fake_redis, workspace, owner, time_entry): def test_generate_pdf_export_supports_persian_locale(fake_redis, workspace, owner, time_entry):
job = ReportExportJob.objects.create( job = ReportExportJob.objects.create(
requesting_user=owner, requesting_user=owner,

View File

@@ -108,6 +108,48 @@ def test_admin_can_request_combined_table_report(api_client, admin, workspace, t
assert response.status_code == 200 assert response.status_code == 200
assert response.data["summary"]["total_duration"] == "03:00:00" assert response.data["summary"]["total_duration"] == "03:00:00"
assert len(response.data["days"]) == 2 assert len(response.data["days"]) == 2
assert response.data["days"][0]["latest_hourly_rate"] is None
assert response.data["days"][1]["latest_hourly_rate"] is None
def test_daily_rate_uses_latest_billable_entry_snapshot(api_client, owner, workspace, project):
api_client.force_authenticate(user=owner)
TimeEntry.objects.create(
workspace=workspace,
user=owner,
project=project,
description="Morning work",
start_time="2026-04-15T08:00:00+03:30",
end_time="2026-04-15T09:00:00+03:30",
duration=timedelta(hours=1),
is_billable=True,
hourly_rate=Decimal("20.00"),
currency="USD",
)
TimeEntry.objects.create(
workspace=workspace,
user=owner,
project=project,
description="Later work",
start_time="2026-04-15T13:00:00+03:30",
end_time="2026-04-15T15:00:00+03:30",
duration=timedelta(hours=2),
is_billable=True,
hourly_rate=Decimal("35.00"),
currency="USD",
)
response = api_client.get(
"/api/reports/table/",
{"workspace": str(workspace.id), "period": "this_month", "user": str(owner.id)},
)
assert response.status_code == 200
assert response.data["days"][0]["latest_hourly_rate"] == {
"amount": "35.00",
"currency": "USD",
}
def test_custom_period_longer_than_31_days_is_rejected(api_client, owner, workspace): def test_custom_period_longer_than_31_days_is_rejected(api_client, owner, workspace):

View File

@@ -132,15 +132,17 @@ class WorkspaceSerializer(BaseModelSerializer):
class WorkspaceMembershipSerializer(BaseModelSerializer): class WorkspaceMembershipSerializer(BaseModelSerializer):
user = serializers.SerializerMethodField() user = serializers.SerializerMethodField()
user_id = serializers.UUIDField(write_only=True, required=False)
class Meta: class Meta:
model = WorkspaceMembership model = WorkspaceMembership
fields = BaseModelSerializer.Meta.fields + ( fields = BaseModelSerializer.Meta.fields + (
"workspace", "workspace",
"user", "user",
"role", "user_id",
"is_active", "role",
) "is_active",
)
def get_user(self, instance): def get_user(self, instance):
request = self.context.get("request") request = self.context.get("request")

View File

@@ -164,7 +164,11 @@ class WorkspaceMembershipViewSet(ModelViewSet):
status=status.HTTP_403_FORBIDDEN, status=status.HTTP_403_FORBIDDEN,
) )
serializer = self.get_serializer(data=request.data) payload = request.data.copy()
if payload.get("user") and not payload.get("user_id"):
payload["user_id"] = payload.get("user")
serializer = self.get_serializer(data=payload)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
membership = serializer.save() membership = serializer.save()
notify_workspace_membership_added( notify_workspace_membership_added(

View File

@@ -8,10 +8,6 @@ from apps.workspaces.services.permissions import (
PROJECTS_DELETE, PROJECTS_DELETE,
PROJECTS_EDIT, PROJECTS_EDIT,
PROJECTS_VIEW, PROJECTS_VIEW,
PROJECT_MEMBERS_ADD,
PROJECT_MEMBERS_CHANGE_ROLE,
PROJECT_MEMBERS_REMOVE,
PROJECT_MEMBERS_VIEW,
TAGS_CREATE, TAGS_CREATE,
TAGS_DELETE, TAGS_DELETE,
TAGS_EDIT, TAGS_EDIT,
@@ -62,10 +58,6 @@ __all__ = [
"PROJECTS_EDIT", "PROJECTS_EDIT",
"PROJECTS_DELETE", "PROJECTS_DELETE",
"PROJECTS_ARCHIVE", "PROJECTS_ARCHIVE",
"PROJECT_MEMBERS_VIEW",
"PROJECT_MEMBERS_ADD",
"PROJECT_MEMBERS_REMOVE",
"PROJECT_MEMBERS_CHANGE_ROLE",
"TIME_ENTRIES_VIEW_OWN", "TIME_ENTRIES_VIEW_OWN",
"TIME_ENTRIES_MANAGE_OWN", "TIME_ENTRIES_MANAGE_OWN",
"get_workspace_membership", "get_workspace_membership",

View File

@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
from apps.projects.models import ProjectMembership
from apps.workspaces.models import Workspace, WorkspaceMembership from apps.workspaces.models import Workspace, WorkspaceMembership
@@ -25,22 +24,9 @@ PROJECTS_CREATE = "projects.create"
PROJECTS_EDIT = "projects.edit" PROJECTS_EDIT = "projects.edit"
PROJECTS_DELETE = "projects.delete" PROJECTS_DELETE = "projects.delete"
PROJECTS_ARCHIVE = "projects.archive" PROJECTS_ARCHIVE = "projects.archive"
PROJECT_MEMBERS_VIEW = "project_members.view"
PROJECT_MEMBERS_ADD = "project_members.add"
PROJECT_MEMBERS_REMOVE = "project_members.remove"
PROJECT_MEMBERS_CHANGE_ROLE = "project_members.change_role"
TIME_ENTRIES_VIEW_OWN = "time_entries.view_own" TIME_ENTRIES_VIEW_OWN = "time_entries.view_own"
TIME_ENTRIES_MANAGE_OWN = "time_entries.manage_own" TIME_ENTRIES_MANAGE_OWN = "time_entries.manage_own"
PROJECT_MANAGER_CAPABILITIES = {
PROJECTS_EDIT,
PROJECTS_ARCHIVE,
PROJECT_MEMBERS_VIEW,
PROJECT_MEMBERS_ADD,
PROJECT_MEMBERS_REMOVE,
PROJECT_MEMBERS_CHANGE_ROLE,
}
WORKSPACE_ROLE_CAPABILITIES = { WORKSPACE_ROLE_CAPABILITIES = {
WorkspaceMembership.Role.OWNER: { WorkspaceMembership.Role.OWNER: {
WORKSPACE_VIEW, WORKSPACE_VIEW,
@@ -64,10 +50,6 @@ WORKSPACE_ROLE_CAPABILITIES = {
PROJECTS_EDIT, PROJECTS_EDIT,
PROJECTS_DELETE, PROJECTS_DELETE,
PROJECTS_ARCHIVE, PROJECTS_ARCHIVE,
PROJECT_MEMBERS_VIEW,
PROJECT_MEMBERS_ADD,
PROJECT_MEMBERS_REMOVE,
PROJECT_MEMBERS_CHANGE_ROLE,
TIME_ENTRIES_VIEW_OWN, TIME_ENTRIES_VIEW_OWN,
TIME_ENTRIES_MANAGE_OWN, TIME_ENTRIES_MANAGE_OWN,
}, },
@@ -92,10 +74,6 @@ WORKSPACE_ROLE_CAPABILITIES = {
PROJECTS_EDIT, PROJECTS_EDIT,
PROJECTS_DELETE, PROJECTS_DELETE,
PROJECTS_ARCHIVE, PROJECTS_ARCHIVE,
PROJECT_MEMBERS_VIEW,
PROJECT_MEMBERS_ADD,
PROJECT_MEMBERS_REMOVE,
PROJECT_MEMBERS_CHANGE_ROLE,
TIME_ENTRIES_VIEW_OWN, TIME_ENTRIES_VIEW_OWN,
TIME_ENTRIES_MANAGE_OWN, TIME_ENTRIES_MANAGE_OWN,
}, },
@@ -149,24 +127,7 @@ def has_workspace_capability(user, workspace: Workspace, capability: str) -> boo
def has_project_capability(user, project, capability: str) -> bool: def has_project_capability(user, project, capability: str) -> bool:
if has_workspace_capability(user, project.workspace, capability): return has_workspace_capability(user, project.workspace, capability)
return True
workspace_role = get_workspace_role(user, project.workspace)
if workspace_role not in {
WorkspaceMembership.Role.OWNER,
WorkspaceMembership.Role.ADMIN,
}:
return False
is_project_manager = ProjectMembership.objects.filter(
project=project,
user=user,
role=ProjectMembership.Role.MANAGER,
is_active=True,
is_deleted=False,
).exists()
return is_project_manager and capability in PROJECT_MANAGER_CAPABILITIES
def can_delete_workspace_object(user, obj, capability: str) -> bool: def can_delete_workspace_object(user, obj, capability: str) -> bool:

View File

@@ -5,7 +5,7 @@ from django.utils import timezone
from rest_framework.test import APIClient from rest_framework.test import APIClient
from apps.clients.models import Client from apps.clients.models import Client
from apps.projects.models import Project, ProjectMembership from apps.projects.models import Project
from apps.tags.models import Tag from apps.tags.models import Tag
from apps.users.models import User from apps.users.models import User
from apps.workspaces.models import Workspace, WorkspaceMembership from apps.workspaces.models import Workspace, WorkspaceMembership
@@ -75,20 +75,7 @@ def workspace(owner, admin, member, guest):
@pytest.fixture() @pytest.fixture()
def project(workspace, owner, member): def project(workspace, owner, member):
project = Project.objects.create(workspace=workspace, name="Alpha", description="") return Project.objects.create(workspace=workspace, name="Alpha", description="")
ProjectMembership.objects.create(
project=project,
user=owner,
role=ProjectMembership.Role.MANAGER,
is_active=True,
)
ProjectMembership.objects.create(
project=project,
user=member,
role=ProjectMembership.Role.MANAGER,
is_active=True,
)
return project
def test_member_is_read_only_for_clients_and_projects(api_client, member, workspace, project): def test_member_is_read_only_for_clients_and_projects(api_client, member, workspace, project):
@@ -118,16 +105,6 @@ def test_member_is_read_only_for_clients_and_projects(api_client, member, worksp
) )
archive_project_response = api_client.post(f"/api/projects/{project.id}/archive/") archive_project_response = api_client.post(f"/api/projects/{project.id}/archive/")
delete_project_response = api_client.delete(f"/api/projects/{project.id}/") delete_project_response = api_client.delete(f"/api/projects/{project.id}/")
membership_response = api_client.post(
"/api/memberships/",
{
"project_id": str(project.id),
"user_id": str(workspace.owner_id),
"role": ProjectMembership.Role.MEMBER,
},
format="json",
)
assert client_response.status_code == 403 assert client_response.status_code == 403
assert update_client_response.status_code == 403 assert update_client_response.status_code == 403
assert delete_client_response.status_code == 403 assert delete_client_response.status_code == 403
@@ -135,7 +112,6 @@ def test_member_is_read_only_for_clients_and_projects(api_client, member, worksp
assert update_project_response.status_code == 403 assert update_project_response.status_code == 403
assert archive_project_response.status_code == 403 assert archive_project_response.status_code == 403
assert delete_project_response.status_code == 403 assert delete_project_response.status_code == 403
assert membership_response.status_code == 403
def test_member_can_create_tags_and_manage_own_time_entries(api_client, owner, member, workspace): def test_member_can_create_tags_and_manage_own_time_entries(api_client, owner, member, workspace):
@@ -218,7 +194,7 @@ def test_guest_is_read_only_for_workspace_resources(api_client, owner, guest, wo
assert edit_project_response.status_code == 403 assert edit_project_response.status_code == 403
def test_member_project_manager_cannot_edit_project(api_client, member, project): def test_member_cannot_edit_project(api_client, member, project):
api_client.force_authenticate(user=member) api_client.force_authenticate(user=member)
response = api_client.patch( response = api_client.patch(

View File

@@ -3,7 +3,7 @@ from decimal import Decimal
import pytest import pytest
from rest_framework.test import APIClient from rest_framework.test import APIClient
from apps.projects.models import Project, ProjectMembership from apps.projects.models import Project
from apps.time_entries.services.rates import resolve_rate from apps.time_entries.services.rates import resolve_rate
from apps.users.models import User from apps.users.models import User
from apps.workspaces.models import PriceUnit, Workspace, WorkspaceMembership, WorkspaceUserRate from apps.workspaces.models import PriceUnit, Workspace, WorkspaceMembership, WorkspaceUserRate
@@ -39,11 +39,7 @@ def workspace(owner, admin, member):
@pytest.fixture() @pytest.fixture()
def project(workspace, owner, admin, member): def project(workspace, owner, admin, member):
project = Project.objects.create(workspace=workspace, name="Billing") return Project.objects.create(workspace=workspace, name="Billing")
ProjectMembership.objects.create(project=project, user=owner, role=ProjectMembership.Role.MANAGER, is_active=True)
ProjectMembership.objects.create(project=project, user=admin, role=ProjectMembership.Role.MANAGER, is_active=True)
ProjectMembership.objects.create(project=project, user=member, role=ProjectMembership.Role.MEMBER, is_active=True)
return project
@pytest.fixture() @pytest.fixture()

View File

@@ -40,12 +40,6 @@ AUDITLOG_INCLUDE_TRACKING_MODELS = [
"serialize_data": True, "serialize_data": True,
"serialize_auditlog_fields_only": True, "serialize_auditlog_fields_only": True,
}, },
{
"model": "projects.ProjectMembership",
"exclude_fields": COMMON_EXCLUDED_FIELDS,
"serialize_data": True,
"serialize_auditlog_fields_only": True,
},
{ {
"model": "tags.Tag", "model": "tags.Tag",
"exclude_fields": COMMON_EXCLUDED_FIELDS, "exclude_fields": COMMON_EXCLUDED_FIELDS,