Backend Draft

This commit is contained in:
__init__
2026-02-23 20:31:53 +05:30
commit eec700af51
127 changed files with 2356 additions and 0 deletions

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View 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

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

View File

View 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}"

View 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']

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

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

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

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