From 71924ce6fb9d7bcca39b21d45aa7f6fe00e40a94 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Tue, 28 Apr 2026 16:42:37 +0330 Subject: [PATCH] feat(logs): add workspace activity log api --- .gitignore | 10 +- apps/clients/models.py | 10 + apps/logs/__init__.py | 0 apps/logs/admin.py | 2 + apps/logs/api/permissions.py | 12 + apps/logs/api/serializers.py | 32 ++ apps/logs/api/urls.py | 12 + apps/logs/api/views.py | 80 +++++ apps/logs/apps.py | 8 + apps/logs/middlewares.py | 29 ++ apps/logs/migrations/__init__.py | 0 apps/logs/models.py | 2 + apps/logs/services/__init__.py | 54 ++++ apps/logs/services/constants.py | 58 ++++ apps/logs/services/metadata.py | 27 ++ apps/logs/services/query.py | 297 ++++++++++++++++++ apps/logs/tests/__init__.py | 1 + apps/logs/tests/test_views.py | 181 +++++++++++ apps/projects/models.py | 24 ++ apps/reports/migrations/0001_initial.py | 94 +++--- apps/reports/models.py | 13 + apps/tags/models.py | 10 + apps/time_entries/models.py | 32 +- ...rrate_workspaceuserrate_id_idx_and_more.py | 60 ++-- ...ove_priceunit_priceunit_id_idx_and_more.py | 60 ++-- apps/workspaces/models.py | 42 +++ apps/workspaces/services/__init__.py | 2 + apps/workspaces/services/permissions.py | 3 + config/services/auditlog.py | 69 +++- config/settings/base.py | 4 + config/urls.py | 1 + core/middlewares/current_user.py | 11 +- 32 files changed, 1118 insertions(+), 122 deletions(-) create mode 100644 apps/logs/__init__.py create mode 100644 apps/logs/admin.py create mode 100644 apps/logs/api/permissions.py create mode 100644 apps/logs/api/serializers.py create mode 100644 apps/logs/api/urls.py create mode 100644 apps/logs/api/views.py create mode 100644 apps/logs/apps.py create mode 100644 apps/logs/middlewares.py create mode 100644 apps/logs/migrations/__init__.py create mode 100644 apps/logs/models.py create mode 100644 apps/logs/services/__init__.py create mode 100644 apps/logs/services/constants.py create mode 100644 apps/logs/services/metadata.py create mode 100644 apps/logs/services/query.py create mode 100644 apps/logs/tests/__init__.py create mode 100644 apps/logs/tests/test_views.py diff --git a/.gitignore b/.gitignore index 474775d..b52d7be 100644 --- a/.gitignore +++ b/.gitignore @@ -20,9 +20,13 @@ staticfiles/ # Migrations **/migrations/*.pyc -# Logs -*.log -logs/ +# Logs +*.log +logs/ +!apps/logs/ +!apps/logs/** +apps/logs/**/__pycache__/ +apps/logs/**/*.pyc # IDE / Editor .vscode/ diff --git a/apps/clients/models.py b/apps/clients/models.py index d35bc43..29066f7 100644 --- a/apps/clients/models.py +++ b/apps/clients/models.py @@ -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, + ) diff --git a/apps/logs/__init__.py b/apps/logs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/logs/admin.py b/apps/logs/admin.py new file mode 100644 index 0000000..576654a --- /dev/null +++ b/apps/logs/admin.py @@ -0,0 +1,2 @@ +"""Admin registrations for apps.logs live on django-auditlog.""" + diff --git a/apps/logs/api/permissions.py b/apps/logs/api/permissions.py new file mode 100644 index 0000000..8c4081c --- /dev/null +++ b/apps/logs/api/permissions.py @@ -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.") + diff --git a/apps/logs/api/serializers.py b/apps/logs/api/serializers.py new file mode 100644 index 0000000..445e37c --- /dev/null +++ b/apps/logs/api/serializers.py @@ -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) diff --git a/apps/logs/api/urls.py b/apps/logs/api/urls.py new file mode 100644 index 0000000..a67083e --- /dev/null +++ b/apps/logs/api/urls.py @@ -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)), +] + diff --git a/apps/logs/api/views.py b/apps/logs/api/views.py new file mode 100644 index 0000000..43c52b1 --- /dev/null +++ b/apps/logs/api/views.py @@ -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) diff --git a/apps/logs/apps.py b/apps/logs/apps.py new file mode 100644 index 0000000..3340b08 --- /dev/null +++ b/apps/logs/apps.py @@ -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" + diff --git a/apps/logs/middlewares.py b/apps/logs/middlewares.py new file mode 100644 index 0000000..0f202ee --- /dev/null +++ b/apps/logs/middlewares.py @@ -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) + diff --git a/apps/logs/migrations/__init__.py b/apps/logs/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/logs/models.py b/apps/logs/models.py new file mode 100644 index 0000000..2b27985 --- /dev/null +++ b/apps/logs/models.py @@ -0,0 +1,2 @@ +"""Workspace logs are backed by django-auditlog models.""" + diff --git a/apps/logs/services/__init__.py b/apps/logs/services/__init__.py new file mode 100644 index 0000000..0368481 --- /dev/null +++ b/apps/logs/services/__init__.py @@ -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", +] + diff --git a/apps/logs/services/constants.py b/apps/logs/services/constants.py new file mode 100644 index 0000000..a55725f --- /dev/null +++ b/apps/logs/services/constants.py @@ -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()) + diff --git a/apps/logs/services/metadata.py b/apps/logs/services/metadata.py new file mode 100644 index 0000000..40acf06 --- /dev/null +++ b/apps/logs/services/metadata.py @@ -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 + diff --git a/apps/logs/services/query.py b/apps/logs/services/query.py new file mode 100644 index 0000000..f1d378f --- /dev/null +++ b/apps/logs/services/query.py @@ -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(), + ) + diff --git a/apps/logs/tests/__init__.py b/apps/logs/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/logs/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/logs/tests/test_views.py b/apps/logs/tests/test_views.py new file mode 100644 index 0000000..ec75d4f --- /dev/null +++ b/apps/logs/tests/test_views.py @@ -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"]) + diff --git a/apps/projects/models.py b/apps/projects/models.py index 8ad5654..231a0f4 100644 --- a/apps/projects/models.py +++ b/apps/projects/models.py @@ -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( diff --git a/apps/reports/migrations/0001_initial.py b/apps/reports/migrations/0001_initial.py index db7a79e..558414c 100644 --- a/apps/reports/migrations/0001_initial.py +++ b/apps/reports/migrations/0001_initial.py @@ -1,47 +1,47 @@ -# Generated by Django 5.2.12 on 2026-04-26 19:23 - -import django.db.models.deletion -import uuid -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('workspaces', '0005_remove_priceunit_priceunit_id_idx_and_more'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='ReportExportJob', - fields=[ - ('id', models.UUIDField(default=uuid.uuid7, primary_key=True, serialize=False)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('deleted_at', models.DateTimeField(blank=True, null=True)), - ('is_deleted', models.BooleanField(default=False)), - ('is_active', models.BooleanField(default=False)), - ('export_type', models.CharField(choices=[('excel', 'Excel'), ('pdf', 'PDF')], max_length=16)), - ('status', models.CharField(choices=[('pending', 'Pending'), ('processing', 'Processing'), ('completed', 'Completed'), ('failed', 'Failed'), ('expired', 'Expired')], default='pending', max_length=16)), - ('filters', models.JSONField(default=dict)), - ('file', models.FileField(blank=True, null=True, upload_to='reports/exports/')), - ('file_name', models.CharField(blank=True, max_length=255)), - ('error_message', models.TextField(blank=True)), - ('expires_at', models.DateTimeField(blank=True, null=True)), - ('completed_at', models.DateTimeField(blank=True, null=True)), - ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_%(app_label)s_%(class)s_set', to=settings.AUTH_USER_MODEL)), - ('requesting_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='report_export_jobs', to=settings.AUTH_USER_MODEL)), - ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_%(app_label)s_%(class)s_set', to=settings.AUTH_USER_MODEL)), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='report_export_jobs', to='workspaces.workspace')), - ], - options={ - 'db_table': 'report_export_job', - 'ordering': ('-created_at',), - 'indexes': [models.Index(fields=['requesting_user'], name='report_export_user_idx'), models.Index(fields=['workspace'], name='report_export_workspace_idx'), models.Index(fields=['status'], name='report_export_status_idx'), models.Index(fields=['expires_at'], name='report_export_expires_idx')], - }, - ), - ] +# Generated by Django 5.2.12 on 2026-04-26 19:23 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('workspaces', '0005_remove_priceunit_priceunit_id_idx_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ReportExportJob', + fields=[ + ('id', models.UUIDField(default=uuid.uuid7, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('is_deleted', models.BooleanField(default=False)), + ('is_active', models.BooleanField(default=False)), + ('export_type', models.CharField(choices=[('excel', 'Excel'), ('pdf', 'PDF')], max_length=16)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('processing', 'Processing'), ('completed', 'Completed'), ('failed', 'Failed'), ('expired', 'Expired')], default='pending', max_length=16)), + ('filters', models.JSONField(default=dict)), + ('file', models.FileField(blank=True, null=True, upload_to='reports/exports/')), + ('file_name', models.CharField(blank=True, max_length=255)), + ('error_message', models.TextField(blank=True)), + ('expires_at', models.DateTimeField(blank=True, null=True)), + ('completed_at', models.DateTimeField(blank=True, null=True)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_%(app_label)s_%(class)s_set', to=settings.AUTH_USER_MODEL)), + ('requesting_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='report_export_jobs', to=settings.AUTH_USER_MODEL)), + ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_%(app_label)s_%(class)s_set', to=settings.AUTH_USER_MODEL)), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='report_export_jobs', to='workspaces.workspace')), + ], + options={ + 'db_table': 'report_export_job', + 'ordering': ('-created_at',), + 'indexes': [models.Index(fields=['requesting_user'], name='report_export_user_idx'), models.Index(fields=['workspace'], name='report_export_workspace_idx'), models.Index(fields=['status'], name='report_export_status_idx'), models.Index(fields=['expires_at'], name='report_export_expires_idx')], + }, + ), + ] diff --git a/apps/reports/models.py b/apps/reports/models.py index 863ef25..df584ae 100644 --- a/apps/reports/models.py +++ b/apps/reports/models.py @@ -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, + }, + ) diff --git a/apps/tags/models.py b/apps/tags/models.py index 97a733d..fae88ae 100644 --- a/apps/tags/models.py +++ b/apps/tags/models.py @@ -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, + ) diff --git a/apps/time_entries/models.py b/apps/time_entries/models.py index 6d25f6b..edacf37 100644 --- a/apps/time_entries/models.py +++ b/apps/time_entries/models.py @@ -1,11 +1,13 @@ -from django.core.exceptions import ValidationError -from django.conf import settings -from django.db import models -from django.db.models import Q - -from core.models.base import BaseModel -from apps.workspaces.models import Workspace -from apps.projects.models import Project +from django.core.exceptions import ValidationError +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 from apps.tags.models import Tag @@ -69,8 +71,18 @@ class TimeEntry(BaseModel): ) ] - def __str__(self): - return f"{self.user} - {self.start_time}" + 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: diff --git a/apps/workspaces/migrations/0003_remove_workspaceuserrate_workspaceuserrate_id_idx_and_more.py b/apps/workspaces/migrations/0003_remove_workspaceuserrate_workspaceuserrate_id_idx_and_more.py index 061036f..33fa123 100644 --- a/apps/workspaces/migrations/0003_remove_workspaceuserrate_workspaceuserrate_id_idx_and_more.py +++ b/apps/workspaces/migrations/0003_remove_workspaceuserrate_workspaceuserrate_id_idx_and_more.py @@ -1,30 +1,30 @@ -# Generated by Django 5.2.12 on 2026-04-26 05:53 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('workspaces', '0002_workspaceuserrate'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.RemoveIndex( - model_name='workspaceuserrate', - name='workspaceuserrate_id_idx', - ), - migrations.AlterField( - model_name='workspaceuserrate', - name='created_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_%(app_label)s_%(class)s_set', to=settings.AUTH_USER_MODEL), - ), - migrations.AlterField( - model_name='workspaceuserrate', - name='updated_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_%(app_label)s_%(class)s_set', to=settings.AUTH_USER_MODEL), - ), - ] +# Generated by Django 5.2.12 on 2026-04-26 05:53 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workspaces', '0002_workspaceuserrate'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RemoveIndex( + model_name='workspaceuserrate', + name='workspaceuserrate_id_idx', + ), + migrations.AlterField( + model_name='workspaceuserrate', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_%(app_label)s_%(class)s_set', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='workspaceuserrate', + name='updated_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_%(app_label)s_%(class)s_set', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/apps/workspaces/migrations/0005_remove_priceunit_priceunit_id_idx_and_more.py b/apps/workspaces/migrations/0005_remove_priceunit_priceunit_id_idx_and_more.py index 22bee31..1b5014e 100644 --- a/apps/workspaces/migrations/0005_remove_priceunit_priceunit_id_idx_and_more.py +++ b/apps/workspaces/migrations/0005_remove_priceunit_priceunit_id_idx_and_more.py @@ -1,30 +1,30 @@ -# Generated by Django 5.2.12 on 2026-04-26 06:25 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('workspaces', '0004_priceunit'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.RemoveIndex( - model_name='priceunit', - name='priceunit_id_idx', - ), - migrations.AlterField( - model_name='priceunit', - name='created_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_%(app_label)s_%(class)s_set', to=settings.AUTH_USER_MODEL), - ), - migrations.AlterField( - model_name='priceunit', - name='updated_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_%(app_label)s_%(class)s_set', to=settings.AUTH_USER_MODEL), - ), - ] +# Generated by Django 5.2.12 on 2026-04-26 06:25 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workspaces', '0004_priceunit'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RemoveIndex( + model_name='priceunit', + name='priceunit_id_idx', + ), + migrations.AlterField( + model_name='priceunit', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_%(app_label)s_%(class)s_set', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='priceunit', + name='updated_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_%(app_label)s_%(class)s_set', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/apps/workspaces/models.py b/apps/workspaces/models.py index 1c91442..77ccc1f 100644 --- a/apps/workspaces/models.py +++ b/apps/workspaces/models.py @@ -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() @@ -26,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( @@ -77,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) @@ -130,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, + }, + ) diff --git a/apps/workspaces/services/__init__.py b/apps/workspaces/services/__init__.py index 8d1d714..d6a0999 100644 --- a/apps/workspaces/services/__init__.py +++ b/apps/workspaces/services/__init__.py @@ -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, @@ -43,6 +44,7 @@ __all__ = [ "WORKSPACE_VIEW", "WORKSPACE_EDIT", "WORKSPACE_DELETE", + "WORKSPACE_LOGS_VIEW", "WORKSPACE_MEMBERS_VIEW", "WORKSPACE_MEMBERS_ADD", "WORKSPACE_MEMBERS_REMOVE", diff --git a/apps/workspaces/services/permissions.py b/apps/workspaces/services/permissions.py index 356936b..2a4ab33 100644 --- a/apps/workspaces/services/permissions.py +++ b/apps/workspaces/services/permissions.py @@ -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, diff --git a/config/services/auditlog.py b/config/services/auditlog.py index 164896e..daa60b6 100644 --- a/config/services/auditlog.py +++ b/config/services/auditlog.py @@ -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, + }, +] diff --git a/config/settings/base.py b/config/settings/base.py index 943e5cc..9999cbd 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -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 diff --git a/config/urls.py b/config/urls.py index b575a98..3ee9003 100644 --- a/config/urls.py +++ b/config/urls.py @@ -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: diff --git a/core/middlewares/current_user.py b/core/middlewares/current_user.py index 480022c..c0a166a 100644 --- a/core/middlewares/current_user.py +++ b/core/middlewares/current_user.py @@ -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