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.

12
backend/accounts/admin.py Normal file
View File

@@ -0,0 +1,12 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from .models import User
class CustomUserAdmin(UserAdmin):
model = User
fieldsets = UserAdmin.fieldsets + (
('Tenant Info', {'fields': ('tenant', 'role')}),
)
list_display = ['username', 'email', 'first_name', 'last_name', 'role', 'tenant', 'is_staff']
admin.site.register(User, CustomUserAdmin)

6
backend/accounts/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "accounts"

View File

@@ -0,0 +1,154 @@
# Generated by Django 6.0.2 on 2026-02-20 18:33
import django.contrib.auth.validators
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
("tenants", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="User",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("password", models.CharField(max_length=128, verbose_name="password")),
(
"last_login",
models.DateTimeField(
blank=True, null=True, verbose_name="last login"
),
),
(
"is_superuser",
models.BooleanField(
default=False,
help_text="Designates that this user has all permissions without explicitly assigning them.",
verbose_name="superuser status",
),
),
(
"username",
models.CharField(
error_messages={
"unique": "A user with that username already exists."
},
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
max_length=150,
unique=True,
validators=[
django.contrib.auth.validators.UnicodeUsernameValidator()
],
verbose_name="username",
),
),
(
"first_name",
models.CharField(
blank=True, max_length=150, verbose_name="first name"
),
),
(
"last_name",
models.CharField(
blank=True, max_length=150, verbose_name="last name"
),
),
(
"email",
models.EmailField(
blank=True, max_length=254, verbose_name="email address"
),
),
(
"is_staff",
models.BooleanField(
default=False,
help_text="Designates whether the user can log into this admin site.",
verbose_name="staff status",
),
),
(
"is_active",
models.BooleanField(
default=True,
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
verbose_name="active",
),
),
(
"date_joined",
models.DateTimeField(
default=django.utils.timezone.now, verbose_name="date joined"
),
),
(
"role",
models.CharField(
choices=[
("super_admin", "Super Admin"),
("institution_admin", "Institution Admin"),
("teacher", "Teacher"),
("student", "Student"),
("project_manager", "Project Manager"),
],
default="student",
max_length=20,
),
),
(
"groups",
models.ManyToManyField(
blank=True,
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
related_name="user_set",
related_query_name="user",
to="auth.group",
verbose_name="groups",
),
),
(
"tenant",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="users",
to="tenants.tenant",
),
),
(
"user_permissions",
models.ManyToManyField(
blank=True,
help_text="Specific permissions for this user.",
related_name="user_set",
related_query_name="user",
to="auth.permission",
verbose_name="user permissions",
),
),
],
options={
"verbose_name": "user",
"verbose_name_plural": "users",
"abstract": False,
},
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 4.2.28 on 2026-02-20 18:37
import django.contrib.auth.models
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("accounts", "0001_initial"),
]
operations = [
migrations.AlterModelManagers(
name="user",
managers=[
("objects", django.contrib.auth.models.UserManager()),
],
),
]

View File

View File

@@ -0,0 +1,22 @@
from django.contrib.auth.models import AbstractUser, UserManager
from django.db import models
from tenants.models import Tenant
from tenants.managers import TenantScopedManager
class User(AbstractUser):
ROLE_CHOICES = [
('super_admin', 'Super Admin'),
('institution_admin', 'Institution Admin'),
('teacher', 'Teacher'),
('student', 'Student'),
('project_manager', 'Project Manager'),
]
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='users', null=True, blank=True)
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='student')
objects = UserManager() # Default manager for auth operations
tenant_objects = TenantScopedManager() # Custom manager for tenant filtering
def __str__(self):
return self.username

View File

