Backend Draft
This commit is contained in:
0
backend/analytics/__init__.py
Normal file
0
backend/analytics/__init__.py
Normal file
BIN
backend/analytics/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
backend/analytics/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/analytics/__pycache__/admin.cpython-314.pyc
Normal file
BIN
backend/analytics/__pycache__/admin.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/analytics/__pycache__/apps.cpython-314.pyc
Normal file
BIN
backend/analytics/__pycache__/apps.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/analytics/__pycache__/models.cpython-314.pyc
Normal file
BIN
backend/analytics/__pycache__/models.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/analytics/__pycache__/serializers.cpython-314.pyc
Normal file
BIN
backend/analytics/__pycache__/serializers.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/analytics/__pycache__/signals.cpython-314.pyc
Normal file
BIN
backend/analytics/__pycache__/signals.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/analytics/__pycache__/urls.cpython-314.pyc
Normal file
BIN
backend/analytics/__pycache__/urls.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/analytics/__pycache__/views.cpython-314.pyc
Normal file
BIN
backend/analytics/__pycache__/views.cpython-314.pyc
Normal file
Binary file not shown.
3
backend/analytics/admin.py
Normal file
3
backend/analytics/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
9
backend/analytics/apps.py
Normal file
9
backend/analytics/apps.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AnalyticsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "analytics"
|
||||
|
||||
def ready(self):
|
||||
import analytics.signals
|
||||
109
backend/analytics/migrations/0001_initial.py
Normal file
109
backend/analytics/migrations/0001_initial.py
Normal file
@@ -0,0 +1,109 @@
|
||||
# Generated by Django 4.2.28 on 2026-02-20 19:49
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("tenants", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="AuditLog",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("action", models.CharField(max_length=50)),
|
||||
("model_name", models.CharField(max_length=100)),
|
||||
("object_id", models.CharField(max_length=255)),
|
||||
("changes", models.JSONField(blank=True, default=dict)),
|
||||
("ip_address", models.GenericIPAddressField(blank=True, null=True)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"tenant",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="audit_logs",
|
||||
to="tenants.tenant",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="audit_logs",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
"indexes": [
|
||||
models.Index(
|
||||
fields=["tenant", "created_at"],
|
||||
name="analytics_a_tenant__9c95af_idx",
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ActivityLog",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("action", models.CharField(max_length=100)),
|
||||
("target_type", models.CharField(max_length=100)),
|
||||
("target_id", models.CharField(max_length=255)),
|
||||
("metadata", models.JSONField(blank=True, default=dict)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"tenant",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="activity_logs",
|
||||
to="tenants.tenant",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="activity_logs",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
"indexes": [
|
||||
models.Index(
|
||||
fields=["tenant", "created_at"],
|
||||
name="analytics_a_tenant__75e066_idx",
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
backend/analytics/migrations/__init__.py
Normal file
0
backend/analytics/migrations/__init__.py
Normal file
Binary file not shown.
Binary file not shown.
45
backend/analytics/models.py
Normal file
45
backend/analytics/models.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from tenants.managers import TenantScopedManager
|
||||
from tenants.models import Tenant
|
||||
|
||||
class AuditLog(models.Model):
|
||||
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='audit_logs')
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name='audit_logs')
|
||||
action = models.CharField(max_length=50) # created, updated, deleted
|
||||
model_name = models.CharField(max_length=100)
|
||||
object_id = models.CharField(max_length=255)
|
||||
changes = models.JSONField(default=dict, blank=True)
|
||||
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
objects = TenantScopedManager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['tenant', 'created_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.action} on {self.model_name}:{self.object_id} by {self.user}"
|
||||
|
||||
class ActivityLog(models.Model):
|
||||
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='activity_logs')
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name='activity_logs')
|
||||
action = models.CharField(max_length=100)
|
||||
target_type = models.CharField(max_length=100)
|
||||
target_id = models.CharField(max_length=255)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
objects = TenantScopedManager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['tenant', 'created_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user} {self.action} {self.target_type}:{self.target_id}"
|
||||
18
backend/analytics/serializers.py
Normal file
18
backend/analytics/serializers.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from rest_framework import serializers
|
||||
from .models import ActivityLog, AuditLog
|
||||
|
||||
class ActivityLogSerializer(serializers.ModelSerializer):
|
||||
user_name = serializers.CharField(source='user.get_full_name', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ActivityLog
|
||||
fields = ['id', 'user_name', 'action', 'target_type', 'target_id', 'metadata', 'created_at']
|
||||
read_only_fields = ['id', 'user_name', 'action', 'target_type', 'target_id', 'metadata', 'created_at']
|
||||
|
||||
class AuditLogSerializer(serializers.ModelSerializer):
|
||||
user_name = serializers.CharField(source='user.get_full_name', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = AuditLog
|
||||
fields = ['id', 'user_name', 'action', 'model_name', 'object_id', 'changes', 'ip_address', 'created_at']
|
||||
read_only_fields = ['id', 'user_name', 'action', 'model_name', 'object_id', 'changes', 'ip_address', 'created_at']
|
||||
162
backend/analytics/signals.py
Normal file
162
backend/analytics/signals.py
Normal file
@@ -0,0 +1,162 @@
|
||||
from django.db.models.signals import post_save, post_delete, pre_save
|
||||
from django.dispatch import receiver
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from .models import ActivityLog, AuditLog
|
||||
import json
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
import inspect
|
||||
|
||||
# Try to import models dynamically to avoid circular imports during startup
|
||||
# We'll use get_model where possible, but for signals we need the actual models
|
||||
from projects.models import Project, Task
|
||||
|
||||
def get_current_request():
|
||||
"""
|
||||
A hacky way to get the current request in signals.
|
||||
In a real production app, you'd use a middleware like django-crum
|
||||
or pass the user explicitly.
|
||||
For this prototype, we'll try to find the request in the stack.
|
||||
"""
|
||||
for f in inspect.stack():
|
||||
if 'request' in f[0].f_locals:
|
||||
return f[0].f_locals['request']
|
||||
return None
|
||||
|
||||
def get_user_and_tenant_from_instance_or_request(instance):
|
||||
request = get_current_request()
|
||||
user = getattr(request, 'user', None) if request else None
|
||||
|
||||
# If user is anonymous, try to get from instance
|
||||
if user and user.is_authenticated:
|
||||
pass
|
||||
elif hasattr(instance, 'created_by') and instance.created_by:
|
||||
user = instance.created_by
|
||||
elif hasattr(instance, 'user') and instance.user:
|
||||
user = instance.user
|
||||
|
||||
tenant = getattr(request, 'tenant', None) if request else getattr(instance, 'tenant', None)
|
||||
|
||||
return user, tenant
|
||||
|
||||
@receiver(post_save, sender=Project)
|
||||
@receiver(post_save, sender=Task)
|
||||
def log_activity_on_save(sender, instance, created, **kwargs):
|
||||
user, tenant = get_user_and_tenant_from_instance_or_request(instance)
|
||||
|
||||
if not tenant:
|
||||
return # Cannot log without tenant
|
||||
|
||||
action = "created" if created else "updated"
|
||||
target_type = sender.__name__
|
||||
|
||||
metadata = {}
|
||||
if hasattr(instance, 'name'):
|
||||
metadata['name'] = instance.name
|
||||
elif hasattr(instance, 'title'):
|
||||
metadata['title'] = instance.title
|
||||
|
||||
ActivityLog.objects.create(
|
||||
tenant=tenant,
|
||||
user=user if (user and user.is_authenticated) else None,
|
||||
action=action,
|
||||
target_type=target_type,
|
||||
target_id=str(instance.id),
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
@receiver(post_delete, sender=Project)
|
||||
@receiver(post_delete, sender=Task)
|
||||
def log_activity_on_delete(sender, instance, **kwargs):
|
||||
user, tenant = get_user_and_tenant_from_instance_or_request(instance)
|
||||
|
||||
if not tenant:
|
||||
return
|
||||
|
||||
action = "deleted"
|
||||
target_type = sender.__name__
|
||||
|
||||
metadata = {}
|
||||
if hasattr(instance, 'name'):
|
||||
metadata['name'] = instance.name
|
||||
elif hasattr(instance, 'title'):
|
||||
metadata['title'] = instance.title
|
||||
|
||||
ActivityLog.objects.create(
|
||||
tenant=tenant,
|
||||
user=user if (user and user.is_authenticated) else None,
|
||||
action=action,
|
||||
target_type=target_type,
|
||||
target_id=str(instance.id),
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
# --- Audit Logs ---
|
||||
|
||||
# Small hack: Store the original state before saving
|
||||
@receiver(pre_save, sender=Project)
|
||||
@receiver(pre_save, sender=Task)
|
||||
def store_original_state(sender, instance, **kwargs):
|
||||
if instance.pk:
|
||||
try:
|
||||
old_instance = sender.objects.get(pk=instance.pk)
|
||||
# Store it on the instance for the post_save signal
|
||||
instance._old_state = {f.name: getattr(old_instance, f.name) for f in sender._meta.fields}
|
||||
except sender.DoesNotExist:
|
||||
pass
|
||||
|
||||
@receiver(post_save, sender=Project)
|
||||
@receiver(post_save, sender=Task)
|
||||
def log_audit_on_save(sender, instance, created, **kwargs):
|
||||
user, tenant = get_user_and_tenant_from_instance_or_request(instance)
|
||||
request = get_current_request()
|
||||
|
||||
if not tenant:
|
||||
return
|
||||
|
||||
action = "created" if created else "updated"
|
||||
|
||||
changes = {}
|
||||
if not created and hasattr(instance, '_old_state'):
|
||||
for f in sender._meta.fields:
|
||||
old_val = instance._old_state.get(f.name)
|
||||
new_val = getattr(instance, f.name)
|
||||
if old_val != new_val:
|
||||
# Basic stringification for JSON serialization
|
||||
changes[f.name] = {
|
||||
'old': str(old_val),
|
||||
'new': str(new_val)
|
||||
}
|
||||
elif created:
|
||||
for f in sender._meta.fields:
|
||||
changes[f.name] = {'new': str(getattr(instance, f.name))}
|
||||
|
||||
AuditLog.objects.create(
|
||||
tenant=tenant,
|
||||
user=user if (user and user.is_authenticated) else None,
|
||||
action=action,
|
||||
model_name=sender.__name__,
|
||||
object_id=str(instance.id),
|
||||
changes=changes,
|
||||
ip_address=request.META.get('REMOTE_ADDR') if request else None
|
||||
)
|
||||
|
||||
@receiver(post_delete, sender=Project)
|
||||
@receiver(post_delete, sender=Task)
|
||||
def log_audit_on_delete(sender, instance, **kwargs):
|
||||
user, tenant = get_user_and_tenant_from_instance_or_request(instance)
|
||||
request = get_current_request()
|
||||
|
||||
if not tenant:
|
||||
return
|
||||
|
||||
changes = {f.name: {'old': str(getattr(instance, f.name))} for f in sender._meta.fields}
|
||||
|
||||
AuditLog.objects.create(
|
||||
tenant=tenant,
|
||||
user=user if (user and user.is_authenticated) else None,
|
||||
action="deleted",
|
||||
model_name=sender.__name__,
|
||||
object_id=str(instance.id),
|
||||
changes=changes,
|
||||
ip_address=request.META.get('REMOTE_ADDR') if request else None
|
||||
)
|
||||
3
backend/analytics/tests.py
Normal file
3
backend/analytics/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
7
backend/analytics/urls.py
Normal file
7
backend/analytics/urls.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.urls import path
|
||||
from .views import ActivityLogListView, ReportView
|
||||
|
||||
urlpatterns = [
|
||||
path('activity/', ActivityLogListView.as_view(), name='activity-list'),
|
||||
path('reports/', ReportView.as_view(), name='reports'),
|
||||
]
|
||||
95
backend/analytics/views.py
Normal file
95
backend/analytics/views.py
Normal file
@@ -0,0 +1,95 @@
|
||||
from rest_framework import generics, permissions, status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from django.db.models import Count
|
||||
from django.db.models.functions import TruncDate
|
||||
from django.utils.dateparse import parse_date
|
||||
from .models import ActivityLog, AuditLog
|
||||
from .serializers import ActivityLogSerializer, AuditLogSerializer
|
||||
from accounts.permissions import IsTenantUser, IsAdmin
|
||||
from projects.models import Project, Task
|
||||
|
||||
class ActivityLogListView(generics.ListAPIView):
|
||||
serializer_class = ActivityLogSerializer
|
||||
permission_classes = [permissions.IsAuthenticated, IsTenantUser]
|
||||
|
||||
def get_queryset(self):
|
||||
return ActivityLog.objects.filter(tenant=self.request.tenant)
|
||||
|
||||
class ReportView(APIView):
|
||||
"""
|
||||
Returns aggregated stats for a date range with daily breakdown
|
||||
Query params: start_date (YYYY-MM-DD), end_date (YYYY-MM-DD)
|
||||
"""
|
||||
permission_classes = [permissions.IsAuthenticated, IsTenantUser, IsAdmin] # Only admins can see reports usually
|
||||
|
||||
def get(self, request):
|
||||
tenant = request.tenant
|
||||
start_date_str = request.query_params.get('start_date')
|
||||
end_date_str = request.query_params.get('end_date')
|
||||
|
||||
project_qs = Project.objects.filter(tenant=tenant)
|
||||
task_qs = Task.objects.filter(tenant=tenant)
|
||||
|
||||
if start_date_str:
|
||||
start_date = parse_date(start_date_str)
|
||||
if start_date:
|
||||
project_qs = project_qs.filter(created_at__date__gte=start_date)
|
||||
task_qs = task_qs.filter(created_at__date__gte=start_date)
|
||||
|
||||
if end_date_str:
|
||||
end_date = parse_date(end_date_str)
|
||||
if end_date:
|
||||
project_qs = project_qs.filter(created_at__date__lte=end_date)
|
||||
task_qs = task_qs.filter(created_at__date__lte=end_date)
|
||||
|
||||
# Total counts
|
||||
projects_created = project_qs.count()
|
||||
tasks_created = task_qs.count()
|
||||
tasks_completed = task_qs.filter(status='done').count()
|
||||
|
||||
# Daily metrics: group by date
|
||||
from django.db.models.functions import TruncDate
|
||||
|
||||
daily_projects = project_qs.annotate(day=TruncDate('created_at')).values('day').annotate(count=Count('id')).order_by('day')
|
||||
daily_tasks_completed = task_qs.filter(status='done').annotate(day=TruncDate('created_at')).values('day').annotate(count=Count('id')).order_by('day')
|
||||
daily_tasks_created = task_qs.annotate(day=TruncDate('created_at')).values('day').annotate(count=Count('id')).order_by('day')
|
||||
|
||||
# Build daily metrics array
|
||||
daily_metrics = []
|
||||
# Collect all unique dates from both projects and tasks
|
||||
all_dates = set()
|
||||
for item in daily_projects:
|
||||
all_dates.add(item['day'])
|
||||
for item in daily_tasks_completed:
|
||||
all_dates.add(item['day'])
|
||||
for item in daily_tasks_created:
|
||||
all_dates.add(item['day'])
|
||||
|
||||
# Sort dates
|
||||
sorted_dates = sorted(all_dates)
|
||||
|
||||
for date_obj in sorted_dates:
|
||||
date_str = date_obj.strftime('%Y-%m-%d')
|
||||
# Find counts for this date
|
||||
proj_count = next((item['count'] for item in daily_projects if item['day'] == date_obj), 0)
|
||||
tasks_comp = next((item['count'] for item in daily_tasks_completed if item['day'] == date_obj), 0)
|
||||
tasks_created_count = next((item['count'] for item in daily_tasks_created if item['day'] == date_obj), 0)
|
||||
|
||||
daily_metrics.append({
|
||||
'date': date_str,
|
||||
'projects_created': proj_count,
|
||||
'tasks_completed': tasks_comp,
|
||||
'tasks_created': tasks_created_count,
|
||||
})
|
||||
|
||||
return Response({
|
||||
'period': {
|
||||
'start': start_date_str,
|
||||
'end': end_date_str
|
||||
},
|
||||
'total_projects': projects_created,
|
||||
'total_tasks': tasks_created,
|
||||
'tasks_completed': tasks_completed,
|
||||
'daily_metrics': daily_metrics
|
||||
})
|
||||
Reference in New Issue
Block a user