diff --git a/backend/backend/__init__.py b/backend/backend/__init__.py index e69de29bb..63c59b210 100644 --- a/backend/backend/__init__.py +++ b/backend/backend/__init__.py @@ -0,0 +1,3 @@ +from .celery import app as celery_app + +_all_ = ('celery_app',) \ No newline at end of file diff --git a/backend/backend/celery.py b/backend/backend/celery.py new file mode 100644 index 000000000..57cbdb46d --- /dev/null +++ b/backend/backend/celery.py @@ -0,0 +1,11 @@ + +import os +from celery import Celery + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'backend.settings') + +app = Celery('backend') + +app.config_from_object('django.conf:settings', namespace='CELERY') + +app.autodiscover_tasks() \ No newline at end of file diff --git a/backend/backend/settings.py b/backend/backend/settings.py index 9de4f024a..ad3b24acb 100644 --- a/backend/backend/settings.py +++ b/backend/backend/settings.py @@ -47,6 +47,7 @@ "authentication", "chat", "gpt", + 'django_filters', ] MIDDLEWARE = [ @@ -85,9 +86,13 @@ # https://docs.djangoproject.com/en/4.2/ref/settings/#databases DATABASES = { - "default": { - "ENGINE": "django.db.backends.sqlite3", - "NAME": BASE_DIR / "db.sqlite3", + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': 'mahanthdb', + 'USER': 'postgres', + 'PASSWORD': '8500', + 'HOST': 'localhost', + 'PORT': '5432', } } @@ -149,3 +154,17 @@ SESSION_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True CSRF_COOKIE_SAMESITE = "None" + +# CELERY SETTINGS +CELERY_BROKER_URL = 'redis://localhost:6379/0' +CELERY_ACCEPT_CONTENT = ['json'] +CELERY_TASK_SERIALIZER = 'json' + +# CELERY BEAT SETTINGS +INSTALLED_APPS += ['django_celery_beat'] + +REST_FRAMEWORK = { + 'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'], + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', + 'PAGE_SIZE': 5, +} diff --git a/backend/chat/admin.py b/backend/chat/admin.py index a4e7d15fc..8cd780eb4 100644 --- a/backend/chat/admin.py +++ b/backend/chat/admin.py @@ -48,21 +48,53 @@ def queryset(self, request, queryset): return queryset +# class ConversationAdmin(NestedModelAdmin): + # actions = ["undelete_selected", "soft_delete_selected"] + # inlines = [VersionInline] + # list_display = ("title", "id", "created_at", "modified_at", "deleted_at", "version_count", "is_deleted", "user","summary") + # list_filter = (DeletedListFilter,) + # ordering = ("-modified_at",) + + # def undelete_selected(self, request, queryset): + # queryset.update(deleted_at=None) + + # undelete_selected.short_description = "Undelete selected conversations" + + # def soft_delete_selected(self, request, queryset): + # queryset.update(deleted_at=timezone.now()) + + # soft_delete_selected.short_description = "Soft delete selected conversations" + + # def get_action_choices(self, request, **kwargs): + # choices = super().get_action_choices(request) + # for idx, choice in enumerate(choices): + # fn_name = choice[0] + # if fn_name == "delete_selected": + # new_choice = (fn_name, "Hard delete selected conversations") + # choices[idx] = new_choice + # return choices + + # def is_deleted(self, obj): + # return obj.deleted_at is not None + + # is_deleted.boolean = True + # is_deleted.short_description = "Deleted?" + class ConversationAdmin(NestedModelAdmin): actions = ["undelete_selected", "soft_delete_selected"] inlines = [VersionInline] - list_display = ("title", "id", "created_at", "modified_at", "deleted_at", "version_count", "is_deleted", "user") - list_filter = (DeletedListFilter,) + list_display = ("title", "id", "created_at", "modified_at", "deleted_at", "version_count", "is_deleted", "user", "summary") + list_filter = (DeletedListFilter, 'created_at', 'user') # ✅ added date/user filters + search_fields = ('title', 'summary', 'user__username') # ✅ added search bar + list_per_page = 10 # ✅ enables pagination ordering = ("-modified_at",) def undelete_selected(self, request, queryset): queryset.update(deleted_at=None) - undelete_selected.short_description = "Undelete selected conversations" def soft_delete_selected(self, request, queryset): queryset.update(deleted_at=timezone.now()) - soft_delete_selected.short_description = "Soft delete selected conversations" def get_action_choices(self, request, **kwargs): @@ -76,17 +108,24 @@ def get_action_choices(self, request, **kwargs): def is_deleted(self, obj): return obj.deleted_at is not None - is_deleted.boolean = True is_deleted.short_description = "Deleted?" - + class VersionAdmin(NestedModelAdmin): inlines = [MessageInline] list_display = ("id", "conversation", "parent_version", "root_message") +# task-3 step-9 to upload file +from .models import FileUpload +from django.contrib import admin + +class FileUploadAdmin(admin.ModelAdmin): #remove the file_hash filed in upload file page + readonly_fields = ('file_hash', 'uploaded_at') + fields = ('file', 'original_name') # Hide file_hash admin.site.register(Role, RoleAdmin) admin.site.register(Message, MessageAdmin) admin.site.register(Conversation, ConversationAdmin) admin.site.register(Version, VersionAdmin) +admin.site.register(FileUpload, FileUploadAdmin) diff --git a/backend/chat/apps.py b/backend/chat/apps.py index 5f75238d2..d19997c17 100644 --- a/backend/chat/apps.py +++ b/backend/chat/apps.py @@ -1,6 +1,8 @@ from django.apps import AppConfig - class ChatConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" - name = "chat" + default_auto_field = 'django.db.models.BigAutoField' + name = 'chat' + + def ready(self): + import chat.signals \ No newline at end of file diff --git a/backend/chat/management/commands/cleanup_old_conversations.py b/backend/chat/management/commands/cleanup_old_conversations.py new file mode 100644 index 000000000..cf91d5ea5 --- /dev/null +++ b/backend/chat/management/commands/cleanup_old_conversations.py @@ -0,0 +1,18 @@ +from django.core.management.base import BaseCommand +from django.utils import timezone +from chat.models import Conversation +from datetime import timedelta + +class Command(BaseCommand): + help = 'Soft-deletes conversations older than 30 days' + + def handle(self, *args, **kwargs): + days = 30 + cutoff_date = timezone.now() - timedelta(days=days) + + deleted_count = Conversation.objects.filter( + created_at__lt=cutoff_date, + deleted_at__isnull=True + ).update(deleted_at=timezone.now()) + + self.stdout.write(self.style.SUCCESS(f"{deleted_count} conversations soft-deleted.")) \ No newline at end of file diff --git a/backend/chat/migrations/0002_conversation_summary.py b/backend/chat/migrations/0002_conversation_summary.py new file mode 100644 index 000000000..ac8620a13 --- /dev/null +++ b/backend/chat/migrations/0002_conversation_summary.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.4 on 2025-07-05 09:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chat', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='conversation', + name='summary', + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/backend/chat/migrations/0003_fileupload.py b/backend/chat/migrations/0003_fileupload.py new file mode 100644 index 000000000..ce0680248 --- /dev/null +++ b/backend/chat/migrations/0003_fileupload.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.4 on 2025-07-07 12:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chat', '0002_conversation_summary'), + ] + + operations = [ + migrations.CreateModel( + name='FileUpload', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file', models.FileField(upload_to='uploads/')), + ('file_hash', models.CharField(max_length=64, unique=True)), + ('original_name', models.CharField(max_length=255)), + ('uploaded_at', models.DateTimeField(auto_now_add=True)), + ], + ), + ] diff --git a/backend/chat/models.py b/backend/chat/models.py index 242788f14..217b21a7d 100644 --- a/backend/chat/models.py +++ b/backend/chat/models.py @@ -22,6 +22,9 @@ class Conversation(models.Model): ) deleted_at = models.DateTimeField(null=True, blank=True) user = models.ForeignKey(CustomUser, on_delete=models.CASCADE) + # added the summary field + summary = models.TextField(blank=True, null=True) + def __str__(self): return self.title @@ -63,3 +66,23 @@ def save(self, *args, **kwargs): def __str__(self): return f"{self.role}: {self.content[:20]}..." + +import hashlib +from django.db import models + +class FileUpload(models.Model): + file = models.FileField(upload_to='uploads/') + file_hash = models.CharField(max_length=64, unique=True) + original_name = models.CharField(max_length=255) + uploaded_at = models.DateTimeField(auto_now_add=True) + + def save(self, *args, **kwargs): + if not self.file_hash: + sha256 = hashlib.sha256() + for chunk in self.file.chunks(): + sha256.update(chunk) + self.file_hash = sha256.hexdigest() + super().save(*args, **kwargs) + + def _str_(self): + return self.original_name \ No newline at end of file diff --git a/backend/chat/serializers.py b/backend/chat/serializers.py index 0c721c061..2672c8fc0 100644 --- a/backend/chat/serializers.py +++ b/backend/chat/serializers.py @@ -150,3 +150,46 @@ def update(self, instance, validated_data): version_serializer.save(conversation=instance) return instance + + +# task-3 step-8 add fillter to the conversations +from rest_framework import serializers +from .models import Conversation + +class ConversationSummarySerializer(serializers.ModelSerializer): + class Meta: + model = Conversation + fields = ['id', 'user', 'summary', 'created_at'] + + +# task-3 step-9 to upload the file +import hashlib +from rest_framework import serializers +from .models import FileUpload + +class FileUploadSerializer(serializers.ModelSerializer): + class Meta: + model = FileUpload + fields = ['id', 'file', 'original_name', 'uploaded_at'] + + def validate(self, data): + file = data.get('file') + sha256 = hashlib.sha256() + for chunk in file.chunks(): + sha256.update(chunk) + file_hash = sha256.hexdigest() + + if FileUpload.objects.filter(file_hash=file_hash).exists(): + raise serializers.ValidationError("This file has already been uploaded.") + + data['file_hash'] = file_hash + data['original_name'] = file.name + return data + +# task-3 step-10 + +class FileUploadListSerializer(serializers.ModelSerializer): + class Meta: + model = FileUpload + fields = ['id', 'original_name', 'file', 'uploaded_at', 'file_hash'] + diff --git a/backend/chat/signals.py b/backend/chat/signals.py new file mode 100644 index 000000000..2fa374609 --- /dev/null +++ b/backend/chat/signals.py @@ -0,0 +1,29 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver +from .models import Conversation, Message +import openai + +@receiver(post_save, sender=Conversation) + +# to generate summary chat +def generate_summary(sender, instance, created, **kwargs): + if created or not instance.summary: + messages = Message.objects.filter(conversation=instance).order_by('created_at') + text = "\n".join([msg.content for msg in messages]) + + if text.strip(): + try: + # Use OpenAI or a simple logic to summarize + response = openai.ChatCompletion.create( + model="gpt-3.5-turbo", + messages=[ + {"role": "system", "content": "Summarize this conversation briefly."}, + {"role": "user", "content": text[:3000]} # avoid token limit + ] + ) + summary = response.choices[0].message.content.strip() + except Exception as e: + summary = "Summary generation failed." + + instance.summary = summary + instance.save(update_fields=["summary"]) \ No newline at end of file diff --git a/backend/chat/tasks.py b/backend/chat/tasks.py new file mode 100644 index 000000000..939f19b11 --- /dev/null +++ b/backend/chat/tasks.py @@ -0,0 +1,10 @@ +from celery import shared_task +from django.utils import timezone +from chat.models import Conversation +from datetime import timedelta + +@shared_task +def cleanup_old_conversations(): + cutoff = timezone.now() - timedelta(days=30) + deleted = Conversation.objects.filter(created_at_lt=cutoff, deleted_at_isnull=True).update(deleted_at=timezone.now()) + return f"{deleted} conversations soft-deleted." \ No newline at end of file diff --git a/backend/chat/urls.py b/backend/chat/urls.py index bd8ceadc0..2e7471dec 100644 --- a/backend/chat/urls.py +++ b/backend/chat/urls.py @@ -19,4 +19,41 @@ ), path("conversations//delete/", views.conversation_soft_delete, name="conversation_delete"), path("versions//add_message/", views.version_add_message, name="version_add_message"), + path("summaries/", views.conversation_summaries, name="conversation_summaries"), ] + +# task-3 step-8 +from django.urls import path +from .views import ConversationSummaryListAPIView + +urlpatterns = [ + path('conversation-summaries/', ConversationSummaryListAPIView.as_view(), name='conversation-summaries'), +] + +# task-3 step-9 + +from django.urls import path +from .views import FileUploadView + +urlpatterns = [ + path('upload/', FileUploadView.as_view(), name='file-upload'), +] + +# task-3 step-10 + +from .views import FileUploadListView + +urlpatterns = [ + path('upload/', FileUploadView.as_view(), name='file-upload'), + path('files/', FileUploadListView.as_view(), name='file-list'), # ✅ NEW +] + +# task-3 step-11 + +from .views import FileUploadDeleteView + +urlpatterns = [ + path('upload/', FileUploadView.as_view(), name='file-upload'), + path('files/', FileUploadListView.as_view(), name='file-list'), + path('files//delete/', FileUploadDeleteView.as_view(), name='file-delete'), # ✅ NEW +] \ No newline at end of file diff --git a/backend/chat/views.py b/backend/chat/views.py index 0d18f7a69..823c384d6 100644 --- a/backend/chat/views.py +++ b/backend/chat/views.py @@ -7,7 +7,8 @@ from chat.models import Conversation, Message, Version from chat.serializers import ConversationSerializer, MessageSerializer, TitleSerializer, VersionSerializer from chat.utils.branching import make_branched_conversation - +from django.core.cache import cache +from django.core.paginator import Paginator @api_view(["GET"]) def chat_root_view(request): @@ -230,3 +231,98 @@ def version_add_message(request, pk): status=status.HTTP_201_CREATED, ) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +#### Developed a new API endpoint to retrieve conversation summaries, ensuring it supports pagination and filtering. +@login_required +@api_view(["GET"]) +def conversation_summaries(request): + cache_key = f"summaries_{request.user.id}" + cached_data = cache.get(cache_key) + + if cached_data: + return Response(cached_data) + + conversations = Conversation.objects.filter( + user=request.user, + deleted_at__isnull=True, + summary__isnull=False + ).exclude(summary="").order_by("-modified_at") + + title_filter = request.GET.get('title') + if title_filter: + conversations = conversations.filter(title__icontains=title_filter) + + paginator = Paginator(conversations, 10) + page = request.GET.get('page', 1) + conversations_page = paginator.get_page(page) + + data = { + 'summaries': [{ + 'id': conv.id, + 'title': conv.title, + 'summary': conv.summary, + 'created_at': conv.created_at, + 'modified_at': conv.modified_at + } for conv in conversations_page], + 'total': paginator.count, + 'page': page, + 'pages': paginator.num_pages + } + + cache.set(cache_key, data, 300) + return Response(data) + + +# task-3 step-8 + +from rest_framework import generics, filters +from .models import Conversation +from .serializers import ConversationSummarySerializer +from django_filters.rest_framework import DjangoFilterBackend + +class ConversationSummaryListAPIView(generics.ListAPIView): + queryset = Conversation.objects.filter(deleted_at__isnull=True).order_by('-created_at') + serializer_class = ConversationSummarySerializer + filter_backends = [DjangoFilterBackend, filters.SearchFilter] + filterset_fields = ['user__username'] # filter by user + search_fields = ['summary'] # search by summary content + + +# task-3 step-9 + +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status +from .models import FileUpload +from .serializers import FileUploadSerializer + +class FileUploadView(APIView): + def post(self, request): + serializer = FileUploadSerializer(data=request.data) + if serializer.is_valid(): + serializer.save() + return Response({"message": "File uploaded successfully.", "data": serializer.data}) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + +# task-3 step-10 + +from rest_framework.generics import ListAPIView +from .models import FileUpload +from .serializers import FileUploadListSerializer + +class FileUploadListView(ListAPIView): + queryset = FileUpload.objects.all().order_by('-uploaded_at') + serializer_class = FileUploadListSerializer + + +# task-3 step-11 + +from rest_framework.generics import DestroyAPIView +from .models import FileUpload +from .serializers import FileUploadListSerializer + +class FileUploadDeleteView(DestroyAPIView): + queryset = FileUpload.objects.all() + serializer_class = FileUploadListSerializer + lookup_field = 'id' \ No newline at end of file diff --git a/backend/uploads/Intermediate_Python_Coding_Questions_with_Answers.pdf b/backend/uploads/Intermediate_Python_Coding_Questions_with_Answers.pdf new file mode 100644 index 000000000..d872234f8 Binary files /dev/null and b/backend/uploads/Intermediate_Python_Coding_Questions_with_Answers.pdf differ diff --git a/backend/uploads/mahanthresumefullstack.pdf b/backend/uploads/mahanthresumefullstack.pdf new file mode 100644 index 000000000..01013031b Binary files /dev/null and b/backend/uploads/mahanthresumefullstack.pdf differ diff --git a/backend/uploads/mahanthresumefullstack_IUjL2pv.pdf b/backend/uploads/mahanthresumefullstack_IUjL2pv.pdf new file mode 100644 index 000000000..01013031b Binary files /dev/null and b/backend/uploads/mahanthresumefullstack_IUjL2pv.pdf differ