@@ -0,0 +1,57 @@
from rest_framework import permissions
class IsTenantUser(permissions.BasePermission):
"""
Allows access only to authenticated users who belong to a tenant.
Also ensures object-level tenant isolation.
"""
def has_permission(self, request, view):
return bool(request.user and request.user.is_authenticated and request.user.tenant)
def has_object_permission(self, request, view, obj):
if hasattr(obj, 'tenant'):
return obj.tenant == request.user.tenant
return True
class IsAdmin(permissions.BasePermission):
"""
Allows access only to super_admin and institution_admin roles.
"""
def has_permission(self, request, view):
return bool(
request.user and
request.user.is_authenticated and
request.user.role in ['super_admin', 'institution_admin']
)
class IsProjectOwner(permissions.BasePermission):
"""
Allows object level access only to the user who created it.
"""
def has_object_permission(self, request, view, obj):
if hasattr(obj, 'created_by'):
return obj.created_by == request.user
return False
class IsTeacher(permissions.BasePermission):
"""
Allows access only to teacher, institution_admin, or super_admin roles.
"""
def has_permission(self, request, view):
return bool(
request.user and
request.user.is_authenticated and
request.user.role in ['teacher', 'institution_admin', 'super_admin']
)
class IsStudentReadOnly(permissions.BasePermission):
"""
Students get read-only access (GET, HEAD, OPTIONS).
Other roles are allowed (and restricted by other classes).
"""
def has_permission(self, request, view):
if request.user and request.user.is_authenticated:
if request.user.role == 'student':
return request.method in permissions.SAFE_METHODS
return True
return False

View File

@@ -0,0 +1,44 @@
from rest_framework import serializers
from django.contrib.auth import get_user_model
from tenants.serializers import TenantSerializer
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
User = get_user_model()
class CustomTokenObtainPairSerializer(TokenObtainPairSerializer):
@classmethod
def get_token(cls, user):
token = super().get_token(user)
# Add custom claims
token['role'] = getattr(user, 'role', 'student')
token['tenant_id'] = user.tenant.id if getattr(user, 'tenant', None) else None
return token
class UserSerializer(serializers.ModelSerializer):
tenant = TenantSerializer(read_only=True)
class Meta:
model = User
fields = ['id', 'username', 'email', 'first_name', 'last_name', 'role', 'tenant', 'is_active']
read_only_fields = ['id']
class RegisterSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True)
class Meta:
model = User
fields = ['username', 'email', 'password', 'first_name', 'last_name', 'role']
def create(self, validated_data):
user = User.objects.create_user(
username=validated_data['username'],
email=validated_data.get('email', ''),
password=validated_data['password'],
first_name=validated_data.get('first_name', ''),
last_name=validated_data.get('last_name', ''),
role=validated_data.get('role', 'student'),
tenant=self.context['request'].tenant if 'request' in self.context else None
)
return user

59
backend/accounts/tests.py Normal file
View File

