Merge pull request 'behruz' (#129) from behruz into main
Some checks failed
Deploy to Production / build-and-deploy (push) Has been cancelled

Reviewed-on: #129
This commit is contained in:
2026-04-30 11:03:44 +00:00
32 changed files with 608 additions and 4 deletions

View File

@@ -1,11 +1,11 @@
from config.env import env
APPS = [
"cacheops",
"rosetta",
"django_ckeditor_5",
"drf_spectacular",
"rest_framework",
"corsheaders",
@@ -14,9 +14,10 @@ APPS = [
"rest_framework_simplejwt",
"django_core",
"core.apps.accounts.apps.AccountsConfig",
'core.apps.tasks.apps.TasksConfig',
]
if env.bool("SILK_ENABLED", False):
APPS += [
]

View File

@@ -212,5 +212,31 @@ PAGES = [
"link": reverse_lazy("admin:accounts_role_changelist"),
},
]
},
{
"title": _("Task Management"),
"separator": True,
"items": [
{
"title": _("Task"),
"icon": "task",
"link": reverse_lazy("admin:tasks_task_changelist"),
},
{
"title": _("Column"),
"icon": "tag",
"link": reverse_lazy("admin:tasks_column_changelist"),
},
{
"title": _("Comment"),
"icon": "message",
"link": reverse_lazy("admin:tasks_comment_changelist"),
},
{
"title": _("Label"),
"icon": "tag",
"link": reverse_lazy("admin:tasks_label_changelist"),
},
]
}
]

View File

@@ -23,6 +23,7 @@ urlpatterns = [
path("api/v1/", include("core.apps.evaluation.urls")),
path("api/v1/", include("core.apps.payment.urls")),
path("api/v1/", include("core.apps.chat.urls")),
path("api/v1/tasks/", include("core.apps.tasks.urls")),
]
urlpatterns += [
path("admin/", admin.site.urls),

View File

@@ -54,4 +54,22 @@ class UserCreateSerializer(serializers.ModelSerializer):
"first_name",
"last_name",
"password",
"role"]
"role"
]
class ShortUserSerializer(serializers.ModelSerializer):
class Meta:
model = get_user_model()
fields = [
'id',
'first_name',
'last_name',
'avatar',
]
def get_avatar(self, obj):
request = self.context.get('request')
if obj.avatar:
return request.build_absolute_uri(obj.avatar.url)
return None

View File

View File

@@ -0,0 +1,4 @@
from .column import *
from .comment import *
from .task import *
from .label import *

View File

@@ -0,0 +1,7 @@
from django.contrib import admin
from core.apps.tasks.models import Column
@admin.register(Column)
class ColumnAdmin(admin.ModelAdmin):
list_display = ('name',)

View File

@@ -0,0 +1,7 @@
from django.contrib import admin
from core.apps.tasks.models import Comment
@admin.register(Comment)
class CommentAdmin(admin.ModelAdmin):
list_display = ('created_by', 'type')

View File

@@ -0,0 +1,7 @@
from django.contrib import admin
from core.apps.tasks.models import Label
@admin.register(Label)
class LabelAdmin(admin.ModelAdmin):
list_display = ('name',)

View File

@@ -0,0 +1,7 @@
from django.contrib import admin
from core.apps.tasks.models import Task
@admin.register(Task)
class TaskAdmin(admin.ModelAdmin):
list_display = ('name', 'created_by', 'priority')

8
core/apps/tasks/apps.py Normal file
View File

@@ -0,0 +1,8 @@
from django.apps import AppConfig
class TasksConfig(AppConfig):
name = "core.apps.tasks"
def ready(self):
from core.apps.tasks import admin

View File

@@ -0,0 +1,6 @@
from django.db import models
class MessageChoice(models.TextChoices):
FILE = "file", "File"
TEXT = "text", "Text"

View File

@@ -0,0 +1,7 @@
from django.db import models
class PriorityChoice(models.TextChoices):
LOW = "low", "Low"
MEDIUM = "medium", "Medium"
HIGH = "high", "High"

View File

