Compare commits

...

7 Commits

50 changed files with 1847 additions and 293 deletions

4
.gitignore vendored
View File

@@ -23,6 +23,10 @@ staticfiles/
# Logs
*.log
logs/
!apps/logs/
!apps/logs/**
apps/logs/**/__pycache__/
apps/logs/**/*.pyc
# IDE / Editor
.vscode/

View File

@@ -6,6 +6,7 @@ from apps.workspaces.services import (
CLIENTS_DELETE,
CLIENTS_EDIT,
CLIENTS_VIEW,
can_delete_workspace_object,
has_workspace_capability,
)
@@ -43,4 +44,6 @@ class IsClientWorkspaceMember(permissions.BasePermission):
"partial_update": CLIENTS_EDIT,
"destroy": CLIENTS_DELETE,
}.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)

View File

@@ -1,4 +1,6 @@
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 core.models.base import BaseModel
@@ -32,3 +34,11 @@ class Client(BaseModel):
def __str__(self):
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,
)

View File

@@ -22,7 +22,9 @@ def create_client(user, workspace_id, name, notes=""):
return Client.objects.create(
workspace_id=workspace_id,
name=name,
notes=notes
notes=notes,
created_by=user,
updated_by=user,
)

0
apps/logs/__init__.py Normal file
View File

2
apps/logs/admin.py Normal file
View File

@@ -0,0 +1,2 @@
"""Admin registrations for apps.logs live on django-auditlog."""

View 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.")

View 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
View 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
View 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
View 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
View 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)

View File

2
apps/logs/models.py Normal file
View File

@@ -0,0 +1,2 @@
"""Workspace logs are backed by django-auditlog models."""

View 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",
]

View 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())

View 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
View 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(),
)

View File

@@ -0,0 +1 @@

View 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}&section=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"])

View File

