feat: implement letter encryption fields, lifecycle timestamps, and API endpoints with validation

This commit is contained in:
Your Name
2026-04-10 21:51:53 +05:30
parent 3e02286f6b
commit c6f1e3e2a2
10 changed files with 166 additions and 3 deletions
@@ -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),
),
]
@@ -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),
),
]
@@ -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),
),
]
+13
View File
@@ -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}"
+24
View File
@@ -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)
+39 -2
View File
@@ -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)
+7
View File
@@ -0,0 +1,7 @@
from django.urls import path
from .views import LetterView
urlpatterns = [
path("", LetterView.as_view(), name="letter-list-create"),
]
+15 -1
View File
@@ -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)