mirror of
https://github.com/ramvignesh-b/pi-ku.git
synced 2026-05-04 08:56:52 +00:00
feat: implement letter encryption fields, lifecycle timestamps, and API endpoints with validation
This commit is contained in:
@@ -21,4 +21,5 @@ from django.urls import include, path
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
path("api/auth/", include("users.urls")), # user related operations
|
path("api/auth/", include("users.urls")), # user related operations
|
||||||
|
path("api/letters/", include("letters.urls")), # letter related operations
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
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)
|
status = models.CharField(max_length=10, choices=Status.choices, default=Status.DRAFT)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=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):
|
def __str__(self):
|
||||||
return f"{self.type} - {self.status}"
|
return f"{self.type} - {self.status}"
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from .models import Letter
|
from .models import Letter
|
||||||
|
|
||||||
@@ -10,7 +11,7 @@ class LetterModelTest(TestCase):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.user = User.objects.create_user(email="test@pi-ku.app", password="password1234", full_name="Test User")
|
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"""
|
"""create a basic Letter model with required fields"""
|
||||||
letter = Letter.objects.create(user=self.user, type="KEPT", status="DRAFT")
|
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.type, "KEPT")
|
||||||
self.assertEqual(letter.status, "DRAFT")
|
self.assertEqual(letter.status, "DRAFT")
|
||||||
self.assertIsNotNone(letter.public_id)
|
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
|
# Verify timestamps are auto-added
|
||||||
self.assertIsNotNone(letter.created_at)
|
self.assertIsNotNone(letter.created_at)
|
||||||
self.assertIsNotNone(letter.updated_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)
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from .views import LetterView
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", LetterView.as_view(), name="letter-list-create"),
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ dependencies = [
|
|||||||
"ruff>=0.15.9",
|
"ruff>=0.15.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
target-version = "py313"
|
target-version = "py313"
|
||||||
line-length = 120
|
line-length = 120
|
||||||
|
|||||||
Reference in New Issue
Block a user