Backend Draft
This commit is contained in:
0
backend/accounts/__init__.py
Normal file
0
backend/accounts/__init__.py
Normal file
BIN
backend/accounts/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
backend/accounts/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/accounts/__pycache__/admin.cpython-314.pyc
Normal file
BIN
backend/accounts/__pycache__/admin.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/accounts/__pycache__/apps.cpython-314.pyc
Normal file
BIN
backend/accounts/__pycache__/apps.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/accounts/__pycache__/models.cpython-314.pyc
Normal file
BIN
backend/accounts/__pycache__/models.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/accounts/__pycache__/permissions.cpython-314.pyc
Normal file
BIN
backend/accounts/__pycache__/permissions.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/accounts/__pycache__/serializers.cpython-314.pyc
Normal file
BIN
backend/accounts/__pycache__/serializers.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/accounts/__pycache__/tests.cpython-314.pyc
Normal file
BIN
backend/accounts/__pycache__/tests.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/accounts/__pycache__/urls.cpython-314.pyc
Normal file
BIN
backend/accounts/__pycache__/urls.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/accounts/__pycache__/views.cpython-314.pyc
Normal file
BIN
backend/accounts/__pycache__/views.cpython-314.pyc
Normal file
Binary file not shown.
12
backend/accounts/admin.py
Normal file
12
backend/accounts/admin.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
from .models import User
|
||||
|
||||
class CustomUserAdmin(UserAdmin):
|
||||
model = User
|
||||
fieldsets = UserAdmin.fieldsets + (
|
||||
('Tenant Info', {'fields': ('tenant', 'role')}),
|
||||
)
|
||||
list_display = ['username', 'email', 'first_name', 'last_name', 'role', 'tenant', 'is_staff']
|
||||
|
||||
admin.site.register(User, CustomUserAdmin)
|
||||
6
backend/accounts/apps.py
Normal file
6
backend/accounts/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "accounts"
|
||||
154
backend/accounts/migrations/0001_initial.py
Normal file
154
backend/accounts/migrations/0001_initial.py
Normal file
@@ -0,0 +1,154 @@
|
||||
# Generated by Django 6.0.2 on 2026-02-20 18:33
|
||||
|
||||
import django.contrib.auth.validators
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("auth", "0012_alter_user_first_name_max_length"),
|
||||
("tenants", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="User",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("password", models.CharField(max_length=128, verbose_name="password")),
|
||||
(
|
||||
"last_login",
|
||||
models.DateTimeField(
|
||||
blank=True, null=True, verbose_name="last login"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_superuser",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Designates that this user has all permissions without explicitly assigning them.",
|
||||
verbose_name="superuser status",
|
||||
),
|
||||
),
|
||||
(
|
||||
"username",
|
||||
models.CharField(
|
||||
error_messages={
|
||||
"unique": "A user with that username already exists."
|
||||
},
|
||||
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
|
||||
max_length=150,
|
||||
unique=True,
|
||||
validators=[
|
||||
django.contrib.auth.validators.UnicodeUsernameValidator()
|
||||
],
|
||||
verbose_name="username",
|
||||
),
|
||||
),
|
||||
(
|
||||
"first_name",
|
||||
models.CharField(
|
||||
blank=True, max_length=150, verbose_name="first name"
|
||||
),
|
||||
),
|
||||
(
|
||||
"last_name",
|
||||
models.CharField(
|
||||
blank=True, max_length=150, verbose_name="last name"
|
||||
),
|
||||
),
|
||||
(
|
||||
"email",
|
||||
models.EmailField(
|
||||
blank=True, max_length=254, verbose_name="email address"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_staff",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Designates whether the user can log into this admin site.",
|
||||
verbose_name="staff status",
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_active",
|
||||
models.BooleanField(
|
||||
default=True,
|
||||
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
|
||||
verbose_name="active",
|
||||
),
|
||||
),
|
||||
(
|
||||
"date_joined",
|
||||
models.DateTimeField(
|
||||
default=django.utils.timezone.now, verbose_name="date joined"
|
||||
),
|
||||
),
|
||||
(
|
||||
"role",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("super_admin", "Super Admin"),
|
||||
("institution_admin", "Institution Admin"),
|
||||
("teacher", "Teacher"),
|
||||
("student", "Student"),
|
||||
("project_manager", "Project Manager"),
|
||||
],
|
||||
default="student",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"groups",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
|
||||
related_name="user_set",
|
||||
related_query_name="user",
|
||||
to="auth.group",
|
||||
verbose_name="groups",
|
||||
),
|
||||
),
|
||||
(
|
||||
"tenant",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="users",
|
||||
to="tenants.tenant",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user_permissions",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="Specific permissions for this user.",
|
||||
related_name="user_set",
|
||||
related_query_name="user",
|
||||
to="auth.permission",
|
||||
verbose_name="user permissions",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "user",
|
||||
"verbose_name_plural": "users",
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
]
|
||||
20
backend/accounts/migrations/0002_alter_user_managers.py
Normal file
20
backend/accounts/migrations/0002_alter_user_managers.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 4.2.28 on 2026-02-20 18:37
|
||||
|
||||
import django.contrib.auth.models
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelManagers(
|
||||
name="user",
|
||||
managers=[
|
||||
("objects", django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
]
|
||||
0
backend/accounts/migrations/__init__.py
Normal file
0
backend/accounts/migrations/__init__.py
Normal file
Binary file not shown.
Binary file not shown.
BIN
backend/accounts/migrations/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
backend/accounts/migrations/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
22
backend/accounts/models.py
Normal file
22
backend/accounts/models.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from django.contrib.auth.models import AbstractUser, UserManager
|
||||
from django.db import models
|
||||
from tenants.models import Tenant
|
||||
from tenants.managers import TenantScopedManager
|
||||
|
||||
class User(AbstractUser):
|
||||
ROLE_CHOICES = [
|
||||
('super_admin', 'Super Admin'),
|
||||
('institution_admin', 'Institution Admin'),
|
||||
('teacher', 'Teacher'),
|
||||
('student', 'Student'),
|
||||
('project_manager', 'Project Manager'),
|
||||
]
|
||||
|
||||
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='users', null=True, blank=True)
|
||||
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='student')
|
||||
|
||||
objects = UserManager() # Default manager for auth operations
|
||||
tenant_objects = TenantScopedManager() # Custom manager for tenant filtering
|
||||
|
||||
def __str__(self):
|
||||
return self.username
|
||||
57
backend/accounts/permissions.py
Normal file
57
backend/accounts/permissions.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from rest_framework import permissions
|
||||
|
||||
class IsTenantUser(permissions.BasePermission):
|
||||
"""
|
||||
Allows access only to authenticated users who belong to a tenant.
|
||||
Also ensures object-level tenant isolation.
|
||||
"""
|
||||
def has_permission(self, request, view):
|
||||
return bool(request.user and request.user.is_authenticated and request.user.tenant)
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
if hasattr(obj, 'tenant'):
|
||||
return obj.tenant == request.user.tenant
|
||||
return True
|
||||
|
||||
class IsAdmin(permissions.BasePermission):
|
||||
"""
|
||||
Allows access only to super_admin and institution_admin roles.
|
||||
"""
|
||||
def has_permission(self, request, view):
|
||||
return bool(
|
||||
request.user and
|
||||
request.user.is_authenticated and
|
||||
request.user.role in ['super_admin', 'institution_admin']
|
||||
)
|
||||
|
||||
class IsProjectOwner(permissions.BasePermission):
|
||||
"""
|
||||
Allows object level access only to the user who created it.
|
||||
"""
|
||||
def has_object_permission(self, request, view, obj):
|
||||
if hasattr(obj, 'created_by'):
|
||||
return obj.created_by == request.user
|
||||
return False
|
||||
|
||||
class IsTeacher(permissions.BasePermission):
|
||||
"""
|
||||
Allows access only to teacher, institution_admin, or super_admin roles.
|
||||
"""
|
||||
def has_permission(self, request, view):
|
||||
return bool(
|
||||
request.user and
|
||||
request.user.is_authenticated and
|
||||
request.user.role in ['teacher', 'institution_admin', 'super_admin']
|
||||
)
|
||||
|
||||
class IsStudentReadOnly(permissions.BasePermission):
|
||||
"""
|
||||
Students get read-only access (GET, HEAD, OPTIONS).
|
||||
Other roles are allowed (and restricted by other classes).
|
||||
"""
|
||||
def has_permission(self, request, view):
|
||||
if request.user and request.user.is_authenticated:
|
||||
if request.user.role == 'student':
|
||||
return request.method in permissions.SAFE_METHODS
|
||||
return True
|
||||
return False
|
||||
44
backend/accounts/serializers.py
Normal file
44
backend/accounts/serializers.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from rest_framework import serializers
|
||||
from django.contrib.auth import get_user_model
|
||||
from tenants.serializers import TenantSerializer
|
||||
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
class CustomTokenObtainPairSerializer(TokenObtainPairSerializer):
|
||||
@classmethod
|
||||
def get_token(cls, user):
|
||||
token = super().get_token(user)
|
||||
|
||||
# Add custom claims
|
||||
token['role'] = getattr(user, 'role', 'student')
|
||||
token['tenant_id'] = user.tenant.id if getattr(user, 'tenant', None) else None
|
||||
|
||||
return token
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
tenant = TenantSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['id', 'username', 'email', 'first_name', 'last_name', 'role', 'tenant', 'is_active']
|
||||
read_only_fields = ['id']
|
||||
|
||||
class RegisterSerializer(serializers.ModelSerializer):
|
||||
password = serializers.CharField(write_only=True)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['username', 'email', 'password', 'first_name', 'last_name', 'role']
|
||||
|
||||
def create(self, validated_data):
|
||||
user = User.objects.create_user(
|
||||
username=validated_data['username'],
|
||||
email=validated_data.get('email', ''),
|
||||
password=validated_data['password'],
|
||||
first_name=validated_data.get('first_name', ''),
|
||||
last_name=validated_data.get('last_name', ''),
|
||||
role=validated_data.get('role', 'student'),
|
||||
tenant=self.context['request'].tenant if 'request' in self.context else None
|
||||
)
|
||||
return user
|
||||
59
backend/accounts/tests.py
Normal file
59
backend/accounts/tests.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework import status
|
||||
from django.contrib.auth import get_user_model
|
||||
from tenants.models import Tenant
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
class AuthTests(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.tenant = Tenant.objects.create(name='Test Tenant', subdomain='testtenant')
|
||||
self.register_url = reverse('auth_register')
|
||||
self.login_url = reverse('token_obtain_pair')
|
||||
self.profile_url = reverse('user_profile')
|
||||
|
||||
self.user_data = {
|
||||
'username': 'testuser',
|
||||
'email': 'testuser@example.com',
|
||||
'password': 'strongpassword123',
|
||||
'first_name': 'Test',
|
||||
'last_name': 'User',
|
||||
'role': 'student'
|
||||
}
|
||||
|
||||
def test_registration(self):
|
||||
# Register a new user
|
||||
response = self.client.post(self.register_url, self.user_data, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
user = User.objects.get(username='testuser')
|
||||
self.assertTrue(user.check_password('strongpassword123'))
|
||||
self.assertEqual(user.role, 'student')
|
||||
|
||||
def test_login_and_profile(self):
|
||||
# First create the user
|
||||
user = User.objects.create_user(
|
||||
username='testuser',
|
||||
password='strongpassword123',
|
||||
role='teacher',
|
||||
tenant=self.tenant
|
||||
)
|
||||
|
||||
# Login to get tokens
|
||||
login_data = {'username': 'testuser', 'password': 'strongpassword123'}
|
||||
response = self.client.post(self.login_url, login_data, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
self.assertIn('access', response.data)
|
||||
self.assertIn('refresh', response.data)
|
||||
access_token = response.data['access']
|
||||
|
||||
# Fetch profile using the token
|
||||
self.client.credentials(HTTP_AUTHORIZATION='Bearer ' + access_token)
|
||||
profile_response = self.client.get(self.profile_url)
|
||||
self.assertEqual(profile_response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(profile_response.data['username'], 'testuser')
|
||||
self.assertEqual(profile_response.data['role'], 'teacher')
|
||||
self.assertEqual(profile_response.data['tenant']['id'], self.tenant.id)
|
||||
11
backend/accounts/urls.py
Normal file
11
backend/accounts/urls.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from django.urls import path
|
||||
from rest_framework_simplejwt.views import TokenRefreshView
|
||||
from .views import RegisterView, CustomTokenObtainPairView, UserProfileView, UserListView
|
||||
|
||||
urlpatterns = [
|
||||
path('register/', RegisterView.as_view(), name='auth_register'),
|
||||
path('login/', CustomTokenObtainPairView.as_view(), name='token_obtain_pair'),
|
||||
path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
|
||||
path('profile/', UserProfileView.as_view(), name='user_profile'),
|
||||
path('users/', UserListView.as_view(), name='user_list'),
|
||||
]
|
||||
33
backend/accounts/views.py
Normal file
33
backend/accounts/views.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from rest_framework import generics, permissions, viewsets
|
||||
from rest_framework_simplejwt.views import TokenObtainPairView
|
||||
from django.contrib.auth import get_user_model
|
||||
from .serializers import RegisterSerializer, UserSerializer, CustomTokenObtainPairSerializer
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
class CustomTokenObtainPairView(TokenObtainPairView):
|
||||
serializer_class = CustomTokenObtainPairSerializer
|
||||
|
||||
class RegisterView(generics.CreateAPIView):
|
||||
queryset = User.objects.all()
|
||||
serializer_class = RegisterSerializer
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
class UserProfileView(generics.RetrieveUpdateAPIView):
|
||||
serializer_class = UserSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_object(self):
|
||||
return self.request.user
|
||||
|
||||
class UserListView(generics.ListAPIView):
|
||||
"""
|
||||
List all users in the current tenant.
|
||||
Used for task assignment dropdown.
|
||||
"""
|
||||
serializer_class = UserSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
pagination_class = None # Return all users as a list, not paginated
|
||||
|
||||
def get_queryset(self):
|
||||
return User.objects.filter(tenant=self.request.tenant, is_active=True).order_by('first_name', 'last_name')
|
||||
0
backend/analytics/__init__.py
Normal file
0
backend/analytics/__init__.py
Normal file
BIN
backend/analytics/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
backend/analytics/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/analytics/__pycache__/admin.cpython-314.pyc
Normal file
BIN
backend/analytics/__pycache__/admin.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/analytics/__pycache__/apps.cpython-314.pyc
Normal file
BIN
backend/analytics/__pycache__/apps.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/analytics/__pycache__/models.cpython-314.pyc
Normal file
BIN
backend/analytics/__pycache__/models.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/analytics/__pycache__/serializers.cpython-314.pyc
Normal file
BIN
backend/analytics/__pycache__/serializers.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/analytics/__pycache__/signals.cpython-314.pyc
Normal file
BIN
backend/analytics/__pycache__/signals.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/analytics/__pycache__/urls.cpython-314.pyc
Normal file
BIN
backend/analytics/__pycache__/urls.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/analytics/__pycache__/views.cpython-314.pyc
Normal file
BIN
backend/analytics/__pycache__/views.cpython-314.pyc
Normal file
Binary file not shown.
3
backend/analytics/admin.py
Normal file
3
backend/analytics/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
9
backend/analytics/apps.py
Normal file
9
backend/analytics/apps.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AnalyticsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "analytics"
|
||||
|
||||
def ready(self):
|
||||
import analytics.signals
|
||||
109
backend/analytics/migrations/0001_initial.py
Normal file
109
backend/analytics/migrations/0001_initial.py
Normal file
@@ -0,0 +1,109 @@
|
||||
# Generated by Django 4.2.28 on 2026-02-20 19:49
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("tenants", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="AuditLog",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("action", models.CharField(max_length=50)),
|
||||
("model_name", models.CharField(max_length=100)),
|
||||
("object_id", models.CharField(max_length=255)),
|
||||
("changes", models.JSONField(blank=True, default=dict)),
|
||||
("ip_address", models.GenericIPAddressField(blank=True, null=True)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"tenant",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="audit_logs",
|
||||
to="tenants.tenant",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="audit_logs",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
"indexes": [
|
||||
models.Index(
|
||||
fields=["tenant", "created_at"],
|
||||
name="analytics_a_tenant__9c95af_idx",
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ActivityLog",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("action", models.CharField(max_length=100)),
|
||||
("target_type", models.CharField(max_length=100)),
|
||||
("target_id", models.CharField(max_length=255)),
|
||||
("metadata", models.JSONField(blank=True, default=dict)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"tenant",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="activity_logs",
|
||||
to="tenants.tenant",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="activity_logs",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
"indexes": [
|
||||
models.Index(
|
||||
fields=["tenant", "created_at"],
|
||||
name="analytics_a_tenant__75e066_idx",
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
backend/analytics/migrations/__init__.py
Normal file
0
backend/analytics/migrations/__init__.py
Normal file
Binary file not shown.
Binary file not shown.
45
backend/analytics/models.py
Normal file
45
backend/analytics/models.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from tenants.managers import TenantScopedManager
|
||||
from tenants.models import Tenant
|
||||
|
||||
class AuditLog(models.Model):
|
||||
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='audit_logs')
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name='audit_logs')
|
||||
action = models.CharField(max_length=50) # created, updated, deleted
|
||||
model_name = models.CharField(max_length=100)
|
||||
object_id = models.CharField(max_length=255)
|
||||
changes = models.JSONField(default=dict, blank=True)
|
||||
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
objects = TenantScopedManager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['tenant', 'created_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.action} on {self.model_name}:{self.object_id} by {self.user}"
|
||||
|
||||
class ActivityLog(models.Model):
|
||||
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='activity_logs')
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name='activity_logs')
|
||||
action = models.CharField(max_length=100)
|
||||
target_type = models.CharField(max_length=100)
|
||||
target_id = models.CharField(max_length=255)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
objects = TenantScopedManager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['tenant', 'created_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user} {self.action} {self.target_type}:{self.target_id}"
|
||||
18
backend/analytics/serializers.py
Normal file
18
backend/analytics/serializers.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from rest_framework import serializers
|
||||
from .models import ActivityLog, AuditLog
|
||||
|
||||
class ActivityLogSerializer(serializers.ModelSerializer):
|
||||
user_name = serializers.CharField(source='user.get_full_name', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ActivityLog
|
||||
fields = ['id', 'user_name', 'action', 'target_type', 'target_id', 'metadata', 'created_at']
|
||||
read_only_fields = ['id', 'user_name', 'action', 'target_type', 'target_id', 'metadata', 'created_at']
|
||||
|
||||
class AuditLogSerializer(serializers.ModelSerializer):
|
||||
user_name = serializers.CharField(source='user.get_full_name', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = AuditLog
|
||||
fields = ['id', 'user_name', 'action', 'model_name', 'object_id', 'changes', 'ip_address', 'created_at']
|
||||
read_only_fields = ['id', 'user_name', 'action', 'model_name', 'object_id', 'changes', 'ip_address', 'created_at']
|
||||
162
backend/analytics/signals.py
Normal file
162
backend/analytics/signals.py
Normal file
@@ -0,0 +1,162 @@
|
||||
from django.db.models.signals import post_save, post_delete, pre_save
|
||||
from django.dispatch import receiver
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from .models import ActivityLog, AuditLog
|
||||
import json
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
import inspect
|
||||
|
||||
# Try to import models dynamically to avoid circular imports during startup
|
||||
# We'll use get_model where possible, but for signals we need the actual models
|
||||
from projects.models import Project, Task
|
||||
|
||||
def get_current_request():
|
||||
"""
|
||||
A hacky way to get the current request in signals.
|
||||
In a real production app, you'd use a middleware like django-crum
|
||||
or pass the user explicitly.
|
||||
For this prototype, we'll try to find the request in the stack.
|
||||
"""
|
||||
for f in inspect.stack():
|
||||
if 'request' in f[0].f_locals:
|
||||
return f[0].f_locals['request']
|
||||
return None
|
||||
|
||||
def get_user_and_tenant_from_instance_or_request(instance):
|
||||
request = get_current_request()
|
||||
user = getattr(request, 'user', None) if request else None
|
||||
|
||||
# If user is anonymous, try to get from instance
|
||||
if user and user.is_authenticated:
|
||||
pass
|
||||
elif hasattr(instance, 'created_by') and instance.created_by:
|
||||
user = instance.created_by
|
||||
elif hasattr(instance, 'user') and instance.user:
|
||||
user = instance.user
|
||||
|
||||
tenant = getattr(request, 'tenant', None) if request else getattr(instance, 'tenant', None)
|
||||
|
||||
return user, tenant
|
||||
|
||||
@receiver(post_save, sender=Project)
|
||||
@receiver(post_save, sender=Task)
|
||||
def log_activity_on_save(sender, instance, created, **kwargs):
|
||||
user, tenant = get_user_and_tenant_from_instance_or_request(instance)
|
||||
|
||||
if not tenant:
|
||||
return # Cannot log without tenant
|
||||
|
||||
action = "created" if created else "updated"
|
||||
target_type = sender.__name__
|
||||
|
||||
metadata = {}
|
||||
if hasattr(instance, 'name'):
|
||||
metadata['name'] = instance.name
|
||||
elif hasattr(instance, 'title'):
|
||||
metadata['title'] = instance.title
|
||||
|
||||
ActivityLog.objects.create(
|
||||
tenant=tenant,
|
||||
user=user if (user and user.is_authenticated) else None,
|
||||
action=action,
|
||||
target_type=target_type,
|
||||
target_id=str(instance.id),
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
@receiver(post_delete, sender=Project)
|
||||
@receiver(post_delete, sender=Task)
|
||||
def log_activity_on_delete(sender, instance, **kwargs):
|
||||
user, tenant = get_user_and_tenant_from_instance_or_request(instance)
|
||||
|
||||
if not tenant:
|
||||
return
|
||||
|
||||
action = "deleted"
|
||||
target_type = sender.__name__
|
||||
|
||||
metadata = {}
|
||||
if hasattr(instance, 'name'):
|
||||
metadata['name'] = instance.name
|
||||
elif hasattr(instance, 'title'):
|
||||
metadata['title'] = instance.title
|
||||
|
||||
ActivityLog.objects.create(
|
||||
tenant=tenant,
|
||||
user=user if (user and user.is_authenticated) else None,
|
||||
action=action,
|
||||
target_type=target_type,
|
||||
target_id=str(instance.id),
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
# --- Audit Logs ---
|
||||
|
||||
# Small hack: Store the original state before saving
|
||||
@receiver(pre_save, sender=Project)
|
||||
@receiver(pre_save, sender=Task)
|
||||
def store_original_state(sender, instance, **kwargs):
|
||||
if instance.pk:
|
||||
try:
|
||||
old_instance = sender.objects.get(pk=instance.pk)
|
||||
# Store it on the instance for the post_save signal
|
||||
instance._old_state = {f.name: getattr(old_instance, f.name) for f in sender._meta.fields}
|
||||
except sender.DoesNotExist:
|
||||
pass
|
||||
|
||||
@receiver(post_save, sender=Project)
|
||||
@receiver(post_save, sender=Task)
|
||||
def log_audit_on_save(sender, instance, created, **kwargs):
|
||||
user, tenant = get_user_and_tenant_from_instance_or_request(instance)
|
||||
request = get_current_request()
|
||||
|
||||
if not tenant:
|
||||
return
|
||||
|
||||
action = "created" if created else "updated"
|
||||
|
||||
changes = {}
|
||||
if not created and hasattr(instance, '_old_state'):
|
||||
for f in sender._meta.fields:
|
||||
old_val = instance._old_state.get(f.name)
|
||||
new_val = getattr(instance, f.name)
|
||||
if old_val != new_val:
|
||||
# Basic stringification for JSON serialization
|
||||
changes[f.name] = {
|
||||
'old': str(old_val),
|
||||
'new': str(new_val)
|
||||
}
|
||||
elif created:
|
||||
for f in sender._meta.fields:
|
||||
changes[f.name] = {'new': str(getattr(instance, f.name))}
|
||||
|
||||
AuditLog.objects.create(
|
||||
tenant=tenant,
|
||||
user=user if (user and user.is_authenticated) else None,
|
||||
action=action,
|
||||
model_name=sender.__name__,
|
||||
object_id=str(instance.id),
|
||||
changes=changes,
|
||||
ip_address=request.META.get('REMOTE_ADDR') if request else None
|
||||
)
|
||||
|
||||
@receiver(post_delete, sender=Project)
|
||||
@receiver(post_delete, sender=Task)
|
||||
def log_audit_on_delete(sender, instance, **kwargs):
|
||||
user, tenant = get_user_and_tenant_from_instance_or_request(instance)
|
||||
request = get_current_request()
|
||||
|
||||
if not tenant:
|
||||
return
|
||||
|
||||
changes = {f.name: {'old': str(getattr(instance, f.name))} for f in sender._meta.fields}
|
||||
|
||||
AuditLog.objects.create(
|
||||
tenant=tenant,
|
||||
user=user if (user and user.is_authenticated) else None,
|
||||
action="deleted",
|
||||
model_name=sender.__name__,
|
||||
object_id=str(instance.id),
|
||||
changes=changes,
|
||||
ip_address=request.META.get('REMOTE_ADDR') if request else None
|
||||
)
|
||||
3
backend/analytics/tests.py
Normal file
3
backend/analytics/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
7
backend/analytics/urls.py
Normal file
7
backend/analytics/urls.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.urls import path
|
||||
from .views import ActivityLogListView, ReportView
|
||||
|
||||
urlpatterns = [
|
||||
path('activity/', ActivityLogListView.as_view(), name='activity-list'),
|
||||
path('reports/', ReportView.as_view(), name='reports'),
|
||||
]
|
||||
95
backend/analytics/views.py
Normal file
95
backend/analytics/views.py
Normal file
@@ -0,0 +1,95 @@
|
||||
from rest_framework import generics, permissions, status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from django.db.models import Count
|
||||
from django.db.models.functions import TruncDate
|
||||
from django.utils.dateparse import parse_date
|
||||
from .models import ActivityLog, AuditLog
|
||||
from .serializers import ActivityLogSerializer, AuditLogSerializer
|
||||
from accounts.permissions import IsTenantUser, IsAdmin
|
||||
from projects.models import Project, Task
|
||||
|
||||
class ActivityLogListView(generics.ListAPIView):
|
||||
serializer_class = ActivityLogSerializer
|
||||
permission_classes = [permissions.IsAuthenticated, IsTenantUser]
|
||||
|
||||
def get_queryset(self):
|
||||
return ActivityLog.objects.filter(tenant=self.request.tenant)
|
||||
|
||||
class ReportView(APIView):
|
||||
"""
|
||||
Returns aggregated stats for a date range with daily breakdown
|
||||
Query params: start_date (YYYY-MM-DD), end_date (YYYY-MM-DD)
|
||||
"""
|
||||
permission_classes = [permissions.IsAuthenticated, IsTenantUser, IsAdmin] # Only admins can see reports usually
|
||||
|
||||
def get(self, request):
|
||||
tenant = request.tenant
|
||||
start_date_str = request.query_params.get('start_date')
|
||||
end_date_str = request.query_params.get('end_date')
|
||||
|
||||
project_qs = Project.objects.filter(tenant=tenant)
|
||||
task_qs = Task.objects.filter(tenant=tenant)
|
||||
|
||||
if start_date_str:
|
||||
start_date = parse_date(start_date_str)
|
||||
if start_date:
|
||||
project_qs = project_qs.filter(created_at__date__gte=start_date)
|
||||
task_qs = task_qs.filter(created_at__date__gte=start_date)
|
||||
|
||||
if end_date_str:
|
||||
end_date = parse_date(end_date_str)
|
||||
if end_date:
|
||||
project_qs = project_qs.filter(created_at__date__lte=end_date)
|
||||
task_qs = task_qs.filter(created_at__date__lte=end_date)
|
||||
|
||||
# Total counts
|
||||
projects_created = project_qs.count()
|
||||
tasks_created = task_qs.count()
|
||||
tasks_completed = task_qs.filter(status='done').count()
|
||||
|
||||
# Daily metrics: group by date
|
||||
from django.db.models.functions import TruncDate
|
||||
|
||||
daily_projects = project_qs.annotate(day=TruncDate('created_at')).values('day').annotate(count=Count('id')).order_by('day')
|
||||
daily_tasks_completed = task_qs.filter(status='done').annotate(day=TruncDate('created_at')).values('day').annotate(count=Count('id')).order_by('day')
|
||||
daily_tasks_created = task_qs.annotate(day=TruncDate('created_at')).values('day').annotate(count=Count('id')).order_by('day')
|
||||
|
||||
# Build daily metrics array
|
||||
daily_metrics = []
|
||||
# Collect all unique dates from both projects and tasks
|
||||
all_dates = set()
|
||||
for item in daily_projects:
|
||||
all_dates.add(item['day'])
|
||||
for item in daily_tasks_completed:
|
||||
all_dates.add(item['day'])
|
||||
for item in daily_tasks_created:
|
||||
all_dates.add(item['day'])
|
||||
|
||||
# Sort dates
|
||||
sorted_dates = sorted(all_dates)
|
||||
|
||||
for date_obj in sorted_dates:
|
||||
date_str = date_obj.strftime('%Y-%m-%d')
|
||||
# Find counts for this date
|
||||
proj_count = next((item['count'] for item in daily_projects if item['day'] == date_obj), 0)
|
||||
tasks_comp = next((item['count'] for item in daily_tasks_completed if item['day'] == date_obj), 0)
|
||||
tasks_created_count = next((item['count'] for item in daily_tasks_created if item['day'] == date_obj), 0)
|
||||
|
||||
daily_metrics.append({
|
||||
'date': date_str,
|
||||
'projects_created': proj_count,
|
||||
'tasks_completed': tasks_comp,
|
||||
'tasks_created': tasks_created_count,
|
||||
})
|
||||
|
||||
return Response({
|
||||
'period': {
|
||||
'start': start_date_str,
|
||||
'end': end_date_str
|
||||
},
|
||||
'total_projects': projects_created,
|
||||
'total_tasks': tasks_created,
|
||||
'tasks_completed': tasks_completed,
|
||||
'daily_metrics': daily_metrics
|
||||
})
|
||||
3
backend/core/__init__.py
Normal file
3
backend/core/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .celery import app as celery_app
|
||||
|
||||
__all__ = ('celery_app',)
|
||||
BIN
backend/core/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
backend/core/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/core/__pycache__/celery.cpython-314.pyc
Normal file
BIN
backend/core/__pycache__/celery.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/core/__pycache__/settings.cpython-314.pyc
Normal file
BIN
backend/core/__pycache__/settings.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/core/__pycache__/urls.cpython-314.pyc
Normal file
BIN
backend/core/__pycache__/urls.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/core/__pycache__/wsgi.cpython-314.pyc
Normal file
BIN
backend/core/__pycache__/wsgi.cpython-314.pyc
Normal file
Binary file not shown.
7
backend/core/asgi.py
Normal file
7
backend/core/asgi.py
Normal file
@@ -0,0 +1,7 @@
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings.dev')
|
||||
|
||||
application = get_asgi_application()
|
||||
20
backend/core/celery.py
Normal file
20
backend/core/celery.py
Normal file
@@ -0,0 +1,20 @@
|
||||
import os
|
||||
from celery import Celery
|
||||
|
||||
# Set the default Django settings module for the 'celery' program.
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings.dev')
|
||||
|
||||
app = Celery('mtcbd')
|
||||
|
||||
# Using a string here means the worker doesn't have to serialize
|
||||
# the configuration object to child processes.
|
||||
# - namespace='CELERY' means all celery-related configuration keys
|
||||
# should have a `CELERY_` prefix.
|
||||
app.config_from_object('django.conf:settings', namespace='CELERY')
|
||||
|
||||
# Load task modules from all registered Django apps.
|
||||
app.autodiscover_tasks()
|
||||
|
||||
@app.task(bind=True, ignore_result=True)
|
||||
def debug_task(self):
|
||||
print(f'Request: {self.request!r}')
|
||||
0
backend/core/settings/__init__.py
Normal file
0
backend/core/settings/__init__.py
Normal file
BIN
backend/core/settings/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
backend/core/settings/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/core/settings/__pycache__/base.cpython-314.pyc
Normal file
BIN
backend/core/settings/__pycache__/base.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/core/settings/__pycache__/dev.cpython-314.pyc
Normal file
BIN
backend/core/settings/__pycache__/dev.cpython-314.pyc
Normal file
Binary file not shown.
158
backend/core/settings/base.py
Normal file
158
backend/core/settings/base.py
Normal file
@@ -0,0 +1,158 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from datetime import timedelta
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load .env file from the project root (not backend dir)
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
PROJECT_DIR = BASE_DIR.parent
|
||||
load_dotenv(PROJECT_DIR / ".env")
|
||||
|
||||
SECRET_KEY = os.getenv("SECRET_KEY", "django-insecure-default-key-generate-yours")
|
||||
|
||||
# Installed Apps
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
|
||||
# Third party
|
||||
"rest_framework",
|
||||
"corsheaders",
|
||||
"rest_framework_simplejwt",
|
||||
"django_extensions",
|
||||
"drf_spectacular",
|
||||
"django_filters",
|
||||
|
||||
# Local Apps
|
||||
"tenants",
|
||||
"accounts",
|
||||
"dashboard",
|
||||
"projects",
|
||||
"analytics",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"corsheaders.middleware.CorsMiddleware",
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
# Custom Middleware
|
||||
"tenants.middleware.TenantMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "core.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = "core.wsgi.application"
|
||||
ASGI_APPLICATION = "core.asgi.application"
|
||||
|
||||
# Database defaults
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": BASE_DIR / "db.sqlite3",
|
||||
}
|
||||
}
|
||||
|
||||
# Use Accounts app custom User model
|
||||
AUTH_USER_MODEL = "accounts.User"
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
||||
},
|
||||
]
|
||||
|
||||
LANGUAGE_CODE = "en-us"
|
||||
TIME_ZONE = "UTC"
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
STATIC_URL = "static/"
|
||||
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
# REST Framework settings
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||
"rest_framework_simplejwt.authentication.JWTAuthentication",
|
||||
),
|
||||
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
||||
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
|
||||
"PAGE_SIZE": 10,
|
||||
"DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"],
|
||||
"DEFAULT_THROTTLE_CLASSES": [
|
||||
"rest_framework.throttling.AnonRateThrottle",
|
||||
"rest_framework.throttling.UserRateThrottle"
|
||||
],
|
||||
"DEFAULT_THROTTLE_RATES": {
|
||||
"anon": "100/day",
|
||||
"user": "1000/day"
|
||||
}
|
||||
}
|
||||
|
||||
# Simple JWT Settings
|
||||
SIMPLE_JWT = {
|
||||
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=60),
|
||||
"REFRESH_TOKEN_LIFETIME": timedelta(days=7),
|
||||
"ROTATE_REFRESH_TOKENS": True,
|
||||
"AUTH_HEADER_TYPES": ("Bearer",),
|
||||
}
|
||||
|
||||
# DRF Spectacular
|
||||
SPECTACULAR_SETTINGS = {
|
||||
"TITLE": "MTCBD API",
|
||||
"DESCRIPTION": "Multi-Tenant Cloud Based Dashboard APIs",
|
||||
"VERSION": "1.0.0",
|
||||
"SERVE_INCLUDE_SCHEMA": False,
|
||||
}
|
||||
|
||||
# Redis Caching
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django_redis.cache.RedisCache",
|
||||
"LOCATION": os.getenv("REDIS_URL", "redis://127.0.0.1:6379/1"),
|
||||
"OPTIONS": {
|
||||
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Celery Configuration
|
||||
CELERY_BROKER_URL = os.getenv("REDIS_URL", "redis://127.0.0.1:6379/2")
|
||||
CELERY_RESULT_BACKEND = os.getenv("REDIS_URL", "redis://127.0.0.1:6379/2")
|
||||
CELERY_ACCEPT_CONTENT = ['json']
|
||||
CELERY_TASK_SERIALIZER = 'json'
|
||||
56
backend/core/settings/dev.py
Normal file
56
backend/core/settings/dev.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from .base import *
|
||||
import os
|
||||
|
||||
DEBUG = True
|
||||
|
||||
ALLOWED_HOSTS = ["*"]
|
||||
|
||||
CORS_ALLOW_ALL_ORIGINS = True
|
||||
CORS_ALLOW_HEADERS = [
|
||||
"accept",
|
||||
"accept-encoding",
|
||||
"authorization",
|
||||
"content-type",
|
||||
"dnt",
|
||||
"origin",
|
||||
"user-agent",
|
||||
"x-csrftoken",
|
||||
"x-requested-with",
|
||||
"x-tenant-id", # Allow custom tenant header
|
||||
]
|
||||
CORS_EXPOSE_HEADERS = ["content-type", "x-tenant-id"]
|
||||
|
||||
# Ensure SQLite is used for early local dev if env variables are missing for mysql
|
||||
_DB_NAME = os.getenv("DB_NAME", "")
|
||||
if _DB_NAME:
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.mysql",
|
||||
"NAME": os.getenv("DB_NAME", "mtcbd_db"),
|
||||
"USER": os.getenv("DB_USER", "root"),
|
||||
"PASSWORD": os.getenv("DB_PASSWORD", ""),
|
||||
"HOST": os.getenv("DB_HOST", "127.0.0.1"),
|
||||
"PORT": os.getenv("DB_PORT", "3306"),
|
||||
"OPTIONS": {
|
||||
"init_command": "SET sql_mode='STRICT_TRANS_TABLES'",
|
||||
"charset": "utf8mb4",
|
||||
},
|
||||
}
|
||||
}
|
||||
else:
|
||||
print("WARNING: Using SQLite3 DB. For MySQL, configure DB_NAME in .env.")
|
||||
|
||||
# Basic console logging for localdev
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
},
|
||||
},
|
||||
"root": {
|
||||
"handlers": ["console"],
|
||||
"level": "INFO",
|
||||
},
|
||||
}
|
||||
79
backend/core/settings/prod.py
Normal file
79
backend/core/settings/prod.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from .base import *
|
||||
import dj_database_url
|
||||
import os
|
||||
|
||||
DEBUG = False
|
||||
|
||||
# Comma-separated list of allowed hosts
|
||||
_allowed = os.getenv("ALLOWED_HOSTS", "")
|
||||
ALLOWED_HOSTS = [h.strip() for h in _allowed.split(",") if h.strip()]
|
||||
|
||||
# Strict CORS
|
||||
_cors = os.getenv("CORS_ALLOWED_ORIGINS", "")
|
||||
CORS_ALLOWED_ORIGINS = [h.strip() for h in _cors.split(",") if h.strip()]
|
||||
CORS_ALLOW_ALL_ORIGINS = False
|
||||
|
||||
# Production Database with Connection Pooling Settings
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.mysql",
|
||||
"NAME": os.getenv("DB_NAME", "mtcbd_db"),
|
||||
"USER": os.getenv("DB_USER", "root"),
|
||||
"PASSWORD": os.getenv("DB_PASSWORD", ""),
|
||||
"HOST": os.getenv("DB_HOST", "127.0.0.1"),
|
||||
"PORT": os.getenv("DB_PORT", "3306"),
|
||||
"OPTIONS": {
|
||||
"init_command": "SET sql_mode='STRICT_TRANS_TABLES'",
|
||||
"charset": "utf8mb4",
|
||||
},
|
||||
"CONN_MAX_AGE": int(os.getenv("DB_CONN_MAX_AGE", "60")),
|
||||
}
|
||||
}
|
||||
|
||||
# Production security settings
|
||||
SECURE_SSL_REDIRECT = os.getenv("SECURE_SSL_REDIRECT", "True") == "True"
|
||||
SESSION_COOKIE_SECURE = True
|
||||
CSRF_COOKIE_SECURE = True
|
||||
SECURE_BROWSER_XSS_FILTER = True
|
||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||
|
||||
# Redis caching
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django_redis.cache.RedisCache",
|
||||
"LOCATION": os.getenv("REDIS_URL", "redis://127.0.0.1:6379/1"),
|
||||
"OPTIONS": {
|
||||
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Production Logging
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"verbose": {
|
||||
"format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}",
|
||||
"style": "{",
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"level": "INFO",
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "verbose",
|
||||
},
|
||||
},
|
||||
"root": {
|
||||
"handlers": ["console"],
|
||||
"level": "INFO",
|
||||
},
|
||||
"loggers": {
|
||||
"django": {
|
||||
"handlers": ["console"],
|
||||
"level": os.getenv("DJANGO_LOG_LEVEL", "INFO"),
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
13
backend/core/urls.py
Normal file
13
backend/core/urls.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from django.contrib import admin
|
||||
from django.urls import path, include
|
||||
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
|
||||
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
path('api/auth/', include('accounts.urls')),
|
||||
path('api/', include('projects.urls')),
|
||||
path('api/dashboard/', include('dashboard.urls')),
|
||||
path('api/analytics/', include('analytics.urls')),
|
||||
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
|
||||
path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
|
||||
]
|
||||
16
backend/core/wsgi.py
Normal file
16
backend/core/wsgi.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
WSGI config for core project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings.dev")
|
||||
|
||||
application = get_wsgi_application()
|
||||
0
backend/dashboard/__init__.py
Normal file
0
backend/dashboard/__init__.py
Normal file
BIN
backend/dashboard/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
backend/dashboard/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/dashboard/__pycache__/admin.cpython-314.pyc
Normal file
BIN
backend/dashboard/__pycache__/admin.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/dashboard/__pycache__/apps.cpython-314.pyc
Normal file
BIN
backend/dashboard/__pycache__/apps.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/dashboard/__pycache__/models.cpython-314.pyc
Normal file
BIN
backend/dashboard/__pycache__/models.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/dashboard/__pycache__/serializers.cpython-314.pyc
Normal file
BIN
backend/dashboard/__pycache__/serializers.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/dashboard/__pycache__/urls.cpython-314.pyc
Normal file
BIN
backend/dashboard/__pycache__/urls.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/dashboard/__pycache__/views.cpython-314.pyc
Normal file
BIN
backend/dashboard/__pycache__/views.cpython-314.pyc
Normal file
Binary file not shown.
3
backend/dashboard/admin.py
Normal file
3
backend/dashboard/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
6
backend/dashboard/apps.py
Normal file
6
backend/dashboard/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class DashboardConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "dashboard"
|
||||
62
backend/dashboard/migrations/0001_initial.py
Normal file
62
backend/dashboard/migrations/0001_initial.py
Normal file
@@ -0,0 +1,62 @@
|
||||
# Generated by Django 4.2.28 on 2026-02-20 19:49
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("tenants", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Notification",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("title", models.CharField(max_length=255)),
|
||||
("message", models.TextField()),
|
||||
("is_read", models.BooleanField(default=False)),
|
||||
("link", models.URLField(blank=True, max_length=500, null=True)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"tenant",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="notifications",
|
||||
to="tenants.tenant",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="notifications",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
"indexes": [
|
||||
models.Index(
|
||||
fields=["tenant", "user", "is_read"],
|
||||
name="dashboard_n_tenant__eff9b5_idx",
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
backend/dashboard/migrations/__init__.py
Normal file
0
backend/dashboard/migrations/__init__.py
Normal file
Binary file not shown.
Binary file not shown.
24
backend/dashboard/models.py
Normal file
24
backend/dashboard/models.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from tenants.managers import TenantScopedManager
|
||||
from tenants.models import Tenant
|
||||
|
||||
class Notification(models.Model):
|
||||
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='notifications')
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='notifications')
|
||||
title = models.CharField(max_length=255)
|
||||
message = models.TextField()
|
||||
is_read = models.BooleanField(default=False)
|
||||
link = models.URLField(max_length=500, null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
objects = TenantScopedManager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['tenant', 'user', 'is_read']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"Notification for {self.user}: {self.title}"
|
||||
8
backend/dashboard/serializers.py
Normal file
8
backend/dashboard/serializers.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from rest_framework import serializers
|
||||
from .models import Notification
|
||||
|
||||
class NotificationSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Notification
|
||||
fields = ['id', 'title', 'message', 'is_read', 'link', 'created_at']
|
||||
read_only_fields = ['id', 'title', 'message', 'link', 'created_at']
|
||||
23
backend/dashboard/tasks.py
Normal file
23
backend/dashboard/tasks.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from celery import shared_task
|
||||
from django.contrib.auth import get_user_model
|
||||
from dashboard.models import Notification
|
||||
from tenants.models import Tenant
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@shared_task
|
||||
def send_notification_email_async(notification_id):
|
||||
"""
|
||||
Example Celery task to asynchronously send an email
|
||||
when a new notification is generated.
|
||||
"""
|
||||
try:
|
||||
notification = Notification.objects.get(id=notification_id)
|
||||
user = notification.user
|
||||
|
||||
# In a real scenario, this would use django.core.mail.send_mail
|
||||
print(f"ASYNC TASK: Sending email to {user.email} regarding: {notification.title}")
|
||||
return True
|
||||
except Notification.DoesNotExist:
|
||||
print(f"ASYNC TASK ERR: Notification {notification_id} not found.")
|
||||
return False
|
||||
3
backend/dashboard/tests.py
Normal file
3
backend/dashboard/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
12
backend/dashboard/urls.py
Normal file
12
backend/dashboard/urls.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from django.urls import path
|
||||
from .views import DashboardSummaryView, NotificationViewSet
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'notifications', NotificationViewSet, basename='notification')
|
||||
|
||||
urlpatterns = [
|
||||
path('summary/', DashboardSummaryView.as_view(), name='dashboard-summary'),
|
||||
]
|
||||
|
||||
urlpatterns += router.urls
|
||||
62
backend/dashboard/views.py
Normal file
62
backend/dashboard/views.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from rest_framework import viewsets, generics, permissions, status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.decorators import action
|
||||
from django.db.models import Count
|
||||
from django.contrib.auth import get_user_model
|
||||
from projects.models import Project, Task
|
||||
from analytics.models import ActivityLog
|
||||
from analytics.serializers import ActivityLogSerializer
|
||||
from .models import Notification
|
||||
from .serializers import NotificationSerializer
|
||||
from accounts.permissions import IsTenantUser
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.cache import cache_page
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
class DashboardSummaryView(APIView):
|
||||
permission_classes = [permissions.IsAuthenticated, IsTenantUser]
|
||||
|
||||
def get(self, request):
|
||||
tenant = request.tenant
|
||||
|
||||
# Basic Counts
|
||||
total_projects = Project.objects.filter(tenant=tenant).count()
|
||||
total_tasks = Task.objects.filter(tenant=tenant).count()
|
||||
total_users = User.objects.filter(tenant=tenant).count()
|
||||
|
||||
# Task Status Breakdown
|
||||
task_status_counts = Task.objects.filter(tenant=tenant).values('status').annotate(count=Count('status'))
|
||||
status_breakdown = {item['status']: item['count'] for item in task_status_counts}
|
||||
|
||||
# Recent Activity (Last 10)
|
||||
recent_activity = ActivityLog.objects.filter(tenant=tenant).order_by('-created_at')[:10]
|
||||
activity_serializer = ActivityLogSerializer(recent_activity, many=True)
|
||||
|
||||
return Response({
|
||||
'total_projects': total_projects,
|
||||
'total_tasks': total_tasks,
|
||||
'total_users': total_users,
|
||||
'task_status_breakdown': status_breakdown,
|
||||
'recent_activity': activity_serializer.data
|
||||
})
|
||||
|
||||
class NotificationViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = NotificationSerializer
|
||||
permission_classes = [permissions.IsAuthenticated, IsTenantUser]
|
||||
|
||||
def get_queryset(self):
|
||||
return Notification.objects.filter(tenant=self.request.tenant, user=self.request.user)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def mark_read(self, request, pk=None):
|
||||
notification = self.get_object()
|
||||
notification.is_read = True
|
||||
notification.save()
|
||||
return Response({'status': 'notification marked as read'})
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def mark_all_read(self, request):
|
||||
self.get_queryset().filter(is_read=False).update(is_read=True)
|
||||
return Response({'status': 'all notifications marked as read'})
|
||||
23
backend/manage.py
Executable file
23
backend/manage.py
Executable file
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings.dev")
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
0
backend/projects/__init__.py
Normal file
0
backend/projects/__init__.py
Normal file
BIN
backend/projects/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
backend/projects/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/projects/__pycache__/admin.cpython-314.pyc
Normal file
BIN
backend/projects/__pycache__/admin.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/projects/__pycache__/apps.cpython-314.pyc
Normal file
BIN
backend/projects/__pycache__/apps.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/projects/__pycache__/models.cpython-314.pyc
Normal file
BIN
backend/projects/__pycache__/models.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/projects/__pycache__/serializers.cpython-314.pyc
Normal file
BIN
backend/projects/__pycache__/serializers.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/projects/__pycache__/tests.cpython-314.pyc
Normal file
BIN
backend/projects/__pycache__/tests.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/projects/__pycache__/urls.cpython-314.pyc
Normal file
BIN
backend/projects/__pycache__/urls.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/projects/__pycache__/views.cpython-314.pyc
Normal file
BIN
backend/projects/__pycache__/views.cpython-314.pyc
Normal file
Binary file not shown.
3
backend/projects/admin.py
Normal file
3
backend/projects/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
6
backend/projects/apps.py
Normal file
6
backend/projects/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ProjectsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "projects"
|
||||
160
backend/projects/migrations/0001_initial.py
Normal file
160
backend/projects/migrations/0001_initial.py
Normal file
@@ -0,0 +1,160 @@
|
||||
# Generated by Django 4.2.28 on 2026-02-20 19:38
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("tenants", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Project",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
("description", models.TextField(blank=True)),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("active", "Active"),
|
||||
("completed", "Completed"),
|
||||
("archived", "Archived"),
|
||||
],
|
||||
default="active",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="created_projects",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"tenant",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="projects",
|
||||
to="tenants.tenant",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Task",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("title", models.CharField(max_length=255)),
|
||||
("description", models.TextField(blank=True)),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("todo", "To Do"),
|
||||
("in_progress", "In Progress"),
|
||||
("review", "Under Review"),
|
||||
("done", "Done"),
|
||||
],
|
||||
default="todo",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"priority",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("low", "Low"),
|
||||
("medium", "Medium"),
|
||||
("high", "High"),
|
||||
("critical", "Critical"),
|
||||
],
|
||||
default="medium",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
("due_date", models.DateField(blank=True, null=True)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"assigned_to",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="assigned_tasks",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"project",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="tasks",
|
||||
to="projects.project",
|
||||
),
|
||||
),
|
||||
(
|
||||
"tenant",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="tasks",
|
||||
to="tenants.tenant",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"indexes": [
|
||||
models.Index(
|
||||
fields=["tenant", "project"],
|
||||
name="projects_ta_tenant__2d974e_idx",
|
||||
),
|
||||
models.Index(
|
||||
fields=["tenant", "assigned_to"],
|
||||
name="projects_ta_tenant__eee8a8_idx",
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="project",
|
||||
index=models.Index(
|
||||
fields=["tenant", "created_by"], name="projects_pr_tenant__12414f_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="project",
|
||||
index=models.Index(
|
||||
fields=["tenant", "status"], name="projects_pr_tenant__3f3849_idx"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
0
backend/projects/migrations/__init__.py
Normal file
0
backend/projects/migrations/__init__.py
Normal file
Binary file not shown.
Binary file not shown.
BIN
backend/projects/migrations/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
backend/projects/migrations/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user