@@ -0,0 +1,77 @@
# Generated by Django 5.2.7 on 2026-04-29 13:18
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Column',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('name', models.CharField(max_length=255)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='Label',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('name', models.CharField(max_length=255)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='Task',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('name', models.CharField(max_length=255)),
('description', models.TextField(blank=True)),
('priority', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High')], max_length=255)),
('from_date', models.DateField(blank=True, null=True)),
('to_date', models.DateField(blank=True, null=True)),
('assignees', models.ManyToManyField(related_name='assigned_tasks', to=settings.AUTH_USER_MODEL)),
('column', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tasks', to='tasks.column')),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='created_tasks', to=settings.AUTH_USER_MODEL)),
('labels', models.ManyToManyField(related_name='tasks', to='tasks.label')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='Comment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('message', models.TextField()),
('file', models.FileField(blank=True, null=True, upload_to='comments/')),
('type', models.CharField(choices=[('file', 'File'), ('text', 'Text')], max_length=255)),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='created_comments', to=settings.AUTH_USER_MODEL)),
('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='tasks.task')),
],
options={
'abstract': False,
},
),
]

View File

@@ -0,0 +1,21 @@
# Generated by Django 5.2.7 on 2026-04-29 13:20
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tasks', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='comment',
name='created_by',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to=settings.AUTH_USER_MODEL),
),
]

View File

View File

@@ -0,0 +1,4 @@
from .column import *
from .comment import *
from .task import *
from .label import *

View File

@@ -0,0 +1,10 @@
from django.db import models
from django_core.models import AbstractBaseModel
class Column(AbstractBaseModel):
name = models.CharField(max_length=255)
def __str__(self):
return self.name

View File

@@ -0,0 +1,16 @@
from django.db import models
from django_core.models import AbstractBaseModel
from core.apps.tasks.choices.comment import MessageChoice
class Comment(AbstractBaseModel):
task = models.ForeignKey('tasks.Task', on_delete=models.CASCADE, related_name='comments')
message = models.TextField()
file = models.FileField(upload_to='comments/', blank=True, null=True)
type = models.CharField(max_length=255, choices=MessageChoice.choices)
created_by = models.ForeignKey('accounts.User', on_delete=models.CASCADE, related_name='comments')
def __str__(self):
return f"{self.content} created by {self.created_by}"

View File

@@ -0,0 +1,10 @@
from django.db import models
from django_core.models import AbstractBaseModel
class Label(AbstractBaseModel):
name = models.CharField(max_length=255)
def __str__(self):
return self.name

View File

@@ -0,0 +1,20 @@
from django.db import models
from django_core.models import AbstractBaseModel
from core.apps.tasks.choices.task import PriorityChoice
class Task(AbstractBaseModel):
column = models.ForeignKey('tasks.Column', on_delete=models.CASCADE, related_name='tasks')
name = models.CharField(max_length=255)
description = models.TextField(blank=True)
priority = models.CharField(max_length=255, choices=PriorityChoice.choices)
from_date = models.DateField(null=True, blank=True)
to_date = models.DateField(null=True, blank=True)
labels = models.ManyToManyField('tasks.Label', related_name='tasks')
assignees = models.ManyToManyField('accounts.User', related_name='assigned_tasks')
created_by = models.ForeignKey('accounts.User', on_delete=models.CASCADE, related_name='created_tasks')
def __str__(self):
return f"{self.name} created by {self.created_by}"

View File

@@ -0,0 +1,22 @@
from rest_framework import serializers
from core.apps.tasks.serializers.comment import CommentSerializer
from core.apps.tasks.serializers.task import TaskSerializer
from core.apps.tasks.models import Column, Task
class BoardTaskSerializer(TaskSerializer):
comments = CommentSerializer(many=True, read_only=True)
class Meta(TaskSerializer.Meta):
TaskSerializer.Meta.fields += ['comments']
class BoardSerializer(serializers.ModelSerializer):
tasks = BoardTaskSerializer(many=True, read_only=True)
class Meta:
model = Column
fields = [
'id', 'name', 'tasks',
]

View File

@@ -0,0 +1,11 @@
from rest_framework import serializers
from core.apps.tasks.models.column import Column
class ColumnSerializer(serializers.ModelSerializer):
class Meta:
model = Column
fields = [
'id', 'name'
]

View File

