Compare commits
7 Commits
208e81139b
...
71924ce6fb
| Author | SHA1 | Date | |
|---|---|---|---|
| 71924ce6fb | |||
| c8a118788b | |||
| 315f2ca728 | |||
| 76f02dc259 | |||
| afb1a55570 | |||
| 02c9c17c30 | |||
| 7bd60fd641 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -23,6 +23,10 @@ staticfiles/
|
|||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
logs/
|
logs/
|
||||||
|
!apps/logs/
|
||||||
|
!apps/logs/**
|
||||||
|
apps/logs/**/__pycache__/
|
||||||
|
apps/logs/**/*.pyc
|
||||||
|
|
||||||
# IDE / Editor
|
# IDE / Editor
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from apps.workspaces.services import (
|
|||||||
CLIENTS_DELETE,
|
CLIENTS_DELETE,
|
||||||
CLIENTS_EDIT,
|
CLIENTS_EDIT,
|
||||||
CLIENTS_VIEW,
|
CLIENTS_VIEW,
|
||||||
|
can_delete_workspace_object,
|
||||||
has_workspace_capability,
|
has_workspace_capability,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -43,4 +44,6 @@ class IsClientWorkspaceMember(permissions.BasePermission):
|
|||||||
"partial_update": CLIENTS_EDIT,
|
"partial_update": CLIENTS_EDIT,
|
||||||
"destroy": CLIENTS_DELETE,
|
"destroy": CLIENTS_DELETE,
|
||||||
}.get(view.action, CLIENTS_VIEW)
|
}.get(view.action, CLIENTS_VIEW)
|
||||||
|
if view.action == "destroy":
|
||||||
|
return can_delete_workspace_object(request.user, obj, CLIENTS_DELETE)
|
||||||
return has_workspace_capability(request.user, obj.workspace, capability)
|
return has_workspace_capability(request.user, obj.workspace, capability)
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
|
from apps.logs.services import build_workspace_log_metadata
|
||||||
|
from apps.logs.services.constants import SECTION_CLIENTS
|
||||||
from apps.workspaces.models import Workspace
|
from apps.workspaces.models import Workspace
|
||||||
|
|
||||||
from core.models.base import BaseModel
|
from core.models.base import BaseModel
|
||||||
@@ -32,3 +34,11 @@ class Client(BaseModel):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
def get_additional_data(self):
|
||||||
|
return build_workspace_log_metadata(
|
||||||
|
section=SECTION_CLIENTS,
|
||||||
|
workspace_id=self.workspace_id,
|
||||||
|
target_id=self.id,
|
||||||
|
target_label=self.name,
|
||||||
|
)
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ def create_client(user, workspace_id, name, notes=""):
|
|||||||
return Client.objects.create(
|
return Client.objects.create(
|
||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
name=name,
|
name=name,
|
||||||
notes=notes
|
notes=notes,
|
||||||
|
created_by=user,
|
||||||
|
updated_by=user,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
0
apps/logs/__init__.py
Normal file
0
apps/logs/__init__.py
Normal file
2
apps/logs/admin.py
Normal file
2
apps/logs/admin.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""Admin registrations for apps.logs live on django-auditlog."""
|
||||||
|
|
||||||
12
apps/logs/api/permissions.py
Normal file
12
apps/logs/api/permissions.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from rest_framework.exceptions import PermissionDenied
|
||||||
|
|
||||||
|
from apps.logs.services import WORKSPACE_LOGS_VIEW
|
||||||
|
from apps.workspaces.services import has_workspace_capability
|
||||||
|
|
||||||
|
|
||||||
|
def enforce_workspace_log_access(user, workspace) -> None:
|
||||||
|
if not user or not user.is_authenticated:
|
||||||
|
raise PermissionDenied("Authentication credentials were not provided.")
|
||||||
|
if not has_workspace_capability(user, workspace, WORKSPACE_LOGS_VIEW):
|
||||||
|
raise PermissionDenied("You do not have permission to view workspace logs.")
|
||||||
|
|
||||||
32
apps/logs/api/serializers.py
Normal file
32
apps/logs/api/serializers.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from apps.logs.services import LOG_EVENTS, LOG_SECTIONS
|
||||||
|
from apps.logs.services.query import (
|
||||||
|
serialize_workspace_log_detail,
|
||||||
|
serialize_workspace_log_list_item,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceLogQuerySerializer(serializers.Serializer):
|
||||||
|
workspace = serializers.UUIDField()
|
||||||
|
section = serializers.ChoiceField(choices=LOG_SECTIONS, required=False)
|
||||||
|
actor = serializers.UUIDField(required=False)
|
||||||
|
event = serializers.ChoiceField(choices=LOG_EVENTS, required=False)
|
||||||
|
search = serializers.CharField(required=False, allow_blank=True, trim_whitespace=True)
|
||||||
|
from_date = serializers.CharField(required=False, allow_blank=True, source="from")
|
||||||
|
to_date = serializers.CharField(required=False, allow_blank=True, source="to")
|
||||||
|
ordering = serializers.ChoiceField(
|
||||||
|
choices=("timestamp", "-timestamp"),
|
||||||
|
required=False,
|
||||||
|
default="-timestamp",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceLogListSerializer(serializers.Serializer):
|
||||||
|
def to_representation(self, instance):
|
||||||
|
return serialize_workspace_log_list_item(instance)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceLogDetailSerializer(serializers.Serializer):
|
||||||
|
def to_representation(self, instance):
|
||||||
|
return serialize_workspace_log_detail(instance)
|
||||||
12
apps/logs/api/urls.py
Normal file
12
apps/logs/api/urls.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from django.urls import include, path
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
|
from apps.logs.api.views import WorkspaceLogViewSet
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r"", WorkspaceLogViewSet, basename="workspace-logs")
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", include(router.urls)),
|
||||||
|
]
|
||||||
|
|
||||||
80
apps/logs/api/views.py
Normal file
80
apps/logs/api/views.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
from auditlog.models import LogEntry
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from rest_framework import status, viewsets
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from apps.logs.api.permissions import enforce_workspace_log_access
|
||||||
|
from apps.logs.api.serializers import (
|
||||||
|
WorkspaceLogDetailSerializer,
|
||||||
|
WorkspaceLogListSerializer,
|
||||||
|
WorkspaceLogQuerySerializer,
|
||||||
|
)
|
||||||
|
from apps.logs.services import (
|
||||||
|
WorkspaceLogFilters,
|
||||||
|
filter_workspace_logs,
|
||||||
|
get_log_workspace_id,
|
||||||
|
is_visible_workspace_log,
|
||||||
|
parse_filter_datetime,
|
||||||
|
)
|
||||||
|
from apps.workspaces.models import Workspace
|
||||||
|
from core.paginations.limit_offset import CustomLimitOffsetPagination
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceLogViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
pagination_class = CustomLimitOffsetPagination
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return LogEntry.objects.select_related("actor", "content_type").order_by("-timestamp")
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
if self.action == "retrieve":
|
||||||
|
return WorkspaceLogDetailSerializer
|
||||||
|
return WorkspaceLogListSerializer
|
||||||
|
|
||||||
|
def list(self, request, *args, **kwargs):
|
||||||
|
query_data = request.query_params.copy()
|
||||||
|
if "from" in query_data:
|
||||||
|
query_data["from_date"] = query_data.get("from")
|
||||||
|
if "to" in query_data:
|
||||||
|
query_data["to_date"] = query_data.get("to")
|
||||||
|
|
||||||
|
query_serializer = WorkspaceLogQuerySerializer(data=query_data)
|
||||||
|
query_serializer.is_valid(raise_exception=True)
|
||||||
|
filters_data = query_serializer.validated_data
|
||||||
|
|
||||||
|
workspace = get_object_or_404(
|
||||||
|
Workspace,
|
||||||
|
id=filters_data["workspace"],
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
enforce_workspace_log_access(request.user, workspace)
|
||||||
|
|
||||||
|
filters = WorkspaceLogFilters(
|
||||||
|
workspace_id=str(workspace.id),
|
||||||
|
section=filters_data.get("section"),
|
||||||
|
actor_id=str(filters_data["actor"]) if filters_data.get("actor") else None,
|
||||||
|
event=filters_data.get("event"),
|
||||||
|
search=filters_data.get("search") or None,
|
||||||
|
from_value=parse_filter_datetime(query_data.get("from")),
|
||||||
|
to_value=parse_filter_datetime(query_data.get("to"), is_end=True),
|
||||||
|
ordering=filters_data.get("ordering", "-timestamp"),
|
||||||
|
)
|
||||||
|
|
||||||
|
queryset = filter_workspace_logs(self.get_queryset(), filters)
|
||||||
|
page = self.paginate_queryset(queryset)
|
||||||
|
serializer = self.get_serializer(page, many=True)
|
||||||
|
return self.get_paginated_response(serializer.data)
|
||||||
|
|
||||||
|
def retrieve(self, request, *args, **kwargs):
|
||||||
|
entry = self.get_object()
|
||||||
|
workspace_id = get_log_workspace_id(entry)
|
||||||
|
if not workspace_id:
|
||||||
|
return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
workspace = get_object_or_404(Workspace, id=workspace_id, is_deleted=False)
|
||||||
|
enforce_workspace_log_access(request.user, workspace)
|
||||||
|
if not is_visible_workspace_log(entry):
|
||||||
|
return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
serializer = self.get_serializer(entry)
|
||||||
|
return Response(serializer.data)
|
||||||
8
apps/logs/apps.py
Normal file
8
apps/logs/apps.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class LogsConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.logs"
|
||||||
|
verbose_name = "Workspace Logs"
|
||||||
|
|
||||||
29
apps/logs/middlewares.py
Normal file
29
apps/logs/middlewares.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from django.contrib.auth.models import AnonymousUser
|
||||||
|
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||||
|
|
||||||
|
|
||||||
|
class JWTRequestActorMiddleware:
|
||||||
|
"""
|
||||||
|
Resolve Bearer tokens before DRF runs so middleware-driven audit hooks
|
||||||
|
can see the authenticated actor on API requests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, get_response):
|
||||||
|
self.get_response = get_response
|
||||||
|
self.authenticator = JWTAuthentication()
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
current_user = getattr(request, "user", None)
|
||||||
|
if not getattr(current_user, "is_authenticated", False):
|
||||||
|
try:
|
||||||
|
authenticated = self.authenticator.authenticate(request)
|
||||||
|
except Exception:
|
||||||
|
authenticated = None
|
||||||
|
|
||||||
|
if authenticated is not None:
|
||||||
|
request.user = authenticated[0]
|
||||||
|
elif current_user is None:
|
||||||
|
request.user = AnonymousUser()
|
||||||
|
|
||||||
|
return self.get_response(request)
|
||||||
|
|
||||||
0
apps/logs/migrations/__init__.py
Normal file
0
apps/logs/migrations/__init__.py
Normal file
2
apps/logs/models.py
Normal file
2
apps/logs/models.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""Workspace logs are backed by django-auditlog models."""
|
||||||
|
|
||||||
54
apps/logs/services/__init__.py
Normal file
54
apps/logs/services/__init__.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
from apps.logs.services.constants import (
|
||||||
|
EVENT_ACTIVATE,
|
||||||
|
EVENT_ARCHIVE,
|
||||||
|
EVENT_CREATE,
|
||||||
|
EVENT_DEACTIVATE,
|
||||||
|
EVENT_DELETE,
|
||||||
|
EVENT_RESTORE,
|
||||||
|
EVENT_UNARCHIVE,
|
||||||
|
EVENT_UPDATE,
|
||||||
|
LOG_EVENTS,
|
||||||
|
LOG_SECTIONS,
|
||||||
|
WORKSPACE_LOGS_VIEW,
|
||||||
|
)
|
||||||
|
from apps.logs.services.metadata import build_workspace_log_metadata
|
||||||
|
from apps.logs.services.query import (
|
||||||
|
WorkspaceLogFilters,
|
||||||
|
build_log_target_payload,
|
||||||
|
filter_workspace_logs,
|
||||||
|
get_log_change_rows,
|
||||||
|
get_log_section,
|
||||||
|
get_log_workspace_id,
|
||||||
|
is_visible_workspace_log,
|
||||||
|
normalize_log_event,
|
||||||
|
parse_filter_datetime,
|
||||||
|
serialize_workspace_log_detail,
|
||||||
|
serialize_workspace_log_list_item,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"WORKSPACE_LOGS_VIEW",
|
||||||
|
"LOG_SECTIONS",
|
||||||
|
"LOG_EVENTS",
|
||||||
|
"EVENT_CREATE",
|
||||||
|
"EVENT_UPDATE",
|
||||||
|
"EVENT_DELETE",
|
||||||
|
"EVENT_RESTORE",
|
||||||
|
"EVENT_ARCHIVE",
|
||||||
|
"EVENT_UNARCHIVE",
|
||||||
|
"EVENT_ACTIVATE",
|
||||||
|
"EVENT_DEACTIVATE",
|
||||||
|
"WorkspaceLogFilters",
|
||||||
|
"build_workspace_log_metadata",
|
||||||
|
"get_log_section",
|
||||||
|
"get_log_workspace_id",
|
||||||
|
"normalize_log_event",
|
||||||
|
"get_log_change_rows",
|
||||||
|
"build_log_target_payload",
|
||||||
|
"filter_workspace_logs",
|
||||||
|
"is_visible_workspace_log",
|
||||||
|
"serialize_workspace_log_list_item",
|
||||||
|
"serialize_workspace_log_detail",
|
||||||
|
"parse_filter_datetime",
|
||||||
|
]
|
||||||
|
|
||||||
58
apps/logs/services/constants.py
Normal file
58
apps/logs/services/constants.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
WORKSPACE_LOGS_VIEW = "workspace.logs.view"
|
||||||
|
|
||||||
|
SECTION_WORKSPACE = "workspace"
|
||||||
|
SECTION_WORKSPACE_MEMBERS = "workspace_members"
|
||||||
|
SECTION_CLIENTS = "clients"
|
||||||
|
SECTION_PROJECTS = "projects"
|
||||||
|
SECTION_PROJECT_MEMBERS = "project_members"
|
||||||
|
SECTION_TAGS = "tags"
|
||||||
|
SECTION_TIME_ENTRIES = "time_entries"
|
||||||
|
SECTION_RATES = "rates"
|
||||||
|
SECTION_REPORT_EXPORTS = "report_exports"
|
||||||
|
|
||||||
|
LOG_SECTIONS = (
|
||||||
|
SECTION_WORKSPACE,
|
||||||
|
SECTION_WORKSPACE_MEMBERS,
|
||||||
|
SECTION_CLIENTS,
|
||||||
|
SECTION_PROJECTS,
|
||||||
|
SECTION_PROJECT_MEMBERS,
|
||||||
|
SECTION_TAGS,
|
||||||
|
SECTION_TIME_ENTRIES,
|
||||||
|
SECTION_RATES,
|
||||||
|
SECTION_REPORT_EXPORTS,
|
||||||
|
)
|
||||||
|
|
||||||
|
EVENT_CREATE = "create"
|
||||||
|
EVENT_UPDATE = "update"
|
||||||
|
EVENT_DELETE = "delete"
|
||||||
|
EVENT_RESTORE = "restore"
|
||||||
|
EVENT_ARCHIVE = "archive"
|
||||||
|
EVENT_UNARCHIVE = "unarchive"
|
||||||
|
EVENT_ACTIVATE = "activate"
|
||||||
|
EVENT_DEACTIVATE = "deactivate"
|
||||||
|
|
||||||
|
LOG_EVENTS = (
|
||||||
|
EVENT_CREATE,
|
||||||
|
EVENT_UPDATE,
|
||||||
|
EVENT_DELETE,
|
||||||
|
EVENT_RESTORE,
|
||||||
|
EVENT_ARCHIVE,
|
||||||
|
EVENT_UNARCHIVE,
|
||||||
|
EVENT_ACTIVATE,
|
||||||
|
EVENT_DEACTIVATE,
|
||||||
|
)
|
||||||
|
|
||||||
|
SECTION_BY_MODEL_LABEL = {
|
||||||
|
"workspaces.workspace": SECTION_WORKSPACE,
|
||||||
|
"workspaces.workspacemembership": SECTION_WORKSPACE_MEMBERS,
|
||||||
|
"workspaces.workspaceuserrate": SECTION_RATES,
|
||||||
|
"clients.client": SECTION_CLIENTS,
|
||||||
|
"projects.project": SECTION_PROJECTS,
|
||||||
|
"projects.projectmembership": SECTION_PROJECT_MEMBERS,
|
||||||
|
"tags.tag": SECTION_TAGS,
|
||||||
|
"time_entries.timeentry": SECTION_TIME_ENTRIES,
|
||||||
|
"reports.reportexportjob": SECTION_REPORT_EXPORTS,
|
||||||
|
}
|
||||||
|
|
||||||
|
TRACKED_MODEL_LABELS = tuple(SECTION_BY_MODEL_LABEL.keys())
|
||||||
|
|
||||||
27
apps/logs/services/metadata.py
Normal file
27
apps/logs/services/metadata.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def build_workspace_log_metadata(
|
||||||
|
*,
|
||||||
|
section: str,
|
||||||
|
workspace_id,
|
||||||
|
target_id,
|
||||||
|
target_label: str,
|
||||||
|
extra: dict | None = None,
|
||||||
|
) -> dict:
|
||||||
|
metadata = {
|
||||||
|
"workspace_id": str(workspace_id),
|
||||||
|
"section": section,
|
||||||
|
"target_id": str(target_id),
|
||||||
|
"target_label": target_label,
|
||||||
|
}
|
||||||
|
if extra:
|
||||||
|
metadata.update(
|
||||||
|
{
|
||||||
|
key: value
|
||||||
|
for key, value in extra.items()
|
||||||
|
if value is not None
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return metadata
|
||||||
|
|
||||||
297
apps/logs/services/query.py
Normal file
297
apps/logs/services/query.py
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, time
|
||||||
|
|
||||||
|
from django.db.models import Q, QuerySet, TextField
|
||||||
|
from django.db.models.functions import Cast
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.dateparse import parse_date, parse_datetime
|
||||||
|
|
||||||
|
from auditlog.models import LogEntry
|
||||||
|
|
||||||
|
from apps.logs.services.constants import (
|
||||||
|
EVENT_ACTIVATE,
|
||||||
|
EVENT_ARCHIVE,
|
||||||
|
EVENT_CREATE,
|
||||||
|
EVENT_DEACTIVATE,
|
||||||
|
EVENT_DELETE,
|
||||||
|
EVENT_RESTORE,
|
||||||
|
EVENT_UNARCHIVE,
|
||||||
|
EVENT_UPDATE,
|
||||||
|
LOG_EVENTS,
|
||||||
|
LOG_SECTIONS,
|
||||||
|
SECTION_BY_MODEL_LABEL,
|
||||||
|
SECTION_REPORT_EXPORTS,
|
||||||
|
SECTION_WORKSPACE_MEMBERS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class WorkspaceLogFilters:
|
||||||
|
workspace_id: str
|
||||||
|
section: str | None = None
|
||||||
|
actor_id: str | None = None
|
||||||
|
event: str | None = None
|
||||||
|
search: str | None = None
|
||||||
|
from_value: datetime | None = None
|
||||||
|
to_value: datetime | None = None
|
||||||
|
ordering: str = "-timestamp"
|
||||||
|
|
||||||
|
|
||||||
|
def get_log_model_label(entry: LogEntry) -> str | None:
|
||||||
|
if not entry.content_type_id:
|
||||||
|
return None
|
||||||
|
return f"{entry.content_type.app_label}.{entry.content_type.model}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_log_section(entry: LogEntry) -> str | None:
|
||||||
|
additional_data = entry.additional_data or {}
|
||||||
|
section = additional_data.get("section")
|
||||||
|
if section in LOG_SECTIONS:
|
||||||
|
return section
|
||||||
|
return SECTION_BY_MODEL_LABEL.get(get_log_model_label(entry) or "")
|
||||||
|
|
||||||
|
|
||||||
|
def get_log_workspace_id(entry: LogEntry) -> str | None:
|
||||||
|
additional_data = entry.additional_data or {}
|
||||||
|
workspace_id = additional_data.get("workspace_id")
|
||||||
|
return str(workspace_id) if workspace_id else None
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_log_event(entry: LogEntry) -> str:
|
||||||
|
changes = entry.changes or {}
|
||||||
|
|
||||||
|
is_deleted = changes.get("is_deleted")
|
||||||
|
if isinstance(is_deleted, (list, tuple)) and len(is_deleted) >= 2:
|
||||||
|
if is_deleted[0] in {False, "False", "false", None, "None"} and is_deleted[1] in {True, "True", "true"}:
|
||||||
|
return EVENT_DELETE
|
||||||
|
if is_deleted[0] in {True, "True", "true"} and is_deleted[1] in {False, "False", "false", None, "None"}:
|
||||||
|
return EVENT_RESTORE
|
||||||
|
|
||||||
|
is_archived = changes.get("is_archived")
|
||||||
|
if isinstance(is_archived, (list, tuple)) and len(is_archived) >= 2:
|
||||||
|
if is_archived[0] in {False, "False", "false", None, "None"} and is_archived[1] in {True, "True", "true"}:
|
||||||
|
return EVENT_ARCHIVE
|
||||||
|
if is_archived[0] in {True, "True", "true"} and is_archived[1] in {False, "False", "false", None, "None"}:
|
||||||
|
return EVENT_UNARCHIVE
|
||||||
|
|
||||||
|
is_active = changes.get("is_active")
|
||||||
|
if isinstance(is_active, (list, tuple)) and len(is_active) >= 2:
|
||||||
|
if is_active[0] in {False, "False", "false", None, "None"} and is_active[1] in {True, "True", "true"}:
|
||||||
|
return EVENT_ACTIVATE
|
||||||
|
if is_active[0] in {True, "True", "true"} and is_active[1] in {False, "False", "false", None, "None"}:
|
||||||
|
return EVENT_DEACTIVATE
|
||||||
|
|
||||||
|
if entry.action == LogEntry.Action.CREATE:
|
||||||
|
return EVENT_CREATE
|
||||||
|
return EVENT_UPDATE
|
||||||
|
|
||||||
|
|
||||||
|
def _stringify_change_value(value) -> str | None:
|
||||||
|
if value in (None, "", "None"):
|
||||||
|
return None
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return "true" if value else "false"
|
||||||
|
if isinstance(value, list):
|
||||||
|
return ", ".join(str(item) for item in value if item not in (None, ""))
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def get_log_change_rows(entry: LogEntry) -> list[dict]:
|
||||||
|
changes = entry.changes or {}
|
||||||
|
display_changes = {}
|
||||||
|
try:
|
||||||
|
display_changes = entry.changes_display_dict or {}
|
||||||
|
except Exception:
|
||||||
|
display_changes = {}
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for field_name, raw_value in changes.items():
|
||||||
|
if isinstance(raw_value, dict) and raw_value.get("type") == "m2m":
|
||||||
|
operation = raw_value.get("operation", "update")
|
||||||
|
objects = [str(item) for item in raw_value.get("objects", [])]
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"field": field_name,
|
||||||
|
"label": field_name.replace("_", " ").title(),
|
||||||
|
"change_type": "m2m",
|
||||||
|
"operation": operation,
|
||||||
|
"old_value": None,
|
||||||
|
"new_value": ", ".join(objects) if objects else None,
|
||||||
|
"summary": f"{operation.title()}: {', '.join(objects)}" if objects else operation.title(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
old_value = None
|
||||||
|
new_value = None
|
||||||
|
display_value = display_changes.get(field_name.replace("_", " ").title()) or display_changes.get(field_name)
|
||||||
|
if isinstance(display_value, (list, tuple)) and len(display_value) >= 2:
|
||||||
|
old_value = _stringify_change_value(display_value[0])
|
||||||
|
new_value = _stringify_change_value(display_value[1])
|
||||||
|
elif isinstance(raw_value, (list, tuple)) and len(raw_value) >= 2:
|
||||||
|
old_value = _stringify_change_value(raw_value[0])
|
||||||
|
new_value = _stringify_change_value(raw_value[1])
|
||||||
|
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"field": field_name,
|
||||||
|
"label": field_name.replace("_", " ").title(),
|
||||||
|
"change_type": "field",
|
||||||
|
"operation": "replace",
|
||||||
|
"old_value": old_value,
|
||||||
|
"new_value": new_value,
|
||||||
|
"summary": f"{field_name}: {old_value or '-'} -> {new_value or '-'}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def get_log_preview_changes(entry: LogEntry, limit: int = 3) -> list[dict]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"field": row["field"],
|
||||||
|
"label": row["label"],
|
||||||
|
"summary": row["summary"],
|
||||||
|
}
|
||||||
|
for row in get_log_change_rows(entry)[:limit]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def is_visible_workspace_log(entry: LogEntry) -> bool:
|
||||||
|
if entry.actor_id is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
section = get_log_section(entry)
|
||||||
|
if section not in LOG_SECTIONS:
|
||||||
|
return False
|
||||||
|
|
||||||
|
additional_data = entry.additional_data or {}
|
||||||
|
if section == SECTION_REPORT_EXPORTS and entry.actor_id is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if (
|
||||||
|
section == SECTION_WORKSPACE_MEMBERS
|
||||||
|
and normalize_log_event(entry) == EVENT_CREATE
|
||||||
|
and additional_data.get("canonical_owner_membership") is True
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def filter_workspace_logs(queryset: QuerySet, filters: WorkspaceLogFilters) -> QuerySet:
|
||||||
|
queryset = queryset.filter(additional_data__workspace_id=filters.workspace_id)
|
||||||
|
|
||||||
|
if filters.section:
|
||||||
|
queryset = queryset.filter(additional_data__section=filters.section)
|
||||||
|
|
||||||
|
if filters.actor_id:
|
||||||
|
queryset = queryset.filter(actor_id=filters.actor_id)
|
||||||
|
|
||||||
|
if filters.from_value:
|
||||||
|
queryset = queryset.filter(timestamp__gte=filters.from_value)
|
||||||
|
|
||||||
|
if filters.to_value:
|
||||||
|
queryset = queryset.filter(timestamp__lte=filters.to_value)
|
||||||
|
|
||||||
|
queryset = queryset.annotate(changes_json_text=Cast("changes", TextField()))
|
||||||
|
|
||||||
|
if filters.search:
|
||||||
|
queryset = queryset.filter(
|
||||||
|
Q(object_repr__icontains=filters.search)
|
||||||
|
| Q(changes_text__icontains=filters.search)
|
||||||
|
| Q(changes_json_text__icontains=filters.search)
|
||||||
|
| Q(actor__first_name__icontains=filters.search)
|
||||||
|
| Q(actor__last_name__icontains=filters.search)
|
||||||
|
| Q(actor__mobile__icontains=filters.search)
|
||||||
|
| Q(additional_data__target_label__icontains=filters.search)
|
||||||
|
)
|
||||||
|
|
||||||
|
queryset = queryset.order_by(filters.ordering)
|
||||||
|
if filters.event:
|
||||||
|
matching_ids = [
|
||||||
|
entry.id
|
||||||
|
for entry in queryset
|
||||||
|
if normalize_log_event(entry) == filters.event and is_visible_workspace_log(entry)
|
||||||
|
]
|
||||||
|
return queryset.filter(id__in=matching_ids)
|
||||||
|
|
||||||
|
matching_ids = [entry.id for entry in queryset if is_visible_workspace_log(entry)]
|
||||||
|
return queryset.filter(id__in=matching_ids)
|
||||||
|
|
||||||
|
|
||||||
|
def build_log_actor_payload(entry: LogEntry) -> dict | None:
|
||||||
|
actor = entry.actor
|
||||||
|
if not actor:
|
||||||
|
return None
|
||||||
|
full_name = actor.full_name if getattr(actor, "full_name", "").strip() else actor.mobile
|
||||||
|
return {
|
||||||
|
"id": str(actor.id),
|
||||||
|
"full_name": full_name,
|
||||||
|
"mobile": actor.mobile,
|
||||||
|
"profile_picture": actor.profile_picture.url if getattr(actor, "profile_picture", None) else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_log_target_payload(entry: LogEntry) -> dict:
|
||||||
|
additional_data = entry.additional_data or {}
|
||||||
|
return {
|
||||||
|
"id": str(additional_data.get("target_id") or entry.object_pk),
|
||||||
|
"name": additional_data.get("target_label") or entry.object_repr,
|
||||||
|
"section": get_log_section(entry),
|
||||||
|
"workspace_id": get_log_workspace_id(entry),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_workspace_log_list_item(entry: LogEntry) -> dict:
|
||||||
|
rows = get_log_change_rows(entry)
|
||||||
|
return {
|
||||||
|
"id": entry.id,
|
||||||
|
"timestamp": timezone.localtime(entry.timestamp).isoformat(),
|
||||||
|
"section": get_log_section(entry),
|
||||||
|
"model": get_log_model_label(entry),
|
||||||
|
"event": normalize_log_event(entry),
|
||||||
|
"audit_action": entry.get_action_display(),
|
||||||
|
"actor": build_log_actor_payload(entry),
|
||||||
|
"target": build_log_target_payload(entry),
|
||||||
|
"changed_fields": [row["field"] for row in rows],
|
||||||
|
"preview_changes": get_log_preview_changes(entry),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_workspace_log_detail(entry: LogEntry) -> dict:
|
||||||
|
payload = serialize_workspace_log_list_item(entry)
|
||||||
|
payload.update(
|
||||||
|
{
|
||||||
|
"remote_addr": entry.remote_addr,
|
||||||
|
"changes": get_log_change_rows(entry),
|
||||||
|
"raw_changes": entry.changes or {},
|
||||||
|
"serialized_snapshot": entry.serialized_data,
|
||||||
|
"additional_data": entry.additional_data or {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def parse_filter_datetime(value: str | None, *, is_end: bool = False) -> datetime | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
|
||||||
|
parsed_datetime = parse_datetime(value)
|
||||||
|
if parsed_datetime is not None:
|
||||||
|
if timezone.is_naive(parsed_datetime):
|
||||||
|
return timezone.make_aware(parsed_datetime, timezone.get_current_timezone())
|
||||||
|
return parsed_datetime
|
||||||
|
|
||||||
|
parsed_date = parse_date(value)
|
||||||
|
if parsed_date is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
boundary = time.max if is_end else time.min
|
||||||
|
return timezone.make_aware(
|
||||||
|
datetime.combine(parsed_date, boundary),
|
||||||
|
timezone.get_current_timezone(),
|
||||||
|
)
|
||||||
|
|
||||||
1
apps/logs/tests/__init__.py
Normal file
1
apps/logs/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
181
apps/logs/tests/test_views.py
Normal file
181
apps/logs/tests/test_views.py
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from auditlog.models import LogEntry
|
||||||
|
from rest_framework_simplejwt.tokens import AccessToken
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from apps.reports.models import ReportExportJob
|
||||||
|
from apps.users.models import User
|
||||||
|
from apps.workspaces.models import Workspace, WorkspaceMembership
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def api_client():
|
||||||
|
return APIClient()
|
||||||
|
|
||||||
|
|
||||||
|
def _user(index: int) -> User:
|
||||||
|
return User.objects.create_user(
|
||||||
|
mobile=f"093355500{index:02d}",
|
||||||
|
password="secret123",
|
||||||
|
first_name=f"Log{index}",
|
||||||
|
last_name="User",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def owner(db):
|
||||||
|
return _user(1)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def admin(db):
|
||||||
|
return _user(2)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def member(db):
|
||||||
|
return _user(3)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def outsider(db):
|
||||||
|
return _user(4)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def workspace(owner, admin, member):
|
||||||
|
workspace = Workspace.objects.create(name="Logs WS", description="", owner=owner)
|
||||||
|
WorkspaceMembership.objects.create(
|
||||||
|
workspace=workspace,
|
||||||
|
user=admin,
|
||||||
|
role=WorkspaceMembership.Role.ADMIN,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
WorkspaceMembership.objects.create(
|
||||||
|
workspace=workspace,
|
||||||
|
user=member,
|
||||||
|
role=WorkspaceMembership.Role.MEMBER,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
return workspace
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_headers(user: User) -> dict:
|
||||||
|
token = str(AccessToken.for_user(user))
|
||||||
|
return {"HTTP_AUTHORIZATION": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
def _create_tag(client: APIClient, user: User, workspace: Workspace, *, name="Audit Tag"):
|
||||||
|
return client.post(
|
||||||
|
"/api/tags/",
|
||||||
|
{"workspace_id": str(workspace.id), "name": name, "color": "#123456"},
|
||||||
|
format="json",
|
||||||
|
**_auth_headers(user),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_owner_and_admin_can_list_workspace_logs(api_client, owner, admin, workspace):
|
||||||
|
create_response = _create_tag(api_client, owner, workspace)
|
||||||
|
assert create_response.status_code == 201
|
||||||
|
|
||||||
|
owner_response = api_client.get(
|
||||||
|
f"/api/logs/?workspace={workspace.id}",
|
||||||
|
**_auth_headers(owner),
|
||||||
|
)
|
||||||
|
admin_response = api_client.get(
|
||||||
|
f"/api/logs/?workspace={workspace.id}",
|
||||||
|
**_auth_headers(admin),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert owner_response.status_code == 200
|
||||||
|
assert admin_response.status_code == 200
|
||||||
|
assert owner_response.data["items"][0]["section"] == "tags"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_member_and_non_member_cannot_list_workspace_logs(api_client, owner, member, outsider, workspace):
|
||||||
|
_create_tag(api_client, owner, workspace)
|
||||||
|
|
||||||
|
member_response = api_client.get(
|
||||||
|
f"/api/logs/?workspace={workspace.id}",
|
||||||
|
**_auth_headers(member),
|
||||||
|
)
|
||||||
|
outsider_response = api_client.get(
|
||||||
|
f"/api/logs/?workspace={workspace.id}",
|
||||||
|
**_auth_headers(outsider),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert member_response.status_code == 403
|
||||||
|
assert outsider_response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_jwt_authenticated_writes_capture_actor_and_workspace_metadata(api_client, owner, workspace):
|
||||||
|
response = _create_tag(api_client, owner, workspace, name="JWT Tag")
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
log_entry = LogEntry.objects.filter(content_type__app_label="tags").latest("timestamp")
|
||||||
|
|
||||||
|
assert log_entry.actor_id == owner.id
|
||||||
|
assert log_entry.additional_data["workspace_id"] == str(workspace.id)
|
||||||
|
assert log_entry.additional_data["section"] == "tags"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_logs_support_section_filter_and_detail(api_client, owner, workspace):
|
||||||
|
tag_response = _create_tag(api_client, owner, workspace, name="Filtered Tag")
|
||||||
|
assert tag_response.status_code == 201
|
||||||
|
|
||||||
|
list_response = api_client.get(
|
||||||
|
f"/api/logs/?workspace={workspace.id}§ion=tags",
|
||||||
|
**_auth_headers(owner),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert list_response.status_code == 200
|
||||||
|
assert list_response.data["items"]
|
||||||
|
log_id = list_response.data["items"][0]["id"]
|
||||||
|
|
||||||
|
detail_response = api_client.get(
|
||||||
|
f"/api/logs/{log_id}/",
|
||||||
|
**_auth_headers(owner),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert detail_response.status_code == 200
|
||||||
|
assert detail_response.data["target"]["name"] == "Filtered Tag"
|
||||||
|
assert detail_response.data["changes"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_soft_delete_and_actorless_background_logs_are_filtered(api_client, owner, workspace):
|
||||||
|
create_response = _create_tag(api_client, owner, workspace, name="Delete Me")
|
||||||
|
assert create_response.status_code == 201
|
||||||
|
tag_id = create_response.data["id"]
|
||||||
|
|
||||||
|
delete_response = api_client.delete(
|
||||||
|
f"/api/tags/{tag_id}/",
|
||||||
|
**_auth_headers(owner),
|
||||||
|
)
|
||||||
|
assert delete_response.status_code == 204
|
||||||
|
|
||||||
|
ReportExportJob.objects.create(
|
||||||
|
requesting_user=owner,
|
||||||
|
workspace=workspace,
|
||||||
|
export_type=ReportExportJob.ExportType.PDF,
|
||||||
|
filters={"workspace": str(workspace.id)},
|
||||||
|
status=ReportExportJob.Status.PENDING,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = api_client.get(
|
||||||
|
f"/api/logs/?workspace={workspace.id}&event=delete",
|
||||||
|
**_auth_headers(owner),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert any(item["event"] == "delete" and item["section"] == "tags" for item in response.data["items"])
|
||||||
|
assert all(item["section"] != "report_exports" for item in response.data["items"])
|
||||||
|
|
||||||
@@ -37,10 +37,12 @@ from apps.projects.services.memberships import add_project_member, update_projec
|
|||||||
from apps.workspaces.services import (
|
from apps.workspaces.services import (
|
||||||
PROJECTS_ARCHIVE,
|
PROJECTS_ARCHIVE,
|
||||||
PROJECTS_CREATE,
|
PROJECTS_CREATE,
|
||||||
|
PROJECTS_DELETE,
|
||||||
PROJECTS_EDIT,
|
PROJECTS_EDIT,
|
||||||
PROJECT_MEMBERS_ADD,
|
PROJECT_MEMBERS_ADD,
|
||||||
PROJECT_MEMBERS_CHANGE_ROLE,
|
PROJECT_MEMBERS_CHANGE_ROLE,
|
||||||
PROJECT_MEMBERS_REMOVE,
|
PROJECT_MEMBERS_REMOVE,
|
||||||
|
can_delete_workspace_object,
|
||||||
has_project_capability,
|
has_project_capability,
|
||||||
has_workspace_capability,
|
has_workspace_capability,
|
||||||
)
|
)
|
||||||
@@ -82,8 +84,8 @@ class ProjectViewSet(ModelViewSet):
|
|||||||
return Project.objects.none()
|
return Project.objects.none()
|
||||||
|
|
||||||
return Project.objects.filter(
|
return Project.objects.filter(
|
||||||
memberships__user=self.request.user,
|
workspace__memberships__user=self.request.user,
|
||||||
memberships__is_active=True,
|
workspace__memberships__is_active=True,
|
||||||
is_deleted=False
|
is_deleted=False
|
||||||
).distinct()
|
).distinct()
|
||||||
|
|
||||||
@@ -221,6 +223,11 @@ class ProjectViewSet(ModelViewSet):
|
|||||||
Soft deletes a project.
|
Soft deletes a project.
|
||||||
"""
|
"""
|
||||||
project = self.get_object()
|
project = self.get_object()
|
||||||
|
if not can_delete_workspace_object(request.user, project, PROJECTS_DELETE):
|
||||||
|
return Response(
|
||||||
|
{"detail": "You do not have permission to delete this project."},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
project.is_deleted = True
|
project.is_deleted = True
|
||||||
project.save(update_fields=["is_deleted", "updated_at"])
|
project.save(update_fields=["is_deleted", "updated_at"])
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from django.contrib.auth import get_user_model
|
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.constants import SECTION_PROJECTS, SECTION_PROJECT_MEMBERS
|
||||||
from core.models.base import BaseModel
|
from core.models.base import BaseModel
|
||||||
from apps.workspaces.models import Workspace
|
from apps.workspaces.models import Workspace
|
||||||
|
|
||||||
@@ -47,6 +49,15 @@ class Project(BaseModel):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
def get_additional_data(self):
|
||||||
|
return build_workspace_log_metadata(
|
||||||
|
section=SECTION_PROJECTS,
|
||||||
|
workspace_id=self.workspace_id,
|
||||||
|
target_id=self.id,
|
||||||
|
target_label=self.name,
|
||||||
|
extra={"client_id": str(self.client_id) if self.client_id else None},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ProjectMembership(BaseModel):
|
class ProjectMembership(BaseModel):
|
||||||
|
|
||||||
@@ -92,6 +103,19 @@ class ProjectMembership(BaseModel):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.user} @ {self.project}"
|
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(
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ def create_project(user, workspace, name, client=None, description="", color="")
|
|||||||
name=name,
|
name=name,
|
||||||
client=client,
|
client=client,
|
||||||
description=description,
|
description=description,
|
||||||
color=color
|
color=color,
|
||||||
|
created_by=user,
|
||||||
|
updated_by=user,
|
||||||
)
|
)
|
||||||
|
|
||||||
ProjectMembership.objects.create(
|
ProjectMembership.objects.create(
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ from django.conf import settings
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.logs.services import build_workspace_log_metadata
|
||||||
|
from apps.logs.services.constants import SECTION_REPORT_EXPORTS
|
||||||
from core.models.base import BaseModel
|
from core.models.base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
@@ -81,3 +83,14 @@ class ReportExportJob(BaseModel):
|
|||||||
self.status = self.Status.EXPIRED
|
self.status = self.Status.EXPIRED
|
||||||
self.save(update_fields=["status", "updated_at"])
|
self.save(update_fields=["status", "updated_at"])
|
||||||
|
|
||||||
|
def get_additional_data(self):
|
||||||
|
return build_workspace_log_metadata(
|
||||||
|
section=SECTION_REPORT_EXPORTS,
|
||||||
|
workspace_id=self.workspace_id,
|
||||||
|
target_id=self.id,
|
||||||
|
target_label=f"{self.export_type.upper()} export",
|
||||||
|
extra={
|
||||||
|
"requesting_user_id": str(self.requesting_user_id),
|
||||||
|
"status": self.status,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -418,7 +418,11 @@ def _scope_payload(filters: ReportFilters) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"workspace": {"id": str(filters.workspace.id), "name": filters.workspace.name},
|
"workspace": {
|
||||||
|
"id": str(filters.workspace.id),
|
||||||
|
"name": filters.workspace.name,
|
||||||
|
"thumbnail_path": filters.workspace.thumbnail.path if getattr(filters.workspace, "thumbnail", None) else None,
|
||||||
|
},
|
||||||
"period": filters.period,
|
"period": filters.period,
|
||||||
"from_date": filters.from_date.isoformat(),
|
"from_date": filters.from_date.isoformat(),
|
||||||
"to_date": filters.to_date.isoformat(),
|
"to_date": filters.to_date.isoformat(),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
|
||||||
@@ -89,6 +90,16 @@ PERIOD_LABELS = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CURRENCY_LABELS = {
|
||||||
|
"USD": {"en": "USD", "fa": "دلار آمریکا"},
|
||||||
|
"EUR": {"en": "EUR", "fa": "یورو"},
|
||||||
|
"GBP": {"en": "GBP", "fa": "پوند"},
|
||||||
|
"IRR": {"en": "IRR", "fa": "ریال"},
|
||||||
|
"IRT": {"en": "IRT", "fa": "تومان"},
|
||||||
|
"AED": {"en": "AED", "fa": "درهم"},
|
||||||
|
"TRY": {"en": "TRY", "fa": "لیر"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ExportLocale:
|
class ExportLocale:
|
||||||
@@ -122,14 +133,40 @@ class ExportLocale:
|
|||||||
def format_duration(self, value: str, *, ascii_digits: bool = False) -> str:
|
def format_duration(self, value: str, *, ascii_digits: bool = False) -> str:
|
||||||
return self.format_number(value, ascii_digits=ascii_digits)
|
return self.format_number(value, ascii_digits=ascii_digits)
|
||||||
|
|
||||||
|
def format_amount(self, value: object, *, ascii_digits: bool = False) -> str:
|
||||||
|
raw = str(value).strip()
|
||||||
|
if not raw:
|
||||||
|
return raw
|
||||||
|
try:
|
||||||
|
decimal_value = Decimal(raw)
|
||||||
|
except InvalidOperation:
|
||||||
|
return self.format_number(raw, ascii_digits=ascii_digits)
|
||||||
|
|
||||||
|
sign = "-" if decimal_value < 0 else ""
|
||||||
|
unsigned = abs(decimal_value)
|
||||||
|
normalized = format(unsigned, "f")
|
||||||
|
integer_part, _, fractional_part = normalized.partition(".")
|
||||||
|
grouped_integer = f"{int(integer_part):,}"
|
||||||
|
formatted = f"{sign}{grouped_integer}"
|
||||||
|
if fractional_part:
|
||||||
|
trimmed_fraction = fractional_part.rstrip("0")
|
||||||
|
if trimmed_fraction:
|
||||||
|
formatted = f"{formatted}.{trimmed_fraction}"
|
||||||
|
return self.format_number(formatted, ascii_digits=ascii_digits)
|
||||||
|
|
||||||
def format_money_label(self, income_totals: list[dict], *, ascii_digits: bool = False) -> str:
|
def format_money_label(self, income_totals: list[dict], *, ascii_digits: bool = False) -> str:
|
||||||
if not income_totals:
|
if not income_totals:
|
||||||
return "-"
|
return "-"
|
||||||
parts = []
|
parts = []
|
||||||
for item in income_totals:
|
for item in income_totals:
|
||||||
parts.append(f"{self.format_number(item['amount'], ascii_digits=ascii_digits)} {item['currency']}")
|
currency = self.currency_label(item["currency"])
|
||||||
|
parts.append(f"{self.format_amount(item['amount'], ascii_digits=ascii_digits)} {currency}")
|
||||||
return " | ".join(parts)
|
return " | ".join(parts)
|
||||||
|
|
||||||
|
def currency_label(self, code: str | None) -> str:
|
||||||
|
raw = str(code or "").upper()
|
||||||
|
return CURRENCY_LABELS.get(raw, {}).get(self.language, raw)
|
||||||
|
|
||||||
def shape(self, text: object) -> str:
|
def shape(self, text: object) -> str:
|
||||||
raw = str(text)
|
raw = str(text)
|
||||||
if not any(start <= ord(char) <= end for char in raw for start, end in ARABIC_RANGES):
|
if not any(start <= ord(char) <= end for char in raw for start, end in ARABIC_RANGES):
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import io
|
import io
|
||||||
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from openpyxl import Workbook
|
from openpyxl import Workbook
|
||||||
@@ -12,6 +13,7 @@ from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
|||||||
from reportlab.lib.units import mm
|
from reportlab.lib.units import mm
|
||||||
from reportlab.pdfbase import pdfmetrics
|
from reportlab.pdfbase import pdfmetrics
|
||||||
from reportlab.pdfbase.ttfonts import TTFont
|
from reportlab.pdfbase.ttfonts import TTFont
|
||||||
|
from reportlab.platypus import Image as RLImage
|
||||||
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
|
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
|
||||||
|
|
||||||
from apps.reports.services.export_i18n import ExportLocale, safe_sheet_title, user_label
|
from apps.reports.services.export_i18n import ExportLocale, safe_sheet_title, user_label
|
||||||
@@ -80,19 +82,19 @@ def _append_meta_block(worksheet, *, locale: ExportLocale, report_data: dict) ->
|
|||||||
scope = report_data["scope"]
|
scope = report_data["scope"]
|
||||||
summary = report_data["summary"]
|
summary = report_data["summary"]
|
||||||
|
|
||||||
worksheet.append([locale.t("report_title"), scope["workspace"]["name"]])
|
worksheet.append(_rtl_row(locale, [locale.t("report_title"), scope["workspace"]["name"]]))
|
||||||
worksheet.append([locale.t("workspace"), scope["workspace"]["name"]])
|
worksheet.append(_rtl_row(locale, [locale.t("workspace"), scope["workspace"]["name"]]))
|
||||||
worksheet.append([locale.t("period"), locale.period_label(scope["period"])])
|
worksheet.append(_rtl_row(locale, [locale.t("period"), locale.period_label(scope["period"])]))
|
||||||
worksheet.append([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([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([locale.t("user"), user_label(scope.get("user"), locale, ascii_digits=True)])
|
worksheet.append(_rtl_row(locale, [locale.t("user"), user_label(scope.get("user"), locale, ascii_digits=True)]))
|
||||||
worksheet.append([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")])
|
||||||
worksheet.append([locale.t("total_hours"), locale.format_duration(summary["total_duration"], ascii_digits=True)])
|
worksheet.append(_rtl_row(locale, [locale.t("total_hours"), locale.format_duration(summary["total_duration"], ascii_digits=True)]))
|
||||||
worksheet.append([locale.t("billable_hours"), locale.format_duration(summary["billable_duration"], ascii_digits=True)])
|
worksheet.append(_rtl_row(locale, [locale.t("billable_hours"), locale.format_duration(summary["billable_duration"], ascii_digits=True)]))
|
||||||
worksheet.append([locale.t("non_billable_hours"), locale.format_duration(summary["non_billable_duration"], ascii_digits=True)])
|
worksheet.append(_rtl_row(locale, [locale.t("non_billable_hours"), locale.format_duration(summary["non_billable_duration"], ascii_digits=True)]))
|
||||||
worksheet.append([locale.t("income"), _money_label_excel(locale, summary["income_totals"])])
|
worksheet.append(_rtl_row(locale, [locale.t("income"), _money_label_excel(locale, summary["income_totals"])]))
|
||||||
|
|
||||||
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)
|
||||||
@@ -196,6 +198,9 @@ def _append_breakdown_table(worksheet, *, locale: ExportLocale, title_key: str,
|
|||||||
def _render_excel_sheet(worksheet, *, locale: ExportLocale, report_data: dict) -> None:
|
def _render_excel_sheet(worksheet, *, locale: ExportLocale, report_data: dict) -> None:
|
||||||
if locale.is_rtl:
|
if locale.is_rtl:
|
||||||
worksheet.sheet_view.rightToLeft = True
|
worksheet.sheet_view.rightToLeft = True
|
||||||
|
worksheet.freeze_panes = "E4"
|
||||||
|
else:
|
||||||
|
worksheet.freeze_panes = "A4"
|
||||||
_append_meta_block(worksheet, locale=locale, report_data=report_data)
|
_append_meta_block(worksheet, locale=locale, report_data=report_data)
|
||||||
_append_daily_table(worksheet, locale=locale, report_data=report_data)
|
_append_daily_table(worksheet, locale=locale, report_data=report_data)
|
||||||
_append_breakdown_table(worksheet, locale=locale, title_key="clients", rows=report_data["clients"])
|
_append_breakdown_table(worksheet, locale=locale, title_key="clients", rows=report_data["clients"])
|
||||||
@@ -226,6 +231,11 @@ def _paragraph(text: str, style: ParagraphStyle, locale: ExportLocale) -> Paragr
|
|||||||
return Paragraph(locale.shape(text), style)
|
return Paragraph(locale.shape(text), style)
|
||||||
|
|
||||||
|
|
||||||
|
def _workspace_initial(name: str) -> str:
|
||||||
|
stripped = (name or "").strip()
|
||||||
|
return stripped[0].upper() if stripped else "W"
|
||||||
|
|
||||||
|
|
||||||
def _styled_table(data: list[list[str]], *, locale: ExportLocale, column_widths: list[float]) -> Table:
|
def _styled_table(data: list[list[str]], *, locale: ExportLocale, column_widths: list[float]) -> Table:
|
||||||
shaped_data = [
|
shaped_data = [
|
||||||
[locale.shape(cell) if cell is not None else "" for cell in row]
|
[locale.shape(cell) if cell is not None else "" for cell in row]
|
||||||
@@ -315,10 +325,35 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale) -> bytes:
|
|||||||
|
|
||||||
scope = report_data["scope"]
|
scope = report_data["scope"]
|
||||||
summary = report_data["summary"]
|
summary = report_data["summary"]
|
||||||
story = [
|
workspace_name = scope["workspace"]["name"]
|
||||||
_paragraph(f"{locale.t('report_title')} - {scope['workspace']['name']}", title_style, locale),
|
workspace_thumbnail_path = scope["workspace"].get("thumbnail_path")
|
||||||
Spacer(1, 6 * mm),
|
title_text = f"{locale.t('report_title')} - {workspace_name}"
|
||||||
]
|
|
||||||
|
title_cell = _paragraph(title_text, title_style, locale)
|
||||||
|
badge_cell = _paragraph(_workspace_initial(workspace_name), title_style, locale)
|
||||||
|
if workspace_thumbnail_path and os.path.exists(workspace_thumbnail_path):
|
||||||
|
try:
|
||||||
|
badge_cell = RLImage(workspace_thumbnail_path, width=14 * mm, height=14 * mm)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
badge_cell = _paragraph(_workspace_initial(workspace_name), title_style, locale)
|
||||||
|
|
||||||
|
header_row = [badge_cell, title_cell] if locale.is_rtl else [title_cell, badge_cell]
|
||||||
|
header_col_widths = [doc.width * 0.08, doc.width * 0.92] if locale.is_rtl else [doc.width * 0.92, doc.width * 0.08]
|
||||||
|
header_table = Table([header_row], colWidths=header_col_widths)
|
||||||
|
header_table.setStyle(
|
||||||
|
TableStyle(
|
||||||
|
[
|
||||||
|
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||||
|
("ALIGN", (0, 0), (-1, -1), "RIGHT" if locale.is_rtl else "LEFT"),
|
||||||
|
("LEFTPADDING", (0, 0), (-1, -1), 0),
|
||||||
|
("RIGHTPADDING", (0, 0), (-1, -1), 0),
|
||||||
|
("TOPPADDING", (0, 0), (-1, -1), 0),
|
||||||
|
("BOTTOMPADDING", (0, 0), (-1, -1), 0),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
story = [header_table, Spacer(1, 6 * mm)]
|
||||||
|
|
||||||
meta_rows = [
|
meta_rows = [
|
||||||
[locale.t("workspace"), scope["workspace"]["name"]],
|
[locale.t("workspace"), scope["workspace"]["name"]],
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from apps.workspaces.services import (
|
|||||||
TAGS_DELETE,
|
TAGS_DELETE,
|
||||||
TAGS_EDIT,
|
TAGS_EDIT,
|
||||||
TAGS_VIEW,
|
TAGS_VIEW,
|
||||||
|
can_delete_workspace_object,
|
||||||
has_workspace_capability,
|
has_workspace_capability,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,4 +39,6 @@ class IsTagWorkspaceAllowed(permissions.BasePermission):
|
|||||||
"partial_update": TAGS_EDIT,
|
"partial_update": TAGS_EDIT,
|
||||||
"destroy": TAGS_DELETE,
|
"destroy": TAGS_DELETE,
|
||||||
}.get(view.action, TAGS_VIEW)
|
}.get(view.action, TAGS_VIEW)
|
||||||
|
if view.action == "destroy":
|
||||||
|
return can_delete_workspace_object(request.user, obj, TAGS_DELETE)
|
||||||
return has_workspace_capability(request.user, obj.workspace, capability)
|
return has_workspace_capability(request.user, obj.workspace, capability)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
|
from apps.logs.services import build_workspace_log_metadata
|
||||||
|
from apps.logs.services.constants import SECTION_TAGS
|
||||||
from core.models.base import BaseModel
|
from core.models.base import BaseModel
|
||||||
from apps.workspaces.models import Workspace
|
from apps.workspaces.models import Workspace
|
||||||
|
|
||||||
@@ -32,3 +34,11 @@ class Tag(BaseModel):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
def get_additional_data(self):
|
||||||
|
return build_workspace_log_metadata(
|
||||||
|
section=SECTION_TAGS,
|
||||||
|
workspace_id=self.workspace_id,
|
||||||
|
target_id=self.id,
|
||||||
|
target_label=self.name,
|
||||||
|
)
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ def create_tag(user, workspace_id, name, color=""):
|
|||||||
return Tag.objects.create(
|
return Tag.objects.create(
|
||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
name=name,
|
name=name,
|
||||||
color=color
|
color=color,
|
||||||
|
created_by=user,
|
||||||
|
updated_by=user,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,24 +6,79 @@ from apps.projects.models import Project
|
|||||||
from apps.tags.models import Tag
|
from apps.tags.models import Tag
|
||||||
|
|
||||||
|
|
||||||
|
class TimeEntryProjectDetailsSerializer(serializers.Serializer):
|
||||||
|
id = serializers.UUIDField(read_only=True)
|
||||||
|
name = serializers.CharField(read_only=True)
|
||||||
|
is_deleted = serializers.BooleanField(read_only=True)
|
||||||
|
client_name = serializers.CharField(read_only=True, allow_null=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TimeEntryTagDetailsSerializer(serializers.Serializer):
|
||||||
|
id = serializers.UUIDField(read_only=True)
|
||||||
|
name = serializers.CharField(read_only=True)
|
||||||
|
color = serializers.CharField(read_only=True)
|
||||||
|
is_deleted = serializers.BooleanField(read_only=True)
|
||||||
|
|
||||||
|
|
||||||
class TimeEntrySerializer(BaseModelSerializer):
|
class TimeEntrySerializer(BaseModelSerializer):
|
||||||
"""
|
"""
|
||||||
Output serializer for TimeEntry.
|
Output serializer for TimeEntry.
|
||||||
"""
|
"""
|
||||||
|
project = serializers.UUIDField(source="project_id", allow_null=True, read_only=True)
|
||||||
|
tags = serializers.SerializerMethodField()
|
||||||
|
project_details = serializers.SerializerMethodField()
|
||||||
|
tag_details = serializers.SerializerMethodField()
|
||||||
start_time = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S")
|
start_time = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S")
|
||||||
end_time = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", allow_null=True)
|
end_time = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", allow_null=True)
|
||||||
|
|
||||||
|
def get_tags(self, obj):
|
||||||
|
return [str(tag.id) for tag in Tag.all_objects.filter(time_entries=obj).order_by("-updated_at", "-created_at")]
|
||||||
|
|
||||||
|
def get_project_details(self, obj):
|
||||||
|
if not obj.project_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
project = Project.all_objects.select_related("client").filter(id=obj.project_id).first()
|
||||||
|
if not project:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return TimeEntryProjectDetailsSerializer(
|
||||||
|
{
|
||||||
|
"id": project.id,
|
||||||
|
"name": project.name,
|
||||||
|
"is_deleted": project.is_deleted,
|
||||||
|
"client_name": project.client.name if project.client else None,
|
||||||
|
}
|
||||||
|
).data
|
||||||
|
|
||||||
|
def get_tag_details(self, obj):
|
||||||
|
tags = Tag.all_objects.filter(time_entries=obj).order_by("-updated_at", "-created_at")
|
||||||
|
return TimeEntryTagDetailsSerializer(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": tag.id,
|
||||||
|
"name": tag.name,
|
||||||
|
"color": tag.color,
|
||||||
|
"is_deleted": tag.is_deleted,
|
||||||
|
}
|
||||||
|
for tag in tags
|
||||||
|
],
|
||||||
|
many=True,
|
||||||
|
).data
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = TimeEntry
|
model = TimeEntry
|
||||||
fields = BaseModelSerializer.Meta.fields + (
|
fields = BaseModelSerializer.Meta.fields + (
|
||||||
"workspace",
|
"workspace",
|
||||||
"user",
|
"user",
|
||||||
"project",
|
"project",
|
||||||
|
"project_details",
|
||||||
"description",
|
"description",
|
||||||
"start_time",
|
"start_time",
|
||||||
"end_time",
|
"end_time",
|
||||||
"duration",
|
"duration",
|
||||||
"tags",
|
"tags",
|
||||||
|
"tag_details",
|
||||||
"is_billable",
|
"is_billable",
|
||||||
"hourly_rate",
|
"hourly_rate",
|
||||||
"currency",
|
"currency",
|
||||||
@@ -36,43 +91,83 @@ class TimeEntryCreateSerializer(serializers.Serializer):
|
|||||||
Validates input data for creating/starting a time entry.
|
Validates input data for creating/starting a time entry.
|
||||||
"""
|
"""
|
||||||
workspace_id = serializers.UUIDField()
|
workspace_id = serializers.UUIDField()
|
||||||
project_id = serializers.PrimaryKeyRelatedField(
|
project_id = serializers.UUIDField(required=False, allow_null=True)
|
||||||
queryset=Project.objects.filter(is_deleted=False),
|
|
||||||
required=False,
|
|
||||||
allow_null=True,
|
|
||||||
source='project'
|
|
||||||
)
|
|
||||||
start_time = serializers.DateTimeField()
|
start_time = serializers.DateTimeField()
|
||||||
end_time = serializers.DateTimeField(required=False, allow_null=True)
|
end_time = serializers.DateTimeField(required=False, allow_null=True)
|
||||||
description = serializers.CharField(required=False, allow_blank=True, default="")
|
description = serializers.CharField(required=False, allow_blank=True, default="")
|
||||||
tags = serializers.PrimaryKeyRelatedField(
|
tags = serializers.ListField(child=serializers.UUIDField(), required=False)
|
||||||
queryset=Tag.objects.filter(is_deleted=False),
|
|
||||||
many=True,
|
|
||||||
required=False
|
|
||||||
)
|
|
||||||
is_billable = serializers.BooleanField(default=False)
|
is_billable = serializers.BooleanField(default=False)
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
project_id = attrs.pop("project_id", serializers.empty)
|
||||||
|
if project_id is not serializers.empty:
|
||||||
|
if project_id is None:
|
||||||
|
attrs["project"] = None
|
||||||
|
else:
|
||||||
|
project = Project.objects.filter(id=project_id).first()
|
||||||
|
if not project:
|
||||||
|
raise serializers.ValidationError({"project_id": "Selected project is unavailable."})
|
||||||
|
attrs["project"] = project
|
||||||
|
|
||||||
|
tag_ids = attrs.pop("tags", serializers.empty)
|
||||||
|
if tag_ids is not serializers.empty:
|
||||||
|
active_tags = list(Tag.objects.filter(id__in=tag_ids))
|
||||||
|
active_tag_ids = {str(tag.id) for tag in active_tags}
|
||||||
|
missing_ids = [str(tag_id) for tag_id in tag_ids if str(tag_id) not in active_tag_ids]
|
||||||
|
if missing_ids:
|
||||||
|
raise serializers.ValidationError({"tags": "One or more selected tags are unavailable."})
|
||||||
|
attrs["tags"] = active_tags
|
||||||
|
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
class TimeEntryUpdateSerializer(serializers.Serializer):
|
class TimeEntryUpdateSerializer(serializers.Serializer):
|
||||||
"""
|
"""
|
||||||
Validates input data for updating an existing time entry.
|
Validates input data for updating an existing time entry.
|
||||||
"""
|
"""
|
||||||
project_id = serializers.PrimaryKeyRelatedField(
|
project_id = serializers.UUIDField(required=False, allow_null=True)
|
||||||
queryset=Project.objects.filter(is_deleted=False),
|
|
||||||
required=False,
|
|
||||||
allow_null=True,
|
|
||||||
source='project'
|
|
||||||
)
|
|
||||||
start_time = serializers.DateTimeField(required=False)
|
start_time = serializers.DateTimeField(required=False)
|
||||||
end_time = serializers.DateTimeField(required=False, allow_null=True)
|
end_time = serializers.DateTimeField(required=False, allow_null=True)
|
||||||
description = serializers.CharField(required=False, allow_blank=True)
|
description = serializers.CharField(required=False, allow_blank=True)
|
||||||
tags = serializers.PrimaryKeyRelatedField(
|
tags = serializers.ListField(child=serializers.UUIDField(), required=False)
|
||||||
queryset=Tag.objects.filter(is_deleted=False),
|
|
||||||
many=True,
|
|
||||||
required=False
|
|
||||||
)
|
|
||||||
is_billable = serializers.BooleanField(required=False)
|
is_billable = serializers.BooleanField(required=False)
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
entry = self.instance
|
||||||
|
|
||||||
|
project_id = attrs.pop("project_id", serializers.empty)
|
||||||
|
if project_id is not serializers.empty:
|
||||||
|
current_project = Project.all_objects.filter(id=entry.project_id).first() if entry and entry.project_id else None
|
||||||
|
if project_id is None:
|
||||||
|
attrs["project"] = None
|
||||||
|
elif current_project and str(current_project.id) == str(project_id):
|
||||||
|
attrs["project"] = current_project
|
||||||
|
else:
|
||||||
|
project = Project.objects.filter(id=project_id).first()
|
||||||
|
if not project:
|
||||||
|
raise serializers.ValidationError({"project_id": "Selected project is unavailable."})
|
||||||
|
attrs["project"] = project
|
||||||
|
|
||||||
|
tag_ids = attrs.pop("tags", serializers.empty)
|
||||||
|
if tag_ids is not serializers.empty:
|
||||||
|
active_tags = list(Tag.objects.filter(id__in=tag_ids))
|
||||||
|
active_tag_ids = {str(tag.id) for tag in active_tags}
|
||||||
|
current_tag_ids = {
|
||||||
|
str(tag_id)
|
||||||
|
for tag_id in Tag.all_objects.filter(time_entries=entry).values_list("id", flat=True)
|
||||||
|
} if entry else set()
|
||||||
|
requested_tag_ids = [str(tag_id) for tag_id in tag_ids]
|
||||||
|
missing_ids = [tag_id for tag_id in requested_tag_ids if tag_id not in active_tag_ids]
|
||||||
|
|
||||||
|
if missing_ids:
|
||||||
|
if not set(missing_ids).issubset(current_tag_ids):
|
||||||
|
raise serializers.ValidationError({"tags": "One or more selected tags are unavailable."})
|
||||||
|
attrs["tags"] = list(Tag.all_objects.filter(id__in=tag_ids))
|
||||||
|
else:
|
||||||
|
attrs["tags"] = active_tags
|
||||||
|
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
class TimeEntryStopSerializer(serializers.Serializer):
|
class TimeEntryStopSerializer(serializers.Serializer):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ class TimeEntryViewSet(ModelViewSet):
|
|||||||
**serializer.validated_data
|
**serializer.validated_data
|
||||||
)
|
)
|
||||||
|
|
||||||
output_serializer = TimeEntrySerializer(entry)
|
output_serializer = TimeEntrySerializer(entry, context=self.get_serializer_context())
|
||||||
return Response(output_serializer.data, status=status.HTTP_201_CREATED)
|
return Response(output_serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
def update(self, request, *args, **kwargs):
|
def update(self, request, *args, **kwargs):
|
||||||
@@ -160,7 +160,7 @@ class TimeEntryViewSet(ModelViewSet):
|
|||||||
status=status.HTTP_403_FORBIDDEN,
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
)
|
)
|
||||||
|
|
||||||
serializer = self.get_serializer(data=request.data, partial=partial)
|
serializer = self.get_serializer(entry, data=request.data, partial=partial)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
updated_entry = update_time_entry(
|
updated_entry = update_time_entry(
|
||||||
@@ -168,7 +168,7 @@ class TimeEntryViewSet(ModelViewSet):
|
|||||||
**serializer.validated_data
|
**serializer.validated_data
|
||||||
)
|
)
|
||||||
|
|
||||||
output_serializer = TimeEntrySerializer(updated_entry)
|
output_serializer = TimeEntrySerializer(updated_entry, context=self.get_serializer_context())
|
||||||
return Response(output_serializer.data, status=status.HTTP_200_OK)
|
return Response(output_serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
@action(detail=True, methods=["post"])
|
@action(detail=True, methods=["post"])
|
||||||
@@ -189,7 +189,7 @@ class TimeEntryViewSet(ModelViewSet):
|
|||||||
end_time = serializer.validated_data.get("end_time")
|
end_time = serializer.validated_data.get("end_time")
|
||||||
stopped_entry = stop_time_entry(entry, end_time=end_time)
|
stopped_entry = stop_time_entry(entry, end_time=end_time)
|
||||||
|
|
||||||
output_serializer = TimeEntrySerializer(stopped_entry)
|
output_serializer = TimeEntrySerializer(stopped_entry, context=self.get_serializer_context())
|
||||||
return Response(output_serializer.data, status=status.HTTP_200_OK)
|
return Response(output_serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
def destroy(self, request, *args, **kwargs):
|
def destroy(self, request, *args, **kwargs):
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ from django.conf import settings
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
|
||||||
|
from apps.logs.services import build_workspace_log_metadata
|
||||||
|
from apps.logs.services.constants import SECTION_TIME_ENTRIES
|
||||||
from core.models.base import BaseModel
|
from core.models.base import BaseModel
|
||||||
from apps.workspaces.models import Workspace
|
from apps.workspaces.models import Workspace
|
||||||
from apps.projects.models import Project
|
from apps.projects.models import Project
|
||||||
@@ -72,6 +74,16 @@ class TimeEntry(BaseModel):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.user} - {self.start_time}"
|
return f"{self.user} - {self.start_time}"
|
||||||
|
|
||||||
|
def get_additional_data(self):
|
||||||
|
target_label = self.description.strip() if self.description else f"Time entry {self.start_time.isoformat()}"
|
||||||
|
return build_workspace_log_metadata(
|
||||||
|
section=SECTION_TIME_ENTRIES,
|
||||||
|
workspace_id=self.workspace_id,
|
||||||
|
target_id=self.id,
|
||||||
|
target_label=target_label,
|
||||||
|
extra={"entry_user_id": str(self.user_id)},
|
||||||
|
)
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
if self.project and self.project.workspace_id != self.workspace_id:
|
if self.project and self.project.workspace_id != self.workspace_id:
|
||||||
raise ValidationError("Project must belong to the same workspace.")
|
raise ValidationError("Project must belong to the same workspace.")
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ from django.utils import timezone
|
|||||||
|
|
||||||
from apps.time_entries.api.serializers import TimeEntrySerializer
|
from apps.time_entries.api.serializers import TimeEntrySerializer
|
||||||
from apps.time_entries.models import TimeEntry
|
from apps.time_entries.models import TimeEntry
|
||||||
|
from apps.projects.models import Project
|
||||||
|
from apps.tags.models import Tag
|
||||||
from apps.users.models import User
|
from apps.users.models import User
|
||||||
from apps.workspaces.models import Workspace
|
from apps.workspaces.models import Workspace
|
||||||
|
|
||||||
@@ -27,3 +29,31 @@ def test_time_entry_serializer_keeps_seconds(db):
|
|||||||
|
|
||||||
assert data["start_time"] == start_time.strftime("%Y-%m-%d %H:%M:%S")
|
assert data["start_time"] == start_time.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
assert data["end_time"] == end_time.strftime("%Y-%m-%d %H:%M:%S")
|
assert data["end_time"] == end_time.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
|
||||||
|
def test_time_entry_serializer_includes_deleted_project_and_tags(db):
|
||||||
|
user = User.objects.create_user(mobile="09124444444", password="secret123")
|
||||||
|
workspace = Workspace.objects.create(name="Core", owner=user)
|
||||||
|
project = Project.objects.create(workspace=workspace, name="Legacy Project")
|
||||||
|
tag = Tag.objects.create(workspace=workspace, name="Legacy Tag", color="#334155")
|
||||||
|
project.delete()
|
||||||
|
tag.delete()
|
||||||
|
|
||||||
|
entry = TimeEntry.objects.create(
|
||||||
|
workspace=workspace,
|
||||||
|
user=user,
|
||||||
|
project=Project.all_objects.get(id=project.id),
|
||||||
|
description="Historical work",
|
||||||
|
start_time=timezone.now(),
|
||||||
|
end_time=timezone.now(),
|
||||||
|
)
|
||||||
|
entry.tags.set([Tag.all_objects.get(id=tag.id)])
|
||||||
|
|
||||||
|
data = TimeEntrySerializer(entry).data
|
||||||
|
|
||||||
|
assert data["project"] == str(project.id)
|
||||||
|
assert data["project_details"]["name"] == "Legacy Project"
|
||||||
|
assert data["project_details"]["is_deleted"] is True
|
||||||
|
assert data["tags"] == [str(tag.id)]
|
||||||
|
assert data["tag_details"][0]["name"] == "Legacy Tag"
|
||||||
|
assert data["tag_details"][0]["is_deleted"] is True
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import pytest
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
|
|
||||||
from apps.time_entries.services.time_entries import create_time_entry, stop_time_entry
|
from apps.projects.models import Project
|
||||||
|
from apps.tags.models import Tag
|
||||||
|
from apps.time_entries.services.time_entries import create_time_entry, stop_time_entry, update_time_entry
|
||||||
from apps.users.models import User
|
from apps.users.models import User
|
||||||
from apps.workspaces.models import Workspace
|
from apps.workspaces.models import Workspace
|
||||||
|
|
||||||
@@ -45,3 +47,32 @@ def test_stop_time_entry_sets_end_time_and_duration(workspace_owner):
|
|||||||
|
|
||||||
assert stopped_entry.end_time is not None
|
assert stopped_entry.end_time is not None
|
||||||
assert stopped_entry.duration is not None
|
assert stopped_entry.duration is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_time_entry_preserves_deleted_project_and_tags(workspace_owner):
|
||||||
|
user, workspace = workspace_owner
|
||||||
|
project = Project.objects.create(workspace=workspace, name="Deleted project")
|
||||||
|
tag = Tag.objects.create(workspace=workspace, name="Deleted tag", color="#0f172a")
|
||||||
|
entry = create_time_entry(
|
||||||
|
user=user,
|
||||||
|
workspace_id=workspace.id,
|
||||||
|
start_time=timezone.now() - timedelta(hours=1),
|
||||||
|
end_time=timezone.now(),
|
||||||
|
project=project,
|
||||||
|
tags=[tag],
|
||||||
|
description="Before delete",
|
||||||
|
)
|
||||||
|
|
||||||
|
project.delete()
|
||||||
|
tag.delete()
|
||||||
|
|
||||||
|
updated_entry = update_time_entry(
|
||||||
|
entry,
|
||||||
|
project=Project.all_objects.get(id=project.id),
|
||||||
|
tags=[Tag.all_objects.get(id=tag.id)],
|
||||||
|
description="After delete",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert updated_entry.description == "After delete"
|
||||||
|
assert updated_entry.project_id == project.id
|
||||||
|
assert list(Tag.all_objects.filter(time_entries=updated_entry).values_list("id", flat=True)) == [tag.id]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from datetime import datetime
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
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
|
||||||
@@ -49,3 +50,91 @@ def test_time_entry_list_returns_grouped_payload_for_ended_entries(db):
|
|||||||
assert len(response.data["groups"]) == 1
|
assert len(response.data["groups"]) == 1
|
||||||
assert len(response.data["groups"][0]["days"]) == 1
|
assert len(response.data["groups"][0]["days"]) == 1
|
||||||
assert response.data["groups"][0]["days"][0]["entries"][0]["id"] == str(first_entry.id)
|
assert response.data["groups"][0]["days"][0]["entries"][0]["id"] == str(first_entry.id)
|
||||||
|
|
||||||
|
|
||||||
|
def test_time_entry_update_preserves_current_deleted_tags(db):
|
||||||
|
user = User.objects.create_user(mobile="09127777777", password="secret123")
|
||||||
|
workspace = Workspace.objects.create(name="Core", owner=user)
|
||||||
|
tag = Tag.objects.create(workspace=workspace, name="Legacy Tag", color="#475569")
|
||||||
|
entry = TimeEntry.objects.create(
|
||||||
|
workspace=workspace,
|
||||||
|
user=user,
|
||||||
|
description="Old",
|
||||||
|
start_time=make_aware(2026, 4, 24, 9, 0, 0),
|
||||||
|
end_time=make_aware(2026, 4, 24, 10, 30, 0),
|
||||||
|
)
|
||||||
|
entry.tags.set([tag])
|
||||||
|
tag.delete()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_authenticate(user=user)
|
||||||
|
|
||||||
|
response = client.patch(
|
||||||
|
f"/api/time-entries/{entry.id}/",
|
||||||
|
{
|
||||||
|
"description": "Still editable",
|
||||||
|
"tags": [str(tag.id)],
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data["description"] == "Still editable"
|
||||||
|
assert response.data["tag_details"][0]["is_deleted"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_time_entry_update_rejects_new_deleted_tag_attachment(db):
|
||||||
|
user = User.objects.create_user(mobile="09128888888", password="secret123")
|
||||||
|
workspace = Workspace.objects.create(name="Core", owner=user)
|
||||||
|
deleted_tag = Tag.objects.create(workspace=workspace, name="Deleted tag", color="#475569")
|
||||||
|
deleted_tag.delete()
|
||||||
|
entry = TimeEntry.objects.create(
|
||||||
|
workspace=workspace,
|
||||||
|
user=user,
|
||||||
|
description="Entry",
|
||||||
|
start_time=make_aware(2026, 4, 24, 9, 0, 0),
|
||||||
|
end_time=make_aware(2026, 4, 24, 10, 30, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_authenticate(user=user)
|
||||||
|
|
||||||
|
response = client.patch(
|
||||||
|
f"/api/time-entries/{entry.id}/",
|
||||||
|
{
|
||||||
|
"tags": [str(deleted_tag.id)],
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "unavailable" in response.data["error"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_time_entry_update_can_remove_current_deleted_tag(db):
|
||||||
|
user = User.objects.create_user(mobile="09129999999", password="secret123")
|
||||||
|
workspace = Workspace.objects.create(name="Core", owner=user)
|
||||||
|
deleted_tag = Tag.objects.create(workspace=workspace, name="Deleted tag", color="#475569")
|
||||||
|
entry = TimeEntry.objects.create(
|
||||||
|
workspace=workspace,
|
||||||
|
user=user,
|
||||||
|
description="Entry",
|
||||||
|
start_time=make_aware(2026, 4, 24, 9, 0, 0),
|
||||||
|
end_time=make_aware(2026, 4, 24, 10, 30, 0),
|
||||||
|
)
|
||||||
|
entry.tags.set([deleted_tag])
|
||||||
|
deleted_tag.delete()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_authenticate(user=user)
|
||||||
|
|
||||||
|
response = client.patch(
|
||||||
|
f"/api/time-entries/{entry.id}/",
|
||||||
|
{
|
||||||
|
"tags": [],
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data["tags"] == []
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
import json
|
||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from apps.notifications.services import notify_workspace_membership_added
|
from apps.notifications.services import notify_workspace_membership_added
|
||||||
from apps.users.models import User
|
from apps.users.models import User
|
||||||
|
from apps.workspaces.services import WORKSPACE_MEMBERS_VIEW, has_workspace_capability
|
||||||
from core.serializers.base import BaseModelSerializer
|
from core.serializers.base import BaseModelSerializer
|
||||||
from apps.workspaces.models import PriceUnit, Workspace, WorkspaceMembership, WorkspaceUserRate
|
from apps.workspaces.models import PriceUnit, Workspace, WorkspaceMembership, WorkspaceUserRate
|
||||||
from core.serializers.mini import UserMiniSerializer
|
from core.serializers.mini import UserMiniSerializer
|
||||||
@@ -15,7 +17,8 @@ class WorkspaceMemberInputSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
|
|
||||||
class WorkspaceSerializer(BaseModelSerializer):
|
class WorkspaceSerializer(BaseModelSerializer):
|
||||||
members = WorkspaceMemberInputSerializer(many=True, write_only=True, required=False)
|
members = serializers.JSONField(write_only=True, required=False)
|
||||||
|
clear_thumbnail = serializers.BooleanField(write_only=True, required=False, default=False)
|
||||||
my_role = serializers.SerializerMethodField()
|
my_role = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -23,6 +26,8 @@ class WorkspaceSerializer(BaseModelSerializer):
|
|||||||
fields = BaseModelSerializer.Meta.fields + (
|
fields = BaseModelSerializer.Meta.fields + (
|
||||||
"name",
|
"name",
|
||||||
"description",
|
"description",
|
||||||
|
"thumbnail",
|
||||||
|
"clear_thumbnail",
|
||||||
"owner",
|
"owner",
|
||||||
"my_role",
|
"my_role",
|
||||||
"members",
|
"members",
|
||||||
@@ -38,8 +43,41 @@ class WorkspaceSerializer(BaseModelSerializer):
|
|||||||
).first()
|
).first()
|
||||||
return getattr(membership, "role", None)
|
return getattr(membership, "role", None)
|
||||||
|
|
||||||
|
def validate_thumbnail(self, value):
|
||||||
|
if value is None:
|
||||||
|
return value
|
||||||
|
max_bytes = 2 * 1024 * 1024
|
||||||
|
if getattr(value, "size", 0) > max_bytes:
|
||||||
|
raise serializers.ValidationError("Image size must be 2MB or less.")
|
||||||
|
content_type = (getattr(value, "content_type", "") or "").lower()
|
||||||
|
allowed_types = {"image/jpeg", "image/png", "image/webp"}
|
||||||
|
if content_type and content_type not in allowed_types:
|
||||||
|
raise serializers.ValidationError("Unsupported image type. Use JPG, PNG, or WebP.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def to_representation(self, instance):
|
||||||
|
data = super().to_representation(instance)
|
||||||
|
request = self.context.get("request")
|
||||||
|
if instance.thumbnail:
|
||||||
|
thumbnail_url = instance.thumbnail.url
|
||||||
|
data["thumbnail"] = request.build_absolute_uri(thumbnail_url) if request else thumbnail_url
|
||||||
|
else:
|
||||||
|
data["thumbnail"] = None
|
||||||
|
data.pop("clear_thumbnail", None)
|
||||||
|
return data
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
members_data = validated_data.pop('members', [])
|
members_data = validated_data.pop("members", [])
|
||||||
|
if isinstance(members_data, str):
|
||||||
|
try:
|
||||||
|
members_data = json.loads(members_data)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise serializers.ValidationError({"members": "Invalid members format."}) from exc
|
||||||
|
if members_data:
|
||||||
|
members_serializer = WorkspaceMemberInputSerializer(data=members_data, many=True)
|
||||||
|
members_serializer.is_valid(raise_exception=True)
|
||||||
|
members_data = members_serializer.validated_data
|
||||||
|
validated_data.pop("clear_thumbnail", None)
|
||||||
|
|
||||||
workspace = super().create(validated_data)
|
workspace = super().create(validated_data)
|
||||||
|
|
||||||
@@ -74,8 +112,27 @@ class WorkspaceSerializer(BaseModelSerializer):
|
|||||||
|
|
||||||
return workspace
|
return workspace
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
clear_thumbnail = validated_data.pop("clear_thumbnail", False)
|
||||||
|
old_thumbnail_name = instance.thumbnail.name if instance.thumbnail else None
|
||||||
|
|
||||||
|
if clear_thumbnail and instance.thumbnail:
|
||||||
|
instance.thumbnail.delete(save=False)
|
||||||
|
instance.thumbnail = None
|
||||||
|
|
||||||
|
updated_workspace = super().update(instance, validated_data)
|
||||||
|
|
||||||
|
if old_thumbnail_name and updated_workspace.thumbnail and updated_workspace.thumbnail.name != old_thumbnail_name:
|
||||||
|
storage = updated_workspace.thumbnail.storage
|
||||||
|
if storage.exists(old_thumbnail_name):
|
||||||
|
storage.delete(old_thumbnail_name)
|
||||||
|
|
||||||
|
return updated_workspace
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceMembershipSerializer(BaseModelSerializer):
|
class WorkspaceMembershipSerializer(BaseModelSerializer):
|
||||||
|
user = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = WorkspaceMembership
|
model = WorkspaceMembership
|
||||||
fields = BaseModelSerializer.Meta.fields + (
|
fields = BaseModelSerializer.Meta.fields + (
|
||||||
@@ -85,13 +142,25 @@ class WorkspaceMembershipSerializer(BaseModelSerializer):
|
|||||||
"is_active",
|
"is_active",
|
||||||
)
|
)
|
||||||
|
|
||||||
def to_representation(self, instance):
|
def get_user(self, instance):
|
||||||
data = super().to_representation(instance)
|
request = self.context.get("request")
|
||||||
data["user"] = UserMiniSerializer(
|
viewer = getattr(request, "user", None)
|
||||||
instance.user,
|
can_view_sensitive_details = bool(
|
||||||
context=self.context
|
viewer
|
||||||
).data
|
and viewer.is_authenticated
|
||||||
return data
|
and has_workspace_capability(viewer, instance.workspace, WORKSPACE_MEMBERS_VIEW)
|
||||||
|
)
|
||||||
|
|
||||||
|
user_data = UserMiniSerializer(instance.user, context=self.context).data
|
||||||
|
if can_view_sensitive_details:
|
||||||
|
return user_data
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": user_data["id"],
|
||||||
|
"first_name": user_data.get("first_name"),
|
||||||
|
"last_name": user_data.get("last_name"),
|
||||||
|
"profile_picture": user_data.get("profile_picture"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class PriceUnitSerializer(BaseModelSerializer):
|
class PriceUnitSerializer(BaseModelSerializer):
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from rest_framework.filters import OrderingFilter, SearchFilter
|
|||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import ModelViewSet
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.parsers import FormParser, MultiPartParser, JSONParser
|
||||||
|
|
||||||
from apps.notifications.services import (
|
from apps.notifications.services import (
|
||||||
notify_workspace_membership_added,
|
notify_workspace_membership_added,
|
||||||
@@ -33,6 +34,7 @@ from apps.workspaces.models import PriceUnit, Workspace, WorkspaceMembership, Wo
|
|||||||
from apps.workspaces.services import (
|
from apps.workspaces.services import (
|
||||||
WORKSPACE_MEMBERS_VIEW,
|
WORKSPACE_MEMBERS_VIEW,
|
||||||
WORKSPACE_EDIT,
|
WORKSPACE_EDIT,
|
||||||
|
WORKSPACE_VIEW,
|
||||||
can_assign_workspace_role,
|
can_assign_workspace_role,
|
||||||
can_change_workspace_membership,
|
can_change_workspace_membership,
|
||||||
has_workspace_capability,
|
has_workspace_capability,
|
||||||
@@ -44,6 +46,7 @@ from core.paginations.limit_offset import CustomLimitOffsetPagination
|
|||||||
|
|
||||||
class WorkspaceViewSet(ModelViewSet):
|
class WorkspaceViewSet(ModelViewSet):
|
||||||
serializer_class = WorkspaceSerializer
|
serializer_class = WorkspaceSerializer
|
||||||
|
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||||
pagination_class = CustomLimitOffsetPagination
|
pagination_class = CustomLimitOffsetPagination
|
||||||
filter_backends = (DjangoFilterBackend, OrderingFilter, SearchFilter)
|
filter_backends = (DjangoFilterBackend, OrderingFilter, SearchFilter)
|
||||||
filterset_class = WorkspaceFilter
|
filterset_class = WorkspaceFilter
|
||||||
@@ -102,7 +105,9 @@ class WorkspaceMembershipViewSet(ModelViewSet):
|
|||||||
).distinct()
|
).distinct()
|
||||||
|
|
||||||
def get_permissions(self):
|
def get_permissions(self):
|
||||||
if self.action in ["list", "retrieve", "create", "update", "partial_update"]:
|
if self.action in ["list", "retrieve"]:
|
||||||
|
return [IsAuthenticated()]
|
||||||
|
if self.action in ["create", "update", "partial_update"]:
|
||||||
return [IsAuthenticated(), CanWorkspaceManageMembers()]
|
return [IsAuthenticated(), CanWorkspaceManageMembers()]
|
||||||
if self.action in ["destroy"]:
|
if self.action in ["destroy"]:
|
||||||
return [IsAuthenticated(), CanWorkspaceManageMembers()]
|
return [IsAuthenticated(), CanWorkspaceManageMembers()]
|
||||||
@@ -118,7 +123,7 @@ class WorkspaceMembershipViewSet(ModelViewSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
workspace = get_object_or_404(Workspace, id=workspace_id, is_deleted=False)
|
workspace = get_object_or_404(Workspace, id=workspace_id, is_deleted=False)
|
||||||
if not has_workspace_capability(request.user, workspace, WORKSPACE_MEMBERS_VIEW):
|
if not has_workspace_capability(request.user, workspace, WORKSPACE_VIEW):
|
||||||
return Response(
|
return Response(
|
||||||
{"detail": "You do not have permission to view workspace members."},
|
{"detail": "You do not have permission to view workspace members."},
|
||||||
status=status.HTTP_403_FORBIDDEN,
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
|||||||
17
apps/workspaces/migrations/0006_workspace_thumbnail.py
Normal file
17
apps/workspaces/migrations/0006_workspace_thumbnail.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("workspaces", "0005_remove_priceunit_priceunit_id_idx_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="workspace",
|
||||||
|
name="thumbnail",
|
||||||
|
field=models.ImageField(blank=True, null=True, upload_to="profile/workspaces/"),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
from django.contrib.auth import get_user_model
|
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.constants import (
|
||||||
|
SECTION_RATES,
|
||||||
|
SECTION_WORKSPACE,
|
||||||
|
SECTION_WORKSPACE_MEMBERS,
|
||||||
|
)
|
||||||
from core.models.base import BaseModel
|
from core.models.base import BaseModel
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
@@ -9,6 +15,7 @@ User = get_user_model()
|
|||||||
class Workspace(BaseModel):
|
class Workspace(BaseModel):
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
description = models.TextField(blank=True)
|
description = models.TextField(blank=True)
|
||||||
|
thumbnail = models.ImageField(upload_to="profile/workspaces/", blank=True, null=True)
|
||||||
owner = models.ForeignKey(
|
owner = models.ForeignKey(
|
||||||
User,
|
User,
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
@@ -25,6 +32,15 @@ class Workspace(BaseModel):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
def get_additional_data(self):
|
||||||
|
return build_workspace_log_metadata(
|
||||||
|
section=SECTION_WORKSPACE,
|
||||||
|
workspace_id=self.id,
|
||||||
|
target_id=self.id,
|
||||||
|
target_label=self.name,
|
||||||
|
extra={"owner_id": str(self.owner_id)},
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def members(self):
|
def members(self):
|
||||||
return User.objects.filter(
|
return User.objects.filter(
|
||||||
@@ -76,6 +92,21 @@ class WorkspaceMembership(BaseModel):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.user} @ {self.workspace}"
|
return f"{self.user} @ {self.workspace}"
|
||||||
|
|
||||||
|
def get_additional_data(self):
|
||||||
|
return build_workspace_log_metadata(
|
||||||
|
section=SECTION_WORKSPACE_MEMBERS,
|
||||||
|
workspace_id=self.workspace_id,
|
||||||
|
target_id=self.id,
|
||||||
|
target_label=self.user.full_name or self.user.mobile,
|
||||||
|
extra={
|
||||||
|
"member_user_id": str(self.user_id),
|
||||||
|
"role": self.role,
|
||||||
|
"canonical_owner_membership": (
|
||||||
|
self.role == self.Role.OWNER and self.user_id == self.workspace.owner_id
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PriceUnit(BaseModel):
|
class PriceUnit(BaseModel):
|
||||||
code = models.CharField(max_length=8, unique=True)
|
code = models.CharField(max_length=8, unique=True)
|
||||||
@@ -129,3 +160,15 @@ class WorkspaceUserRate(BaseModel):
|
|||||||
models.Index(fields=["workspace"], name="wur_workspace_idx"),
|
models.Index(fields=["workspace"], name="wur_workspace_idx"),
|
||||||
models.Index(fields=["user"], name="wur_user_idx"),
|
models.Index(fields=["user"], name="wur_user_idx"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def get_additional_data(self):
|
||||||
|
return build_workspace_log_metadata(
|
||||||
|
section=SECTION_RATES,
|
||||||
|
workspace_id=self.workspace_id,
|
||||||
|
target_id=self.id,
|
||||||
|
target_label=self.user.full_name or self.user.mobile,
|
||||||
|
extra={
|
||||||
|
"rate_user_id": str(self.user_id),
|
||||||
|
"currency": self.currency,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from apps.workspaces.services.permissions import (
|
|||||||
TIME_ENTRIES_VIEW_OWN,
|
TIME_ENTRIES_VIEW_OWN,
|
||||||
WORKSPACE_DELETE,
|
WORKSPACE_DELETE,
|
||||||
WORKSPACE_EDIT,
|
WORKSPACE_EDIT,
|
||||||
|
WORKSPACE_LOGS_VIEW,
|
||||||
WORKSPACE_MEMBERS_ADD,
|
WORKSPACE_MEMBERS_ADD,
|
||||||
WORKSPACE_MEMBERS_CHANGE_ROLE,
|
WORKSPACE_MEMBERS_CHANGE_ROLE,
|
||||||
WORKSPACE_MEMBERS_REMOVE,
|
WORKSPACE_MEMBERS_REMOVE,
|
||||||
@@ -27,6 +28,7 @@ from apps.workspaces.services.permissions import (
|
|||||||
WORKSPACE_VIEW,
|
WORKSPACE_VIEW,
|
||||||
can_assign_workspace_role,
|
can_assign_workspace_role,
|
||||||
can_change_workspace_membership,
|
can_change_workspace_membership,
|
||||||
|
can_delete_workspace_object,
|
||||||
can_manage_workspace_members,
|
can_manage_workspace_members,
|
||||||
get_workspace_membership,
|
get_workspace_membership,
|
||||||
get_workspace_role,
|
get_workspace_role,
|
||||||
@@ -42,6 +44,7 @@ __all__ = [
|
|||||||
"WORKSPACE_VIEW",
|
"WORKSPACE_VIEW",
|
||||||
"WORKSPACE_EDIT",
|
"WORKSPACE_EDIT",
|
||||||
"WORKSPACE_DELETE",
|
"WORKSPACE_DELETE",
|
||||||
|
"WORKSPACE_LOGS_VIEW",
|
||||||
"WORKSPACE_MEMBERS_VIEW",
|
"WORKSPACE_MEMBERS_VIEW",
|
||||||
"WORKSPACE_MEMBERS_ADD",
|
"WORKSPACE_MEMBERS_ADD",
|
||||||
"WORKSPACE_MEMBERS_REMOVE",
|
"WORKSPACE_MEMBERS_REMOVE",
|
||||||
@@ -72,6 +75,7 @@ __all__ = [
|
|||||||
"can_manage_workspace_members",
|
"can_manage_workspace_members",
|
||||||
"can_assign_workspace_role",
|
"can_assign_workspace_role",
|
||||||
"can_change_workspace_membership",
|
"can_change_workspace_membership",
|
||||||
|
"can_delete_workspace_object",
|
||||||
"upsert_workspace_user_rate",
|
"upsert_workspace_user_rate",
|
||||||
"update_workspace_user_rate",
|
"update_workspace_user_rate",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from apps.workspaces.models import Workspace, WorkspaceMembership
|
|||||||
WORKSPACE_VIEW = "workspace.view"
|
WORKSPACE_VIEW = "workspace.view"
|
||||||
WORKSPACE_EDIT = "workspace.edit"
|
WORKSPACE_EDIT = "workspace.edit"
|
||||||
WORKSPACE_DELETE = "workspace.delete"
|
WORKSPACE_DELETE = "workspace.delete"
|
||||||
|
WORKSPACE_LOGS_VIEW = "workspace.logs.view"
|
||||||
WORKSPACE_MEMBERS_VIEW = "workspace.members.view"
|
WORKSPACE_MEMBERS_VIEW = "workspace.members.view"
|
||||||
WORKSPACE_MEMBERS_ADD = "workspace.members.add"
|
WORKSPACE_MEMBERS_ADD = "workspace.members.add"
|
||||||
WORKSPACE_MEMBERS_REMOVE = "workspace.members.remove"
|
WORKSPACE_MEMBERS_REMOVE = "workspace.members.remove"
|
||||||
@@ -45,6 +46,7 @@ WORKSPACE_ROLE_CAPABILITIES = {
|
|||||||
WORKSPACE_VIEW,
|
WORKSPACE_VIEW,
|
||||||
WORKSPACE_EDIT,
|
WORKSPACE_EDIT,
|
||||||
WORKSPACE_DELETE,
|
WORKSPACE_DELETE,
|
||||||
|
WORKSPACE_LOGS_VIEW,
|
||||||
WORKSPACE_MEMBERS_VIEW,
|
WORKSPACE_MEMBERS_VIEW,
|
||||||
WORKSPACE_MEMBERS_ADD,
|
WORKSPACE_MEMBERS_ADD,
|
||||||
WORKSPACE_MEMBERS_REMOVE,
|
WORKSPACE_MEMBERS_REMOVE,
|
||||||
@@ -72,6 +74,7 @@ WORKSPACE_ROLE_CAPABILITIES = {
|
|||||||
WorkspaceMembership.Role.ADMIN: {
|
WorkspaceMembership.Role.ADMIN: {
|
||||||
WORKSPACE_VIEW,
|
WORKSPACE_VIEW,
|
||||||
WORKSPACE_EDIT,
|
WORKSPACE_EDIT,
|
||||||
|
WORKSPACE_LOGS_VIEW,
|
||||||
WORKSPACE_MEMBERS_VIEW,
|
WORKSPACE_MEMBERS_VIEW,
|
||||||
WORKSPACE_MEMBERS_ADD,
|
WORKSPACE_MEMBERS_ADD,
|
||||||
WORKSPACE_MEMBERS_REMOVE,
|
WORKSPACE_MEMBERS_REMOVE,
|
||||||
@@ -166,6 +169,21 @@ def has_project_capability(user, project, capability: str) -> bool:
|
|||||||
return is_project_manager and capability in PROJECT_MANAGER_CAPABILITIES
|
return is_project_manager and capability in PROJECT_MANAGER_CAPABILITIES
|
||||||
|
|
||||||
|
|
||||||
|
def can_delete_workspace_object(user, obj, capability: str) -> bool:
|
||||||
|
workspace = getattr(obj, "workspace", None)
|
||||||
|
if workspace is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not has_workspace_capability(user, workspace, capability):
|
||||||
|
return False
|
||||||
|
|
||||||
|
actor_role = get_workspace_role(user, workspace)
|
||||||
|
if actor_role == WorkspaceMembership.Role.OWNER:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return getattr(obj, "created_by_id", None) == getattr(user, "id", None)
|
||||||
|
|
||||||
|
|
||||||
def can_manage_workspace_members(user, workspace: Workspace) -> bool:
|
def can_manage_workspace_members(user, workspace: Workspace) -> bool:
|
||||||
return has_workspace_capability(user, workspace, WORKSPACE_MEMBERS_CHANGE_ROLE)
|
return has_workspace_capability(user, workspace, WORKSPACE_MEMBERS_CHANGE_ROLE)
|
||||||
|
|
||||||
@@ -175,7 +193,10 @@ def can_assign_workspace_role(user, workspace: Workspace, role: str) -> bool:
|
|||||||
if actor_role == WorkspaceMembership.Role.OWNER:
|
if actor_role == WorkspaceMembership.Role.OWNER:
|
||||||
return True
|
return True
|
||||||
if actor_role == WorkspaceMembership.Role.ADMIN:
|
if actor_role == WorkspaceMembership.Role.ADMIN:
|
||||||
return role != WorkspaceMembership.Role.OWNER
|
return role not in {
|
||||||
|
WorkspaceMembership.Role.OWNER,
|
||||||
|
WorkspaceMembership.Role.ADMIN,
|
||||||
|
}
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -193,11 +214,15 @@ def can_change_workspace_membership(user, membership: WorkspaceMembership, *, ne
|
|||||||
|
|
||||||
target_is_canonical_owner = workspace.owner_id == membership.user_id
|
target_is_canonical_owner = workspace.owner_id == membership.user_id
|
||||||
target_is_owner_role = membership.role == WorkspaceMembership.Role.OWNER
|
target_is_owner_role = membership.role == WorkspaceMembership.Role.OWNER
|
||||||
|
target_is_admin_role = membership.role == WorkspaceMembership.Role.ADMIN
|
||||||
|
|
||||||
if actor_role == WorkspaceMembership.Role.ADMIN:
|
if actor_role == WorkspaceMembership.Role.ADMIN:
|
||||||
if target_is_owner_role or target_is_canonical_owner:
|
if target_is_owner_role or target_is_admin_role or target_is_canonical_owner:
|
||||||
return False
|
return False
|
||||||
if new_role == WorkspaceMembership.Role.OWNER:
|
if new_role in {
|
||||||
|
WorkspaceMembership.Role.OWNER,
|
||||||
|
WorkspaceMembership.Role.ADMIN,
|
||||||
|
}:
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ def test_guest_is_read_only_for_workspace_resources(api_client, owner, guest, wo
|
|||||||
assert list_projects_response.status_code == 200
|
assert list_projects_response.status_code == 200
|
||||||
assert create_tag_response.status_code == 403
|
assert create_tag_response.status_code == 403
|
||||||
assert create_entry_response.status_code == 403
|
assert create_entry_response.status_code == 403
|
||||||
assert edit_project_response.status_code == 404
|
assert edit_project_response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
def test_member_project_manager_cannot_edit_project(api_client, member, project):
|
def test_member_project_manager_cannot_edit_project(api_client, member, project):
|
||||||
@@ -230,6 +230,31 @@ def test_member_project_manager_cannot_edit_project(api_client, member, project)
|
|||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_member_can_list_workspace_members_with_restricted_user_fields(api_client, member, workspace):
|
||||||
|
api_client.force_authenticate(user=member)
|
||||||
|
|
||||||
|
response = api_client.get(f"/api/workspace-memberships/?workspace={workspace.id}")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.data.get("items", response.data) if isinstance(response.data, dict) else response.data
|
||||||
|
assert len(payload) >= 1
|
||||||
|
first_user = payload[0]["user"]
|
||||||
|
assert "mobile" not in first_user
|
||||||
|
assert "email" not in first_user
|
||||||
|
|
||||||
|
|
||||||
|
def test_owner_can_list_workspace_members_with_full_user_fields(api_client, owner, workspace):
|
||||||
|
api_client.force_authenticate(user=owner)
|
||||||
|
|
||||||
|
response = api_client.get(f"/api/workspace-memberships/?workspace={workspace.id}")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.data.get("items", response.data) if isinstance(response.data, dict) else response.data
|
||||||
|
assert len(payload) >= 1
|
||||||
|
first_user = payload[0]["user"]
|
||||||
|
assert "mobile" in first_user
|
||||||
|
|
||||||
|
|
||||||
def test_admin_cannot_change_owner_membership_but_canonical_owner_can(
|
def test_admin_cannot_change_owner_membership_but_canonical_owner_can(
|
||||||
api_client, owner, admin, extra_owner, workspace
|
api_client, owner, admin, extra_owner, workspace
|
||||||
):
|
):
|
||||||
@@ -256,3 +281,80 @@ def test_admin_cannot_change_owner_membership_but_canonical_owner_can(
|
|||||||
|
|
||||||
assert admin_response.status_code == 403
|
assert admin_response.status_code == 403
|
||||||
assert owner_response.status_code == 200
|
assert owner_response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_cannot_add_or_change_admin_memberships(api_client, owner, admin, member, workspace):
|
||||||
|
admin_membership = WorkspaceMembership.objects.get(workspace=workspace, user=admin, is_deleted=False)
|
||||||
|
|
||||||
|
api_client.force_authenticate(user=admin)
|
||||||
|
create_response = api_client.post(
|
||||||
|
"/api/workspace-memberships/",
|
||||||
|
{
|
||||||
|
"workspace": str(workspace.id),
|
||||||
|
"user": str(member.id),
|
||||||
|
"role": WorkspaceMembership.Role.ADMIN,
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
update_response = api_client.patch(
|
||||||
|
f"/api/workspace-memberships/{admin_membership.id}/",
|
||||||
|
{"role": WorkspaceMembership.Role.MEMBER},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
delete_response = api_client.delete(f"/api/workspace-memberships/{admin_membership.id}/")
|
||||||
|
|
||||||
|
assert create_response.status_code == 403
|
||||||
|
assert update_response.status_code == 403
|
||||||
|
assert delete_response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_can_delete_only_owned_clients_tags_and_projects(api_client, owner, admin, workspace):
|
||||||
|
api_client.force_authenticate(user=owner)
|
||||||
|
owner_client_response = api_client.post(
|
||||||
|
"/api/clients/",
|
||||||
|
{"workspace_id": str(workspace.id), "name": "Owner Client", "notes": ""},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
owner_tag_response = api_client.post(
|
||||||
|
"/api/tags/",
|
||||||
|
{"workspace_id": str(workspace.id), "name": "Owner Tag", "color": "#123456"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
owner_project_response = api_client.post(
|
||||||
|
"/api/projects/",
|
||||||
|
{"workspace": str(workspace.id), "name": "Owner Project", "description": "", "client": None},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
api_client.force_authenticate(user=admin)
|
||||||
|
admin_client_response = api_client.post(
|
||||||
|
"/api/clients/",
|
||||||
|
{"workspace_id": str(workspace.id), "name": "Admin Client", "notes": ""},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
admin_tag_response = api_client.post(
|
||||||
|
"/api/tags/",
|
||||||
|
{"workspace_id": str(workspace.id), "name": "Admin Tag", "color": "#654321"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
admin_project_response = api_client.post(
|
||||||
|
"/api/projects/",
|
||||||
|
{"workspace": str(workspace.id), "name": "Admin Project", "description": "", "client": None},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
delete_owner_client = api_client.delete(f"/api/clients/{owner_client_response.data['id']}/")
|
||||||
|
delete_owner_tag = api_client.delete(f"/api/tags/{owner_tag_response.data['id']}/")
|
||||||
|
delete_owner_project = api_client.delete(f"/api/projects/{owner_project_response.data['id']}/")
|
||||||
|
|
||||||
|
delete_admin_client = api_client.delete(f"/api/clients/{admin_client_response.data['id']}/")
|
||||||
|
delete_admin_tag = api_client.delete(f"/api/tags/{admin_tag_response.data['id']}/")
|
||||||
|
delete_admin_project = api_client.delete(f"/api/projects/{admin_project_response.data['id']}/")
|
||||||
|
|
||||||
|
assert delete_owner_client.status_code == 403
|
||||||
|
assert delete_owner_tag.status_code == 403
|
||||||
|
assert delete_owner_project.status_code in {403, 404}
|
||||||
|
|
||||||
|
assert delete_admin_client.status_code == 204
|
||||||
|
assert delete_admin_tag.status_code == 204
|
||||||
|
assert delete_admin_project.status_code == 204
|
||||||
|
|||||||
@@ -1 +1,68 @@
|
|||||||
AUDITLOG_INCLUDE_ALL_MODELS = True
|
COMMON_EXCLUDED_FIELDS = [
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"deleted_at",
|
||||||
|
"created_by",
|
||||||
|
"updated_by",
|
||||||
|
]
|
||||||
|
|
||||||
|
AUDITLOG_INCLUDE_ALL_MODELS = False
|
||||||
|
AUDITLOG_STORE_JSON_CHANGES = True
|
||||||
|
AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT = True
|
||||||
|
AUDITLOG_INCLUDE_TRACKING_MODELS = [
|
||||||
|
{
|
||||||
|
"model": "workspaces.Workspace",
|
||||||
|
"exclude_fields": COMMON_EXCLUDED_FIELDS,
|
||||||
|
"serialize_data": True,
|
||||||
|
"serialize_auditlog_fields_only": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "workspaces.WorkspaceMembership",
|
||||||
|
"exclude_fields": COMMON_EXCLUDED_FIELDS,
|
||||||
|
"serialize_data": True,
|
||||||
|
"serialize_auditlog_fields_only": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "workspaces.WorkspaceUserRate",
|
||||||
|
"exclude_fields": COMMON_EXCLUDED_FIELDS,
|
||||||
|
"serialize_data": True,
|
||||||
|
"serialize_auditlog_fields_only": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "clients.Client",
|
||||||
|
"exclude_fields": COMMON_EXCLUDED_FIELDS,
|
||||||
|
"serialize_data": True,
|
||||||
|
"serialize_auditlog_fields_only": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "projects.Project",
|
||||||
|
"exclude_fields": COMMON_EXCLUDED_FIELDS,
|
||||||
|
"serialize_data": 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",
|
||||||
|
"exclude_fields": COMMON_EXCLUDED_FIELDS,
|
||||||
|
"serialize_data": True,
|
||||||
|
"serialize_auditlog_fields_only": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "time_entries.TimeEntry",
|
||||||
|
"exclude_fields": COMMON_EXCLUDED_FIELDS,
|
||||||
|
"m2m_fields": {"tags"},
|
||||||
|
"serialize_data": True,
|
||||||
|
"serialize_auditlog_fields_only": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "reports.ReportExportJob",
|
||||||
|
"exclude_fields": COMMON_EXCLUDED_FIELDS,
|
||||||
|
"serialize_data": True,
|
||||||
|
"serialize_auditlog_fields_only": True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ LOCAL_APPS = [
|
|||||||
"apps.time_entries",
|
"apps.time_entries",
|
||||||
"apps.notifications",
|
"apps.notifications",
|
||||||
"apps.reports",
|
"apps.reports",
|
||||||
|
"apps.logs",
|
||||||
]
|
]
|
||||||
|
|
||||||
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
||||||
@@ -60,6 +61,7 @@ MIDDLEWARE = [
|
|||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
|
"apps.logs.middlewares.JWTRequestActorMiddleware",
|
||||||
"core.middlewares.current_user.CurrentUserMiddleware",
|
"core.middlewares.current_user.CurrentUserMiddleware",
|
||||||
"core.middlewares.exception_logging.ExceptionLoggingMiddleware",
|
"core.middlewares.exception_logging.ExceptionLoggingMiddleware",
|
||||||
"config.services.logging.RequestLoggingMiddleware",
|
"config.services.logging.RequestLoggingMiddleware",
|
||||||
@@ -246,3 +248,5 @@ STORAGES = {
|
|||||||
|
|
||||||
SMS_APIKEY = os.getenv("SMS_APIKEY", "")
|
SMS_APIKEY = os.getenv("SMS_APIKEY", "")
|
||||||
BASE_URL = os.getenv("BASE_URL", "")
|
BASE_URL = os.getenv("BASE_URL", "")
|
||||||
|
|
||||||
|
from config.services.auditlog import * # noqa: E402,F401,F403
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ urlpatterns = [
|
|||||||
path('api/', include('apps.time_entries.api.urls'), name="time_entries"),
|
path('api/', include('apps.time_entries.api.urls'), name="time_entries"),
|
||||||
path("api/notifications/", include("apps.notifications.api.urls"), name="notifications"),
|
path("api/notifications/", include("apps.notifications.api.urls"), name="notifications"),
|
||||||
path("api/reports/", include("apps.reports.api.urls"), name="reports"),
|
path("api/reports/", include("apps.reports.api.urls"), name="reports"),
|
||||||
|
path("api/logs/", include("apps.logs.api.urls"), name="logs"),
|
||||||
]
|
]
|
||||||
|
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
@@ -14,5 +15,13 @@ class CurrentUserMiddleware:
|
|||||||
self.get_response = get_response
|
self.get_response = get_response
|
||||||
|
|
||||||
def __call__(self, request):
|
def __call__(self, request):
|
||||||
|
previous_user = getattr(_local, "user", None)
|
||||||
_local.user = request.user
|
_local.user = request.user
|
||||||
return self.get_response(request)
|
try:
|
||||||
|
return self.get_response(request)
|
||||||
|
finally:
|
||||||
|
if previous_user is None:
|
||||||
|
with contextlib.suppress(AttributeError):
|
||||||
|
del _local.user
|
||||||
|
else:
|
||||||
|
_local.user = previous_user
|
||||||
|
|||||||
Reference in New Issue
Block a user