Backend Draft
This commit is contained in:
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.
69
backend/projects/models.py
Normal file
69
backend/projects/models.py
Normal 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}"
|
||||
43
backend/projects/serializers.py
Normal file
43
backend/projects/serializers.py
Normal 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
83
backend/projects/tests.py
Normal 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
16
backend/projects/urls.py
Normal 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
88
backend/projects/views.py
Normal 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.")
|
||||
Reference in New Issue
Block a user