@@ -0,0 +1,59 @@
from django.test import TestCase
from django.urls import reverse
from rest_framework.test import APIClient
from rest_framework import status
from django.contrib.auth import get_user_model
from tenants.models import Tenant
User = get_user_model()
class AuthTests(TestCase):
def setUp(self):
self.client = APIClient()
self.tenant = Tenant.objects.create(name='Test Tenant', subdomain='testtenant')
self.register_url = reverse('auth_register')
self.login_url = reverse('token_obtain_pair')
self.profile_url = reverse('user_profile')
self.user_data = {
'username': 'testuser',
'email': 'testuser@example.com',
'password': 'strongpassword123',
'first_name': 'Test',
'last_name': 'User',
'role': 'student'
}
def test_registration(self):
# Register a new user
response = self.client.post(self.register_url, self.user_data, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
user = User.objects.get(username='testuser')
self.assertTrue(user.check_password('strongpassword123'))
self.assertEqual(user.role, 'student')
def test_login_and_profile(self):
# First create the user
user = User.objects.create_user(
username='testuser',
password='strongpassword123',
role='teacher',
tenant=self.tenant
)
# Login to get tokens
login_data = {'username': 'testuser', 'password': 'strongpassword123'}
response = self.client.post(self.login_url, login_data, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn('access', response.data)
self.assertIn('refresh', response.data)
access_token = response.data['access']
# Fetch profile using the token
self.client.credentials(HTTP_AUTHORIZATION='Bearer ' + access_token)
profile_response = self.client.get(self.profile_url)
self.assertEqual(profile_response.status_code, status.HTTP_200_OK)
self.assertEqual(profile_response.data['username'], 'testuser')
self.assertEqual(profile_response.data['role'], 'teacher')
self.assertEqual(profile_response.data['tenant']['id'], self.tenant.id)

11
backend/accounts/urls.py Normal file
View File

@@ -0,0 +1,11 @@
from django.urls import path
from rest_framework_simplejwt.views import TokenRefreshView
from .views import RegisterView, CustomTokenObtainPairView, UserProfileView, UserListView
urlpatterns = [
path('register/', RegisterView.as_view(), name='auth_register'),
path('login/', CustomTokenObtainPairView.as_view(), name='token_obtain_pair'),
path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
path('profile/', UserProfileView.as_view(), name='user_profile'),
path('users/', UserListView.as_view(), name='user_list'),
]

33
backend/accounts/views.py Normal file
View File

@@ -0,0 +1,33 @@
from rest_framework import generics, permissions, viewsets
from rest_framework_simplejwt.views import TokenObtainPairView
from django.contrib.auth import get_user_model
from .serializers import RegisterSerializer, UserSerializer, CustomTokenObtainPairSerializer
User = get_user_model()
class CustomTokenObtainPairView(TokenObtainPairView):
serializer_class = CustomTokenObtainPairSerializer
class RegisterView(generics.CreateAPIView):
queryset = User.objects.all()
serializer_class = RegisterSerializer
permission_classes = [permissions.AllowAny]
class UserProfileView(generics.RetrieveUpdateAPIView):
serializer_class = UserSerializer
permission_classes = [permissions.IsAuthenticated]
def get_object(self):
return self.request.user
class UserListView(generics.ListAPIView):
"""
List all users in the current tenant.
Used for task assignment dropdown.
"""
serializer_class = UserSerializer
permission_classes = [permissions.IsAuthenticated]
pagination_class = None # Return all users as a list, not paginated
def get_queryset(self):
return User.objects.filter(tenant=self.request.tenant, is_active=True).order_by('first_name', 'last_name')

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

3
backend/core/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from .celery import app as celery_app
__all__ = ('celery_app',)

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

7
backend/core/asgi.py Normal file
View File

@@ -0,0 +1,7 @@
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings.dev')
application = get_asgi_application()

20
backend/core/celery.py Normal file
View File

@@ -0,0 +1,20 @@
import os
from celery import Celery
# Set the default Django settings module for the 'celery' program.
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings.dev')
app = Celery('mtcbd')
# Using a string here means the worker doesn't have to serialize
# the configuration object to child processes.
# - namespace='CELERY' means all celery-related configuration keys
# should have a `CELERY_` prefix.
app.config_from_object('django.conf:settings', namespace='CELERY')
# Load task modules from all registered Django apps.
app.autodiscover_tasks()
@app.task(bind=True, ignore_result=True)
def debug_task(self):
print(f'Request: {self.request!r}')

View File

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,158 @@
import os
from pathlib import Path
from datetime import timedelta
from dotenv import load_dotenv
# Load .env file from the project root (not backend dir)
BASE_DIR = Path(__file__).resolve().parent.parent.parent
PROJECT_DIR = BASE_DIR.parent
load_dotenv(PROJECT_DIR / ".env")
SECRET_KEY = os.getenv("SECRET_KEY", "django-insecure-default-key-generate-yours")
# Installed Apps
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
# Third party
"rest_framework",
"corsheaders",
"rest_framework_simplejwt",
"django_extensions",
"drf_spectacular",
"django_filters",
# Local Apps
"tenants",
"accounts",
"dashboard",
"projects",
"analytics",
]
MIDDLEWARE = [
"corsheaders.middleware.CorsMiddleware",
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
# Custom Middleware
"tenants.middleware.TenantMiddleware",
]
ROOT_URLCONF = "core.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "core.wsgi.application"
ASGI_APPLICATION = "core.asgi.application"
# Database defaults
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
# Use Accounts app custom User model
AUTH_USER_MODEL = "accounts.User"
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# REST Framework settings
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": (
"rest_framework_simplejwt.authentication.JWTAuthentication",
),
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 10,
"DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"],
"DEFAULT_THROTTLE_CLASSES": [
"rest_framework.throttling.AnonRateThrottle",
"rest_framework.throttling.UserRateThrottle"
],
"DEFAULT_THROTTLE_RATES": {
"anon": "100/day",
"user": "1000/day"
}
}
# Simple JWT Settings
SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=60),
"REFRESH_TOKEN_LIFETIME": timedelta(days=7),
"ROTATE_REFRESH_TOKENS": True,
"AUTH_HEADER_TYPES": ("Bearer",),
}
# DRF Spectacular
SPECTACULAR_SETTINGS = {
"TITLE": "MTCBD API",
"DESCRIPTION": "Multi-Tenant Cloud Based Dashboard APIs",
"VERSION": "1.0.0",
"SERVE_INCLUDE_SCHEMA": False,
}
# Redis Caching
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": os.getenv("REDIS_URL", "redis://127.0.0.1:6379/1"),
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
}
}
}
# Celery Configuration
CELERY_BROKER_URL = os.getenv("REDIS_URL", "redis://127.0.0.1:6379/2")
CELERY_RESULT_BACKEND = os.getenv("REDIS_URL", "redis://127.0.0.1:6379/2")
CELERY_ACCEPT_CONTENT = ['json']
CELERY_TASK_SERIALIZER = 'json'

