Compare commits
4 Commits
d1c4889d22
...
bb06762377
| Author | SHA1 | Date | |
|---|---|---|---|
| bb06762377 | |||
| d4a52d6f3b | |||
| 77c07adec8 | |||
| f9c4c06531 |
82
.gitea/workflows/backend.yml
Normal file
82
.gitea/workflows/backend.yml
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
name: Backend CI/CD
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: qlockify-python
|
||||||
|
steps:
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends git
|
||||||
|
|
||||||
|
- name: Checkout repository
|
||||||
|
env:
|
||||||
|
REPO_URL: ${{ gitea.server_url }}/${{ gitea.repository }}.git
|
||||||
|
REPO_SHA: ${{ gitea.sha }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
WORKSPACE: ${{ gitea.workspace }}
|
||||||
|
run: |
|
||||||
|
mkdir -p "$WORKSPACE"
|
||||||
|
cd "$WORKSPACE"
|
||||||
|
git init
|
||||||
|
git remote add origin "$REPO_URL"
|
||||||
|
git -c http.extraHeader="Authorization: Bearer $GITEA_TOKEN" fetch --depth 1 origin "$REPO_SHA"
|
||||||
|
git checkout --detach FETCH_HEAD
|
||||||
|
|
||||||
|
- name: Install Python dependencies
|
||||||
|
working-directory: ${{ gitea.workspace }}
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python -m pip install -r requirements/base.txt -r requirements/dev.txt
|
||||||
|
|
||||||
|
- name: Lint backend
|
||||||
|
working-directory: ${{ gitea.workspace }}
|
||||||
|
run: python -m ruff check .
|
||||||
|
|
||||||
|
- name: Run backend tests
|
||||||
|
working-directory: ${{ gitea.workspace }}
|
||||||
|
run: python manage.py test --settings=config.settings.test
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
if: github.event_name == 'push' && github.ref_name == 'main'
|
||||||
|
needs:
|
||||||
|
- test
|
||||||
|
runs-on: qlockify-deploy
|
||||||
|
steps:
|
||||||
|
- name: Install SSH client
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends bash openssh-client
|
||||||
|
|
||||||
|
- name: Configure SSH
|
||||||
|
env:
|
||||||
|
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
|
||||||
|
run: |
|
||||||
|
install -m 700 -d ~/.ssh
|
||||||
|
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
printf '%s\n' "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
|
||||||
|
chmod 644 ~/.ssh/known_hosts
|
||||||
|
|
||||||
|
- name: Deploy backend services
|
||||||
|
env:
|
||||||
|
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
|
||||||
|
DEPLOY_PORT: ${{ vars.DEPLOY_PORT }}
|
||||||
|
DEPLOY_USER: ${{ vars.DEPLOY_USER }}
|
||||||
|
DEPLOY_PATH: ${{ vars.DEPLOY_PATH }}
|
||||||
|
DEPLOY_BRANCH: ${{ vars.DEPLOY_BRANCH }}
|
||||||
|
BACKEND_BRANCH: ${{ vars.BACKEND_BRANCH }}
|
||||||
|
FRONTEND_BRANCH: ${{ vars.FRONTEND_BRANCH }}
|
||||||
|
run: |
|
||||||
|
ssh -p "${DEPLOY_PORT:-22}" "${DEPLOY_USER}@${DEPLOY_HOST}" \
|
||||||
|
"DEPLOY_ROOT='${DEPLOY_PATH}' DEPLOY_BRANCH='${DEPLOY_BRANCH}' BACKEND_BRANCH='${BACKEND_BRANCH}' FRONTEND_BRANCH='${FRONTEND_BRANCH}' bash '${DEPLOY_PATH}/scripts/deploy.sh' backend"
|
||||||
@@ -40,3 +40,17 @@ class ProjectUpdateSerializer(serializers.Serializer):
|
|||||||
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)
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectAccessQuerySerializer(serializers.Serializer):
|
||||||
|
workspace = serializers.UUIDField()
|
||||||
|
user = serializers.UUIDField()
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectAccessMutationSerializer(serializers.Serializer):
|
||||||
|
workspace = serializers.UUIDField()
|
||||||
|
user = serializers.UUIDField()
|
||||||
|
project_ids = serializers.ListField(
|
||||||
|
child=serializers.UUIDField(),
|
||||||
|
allow_empty=False,
|
||||||
|
)
|
||||||
|
|||||||
@@ -15,8 +15,17 @@ from apps.clients.models import Client
|
|||||||
from apps.projects.models import Project
|
from apps.projects.models import Project
|
||||||
from apps.projects.api.serializers import (
|
from apps.projects.api.serializers import (
|
||||||
ProjectSerializer, ProjectCreateSerializer, ProjectUpdateSerializer,
|
ProjectSerializer, ProjectCreateSerializer, ProjectUpdateSerializer,
|
||||||
|
ProjectAccessMutationSerializer, ProjectAccessQuerySerializer,
|
||||||
)
|
)
|
||||||
from apps.projects.api.permissions import IsProjectMember, IsProjectManager
|
from apps.projects.api.permissions import IsProjectMember, IsProjectManager
|
||||||
|
from apps.projects.services.access import (
|
||||||
|
build_project_access_items,
|
||||||
|
ensure_workspace_project_access,
|
||||||
|
filter_projects_for_user,
|
||||||
|
get_access_managed_membership,
|
||||||
|
grant_project_accesses,
|
||||||
|
revoke_project_accesses,
|
||||||
|
)
|
||||||
from apps.projects.services.projects import (
|
from apps.projects.services.projects import (
|
||||||
create_project,
|
create_project,
|
||||||
update_project,
|
update_project,
|
||||||
@@ -67,11 +76,10 @@ class ProjectViewSet(ModelViewSet):
|
|||||||
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()
|
||||||
|
|
||||||
queryset = Project.objects.filter(
|
queryset = filter_projects_for_user(
|
||||||
workspace__memberships__user=self.request.user,
|
self.request.user,
|
||||||
workspace__memberships__is_active=True,
|
Project.objects.filter(is_deleted=False),
|
||||||
is_deleted=False
|
)
|
||||||
).distinct()
|
|
||||||
|
|
||||||
client_ids = [client_id for client_id in self.request.query_params.getlist("clients") if client_id]
|
client_ids = [client_id for client_id in self.request.query_params.getlist("clients") if client_id]
|
||||||
if client_ids:
|
if client_ids:
|
||||||
@@ -150,12 +158,76 @@ class ProjectViewSet(ModelViewSet):
|
|||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
@action(detail=True, methods=["post"])
|
@action(detail=True, methods=["post"])
|
||||||
def archive(self, request, pk=None):
|
def archive(self, request, pk=None):
|
||||||
"""
|
"""
|
||||||
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)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["get"], url_path="access")
|
||||||
|
def access(self, request):
|
||||||
|
serializer = ProjectAccessQuerySerializer(data=request.query_params)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
workspace = get_object_or_404(
|
||||||
|
Workspace,
|
||||||
|
id=serializer.validated_data["workspace"],
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
ensure_workspace_project_access(request.user, workspace)
|
||||||
|
membership = get_access_managed_membership(workspace, str(serializer.validated_data["user"]))
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"workspace": {"id": str(workspace.id), "name": workspace.name},
|
||||||
|
"user": {
|
||||||
|
"id": str(membership.user_id),
|
||||||
|
"name": membership.user.full_name or membership.user.mobile,
|
||||||
|
"mobile": membership.user.mobile,
|
||||||
|
"role": membership.role,
|
||||||
|
},
|
||||||
|
"items": build_project_access_items(workspace=workspace, target_user=membership.user),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["post"], url_path="access/grant")
|
||||||
|
def grant_access(self, request):
|
||||||
|
serializer = ProjectAccessMutationSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
workspace = get_object_or_404(
|
||||||
|
Workspace,
|
||||||
|
id=serializer.validated_data["workspace"],
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
membership = get_access_managed_membership(workspace, str(serializer.validated_data["user"]))
|
||||||
|
changed = grant_project_accesses(
|
||||||
|
actor=request.user,
|
||||||
|
workspace=workspace,
|
||||||
|
target_user=membership.user,
|
||||||
|
project_ids=[str(project_id) for project_id in serializer.validated_data["project_ids"]],
|
||||||
|
)
|
||||||
|
return Response({"changed": changed}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["post"], url_path="access/revoke")
|
||||||
|
def revoke_access(self, request):
|
||||||
|
serializer = ProjectAccessMutationSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
workspace = get_object_or_404(
|
||||||
|
Workspace,
|
||||||
|
id=serializer.validated_data["workspace"],
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
membership = get_access_managed_membership(workspace, str(serializer.validated_data["user"]))
|
||||||
|
changed = revoke_project_accesses(
|
||||||
|
actor=request.user,
|
||||||
|
workspace=workspace,
|
||||||
|
target_user=membership.user,
|
||||||
|
project_ids=[str(project_id) for project_id in serializer.validated_data["project_ids"]],
|
||||||
|
)
|
||||||
|
return Response({"changed": changed}, status=status.HTTP_200_OK)
|
||||||
|
|||||||
38
apps/projects/migrations/0004_projectaccess.py
Normal file
38
apps/projects/migrations/0004_projectaccess.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Generated by Django 5.2.12 on 2026-05-13 12:30
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('projects', '0003_project_project_ws_arch_upd_idx'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ProjectAccess',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid7, primary_key=True, serialize=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('is_deleted', models.BooleanField(default=False)),
|
||||||
|
('is_active', models.BooleanField(default=False)),
|
||||||
|
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_%(app_label)s_%(class)s_set', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='access_memberships', to='projects.project')),
|
||||||
|
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_%(app_label)s_%(class)s_set', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_accesses', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'db_table': 'project_access',
|
||||||
|
'ordering': ('-created_at',),
|
||||||
|
'indexes': [models.Index(fields=['project'], name='project_access_project_idx'), models.Index(fields=['user'], name='project_access_user_idx')],
|
||||||
|
'constraints': [models.UniqueConstraint(condition=models.Q(('is_deleted', False)), fields=('project', 'user'), name='unique_project_access')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -130,3 +130,31 @@ class ProjectUserRate(BaseModel):
|
|||||||
models.Index(fields=["project"], name="pur_project_idx"),
|
models.Index(fields=["project"], name="pur_project_idx"),
|
||||||
models.Index(fields=["user"], name="pur_user_idx"),
|
models.Index(fields=["user"], name="pur_user_idx"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectAccess(BaseModel):
|
||||||
|
project = models.ForeignKey(
|
||||||
|
Project,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="access_memberships",
|
||||||
|
)
|
||||||
|
user = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="project_accesses",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "project_access"
|
||||||
|
ordering = ("-created_at",)
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["project", "user"],
|
||||||
|
name="unique_project_access",
|
||||||
|
condition=models.Q(is_deleted=False),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["project"], name="project_access_project_idx"),
|
||||||
|
models.Index(fields=["user"], name="project_access_user_idx"),
|
||||||
|
]
|
||||||
|
|||||||
144
apps/projects/services/access.py
Normal file
144
apps/projects/services/access.py
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db.models import Q, QuerySet
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||||
|
|
||||||
|
from apps.projects.models import Project, ProjectAccess
|
||||||
|
from apps.workspaces.models import Workspace, WorkspaceMembership
|
||||||
|
from apps.workspaces.services import PROJECTS_EDIT, get_workspace_role, has_workspace_capability
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
PROJECT_ACCESS_MANAGED_ROLES = {
|
||||||
|
WorkspaceMembership.Role.MEMBER,
|
||||||
|
WorkspaceMembership.Role.GUEST,
|
||||||
|
}
|
||||||
|
PROJECT_ACCESS_IMPLICIT_ROLES = {
|
||||||
|
WorkspaceMembership.Role.OWNER,
|
||||||
|
WorkspaceMembership.Role.ADMIN,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def user_has_implicit_project_access(user, workspace: Workspace) -> bool:
|
||||||
|
return get_workspace_role(user, workspace) in PROJECT_ACCESS_IMPLICIT_ROLES
|
||||||
|
|
||||||
|
|
||||||
|
def user_has_project_access(user, project: Project) -> bool:
|
||||||
|
if not user or not getattr(user, "is_authenticated", False):
|
||||||
|
return False
|
||||||
|
if user_has_implicit_project_access(user, project.workspace):
|
||||||
|
return True
|
||||||
|
return ProjectAccess.objects.filter(project=project, user=user).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def filter_projects_for_user(user, queryset: QuerySet[Project] | None = None) -> QuerySet[Project]:
|
||||||
|
if queryset is None:
|
||||||
|
queryset = Project.objects.all()
|
||||||
|
|
||||||
|
if not user or not getattr(user, "is_authenticated", False):
|
||||||
|
return queryset.none()
|
||||||
|
|
||||||
|
return queryset.filter(
|
||||||
|
Q(workspace__owner=user)
|
||||||
|
| Q(
|
||||||
|
workspace__memberships__user=user,
|
||||||
|
workspace__memberships__is_active=True,
|
||||||
|
workspace__memberships__role__in=PROJECT_ACCESS_IMPLICIT_ROLES,
|
||||||
|
)
|
||||||
|
| Q(
|
||||||
|
workspace__memberships__user=user,
|
||||||
|
workspace__memberships__is_active=True,
|
||||||
|
workspace__memberships__role__in=PROJECT_ACCESS_MANAGED_ROLES,
|
||||||
|
access_memberships__user=user,
|
||||||
|
)
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_project_access(user, project: Project, *, message: str = "Selected project is unavailable.") -> None:
|
||||||
|
if not user_has_project_access(user, project):
|
||||||
|
raise ValidationError({"project_id": message})
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_workspace_project_access(user, workspace: Workspace) -> None:
|
||||||
|
if not has_workspace_capability(user, workspace, PROJECTS_EDIT):
|
||||||
|
raise PermissionDenied("You do not have permission to manage project access in this workspace.")
|
||||||
|
|
||||||
|
|
||||||
|
def get_access_managed_membership(workspace: Workspace, user_id: str) -> WorkspaceMembership:
|
||||||
|
membership = WorkspaceMembership.objects.filter(
|
||||||
|
workspace=workspace,
|
||||||
|
user_id=user_id,
|
||||||
|
is_active=True,
|
||||||
|
is_deleted=False,
|
||||||
|
).select_related("user").first()
|
||||||
|
if not membership:
|
||||||
|
raise ValidationError({"user": "Selected user is not an active member of this workspace."})
|
||||||
|
if membership.role not in PROJECT_ACCESS_MANAGED_ROLES:
|
||||||
|
raise ValidationError({"user": "Owners and admins have implicit access to all projects."})
|
||||||
|
return membership
|
||||||
|
|
||||||
|
|
||||||
|
def build_project_access_items(*, workspace: Workspace, target_user) -> list[dict]:
|
||||||
|
explicit_access_ids = set(
|
||||||
|
ProjectAccess.objects.filter(project__workspace=workspace, user=target_user).values_list("project_id", flat=True)
|
||||||
|
)
|
||||||
|
projects = (
|
||||||
|
Project.objects.filter(workspace=workspace, is_deleted=False)
|
||||||
|
.select_related("client")
|
||||||
|
.order_by("client__name", "name")
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": str(project.id),
|
||||||
|
"name": project.name,
|
||||||
|
"description": project.description,
|
||||||
|
"color": project.color,
|
||||||
|
"is_archived": project.is_archived,
|
||||||
|
"client": (
|
||||||
|
{"id": str(project.client_id), "name": project.client.name}
|
||||||
|
if project.client_id and project.client
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"has_access": str(project.id) in {str(project_id) for project_id in explicit_access_ids},
|
||||||
|
}
|
||||||
|
for project in projects
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def grant_project_accesses(*, actor, workspace: Workspace, target_user, project_ids: list[str]) -> int:
|
||||||
|
ensure_workspace_project_access(actor, workspace)
|
||||||
|
get_access_managed_membership(workspace, str(target_user.id))
|
||||||
|
|
||||||
|
projects = list(Project.objects.filter(workspace=workspace, id__in=project_ids, is_deleted=False))
|
||||||
|
if len(projects) != len(set(project_ids)):
|
||||||
|
raise ValidationError({"project_ids": "One or more selected projects do not belong to this workspace."})
|
||||||
|
|
||||||
|
changed = 0
|
||||||
|
for project in projects:
|
||||||
|
access, created, restored = ProjectAccess.get_or_restore(project=project, user=target_user)
|
||||||
|
if created or restored:
|
||||||
|
access.is_active = True
|
||||||
|
access.updated_at = timezone.now()
|
||||||
|
access.save(update_fields=["is_active", "updated_at"])
|
||||||
|
changed += 1
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_project_accesses(*, actor, workspace: Workspace, target_user, project_ids: list[str]) -> int:
|
||||||
|
ensure_workspace_project_access(actor, workspace)
|
||||||
|
get_access_managed_membership(workspace, str(target_user.id))
|
||||||
|
|
||||||
|
accesses = list(
|
||||||
|
ProjectAccess.objects.filter(
|
||||||
|
project__workspace=workspace,
|
||||||
|
user=target_user,
|
||||||
|
project_id__in=project_ids,
|
||||||
|
).select_related("project")
|
||||||
|
)
|
||||||
|
changed = 0
|
||||||
|
for access in accesses:
|
||||||
|
access.delete()
|
||||||
|
changed += 1
|
||||||
|
return changed
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from apps.clients.models import Client
|
from apps.clients.models import Client
|
||||||
from apps.projects.models import Project
|
from apps.projects.models import Project, ProjectAccess
|
||||||
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
|
||||||
|
|
||||||
@@ -34,16 +34,19 @@ class ProjectViewTests(APITestCase):
|
|||||||
client=cls.first_client,
|
client=cls.first_client,
|
||||||
name="Alpha",
|
name="Alpha",
|
||||||
)
|
)
|
||||||
Project.objects.create(
|
cls.second_project = Project.objects.create(
|
||||||
workspace=cls.workspace,
|
workspace=cls.workspace,
|
||||||
client=cls.second_client,
|
client=cls.second_client,
|
||||||
name="Beta",
|
name="Beta",
|
||||||
)
|
)
|
||||||
Project.objects.create(
|
cls.third_project = Project.objects.create(
|
||||||
workspace=cls.workspace,
|
workspace=cls.workspace,
|
||||||
client=cls.third_client,
|
client=cls.third_client,
|
||||||
name="Gamma",
|
name="Gamma",
|
||||||
)
|
)
|
||||||
|
cls.first_project = Project.objects.get(name="Alpha")
|
||||||
|
ProjectAccess.objects.create(project=cls.first_project, user=cls.member)
|
||||||
|
ProjectAccess.objects.create(project=cls.second_project, user=cls.member)
|
||||||
|
|
||||||
def test_project_list_supports_multi_client_filter(self):
|
def test_project_list_supports_multi_client_filter(self):
|
||||||
self.client.force_authenticate(user=self.member)
|
self.client.force_authenticate(user=self.member)
|
||||||
@@ -68,3 +71,46 @@ class ProjectViewTests(APITestCase):
|
|||||||
result_ids,
|
result_ids,
|
||||||
{str(self.first_client.id), str(self.second_client.id)},
|
{str(self.first_client.id), str(self.second_client.id)},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_project_access_list_and_mutations_require_explicit_member_access(self):
|
||||||
|
self.client.force_authenticate(user=self.owner)
|
||||||
|
|
||||||
|
access_response = self.client.get(
|
||||||
|
"/api/projects/access/",
|
||||||
|
{"workspace": str(self.workspace.id), "user": str(self.member.id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(access_response.status_code, 200)
|
||||||
|
items = access_response.data["items"]
|
||||||
|
gamma_item = next(item for item in items if item["id"] == str(self.third_project.id))
|
||||||
|
self.assertFalse(gamma_item["has_access"])
|
||||||
|
|
||||||
|
grant_response = self.client.post(
|
||||||
|
"/api/projects/access/grant/",
|
||||||
|
{
|
||||||
|
"workspace": str(self.workspace.id),
|
||||||
|
"user": str(self.member.id),
|
||||||
|
"project_ids": [str(self.third_project.id)],
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(grant_response.status_code, 200)
|
||||||
|
|
||||||
|
access_response = self.client.get(
|
||||||
|
"/api/projects/access/",
|
||||||
|
{"workspace": str(self.workspace.id), "user": str(self.member.id)},
|
||||||
|
)
|
||||||
|
gamma_item = next(item for item in access_response.data["items"] if item["id"] == str(self.third_project.id))
|
||||||
|
self.assertTrue(gamma_item["has_access"])
|
||||||
|
|
||||||
|
revoke_response = self.client.post(
|
||||||
|
"/api/projects/access/revoke/",
|
||||||
|
{
|
||||||
|
"workspace": str(self.workspace.id),
|
||||||
|
"user": str(self.member.id),
|
||||||
|
"project_ids": [str(self.first_project.id)],
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(revoke_response.status_code, 200)
|
||||||
|
self.assertFalse(ProjectAccess.objects.filter(project=self.first_project, user=self.member).exists())
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
from datetime import date, datetime, time, timedelta
|
from datetime import date, datetime, time, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
|
||||||
import jdatetime
|
import jdatetime
|
||||||
@@ -15,6 +15,7 @@ from rest_framework import serializers
|
|||||||
|
|
||||||
from apps.clients.models import Client
|
from apps.clients.models import Client
|
||||||
from apps.projects.models import Project
|
from apps.projects.models import Project
|
||||||
|
from apps.projects.services.access import user_has_project_access
|
||||||
from apps.tags.models import Tag
|
from apps.tags.models import Tag
|
||||||
from apps.time_entries.models import TimeEntry
|
from apps.time_entries.models import TimeEntry
|
||||||
from apps.workspaces.models import Workspace
|
from apps.workspaces.models import Workspace
|
||||||
@@ -90,6 +91,205 @@ def _serialize_rate(amount: Decimal | None, currency: str | None) -> dict | None
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_distinct_rates(entries: list[TimeEntry]) -> list[dict]:
|
||||||
|
unique_rates: set[tuple[str, str]] = set()
|
||||||
|
for entry in entries:
|
||||||
|
if not entry.hourly_rate:
|
||||||
|
continue
|
||||||
|
unique_rates.add((f"{Decimal(entry.hourly_rate).quantize(Decimal('0.01'))}", entry.currency or "USD"))
|
||||||
|
return [
|
||||||
|
{"amount": amount, "currency": currency}
|
||||||
|
for amount, currency in sorted(unique_rates, key=lambda item: (item[1], Decimal(item[0])))
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_rate_periods(entries: list[TimeEntry]) -> list[dict]:
|
||||||
|
sorted_entries = sorted(entries, key=lambda entry: entry.start_time)
|
||||||
|
periods: list[dict] = []
|
||||||
|
current: dict | None = None
|
||||||
|
|
||||||
|
for entry in sorted_entries:
|
||||||
|
if not entry.hourly_rate:
|
||||||
|
continue
|
||||||
|
|
||||||
|
amount = f"{Decimal(entry.hourly_rate).quantize(Decimal('0.01'))}"
|
||||||
|
currency = entry.currency or "USD"
|
||||||
|
start_date = _localize_datetime(entry.start_time).date()
|
||||||
|
end_source = entry.end_time or entry.start_time
|
||||||
|
end_date = _localize_datetime(end_source).date()
|
||||||
|
|
||||||
|
if (
|
||||||
|
current
|
||||||
|
and current["amount"] == amount
|
||||||
|
and current["currency"] == currency
|
||||||
|
):
|
||||||
|
if end_date > current["to_date"]:
|
||||||
|
current["to_date"] = end_date
|
||||||
|
continue
|
||||||
|
|
||||||
|
if current:
|
||||||
|
periods.append(
|
||||||
|
{
|
||||||
|
"amount": current["amount"],
|
||||||
|
"currency": current["currency"],
|
||||||
|
"from_date": current["from_date"].isoformat(),
|
||||||
|
"to_date": current["to_date"].isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
current = {
|
||||||
|
"amount": amount,
|
||||||
|
"currency": currency,
|
||||||
|
"from_date": start_date,
|
||||||
|
"to_date": end_date,
|
||||||
|
}
|
||||||
|
|
||||||
|
if current:
|
||||||
|
periods.append(
|
||||||
|
{
|
||||||
|
"amount": current["amount"],
|
||||||
|
"currency": current["currency"],
|
||||||
|
"from_date": current["from_date"].isoformat(),
|
||||||
|
"to_date": current["to_date"].isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return periods
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_percentage_rows(shares: dict[str, dict], total_seconds: int) -> list[dict]:
|
||||||
|
if total_seconds <= 0:
|
||||||
|
return []
|
||||||
|
rows = []
|
||||||
|
for bucket in shares.values():
|
||||||
|
percentage = (
|
||||||
|
Decimal(bucket["seconds"]) * Decimal("100") / Decimal(total_seconds)
|
||||||
|
).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"id": bucket["id"],
|
||||||
|
"name": bucket["name"],
|
||||||
|
"percentage": f"{percentage}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
rows.sort(key=lambda item: (-Decimal(item["percentage"]), item["name"].lower()))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _build_user_summary(user, entries: list[TimeEntry]) -> dict:
|
||||||
|
summary = _summary_from_entries(entries)
|
||||||
|
project_shares: dict[str, dict] = {}
|
||||||
|
client_shares: dict[str, dict] = {}
|
||||||
|
tag_shares: dict[str, dict] = {}
|
||||||
|
|
||||||
|
total_seconds = summary["billable_seconds"]
|
||||||
|
for entry in entries:
|
||||||
|
if not entry.is_billable:
|
||||||
|
continue
|
||||||
|
duration_seconds = get_entry_duration_seconds(entry)
|
||||||
|
if duration_seconds <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if entry.project_id:
|
||||||
|
project_bucket = project_shares.setdefault(
|
||||||
|
str(entry.project_id),
|
||||||
|
{"id": str(entry.project_id), "name": entry.project.name, "seconds": 0},
|
||||||
|
)
|
||||||
|
project_bucket["seconds"] += duration_seconds
|
||||||
|
|
||||||
|
if entry.project and entry.project.client_id:
|
||||||
|
client_bucket = client_shares.setdefault(
|
||||||
|
str(entry.project.client_id),
|
||||||
|
{"id": str(entry.project.client_id), "name": entry.project.client.name, "seconds": 0},
|
||||||
|
)
|
||||||
|
client_bucket["seconds"] += duration_seconds
|
||||||
|
|
||||||
|
tags = list(entry.tags.all())
|
||||||
|
if tags:
|
||||||
|
allocated_seconds = Decimal(duration_seconds) / Decimal(len(tags))
|
||||||
|
for tag in tags:
|
||||||
|
tag_bucket = tag_shares.setdefault(
|
||||||
|
str(tag.id),
|
||||||
|
{"id": str(tag.id), "name": tag.name, "seconds": Decimal("0")},
|
||||||
|
)
|
||||||
|
tag_bucket["seconds"] += allocated_seconds
|
||||||
|
|
||||||
|
return {
|
||||||
|
"user": {
|
||||||
|
"id": str(user.id),
|
||||||
|
"name": _user_display(user),
|
||||||
|
"mobile": user.mobile,
|
||||||
|
},
|
||||||
|
"hourly_rates": _serialize_distinct_rates(entries),
|
||||||
|
"rate_periods": _serialize_rate_periods(entries),
|
||||||
|
"total_seconds": total_seconds,
|
||||||
|
"total_duration": summary["total_duration"],
|
||||||
|
"billable_seconds": summary["billable_seconds"],
|
||||||
|
"billable_duration": summary["billable_duration"],
|
||||||
|
"non_billable_seconds": summary["non_billable_seconds"],
|
||||||
|
"non_billable_duration": summary["non_billable_duration"],
|
||||||
|
"income_totals": summary["income_totals"],
|
||||||
|
"project_percentages": _serialize_percentage_rows(project_shares, total_seconds),
|
||||||
|
"client_percentages": _serialize_percentage_rows(client_shares, total_seconds),
|
||||||
|
"tag_percentages": _serialize_percentage_rows(tag_shares, total_seconds),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_user_summaries(entries: list[TimeEntry]) -> list[dict]:
|
||||||
|
grouped: dict[str, list[TimeEntry]] = defaultdict(list)
|
||||||
|
for entry in entries:
|
||||||
|
grouped[str(entry.user_id)].append(entry)
|
||||||
|
|
||||||
|
summaries = [_build_user_summary(grouped_entries[0].user, grouped_entries) for grouped_entries in grouped.values() if grouped_entries]
|
||||||
|
summaries.sort(key=lambda item: item["user"]["name"].lower())
|
||||||
|
return summaries
|
||||||
|
|
||||||
|
|
||||||
|
def _build_overall_percentage_payload(entries: list[TimeEntry]) -> dict:
|
||||||
|
project_shares: dict[str, dict] = {}
|
||||||
|
client_shares: dict[str, dict] = {}
|
||||||
|
tag_shares: dict[str, dict] = {}
|
||||||
|
total_seconds = 0
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
if not entry.is_billable:
|
||||||
|
continue
|
||||||
|
duration_seconds = get_entry_duration_seconds(entry)
|
||||||
|
if duration_seconds <= 0:
|
||||||
|
continue
|
||||||
|
total_seconds += duration_seconds
|
||||||
|
|
||||||
|
if entry.project_id:
|
||||||
|
project_bucket = project_shares.setdefault(
|
||||||
|
str(entry.project_id),
|
||||||
|
{"id": str(entry.project_id), "name": entry.project.name, "seconds": 0},
|
||||||
|
)
|
||||||
|
project_bucket["seconds"] += duration_seconds
|
||||||
|
|
||||||
|
if entry.project and entry.project.client_id:
|
||||||
|
client_bucket = client_shares.setdefault(
|
||||||
|
str(entry.project.client_id),
|
||||||
|
{"id": str(entry.project.client_id), "name": entry.project.client.name, "seconds": 0},
|
||||||
|
)
|
||||||
|
client_bucket["seconds"] += duration_seconds
|
||||||
|
|
||||||
|
tags = list(entry.tags.all())
|
||||||
|
if tags:
|
||||||
|
allocated_seconds = Decimal(duration_seconds) / Decimal(len(tags))
|
||||||
|
for tag in tags:
|
||||||
|
tag_bucket = tag_shares.setdefault(
|
||||||
|
str(tag.id),
|
||||||
|
{"id": str(tag.id), "name": tag.name, "seconds": Decimal("0")},
|
||||||
|
)
|
||||||
|
tag_bucket["seconds"] += allocated_seconds
|
||||||
|
|
||||||
|
return {
|
||||||
|
"project_percentages": _serialize_percentage_rows(project_shares, total_seconds),
|
||||||
|
"client_percentages": _serialize_percentage_rows(client_shares, total_seconds),
|
||||||
|
"tag_percentages": _serialize_percentage_rows(tag_shares, total_seconds),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
@@ -251,6 +451,10 @@ def load_report_filters(actor, raw_data) -> ReportFilters:
|
|||||||
raise serializers.ValidationError("Client does not belong to this workspace.")
|
raise serializers.ValidationError("Client does not belong to this workspace.")
|
||||||
if project_id and not Project.objects.filter(id=project_id, workspace=workspace).exists():
|
if project_id and not Project.objects.filter(id=project_id, workspace=workspace).exists():
|
||||||
raise serializers.ValidationError("Project does not belong to this workspace.")
|
raise serializers.ValidationError("Project does not belong to this workspace.")
|
||||||
|
if project_id and not is_workspace_scope:
|
||||||
|
project = Project.objects.filter(id=project_id, workspace=workspace).first()
|
||||||
|
if project and not user_has_project_access(actor, project):
|
||||||
|
raise serializers.ValidationError("Project does not belong to this workspace.")
|
||||||
if tag_ids:
|
if tag_ids:
|
||||||
existing_tag_ids = set(Tag.objects.filter(id__in=tag_ids, workspace=workspace).values_list("id", flat=True))
|
existing_tag_ids = set(Tag.objects.filter(id__in=tag_ids, workspace=workspace).values_list("id", flat=True))
|
||||||
if len(existing_tag_ids) != len(tag_ids):
|
if len(existing_tag_ids) != len(tag_ids):
|
||||||
@@ -387,31 +591,57 @@ def build_chart_report(actor, raw_filters) -> dict:
|
|||||||
filters = load_report_filters(actor, raw_filters)
|
filters = load_report_filters(actor, raw_filters)
|
||||||
entries = list(_base_queryset(filters))
|
entries = list(_base_queryset(filters))
|
||||||
summary = _summary_from_entries(entries)
|
summary = _summary_from_entries(entries)
|
||||||
buckets: dict[str, dict] = {}
|
grouped_entries: dict[str | None, list[TimeEntry]] = defaultdict(list)
|
||||||
|
if filters.is_workspace_scope and not filters.user_id:
|
||||||
|
for entry in entries:
|
||||||
|
grouped_entries[str(entry.user_id)].append(entry)
|
||||||
|
else:
|
||||||
|
grouped_entries[filters.user_id] = entries
|
||||||
|
|
||||||
for entry in entries:
|
serialized_series = []
|
||||||
local_start = _localize_datetime(entry.start_time)
|
for _, series_entries in sorted(
|
||||||
bucket_id, bucket_date = _bucket_key(filters, local_start)
|
grouped_entries.items(),
|
||||||
bucket = buckets.setdefault(
|
key=lambda item: _user_display(item[1][0].user).lower() if item[1] else "",
|
||||||
bucket_id,
|
):
|
||||||
|
if not series_entries:
|
||||||
|
continue
|
||||||
|
|
||||||
|
buckets: dict[str, dict] = {}
|
||||||
|
for entry in series_entries:
|
||||||
|
local_start = _localize_datetime(entry.start_time)
|
||||||
|
bucket_id, bucket_date = _bucket_key(filters, local_start)
|
||||||
|
bucket = buckets.setdefault(
|
||||||
|
bucket_id,
|
||||||
|
{
|
||||||
|
"bucket_key": bucket_id,
|
||||||
|
"bucket_label": _bucket_label(filters, bucket_date),
|
||||||
|
"total_seconds": 0,
|
||||||
|
"total_duration": "00:00:00",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
bucket["total_seconds"] += get_entry_duration_seconds(entry)
|
||||||
|
|
||||||
|
serialized_buckets = []
|
||||||
|
for bucket in sorted(buckets.values(), key=lambda item: item["bucket_key"]):
|
||||||
|
bucket["total_duration"] = _format_duration_seconds(bucket["total_seconds"])
|
||||||
|
serialized_buckets.append(bucket)
|
||||||
|
|
||||||
|
user = series_entries[0].user
|
||||||
|
serialized_series.append(
|
||||||
{
|
{
|
||||||
"bucket_key": bucket_id,
|
"user": {
|
||||||
"bucket_label": _bucket_label(filters, bucket_date),
|
"id": str(user.id),
|
||||||
"total_seconds": 0,
|
"name": _user_display(user),
|
||||||
"total_duration": "00:00:00",
|
"mobile": user.mobile,
|
||||||
},
|
},
|
||||||
|
"buckets": serialized_buckets,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
bucket["total_seconds"] += get_entry_duration_seconds(entry)
|
|
||||||
|
|
||||||
serialized_buckets = []
|
|
||||||
for bucket in sorted(buckets.values(), key=lambda item: item["bucket_key"]):
|
|
||||||
bucket["total_duration"] = _format_duration_seconds(bucket["total_seconds"])
|
|
||||||
serialized_buckets.append(bucket)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"scope": _scope_payload(filters),
|
"scope": _scope_payload(filters),
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
"buckets": serialized_buckets,
|
"series": serialized_series,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -445,10 +675,16 @@ 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],
|
||||||
|
*,
|
||||||
|
user_summary: dict | None = None,
|
||||||
|
user_summaries: list[dict] | None = None,
|
||||||
|
) -> dict:
|
||||||
summary = _summary_from_entries(entries)
|
summary = _summary_from_entries(entries)
|
||||||
include_latest_rate = not (filters.is_workspace_scope and not filters.user_id)
|
include_latest_rate = not (filters.is_workspace_scope and not filters.user_id)
|
||||||
return {
|
payload = {
|
||||||
"scope": _scope_payload(filters),
|
"scope": _scope_payload(filters),
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
"days": _group_daily(entries, include_latest_rate=include_latest_rate),
|
"days": _group_daily(entries, include_latest_rate=include_latest_rate),
|
||||||
@@ -456,6 +692,13 @@ def _table_report_payload(filters: ReportFilters, entries: list[TimeEntry]) -> d
|
|||||||
"projects": _build_breakdown(entries, "projects"),
|
"projects": _build_breakdown(entries, "projects"),
|
||||||
"tags": _build_breakdown(entries, "tags"),
|
"tags": _build_breakdown(entries, "tags"),
|
||||||
}
|
}
|
||||||
|
if filters.is_workspace_scope and not filters.user_id:
|
||||||
|
payload.update(_build_overall_percentage_payload(entries))
|
||||||
|
if user_summary is not None:
|
||||||
|
payload["user_summary"] = user_summary
|
||||||
|
if user_summaries is not None:
|
||||||
|
payload["user_summaries"] = user_summaries
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def _group_daily(entries: list[TimeEntry], *, include_latest_rate: bool) -> list[dict]:
|
def _group_daily(entries: list[TimeEntry], *, include_latest_rate: bool) -> list[dict]:
|
||||||
@@ -595,7 +838,10 @@ def _build_breakdown(entries: list[TimeEntry], kind: str) -> list[dict]:
|
|||||||
def build_table_report(actor, raw_filters) -> dict:
|
def build_table_report(actor, raw_filters) -> dict:
|
||||||
filters = load_report_filters(actor, raw_filters)
|
filters = load_report_filters(actor, raw_filters)
|
||||||
entries = list(_base_queryset(filters))
|
entries = list(_base_queryset(filters))
|
||||||
return _table_report_payload(filters, entries)
|
if filters.is_workspace_scope and not filters.user_id:
|
||||||
|
return _table_report_payload(filters, entries, user_summaries=_build_user_summaries(entries))
|
||||||
|
user_summary = _build_user_summary(entries[0].user, entries) if entries and filters.user_id else None
|
||||||
|
return _table_report_payload(filters, entries, user_summary=user_summary)
|
||||||
|
|
||||||
|
|
||||||
def build_user_scoped_table_reports(actor, raw_filters) -> list[dict]:
|
def build_user_scoped_table_reports(actor, raw_filters) -> list[dict]:
|
||||||
@@ -616,7 +862,13 @@ def build_user_scoped_table_reports(actor, raw_filters) -> list[dict]:
|
|||||||
reports: list[dict] = []
|
reports: list[dict] = []
|
||||||
for user_id, user_entries in sorted_groups:
|
for user_id, user_entries in sorted_groups:
|
||||||
user_filters = replace(filters, user_id=user_id)
|
user_filters = replace(filters, user_id=user_id)
|
||||||
reports.append(_table_report_payload(user_filters, user_entries))
|
reports.append(
|
||||||
|
_table_report_payload(
|
||||||
|
user_filters,
|
||||||
|
user_entries,
|
||||||
|
user_summary=_build_user_summary(user_entries[0].user, user_entries),
|
||||||
|
)
|
||||||
|
)
|
||||||
return reports
|
return reports
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ TRANSLATIONS = {
|
|||||||
"en": {
|
"en": {
|
||||||
"report_title": "Workspace Report",
|
"report_title": "Workspace Report",
|
||||||
"overall_sheet": "Overall Report",
|
"overall_sheet": "Overall Report",
|
||||||
|
"users_summary_sheet": "Users Summary",
|
||||||
"workspace": "Workspace",
|
"workspace": "Workspace",
|
||||||
"period": "Period",
|
"period": "Period",
|
||||||
"from_date": "From date",
|
"from_date": "From date",
|
||||||
@@ -38,6 +39,18 @@ TRANSLATIONS = {
|
|||||||
"non_billable_hours": "Non-billable hours",
|
"non_billable_hours": "Non-billable hours",
|
||||||
"hourly_rate": "Hourly rate",
|
"hourly_rate": "Hourly rate",
|
||||||
"income": "Income",
|
"income": "Income",
|
||||||
|
"working_hours": "Working hours",
|
||||||
|
"non_working_hours": "Non-working hours",
|
||||||
|
"hourly_rates": "Hourly rates",
|
||||||
|
"project_percentages": "Project percentages",
|
||||||
|
"client_percentages": "Client percentages",
|
||||||
|
"tag_percentages": "Tag percentages",
|
||||||
|
"summary_by_user": "Summary by user",
|
||||||
|
"rate_history": "Hourly rate history",
|
||||||
|
"from": "From",
|
||||||
|
"to": "To",
|
||||||
|
"percentage": "Percentage",
|
||||||
|
"none": "None",
|
||||||
"daily_summary": "Daily Summary",
|
"daily_summary": "Daily Summary",
|
||||||
"clients": "Clients",
|
"clients": "Clients",
|
||||||
"projects": "Projects",
|
"projects": "Projects",
|
||||||
@@ -50,6 +63,7 @@ TRANSLATIONS = {
|
|||||||
"fa": {
|
"fa": {
|
||||||
"report_title": "گزارش فضای کاری",
|
"report_title": "گزارش فضای کاری",
|
||||||
"overall_sheet": "گزارش کلی",
|
"overall_sheet": "گزارش کلی",
|
||||||
|
"users_summary_sheet": "خلاصه کاربران",
|
||||||
"workspace": "فضای کاری",
|
"workspace": "فضای کاری",
|
||||||
"period": "بازه",
|
"period": "بازه",
|
||||||
"from_date": "از تاریخ",
|
"from_date": "از تاریخ",
|
||||||
@@ -63,7 +77,19 @@ TRANSLATIONS = {
|
|||||||
"billable_hours": "ساعات کاری",
|
"billable_hours": "ساعات کاری",
|
||||||
"non_billable_hours": "ساعات غیر کاری",
|
"non_billable_hours": "ساعات غیر کاری",
|
||||||
"hourly_rate": "نرخ ساعتی",
|
"hourly_rate": "نرخ ساعتی",
|
||||||
"income": "درآمد",
|
"income": "کارکرد",
|
||||||
|
"working_hours": "ساعات کاری",
|
||||||
|
"non_working_hours": "ساعات غیرکاری",
|
||||||
|
"hourly_rates": "نرخهای ساعتی",
|
||||||
|
"project_percentages": "درصد پروژهها",
|
||||||
|
"client_percentages": "درصد مشتریها",
|
||||||
|
"tag_percentages": "درصد تگها",
|
||||||
|
"summary_by_user": "خلاصه کاربران",
|
||||||
|
"rate_history": "تاریخچه نرخ ساعتی",
|
||||||
|
"from": "از",
|
||||||
|
"to": "تا",
|
||||||
|
"percentage": "درصد",
|
||||||
|
"none": "بدون مورد",
|
||||||
"daily_summary": "خلاصه روزانه",
|
"daily_summary": "خلاصه روزانه",
|
||||||
"clients": "مشتریان",
|
"clients": "مشتریان",
|
||||||
"projects": "پروژهها",
|
"projects": "پروژهها",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -30,13 +30,13 @@ def generate_report_export_task(job_id: str):
|
|||||||
try:
|
try:
|
||||||
locale = build_export_locale(job.filters.get("language"))
|
locale = build_export_locale(job.filters.get("language"))
|
||||||
report_data = build_table_report(job.requesting_user, job.filters)
|
report_data = build_table_report(job.requesting_user, job.filters)
|
||||||
|
per_user_reports = build_user_scoped_table_reports(job.requesting_user, job.filters)
|
||||||
if job.export_type == ReportExportJob.ExportType.EXCEL:
|
if job.export_type == ReportExportJob.ExportType.EXCEL:
|
||||||
per_user_reports = build_user_scoped_table_reports(job.requesting_user, job.filters)
|
|
||||||
content = build_excel_report(report_data=report_data, locale=locale, per_user_reports=per_user_reports)
|
content = build_excel_report(report_data=report_data, locale=locale, per_user_reports=per_user_reports)
|
||||||
suffix = "xlsx"
|
suffix = "xlsx"
|
||||||
mime_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
mime_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
else:
|
else:
|
||||||
content = build_pdf_report(report_data=report_data, locale=locale)
|
content = build_pdf_report(report_data=report_data, locale=locale, per_user_reports=per_user_reports)
|
||||||
suffix = "pdf"
|
suffix = "pdf"
|
||||||
mime_type = "application/pdf"
|
mime_type = "application/pdf"
|
||||||
|
|
||||||
|
|||||||
@@ -46,15 +46,58 @@ def make_report_data(*, user_name="Owner User", mobile="09129990001", hourly_rat
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def make_user_summary(*, name: str, mobile: str):
|
||||||
|
return {
|
||||||
|
"user": {"id": mobile, "name": name, "mobile": mobile},
|
||||||
|
"hourly_rates": [{"amount": "15.00", "currency": "USD"}],
|
||||||
|
"rate_periods": [
|
||||||
|
{
|
||||||
|
"amount": "15.00",
|
||||||
|
"currency": "USD",
|
||||||
|
"from_date": "2026-04-01",
|
||||||
|
"to_date": "2026-04-30",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total_seconds": 7200,
|
||||||
|
"total_duration": "02:00:00",
|
||||||
|
"billable_seconds": 7200,
|
||||||
|
"billable_duration": "02:00:00",
|
||||||
|
"non_billable_seconds": 0,
|
||||||
|
"non_billable_duration": "00:00:00",
|
||||||
|
"income_totals": [{"amount": "30.00", "currency": "USD"}],
|
||||||
|
"project_percentages": [{"id": "1", "name": "Website", "percentage": "100"}],
|
||||||
|
"client_percentages": [{"id": "1", "name": "Acme", "percentage": "100"}],
|
||||||
|
"tag_percentages": [{"id": "1", "name": "Design", "percentage": "100"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class ReportExporterTests(TestCase):
|
class ReportExporterTests(TestCase):
|
||||||
def test_excel_export_adds_per_user_sheets_and_daily_rate_column(self):
|
def test_excel_export_adds_per_user_sheets_and_daily_rate_column(self):
|
||||||
locale = build_export_locale("en")
|
locale = build_export_locale("en")
|
||||||
report_data = make_report_data(
|
report_data = make_report_data(
|
||||||
hourly_rate={"amount": "15.00", "currency": "USD"},
|
hourly_rate={"amount": "15.00", "currency": "USD"},
|
||||||
)
|
)
|
||||||
|
report_data["user_summaries"] = [
|
||||||
|
make_user_summary(name="Owner User", mobile="09129990001"),
|
||||||
|
make_user_summary(name="Team Mate", mobile="09129990002"),
|
||||||
|
]
|
||||||
per_user_reports = [
|
per_user_reports = [
|
||||||
make_report_data(user_name="Owner User", mobile="09129990001"),
|
{
|
||||||
make_report_data(user_name="Team Mate", mobile="09129990002"),
|
**make_report_data(
|
||||||
|
user_name="Owner User",
|
||||||
|
mobile="09129990001",
|
||||||
|
hourly_rate={"amount": "15.00", "currency": "USD"},
|
||||||
|
),
|
||||||
|
"user_summary": make_user_summary(name="Owner User", mobile="09129990001"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
**make_report_data(
|
||||||
|
user_name="Team Mate",
|
||||||
|
mobile="09129990002",
|
||||||
|
hourly_rate={"amount": "15.00", "currency": "USD"},
|
||||||
|
),
|
||||||
|
"user_summary": make_user_summary(name="Team Mate", mobile="09129990002"),
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
workbook = load_workbook(
|
workbook = load_workbook(
|
||||||
@@ -71,13 +114,38 @@ class ReportExporterTests(TestCase):
|
|||||||
self.assertIn("Owner User", workbook.sheetnames[1])
|
self.assertIn("Owner User", workbook.sheetnames[1])
|
||||||
self.assertIn("Team Mate", workbook.sheetnames[2])
|
self.assertIn("Team Mate", workbook.sheetnames[2])
|
||||||
|
|
||||||
worksheet = workbook.active
|
summary_sheet = workbook[workbook.sheetnames[0]]
|
||||||
values = list(worksheet.iter_rows(values_only=True))
|
summary_values = list(summary_sheet.iter_rows(values_only=True))
|
||||||
|
|
||||||
self.assertTrue(any(row[:2] == ("User", "Owner User") for row in values if row))
|
self.assertEqual(summary_sheet["A1"].value, "Workspace Report")
|
||||||
self.assertTrue(any(row[:2] == ("Mobile", "09129990001") for row in values if row))
|
self.assertEqual(summary_sheet["B1"].value, "Exports")
|
||||||
|
self.assertEqual(summary_sheet["A15"].value, "Users Summary")
|
||||||
|
self.assertIn("A15:M15", {str(item) for item in summary_sheet.merged_cells.ranges})
|
||||||
|
self.assertEqual(
|
||||||
|
tuple(summary_sheet.iter_rows(min_row=16, max_row=16, values_only=True))[0][:13],
|
||||||
|
(
|
||||||
|
"Name",
|
||||||
|
"Mobile",
|
||||||
|
"Working hours",
|
||||||
|
"Non-working hours",
|
||||||
|
"Income",
|
||||||
|
"Hourly rate",
|
||||||
|
"Period",
|
||||||
|
"Clients",
|
||||||
|
"Percentage",
|
||||||
|
"Projects",
|
||||||
|
"Percentage",
|
||||||
|
"Tags",
|
||||||
|
"Percentage",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertTrue(any(row and "Owner User" in row for row in summary_values))
|
||||||
|
self.assertTrue(any(row and "09129990001" in row for row in summary_values))
|
||||||
|
|
||||||
daily_header = next(row[:6] for row in values if row and row[0] == "Date")
|
user_sheet = workbook[workbook.sheetnames[1]]
|
||||||
|
user_values = list(user_sheet.iter_rows(values_only=True))
|
||||||
|
|
||||||
|
daily_header = next(row[:6] for row in user_values if row and "Date" in row)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
daily_header,
|
daily_header,
|
||||||
(
|
(
|
||||||
@@ -90,7 +158,7 @@ class ReportExporterTests(TestCase):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
daily_row = next(row[:6] for row in values if row and row[0] == "2026/04/12")
|
daily_row = next(row[:6] for row in user_values if row and "2026/04/12" in row)
|
||||||
self.assertEqual(daily_row[4], "15 USD")
|
self.assertEqual(daily_row[4], "15 USD")
|
||||||
|
|
||||||
def test_pdf_export_supports_persian_locale(self):
|
def test_pdf_export_supports_persian_locale(self):
|
||||||
@@ -98,7 +166,11 @@ class ReportExporterTests(TestCase):
|
|||||||
report_data = make_report_data(
|
report_data = make_report_data(
|
||||||
hourly_rate={"amount": "15.00", "currency": "USD"},
|
hourly_rate={"amount": "15.00", "currency": "USD"},
|
||||||
)
|
)
|
||||||
|
report_data["user_summaries"] = [make_user_summary(name="Owner User", mobile="09129990001")]
|
||||||
|
per_user_reports = [
|
||||||
|
{**make_report_data(user_name="Owner User", mobile="09129990001"), "user_summary": make_user_summary(name="Owner User", mobile="09129990001")}
|
||||||
|
]
|
||||||
|
|
||||||
content = build_pdf_report(report_data=report_data, locale=locale)
|
content = build_pdf_report(report_data=report_data, locale=locale, per_user_reports=per_user_reports)
|
||||||
|
|
||||||
self.assertEqual(content[:4], b"%PDF")
|
self.assertEqual(content[:4], b"%PDF")
|
||||||
|
|||||||
@@ -100,6 +100,28 @@ class ReportViewTests(APITestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response.data["summary"]["total_duration"], "01:00:00")
|
self.assertEqual(response.data["summary"]["total_duration"], "01:00:00")
|
||||||
|
self.assertEqual(len(response.data["series"]), 1)
|
||||||
|
self.assertEqual(response.data["series"][0]["user"]["id"], str(self.member.id))
|
||||||
|
|
||||||
|
def test_admin_chart_without_user_filter_returns_series_for_all_users(self):
|
||||||
|
self.client.force_authenticate(user=self.admin)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"apps.reports.services.aggregation.timezone.localdate",
|
||||||
|
return_value=date(2026, 4, 20),
|
||||||
|
):
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/reports/chart/",
|
||||||
|
{"workspace": str(self.workspace.id), "period": "this_month"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.data["summary"]["total_duration"], "03:00:00")
|
||||||
|
self.assertEqual(len(response.data["series"]), 2)
|
||||||
|
self.assertEqual(
|
||||||
|
{series["user"]["id"] for series in response.data["series"]},
|
||||||
|
{str(self.owner.id), str(self.member.id)},
|
||||||
|
)
|
||||||
|
|
||||||
def test_admin_can_request_combined_table_report(self):
|
def test_admin_can_request_combined_table_report(self):
|
||||||
self.client.force_authenticate(user=self.admin)
|
self.client.force_authenticate(user=self.admin)
|
||||||
@@ -116,8 +138,18 @@ class ReportViewTests(APITestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response.data["summary"]["total_duration"], "03:00:00")
|
self.assertEqual(response.data["summary"]["total_duration"], "03:00:00")
|
||||||
self.assertEqual(len(response.data["days"]), 2)
|
self.assertEqual(len(response.data["days"]), 2)
|
||||||
|
self.assertEqual(len(response.data["user_summaries"]), 2)
|
||||||
self.assertIsNone(response.data["days"][0]["latest_hourly_rate"])
|
self.assertIsNone(response.data["days"][0]["latest_hourly_rate"])
|
||||||
self.assertIsNone(response.data["days"][1]["latest_hourly_rate"])
|
self.assertIsNone(response.data["days"][1]["latest_hourly_rate"])
|
||||||
|
summaries = {item["user"]["id"]: item for item in response.data["user_summaries"]}
|
||||||
|
owner_summary = summaries[str(self.owner.id)]
|
||||||
|
member_summary = summaries[str(self.member.id)]
|
||||||
|
self.assertEqual(owner_summary["project_percentages"][0]["percentage"], "100")
|
||||||
|
self.assertEqual(owner_summary["client_percentages"][0]["percentage"], "100")
|
||||||
|
self.assertEqual(owner_summary["tag_percentages"][0]["percentage"], "100")
|
||||||
|
self.assertEqual(member_summary["project_percentages"], [])
|
||||||
|
self.assertEqual(member_summary["client_percentages"], [])
|
||||||
|
self.assertEqual(member_summary["tag_percentages"], [])
|
||||||
|
|
||||||
def test_daily_rate_uses_latest_billable_entry_snapshot(self):
|
def test_daily_rate_uses_latest_billable_entry_snapshot(self):
|
||||||
self.client.force_authenticate(user=self.owner)
|
self.client.force_authenticate(user=self.owner)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from rest_framework import serializers
|
|||||||
from core.serializers.base import BaseModelSerializer
|
from core.serializers.base import BaseModelSerializer
|
||||||
from apps.time_entries.models import TimeEntry
|
from apps.time_entries.models import TimeEntry
|
||||||
from apps.projects.models import Project
|
from apps.projects.models import Project
|
||||||
|
from apps.projects.services.access import ensure_project_access
|
||||||
from apps.tags.models import Tag
|
from apps.tags.models import Tag
|
||||||
|
|
||||||
|
|
||||||
@@ -99,6 +100,8 @@ class TimeEntryCreateSerializer(serializers.Serializer):
|
|||||||
is_billable = serializers.BooleanField(default=False)
|
is_billable = serializers.BooleanField(default=False)
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
|
user = self.context.get("request").user if self.context.get("request") else None
|
||||||
|
workspace_id = attrs.get("workspace_id")
|
||||||
project_id = attrs.pop("project_id", serializers.empty)
|
project_id = attrs.pop("project_id", serializers.empty)
|
||||||
if project_id is not serializers.empty:
|
if project_id is not serializers.empty:
|
||||||
if project_id is None:
|
if project_id is None:
|
||||||
@@ -107,6 +110,10 @@ class TimeEntryCreateSerializer(serializers.Serializer):
|
|||||||
project = Project.objects.filter(id=project_id).first()
|
project = Project.objects.filter(id=project_id).first()
|
||||||
if not project:
|
if not project:
|
||||||
raise serializers.ValidationError({"project_id": "Selected project is unavailable."})
|
raise serializers.ValidationError({"project_id": "Selected project is unavailable."})
|
||||||
|
if workspace_id and str(project.workspace_id) != str(workspace_id):
|
||||||
|
raise serializers.ValidationError({"project_id": "Selected project is unavailable."})
|
||||||
|
if user:
|
||||||
|
ensure_project_access(user, project)
|
||||||
attrs["project"] = project
|
attrs["project"] = project
|
||||||
|
|
||||||
tag_ids = attrs.pop("tags", serializers.empty)
|
tag_ids = attrs.pop("tags", serializers.empty)
|
||||||
@@ -134,6 +141,7 @@ class TimeEntryUpdateSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
entry = self.instance
|
entry = self.instance
|
||||||
|
user = self.context.get("request").user if self.context.get("request") else None
|
||||||
|
|
||||||
project_id = attrs.pop("project_id", serializers.empty)
|
project_id = attrs.pop("project_id", serializers.empty)
|
||||||
if project_id is not serializers.empty:
|
if project_id is not serializers.empty:
|
||||||
@@ -146,6 +154,10 @@ class TimeEntryUpdateSerializer(serializers.Serializer):
|
|||||||
project = Project.objects.filter(id=project_id).first()
|
project = Project.objects.filter(id=project_id).first()
|
||||||
if not project:
|
if not project:
|
||||||
raise serializers.ValidationError({"project_id": "Selected project is unavailable."})
|
raise serializers.ValidationError({"project_id": "Selected project is unavailable."})
|
||||||
|
if entry and str(project.workspace_id) != str(entry.workspace_id):
|
||||||
|
raise serializers.ValidationError({"project_id": "Selected project is unavailable."})
|
||||||
|
if user:
|
||||||
|
ensure_project_access(user, project)
|
||||||
attrs["project"] = project
|
attrs["project"] = project
|
||||||
|
|
||||||
tag_ids = attrs.pop("tags", serializers.empty)
|
tag_ids = attrs.pop("tags", serializers.empty)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework.exceptions import ValidationError, PermissionDenied
|
from rest_framework.exceptions import ValidationError, PermissionDenied
|
||||||
|
|
||||||
|
from apps.projects.services.access import user_has_project_access
|
||||||
from apps.time_entries.models import TimeEntry
|
from apps.time_entries.models import TimeEntry
|
||||||
from apps.time_entries.services.rates import resolve_rate
|
from apps.time_entries.services.rates import resolve_rate
|
||||||
from apps.workspaces.models import Workspace
|
from apps.workspaces.models import Workspace
|
||||||
@@ -40,8 +41,10 @@ def create_time_entry(user, workspace_id, start_time, end_time=None, project=Non
|
|||||||
if start_time and end_time and start_time >= end_time:
|
if start_time and end_time and start_time >= end_time:
|
||||||
raise ValidationError({"end_time": "End time must be strictly after start time."})
|
raise ValidationError({"end_time": "End time must be strictly after start time."})
|
||||||
|
|
||||||
if project and project.workspace_id != workspace_id:
|
if project and project.workspace_id != workspace_id:
|
||||||
raise ValidationError({"project": "Project must belong to the same workspace."})
|
raise ValidationError({"project": "Project must belong to the same workspace."})
|
||||||
|
if project and not user_has_project_access(user, project):
|
||||||
|
raise ValidationError({"project_id": "Selected project is unavailable."})
|
||||||
|
|
||||||
duration = (end_time - start_time) if end_time else None
|
duration = (end_time - start_time) if end_time else None
|
||||||
|
|
||||||
@@ -76,9 +79,11 @@ def update_time_entry(entry, **kwargs):
|
|||||||
Updates an existing time entry, recalculating duration and rates if necessary.
|
Updates an existing time entry, recalculating duration and rates if necessary.
|
||||||
"""
|
"""
|
||||||
# Verify Project Workspace if changing
|
# Verify Project Workspace if changing
|
||||||
project = kwargs.get("project", entry.project)
|
project = kwargs.get("project", entry.project)
|
||||||
if project and project.workspace_id != entry.workspace_id:
|
if project and project.workspace_id != entry.workspace_id:
|
||||||
raise ValidationError({"project": "Project must belong to the same workspace."})
|
raise ValidationError({"project": "Project must belong to the same workspace."})
|
||||||
|
if project and not user_has_project_access(entry.user, project):
|
||||||
|
raise ValidationError({"project_id": "Selected project is unavailable."})
|
||||||
|
|
||||||
start_time = kwargs.get("start_time", entry.start_time)
|
start_time = kwargs.get("start_time", entry.start_time)
|
||||||
end_time = kwargs.get("end_time", entry.end_time)
|
end_time = kwargs.get("end_time", entry.end_time)
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ from datetime import datetime
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
|
from apps.projects.models import Project, ProjectAccess
|
||||||
from apps.tags.models import Tag
|
from apps.tags.models import Tag
|
||||||
from apps.time_entries.models import TimeEntry
|
from apps.time_entries.models import TimeEntry
|
||||||
from apps.users.models import User
|
from apps.users.models import User
|
||||||
from apps.workspaces.models import Workspace
|
from apps.workspaces.models import Workspace, WorkspaceMembership
|
||||||
|
|
||||||
|
|
||||||
def make_aware(year, month, day, hour=9, minute=0, second=0):
|
def make_aware(year, month, day, hour=9, minute=0, second=0):
|
||||||
@@ -139,3 +140,62 @@ class TimeEntryViewTests(APITestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response.data["tags"], [])
|
self.assertEqual(response.data["tags"], [])
|
||||||
|
|
||||||
|
def test_member_cannot_create_time_entry_for_inaccessible_project(self):
|
||||||
|
owner = User.objects.create_user(mobile="09120000001", password="secret123")
|
||||||
|
member = User.objects.create_user(mobile="09120000002", password="secret123")
|
||||||
|
workspace = Workspace.objects.create(name="Core", owner=owner)
|
||||||
|
WorkspaceMembership.objects.create(
|
||||||
|
workspace=workspace,
|
||||||
|
user=member,
|
||||||
|
role=WorkspaceMembership.Role.MEMBER,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
project = Project.objects.create(workspace=workspace, name="Restricted")
|
||||||
|
|
||||||
|
self.client.force_authenticate(user=member)
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/time-entries/",
|
||||||
|
{
|
||||||
|
"workspace_id": str(workspace.id),
|
||||||
|
"project_id": str(project.id),
|
||||||
|
"description": "Blocked",
|
||||||
|
"start_time": make_aware(2026, 4, 24, 9, 0, 0).isoformat(),
|
||||||
|
"end_time": make_aware(2026, 4, 24, 10, 0, 0).isoformat(),
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertTrue(
|
||||||
|
any("Selected project is unavailable." in item["message"] for item in response.data["messages"])
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_member_can_create_time_entry_after_project_access_is_granted(self):
|
||||||
|
owner = User.objects.create_user(mobile="09120000011", password="secret123")
|
||||||
|
member = User.objects.create_user(mobile="09120000012", password="secret123")
|
||||||
|
workspace = Workspace.objects.create(name="Core", owner=owner)
|
||||||
|
WorkspaceMembership.objects.create(
|
||||||
|
workspace=workspace,
|
||||||
|
user=member,
|
||||||
|
role=WorkspaceMembership.Role.MEMBER,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
project = Project.objects.create(workspace=workspace, name="Accessible")
|
||||||
|
ProjectAccess.objects.create(project=project, user=member)
|
||||||
|
|
||||||
|
self.client.force_authenticate(user=member)
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/time-entries/",
|
||||||
|
{
|
||||||
|
"workspace_id": str(workspace.id),
|
||||||
|
"project_id": str(project.id),
|
||||||
|
"description": "Allowed",
|
||||||
|
"start_time": make_aware(2026, 4, 24, 9, 0, 0).isoformat(),
|
||||||
|
"end_time": make_aware(2026, 4, 24, 10, 0, 0).isoformat(),
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 201)
|
||||||
|
self.assertEqual(response.data["project"], str(project.id))
|
||||||
|
|||||||
@@ -125,12 +125,12 @@ class SendOTPView(APIView):
|
|||||||
serializer = SendOTPSerializer(data=request.data)
|
serializer = SendOTPSerializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
generate_and_send_otp(
|
payload = generate_and_send_otp(
|
||||||
mobile=serializer.validated_data["mobile"],
|
mobile=serializer.validated_data["mobile"],
|
||||||
mode=serializer.validated_data["mode"]
|
mode=serializer.validated_data["mode"]
|
||||||
)
|
)
|
||||||
|
|
||||||
return Response({"detail": "OTP sent successfully"}, status=status.HTTP_200_OK)
|
return Response(payload, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
class LoginView(APIView):
|
class LoginView(APIView):
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model, password_validation
|
from django.contrib.auth import get_user_model, password_validation
|
||||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||||
@@ -18,6 +19,7 @@ User = get_user_model()
|
|||||||
|
|
||||||
USER_ALREADY_EXISTS_MESSAGE = "User already exists."
|
USER_ALREADY_EXISTS_MESSAGE = "User already exists."
|
||||||
PASSWORD_REUSE_MESSAGE = "\u0631\u0645\u0632 \u0639\u0628\u0648\u0631 \u062c\u062f\u06cc\u062f \u0646\u0628\u0627\u06cc\u062f \u0628\u0627 \u0631\u0645\u0632 \u0639\u0628\u0648\u0631 \u0642\u0628\u0644\u06cc \u06cc\u06a9\u0633\u0627\u0646 \u0628\u0627\u0634\u062f."
|
PASSWORD_REUSE_MESSAGE = "\u0631\u0645\u0632 \u0639\u0628\u0648\u0631 \u062c\u062f\u06cc\u062f \u0646\u0628\u0627\u06cc\u062f \u0628\u0627 \u0631\u0645\u0632 \u0639\u0628\u0648\u0631 \u0642\u0628\u0644\u06cc \u06cc\u06a9\u0633\u0627\u0646 \u0628\u0627\u0634\u062f."
|
||||||
|
OTP_EXPIRY_SECONDS = 120
|
||||||
|
|
||||||
|
|
||||||
def _validate_new_password(password, *, user, field_name):
|
def _validate_new_password(password, *, user, field_name):
|
||||||
@@ -90,9 +92,15 @@ def generate_and_send_otp(mobile, mode):
|
|||||||
verification_code = "".join(random.choices(string.digits, k=5))
|
verification_code = "".join(random.choices(string.digits, k=5))
|
||||||
|
|
||||||
redis_conn = get_redis_connection("default")
|
redis_conn = get_redis_connection("default")
|
||||||
redis_conn.setex(f"verification_code:{mobile}", 120, verification_code)
|
redis_conn.setex(f"verification_code:{mobile}", OTP_EXPIRY_SECONDS, verification_code)
|
||||||
|
|
||||||
send_verification_sms.delay(mobile, verification_code)
|
send_verification_sms.delay(mobile, verification_code)
|
||||||
|
expires_at = timezone.now() + timedelta(seconds=OTP_EXPIRY_SECONDS)
|
||||||
|
return {
|
||||||
|
"detail": "OTP sent successfully",
|
||||||
|
"expires_in_seconds": OTP_EXPIRY_SECONDS,
|
||||||
|
"expires_at": expires_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def login_with_password(mobile, password, request=None):
|
def login_with_password(mobile, password, request=None):
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ class UserApiViewTests(APITestCase):
|
|||||||
|
|
||||||
@patch("apps.users.api.views.generate_and_send_otp")
|
@patch("apps.users.api.views.generate_and_send_otp")
|
||||||
def test_send_otp_view_validates_and_dispatches(self, generate_and_send_otp):
|
def test_send_otp_view_validates_and_dispatches(self, generate_and_send_otp):
|
||||||
|
generate_and_send_otp.return_value = {
|
||||||
|
"detail": "OTP sent successfully",
|
||||||
|
"expires_in_seconds": 120,
|
||||||
|
"expires_at": "2026-05-12T10:00:00+03:30",
|
||||||
|
}
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/users/otp/send/",
|
"/api/users/otp/send/",
|
||||||
{"mobile": "09123330009", "mode": "login"},
|
{"mobile": "09123330009", "mode": "login"},
|
||||||
@@ -60,6 +65,7 @@ class UserApiViewTests(APITestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(response.data["expires_in_seconds"], 120)
|
||||||
generate_and_send_otp.assert_called_once_with(
|
generate_and_send_otp.assert_called_once_with(
|
||||||
mobile="09123330009",
|
mobile="09123330009",
|
||||||
mode="login",
|
mode="login",
|
||||||
@@ -67,6 +73,11 @@ class UserApiViewTests(APITestCase):
|
|||||||
|
|
||||||
@patch("apps.users.api.views.generate_and_send_otp")
|
@patch("apps.users.api.views.generate_and_send_otp")
|
||||||
def test_send_otp_view_supports_forget_password_mode(self, generate_and_send_otp):
|
def test_send_otp_view_supports_forget_password_mode(self, generate_and_send_otp):
|
||||||
|
generate_and_send_otp.return_value = {
|
||||||
|
"detail": "OTP sent successfully",
|
||||||
|
"expires_in_seconds": 120,
|
||||||
|
"expires_at": "2026-05-12T10:00:00+03:30",
|
||||||
|
}
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/users/otp/send/",
|
"/api/users/otp/send/",
|
||||||
{"mobile": "09123330001", "mode": "forget_password"},
|
{"mobile": "09123330001", "mode": "forget_password"},
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ WORKSPACE_ROLE_CAPABILITIES = {
|
|||||||
TAGS_VIEW,
|
TAGS_VIEW,
|
||||||
PROJECTS_VIEW,
|
PROJECTS_VIEW,
|
||||||
TIME_ENTRIES_VIEW_OWN,
|
TIME_ENTRIES_VIEW_OWN,
|
||||||
|
TIME_ENTRIES_MANAGE_OWN,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user