@@ -37,10 +37,12 @@ from apps.projects.services.memberships import add_project_member, update_projec
from apps.workspaces.services import (
PROJECTS_ARCHIVE,
PROJECTS_CREATE,
PROJECTS_DELETE,
PROJECTS_EDIT,
PROJECT_MEMBERS_ADD,
PROJECT_MEMBERS_CHANGE_ROLE,
PROJECT_MEMBERS_REMOVE,
can_delete_workspace_object,
has_project_capability,
has_workspace_capability,
)
@@ -82,8 +84,8 @@ class ProjectViewSet(ModelViewSet):
return Project.objects.none()
return Project.objects.filter(
memberships__user=self.request.user,
memberships__is_active=True,
workspace__memberships__user=self.request.user,
workspace__memberships__is_active=True,
is_deleted=False
).distinct()
@@ -221,6 +223,11 @@ class ProjectViewSet(ModelViewSet):
Soft deletes a project.
"""
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.save(update_fields=["is_deleted", "updated_at"])
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -1,6 +1,8 @@
from django.contrib.auth import get_user_model
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 apps.workspaces.models import Workspace
@@ -47,6 +49,15 @@ class Project(BaseModel):
def __str__(self):
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):
@@ -92,6 +103,19 @@ class ProjectMembership(BaseModel):
def __str__(self):
return f"{self.user} @ {self.project}"
def get_additional_data(self):
return build_workspace_log_metadata(
section=SECTION_PROJECT_MEMBERS,
workspace_id=self.project.workspace_id,
target_id=self.id,
target_label=self.user.full_name or self.user.mobile,
extra={
"project_id": str(self.project_id),
"member_user_id": str(self.user_id),
"role": self.role,
},
)
class ProjectRate(BaseModel):
project = models.ForeignKey(

View File

@@ -31,7 +31,9 @@ def create_project(user, workspace, name, client=None, description="", color="")
name=name,
client=client,
description=description,
color=color
color=color,
created_by=user,
updated_by=user,
)
ProjectMembership.objects.create(

View File

@@ -2,6 +2,8 @@ from django.conf import settings
from django.db import models
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
@@ -81,3 +83,14 @@ class ReportExportJob(BaseModel):
self.status = self.Status.EXPIRED
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,
},
)

View File

@@ -418,7 +418,11 @@ def _scope_payload(filters: ReportFilters) -> dict:
}
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,
"from_date": filters.from_date.isoformat(),
"to_date": filters.to_date.isoformat(),

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from decimal import Decimal, InvalidOperation
from pathlib import Path
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)
class ExportLocale:
@@ -122,14 +133,40 @@ class ExportLocale:
def format_duration(self, value: str, *, ascii_digits: bool = False) -> str:
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:
if not income_totals:
return "-"
parts = []
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)
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:
raw = str(text)
if not any(start <= ord(char) <= end for char in raw for start, end in ARABIC_RANGES):

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import io
import os
from datetime import datetime
from openpyxl import Workbook
@@ -12,6 +13,7 @@ from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import mm
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.platypus import Image as RLImage
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
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"]
summary = report_data["summary"]
worksheet.append([locale.t("report_title"), scope["workspace"]["name"]])
worksheet.append([locale.t("workspace"), scope["workspace"]["name"]])
worksheet.append([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([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([locale.t("generated_at"), locale.format_date(datetime.now().date(), ascii_digits=True)])
worksheet.append(_rtl_row(locale, [locale.t("report_title"), scope["workspace"]["name"]]))
worksheet.append(_rtl_row(locale, [locale.t("workspace"), scope["workspace"]["name"]]))
worksheet.append(_rtl_row(locale, [locale.t("period"), locale.period_label(scope["period"])]))
worksheet.append(_rtl_row(locale, [locale.t("from_date"), locale.format_date(scope["from_date"], ascii_digits=True)]))
worksheet.append(_rtl_row(locale, [locale.t("to_date"), locale.format_date(scope["to_date"], ascii_digits=True)]))
worksheet.append(_rtl_row(locale, [locale.t("user"), user_label(scope.get("user"), locale, ascii_digits=True)]))
worksheet.append(_rtl_row(locale, [locale.t("generated_at"), locale.format_date(datetime.now().date(), ascii_digits=True)]))
worksheet.append([])
worksheet.append([locale.t("summary")])
worksheet.append([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([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("total_hours"), locale.format_duration(summary["total_duration"], ascii_digits=True)]))
worksheet.append(_rtl_row(locale, [locale.t("billable_hours"), locale.format_duration(summary["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(_rtl_row(locale, [locale.t("income"), _money_label_excel(locale, summary["income_totals"])]))
for row_index in range(1, worksheet.max_row + 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:
if locale.is_rtl:
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_daily_table(worksheet, locale=locale, report_data=report_data)
_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)
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:
shaped_data = [
[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"]
summary = report_data["summary"]
story = [
_paragraph(f"{locale.t('report_title')} - {scope['workspace']['name']}", title_style, locale),
Spacer(1, 6 * mm),
]
workspace_name = scope["workspace"]["name"]
workspace_thumbnail_path = scope["workspace"].get("thumbnail_path")
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 = [
[locale.t("workspace"), scope["workspace"]["name"]],

View File

@@ -6,6 +6,7 @@ from apps.workspaces.services import (
TAGS_DELETE,
TAGS_EDIT,
TAGS_VIEW,
can_delete_workspace_object,
has_workspace_capability,
)
@@ -38,4 +39,6 @@ class IsTagWorkspaceAllowed(permissions.BasePermission):
"partial_update": TAGS_EDIT,
"destroy": TAGS_DELETE,
}.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)

View File

@@ -1,5 +1,7 @@
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 apps.workspaces.models import Workspace
@@ -32,3 +34,11 @@ class Tag(BaseModel):
def __str__(self):
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,
)

View File

@@ -25,7 +25,9 @@ def create_tag(user, workspace_id, name, color=""):
return Tag.objects.create(
workspace_id=workspace_id,
name=name,
color=color
color=color,
created_by=user,
updated_by=user,
)

View File

@@ -6,24 +6,79 @@ from apps.projects.models import Project
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):
"""
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")
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:
model = TimeEntry
fields = BaseModelSerializer.Meta.fields + (
"workspace",
"user",
"project",
"project_details",
"description",
"start_time",
"end_time",
"duration",
"tags",
"tag_details",
"is_billable",
"hourly_rate",
"currency",
@@ -36,43 +91,83 @@ class TimeEntryCreateSerializer(serializers.Serializer):
Validates input data for creating/starting a time entry.
"""
workspace_id = serializers.UUIDField()
project_id = serializers.PrimaryKeyRelatedField(
queryset=Project.objects.filter(is_deleted=False),
required=False,
allow_null=True,
source='project'
)
project_id = serializers.UUIDField(required=False, allow_null=True)
start_time = serializers.DateTimeField()
end_time = serializers.DateTimeField(required=False, allow_null=True)
description = serializers.CharField(required=False, allow_blank=True, default="")
tags = serializers.PrimaryKeyRelatedField(
queryset=Tag.objects.filter(is_deleted=False),
many=True,
required=False
)
tags = serializers.ListField(child=serializers.UUIDField(), required=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):
"""
Validates input data for updating an existing time entry.
"""
project_id = serializers.PrimaryKeyRelatedField(
queryset=Project.objects.filter(is_deleted=False),
required=False,
allow_null=True,
source='project'
)
project_id = serializers.UUIDField(required=False, allow_null=True)
start_time = serializers.DateTimeField(required=False)
end_time = serializers.DateTimeField(required=False, allow_null=True)
description = serializers.CharField(required=False, allow_blank=True)
tags = serializers.PrimaryKeyRelatedField(
queryset=Tag.objects.filter(is_deleted=False),
many=True,
required=False
)
tags = serializers.ListField(child=serializers.UUIDField(), 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):
"""

View File

@@ -148,7 +148,7 @@ class TimeEntryViewSet(ModelViewSet):
**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)
def update(self, request, *args, **kwargs):
@@ -160,7 +160,7 @@ class TimeEntryViewSet(ModelViewSet):
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)
updated_entry = update_time_entry(
@@ -168,7 +168,7 @@ class TimeEntryViewSet(ModelViewSet):
**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)
@action(detail=True, methods=["post"])
@@ -189,7 +189,7 @@ class TimeEntryViewSet(ModelViewSet):
end_time = serializer.validated_data.get("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)
def destroy(self, request, *args, **kwargs):

View File

@@ -3,6 +3,8 @@ from django.conf import settings
from django.db import models
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 apps.workspaces.models import Workspace
from apps.projects.models import Project
@@ -72,6 +74,16 @@ class TimeEntry(BaseModel):
def __str__(self):
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):
if self.project and self.project.workspace_id != self.workspace_id:
raise ValidationError("Project must belong to the same workspace.")

