diff --git a/backend/config/urls.py b/backend/config/urls.py index 7627c81..d353e73 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -21,4 +21,5 @@ from django.urls import include, path urlpatterns = [ path("admin/", admin.site.urls), path("api/auth/", include("users.urls")), # user related operations + path("api/letters/", include("letters.urls")), # letter related operations ] diff --git a/backend/letters/migrations/0002_letter_encrypted_content_letter_encrypted_metadata.py b/backend/letters/migrations/0002_letter_encrypted_content_letter_encrypted_metadata.py new file mode 100644 index 0000000..a09eec3 --- /dev/null +++ b/backend/letters/migrations/0002_letter_encrypted_content_letter_encrypted_metadata.py @@ -0,0 +1,22 @@ +# Generated by Django 6.0.4 on 2026-04-10 15:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("letters", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="letter", + name="encrypted_content", + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name="letter", + name="encrypted_metadata", + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/backend/letters/migrations/0003_letter_unlock_at.py b/backend/letters/migrations/0003_letter_unlock_at.py new file mode 100644 index 0000000..84c2459 --- /dev/null +++ b/backend/letters/migrations/0003_letter_unlock_at.py @@ -0,0 +1,17 @@ +# Generated by Django 6.0.4 on 2026-04-10 15:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("letters", "0002_letter_encrypted_content_letter_encrypted_metadata"), + ] + + operations = [ + migrations.AddField( + model_name="letter", + name="unlock_at", + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/backend/letters/migrations/0004_letter_burned_at_letter_opened_at_letter_sealed_at.py b/backend/letters/migrations/0004_letter_burned_at_letter_opened_at_letter_sealed_at.py new file mode 100644 index 0000000..000aa6a --- /dev/null +++ b/backend/letters/migrations/0004_letter_burned_at_letter_opened_at_letter_sealed_at.py @@ -0,0 +1,27 @@ +# Generated by Django 6.0.4 on 2026-04-10 16:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("letters", "0003_letter_unlock_at"), + ] + + operations = [ + migrations.AddField( + model_name="letter", + name="burned_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="letter", + name="opened_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="letter", + name="sealed_at", + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/backend/letters/models.py b/backend/letters/models.py index 8f296d7..6f4a137 100644 --- a/backend/letters/models.py +++ b/backend/letters/models.py @@ -1,6 +1,7 @@ import uuid from django.conf import settings +from django.core.exceptions import ValidationError from django.db import models @@ -21,6 +22,18 @@ class Letter(models.Model): status = models.CharField(max_length=10, choices=Status.choices, default=Status.DRAFT) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + encrypted_content = models.TextField(null=True, blank=True) + encrypted_metadata = models.TextField(null=True, blank=True) + unlock_at = models.DateTimeField(null=True, blank=True) + sealed_at = models.DateTimeField(null=True, blank=True) + opened_at = models.DateTimeField(null=True, blank=True) + burned_at = models.DateTimeField(null=True, blank=True) + + def clean(self): + # custom validation + super().clean() + if self.type == Letter.Type.VAULT and self.status == Letter.Status.SEALED and not self.unlock_at: + raise ValidationError("A sealed VAULT letter must have an unlock_date.") def __str__(self): return f"{self.type} - {self.status}" diff --git a/backend/letters/serializers.py b/backend/letters/serializers.py new file mode 100644 index 0000000..000d2e2 --- /dev/null +++ b/backend/letters/serializers.py @@ -0,0 +1,24 @@ +from rest_framework import serializers + +from .models import Letter + + +class LetterSerializer(serializers.ModelSerializer): + class Meta: + model = Letter + fields = [ + "public_id", + "type", + "status", + "encrypted_content", + "encrypted_metadata", + "unlock_at", + "sealed_at", + "created_at", + "updated_at", + ] # user to be fetched from request + read_only_fields = ["public_id", "created_at", "updated_at"] + + def create(self, validated_data): + user = self.context["request"].user # get user from access token + return Letter.objects.create(user=user, **validated_data) diff --git a/backend/letters/tests.py b/backend/letters/tests.py index a723ce4..1baddbf 100644 --- a/backend/letters/tests.py +++ b/backend/letters/tests.py @@ -1,5 +1,6 @@ from django.contrib.auth import get_user_model from django.test import TestCase +from rest_framework.test import APITestCase from .models import Letter @@ -10,7 +11,7 @@ class LetterModelTest(TestCase): def setUp(self): self.user = User.objects.create_user(email="test@pi-ku.app", password="password1234", full_name="Test User") - def test_create_letter_basic(self): + def test_create_letter_draft(self): """create a basic Letter model with required fields""" letter = Letter.objects.create(user=self.user, type="KEPT", status="DRAFT") @@ -18,7 +19,43 @@ class LetterModelTest(TestCase): self.assertEqual(letter.type, "KEPT") self.assertEqual(letter.status, "DRAFT") self.assertIsNotNone(letter.public_id) - + self.assertIsNone(letter.unlock_at) + self.assertEqual(letter.encrypted_content, None) + self.assertEqual(letter.encrypted_metadata, None) + self.assertIsNone(letter.sealed_at) + self.assertIsNone(letter.opened_at) + self.assertIsNone(letter.burned_at) # Verify timestamps are auto-added self.assertIsNotNone(letter.created_at) self.assertIsNotNone(letter.updated_at) + + def test_vault_requires_unlock_date_when_sealed(self): + """a sealed VAULT letter must have an unlock_date""" + from django.core.exceptions import ValidationError + + letter = Letter( + user=self.user, + type=Letter.Type.VAULT, + status=Letter.Status.SEALED, + encrypted_content="enc_v1...", + ) + with self.assertRaises(ValidationError): + letter.full_clean() + + +class LetterAPITest(APITestCase): + def setUp(self): + self.user = User.objects.create_user(email="api@pi-ku.app", password="password1234", full_name="API User") + self.client.force_authenticate(user=self.user) + self.url = "/api/letters/" + + def test_create_draft_letter_api(self): + """Test API can successfully create a basic draft letter.""" + payload = {"type": "KEPT", "encrypted_content": "enc_xyz==", "encrypted_metadata": "enc_meta=="} + + response = self.client.post(self.url, payload) + self.assertEqual(response.status_code, 201) + self.assertEqual(Letter.objects.count(), 1) + self.assertEqual(Letter.objects.get().status, "DRAFT") + self.assertEqual(Letter.objects.get().type, "KEPT") + self.assertEqual(Letter.objects.get().user, self.user) diff --git a/backend/letters/urls.py b/backend/letters/urls.py new file mode 100644 index 0000000..aa66f26 --- /dev/null +++ b/backend/letters/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from .views import LetterView + +urlpatterns = [ + path("", LetterView.as_view(), name="letter-list-create"), +] diff --git a/backend/letters/views.py b/backend/letters/views.py index 60f00ef..4e4b9e6 100644 --- a/backend/letters/views.py +++ b/backend/letters/views.py @@ -1 +1,15 @@ -# Create your views here. +from rest_framework import generics +from rest_framework.permissions import IsAuthenticated + +from letters.models import Letter +from letters.serializers import LetterSerializer + + +class LetterView(generics.ListCreateAPIView): + serializer_class = LetterSerializer + # enforce auth guard + permission_classes = [IsAuthenticated] + + def get_queryset(self): + """return only letters of the authenticated user""" + return Letter.objects.filter(user=self.request.user) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 753bf2e..b651a16 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "ruff>=0.15.9", ] + [tool.ruff] target-version = "py313" line-length = 120