Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ client/node_modules
client/dist
client/dist-ssr
client/*.local
frontend/.env

# Server
server/.env
Expand Down
32 changes: 26 additions & 6 deletions backend/backend/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,20 +44,22 @@
"corsheaders",
"rest_framework",
"nested_admin",
"django_crontab",
"authentication",
"chat",
"gpt",
]

MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
# Keep this before CommonMiddleware so CORS headers are added to every response.
"corsheaders.middleware.CorsMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"corsheaders.middleware.CorsMiddleware",
]

ROOT_URLCONF = "backend.urls"
Expand Down Expand Up @@ -86,8 +88,12 @@

DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ["POSTGRES_DB"],
"USER": os.environ["POSTGRES_USER"],
"PASSWORD": os.environ["POSTGRES_PASSWORD"],
"HOST": os.getenv("POSTGRES_HOST", "127.0.0.1"),
"PORT": os.getenv("POSTGRES_PORT", "5432"),
}
}

Expand Down Expand Up @@ -132,20 +138,34 @@
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

DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"

# Next.js runs on port 3000 by default. Keep the configured deployment URL too.
CORS_ALLOWED_ORIGINS = [
FRONTEND_URL,
"http://localhost:3000",
"http://127.0.0.1:3000",
]
CORS_ALLOW_CREDENTIALS = True

# Run the soft-deleted conversation retention policy daily at 02:00.
CRONJOBS = [
("0 2 * * *", "chat.management.commands.cleanup_old_conversations.run_cleanup"),
]

CSRF_TRUSTED_ORIGINS = [
FRONTEND_URL,
"http://localhost:3000",
"http://127.0.0.1:3000",
]

SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
CSRF_COOKIE_SAMESITE = "None"
# Secure cookies require HTTPS. Local development normally uses HTTP.
SESSION_COOKIE_SECURE = not DEBUG
CSRF_COOKIE_SECURE = not DEBUG
CSRF_COOKIE_SAMESITE = "None" if not DEBUG else "Lax"
2 changes: 1 addition & 1 deletion backend/backend/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,4 @@ def root_view(request):
path("gpt/", include("gpt.urls")),
path("auth/", include("authentication.urls")),
path("", root_view),
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
12 changes: 11 additions & 1 deletion backend/chat/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,17 @@ 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",)

Expand Down
44 changes: 44 additions & 0 deletions backend/chat/management/commands/cleanup_old_conversations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from datetime import timedelta

from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone

from chat.models import Conversation


class Command(BaseCommand):
help = "Permanently delete conversations that were soft-deleted before the retention cutoff."

def add_arguments(self, parser):
parser.add_argument(
"--days",
type=int,
default=30,
help="Retention period in days for soft-deleted conversations (default: 30).",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Report how many conversations would be deleted without deleting them.",
)

def handle(self, *args, **options):
days = options["days"]
if days < 0:
raise CommandError("--days must be zero or greater.")

cutoff = timezone.now() - timedelta(days=days)
queryset = Conversation.objects.filter(deleted_at__isnull=False, deleted_at__lt=cutoff)
count = queryset.count()

if options["dry_run"]:
self.stdout.write(self.style.WARNING(f"{count} conversation(s) would be permanently deleted."))
return

queryset.delete()
self.stdout.write(self.style.SUCCESS(f"Permanently deleted {count} conversation(s)."))


def run_cleanup():
"""Entry point used by django-crontab for the daily retention job."""
Command().handle(days=30, dry_run=False)
17 changes: 17 additions & 0 deletions backend/chat/migrations/0002_conversation_summary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 5.0.2 on 2026-07-28

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, default=""),
),
]
42 changes: 42 additions & 0 deletions backend/chat/migrations/0003_uploadedfile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Generated manually for the uploaded-file API.

import uuid

import django.db.models.deletion
import chat.models
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("chat", "0002_conversation_summary"),
]

operations = [
migrations.CreateModel(
name="UploadedFile",
fields=[
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
("file", models.FileField(upload_to=chat.models.uploaded_file_path)),
("original_name", models.CharField(max_length=255)),
("content_type", models.CharField(blank=True, max_length=255)),
("size", models.PositiveBigIntegerField()),
("sha256", models.CharField(max_length=64)),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="uploaded_files",
to=settings.AUTH_USER_MODEL,
),
),
],
options={"ordering": ["-created_at"]},
),
migrations.AddConstraint(
model_name="uploadedfile",
constraint=models.UniqueConstraint(fields=("user", "sha256"), name="unique_uploaded_file_hash_per_user"),
),
]
46 changes: 46 additions & 0 deletions backend/chat/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,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, default="")
created_at = models.DateTimeField(auto_now_add=True)
modified_at = models.DateTimeField(auto_now=True)
active_version = models.ForeignKey(
Expand All @@ -31,6 +32,51 @@ def version_count(self):

version_count.short_description = "Number of versions"

def refresh_summary(self):
"""Generate and persist a summary for the active version of this conversation."""
if self.active_version_id is None:
self.summary = ""
self.save(update_fields=["summary", "modified_at"])
return self.summary

messages = list(self.active_version.messages.select_related("role").all())
if not messages:
self.summary = ""
self.save(update_fields=["summary", "modified_at"])
return self.summary

from src.utils.gpt import get_gpt_summary

conversation = [{"role": message.role.name, "content": message.content} for message in messages]
self.summary = get_gpt_summary(conversation)
self.save(update_fields=["summary", "modified_at"])
return self.summary


def uploaded_file_path(instance, filename):
"""Keep uploads grouped by owner while letting storage choose a safe name."""
return f"uploads/{instance.user_id}/{filename}"


class UploadedFile(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey(CustomUser, on_delete=models.CASCADE, related_name="uploaded_files")
file = models.FileField(upload_to=uploaded_file_path)
original_name = models.CharField(max_length=255)
content_type = models.CharField(max_length=255, blank=True)
size = models.PositiveBigIntegerField()
sha256 = models.CharField(max_length=64)
created_at = models.DateTimeField(auto_now_add=True)

class Meta:
ordering = ["-created_at"]
constraints = [
models.UniqueConstraint(fields=["user", "sha256"], name="unique_uploaded_file_hash_per_user"),
]

def __str__(self):
return self.original_name


class Version(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
Expand Down
23 changes: 22 additions & 1 deletion backend/chat/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, UploadedFile, Version


def should_serialize(validated_data, field_name) -> bool:
Expand All @@ -14,6 +14,26 @@ class TitleSerializer(serializers.Serializer):
title = serializers.CharField(max_length=100, required=True)


class ConversationSummarySerializer(serializers.ModelSerializer):
class Meta:
model = Conversation
fields = ["id", "title", "summary", "created_at", "modified_at"]


class ConversationSummaryFilterSerializer(serializers.Serializer):
search = serializers.CharField(required=False, allow_blank=False, max_length=100)
title = serializers.CharField(required=False, allow_blank=False, max_length=100)
created_after = serializers.DateTimeField(required=False)
created_before = serializers.DateTimeField(required=False)


class UploadedFileSerializer(serializers.ModelSerializer):
class Meta:
model = UploadedFile
fields = ["id", "file", "original_name", "content_type", "size", "sha256", "created_at"]
read_only_fields = fields


class VersionTimeIdSerializer(serializers.Serializer):
id = serializers.UUIDField()
created_at = serializers.DateTimeField()
Expand Down Expand Up @@ -116,6 +136,7 @@ class Meta:
fields = [
"id", # DB
"title", # required
"summary",
"active_version",
"versions", # optional
"modified_at", # DB, read-only
Expand Down
70 changes: 70 additions & 0 deletions backend/chat/tests/test_summaries_and_uploads.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import override_settings
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase

from authentication.models import CustomUser
from chat.models import Conversation, UploadedFile


class SummaryAndUploadApiTests(APITestCase):
def setUp(self):
self.user = CustomUser.objects.create_user("files@example.com", "password", is_active=True)
self.other_user = CustomUser.objects.create_user("other@example.com", "password", is_active=True)
self.client.force_login(self.user)

def test_summaries_are_paginated_and_filterable(self):
Conversation.objects.create(user=self.user, title="Python notes", summary="Django pagination")
Conversation.objects.create(user=self.user, title="Travel", summary="Packing list")
Conversation.objects.create(user=self.other_user, title="Private", summary="Do not return")

response = self.client.get(reverse("get_conversation_summaries"), {"search": "django", "page_size": 1})

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["count"], 1)
self.assertEqual(len(response.data["results"]), 1)
self.assertEqual(response.data["results"][0]["title"], "Python notes")

@override_settings(MEDIA_ROOT="/tmp/chat-upload-tests")
def test_upload_list_duplicate_and_delete(self):
upload_url = reverse("uploaded_files")
file_data = b"identical file contents"

response = self.client.post(
upload_url,
{"file": SimpleUploadedFile("notes.txt", file_data, content_type="text/plain")},
format="multipart",
)

self.assertEqual(response.status_code, status.HTTP_201_CREATED)
uploaded_id = response.data["id"]
self.assertEqual(response.data["original_name"], "notes.txt")
self.assertEqual(response.data["size"], len(file_data))

duplicate = self.client.post(
upload_url,
{"file": SimpleUploadedFile("renamed.txt", file_data, content_type="text/plain")},
format="multipart",
)
self.assertEqual(duplicate.status_code, status.HTTP_409_CONFLICT)
self.assertEqual(self.client.get(upload_url).data[0]["id"], uploaded_id)

self.assertTrue(UploadedFile.objects.filter(pk=uploaded_id).exists())
deleted = self.client.delete(reverse("delete_uploaded_file", kwargs={"pk": uploaded_id}))
self.assertEqual(deleted.status_code, status.HTTP_204_NO_CONTENT)
self.assertFalse(UploadedFile.objects.filter(pk=uploaded_id).exists())

def test_file_cannot_be_deleted_by_another_user(self):
uploaded = UploadedFile.objects.create(
user=self.other_user,
file="",
original_name="private.txt",
size=7,
sha256="a" * 64,
)

response = self.client.delete(reverse("delete_uploaded_file", kwargs={"pk": uploaded.id}))

self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
self.assertTrue(UploadedFile.objects.filter(pk=uploaded.id).exists())
Loading