View File

@@ -4,6 +4,8 @@ from django.utils import timezone
from apps.time_entries.api.serializers import TimeEntrySerializer
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.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["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

View File

@@ -4,7 +4,9 @@ import pytest
from django.utils import timezone
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.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.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]

View File

@@ -3,6 +3,7 @@ from datetime import datetime
from django.utils import timezone
from rest_framework.test import APIClient
from apps.tags.models import Tag
from apps.time_entries.models import TimeEntry
from apps.users.models import User
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"][0]["days"]) == 1
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"] == []

View File

@@ -1,9 +1,11 @@
from decimal import Decimal
import json
from rest_framework import serializers
from apps.notifications.services import notify_workspace_membership_added
from apps.users.models import User
from apps.workspaces.services import WORKSPACE_MEMBERS_VIEW, has_workspace_capability
from core.serializers.base import BaseModelSerializer
from apps.workspaces.models import PriceUnit, Workspace, WorkspaceMembership, WorkspaceUserRate
from core.serializers.mini import UserMiniSerializer
@@ -15,7 +17,8 @@ class WorkspaceMemberInputSerializer(serializers.Serializer):
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()
class Meta:
@@ -23,6 +26,8 @@ class WorkspaceSerializer(BaseModelSerializer):
fields = BaseModelSerializer.Meta.fields + (
"name",
"description",
"thumbnail",
"clear_thumbnail",
"owner",
"my_role",
"members",
@@ -38,8 +43,41 @@ class WorkspaceSerializer(BaseModelSerializer):
).first()
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):
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)
@@ -74,8 +112,27 @@ class WorkspaceSerializer(BaseModelSerializer):
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):
user = serializers.SerializerMethodField()
class Meta:
model = WorkspaceMembership
fields = BaseModelSerializer.Meta.fields + (
@@ -85,13 +142,25 @@ class WorkspaceMembershipSerializer(BaseModelSerializer):
"is_active",
)
def to_representation(self, instance):
data = super().to_representation(instance)
data["user"] = UserMiniSerializer(
instance.user,
context=self.context
).data
return data
def get_user(self, instance):
request = self.context.get("request")
viewer = getattr(request, "user", None)
can_view_sensitive_details = bool(
viewer
and viewer.is_authenticated
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):

