Backend Draft

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

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

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

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

View File

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

View File

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

View File

View File

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

View File

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

83
backend/projects/tests.py Normal file
View File

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

16
backend/projects/urls.py Normal file
View File

@@ -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)),
]

88
backend/projects/views.py Normal file
View File

@@ -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.")