@@ -0,0 +1,44 @@
from django.db import transaction
from rest_framework import serializers
from core.apps.tasks.models.comment import Comment
from core.apps.tasks.models.task import Task
class CommentSerializer(serializers.ModelSerializer):
class Meta:
model = Comment
fields = [
'id', 'message', 'file', 'type', 'created_by'
]
def get_created_by(self, obj):
request = self.context.get('request')
return {
"id": obj.created_by.id,
"first_name": obj.created_by.first_name,
"last_name": obj.created_by.last_name,
"avatar": request.build_absolute_uri(obj.created_by.avatar.url) if obj.created_by.avatar else None
}
class CommentCreateSerializer(serializers.ModelSerializer):
class Meta:
model = Comment
fields = [
'id', 'message', 'file', 'type', 'task'
]
def validate(self, data):
task = Task.objects.filter(id=data['task']).first()
if not task:
raise serializers.ValidationError("Task not found")
data['task'] = task
return data
def create(self, validated_data):
with transaction.atomic():
task = validated_data.pop('task')
comment = Comment.objects.create(task=task, created_by=self.context['request'].user, **validated_data)
return comment

View File

@@ -0,0 +1,11 @@
from rest_framework import serializers
from core.apps.tasks.models.label import Label
class LabelSerializer(serializers.ModelSerializer):
class Meta:
model = Label
fields = [
'id', 'name'
]

View File

@@ -0,0 +1,52 @@
from rest_framework import serializers
from core.apps.tasks.models.task import Task
from core.apps.accounts.serializers.user import ShortUserSerializer
from core.apps.tasks.serializers.label import LabelSerializer
class TaskSerializer(serializers.ModelSerializer):
labels = LabelSerializer(many=True)
assignees = serializers.SerializerMethodField(method_name='get_assignees')
created_by = serializers.SerializerMethodField(method_name='get_created_by')
class Meta:
model = Task
fields = [
'id',
'column',
'name',
'description',
'priority',
'from_date',
'to_date',
'labels',
'assignees',
'created_by'
]
def get_assignees(self, obj):
return ShortUserSerializer(obj.assignees.all(), many=True, context={"request": self.context['request']}).data
def get_created_by(self, obj):
return ShortUserSerializer(obj.created_by, context={"request": self.context['request']}).data
class TaskCreateSerializer(serializers.ModelSerializer):
class Meta:
model = Task
fields = [
'id',
'column',
'name',
'description',
'priority',
'from_date',
'to_date',
'labels',
'assignees',
]
def create(self, validated_data):
validated_data['created_by'] = self.context['request'].user
return super().create(validated_data)

34
core/apps/tasks/urls.py Normal file
View File

@@ -0,0 +1,34 @@
from django.urls import path, include
from core.apps.tasks.views import task, column, comment, label, board
urlpatterns = [
path('column/', include(
[
path('list/', column.ColumnListApiView.as_view()),
path('create/', column.ColumnCreateApiView.as_view()),
path('<int:id>/update/', column.ColumnUpdateApiView.as_view()),
path('<int:id>/delete/', column.ColumnDeleteApiView.as_view())
]
)),
path('label/', include(
[
path('', label.LabelListCreateApiView.as_view()),
path('<int:id>/', label.LabelRetrieveUpdateDestroyApiView.as_view()),
]
)),
path('task/', include(
[
path('list/', task.TaskListView.as_view()),
path('<int:id>/', task.TaskDetailView.as_view()),
path('create/', task.TaskCreateView.as_view()),
]
)),
path('comment/', include(
[
path('', comment.CommentListCreateAPIView.as_view()),
path('<int:pk>/', comment.CommentDetailAPIView.as_view()),
]
)),
path('board/', board.BoardListView.as_view()),
]

View File

@@ -0,0 +1,13 @@
from rest_framework import generics, permissions
from rest_framework.response import Response
from core.apps.tasks.serializers.board import BoardSerializer
from core.apps.tasks.models import Column
class BoardListView(generics.ListAPIView):
queryset = Column.objects.order_by('id')
serializer_class = BoardSerializer
permission_classes = [permissions.IsAuthenticated]

View File