View File

@@ -7,6 +7,7 @@ from rest_framework.filters import OrderingFilter, SearchFilter
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.viewsets import ModelViewSet
from rest_framework.permissions import IsAuthenticated
from rest_framework.parsers import FormParser, MultiPartParser, JSONParser
from apps.notifications.services import (
notify_workspace_membership_added,
@@ -33,6 +34,7 @@ from apps.workspaces.models import PriceUnit, Workspace, WorkspaceMembership, Wo
from apps.workspaces.services import (
WORKSPACE_MEMBERS_VIEW,
WORKSPACE_EDIT,
WORKSPACE_VIEW,
can_assign_workspace_role,
can_change_workspace_membership,
has_workspace_capability,
@@ -44,6 +46,7 @@ from core.paginations.limit_offset import CustomLimitOffsetPagination
class WorkspaceViewSet(ModelViewSet):
serializer_class = WorkspaceSerializer
parser_classes = [MultiPartParser, FormParser, JSONParser]
pagination_class = CustomLimitOffsetPagination
filter_backends = (DjangoFilterBackend, OrderingFilter, SearchFilter)
filterset_class = WorkspaceFilter
@@ -102,7 +105,9 @@ class WorkspaceMembershipViewSet(ModelViewSet):
).distinct()
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()]
if self.action in ["destroy"]:
return [IsAuthenticated(), CanWorkspaceManageMembers()]
@@ -118,7 +123,7 @@ class WorkspaceMembershipViewSet(ModelViewSet):
)
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(
{"detail": "You do not have permission to view workspace members."},
status=status.HTTP_403_FORBIDDEN,

View 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/"),
),
]

View File

@@ -1,6 +1,12 @@
from django.contrib.auth import get_user_model
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
User = get_user_model()
@@ -9,6 +15,7 @@ User = get_user_model()
class Workspace(BaseModel):
name = models.CharField(max_length=255)
description = models.TextField(blank=True)
thumbnail = models.ImageField(upload_to="profile/workspaces/", blank=True, null=True)
owner = models.ForeignKey(
User,
on_delete=models.PROTECT,
@@ -25,6 +32,15 @@ class Workspace(BaseModel):
def __str__(self):
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
def members(self):
return User.objects.filter(
@@ -76,6 +92,21 @@ class WorkspaceMembership(BaseModel):
def __str__(self):
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):
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=["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,
},
)

View File

