commit eec700af51f7bbacfc0303e4c3cd1d32709c370a Author: __init__ Date: Mon Feb 23 20:31:53 2026 +0530 Backend Draft diff --git a/backend/accounts/__init__.py b/backend/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/accounts/__pycache__/__init__.cpython-314.pyc b/backend/accounts/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..2e4c064 Binary files /dev/null and b/backend/accounts/__pycache__/__init__.cpython-314.pyc differ diff --git a/backend/accounts/__pycache__/admin.cpython-314.pyc b/backend/accounts/__pycache__/admin.cpython-314.pyc new file mode 100644 index 0000000..caad718 Binary files /dev/null and b/backend/accounts/__pycache__/admin.cpython-314.pyc differ diff --git a/backend/accounts/__pycache__/apps.cpython-314.pyc b/backend/accounts/__pycache__/apps.cpython-314.pyc new file mode 100644 index 0000000..5293f47 Binary files /dev/null and b/backend/accounts/__pycache__/apps.cpython-314.pyc differ diff --git a/backend/accounts/__pycache__/models.cpython-314.pyc b/backend/accounts/__pycache__/models.cpython-314.pyc new file mode 100644 index 0000000..741847b Binary files /dev/null and b/backend/accounts/__pycache__/models.cpython-314.pyc differ diff --git a/backend/accounts/__pycache__/permissions.cpython-314.pyc b/backend/accounts/__pycache__/permissions.cpython-314.pyc new file mode 100644 index 0000000..46eb3a8 Binary files /dev/null and b/backend/accounts/__pycache__/permissions.cpython-314.pyc differ diff --git a/backend/accounts/__pycache__/serializers.cpython-314.pyc b/backend/accounts/__pycache__/serializers.cpython-314.pyc new file mode 100644 index 0000000..88f48f4 Binary files /dev/null and b/backend/accounts/__pycache__/serializers.cpython-314.pyc differ diff --git a/backend/accounts/__pycache__/tests.cpython-314.pyc b/backend/accounts/__pycache__/tests.cpython-314.pyc new file mode 100644 index 0000000..bcf49ff Binary files /dev/null and b/backend/accounts/__pycache__/tests.cpython-314.pyc differ diff --git a/backend/accounts/__pycache__/urls.cpython-314.pyc b/backend/accounts/__pycache__/urls.cpython-314.pyc new file mode 100644 index 0000000..57e54b2 Binary files /dev/null and b/backend/accounts/__pycache__/urls.cpython-314.pyc differ diff --git a/backend/accounts/__pycache__/views.cpython-314.pyc b/backend/accounts/__pycache__/views.cpython-314.pyc new file mode 100644 index 0000000..156d623 Binary files /dev/null and b/backend/accounts/__pycache__/views.cpython-314.pyc differ diff --git a/backend/accounts/admin.py b/backend/accounts/admin.py new file mode 100644 index 0000000..a3a6b87 --- /dev/null +++ b/backend/accounts/admin.py @@ -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) diff --git a/backend/accounts/apps.py b/backend/accounts/apps.py new file mode 100644 index 0000000..0cb51e6 --- /dev/null +++ b/backend/accounts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" diff --git a/backend/accounts/migrations/0001_initial.py b/backend/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..54cce46 --- /dev/null +++ b/backend/accounts/migrations/0001_initial.py @@ -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, + }, + ), + ] diff --git a/backend/accounts/migrations/0002_alter_user_managers.py b/backend/accounts/migrations/0002_alter_user_managers.py new file mode 100644 index 0000000..a579cb0 --- /dev/null +++ b/backend/accounts/migrations/0002_alter_user_managers.py @@ -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()), + ], + ), + ] diff --git a/backend/accounts/migrations/__init__.py b/backend/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/accounts/migrations/__pycache__/0001_initial.cpython-314.pyc b/backend/accounts/migrations/__pycache__/0001_initial.cpython-314.pyc new file mode 100644 index 0000000..384a96f Binary files /dev/null and b/backend/accounts/migrations/__pycache__/0001_initial.cpython-314.pyc differ diff --git a/backend/accounts/migrations/__pycache__/0002_alter_user_managers.cpython-314.pyc b/backend/accounts/migrations/__pycache__/0002_alter_user_managers.cpython-314.pyc new file mode 100644 index 0000000..8ba36b1 Binary files /dev/null and b/backend/accounts/migrations/__pycache__/0002_alter_user_managers.cpython-314.pyc differ diff --git a/backend/accounts/migrations/__pycache__/__init__.cpython-314.pyc b/backend/accounts/migrations/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..4d220d3 Binary files /dev/null and b/backend/accounts/migrations/__pycache__/__init__.cpython-314.pyc differ diff --git a/backend/accounts/models.py b/backend/accounts/models.py new file mode 100644 index 0000000..13adc24 --- /dev/null +++ b/backend/accounts/models.py @@ -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 diff --git a/backend/accounts/permissions.py b/backend/accounts/permissions.py new file mode 100644 index 0000000..c92e804 --- /dev/null +++ b/backend/accounts/permissions.py @@ -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 diff --git a/backend/accounts/serializers.py b/backend/accounts/serializers.py new file mode 100644 index 0000000..8c8ffaf --- /dev/null +++ b/backend/accounts/serializers.py @@ -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 diff --git a/backend/accounts/tests.py b/backend/accounts/tests.py new file mode 100644 index 0000000..183c9af --- /dev/null +++ b/backend/accounts/tests.py @@ -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) diff --git a/backend/accounts/urls.py b/backend/accounts/urls.py new file mode 100644 index 0000000..10c515e --- /dev/null +++ b/backend/accounts/urls.py @@ -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'), +] diff --git a/backend/accounts/views.py b/backend/accounts/views.py new file mode 100644 index 0000000..15c1478 --- /dev/null +++ b/backend/accounts/views.py @@ -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') diff --git a/backend/analytics/__init__.py b/backend/analytics/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/analytics/__pycache__/__init__.cpython-314.pyc b/backend/analytics/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..4bb09e9 Binary files /dev/null and b/backend/analytics/__pycache__/__init__.cpython-314.pyc differ diff --git a/backend/analytics/__pycache__/admin.cpython-314.pyc b/backend/analytics/__pycache__/admin.cpython-314.pyc new file mode 100644 index 0000000..853807f Binary files /dev/null and b/backend/analytics/__pycache__/admin.cpython-314.pyc differ diff --git a/backend/analytics/__pycache__/apps.cpython-314.pyc b/backend/analytics/__pycache__/apps.cpython-314.pyc new file mode 100644 index 0000000..0126566 Binary files /dev/null and b/backend/analytics/__pycache__/apps.cpython-314.pyc differ diff --git a/backend/analytics/__pycache__/models.cpython-314.pyc b/backend/analytics/__pycache__/models.cpython-314.pyc new file mode 100644 index 0000000..0418934 Binary files /dev/null and b/backend/analytics/__pycache__/models.cpython-314.pyc differ diff --git a/backend/analytics/__pycache__/serializers.cpython-314.pyc b/backend/analytics/__pycache__/serializers.cpython-314.pyc new file mode 100644 index 0000000..eda060a Binary files /dev/null and b/backend/analytics/__pycache__/serializers.cpython-314.pyc differ diff --git a/backend/analytics/__pycache__/signals.cpython-314.pyc b/backend/analytics/__pycache__/signals.cpython-314.pyc new file mode 100644 index 0000000..8be1bfe Binary files /dev/null and b/backend/analytics/__pycache__/signals.cpython-314.pyc differ diff --git a/backend/analytics/__pycache__/urls.cpython-314.pyc b/backend/analytics/__pycache__/urls.cpython-314.pyc new file mode 100644 index 0000000..15d9ace Binary files /dev/null and b/backend/analytics/__pycache__/urls.cpython-314.pyc differ diff --git a/backend/analytics/__pycache__/views.cpython-314.pyc b/backend/analytics/__pycache__/views.cpython-314.pyc new file mode 100644 index 0000000..7712449 Binary files /dev/null and b/backend/analytics/__pycache__/views.cpython-314.pyc differ diff --git a/backend/analytics/admin.py b/backend/analytics/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/backend/analytics/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/backend/analytics/apps.py b/backend/analytics/apps.py new file mode 100644 index 0000000..5a013ae --- /dev/null +++ b/backend/analytics/apps.py @@ -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 diff --git a/backend/analytics/migrations/0001_initial.py b/backend/analytics/migrations/0001_initial.py new file mode 100644 index 0000000..9d6d24a --- /dev/null +++ b/backend/analytics/migrations/0001_initial.py @@ -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", + ) + ], + }, + ), + ] diff --git a/backend/analytics/migrations/__init__.py b/backend/analytics/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/analytics/migrations/__pycache__/0001_initial.cpython-314.pyc b/backend/analytics/migrations/__pycache__/0001_initial.cpython-314.pyc new file mode 100644 index 0000000..37d1082 Binary files /dev/null and b/backend/analytics/migrations/__pycache__/0001_initial.cpython-314.pyc differ diff --git a/backend/analytics/migrations/__pycache__/__init__.cpython-314.pyc b/backend/analytics/migrations/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..9d9101b Binary files /dev/null and b/backend/analytics/migrations/__pycache__/__init__.cpython-314.pyc differ diff --git a/backend/analytics/models.py b/backend/analytics/models.py new file mode 100644 index 0000000..d714d98 --- /dev/null +++ b/backend/analytics/models.py @@ -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}" diff --git a/backend/analytics/serializers.py b/backend/analytics/serializers.py new file mode 100644 index 0000000..848098a --- /dev/null +++ b/backend/analytics/serializers.py @@ -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'] diff --git a/backend/analytics/signals.py b/backend/analytics/signals.py new file mode 100644 index 0000000..27c19ed --- /dev/null +++ b/backend/analytics/signals.py @@ -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 + ) diff --git a/backend/analytics/tests.py b/backend/analytics/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/analytics/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/analytics/urls.py b/backend/analytics/urls.py new file mode 100644 index 0000000..f4f0823 --- /dev/null +++ b/backend/analytics/urls.py @@ -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'), +] diff --git a/backend/analytics/views.py b/backend/analytics/views.py new file mode 100644 index 0000000..365c885 --- /dev/null +++ b/backend/analytics/views.py @@ -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 + }) diff --git a/backend/core/__init__.py b/backend/core/__init__.py new file mode 100644 index 0000000..9e0d95f --- /dev/null +++ b/backend/core/__init__.py @@ -0,0 +1,3 @@ +from .celery import app as celery_app + +__all__ = ('celery_app',) \ No newline at end of file diff --git a/backend/core/__pycache__/__init__.cpython-314.pyc b/backend/core/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..088c4e8 Binary files /dev/null and b/backend/core/__pycache__/__init__.cpython-314.pyc differ diff --git a/backend/core/__pycache__/celery.cpython-314.pyc b/backend/core/__pycache__/celery.cpython-314.pyc new file mode 100644 index 0000000..81f3ea8 Binary files /dev/null and b/backend/core/__pycache__/celery.cpython-314.pyc differ diff --git a/backend/core/__pycache__/settings.cpython-314.pyc b/backend/core/__pycache__/settings.cpython-314.pyc new file mode 100644 index 0000000..9a131c0 Binary files /dev/null and b/backend/core/__pycache__/settings.cpython-314.pyc differ diff --git a/backend/core/__pycache__/urls.cpython-314.pyc b/backend/core/__pycache__/urls.cpython-314.pyc new file mode 100644 index 0000000..c2ff788 Binary files /dev/null and b/backend/core/__pycache__/urls.cpython-314.pyc differ diff --git a/backend/core/__pycache__/wsgi.cpython-314.pyc b/backend/core/__pycache__/wsgi.cpython-314.pyc new file mode 100644 index 0000000..7f87b18 Binary files /dev/null and b/backend/core/__pycache__/wsgi.cpython-314.pyc differ diff --git a/backend/core/asgi.py b/backend/core/asgi.py new file mode 100644 index 0000000..e9d70c5 --- /dev/null +++ b/backend/core/asgi.py @@ -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() diff --git a/backend/core/celery.py b/backend/core/celery.py new file mode 100644 index 0000000..ad749d2 --- /dev/null +++ b/backend/core/celery.py @@ -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}') diff --git a/backend/core/settings/__init__.py b/backend/core/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/settings/__pycache__/__init__.cpython-314.pyc b/backend/core/settings/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..a0ec350 Binary files /dev/null and b/backend/core/settings/__pycache__/__init__.cpython-314.pyc differ diff --git a/backend/core/settings/__pycache__/base.cpython-314.pyc b/backend/core/settings/__pycache__/base.cpython-314.pyc new file mode 100644 index 0000000..c793a51 Binary files /dev/null and b/backend/core/settings/__pycache__/base.cpython-314.pyc differ diff --git a/backend/core/settings/__pycache__/dev.cpython-314.pyc b/backend/core/settings/__pycache__/dev.cpython-314.pyc new file mode 100644 index 0000000..f132df2 Binary files /dev/null and b/backend/core/settings/__pycache__/dev.cpython-314.pyc differ diff --git a/backend/core/settings/base.py b/backend/core/settings/base.py new file mode 100644 index 0000000..29ecf5a --- /dev/null +++ b/backend/core/settings/base.py @@ -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' diff --git a/backend/core/settings/dev.py b/backend/core/settings/dev.py new file mode 100644 index 0000000..bdd76f9 --- /dev/null +++ b/backend/core/settings/dev.py @@ -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", + }, +} diff --git a/backend/core/settings/prod.py b/backend/core/settings/prod.py new file mode 100644 index 0000000..e989299 --- /dev/null +++ b/backend/core/settings/prod.py @@ -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, + }, + }, +} diff --git a/backend/core/urls.py b/backend/core/urls.py new file mode 100644 index 0000000..bca6ac2 --- /dev/null +++ b/backend/core/urls.py @@ -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'), +] diff --git a/backend/core/wsgi.py b/backend/core/wsgi.py new file mode 100644 index 0000000..fb0eec0 --- /dev/null +++ b/backend/core/wsgi.py @@ -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() diff --git a/backend/dashboard/__init__.py b/backend/dashboard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/dashboard/__pycache__/__init__.cpython-314.pyc b/backend/dashboard/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..0209a7d Binary files /dev/null and b/backend/dashboard/__pycache__/__init__.cpython-314.pyc differ diff --git a/backend/dashboard/__pycache__/admin.cpython-314.pyc b/backend/dashboard/__pycache__/admin.cpython-314.pyc new file mode 100644 index 0000000..478627f Binary files /dev/null and b/backend/dashboard/__pycache__/admin.cpython-314.pyc differ diff --git a/backend/dashboard/__pycache__/apps.cpython-314.pyc b/backend/dashboard/__pycache__/apps.cpython-314.pyc new file mode 100644 index 0000000..2a69019 Binary files /dev/null and b/backend/dashboard/__pycache__/apps.cpython-314.pyc differ diff --git a/backend/dashboard/__pycache__/models.cpython-314.pyc b/backend/dashboard/__pycache__/models.cpython-314.pyc new file mode 100644 index 0000000..812e656 Binary files /dev/null and b/backend/dashboard/__pycache__/models.cpython-314.pyc differ diff --git a/backend/dashboard/__pycache__/serializers.cpython-314.pyc b/backend/dashboard/__pycache__/serializers.cpython-314.pyc new file mode 100644 index 0000000..8404bef Binary files /dev/null and b/backend/dashboard/__pycache__/serializers.cpython-314.pyc differ diff --git a/backend/dashboard/__pycache__/urls.cpython-314.pyc b/backend/dashboard/__pycache__/urls.cpython-314.pyc new file mode 100644 index 0000000..69765ef Binary files /dev/null and b/backend/dashboard/__pycache__/urls.cpython-314.pyc differ diff --git a/backend/dashboard/__pycache__/views.cpython-314.pyc b/backend/dashboard/__pycache__/views.cpython-314.pyc new file mode 100644 index 0000000..487e513 Binary files /dev/null and b/backend/dashboard/__pycache__/views.cpython-314.pyc differ diff --git a/backend/dashboard/admin.py b/backend/dashboard/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/backend/dashboard/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/backend/dashboard/apps.py b/backend/dashboard/apps.py new file mode 100644 index 0000000..db15b45 --- /dev/null +++ b/backend/dashboard/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class DashboardConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "dashboard" diff --git a/backend/dashboard/migrations/0001_initial.py b/backend/dashboard/migrations/0001_initial.py new file mode 100644 index 0000000..a9b0adc --- /dev/null +++ b/backend/dashboard/migrations/0001_initial.py @@ -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", + ) + ], + }, + ), + ] diff --git a/backend/dashboard/migrations/__init__.py b/backend/dashboard/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/dashboard/migrations/__pycache__/0001_initial.cpython-314.pyc b/backend/dashboard/migrations/__pycache__/0001_initial.cpython-314.pyc new file mode 100644 index 0000000..773d617 Binary files /dev/null and b/backend/dashboard/migrations/__pycache__/0001_initial.cpython-314.pyc differ diff --git a/backend/dashboard/migrations/__pycache__/__init__.cpython-314.pyc b/backend/dashboard/migrations/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..28d1b88 Binary files /dev/null and b/backend/dashboard/migrations/__pycache__/__init__.cpython-314.pyc differ diff --git a/backend/dashboard/models.py b/backend/dashboard/models.py new file mode 100644 index 0000000..7fdd32c --- /dev/null +++ b/backend/dashboard/models.py @@ -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}" diff --git a/backend/dashboard/serializers.py b/backend/dashboard/serializers.py new file mode 100644 index 0000000..263b1dd --- /dev/null +++ b/backend/dashboard/serializers.py @@ -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'] diff --git a/backend/dashboard/tasks.py b/backend/dashboard/tasks.py new file mode 100644 index 0000000..c798557 --- /dev/null +++ b/backend/dashboard/tasks.py @@ -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 diff --git a/backend/dashboard/tests.py b/backend/dashboard/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/dashboard/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/dashboard/urls.py b/backend/dashboard/urls.py new file mode 100644 index 0000000..e29f5fe --- /dev/null +++ b/backend/dashboard/urls.py @@ -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 diff --git a/backend/dashboard/views.py b/backend/dashboard/views.py new file mode 100644 index 0000000..3da6a1b --- /dev/null +++ b/backend/dashboard/views.py @@ -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'}) diff --git a/backend/manage.py b/backend/manage.py new file mode 100755 index 0000000..598347c --- /dev/null +++ b/backend/manage.py @@ -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() diff --git a/backend/projects/__init__.py b/backend/projects/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/projects/__pycache__/__init__.cpython-314.pyc b/backend/projects/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..4fb7702 Binary files /dev/null and b/backend/projects/__pycache__/__init__.cpython-314.pyc differ diff --git a/backend/projects/__pycache__/admin.cpython-314.pyc b/backend/projects/__pycache__/admin.cpython-314.pyc new file mode 100644 index 0000000..8bb4f2d Binary files /dev/null and b/backend/projects/__pycache__/admin.cpython-314.pyc differ diff --git a/backend/projects/__pycache__/apps.cpython-314.pyc b/backend/projects/__pycache__/apps.cpython-314.pyc new file mode 100644 index 0000000..1c4f07f Binary files /dev/null and b/backend/projects/__pycache__/apps.cpython-314.pyc differ diff --git a/backend/projects/__pycache__/models.cpython-314.pyc b/backend/projects/__pycache__/models.cpython-314.pyc new file mode 100644 index 0000000..22605d7 Binary files /dev/null and b/backend/projects/__pycache__/models.cpython-314.pyc differ diff --git a/backend/projects/__pycache__/serializers.cpython-314.pyc b/backend/projects/__pycache__/serializers.cpython-314.pyc new file mode 100644 index 0000000..46f0088 Binary files /dev/null and b/backend/projects/__pycache__/serializers.cpython-314.pyc differ diff --git a/backend/projects/__pycache__/tests.cpython-314.pyc b/backend/projects/__pycache__/tests.cpython-314.pyc new file mode 100644 index 0000000..e89f373 Binary files /dev/null and b/backend/projects/__pycache__/tests.cpython-314.pyc differ diff --git a/backend/projects/__pycache__/urls.cpython-314.pyc b/backend/projects/__pycache__/urls.cpython-314.pyc new file mode 100644 index 0000000..45f1cda Binary files /dev/null and b/backend/projects/__pycache__/urls.cpython-314.pyc differ diff --git a/backend/projects/__pycache__/views.cpython-314.pyc b/backend/projects/__pycache__/views.cpython-314.pyc new file mode 100644 index 0000000..7fe0843 Binary files /dev/null and b/backend/projects/__pycache__/views.cpython-314.pyc differ diff --git a/backend/projects/admin.py b/backend/projects/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/backend/projects/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/backend/projects/apps.py b/backend/projects/apps.py new file mode 100644 index 0000000..c992f4a --- /dev/null +++ b/backend/projects/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ProjectsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "projects" diff --git a/backend/projects/migrations/0001_initial.py b/backend/projects/migrations/0001_initial.py new file mode 100644 index 0000000..2634324 --- /dev/null +++ b/backend/projects/migrations/0001_initial.py @@ -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" + ), + ), + ] diff --git a/backend/projects/migrations/0002_project_projects_pr_tenant__5d8ad4_idx_and_more.py b/backend/projects/migrations/0002_project_projects_pr_tenant__5d8ad4_idx_and_more.py new file mode 100644 index 0000000..559eb53 --- /dev/null +++ b/backend/projects/migrations/0002_project_projects_pr_tenant__5d8ad4_idx_and_more.py @@ -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'), + ), + ] diff --git a/backend/projects/migrations/__init__.py b/backend/projects/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/projects/migrations/__pycache__/0001_initial.cpython-314.pyc b/backend/projects/migrations/__pycache__/0001_initial.cpython-314.pyc new file mode 100644 index 0000000..a343811 Binary files /dev/null and b/backend/projects/migrations/__pycache__/0001_initial.cpython-314.pyc differ diff --git a/backend/projects/migrations/__pycache__/0002_project_projects_pr_tenant__5d8ad4_idx_and_more.cpython-314.pyc b/backend/projects/migrations/__pycache__/0002_project_projects_pr_tenant__5d8ad4_idx_and_more.cpython-314.pyc new file mode 100644 index 0000000..80a58bd Binary files /dev/null and b/backend/projects/migrations/__pycache__/0002_project_projects_pr_tenant__5d8ad4_idx_and_more.cpython-314.pyc differ diff --git a/backend/projects/migrations/__pycache__/__init__.cpython-314.pyc b/backend/projects/migrations/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..a354cdc Binary files /dev/null and b/backend/projects/migrations/__pycache__/__init__.cpython-314.pyc differ diff --git a/backend/projects/models.py b/backend/projects/models.py new file mode 100644 index 0000000..6b65618 --- /dev/null +++ b/backend/projects/models.py @@ -0,0 +1,69 @@ +from django.db import models +from django.conf import settings +from tenants.models import Tenant +from tenants.managers import TenantScopedManager + +class Project(models.fields.related.RelatedField if False else models.Model): + STATUS_CHOICES = [ + ('active', 'Active'), + ('completed', 'Completed'), + ('archived', 'Archived'), + ] + + tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='projects') + name = models.CharField(max_length=255) + description = models.TextField(blank=True) + created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name='created_projects') + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='active') + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + objects = TenantScopedManager() + + class Meta: + indexes = [ + models.Index(fields=['tenant', 'created_by']), + models.Index(fields=['tenant', 'status']), + models.Index(fields=['tenant', '-created_at']), + ] + + def __str__(self): + return self.name + +class Task(models.fields.related.RelatedField if False else models.Model): + STATUS_CHOICES = [ + ('todo', 'To Do'), + ('in_progress', 'In Progress'), + ('review', 'Under Review'), + ('done', 'Done'), + ] + + PRIORITY_CHOICES = [ + ('low', 'Low'), + ('medium', 'Medium'), + ('high', 'High'), + ('critical', 'Critical'), + ] + + tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='tasks') + project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='tasks') + title = models.CharField(max_length=255) + description = models.TextField(blank=True) + assigned_to = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name='assigned_tasks') + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='todo') + priority = models.CharField(max_length=20, choices=PRIORITY_CHOICES, default='medium') + due_date = models.DateField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + objects = TenantScopedManager() + + class Meta: + indexes = [ + models.Index(fields=['tenant', 'project']), + models.Index(fields=['tenant', 'assigned_to']), + models.Index(fields=['tenant', '-created_at']), + ] + + def __str__(self): + return f"{self.project.name} - {self.title}" diff --git a/backend/projects/serializers.py b/backend/projects/serializers.py new file mode 100644 index 0000000..0f80c66 --- /dev/null +++ b/backend/projects/serializers.py @@ -0,0 +1,43 @@ +from rest_framework import serializers +from .models import Project, Task +from accounts.serializers import UserSerializer + +class TaskSerializer(serializers.ModelSerializer): + class Meta: + model = Task + fields = ['id', 'project', 'title', 'description', 'assigned_to', + 'status', 'priority', 'due_date', 'created_at', 'updated_at'] + read_only_fields = ['id', 'project', 'created_at', 'updated_at'] + +from django.contrib.auth import get_user_model + +User = get_user_model() + +class TaskDetailSerializer(TaskSerializer): + assigned_to = UserSerializer(read_only=True) + assigned_to_id = serializers.PrimaryKeyRelatedField( + source='assigned_to', + queryset=User.objects.all(), + required=False, allow_null=True + ) + + class Meta(TaskSerializer.Meta): + fields = TaskSerializer.Meta.fields + ['assigned_to_id'] + +class ProjectSerializer(serializers.ModelSerializer): + task_count = serializers.SerializerMethodField() + + class Meta: + model = Project + fields = ['id', 'name', 'description', 'status', 'created_by', 'created_at', 'updated_at', 'task_count'] + read_only_fields = ['id', 'created_by', 'created_at', 'updated_at', 'task_count'] + + def get_task_count(self, obj): + return obj.tasks.count() + +class ProjectDetailSerializer(ProjectSerializer): + tasks = TaskSerializer(many=True, read_only=True) + created_by = UserSerializer(read_only=True) + + class Meta(ProjectSerializer.Meta): + fields = ProjectSerializer.Meta.fields + ['tasks'] diff --git a/backend/projects/tests.py b/backend/projects/tests.py new file mode 100644 index 0000000..c9f62bc --- /dev/null +++ b/backend/projects/tests.py @@ -0,0 +1,83 @@ +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 +from .models import Project, Task + +User = get_user_model() + +class ProjectTaskTests(TestCase): + def setUp(self): + self.client_a = APIClient() + self.client_b = APIClient() + + self.tenant_a = Tenant.objects.create(name='Tenant A', subdomain='tenant-a') + self.tenant_b = Tenant.objects.create(name='Tenant B', subdomain='tenant-b') + + self.teacher_a = User.objects.create_user(username='teacher_a', password='pw', role='teacher', tenant=self.tenant_a) + self.student_a = User.objects.create_user(username='student_a', password='pw', role='student', tenant=self.tenant_a) + self.teacher_b = User.objects.create_user(username='teacher_b', password='pw', role='teacher', tenant=self.tenant_b) + + # Login clients + response_a = self.client_a.post(reverse('token_obtain_pair'), {'username': 'teacher_a', 'password': 'pw'}) + self.client_a.credentials(HTTP_AUTHORIZATION='Bearer ' + response_a.data['access'], HTTP_X_TENANT_ID=str(self.tenant_a.id)) + + response_b = self.client_b.post(reverse('token_obtain_pair'), {'username': 'teacher_b', 'password': 'pw'}) + self.client_b.credentials(HTTP_AUTHORIZATION='Bearer ' + response_b.data['access'], HTTP_X_TENANT_ID=str(self.tenant_b.id)) + + self.client_student = APIClient() + response_stud = self.client_student.post(reverse('token_obtain_pair'), {'username': 'student_a', 'password': 'pw'}) + self.client_student.credentials(HTTP_AUTHORIZATION='Bearer ' + response_stud.data['access'], HTTP_X_TENANT_ID=str(self.tenant_a.id)) + + def test_project_crud(self): + # Create + url = reverse('project-list') + data = {'name': 'Project Alpha', 'description': 'Test project'} + response = self.client_a.post(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Project.objects.count(), 1) + project_id = response.data['id'] + + # List + response = self.client_a.get(url) + self.assertEqual(response.data['count'], 1) + self.assertEqual(len(response.data['results']), 1) + + # Update + detail_url = reverse('project-detail', kwargs={'pk': project_id}) + response = self.client_a.put(detail_url, {'name': 'Project Alpha Updated'}, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['name'], 'Project Alpha Updated') + + # Tenant Isolation - B should not see A's projects + response = self.client_b.get(url) + self.assertEqual(response.data['count'], 0) + + # Role Based - Student can read, but not create + response = self.client_student.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 1) + + response = self.client_student.post(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_task_crud(self): + project = Project.objects.create(name='Proj A', tenant=self.tenant_a, created_by=self.teacher_a) + + # Create Task + url = reverse('project-tasks-list', kwargs={'project_pk': project.id}) + data = {'title': 'Backend Auth', 'description': 'JWT Setup', 'status': 'todo', 'priority': 'high'} + response = self.client_a.post(url, data, format='json') + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + task_id = response.data['id'] + + # Update Task Assignment + detail_url = reverse('project-tasks-detail', kwargs={'project_pk': project.id, 'pk': task_id}) + response = self.client_a.patch(detail_url, {'assigned_to_id': self.student_a.id}, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + + task = Task.objects.get(id=task_id) + self.assertEqual(task.assigned_to, self.student_a) diff --git a/backend/projects/urls.py b/backend/projects/urls.py new file mode 100644 index 0000000..96c0640 --- /dev/null +++ b/backend/projects/urls.py @@ -0,0 +1,16 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from rest_framework_nested.routers import NestedDefaultRouter +from .views import ProjectViewSet, TaskViewSet + +router = DefaultRouter() +router.register(r'projects', ProjectViewSet, basename='project') + +# Nested router for tasks under a project +projects_router = NestedDefaultRouter(router, r'projects', lookup='project') +projects_router.register(r'tasks', TaskViewSet, basename='project-tasks') + +urlpatterns = [ + path('', include(router.urls)), + path('', include(projects_router.urls)), +] diff --git a/backend/projects/views.py b/backend/projects/views.py new file mode 100644 index 0000000..9034281 --- /dev/null +++ b/backend/projects/views.py @@ -0,0 +1,88 @@ +from rest_framework import viewsets, permissions +from .models import Project, Task +from .serializers import ProjectSerializer, ProjectDetailSerializer, TaskSerializer, TaskDetailSerializer +from accounts.permissions import IsAdmin, IsTeacher, IsStudentReadOnly +from rest_framework.exceptions import PermissionDenied + +class ProjectViewSet(viewsets.ModelViewSet): + """ + CRUD for Projects. + - list/retrieve: Admin, Teacher, Student + - create/update/destroy: Admin, Teacher (but Teacher can only update/destroy their own) + """ + # Base permissions. Object-level is handled in check_object_permissions or via queryset + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + # Filter by tenant + return Project.objects.tenant(self.request.tenant).select_related('created_by').order_by('-created_at') + + def get_serializer_class(self): + if self.action in ['retrieve']: + return ProjectDetailSerializer + return ProjectSerializer + + def perform_create(self, serializer): + # Auto-set the tenant and creator + serializer.save(tenant=self.request.tenant, created_by=self.request.user) + + def check_permissions(self, request): + super().check_permissions(request) + if request.user.role == 'student' and request.method not in permissions.SAFE_METHODS: + raise PermissionDenied("Students cannot modify projects.") + + def check_object_permissions(self, request, obj): + super().check_object_permissions(request, obj) + if request.method not in permissions.SAFE_METHODS: + if request.user.role == 'teacher' and obj.created_by != request.user: + raise PermissionDenied("You can only modify projects you created.") + +class TaskViewSet(viewsets.ModelViewSet): + """ + CRUD for Tasks nested under a Project. + """ + permission_classes = [permissions.IsAuthenticated] + pagination_class = None # Return all tasks as list, not paginated + + def get_queryset(self): + # The URL captures project_pk + project_id = self.kwargs.get('project_pk') + return Task.objects.tenant(self.request.tenant).filter(project_id=project_id).select_related('assigned_to', 'project').order_by('-created_at') + + def get_serializer_class(self): + if self.action in ['retrieve', 'create', 'update', 'partial_update']: + return TaskDetailSerializer + return TaskSerializer + + def perform_create(self, serializer): + project_id = self.kwargs.get('project_pk') + try: + project = Project.objects.get(id=project_id) + except Project.DoesNotExist: + raise serializers.ValidationError("Project does not exist.") + + # Check permissions before allowing to create task + if self.request.user.role == 'teacher' and project.created_by != self.request.user: + raise PermissionDenied("You can only add tasks to projects you created.") + + assigned_to_id = self.request.data.get('assigned_to_id') + + serializer.save(tenant=self.request.tenant, project=project, assigned_to_id=assigned_to_id) + + def perform_update(self, serializer): + assigned_to_id = self.request.data.get('assigned_to_id') + if 'assigned_to_id' in self.request.data: + serializer.save(assigned_to_id=assigned_to_id) + else: + serializer.save() + + def check_permissions(self, request): + super().check_permissions(request) + if request.user.role == 'student' and request.method not in permissions.SAFE_METHODS: + raise PermissionDenied("Students cannot modify tasks.") + + def check_object_permissions(self, request, obj): + super().check_object_permissions(request, obj) + if request.method not in permissions.SAFE_METHODS: + if request.user.role == 'teacher' and obj.project.created_by != request.user: + raise PermissionDenied("You can only modify tasks in projects you created.") diff --git a/backend/tenants/__init__.py b/backend/tenants/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tenants/__pycache__/__init__.cpython-314.pyc b/backend/tenants/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..13e8c84 Binary files /dev/null and b/backend/tenants/__pycache__/__init__.cpython-314.pyc differ diff --git a/backend/tenants/__pycache__/admin.cpython-314.pyc b/backend/tenants/__pycache__/admin.cpython-314.pyc new file mode 100644 index 0000000..918ca98 Binary files /dev/null and b/backend/tenants/__pycache__/admin.cpython-314.pyc differ diff --git a/backend/tenants/__pycache__/apps.cpython-314.pyc b/backend/tenants/__pycache__/apps.cpython-314.pyc new file mode 100644 index 0000000..98e4bb7 Binary files /dev/null and b/backend/tenants/__pycache__/apps.cpython-314.pyc differ diff --git a/backend/tenants/__pycache__/managers.cpython-314.pyc b/backend/tenants/__pycache__/managers.cpython-314.pyc new file mode 100644 index 0000000..e0e5b92 Binary files /dev/null and b/backend/tenants/__pycache__/managers.cpython-314.pyc differ diff --git a/backend/tenants/__pycache__/middleware.cpython-314.pyc b/backend/tenants/__pycache__/middleware.cpython-314.pyc new file mode 100644 index 0000000..deaf4f1 Binary files /dev/null and b/backend/tenants/__pycache__/middleware.cpython-314.pyc differ diff --git a/backend/tenants/__pycache__/models.cpython-314.pyc b/backend/tenants/__pycache__/models.cpython-314.pyc new file mode 100644 index 0000000..a0fb3c8 Binary files /dev/null and b/backend/tenants/__pycache__/models.cpython-314.pyc differ diff --git a/backend/tenants/__pycache__/serializers.cpython-314.pyc b/backend/tenants/__pycache__/serializers.cpython-314.pyc new file mode 100644 index 0000000..75840b6 Binary files /dev/null and b/backend/tenants/__pycache__/serializers.cpython-314.pyc differ diff --git a/backend/tenants/admin.py b/backend/tenants/admin.py new file mode 100644 index 0000000..22a1ade --- /dev/null +++ b/backend/tenants/admin.py @@ -0,0 +1,8 @@ +from django.contrib import admin +from .models import Tenant + +@admin.register(Tenant) +class TenantAdmin(admin.ModelAdmin): + list_display = ('name', 'subdomain', 'is_active', 'created_at') + search_fields = ('name', 'subdomain') + list_filter = ('is_active',) diff --git a/backend/tenants/apps.py b/backend/tenants/apps.py new file mode 100644 index 0000000..668b464 --- /dev/null +++ b/backend/tenants/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class TenantsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "tenants" diff --git a/backend/tenants/management/commands/__pycache__/populate_test_data.cpython-314.pyc b/backend/tenants/management/commands/__pycache__/populate_test_data.cpython-314.pyc new file mode 100644 index 0000000..3f4394f Binary files /dev/null and b/backend/tenants/management/commands/__pycache__/populate_test_data.cpython-314.pyc differ diff --git a/backend/tenants/management/commands/populate_test_data.py b/backend/tenants/management/commands/populate_test_data.py new file mode 100644 index 0000000..467ebc3 --- /dev/null +++ b/backend/tenants/management/commands/populate_test_data.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python +""" +Django Management Command: populate_test_data + +Creates realistic test data for MTCBD. +Run with: python manage.py populate_test_data +""" + +from django.core.management.base import BaseCommand +from faker import Faker +from django.utils import timezone +import random +import datetime + +from tenants.models import Tenant +from accounts.models import User +from projects.models import Project, Task +from dashboard.models import Notification +from analytics.models import ActivityLog + +fake = Faker() + +class Command(BaseCommand): + help = 'Populate database with realistic test data (100+ users, projects, tasks, etc.)' + + def add_arguments(self, parser): + parser.add_argument( + '--clear', + action='store_true', + help='Clear existing data before populating', + ) + + def handle(self, *args, **options): + clear = options['clear'] + + if clear and Tenant.objects.exists(): + self.stdout.write(self.style.WARNING('Clearing existing data...')) + Task.objects.all().delete() + Project.objects.all().delete() + Notification.objects.all().delete() + ActivityLog.objects.all().delete() + User.objects.all().delete() + Tenant.objects.all().delete() + self.stdout.write(self.style.SUCCESS('✓ Data cleared')) + + self.main() + self.stdout.write(self.style.SUCCESS('\n✅ Population complete!')) + + def main(self): + self.stdout.write("MTCBD Database Population".center(60)) + self.stdout.write("="*60) + + # Configuration + NUM_TENANTS = 5 + USERS_PER_TENANT = 20 + PROJECTS_PER_TENANT = (3, 8) + TASKS_PER_PROJECT = (5, 15) + NOTIFICATIONS_PER_USER = (0, 5) + ACTIVITY_LOGS_PER_TENANT = 50 + + TENANT_DATA = [ + {'name': 'Tech Academy', 'subdomain': 'techacademy'}, + {'name': 'Medical College', 'subdomain': 'medcollege'}, + {'name': 'Business School', 'subdomain': 'businessschool'}, + {'name': 'Engineering Institute', 'subdomain': 'enginstitute'}, + {'name': 'Arts University', 'subdomain': 'artsuniv'}, + ] + + tenants = self.create_tenants(TENANT_DATA) + total_users = 0 + total_projects = 0 + total_tasks = 0 + total_notifications = 0 + total_logs = 0 + + for tenant in tenants: + users = self.create_users_for_tenant(tenant, USERS_PER_TENANT) + total_users += len(users) + + projects = self.create_projects_for_tenant(tenant, users, PROJECTS_PER_TENANT) + total_projects += len(projects) + + if projects: + tasks_created = self.create_tasks_for_projects(projects, users, TASKS_PER_PROJECT) + total_tasks += tasks_created + + notifications = self.create_notifications(users, NOTIFICATIONS_PER_USER) + total_notifications += notifications + + logs = self.create_activity_logs(tenant, users, ACTIVITY_LOGS_PER_TENANT) + total_logs += logs + + self.print_summary(tenants, total_users, total_projects, total_tasks, total_notifications, total_logs) + + def create_tenants(self, tenant_data): + self.stdout.write('\nCreating tenants...') + tenants = [] + for data in tenant_data: + tenant, created = Tenant.objects.get_or_create( + subdomain=data['subdomain'], + defaults={'name': data['name'], 'is_active': True} + ) + tenants.append(tenant) + status = "✓ Created" if created else "→ Exists" + self.stdout.write(f' {status}: {tenant.name} ({tenant.subdomain})') + return tenants + + def create_users_for_tenant(self, tenant, count): + self.stdout.write(f'\n Creating users for {tenant.subdomain}...') + users = [] + + # One super_admin and one institution_admin + admin_roles = ['super_admin', 'institution_admin'] + for role in admin_roles: + username = f"{role}_{tenant.subdomain}" + email = f"{role}@{tenant.subdomain}.com" + user, created = User.objects.get_or_create( + username=username, + defaults={ + 'email': email, + 'first_name': fake.first_name(), + 'last_name': fake.last_name(), + 'role': role, + 'tenant': tenant, + 'is_staff': role in ['super_admin', 'institution_admin'], + 'is_active': True, + } + ) + if created: + user.set_password('password123') + user.save() + users.append(user) + self.stdout.write(f' ✓ {role}: {username}') + + # Other users + remaining = count - 2 + for i in range(remaining): + role = random.choice(['teacher', 'project_manager', 'student', 'student', 'student']) + username = f"{tenant.subdomain}_{i+1}" + email = f"user{i+1}@{tenant.subdomain}.com" + user, created = User.objects.get_or_create( + username=username, + defaults={ + 'email': email, + 'first_name': fake.first_name(), + 'last_name': fake.last_name(), + 'role': role, + 'tenant': tenant, + 'is_active': True, + } + ) + if created: + user.set_password('password123') + user.save() + users.append(user) + + self.stdout.write(f' → Total users for {tenant.subdomain}: {len(users)}') + return users + + def create_projects_for_tenant(self, tenant, users, range_tuple): + self.stdout.write(f'\n Creating projects for {tenant.subdomain}...') + projects = [] + num_projects = random.randint(*range_tuple) + statuses = ['active', 'planned', 'completed', 'archived'] + + admin_users = [u for u in users if u.role in ['teacher', 'institution_admin', 'super_admin', 'project_manager']] + if not admin_users: + admin_users = users + + for i in range(num_projects): + creator = random.choice(admin_users) + project = Project.objects.create( + tenant=tenant, + name=fake.catch_phrase().title(), + description=fake.text(max_nb_chars=200) if random.random() > 0.3 else '', + created_by=creator, + status=random.choice(statuses), + created_at=random_date(timezone.now() - datetime.timedelta(days=365), timezone.now()), + ) + projects.append(project) + self.stdout.write(f' ✓ Project: {project.name}') + return projects + + def create_tasks_for_projects(self, projects, users, range_tuple): + self.stdout.write(f'\n Creating tasks...') + total_tasks = 0 + statuses = ['todo', 'in_progress', 'review', 'done'] + priorities = ['low', 'medium', 'high', 'urgent'] + + for project in projects: + num_tasks = random.randint(*range_tuple) + project_users = [u for u in users if u.tenant == project.tenant] + + for i in range(num_tasks): + assigned_to = random.choice(project_users) if random.random() > 0.3 else None + due_date = random_date(timezone.now() + datetime.timedelta(days=1), timezone.now() + datetime.timedelta(days=90)) if random.random() > 0.4 else None + + task = Task.objects.create( + tenant=project.tenant, + project=project, + title=fake.sentence(nb_words=6).rstrip('.'), + description=fake.text(max_nb_chars=150) if random.random() > 0.5 else '', + assigned_to=assigned_to, + status=random.choice(statuses), + priority=random.choice(priorities), + due_date=due_date, + ) + total_tasks += 1 + self.stdout.write(f' → {project.name}: {num_tasks} tasks') + + self.stdout.write(f' ✓ Total tasks created: {total_tasks}') + return total_tasks + + def create_notifications(self, users, range_tuple): + self.stdout.write(f'\n Creating notifications...') + total_notifications = 0 + titles = [ + 'New task assigned', 'Project update', 'Deadline approaching', + 'Comment on your task', 'Project completed', 'Meeting reminder', + ] + + for user in users: + num_notifications = random.randint(*range_tuple) + for i in range(num_notifications): + Notification.objects.create( + tenant=user.tenant, + user=user, + title=random.choice(titles), + message=fake.sentence(), + is_read=random.choice([True, False, False, False]), + link=fake.url() if random.random() > 0.7 else None, + created_at=random_date(timezone.now() - datetime.timedelta(days=30), timezone.now()), + ) + total_notifications += 1 + self.stdout.write(f' ✓ Total notifications: {total_notifications}') + return total_notifications + + def create_activity_logs(self, tenant, users, count): + self.stdout.write(f'\n Creating activity logs for {tenant.subdomain}...') + actions = ['created', 'updated', 'deleted', 'logged_in', 'assigned', 'completed'] + target_types = ['project', 'task', 'user', 'notification'] + + all_users = list(User.objects.all()) + total_logs = 0 + + for i in range(count): + user = random.choice(all_users) if all_users else None + ActivityLog.objects.create( + tenant=tenant, + user=user, + action=random.choice(actions), + target_type=random.choice(target_types), + target_id=str(random.randint(1, 1000)), + metadata={ + 'ip_address': fake.ipv4(), + 'user_agent': fake.user_agent(), + } if random.random() > 0.5 else {}, + created_at=random_date(timezone.now() - datetime.timedelta(days=180), timezone.now()), + ) + total_logs += 1 + + self.stdout.write(f' ✓ Activity logs: {total_logs}') + return total_logs + + def print_summary(self, tenants, total_users, total_projects, total_tasks, total_notifications, total_logs): + self.stdout.write("\n" + "="*60) + self.stdout.write(" POPULATION COMPLETE".center(60)) + self.stdout.write("="*60) + self.stdout.write(f' Tenants created: {len(tenants)}') + self.stdout.write(f' Total users: {total_users}') + self.stdout.write(f' Total projects: {total_projects}') + self.stdout.write(f' Total tasks: {total_tasks}') + self.stdout.write(f' Total notifications: {total_notifications}') + self.stdout.write(f' Total activity logs: {total_logs}') + self.stdout.write("="*60) + self.stdout.write('\nTest accounts per tenant:') + self.stdout.write(' Username: super_admin_ / Password: password123') + self.stdout.write(' Username: institution_admin_ / Password: password123') + self.stdout.write(' Other users: _1, _2, ...') + self.stdout.write('\nLogin example (use first tenant ID as X-Tenant-ID):') + self.stdout.write(' POST /api/auth/login/') + self.stdout.write(' Headers: X-Tenant-ID: 1') + self.stdout.write(' Body: {"username": "super_admin_techacademy", "password": "password123"}') + self.stdout.write("="*60) + + # Also print tenant IDs for reference + self.stdout.write('\nTenant IDs:') + for t in tenants: + self.stdout.write(f' {t.id}: {t.name} ({t.subdomain})') + +def random_date(start_date, end_date): + delta = end_date - start_date + random_days = random.randint(0, delta.days) + return start_date + datetime.timedelta(days=random_days) diff --git a/backend/tenants/managers.py b/backend/tenants/managers.py new file mode 100644 index 0000000..0eba805 --- /dev/null +++ b/backend/tenants/managers.py @@ -0,0 +1,15 @@ +from django.db import models +from django.core.exceptions import FieldError + +class TenantScopedQuerySet(models.QuerySet): + def tenant(self, tenant=None): + if tenant: + return self.filter(tenant=tenant) + return self + +class TenantScopedManager(models.Manager): + def get_queryset(self): + return TenantScopedQuerySet(self.model, using=self._db) + + def tenant(self, tenant=None): + return self.get_queryset().tenant(tenant) diff --git a/backend/tenants/middleware.py b/backend/tenants/middleware.py new file mode 100644 index 0000000..e28c934 --- /dev/null +++ b/backend/tenants/middleware.py @@ -0,0 +1,39 @@ +from django.http import JsonResponse +from tenants.models import Tenant + +class TenantMiddleware: + def __init__(self, get_response): + self.get_response = get_response + self.exempt_paths = [ + '/admin/', + '/api/schema/', + '/api/docs/', + '/api/auth/login/', + '/api/auth/register/', + '/api/auth/token/refresh/', + '/api/auth/profile/', + ] + + def __call__(self, request): + if any(request.path.startswith(path) for path in self.exempt_paths): + request.tenant = None + return self.get_response(request) + + # 1. Check Header + tenant_id = request.headers.get('X-Tenant-ID') + if not tenant_id: + # 2. Check Subdomain (Optional, skipping for now, can implement later) + # host = request.get_host().split(':')[0] + # subdomain = host.split('.')[0] + pass + + if tenant_id: + try: + request.tenant = Tenant.objects.get(id=tenant_id, is_active=True) + except Tenant.DoesNotExist: + return JsonResponse({"detail": "Invalid or inactive tenant ID supplied."}, status=403) + else: + # Normally we might enforce tenant_id, but we'll let permission classes handle it. + request.tenant = None + + return self.get_response(request) diff --git a/backend/tenants/migrations/0001_initial.py b/backend/tenants/migrations/0001_initial.py new file mode 100644 index 0000000..463dc8c --- /dev/null +++ b/backend/tenants/migrations/0001_initial.py @@ -0,0 +1,34 @@ +# Generated by Django 6.0.2 on 2026-02-20 18:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Tenant", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ( + "subdomain", + models.CharField(db_index=True, max_length=100, unique=True), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("is_active", models.BooleanField(default=True)), + ], + ), + ] diff --git a/backend/tenants/migrations/__init__.py b/backend/tenants/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tenants/migrations/__pycache__/0001_initial.cpython-314.pyc b/backend/tenants/migrations/__pycache__/0001_initial.cpython-314.pyc new file mode 100644 index 0000000..b59a8af Binary files /dev/null and b/backend/tenants/migrations/__pycache__/0001_initial.cpython-314.pyc differ diff --git a/backend/tenants/migrations/__pycache__/__init__.cpython-314.pyc b/backend/tenants/migrations/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..e9c7f92 Binary files /dev/null and b/backend/tenants/migrations/__pycache__/__init__.cpython-314.pyc differ diff --git a/backend/tenants/models.py b/backend/tenants/models.py new file mode 100644 index 0000000..737ef3d --- /dev/null +++ b/backend/tenants/models.py @@ -0,0 +1,10 @@ +from django.db import models + +class Tenant(models.Model): + name = models.CharField(max_length=255) + subdomain = models.CharField(max_length=100, unique=True, db_index=True) + created_at = models.DateTimeField(auto_now_add=True) + is_active = models.BooleanField(default=True) + + def __str__(self): + return self.name diff --git a/backend/tenants/serializers.py b/backend/tenants/serializers.py new file mode 100644 index 0000000..d97b15b --- /dev/null +++ b/backend/tenants/serializers.py @@ -0,0 +1,8 @@ +from rest_framework import serializers +from .models import Tenant + +class TenantSerializer(serializers.ModelSerializer): + class Meta: + model = Tenant + fields = ['id', 'name', 'subdomain', 'created_at', 'is_active'] + read_only_fields = ['id', 'created_at'] diff --git a/backend/tenants/tests.py b/backend/tenants/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/tenants/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/tenants/views.py b/backend/tenants/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/backend/tenants/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here.