View File

@@ -0,0 +1,56 @@
from .base import *
import os
DEBUG = True
ALLOWED_HOSTS = ["*"]
CORS_ALLOW_ALL_ORIGINS = True
CORS_ALLOW_HEADERS = [
"accept",
"accept-encoding",
"authorization",
"content-type",
"dnt",
"origin",
"user-agent",
"x-csrftoken",
"x-requested-with",
"x-tenant-id", # Allow custom tenant header
]
CORS_EXPOSE_HEADERS = ["content-type", "x-tenant-id"]
# Ensure SQLite is used for early local dev if env variables are missing for mysql
_DB_NAME = os.getenv("DB_NAME", "")
if _DB_NAME:
DATABASES = {
"default": {
"ENGINE": "django.db.backends.mysql",
"NAME": os.getenv("DB_NAME", "mtcbd_db"),
"USER": os.getenv("DB_USER", "root"),
"PASSWORD": os.getenv("DB_PASSWORD", ""),
"HOST": os.getenv("DB_HOST", "127.0.0.1"),
"PORT": os.getenv("DB_PORT", "3306"),
"OPTIONS": {
"init_command": "SET sql_mode='STRICT_TRANS_TABLES'",
"charset": "utf8mb4",
},
}
}
else:
print("WARNING: Using SQLite3 DB. For MySQL, configure DB_NAME in .env.")
# Basic console logging for localdev
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {
"class": "logging.StreamHandler",
},
},
"root": {
"handlers": ["console"],
"level": "INFO",
},
}

View File

@@ -0,0 +1,79 @@
from .base import *
import dj_database_url
import os
DEBUG = False
# Comma-separated list of allowed hosts
_allowed = os.getenv("ALLOWED_HOSTS", "")
ALLOWED_HOSTS = [h.strip() for h in _allowed.split(",") if h.strip()]
# Strict CORS
_cors = os.getenv("CORS_ALLOWED_ORIGINS", "")
CORS_ALLOWED_ORIGINS = [h.strip() for h in _cors.split(",") if h.strip()]
CORS_ALLOW_ALL_ORIGINS = False
# Production Database with Connection Pooling Settings
DATABASES = {
"default": {
"ENGINE": "django.db.backends.mysql",
"NAME": os.getenv("DB_NAME", "mtcbd_db"),
"USER": os.getenv("DB_USER", "root"),
"PASSWORD": os.getenv("DB_PASSWORD", ""),
"HOST": os.getenv("DB_HOST", "127.0.0.1"),
"PORT": os.getenv("DB_PORT", "3306"),
"OPTIONS": {
"init_command": "SET sql_mode='STRICT_TRANS_TABLES'",
"charset": "utf8mb4",
},
"CONN_MAX_AGE": int(os.getenv("DB_CONN_MAX_AGE", "60")),
}
}
# Production security settings
SECURE_SSL_REDIRECT = os.getenv("SECURE_SSL_REDIRECT", "True") == "True"
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
# Redis caching
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": os.getenv("REDIS_URL", "redis://127.0.0.1:6379/1"),
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
}
}
}
# Production Logging
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"verbose": {
"format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}",
"style": "{",
},
},
"handlers": {
"console": {
"level": "INFO",
"class": "logging.StreamHandler",
"formatter": "verbose",
},
},
"root": {
"handlers": ["console"],
"level": "INFO",
},
"loggers": {
"django": {
"handlers": ["console"],
"level": os.getenv("DJANGO_LOG_LEVEL", "INFO"),
"propagate": False,
},
},
}