@@ -20,6 +20,7 @@ from apps.workspaces.services.permissions import (
TIME_ENTRIES_VIEW_OWN,
WORKSPACE_DELETE,
WORKSPACE_EDIT,
WORKSPACE_LOGS_VIEW,
WORKSPACE_MEMBERS_ADD,
WORKSPACE_MEMBERS_CHANGE_ROLE,
WORKSPACE_MEMBERS_REMOVE,
@@ -27,6 +28,7 @@ from apps.workspaces.services.permissions import (
WORKSPACE_VIEW,
can_assign_workspace_role,
can_change_workspace_membership,
can_delete_workspace_object,
can_manage_workspace_members,
get_workspace_membership,
get_workspace_role,
@@ -42,6 +44,7 @@ __all__ = [
"WORKSPACE_VIEW",
"WORKSPACE_EDIT",
"WORKSPACE_DELETE",
"WORKSPACE_LOGS_VIEW",
"WORKSPACE_MEMBERS_VIEW",
"WORKSPACE_MEMBERS_ADD",
"WORKSPACE_MEMBERS_REMOVE",
@@ -72,6 +75,7 @@ __all__ = [
"can_manage_workspace_members",
"can_assign_workspace_role",
"can_change_workspace_membership",
"can_delete_workspace_object",
"upsert_workspace_user_rate",
"update_workspace_user_rate",
]

View File

@@ -7,6 +7,7 @@ from apps.workspaces.models import Workspace, WorkspaceMembership
WORKSPACE_VIEW = "workspace.view"
WORKSPACE_EDIT = "workspace.edit"
WORKSPACE_DELETE = "workspace.delete"
WORKSPACE_LOGS_VIEW = "workspace.logs.view"
WORKSPACE_MEMBERS_VIEW = "workspace.members.view"
WORKSPACE_MEMBERS_ADD = "workspace.members.add"
WORKSPACE_MEMBERS_REMOVE = "workspace.members.remove"
@@ -45,6 +46,7 @@ WORKSPACE_ROLE_CAPABILITIES = {
WORKSPACE_VIEW,
WORKSPACE_EDIT,
WORKSPACE_DELETE,
WORKSPACE_LOGS_VIEW,
WORKSPACE_MEMBERS_VIEW,
WORKSPACE_MEMBERS_ADD,
WORKSPACE_MEMBERS_REMOVE,
@@ -72,6 +74,7 @@ WORKSPACE_ROLE_CAPABILITIES = {
WorkspaceMembership.Role.ADMIN: {
WORKSPACE_VIEW,
WORKSPACE_EDIT,
WORKSPACE_LOGS_VIEW,
WORKSPACE_MEMBERS_VIEW,
WORKSPACE_MEMBERS_ADD,
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
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:
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:
return True
if actor_role == WorkspaceMembership.Role.ADMIN:
return role != WorkspaceMembership.Role.OWNER
return role not in {
WorkspaceMembership.Role.OWNER,
WorkspaceMembership.Role.ADMIN,
}
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_owner_role = membership.role == WorkspaceMembership.Role.OWNER
target_is_admin_role = membership.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
if new_role == WorkspaceMembership.Role.OWNER:
if new_role in {
WorkspaceMembership.Role.OWNER,
WorkspaceMembership.Role.ADMIN,
}:
return False
return True

View File

@@ -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 create_tag_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):
@@ -230,6 +230,31 @@ def test_member_project_manager_cannot_edit_project(api_client, member, project)
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(
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 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

View File

@@ -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,
},
]

View File

@@ -47,6 +47,7 @@ LOCAL_APPS = [
"apps.time_entries",
"apps.notifications",
"apps.reports",
"apps.logs",
]
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
@@ -60,6 +61,7 @@ MIDDLEWARE = [
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"apps.logs.middlewares.JWTRequestActorMiddleware",
"core.middlewares.current_user.CurrentUserMiddleware",
"core.middlewares.exception_logging.ExceptionLoggingMiddleware",
"config.services.logging.RequestLoggingMiddleware",
@@ -246,3 +248,5 @@ STORAGES = {
SMS_APIKEY = os.getenv("SMS_APIKEY", "")
BASE_URL = os.getenv("BASE_URL", "")
from config.services.auditlog import * # noqa: E402,F401,F403

View File

@@ -23,6 +23,7 @@ urlpatterns = [
path('api/', include('apps.time_entries.api.urls'), name="time_entries"),
path("api/notifications/", include("apps.notifications.api.urls"), name="notifications"),
path("api/reports/", include("apps.reports.api.urls"), name="reports"),
path("api/logs/", include("apps.logs.api.urls"), name="logs"),
]
if settings.DEBUG:

View File

@@ -1,3 +1,4 @@
import contextlib
import logging
import threading
@@ -14,5 +15,13 @@ class CurrentUserMiddleware:
self.get_response = get_response
def __call__(self, request):
previous_user = getattr(_local, "user", None)
_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