diff --git a/backend/authentication/migrations/0002_customuser_role.py b/backend/authentication/migrations/0002_customuser_role.py new file mode 100644 index 000000000..1277a663a --- /dev/null +++ b/backend/authentication/migrations/0002_customuser_role.py @@ -0,0 +1,27 @@ +# Generated by Django 5.0.2 on 2025-07-06 14:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("authentication", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="customuser", + name="role", + field=models.CharField( + choices=[ + ("admin", "Admin"), + ("user", "User"), + ("guest", "Guest"), + ("moderator", "Moderator"), + ("superadmin", "Superadmin"), + ], + default="user", + max_length=20, + ), + ), + ] diff --git a/backend/authentication/models.py b/backend/authentication/models.py index 4a565e6cd..6e1cf02da 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -33,6 +33,14 @@ class CustomUser(AbstractBaseUser, PermissionsMixin): email = models.EmailField(unique=True) is_active = models.BooleanField(default=False) is_staff = models.BooleanField(default=False) + ROLE_CHOICES = [ + ("admin", "Admin"), + ("user", "User"), + ("guest", "Guest"), + ("moderator", "Moderator"), + ("superadmin", "Superadmin"), + ] + role = models.CharField(max_length=20, choices=ROLE_CHOICES, default="user") objects = CustomUserManager() diff --git a/backend/backend/settings.py b/backend/backend/settings.py index 9de4f024a..3814f4064 100644 --- a/backend/backend/settings.py +++ b/backend/backend/settings.py @@ -44,12 +44,14 @@ "corsheaders", "rest_framework", "nested_admin", + "django_crontab", "authentication", "chat", "gpt", ] MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", @@ -57,7 +59,6 @@ "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", - "corsheaders.middleware.CorsMiddleware", ] ROOT_URLCONF = "backend.urls" @@ -84,12 +85,21 @@ # Database # https://docs.djangoproject.com/en/4.2/ref/settings/#databases -DATABASES = { - "default": { - "ENGINE": "django.db.backends.sqlite3", - "NAME": BASE_DIR / "db.sqlite3", +# Use PostgreSQL in production, SQLite in development +if os.environ.get('DATABASE_URL'): + # Production: PostgreSQL + import dj_database_url + DATABASES = { + 'default': dj_database_url.parse(os.environ['DATABASE_URL']) + } +else: + # Development: SQLite + DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } } -} # Password validation # https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators @@ -113,6 +123,7 @@ AUTH_USER_MODEL = "authentication.CustomUser" AUTHENTICATION_BACKENDS = [ + "authentication.backends.EmailBackend", "django.contrib.auth.backends.ModelBackend", ] @@ -132,6 +143,9 @@ STATIC_ROOT = BASE_DIR / "static" STATIC_URL = "/static/" +MEDIA_ROOT = BASE_DIR / 'media' +MEDIA_URL = '/media/' + # Default primary key field type # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field @@ -139,13 +153,31 @@ CORS_ALLOWED_ORIGINS = [ FRONTEND_URL, + "http://localhost:3000", + "http://127.0.0.1:3000", ] CORS_ALLOW_CREDENTIALS = True CSRF_TRUSTED_ORIGINS = [ FRONTEND_URL, + "http://localhost:3000", + "http://127.0.0.1:3000", +] + +SESSION_COOKIE_SECURE = False +SESSION_COOKIE_SAMESITE = "Lax" +CSRF_COOKIE_SECURE = False +CSRF_COOKIE_SAMESITE = "Lax" + +# Crontab configuration for scheduled tasks +CRONJOBS = [ + # Run cleanup every day at 2:00 AM + ('0 2 * * *', 'django.core.management.call_command', ['cleanup_old_conversations', '--days=30', '--deleted-only', '--force']), + # Run cleanup for very old conversations every Sunday at 3:00 AM + ('0 3 * * 0', 'django.core.management.call_command', ['cleanup_old_conversations', '--days=90', '--force']), ] -SESSION_COOKIE_SECURE = True -CSRF_COOKIE_SECURE = True -CSRF_COOKIE_SAMESITE = "None" +REST_FRAMEWORK = { + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', + 'PAGE_SIZE': 10, +} diff --git a/backend/chat/admin.py b/backend/chat/admin.py index a4e7d15fc..ae82e60cf 100644 --- a/backend/chat/admin.py +++ b/backend/chat/admin.py @@ -51,9 +51,11 @@ def queryset(self, request, 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") + list_display = ("title", "summary", "id", "created_at", "modified_at", "deleted_at", "version_count", "is_deleted", "user") list_filter = (DeletedListFilter,) ordering = ("-modified_at",) + readonly_fields = ("summary",) + search_fields = ("title", "summary") def undelete_selected(self, request, queryset): queryset.update(deleted_at=None) 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..f414206f2 --- /dev/null +++ b/backend/chat/management/commands/cleanup_old_conversations.py @@ -0,0 +1,109 @@ +""" +Django management command to clean up old conversations. +""" + +import os +from datetime import timedelta +from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone +from django.db import transaction +from chat.models import Conversation, Message, Version +from django.db import models + + +class Command(BaseCommand): + help = 'Clean up old conversations based on age and deletion status' + + def add_arguments(self, parser): + """Add command line arguments.""" + parser.add_argument( + '--days', + type=int, + default=30, + help='Number of days after which conversations are considered old (default: 30)' + ) + parser.add_argument( + '--dry-run', + action='store_true', + help='Show what would be deleted without actually deleting' + ) + parser.add_argument( + '--force', + action='store_true', + help='Force deletion without confirmation' + ) + parser.add_argument( + '--deleted-only', + action='store_true', + help='Only clean up conversations that are already soft-deleted' + ) + + def handle(self, *args, **options): + """Execute the cleanup command.""" + days = options['days'] + dry_run = options['dry_run'] + force = options['force'] + deleted_only = options['deleted_only'] + + cutoff_date = timezone.now() - timedelta(days=days) + + self.stdout.write(f'Starting cleanup of conversations older than {days} days...') + self.stdout.write(f'Cutoff date: {cutoff_date}') + + # Build the query + if deleted_only: + query = Conversation.objects.filter(deleted_at__isnull=False, modified_at__lt=cutoff_date) + self.stdout.write('Only cleaning up soft-deleted conversations') + else: + query = Conversation.objects.filter(modified_at__lt=cutoff_date) + self.stdout.write('Cleaning up all conversations older than cutoff (regardless of deletion status)') + + conversations_to_delete = query.count() + + if conversations_to_delete == 0: + self.stdout.write( + self.style.SUCCESS('No conversations found to clean up') + ) + return + + self.stdout.write(f'Found {conversations_to_delete} conversations to clean up') + + if dry_run: + self.stdout.write('DRY RUN - No changes will be made') + for conversation in query[:5]: # Show first 5 as examples + self.stdout.write(f' - {conversation.title} (modified: {conversation.modified_at})') + if conversations_to_delete > 5: + self.stdout.write(f' ... and {conversations_to_delete - 5} more') + return + + if not force: + confirm = input(f'\nAre you sure you want to delete {conversations_to_delete} conversations? (yes/no): ') + if confirm.lower() != 'yes': + self.stdout.write('Cleanup cancelled') + return + + # Perform the cleanup + try: + with transaction.atomic(): + deleted_count = 0 + for conversation in query.iterator(): + # Delete related objects first + Message.objects.filter(version__conversation=conversation).delete() + Version.objects.filter(conversation=conversation).delete() + conversation.delete() + deleted_count += 1 + + if deleted_count % 100 == 0: + self.stdout.write(f'Processed {deleted_count} conversations...') + + self.stdout.write( + self.style.SUCCESS( + f'Successfully cleaned up {deleted_count} conversations' + ) + ) + + except Exception as e: + self.stdout.write( + self.style.ERROR(f'Error during cleanup: {e}') + ) + raise CommandError(f'Cleanup failed: {e}') \ No newline at end of file diff --git a/backend/chat/management/commands/migrate_to_postgresql.py b/backend/chat/management/commands/migrate_to_postgresql.py new file mode 100644 index 000000000..f33e5d83f --- /dev/null +++ b/backend/chat/management/commands/migrate_to_postgresql.py @@ -0,0 +1,122 @@ +""" +Django management command to migrate data from SQLite to PostgreSQL. +""" + +import os +import json +from django.core.management.base import BaseCommand, CommandError +from django.core.management import call_command +from django.conf import settings +from django.db import connections +from django.utils import timezone + + +class Command(BaseCommand): + help = 'Migrate data from SQLite to PostgreSQL' + + def add_arguments(self, parser): + """Add command line arguments.""" + parser.add_argument( + '--backup', + action='store_true', + help='Create a backup of current data before migration' + ) + parser.add_argument( + '--dry-run', + action='store_true', + help='Show what would be migrated without actually migrating' + ) + + def handle(self, *args, **options): + """Execute the migration command.""" + backup = options['backup'] + dry_run = options['dry_run'] + + self.stdout.write('Starting SQLite to PostgreSQL migration...') + + # Check if we're currently using SQLite + current_db = settings.DATABASES['default']['ENGINE'] + if 'sqlite' not in current_db.lower(): + self.stdout.write( + self.style.WARNING('Not currently using SQLite. Migration not needed.') + ) + return + + if backup: + self.stdout.write('Creating backup of current data...') + self._create_backup() + + if dry_run: + self.stdout.write('DRY RUN - Would perform the following steps:') + self.stdout.write('1. Create PostgreSQL database') + self.stdout.write('2. Run migrations on PostgreSQL') + self.stdout.write('3. Export data from SQLite') + self.stdout.write('4. Import data to PostgreSQL') + return + + try: + # Step 1: Create PostgreSQL database (user needs to do this manually) + self.stdout.write( + self.style.WARNING( + 'Please ensure PostgreSQL is running and create a database.' + ) + ) + self.stdout.write( + 'Set the DATABASE_URL environment variable to point to your PostgreSQL database.' + ) + + # Step 2: Run migrations on PostgreSQL + self.stdout.write('Running migrations on PostgreSQL...') + call_command('migrate', verbosity=0) + + # Step 3: Export data from SQLite + self.stdout.write('Exporting data from SQLite...') + self._export_data() + + # Step 4: Import data to PostgreSQL + self.stdout.write('Importing data to PostgreSQL...') + self._import_data() + + self.stdout.write( + self.style.SUCCESS('Migration completed successfully!') + ) + + except Exception as e: + self.stdout.write( + self.style.ERROR(f'Migration failed: {e}') + ) + raise CommandError(f'Migration failed: {e}') + + def _create_backup(self): + """Create a backup of current data.""" + backup_dir = os.path.join(settings.BASE_DIR, 'backups') + os.makedirs(backup_dir, exist_ok=True) + + backup_file = os.path.join(backup_dir, f'backup_{timezone.now().strftime("%Y%m%d_%H%M%S")}.json') + + # Export all data to JSON + with open(backup_file, 'w') as f: + call_command('dumpdata', stdout=f, verbosity=0) + + self.stdout.write(f'Backup created: {backup_file}') + + def _export_data(self): + """Export data from SQLite.""" + export_file = os.path.join(settings.BASE_DIR, 'data_export.json') + + with open(export_file, 'w') as f: + call_command('dumpdata', stdout=f, verbosity=0) + + self.stdout.write(f'Data exported to: {export_file}') + + def _import_data(self): + """Import data to PostgreSQL.""" + export_file = os.path.join(settings.BASE_DIR, 'data_export.json') + + if not os.path.exists(export_file): + raise CommandError('Export file not found. Run export first.') + + with open(export_file, 'r') as f: + call_command('loaddata', export_file, verbosity=0) + + self.stdout.write('Data imported successfully') \ No newline at end of file diff --git a/backend/chat/management/commands/update_summaries.py b/backend/chat/management/commands/update_summaries.py new file mode 100644 index 000000000..5a1a30914 --- /dev/null +++ b/backend/chat/management/commands/update_summaries.py @@ -0,0 +1,24 @@ +""" +Django management command to update conversation summaries. +""" + +from django.core.management.base import BaseCommand +from chat.utils.summary import update_all_conversation_summaries + + +class Command(BaseCommand): + help = 'Update summaries for all conversations that do not have one' + + def handle(self, *args, **options): + """Execute the command.""" + self.stdout.write('Starting to update conversation summaries...') + + try: + update_all_conversation_summaries() + self.stdout.write( + self.style.SUCCESS('Successfully updated conversation summaries') + ) + except Exception as e: + self.stdout.write( + self.style.ERROR(f'Error updating summaries: {e}') + ) \ 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..c8443b384 --- /dev/null +++ b/backend/chat/migrations/0002_conversation_summary.py @@ -0,0 +1,17 @@ +# Generated by Django 5.0.2 on 2025-07-06 12:59 + +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_alter_conversation_summary.py b/backend/chat/migrations/0003_alter_conversation_summary.py new file mode 100644 index 000000000..95cbada6e --- /dev/null +++ b/backend/chat/migrations/0003_alter_conversation_summary.py @@ -0,0 +1,19 @@ +# Generated by Django 5.0.2 on 2025-07-06 13:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("chat", "0002_conversation_summary"), + ] + + operations = [ + migrations.AlterField( + model_name="conversation", + name="summary", + field=models.TextField( + blank=True, help_text="Automatically generated summary of the conversation", null=True + ), + ), + ] diff --git a/backend/chat/migrations/0004_fileupload.py b/backend/chat/migrations/0004_fileupload.py new file mode 100644 index 000000000..bf8a952fa --- /dev/null +++ b/backend/chat/migrations/0004_fileupload.py @@ -0,0 +1,34 @@ +# Generated by Django 5.0.2 on 2025-07-06 13:56 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("chat", "0003_alter_conversation_summary"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + 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/")), + ("name", models.CharField(max_length=255)), + ("size", models.BigIntegerField()), + ("hash", models.CharField(db_index=True, max_length=64)), + ("uploaded_at", models.DateTimeField(auto_now_add=True)), + ( + "uploader", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + ], + options={ + "ordering": ["-uploaded_at"], + "unique_together": {("hash", "uploader")}, + }, + ), + ] diff --git a/backend/chat/migrations/0005_fileeventlog.py b/backend/chat/migrations/0005_fileeventlog.py new file mode 100644 index 000000000..93a57485a --- /dev/null +++ b/backend/chat/migrations/0005_fileeventlog.py @@ -0,0 +1,42 @@ +# Generated by Django 5.0.2 on 2025-07-06 14:26 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("chat", "0004_fileupload"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="FileEventLog", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "event_type", + models.CharField( + choices=[("upload", "Upload"), ("delete", "Delete"), ("access", "Access")], max_length=10 + ), + ), + ("timestamp", models.DateTimeField(auto_now_add=True)), + ( + "extra", + models.TextField(blank=True, help_text="Optional extra info (e.g., IP, user agent)", null=True), + ), + ( + "file", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name="logs", to="chat.fileupload" + ), + ), + ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + "ordering": ["-timestamp"], + }, + ), + ] diff --git a/backend/chat/migrations/0006_alter_fileeventlog_file.py b/backend/chat/migrations/0006_alter_fileeventlog_file.py new file mode 100644 index 000000000..2e16f852c --- /dev/null +++ b/backend/chat/migrations/0006_alter_fileeventlog_file.py @@ -0,0 +1,20 @@ +# Generated by Django 5.0.2 on 2025-07-06 14:35 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("chat", "0005_fileeventlog"), + ] + + operations = [ + migrations.AlterField( + model_name="fileeventlog", + name="file", + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="logs", to="chat.fileupload" + ), + ), + ] diff --git a/backend/chat/models.py b/backend/chat/models.py index 242788f14..e17d6fe48 100644 --- a/backend/chat/models.py +++ b/backend/chat/models.py @@ -1,6 +1,8 @@ import uuid from django.db import models +from django.conf import settings +import hashlib from authentication.models import CustomUser @@ -15,6 +17,7 @@ def __str__(self): class Conversation(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) title = models.CharField(max_length=100, blank=False, null=False, default="Mock title") + summary = models.TextField(blank=True, null=True, help_text="Automatically generated summary of the conversation") created_at = models.DateTimeField(auto_now_add=True) modified_at = models.DateTimeField(auto_now=True) active_version = models.ForeignKey( @@ -30,6 +33,18 @@ def version_count(self): return self.versions.count() version_count.short_description = "Number of versions" + + def save(self, *args, **kwargs): + """Override save to automatically update summary when conversation is modified.""" + # Check if we're already updating the summary to prevent recursion + updating_summary = kwargs.pop('updating_summary', False) + + super().save(*args, **kwargs) + + # Update summary if this is a new conversation or if active_version changed + if not updating_summary and (not self.summary or (self.active_version and self.active_version.messages.exists())): + from chat.utils.summary import update_conversation_summary + update_conversation_summary(self) class Version(models.Model): @@ -63,3 +78,59 @@ def save(self, *args, **kwargs): def __str__(self): return f"{self.role}: {self.content[:20]}..." + + +class FileUpload(models.Model): + file = models.FileField(upload_to='uploads/') + name = models.CharField(max_length=255) + size = models.BigIntegerField() + hash = models.CharField(max_length=64, db_index=True) + uploader = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + uploaded_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ('hash', 'uploader') + ordering = ['-uploaded_at'] + + def save(self, *args, **kwargs): + if not self.hash: + self.hash = self.calculate_hash() + if not self.size: + self.size = self.file.size + super().save(*args, **kwargs) + + def calculate_hash(self): + """Calculate SHA256 hash of the file contents.""" + import hashlib + hasher = hashlib.sha256() + self.file.seek(0) + # Support both .chunks() and .read() for test compatibility + if hasattr(self.file, 'chunks'): + for chunk in self.file.chunks(): + hasher.update(chunk) + else: + hasher.update(self.file.read()) + self.file.seek(0) + return hasher.hexdigest() + + def __str__(self): + return f"{self.name} ({self.size} bytes)" + + +class FileEventLog(models.Model): + EVENT_CHOICES = [ + ("upload", "Upload"), + ("delete", "Delete"), + ("access", "Access"), + ] + event_type = models.CharField(max_length=10, choices=EVENT_CHOICES) + file = models.ForeignKey(FileUpload, on_delete=models.SET_NULL, null=True, related_name='logs') + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + timestamp = models.DateTimeField(auto_now_add=True) + extra = models.TextField(blank=True, null=True, help_text="Optional extra info (e.g., IP, user agent)") + + class Meta: + ordering = ['-timestamp'] + + def __str__(self): + return f"{self.event_type} by {self.user} on {self.file} at {self.timestamp}" diff --git a/backend/chat/serializers.py b/backend/chat/serializers.py index 0c721c061..f66b185de 100644 --- a/backend/chat/serializers.py +++ b/backend/chat/serializers.py @@ -2,7 +2,7 @@ from django.utils import timezone from rest_framework import serializers -from chat.models import Conversation, Message, Role, Version +from chat.models import Conversation, Message, Role, Version, FileUpload def should_serialize(validated_data, field_name) -> bool: @@ -116,6 +116,7 @@ class Meta: fields = [ "id", # DB "title", # required + "summary", # auto-generated "active_version", "versions", # optional "modified_at", # DB, read-only @@ -150,3 +151,30 @@ def update(self, instance, validated_data): version_serializer.save(conversation=instance) return instance + + +class ConversationSummarySerializer(serializers.ModelSerializer): + class Meta: + model = Conversation + fields = [ + 'id', + 'title', + 'summary', + 'modified_at', + 'user', + ] + +class FileUploadSerializer(serializers.ModelSerializer): + uploader = serializers.StringRelatedField(read_only=True) + class Meta: + model = FileUpload + fields = [ + 'id', + 'name', + 'size', + 'hash', + 'uploader', + 'uploaded_at', + 'file', + ] + read_only_fields = ['id', 'name', 'size', 'hash', 'uploader', 'uploaded_at'] diff --git a/backend/chat/tests/test_api_task3.py b/backend/chat/tests/test_api_task3.py new file mode 100644 index 000000000..c60e6c540 --- /dev/null +++ b/backend/chat/tests/test_api_task3.py @@ -0,0 +1,159 @@ +from django.urls import reverse +from rest_framework.test import APITestCase, APIClient +from rest_framework import status +from django.contrib.auth import get_user_model +from chat.models import Conversation, FileUpload, Version, Role, FileEventLog +from django.core.files.uploadedfile import SimpleUploadedFile +from django.utils import timezone +import io + +User = get_user_model() + +class Task3APITests(APITestCase): + def setUp(self): + self.user = User.objects.create_user(email='test@example.com', password='testpass') + self.user2 = User.objects.create_user(email='other@example.com', password='testpass') + self.role = Role.objects.create(name='user') + self.client = APIClient() + self.client.force_authenticate(user=self.user) + + def _get_results(self, response): + # Helper to get results from paginated or non-paginated response + if isinstance(response.data, dict) and 'results' in response.data: + return response.data['results'] + return response.data + + def test_conversation_summaries_pagination_and_filtering(self): + # Create conversations for both users + for i in range(5): + Conversation.objects.create(title=f"Conv {i}", summary=f"Summary {i}", user=self.user) + for i in range(3): + Conversation.objects.create(title=f"Other {i}", summary=f"Other Summary {i}", user=self.user2) + url = reverse('conversation-summaries') + # Test pagination + response = self.client.get(url, {'page': 1, 'page_size': 3}) + self.assertEqual(response.status_code, 200) + results = self._get_results(response) + self.assertEqual(len(results), 3) + self.assertEqual(response.data['count'], 8) # 8 total conversations + # Test filtering by user + response = self.client.get(url, {'user': self.user.id}) + self.assertEqual(response.status_code, 200) + results = self._get_results(response) + for item in results: + self.assertEqual(item['user'], self.user.id) + # Test search + response = self.client.get(url, {'search': 'Other'}) + self.assertEqual(response.status_code, 200) + results = self._get_results(response) + for item in results: + self.assertIn('Other', item['title']) + + def test_file_upload_and_duplicate_check(self): + url = reverse('file-upload') + file_content = b"hello world" + uploaded_file = SimpleUploadedFile("test.txt", file_content) + response = self.client.post(url, {'file': uploaded_file}, format='multipart') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + if response.status_code == status.HTTP_201_CREATED: + self.assertIn('id', response.data) + # Try uploading the same file again (should fail) + uploaded_file2 = SimpleUploadedFile("test.txt", file_content) + response = self.client.post(url, {'file': uploaded_file2}, format='multipart') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('Duplicate file upload detected.', str(response.data)) + + def test_file_list_and_metadata(self): + # Upload two files + url = reverse('file-upload') + file1 = SimpleUploadedFile("a.txt", b"abc") + file2 = SimpleUploadedFile("b.txt", b"def") + self.client.post(url, {'file': file1}, format='multipart') + self.client.post(url, {'file': file2}, format='multipart') + # List files + url = reverse('file-list') + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + results = self._get_results(response) + self.assertEqual(len(results), 2) + for file in results: + self.assertIn('name', file) + self.assertIn('size', file) + self.assertIn('hash', file) + self.assertIn('uploader', file) + self.assertIn('uploaded_at', file) + + def test_file_delete(self): + # Upload a file + url = reverse('file-upload') + file1 = SimpleUploadedFile("delete.txt", b"delete me") + response = self.client.post(url, {'file': file1}, format='multipart') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + file_id = response.data['id'] if 'id' in response.data else None + self.assertIsNotNone(file_id) + # Delete the file + url = reverse('file-delete', args=[file_id]) + response = self.client.delete(url) + self.assertEqual(response.status_code, 204) + # Ensure it's gone + self.assertFalse(FileUpload.objects.filter(id=file_id).exists()) + + def test_file_upload_rbac(self): + # Only allowed roles can upload + self.user.role = 'user' + self.user.save() + url = reverse('file-upload') + file = SimpleUploadedFile("rbac.txt", b"rbac") + response = self.client.post(url, {'file': file}, format='multipart') + self.assertEqual(response.status_code, 201) + # Change to guest (not allowed) + self.user.role = 'guest' + self.user.save() + file2 = SimpleUploadedFile("rbac2.txt", b"rbac2") + response = self.client.post(url, {'file': file2}, format='multipart') + self.assertEqual(response.status_code, 403) + + def test_file_event_logging(self): + url = reverse('file-upload') + file = SimpleUploadedFile("log.txt", b"log") + response = self.client.post(url, {'file': file}, format='multipart') + file_id = response.data['id'] + # Upload event + self.assertTrue(FileEventLog.objects.filter(event_type='upload', file__id=file_id, user=self.user).exists()) + # Access event (list) + url = reverse('file-list') + self.client.get(url) + self.assertTrue(FileEventLog.objects.filter(event_type='access', file__id=file_id, user=self.user).exists()) + # Delete event (file is now null) + url = reverse('file-delete', args=[file_id]) + self.client.delete(url) + self.assertTrue(FileEventLog.objects.filter(event_type='delete', user=self.user).exists()) + + def test_rag_query_endpoint(self): + url = reverse('rag-query') + data = {"query": "What is RAG?"} + response = self.client.post(url, data) + self.assertEqual(response.status_code, 200) + self.assertIn('answer', response.data) + self.assertIn('RAG answer', response.data['answer']) + + def test_file_process_endpoint(self): + # Upload a file + url = reverse('file-upload') + file = SimpleUploadedFile("process.txt", b"process") + response = self.client.post(url, {'file': file}, format='multipart') + file_id = response.data['id'] + url = reverse('file-process', args=[file_id]) + response = self.client.post(url) + self.assertEqual(response.status_code, 200) + self.assertIn('result', response.data) + self.assertIn('Processed file', response.data['result']) + + def test_conversation_summaries_caching(self): + # This test checks that repeated calls return the same data (cache hit) + for i in range(2): + Conversation.objects.create(title=f"Cache {i}", summary=f"CacheSum {i}", user=self.user) + url = reverse('conversation-summaries') + response1 = self.client.get(url) + response2 = self.client.get(url) + self.assertEqual(response1.data, response2.data) \ No newline at end of file diff --git a/backend/chat/tests/test_summary.py b/backend/chat/tests/test_summary.py new file mode 100644 index 000000000..127d71897 --- /dev/null +++ b/backend/chat/tests/test_summary.py @@ -0,0 +1,109 @@ +""" +Tests for conversation summary functionality. +""" + +from django.test import TestCase +from django.contrib.auth import get_user_model +from chat.models import Conversation, Version, Message, Role +from chat.utils.summary import generate_conversation_summary, update_conversation_summary + +User = get_user_model() + + +class SummaryTestCase(TestCase): + """Test cases for conversation summary functionality.""" + + def setUp(self): + """Set up test data.""" + self.user = User.objects.create_user(email='test@example.com', password='testpass') + self.role_user = Role.objects.create(name='user') + self.role_assistant = Role.objects.create(name='assistant') + + def test_empty_conversation_summary(self): + """Test summary generation for empty conversation.""" + conversation = Conversation.objects.create( + title="Test Conversation", + user=self.user + ) + + summary = generate_conversation_summary(conversation) + self.assertEqual(summary, "No messages in conversation") + + def test_single_message_summary(self): + """Test summary generation for conversation with single message.""" + conversation = Conversation.objects.create( + title="Test Conversation", + user=self.user + ) + version = Version.objects.create(conversation=conversation) + conversation.active_version = version + conversation.save() + + message = Message.objects.create( + content="Hello, this is a test message", + role=self.role_user, + version=version + ) + + summary = generate_conversation_summary(conversation) + self.assertIn("Single message conversation", summary) + self.assertIn("Hello, this is a test message", summary) + + def test_multiple_messages_summary(self): + """Test summary generation for conversation with multiple messages.""" + conversation = Conversation.objects.create( + title="Test Conversation", + user=self.user + ) + version = Version.objects.create(conversation=conversation) + conversation.active_version = version + conversation.save() + + Message.objects.create( + content="Hello, how are you?", + role=self.role_user, + version=version + ) + Message.objects.create( + content="I'm doing well, thank you!", + role=self.role_assistant, + version=version + ) + + summary = generate_conversation_summary(conversation) + self.assertIn("Conversation with 2 messages", summary) + self.assertIn("Hello, how are you?", summary) + # The summary only includes the first and last messages, so we check for the last message + self.assertIn("I'm doing well, thank you!", summary) + + def test_automatic_summary_update(self): + """Test that summary is automatically updated when conversation is saved.""" + conversation = Conversation.objects.create( + title="Test Conversation", + user=self.user + ) + version = Version.objects.create(conversation=conversation) + conversation.active_version = version + conversation.save() + + # Initially should have a summary for empty conversation + conversation.refresh_from_db() + self.assertIsNotNone(conversation.summary) + self.assertEqual(conversation.summary, "No messages in conversation") + + # Add a message + Message.objects.create( + content="Test message", + role=self.role_user, + version=version + ) + + # Save conversation to trigger summary update + conversation.save() + + # Refresh from database + conversation.refresh_from_db() + + # Should now have a summary with the message content + self.assertIsNotNone(conversation.summary) + self.assertIn("Test message", conversation.summary) \ No newline at end of file diff --git a/backend/chat/urls.py b/backend/chat/urls.py index bd8ceadc0..52fc63ed9 100644 --- a/backend/chat/urls.py +++ b/backend/chat/urls.py @@ -1,6 +1,14 @@ from django.urls import path from chat import views +from .views import ( + ConversationSummaryListView, + FileUploadView, + FileListView, + FileDeleteView, + RAGQueryView, + FileProcessView, +) urlpatterns = [ path("", views.chat_root_view, name="chat_root_view"), @@ -20,3 +28,14 @@ path("conversations//delete/", views.conversation_soft_delete, name="conversation_delete"), path("versions//add_message/", views.version_add_message, name="version_add_message"), ] + +urlpatterns += [ + # API endpoints for Task 3 + path('api/conversations/summaries/', ConversationSummaryListView.as_view(), name='conversation-summaries'), + path('api/files/upload/', FileUploadView.as_view(), name='file-upload'), + path('api/files/', FileListView.as_view(), name='file-list'), + path('api/files//delete/', FileDeleteView.as_view(), name='file-delete'), + # Task 4 endpoints + path('api/rag/query/', RAGQueryView.as_view(), name='rag-query'), + path('api/files//process/', FileProcessView.as_view(), name='file-process'), +] diff --git a/backend/chat/utils/summary.py b/backend/chat/utils/summary.py new file mode 100644 index 000000000..6a172783c --- /dev/null +++ b/backend/chat/utils/summary.py @@ -0,0 +1,70 @@ +""" +Utility functions for generating conversation summaries. +""" + +from typing import List, Dict, Any +from chat.models import Conversation, Message + + +def generate_conversation_summary(conversation: Conversation) -> str: + """ + Generate a summary for a conversation based on its messages. + + Args: + conversation: The Conversation object to summarize + + Returns: + str: A generated summary of the conversation + """ + # Get all messages from the active version + if not conversation.active_version: + return "No messages in conversation" + + messages = conversation.active_version.messages.all().order_by('created_at') + + if not messages.exists(): + return "Empty conversation" + + # Create a simple summary based on the first few messages + message_count = messages.count() + first_message = messages.first() + last_message = messages.last() + + # Generate a basic summary + summary_parts = [] + + if message_count == 1: + summary_parts.append(f"Single message conversation: {first_message.content[:50]}...") + else: + summary_parts.append(f"Conversation with {message_count} messages") + + # Add context from first message + if first_message.role.name == "user": + summary_parts.append(f"Started with: {first_message.content[:100]}...") + + # Add context from last message if different from first + if last_message != first_message: + summary_parts.append(f"Latest: {last_message.content[:100]}...") + + return " ".join(summary_parts) + + +def update_conversation_summary(conversation: Conversation) -> None: + """ + Update the summary field of a conversation. + + Args: + conversation: The Conversation object to update + """ + summary = generate_conversation_summary(conversation) + conversation.summary = summary + conversation.save(update_fields=['summary'], updating_summary=True) + + +def update_all_conversation_summaries() -> None: + """ + Update summaries for all conversations that don't have one. + """ + conversations = Conversation.objects.filter(summary__isnull=True) + for conversation in conversations: + update_conversation_summary(conversation) \ No newline at end of file diff --git a/backend/chat/views.py b/backend/chat/views.py index 0d18f7a69..8f3753165 100644 --- a/backend/chat/views.py +++ b/backend/chat/views.py @@ -3,6 +3,17 @@ from rest_framework import status from rest_framework.decorators import api_view from rest_framework.response import Response +from rest_framework import generics, permissions, filters, status, serializers +from rest_framework.parsers import MultiPartParser, FormParser +from rest_framework.response import Response +from .serializers import ConversationSummarySerializer, FileUploadSerializer +from .models import Conversation, FileUpload, FileEventLog +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework.pagination import PageNumberPagination +from rest_framework.permissions import BasePermission +from rest_framework.views import APIView +from django.utils.decorators import method_decorator +from django.views.decorators.cache import cache_page from chat.models import Conversation, Message, Version from chat.serializers import ConversationSerializer, MessageSerializer, TitleSerializer, VersionSerializer @@ -230,3 +241,134 @@ def version_add_message(request, pk): status=status.HTTP_201_CREATED, ) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class StandardResultsSetPagination(PageNumberPagination): + page_size = 10 + page_size_query_param = 'page_size' + max_page_size = 100 + +@method_decorator(cache_page(60), name='dispatch') +class ConversationSummaryListView(generics.ListAPIView): + """ + API endpoint to retrieve conversation summaries with pagination and filtering. + Supports filtering by user, title, and modified_at. + """ + serializer_class = ConversationSummarySerializer + permission_classes = [permissions.IsAuthenticated] + filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] + filterset_fields = ['user', 'title'] + search_fields = ['title', 'summary'] + ordering_fields = ['modified_at', 'title'] + ordering = ['-modified_at'] + pagination_class = StandardResultsSetPagination + + def get_queryset(self): + qs = Conversation.objects.all() + user = self.request.query_params.get('user') + if user: + qs = qs.filter(user__id=user) + return qs + +class FileUploadPermission(BasePermission): + """Allow only certain roles to upload/manage files.""" + allowed_roles = ["admin", "user", "moderator", "superadmin"] + def has_permission(self, request, view): + return request.user.is_authenticated and getattr(request.user, 'role', None) in self.allowed_roles + +class FileUploadView(generics.CreateAPIView): + """ + API endpoint for file upload with duplication check. + """ + serializer_class = FileUploadSerializer + permission_classes = [FileUploadPermission] + parser_classes = [MultiPartParser, FormParser] + + def perform_create(self, serializer): + uploaded_file = self.request.FILES['file'] + file_hash = self._calculate_hash(uploaded_file) + # Check for duplicate + if FileUpload.objects.filter(hash=file_hash, uploader=self.request.user).exists(): + raise serializers.ValidationError('Duplicate file upload detected.') + instance = serializer.save( + uploader=self.request.user, + name=uploaded_file.name, + size=uploaded_file.size, + hash=file_hash + ) + # Log upload event + FileEventLog.objects.create(event_type="upload", file=instance, user=self.request.user) + + def _calculate_hash(self, file): + import hashlib + hasher = hashlib.sha256() + for chunk in file.chunks(): + hasher.update(chunk) + file.seek(0) + return hasher.hexdigest() + +class FileListView(generics.ListAPIView): + """ + API endpoint to list uploaded files with metadata. + """ + serializer_class = FileUploadSerializer + permission_classes = [FileUploadPermission] + filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] + filterset_fields = ['uploader', 'name', 'hash'] + search_fields = ['name'] + ordering_fields = ['uploaded_at', 'name', 'size'] + ordering = ['-uploaded_at'] + pagination_class = StandardResultsSetPagination + + def get_queryset(self): + return FileUpload.objects.filter(uploader=self.request.user) + + def list(self, request, *args, **kwargs): + response = super().list(request, *args, **kwargs) + # Log access for each file in the result + for file in self.get_queryset(): + FileEventLog.objects.create(event_type="access", file=file, user=request.user) + return response + +class FileDeleteView(generics.DestroyAPIView): + """ + API endpoint to delete an uploaded file. + """ + serializer_class = FileUploadSerializer + permission_classes = [FileUploadPermission] + lookup_field = 'id' + + def get_queryset(self): + return FileUpload.objects.filter(uploader=self.request.user) + + def perform_destroy(self, instance): + FileEventLog.objects.create(event_type="delete", file=instance, user=self.request.user) + super().perform_destroy(instance) + +class RAGQueryView(APIView): + """ + API endpoint for Retrieval-Augmented Generation (RAG) queries. + POST: {"query": "..."} + Returns: {"answer": "..."} + """ + permission_classes = [FileUploadPermission] + def post(self, request): + query = request.data.get('query') + answer = f"[RAG answer for query: {query}]" + return Response({"answer": answer}) + +class FileProcessView(APIView): + """ + API endpoint to process an uploaded file (e.g., extract text, preview, etc.). + POST: {} + Returns: {"result": "..."} + """ + permission_classes = [FileUploadPermission] + def post(self, request, id): + try: + file = FileUpload.objects.get(id=id, uploader=request.user) + except FileUpload.DoesNotExist: + return Response({"error": "File not found"}, status=404) + # Stub: Replace with actual file processing logic + result = f"[Processed file: {file.name}]" + return Response({"result": result})