feat(logs): add workspace activity log api
This commit is contained in:
10
.gitignore
vendored
10
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
0
apps/logs/__init__.py
Normal file
0
apps/logs/__init__.py
Normal file
2
apps/logs/admin.py
Normal file
2
apps/logs/admin.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Admin registrations for apps.logs live on django-auditlog."""
|
||||
|
||||
12
apps/logs/api/permissions.py
Normal file
12
apps/logs/api/permissions.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
|
||||
from apps.logs.services import WORKSPACE_LOGS_VIEW
|
||||
from apps.workspaces.services import has_workspace_capability
|
||||
|
||||
|
||||
def enforce_workspace_log_access(user, workspace) -> None:
|
||||
if not user or not user.is_authenticated:
|
||||
raise PermissionDenied("Authentication credentials were not provided.")
|
||||
if not has_workspace_capability(user, workspace, WORKSPACE_LOGS_VIEW):
|
||||
raise PermissionDenied("You do not have permission to view workspace logs.")
|
||||
|
||||
32
apps/logs/api/serializers.py
Normal file
32
apps/logs/api/serializers.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.logs.services import LOG_EVENTS, LOG_SECTIONS
|
||||
from apps.logs.services.query import (
|
||||
serialize_workspace_log_detail,
|
||||
serialize_workspace_log_list_item,
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceLogQuerySerializer(serializers.Serializer):
|
||||
workspace = serializers.UUIDField()
|
||||
section = serializers.ChoiceField(choices=LOG_SECTIONS, required=False)
|
||||
actor = serializers.UUIDField(required=False)
|
||||
event = serializers.ChoiceField(choices=LOG_EVENTS, required=False)
|
||||
search = serializers.CharField(required=False, allow_blank=True, trim_whitespace=True)
|
||||
from_date = serializers.CharField(required=False, allow_blank=True, source="from")
|
||||
to_date = serializers.CharField(required=False, allow_blank=True, source="to")
|
||||
ordering = serializers.ChoiceField(
|
||||
choices=("timestamp", "-timestamp"),
|
||||
required=False,
|
||||
default="-timestamp",
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceLogListSerializer(serializers.Serializer):
|
||||
def to_representation(self, instance):
|
||||
return serialize_workspace_log_list_item(instance)
|
||||
|
||||
|
||||
class WorkspaceLogDetailSerializer(serializers.Serializer):
|
||||
def to_representation(self, instance):
|
||||
return serialize_workspace_log_detail(instance)
|
||||
12
apps/logs/api/urls.py
Normal file
12
apps/logs/api/urls.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from apps.logs.api.views import WorkspaceLogViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r"", WorkspaceLogViewSet, basename="workspace-logs")
|
||||
|
||||
urlpatterns = [
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
|
||||
80
apps/logs/api/views.py
Normal file
80
apps/logs/api/views.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from auditlog.models import LogEntry
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
from apps.logs.api.permissions import enforce_workspace_log_access
|
||||
from apps.logs.api.serializers import (
|
||||
WorkspaceLogDetailSerializer,
|
||||
WorkspaceLogListSerializer,
|
||||
WorkspaceLogQuerySerializer,
|
||||
)
|
||||
from apps.logs.services import (
|
||||
WorkspaceLogFilters,
|
||||
filter_workspace_logs,
|
||||
get_log_workspace_id,
|
||||
is_visible_workspace_log,
|
||||
parse_filter_datetime,
|
||||
)
|
||||
from apps.workspaces.models import Workspace
|
||||
from core.paginations.limit_offset import CustomLimitOffsetPagination
|
||||
|
||||
|
||||
class WorkspaceLogViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
pagination_class = CustomLimitOffsetPagination
|
||||
|
||||
def get_queryset(self):
|
||||
return LogEntry.objects.select_related("actor", "content_type").order_by("-timestamp")
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "retrieve":
|
||||
return WorkspaceLogDetailSerializer
|
||||
return WorkspaceLogListSerializer
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
query_data = request.query_params.copy()
|
||||
if "from" in query_data:
|
||||
query_data["from_date"] = query_data.get("from")
|
||||
if "to" in query_data:
|
||||
query_data["to_date"] = query_data.get("to")
|
||||
|
||||
query_serializer = WorkspaceLogQuerySerializer(data=query_data)
|
||||
query_serializer.is_valid(raise_exception=True)
|
||||
filters_data = query_serializer.validated_data
|
||||
|
||||
workspace = get_object_or_404(
|
||||
Workspace,
|
||||
id=filters_data["workspace"],
|
||||
is_deleted=False,
|
||||
)
|
||||
enforce_workspace_log_access(request.user, workspace)
|
||||
|
||||
filters = WorkspaceLogFilters(
|
||||
workspace_id=str(workspace.id),
|
||||
section=filters_data.get("section"),
|
||||
actor_id=str(filters_data["actor"]) if filters_data.get("actor") else None,
|
||||
event=filters_data.get("event"),
|
||||
search=filters_data.get("search") or None,
|
||||
from_value=parse_filter_datetime(query_data.get("from")),
|
||||
to_value=parse_filter_datetime(query_data.get("to"), is_end=True),
|
||||
ordering=filters_data.get("ordering", "-timestamp"),
|
||||
)
|
||||
|
||||
queryset = filter_workspace_logs(self.get_queryset(), filters)
|
||||
page = self.paginate_queryset(queryset)
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
entry = self.get_object()
|
||||
workspace_id = get_log_workspace_id(entry)
|
||||
if not workspace_id:
|
||||
return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||
workspace = get_object_or_404(Workspace, id=workspace_id, is_deleted=False)
|
||||
enforce_workspace_log_access(request.user, workspace)
|
||||
if not is_visible_workspace_log(entry):
|
||||
return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||
serializer = self.get_serializer(entry)
|
||||
return Response(serializer.data)
|
||||
8
apps/logs/apps.py
Normal file
8
apps/logs/apps.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class LogsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.logs"
|
||||
verbose_name = "Workspace Logs"
|
||||
|
||||
29
apps/logs/middlewares.py
Normal file
29
apps/logs/middlewares.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||
|
||||
|
||||
class JWTRequestActorMiddleware:
|
||||
"""
|
||||
Resolve Bearer tokens before DRF runs so middleware-driven audit hooks
|
||||
can see the authenticated actor on API requests.
|
||||
"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
self.authenticator = JWTAuthentication()
|
||||
|
||||
def __call__(self, request):
|
||||
current_user = getattr(request, "user", None)
|
||||
if not getattr(current_user, "is_authenticated", False):
|
||||
try:
|
||||
authenticated = self.authenticator.authenticate(request)
|
||||
except Exception:
|
||||
authenticated = None
|
||||
|
||||
if authenticated is not None:
|
||||
request.user = authenticated[0]
|
||||
elif current_user is None:
|
||||
request.user = AnonymousUser()
|
||||
|
||||
return self.get_response(request)
|
||||
|
||||
0
apps/logs/migrations/__init__.py
Normal file
0
apps/logs/migrations/__init__.py
Normal file
2
apps/logs/models.py
Normal file
2
apps/logs/models.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Workspace logs are backed by django-auditlog models."""
|
||||
|
||||
54
apps/logs/services/__init__.py
Normal file
54
apps/logs/services/__init__.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from apps.logs.services.constants import (
|
||||
EVENT_ACTIVATE,
|
||||
EVENT_ARCHIVE,
|
||||
EVENT_CREATE,
|
||||
EVENT_DEACTIVATE,
|
||||
EVENT_DELETE,
|
||||
EVENT_RESTORE,
|
||||
EVENT_UNARCHIVE,
|
||||
EVENT_UPDATE,
|
||||
LOG_EVENTS,
|
||||
LOG_SECTIONS,
|
||||
WORKSPACE_LOGS_VIEW,
|
||||
)
|
||||
from apps.logs.services.metadata import build_workspace_log_metadata
|
||||
from apps.logs.services.query import (
|
||||
WorkspaceLogFilters,
|
||||
build_log_target_payload,
|
||||
filter_workspace_logs,
|
||||
get_log_change_rows,
|
||||
get_log_section,
|
||||
get_log_workspace_id,
|
||||
is_visible_workspace_log,
|
||||
normalize_log_event,
|
||||
parse_filter_datetime,
|
||||
serialize_workspace_log_detail,
|
||||
serialize_workspace_log_list_item,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"WORKSPACE_LOGS_VIEW",
|
||||
"LOG_SECTIONS",
|
||||
"LOG_EVENTS",
|
||||
"EVENT_CREATE",
|
||||
"EVENT_UPDATE",
|
||||
"EVENT_DELETE",
|
||||
"EVENT_RESTORE",
|
||||
"EVENT_ARCHIVE",
|
||||
"EVENT_UNARCHIVE",
|
||||
"EVENT_ACTIVATE",
|
||||
"EVENT_DEACTIVATE",
|
||||
"WorkspaceLogFilters",
|
||||
"build_workspace_log_metadata",
|
||||
"get_log_section",
|
||||
"get_log_workspace_id",
|
||||
"normalize_log_event",
|
||||
"get_log_change_rows",
|
||||
"build_log_target_payload",
|
||||
"filter_workspace_logs",
|
||||
"is_visible_workspace_log",
|
||||
"serialize_workspace_log_list_item",
|
||||
"serialize_workspace_log_detail",
|
||||
"parse_filter_datetime",
|
||||
]
|
||||
|
||||
58
apps/logs/services/constants.py
Normal file
58
apps/logs/services/constants.py
Normal file
@@ -0,0 +1,58 @@
|
||||
WORKSPACE_LOGS_VIEW = "workspace.logs.view"
|
||||
|
||||
SECTION_WORKSPACE = "workspace"
|
||||
SECTION_WORKSPACE_MEMBERS = "workspace_members"
|
||||
SECTION_CLIENTS = "clients"
|
||||
SECTION_PROJECTS = "projects"
|
||||
SECTION_PROJECT_MEMBERS = "project_members"
|
||||
SECTION_TAGS = "tags"
|
||||
SECTION_TIME_ENTRIES = "time_entries"
|
||||
SECTION_RATES = "rates"
|
||||
SECTION_REPORT_EXPORTS = "report_exports"
|
||||
|
||||
LOG_SECTIONS = (
|
||||
SECTION_WORKSPACE,
|
||||
SECTION_WORKSPACE_MEMBERS,
|
||||
SECTION_CLIENTS,
|
||||
SECTION_PROJECTS,
|
||||
SECTION_PROJECT_MEMBERS,
|
||||
SECTION_TAGS,
|
||||
SECTION_TIME_ENTRIES,
|
||||
SECTION_RATES,
|
||||
SECTION_REPORT_EXPORTS,
|
||||
)
|
||||
|
||||
EVENT_CREATE = "create"
|
||||
EVENT_UPDATE = "update"
|
||||
EVENT_DELETE = "delete"
|
||||
EVENT_RESTORE = "restore"
|
||||
EVENT_ARCHIVE = "archive"
|
||||
EVENT_UNARCHIVE = "unarchive"
|
||||
EVENT_ACTIVATE = "activate"
|
||||
EVENT_DEACTIVATE = "deactivate"
|
||||
|
||||
LOG_EVENTS = (
|
||||
EVENT_CREATE,
|
||||
EVENT_UPDATE,
|
||||
EVENT_DELETE,
|
||||
EVENT_RESTORE,
|
||||
EVENT_ARCHIVE,
|
||||
EVENT_UNARCHIVE,
|
||||
EVENT_ACTIVATE,
|
||||
EVENT_DEACTIVATE,
|
||||
)
|
||||
|
||||
SECTION_BY_MODEL_LABEL = {
|
||||
"workspaces.workspace": SECTION_WORKSPACE,
|
||||
"workspaces.workspacemembership": SECTION_WORKSPACE_MEMBERS,
|
||||
"workspaces.workspaceuserrate": SECTION_RATES,
|
||||
"clients.client": SECTION_CLIENTS,
|
||||
"projects.project": SECTION_PROJECTS,
|
||||
"projects.projectmembership": SECTION_PROJECT_MEMBERS,
|
||||
"tags.tag": SECTION_TAGS,
|
||||
"time_entries.timeentry": SECTION_TIME_ENTRIES,
|
||||
"reports.reportexportjob": SECTION_REPORT_EXPORTS,
|
||||
}
|
||||
|
||||
TRACKED_MODEL_LABELS = tuple(SECTION_BY_MODEL_LABEL.keys())
|
||||
|
||||
27
apps/logs/services/metadata.py
Normal file
27
apps/logs/services/metadata.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def build_workspace_log_metadata(
|
||||
*,
|
||||
section: str,
|
||||
workspace_id,
|
||||
target_id,
|
||||
target_label: str,
|
||||
extra: dict | None = None,
|
||||
) -> dict:
|
||||
metadata = {
|
||||
"workspace_id": str(workspace_id),
|
||||
"section": section,
|
||||
"target_id": str(target_id),
|
||||
"target_label": target_label,
|
||||
}
|
||||
if extra:
|
||||
metadata.update(
|
||||
{
|
||||
key: value
|
||||
for key, value in extra.items()
|
||||
if value is not None
|
||||
}
|
||||
)
|
||||
return metadata
|
||||
|
||||
297
apps/logs/services/query.py
Normal file
297
apps/logs/services/query.py
Normal file
@@ -0,0 +1,297 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, time
|
||||
|
||||
from django.db.models import Q, QuerySet, TextField
|
||||
from django.db.models.functions import Cast
|
||||
from django.utils import timezone
|
||||
from django.utils.dateparse import parse_date, parse_datetime
|
||||
|
||||
from auditlog.models import LogEntry
|
||||
|
||||
from apps.logs.services.constants import (
|
||||
EVENT_ACTIVATE,
|
||||
EVENT_ARCHIVE,
|
||||
EVENT_CREATE,
|
||||
EVENT_DEACTIVATE,
|
||||
EVENT_DELETE,
|
||||
EVENT_RESTORE,
|
||||
EVENT_UNARCHIVE,
|
||||
EVENT_UPDATE,
|
||||
LOG_EVENTS,
|
||||
LOG_SECTIONS,
|
||||
SECTION_BY_MODEL_LABEL,
|
||||
SECTION_REPORT_EXPORTS,
|
||||
SECTION_WORKSPACE_MEMBERS,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkspaceLogFilters:
|
||||
workspace_id: str
|
||||
section: str | None = None
|
||||
actor_id: str | None = None
|
||||
event: str | None = None
|
||||
search: str | None = None
|
||||
from_value: datetime | None = None
|
||||
to_value: datetime | None = None
|
||||
ordering: str = "-timestamp"
|
||||
|
||||
|
||||
def get_log_model_label(entry: LogEntry) -> str | None:
|
||||
if not entry.content_type_id:
|
||||
return None
|
||||
return f"{entry.content_type.app_label}.{entry.content_type.model}"
|
||||
|
||||
|
||||
def get_log_section(entry: LogEntry) -> str | None:
|
||||
additional_data = entry.additional_data or {}
|
||||
section = additional_data.get("section")
|
||||
if section in LOG_SECTIONS:
|
||||
return section
|
||||
return SECTION_BY_MODEL_LABEL.get(get_log_model_label(entry) or "")
|
||||
|
||||
|
||||
def get_log_workspace_id(entry: LogEntry) -> str | None:
|
||||
additional_data = entry.additional_data or {}
|
||||
workspace_id = additional_data.get("workspace_id")
|
||||
return str(workspace_id) if workspace_id else None
|
||||
|
||||
|
||||
def normalize_log_event(entry: LogEntry) -> str:
|
||||
changes = entry.changes or {}
|
||||
|
||||
is_deleted = changes.get("is_deleted")
|
||||
if isinstance(is_deleted, (list, tuple)) and len(is_deleted) >= 2:
|
||||
if is_deleted[0] in {False, "False", "false", None, "None"} and is_deleted[1] in {True, "True", "true"}:
|
||||
return EVENT_DELETE
|
||||
if is_deleted[0] in {True, "True", "true"} and is_deleted[1] in {False, "False", "false", None, "None"}:
|
||||
return EVENT_RESTORE
|
||||
|
||||
is_archived = changes.get("is_archived")
|
||||
if isinstance(is_archived, (list, tuple)) and len(is_archived) >= 2:
|
||||
if is_archived[0] in {False, "False", "false", None, "None"} and is_archived[1] in {True, "True", "true"}:
|
||||
return EVENT_ARCHIVE
|
||||
if is_archived[0] in {True, "True", "true"} and is_archived[1] in {False, "False", "false", None, "None"}:
|
||||
return EVENT_UNARCHIVE
|
||||
|
||||
is_active = changes.get("is_active")
|
||||
if isinstance(is_active, (list, tuple)) and len(is_active) >= 2:
|
||||
if is_active[0] in {False, "False", "false", None, "None"} and is_active[1] in {True, "True", "true"}:
|
||||
return EVENT_ACTIVATE
|
||||
if is_active[0] in {True, "True", "true"} and is_active[1] in {False, "False", "false", None, "None"}:
|
||||
return EVENT_DEACTIVATE
|
||||
|
||||
if entry.action == LogEntry.Action.CREATE:
|
||||
return EVENT_CREATE
|
||||
return EVENT_UPDATE
|
||||
|
||||
|
||||
def _stringify_change_value(value) -> str | None:
|
||||
if value in (None, "", "None"):
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if isinstance(value, list):
|
||||
return ", ".join(str(item) for item in value if item not in (None, ""))
|
||||
return str(value)
|
||||
|
||||
|
||||
def get_log_change_rows(entry: LogEntry) -> list[dict]:
|
||||
changes = entry.changes or {}
|
||||
display_changes = {}
|
||||
try:
|
||||
display_changes = entry.changes_display_dict or {}
|
||||
except Exception:
|
||||
display_changes = {}
|
||||
|
||||
rows = []
|
||||
for field_name, raw_value in changes.items():
|
||||
if isinstance(raw_value, dict) and raw_value.get("type") == "m2m":
|
||||
operation = raw_value.get("operation", "update")
|
||||
objects = [str(item) for item in raw_value.get("objects", [])]
|
||||
rows.append(
|
||||
{
|
||||
"field": field_name,
|
||||
"label": field_name.replace("_", " ").title(),
|
||||
"change_type": "m2m",
|
||||
"operation": operation,
|
||||
"old_value": None,
|
||||
"new_value": ", ".join(objects) if objects else None,
|
||||
"summary": f"{operation.title()}: {', '.join(objects)}" if objects else operation.title(),
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
old_value = None
|
||||
new_value = None
|
||||
display_value = display_changes.get(field_name.replace("_", " ").title()) or display_changes.get(field_name)
|
||||
if isinstance(display_value, (list, tuple)) and len(display_value) >= 2:
|
||||
old_value = _stringify_change_value(display_value[0])
|
||||
new_value = _stringify_change_value(display_value[1])
|
||||
elif isinstance(raw_value, (list, tuple)) and len(raw_value) >= 2:
|
||||
old_value = _stringify_change_value(raw_value[0])
|
||||
new_value = _stringify_change_value(raw_value[1])
|
||||
|
||||
rows.append(
|
||||
{
|
||||
"field": field_name,
|
||||
"label": field_name.replace("_", " ").title(),
|
||||
"change_type": "field",
|
||||
"operation": "replace",
|
||||
"old_value": old_value,
|
||||
"new_value": new_value,
|
||||
"summary": f"{field_name}: {old_value or '-'} -> {new_value or '-'}",
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def get_log_preview_changes(entry: LogEntry, limit: int = 3) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"field": row["field"],
|
||||
"label": row["label"],
|
||||
"summary": row["summary"],
|
||||
}
|
||||
for row in get_log_change_rows(entry)[:limit]
|
||||
]
|
||||
|
||||
|
||||
def is_visible_workspace_log(entry: LogEntry) -> bool:
|
||||
if entry.actor_id is None:
|
||||
return False
|
||||
|
||||
section = get_log_section(entry)
|
||||
if section not in LOG_SECTIONS:
|
||||
return False
|
||||
|
||||
additional_data = entry.additional_data or {}
|
||||
if section == SECTION_REPORT_EXPORTS and entry.actor_id is None:
|
||||
return False
|
||||
|
||||
if (
|
||||
section == SECTION_WORKSPACE_MEMBERS
|
||||
and normalize_log_event(entry) == EVENT_CREATE
|
||||
and additional_data.get("canonical_owner_membership") is True
|
||||
):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def filter_workspace_logs(queryset: QuerySet, filters: WorkspaceLogFilters) -> QuerySet:
|
||||
queryset = queryset.filter(additional_data__workspace_id=filters.workspace_id)
|
||||
|
||||
if filters.section:
|
||||
queryset = queryset.filter(additional_data__section=filters.section)
|
||||
|
||||
if filters.actor_id:
|
||||
queryset = queryset.filter(actor_id=filters.actor_id)
|
||||
|
||||
if filters.from_value:
|
||||
queryset = queryset.filter(timestamp__gte=filters.from_value)
|
||||
|
||||
if filters.to_value:
|
||||
queryset = queryset.filter(timestamp__lte=filters.to_value)
|
||||
|
||||
queryset = queryset.annotate(changes_json_text=Cast("changes", TextField()))
|
||||
|
||||
if filters.search:
|
||||
queryset = queryset.filter(
|
||||
Q(object_repr__icontains=filters.search)
|
||||
| Q(changes_text__icontains=filters.search)
|
||||
| Q(changes_json_text__icontains=filters.search)
|
||||
| Q(actor__first_name__icontains=filters.search)
|
||||
| Q(actor__last_name__icontains=filters.search)
|
||||
| Q(actor__mobile__icontains=filters.search)
|
||||
| Q(additional_data__target_label__icontains=filters.search)
|
||||
)
|
||||
|
||||
queryset = queryset.order_by(filters.ordering)
|
||||
if filters.event:
|
||||
matching_ids = [
|
||||
entry.id
|
||||
for entry in queryset
|
||||
if normalize_log_event(entry) == filters.event and is_visible_workspace_log(entry)
|
||||
]
|
||||
return queryset.filter(id__in=matching_ids)
|
||||
|
||||
matching_ids = [entry.id for entry in queryset if is_visible_workspace_log(entry)]
|
||||
return queryset.filter(id__in=matching_ids)
|
||||
|
||||
|
||||
def build_log_actor_payload(entry: LogEntry) -> dict | None:
|
||||
actor = entry.actor
|
||||
if not actor:
|
||||
return None
|
||||
full_name = actor.full_name if getattr(actor, "full_name", "").strip() else actor.mobile
|
||||
return {
|
||||
"id": str(actor.id),
|
||||
"full_name": full_name,
|
||||
"mobile": actor.mobile,
|
||||
"profile_picture": actor.profile_picture.url if getattr(actor, "profile_picture", None) else None,
|
||||
}
|
||||
|
||||
|
||||
def build_log_target_payload(entry: LogEntry) -> dict:
|
||||
additional_data = entry.additional_data or {}
|
||||
return {
|
||||
"id": str(additional_data.get("target_id") or entry.object_pk),
|
||||
"name": additional_data.get("target_label") or entry.object_repr,
|
||||
"section": get_log_section(entry),
|
||||
"workspace_id": get_log_workspace_id(entry),
|
||||
}
|
||||
|
||||
|
||||
def serialize_workspace_log_list_item(entry: LogEntry) -> dict:
|
||||
rows = get_log_change_rows(entry)
|
||||
return {
|
||||
"id": entry.id,
|
||||
"timestamp": timezone.localtime(entry.timestamp).isoformat(),
|
||||
"section": get_log_section(entry),
|
||||
"model": get_log_model_label(entry),
|
||||
"event": normalize_log_event(entry),
|
||||
"audit_action": entry.get_action_display(),
|
||||
"actor": build_log_actor_payload(entry),
|
||||
"target": build_log_target_payload(entry),
|
||||
"changed_fields": [row["field"] for row in rows],
|
||||
"preview_changes": get_log_preview_changes(entry),
|
||||
}
|
||||
|
||||
|
||||
def serialize_workspace_log_detail(entry: LogEntry) -> dict:
|
||||
payload = serialize_workspace_log_list_item(entry)
|
||||
payload.update(
|
||||
{
|
||||
"remote_addr": entry.remote_addr,
|
||||
"changes": get_log_change_rows(entry),
|
||||
"raw_changes": entry.changes or {},
|
||||
"serialized_snapshot": entry.serialized_data,
|
||||
"additional_data": entry.additional_data or {},
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def parse_filter_datetime(value: str | None, *, is_end: bool = False) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
|
||||
parsed_datetime = parse_datetime(value)
|
||||
if parsed_datetime is not None:
|
||||
if timezone.is_naive(parsed_datetime):
|
||||
return timezone.make_aware(parsed_datetime, timezone.get_current_timezone())
|
||||
return parsed_datetime
|
||||
|
||||
parsed_date = parse_date(value)
|
||||
if parsed_date is None:
|
||||
return None
|
||||
|
||||
boundary = time.max if is_end else time.min
|
||||
return timezone.make_aware(
|
||||
datetime.combine(parsed_date, boundary),
|
||||
timezone.get_current_timezone(),
|
||||
)
|
||||
|
||||
1
apps/logs/tests/__init__.py
Normal file
1
apps/logs/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
181
apps/logs/tests/test_views.py
Normal file
181
apps/logs/tests/test_views.py
Normal file
@@ -0,0 +1,181 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from auditlog.models import LogEntry
|
||||
from rest_framework_simplejwt.tokens import AccessToken
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.reports.models import ReportExportJob
|
||||
from apps.users.models import User
|
||||
from apps.workspaces.models import Workspace, WorkspaceMembership
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def api_client():
|
||||
return APIClient()
|
||||
|
||||
|
||||
def _user(index: int) -> User:
|
||||
return User.objects.create_user(
|
||||
mobile=f"093355500{index:02d}",
|
||||
password="secret123",
|
||||
first_name=f"Log{index}",
|
||||
last_name="User",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def owner(db):
|
||||
return _user(1)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def admin(db):
|
||||
return _user(2)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def member(db):
|
||||
return _user(3)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def outsider(db):
|
||||
return _user(4)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def workspace(owner, admin, member):
|
||||
workspace = Workspace.objects.create(name="Logs WS", description="", owner=owner)
|
||||
WorkspaceMembership.objects.create(
|
||||
workspace=workspace,
|
||||
user=admin,
|
||||
role=WorkspaceMembership.Role.ADMIN,
|
||||
is_active=True,
|
||||
)
|
||||
WorkspaceMembership.objects.create(
|
||||
workspace=workspace,
|
||||
user=member,
|
||||
role=WorkspaceMembership.Role.MEMBER,
|
||||
is_active=True,
|
||||
)
|
||||
return workspace
|
||||
|
||||
|
||||
def _auth_headers(user: User) -> dict:
|
||||
token = str(AccessToken.for_user(user))
|
||||
return {"HTTP_AUTHORIZATION": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _create_tag(client: APIClient, user: User, workspace: Workspace, *, name="Audit Tag"):
|
||||
return client.post(
|
||||
"/api/tags/",
|
||||
{"workspace_id": str(workspace.id), "name": name, "color": "#123456"},
|
||||
format="json",
|
||||
**_auth_headers(user),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_owner_and_admin_can_list_workspace_logs(api_client, owner, admin, workspace):
|
||||
create_response = _create_tag(api_client, owner, workspace)
|
||||
assert create_response.status_code == 201
|
||||
|
||||
owner_response = api_client.get(
|
||||
f"/api/logs/?workspace={workspace.id}",
|
||||
**_auth_headers(owner),
|
||||
)
|
||||
admin_response = api_client.get(
|
||||
f"/api/logs/?workspace={workspace.id}",
|
||||
**_auth_headers(admin),
|
||||
)
|
||||
|
||||
assert owner_response.status_code == 200
|
||||
assert admin_response.status_code == 200
|
||||
assert owner_response.data["items"][0]["section"] == "tags"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_member_and_non_member_cannot_list_workspace_logs(api_client, owner, member, outsider, workspace):
|
||||
_create_tag(api_client, owner, workspace)
|
||||
|
||||
member_response = api_client.get(
|
||||
f"/api/logs/?workspace={workspace.id}",
|
||||
**_auth_headers(member),
|
||||
)
|
||||
outsider_response = api_client.get(
|
||||
f"/api/logs/?workspace={workspace.id}",
|
||||
**_auth_headers(outsider),
|
||||
)
|
||||
|
||||
assert member_response.status_code == 403
|
||||
assert outsider_response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_jwt_authenticated_writes_capture_actor_and_workspace_metadata(api_client, owner, workspace):
|
||||
response = _create_tag(api_client, owner, workspace, name="JWT Tag")
|
||||
assert response.status_code == 201
|
||||
|
||||
log_entry = LogEntry.objects.filter(content_type__app_label="tags").latest("timestamp")
|
||||
|
||||
assert log_entry.actor_id == owner.id
|
||||
assert log_entry.additional_data["workspace_id"] == str(workspace.id)
|
||||
assert log_entry.additional_data["section"] == "tags"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_logs_support_section_filter_and_detail(api_client, owner, workspace):
|
||||
tag_response = _create_tag(api_client, owner, workspace, name="Filtered Tag")
|
||||
assert tag_response.status_code == 201
|
||||
|
||||
list_response = api_client.get(
|
||||
f"/api/logs/?workspace={workspace.id}§ion=tags",
|
||||
**_auth_headers(owner),
|
||||
)
|
||||
|
||||
assert list_response.status_code == 200
|
||||
assert list_response.data["items"]
|
||||
log_id = list_response.data["items"][0]["id"]
|
||||
|
||||
detail_response = api_client.get(
|
||||
f"/api/logs/{log_id}/",
|
||||
**_auth_headers(owner),
|
||||
)
|
||||
|
||||
assert detail_response.status_code == 200
|
||||
assert detail_response.data["target"]["name"] == "Filtered Tag"
|
||||
assert detail_response.data["changes"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_soft_delete_and_actorless_background_logs_are_filtered(api_client, owner, workspace):
|
||||
create_response = _create_tag(api_client, owner, workspace, name="Delete Me")
|
||||
assert create_response.status_code == 201
|
||||
tag_id = create_response.data["id"]
|
||||
|
||||
delete_response = api_client.delete(
|
||||
f"/api/tags/{tag_id}/",
|
||||
**_auth_headers(owner),
|
||||
)
|
||||
assert delete_response.status_code == 204
|
||||
|
||||
ReportExportJob.objects.create(
|
||||
requesting_user=owner,
|
||||
workspace=workspace,
|
||||
export_type=ReportExportJob.ExportType.PDF,
|
||||
filters={"workspace": str(workspace.id)},
|
||||
status=ReportExportJob.Status.PENDING,
|
||||
)
|
||||
|
||||
response = api_client.get(
|
||||
f"/api/logs/?workspace={workspace.id}&event=delete",
|
||||
**_auth_headers(owner),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert any(item["event"] == "delete" and item["section"] == "tags" for item in response.data["items"])
|
||||
assert all(item["section"] != "report_exports" for item in response.data["items"])
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1 +1,68 @@
|
||||
AUDITLOG_INCLUDE_ALL_MODELS = True
|
||||
COMMON_EXCLUDED_FIELDS = [
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"deleted_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
]
|
||||
|
||||
AUDITLOG_INCLUDE_ALL_MODELS = False
|
||||
AUDITLOG_STORE_JSON_CHANGES = True
|
||||
AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT = True
|
||||
AUDITLOG_INCLUDE_TRACKING_MODELS = [
|
||||
{
|
||||
"model": "workspaces.Workspace",
|
||||
"exclude_fields": COMMON_EXCLUDED_FIELDS,
|
||||
"serialize_data": True,
|
||||
"serialize_auditlog_fields_only": True,
|
||||
},
|
||||
{
|
||||
"model": "workspaces.WorkspaceMembership",
|
||||
"exclude_fields": COMMON_EXCLUDED_FIELDS,
|
||||
"serialize_data": True,
|
||||
"serialize_auditlog_fields_only": True,
|
||||
},
|
||||
{
|
||||
"model": "workspaces.WorkspaceUserRate",
|
||||
"exclude_fields": COMMON_EXCLUDED_FIELDS,
|
||||
"serialize_data": True,
|
||||
"serialize_auditlog_fields_only": True,
|
||||
},
|
||||
{
|
||||
"model": "clients.Client",
|
||||
"exclude_fields": COMMON_EXCLUDED_FIELDS,
|
||||
"serialize_data": True,
|
||||
"serialize_auditlog_fields_only": True,
|
||||
},
|
||||
{
|
||||
"model": "projects.Project",
|
||||
"exclude_fields": COMMON_EXCLUDED_FIELDS,
|
||||
"serialize_data": True,
|
||||
"serialize_auditlog_fields_only": True,
|
||||
},
|
||||
{
|
||||
"model": "projects.ProjectMembership",
|
||||
"exclude_fields": COMMON_EXCLUDED_FIELDS,
|
||||
"serialize_data": True,
|
||||
"serialize_auditlog_fields_only": True,
|
||||
},
|
||||
{
|
||||
"model": "tags.Tag",
|
||||
"exclude_fields": COMMON_EXCLUDED_FIELDS,
|
||||
"serialize_data": True,
|
||||
"serialize_auditlog_fields_only": True,
|
||||
},
|
||||
{
|
||||
"model": "time_entries.TimeEntry",
|
||||
"exclude_fields": COMMON_EXCLUDED_FIELDS,
|
||||
"m2m_fields": {"tags"},
|
||||
"serialize_data": True,
|
||||
"serialize_auditlog_fields_only": True,
|
||||
},
|
||||
{
|
||||
"model": "reports.ReportExportJob",
|
||||
"exclude_fields": COMMON_EXCLUDED_FIELDS,
|
||||
"serialize_data": True,
|
||||
"serialize_auditlog_fields_only": True,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -47,6 +47,7 @@ LOCAL_APPS = [
|
||||
"apps.time_entries",
|
||||
"apps.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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user