@@ -0,0 +1,47 @@
from django.db import transaction
from rest_framework import generics, permissions
from rest_framework.response import Response
from drf_spectacular.utils import extend_schema
from core.apps.tasks.serializers.column import ColumnSerializer
from core.apps.tasks.models.column import Column
@extend_schema(tags=['Tasks'])
class ColumnCreateApiView(generics.GenericAPIView):
permission_classes = [permissions.IsAuthenticated]
serializer_class = ColumnSerializer
@transaction.atomic
def post(self, request):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
column = serializer.save()
return Response(serializer.data)
@extend_schema(tags=['Tasks'])
class ColumnListApiView(generics.ListAPIView):
permission_classes = [permissions.IsAuthenticated]
serializer_class = ColumnSerializer
def get_queryset(self):
return Column.objects.order_by('id')
@extend_schema(tags=['Tasks'])
class ColumnUpdateApiView(generics.UpdateAPIView):
permission_classes = [permissions.IsAuthenticated]
serializer_class = ColumnSerializer
lookup_field = 'id'
queryset = Column.objects.all()
@extend_schema(tags=['Tasks'])
class ColumnDeleteApiView(generics.DestroyAPIView):
permission_classes = [permissions.IsAuthenticated]
serializer_class = ColumnSerializer
lookup_field = 'id'
queryset = Column.objects.all()

View File

@@ -0,0 +1,51 @@
from rest_framework import generics, permissions
from rest_framework.exceptions import PermissionDenied
from core.apps.tasks.models.comment import Comment
from core.apps.tasks.serializers.comment import CommentSerializer, CommentCreateSerializer
class CommentListCreateAPIView(generics.ListCreateAPIView):
queryset = Comment.objects.all()
permission_classes = [permissions.IsAuthenticated]
def get_serializer_class(self):
if self.request.method == 'POST':
return CommentCreateSerializer
return CommentSerializer
def get_queryset(self):
task_id = self.request.query_params.get('task_id')
queryset = self.queryset
if task_id:
queryset = queryset.filter(task_id=task_id)
return queryset.order_by('-id')
def get_serializer_context(self):
return {"request": self.request}
class CommentDetailAPIView(generics.RetrieveUpdateDestroyAPIView):
queryset = Comment.objects.all()
permission_classes = [permissions.IsAuthenticated]
def get_serializer_class(self):
if self.request.method in ['PUT', 'PATCH']:
return CommentCreateSerializer
return CommentSerializer
def get_serializer_context(self):
return {"request": self.request}
def perform_update(self, serializer):
comment = self.get_object()
if comment.created_by != self.request.user:
raise PermissionDenied("You cannot edit this comment")
serializer.save()
def perform_destroy(self, instance):
if instance.created_by != self.request.user:
raise PermissionDenied("You cannot delete this comment")
instance.delete()

View File

@@ -0,0 +1,22 @@
from rest_framework import generics, permissions
from rest_framework.response import Response
from drf_spectacular.utils import extend_schema
from core.apps.tasks.serializers.label import LabelSerializer
from core.apps.tasks.models.label import Label
@extend_schema(tags=['Tasks'])
class LabelListCreateApiView(generics.ListCreateAPIView):
queryset = Label.objects.order_by('id')
serializer_class = LabelSerializer
permission_classes = [permissions.IsAuthenticated]
@extend_schema(tags=['Tasks'])
class LabelRetrieveUpdateDestroyApiView(generics.RetrieveUpdateDestroyAPIView):
queryset = Label.objects.order_by('id')
serializer_class = LabelSerializer
permission_classes = [permissions.IsAuthenticated]
lookup_field = 'id'

View File

@@ -0,0 +1,40 @@
from django.db import transaction
from rest_framework import permissions, generics
from rest_framework.response import Response
from drf_spectacular.utils import extend_schema
from core.apps.tasks.models.task import Task
from core.apps.tasks.serializers.task import TaskSerializer, TaskCreateSerializer
@extend_schema(tags=['Tasks'])
class TaskCreateView(generics.GenericAPIView):
permission_classes = [permissions.IsAuthenticated]
serializer_class = TaskCreateSerializer
queryset = Task.objects.order_by('id')
@transaction.atomic
def post(self, request):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data)
@extend_schema(tags=['Tasks'])
class TaskListView(generics.ListAPIView):
permission_classes = [permissions.IsAuthenticated]
serializer_class = TaskSerializer
queryset = Task.objects.order_by('id')
def serializer_context(self):
return self.serializer_class(context={"request": self.request})
@extend_schema(tags=['Tasks'])
class TaskDetailView(generics.RetrieveUpdateDestroyAPIView):
permission_classes = [permissions.IsAuthenticated]
serializer_class = TaskSerializer
queryset = Task.objects.order_by('id')