13
backend/core/urls.py Normal file
View File

@@ -0,0 +1,13 @@
from django.contrib import admin
from django.urls import path, include
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
urlpatterns = [
path('admin/', admin.site.urls),
path('api/auth/', include('accounts.urls')),
path('api/', include('projects.urls')),
path('api/dashboard/', include('dashboard.urls')),
path('api/analytics/', include('analytics.urls')),
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
]

16
backend/core/wsgi.py Normal file
View File

@@ -0,0 +1,16 @@
"""
WSGI config for core project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings.dev")
application = get_wsgi_application()

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.

View File

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

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class DashboardConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "dashboard"

View File

@@ -0,0 +1,62 @@
# 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="Notification",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("title", models.CharField(max_length=255)),
("message", models.TextField()),
("is_read", models.BooleanField(default=False)),
("link", models.URLField(blank=True, max_length=500, null=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"tenant",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="notifications",
to="tenants.tenant",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="notifications",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ["-created_at"],
"indexes": [
models.Index(
fields=["tenant", "user", "is_read"],
name="dashboard_n_tenant__eff9b5_idx",
)
],
},
),
]

View File

View File

@@ -0,0 +1,24 @@
from django.db import models
from django.conf import settings
from tenants.managers import TenantScopedManager
from tenants.models import Tenant
class Notification(models.Model):
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='notifications')
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='notifications')
title = models.CharField(max_length=255)
message = models.TextField()
is_read = models.BooleanField(default=False)
link = models.URLField(max_length=500, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
objects = TenantScopedManager()
class Meta:
ordering = ['-created_at']
indexes = [
models.Index(fields=['tenant', 'user', 'is_read']),
]
def __str__(self):
return f"Notification for {self.user}: {self.title}"

View File

@@ -0,0 +1,8 @@
from rest_framework import serializers
from .models import Notification
class NotificationSerializer(serializers.ModelSerializer):
class Meta:
model = Notification
fields = ['id', 'title', 'message', 'is_read', 'link', 'created_at']
read_only_fields = ['id', 'title', 'message', 'link', 'created_at']

View File

@@ -0,0 +1,23 @@
from celery import shared_task
from django.contrib.auth import get_user_model
from dashboard.models import Notification
from tenants.models import Tenant
User = get_user_model()
@shared_task
def send_notification_email_async(notification_id):
"""
Example Celery task to asynchronously send an email
when a new notification is generated.
"""
try:
notification = Notification.objects.get(id=notification_id)
user = notification.user
# In a real scenario, this would use django.core.mail.send_mail
print(f"ASYNC TASK: Sending email to {user.email} regarding: {notification.title}")
return True
except Notification.DoesNotExist:
print(f"ASYNC TASK ERR: Notification {notification_id} not found.")
return False

View File

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

12
backend/dashboard/urls.py Normal file
View File

@@ -0,0 +1,12 @@
from django.urls import path
from .views import DashboardSummaryView, NotificationViewSet
from rest_framework.routers import DefaultRouter
router = DefaultRouter()
router.register(r'notifications', NotificationViewSet, basename='notification')
urlpatterns = [
path('summary/', DashboardSummaryView.as_view(), name='dashboard-summary'),
]
urlpatterns += router.urls

View File

@@ -0,0 +1,62 @@
from rest_framework import viewsets, generics, permissions, status
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.decorators import action
from django.db.models import Count
from django.contrib.auth import get_user_model
from projects.models import Project, Task
from analytics.models import ActivityLog
from analytics.serializers import ActivityLogSerializer
from .models import Notification
from .serializers import NotificationSerializer
from accounts.permissions import IsTenantUser
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page
User = get_user_model()
class DashboardSummaryView(APIView):
permission_classes = [permissions.IsAuthenticated, IsTenantUser]
def get(self, request):
tenant = request.tenant
# Basic Counts
total_projects = Project.objects.filter(tenant=tenant).count()
total_tasks = Task.objects.filter(tenant=tenant).count()
total_users = User.objects.filter(tenant=tenant).count()
# Task Status Breakdown
task_status_counts = Task.objects.filter(tenant=tenant).values('status').annotate(count=Count('status'))
status_breakdown = {item['status']: item['count'] for item in task_status_counts}
# Recent Activity (Last 10)
recent_activity = ActivityLog.objects.filter(tenant=tenant).order_by('-created_at')[:10]
activity_serializer = ActivityLogSerializer(recent_activity, many=True)
return Response({
'total_projects': total_projects,
'total_tasks': total_tasks,
'total_users': total_users,
'task_status_breakdown': status_breakdown,
'recent_activity': activity_serializer.data
})
class NotificationViewSet(viewsets.ModelViewSet):
serializer_class = NotificationSerializer
permission_classes = [permissions.IsAuthenticated, IsTenantUser]
def get_queryset(self):
return Notification.objects.filter(tenant=self.request.tenant, user=self.request.user)
@action(detail=True, methods=['post'])
def mark_read(self, request, pk=None):
notification = self.get_object()
notification.is_read = True
notification.save()
return Response({'status': 'notification marked as read'})
@action(detail=False, methods=['post'])
def mark_all_read(self, request):
self.get_queryset().filter(is_read=False).update(is_read=True)
return Response({'status': 'all notifications marked as read'})

23
backend/manage.py Executable file
View File

@@ -0,0 +1,23 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings.dev")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()

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.

6
backend/projects/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ProjectsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "projects"

View File

@@ -0,0 +1,160 @@
# Generated by Django 4.2.28 on 2026-02-20 19:38
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="Project",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
("description", models.TextField(blank=True)),
(
"status",
models.CharField(
choices=[
("active", "Active"),
("completed", "Completed"),
("archived", "Archived"),
],
default="active",
max_length=20,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="created_projects",
to=settings.AUTH_USER_MODEL,
),
),
(
"tenant",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="projects",
to="tenants.tenant",
),
),
],
),
migrations.CreateModel(
name="Task",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("title", models.CharField(max_length=255)),
("description", models.TextField(blank=True)),
(
"status",
models.CharField(
choices=[
("todo", "To Do"),
("in_progress", "In Progress"),
("review", "Under Review"),
("done", "Done"),
],
default="todo",
max_length=20,
),
),
(
"priority",
models.CharField(
choices=[
("low", "Low"),
("medium", "Medium"),
("high", "High"),
("critical", "Critical"),
],
default="medium",
max_length=20,
),
),
("due_date", models.DateField(blank=True, null=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"assigned_to",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="assigned_tasks",
to=settings.AUTH_USER_MODEL,
),
),
(
"project",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="tasks",
to="projects.project",
),
),
(
"tenant",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="tasks",
to="tenants.tenant",
),
),
],
options={
"indexes": [
models.Index(
fields=["tenant", "project"],
name="projects_ta_tenant__2d974e_idx",
),
models.Index(
fields=["tenant", "assigned_to"],
name="projects_ta_tenant__eee8a8_idx",
),
],
},
),
migrations.AddIndex(
model_name="project",
index=models.Index(
fields=["tenant", "created_by"], name="projects_pr_tenant__12414f_idx"
),
),
migrations.AddIndex(
model_name="project",
index=models.Index(
fields=["tenant", "status"], name="projects_pr_tenant__3f3849_idx"
),
),
]

View File

@@ -0,0 +1,21 @@
# Generated by Django 4.2.28 on 2026-02-20 20:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('projects', '0001_initial'),
]
operations = [
migrations.AddIndex(
model_name='project',
index=models.Index(fields=['tenant', '-created_at'], name='projects_pr_tenant__5d8ad4_idx'),
),
migrations.AddIndex(
model_name='task',
index=models.Index(fields=['tenant', '-created_at'], name='projects_ta_tenant__a717eb_idx'),
),
]

View File

Some files were not shown because too many files have changed in this diff Show More