mirror of
https://github.com/ramvignesh-b/pi-ku.git
synced 2026-05-04 08:56:52 +00:00
refactor: clean up scaffolding backend
This commit is contained in:
@@ -1 +0,0 @@
|
||||
# Register your models here.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 6.0.4 on 2026-04-15 18:13
|
||||
|
||||
import uuid
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("letters", "0006_letterimage"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="letter",
|
||||
name="public_id",
|
||||
field=models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False),
|
||||
),
|
||||
]
|
||||
@@ -31,7 +31,9 @@ class Letter(models.Model):
|
||||
encrypted_dek = models.TextField(null=True, blank=True)
|
||||
|
||||
def clean(self):
|
||||
# custom validation
|
||||
"""
|
||||
Performs custom validation logic for Letter status and type relations.
|
||||
"""
|
||||
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.")
|
||||
@@ -41,6 +43,11 @@ class Letter(models.Model):
|
||||
|
||||
|
||||
class LetterImage(models.Model):
|
||||
"""
|
||||
Creates one to many relationship between Letter and Image.
|
||||
Stores the uploaded images to server directory after inserting into the DB.
|
||||
"""
|
||||
|
||||
public_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
letter = models.ForeignKey(Letter, on_delete=models.CASCADE, related_name="images")
|
||||
file_name = models.CharField(max_length=255)
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from letters.models import LetterImage
|
||||
|
||||
from .models import Letter
|
||||
from letters.models import Letter, LetterImage
|
||||
|
||||
|
||||
class LetterImageSerializer(serializers.ModelSerializer):
|
||||
@@ -16,6 +14,10 @@ class LetterSerializer(serializers.ModelSerializer):
|
||||
images = LetterImageSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
"""
|
||||
Specifies the public_id as editable field for the client to generate.
|
||||
"""
|
||||
|
||||
model = Letter
|
||||
fields = [
|
||||
"public_id",
|
||||
@@ -29,10 +31,13 @@ class LetterSerializer(serializers.ModelSerializer):
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"images",
|
||||
] # user to be fetched from request
|
||||
]
|
||||
read_only_fields = ["created_at", "updated_at"]
|
||||
|
||||
def validate(self, data):
|
||||
"""
|
||||
Validates the requirmnt of DEK when encrypted content and metadata are stored.
|
||||
"""
|
||||
if (data.get("encrypted_content") or data.get("encrypted_metadata")) and not data.get("encrypted_dek"):
|
||||
raise serializers.ValidationError(
|
||||
"encrypted_dek is required when encrypted_content and encrypted_metadata are present"
|
||||
|
||||
+46
-14
@@ -15,7 +15,9 @@ class LetterModelTest(TestCase):
|
||||
self.user = User.objects.create_user(email="test@pi-ku.app", password="password1234", full_name="Test User")
|
||||
|
||||
def test_create_letter_draft(self):
|
||||
"""create a basic Letter model with required fields"""
|
||||
"""
|
||||
Test the Letter model is created with required fields and auto timestamps.
|
||||
"""
|
||||
letter = Letter.objects.create(user=self.user, type="KEPT", status="DRAFT")
|
||||
|
||||
self.assertEqual(letter.user, self.user)
|
||||
@@ -28,20 +30,24 @@ class LetterModelTest(TestCase):
|
||||
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"""
|
||||
"""
|
||||
Test that a sealed VAULT letter cannot be created without 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...",
|
||||
encrypted_content="enc_content==",
|
||||
encrypted_metadata="enc_meta==",
|
||||
encrypted_dek="enc_dek==",
|
||||
)
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
letter.full_clean()
|
||||
|
||||
@@ -53,7 +59,9 @@ class LetterAPITest(APITestCase):
|
||||
self.url = "/api/letters/"
|
||||
|
||||
def test_create_draft_letter_api(self):
|
||||
"""Test API can successfully create a basic draft letter."""
|
||||
"""
|
||||
Test that the API can successfully create a basic draft letter.
|
||||
"""
|
||||
payload = {
|
||||
"public_id": "4281edcc-5459-4ff2-bb5e-669fb44e0757",
|
||||
"type": "KEPT",
|
||||
@@ -63,6 +71,7 @@ class LetterAPITest(APITestCase):
|
||||
}
|
||||
|
||||
response = self.client.put(self.url + payload["public_id"] + "/", payload)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(Letter.objects.count(), 1)
|
||||
self.assertEqual(Letter.objects.get().status, "DRAFT")
|
||||
@@ -70,7 +79,9 @@ class LetterAPITest(APITestCase):
|
||||
self.assertEqual(Letter.objects.get().user, self.user)
|
||||
|
||||
def test_update_draft_letter_with_public_id(self):
|
||||
"""Test API can successfully update an existing letter with new values."""
|
||||
"""
|
||||
Test API can successfully update an existing letter with new values.
|
||||
"""
|
||||
letter = Letter.objects.create(
|
||||
user=self.user,
|
||||
type="KEPT",
|
||||
@@ -87,7 +98,9 @@ class LetterAPITest(APITestCase):
|
||||
"encrypted_metadata": "enc_meta==",
|
||||
"encrypted_dek": "enc_dek==",
|
||||
}
|
||||
|
||||
response = self.client.put(self.url + letter.public_id + "/", payload)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(Letter.objects.count(), 1)
|
||||
self.assertEqual(Letter.objects.get().status, "DRAFT")
|
||||
@@ -98,7 +111,9 @@ class LetterAPITest(APITestCase):
|
||||
self.assertEqual(Letter.objects.get().encrypted_dek, "enc_dek==")
|
||||
|
||||
def test_sealed_letters_cannot_be_updated(self):
|
||||
"""Test API returns 400 when trying to update an already sealed letter."""
|
||||
"""
|
||||
Test that the API returns 400 when a user tries to update an already sealed letter.
|
||||
"""
|
||||
letter = Letter.objects.create(
|
||||
user=self.user,
|
||||
type="KEPT",
|
||||
@@ -115,14 +130,20 @@ class LetterAPITest(APITestCase):
|
||||
"encrypted_metadata": "enc_meta==",
|
||||
"encrypted_dek": "enc_dek==",
|
||||
}
|
||||
|
||||
response = self.client.put(self.url + letter.public_id + "/", payload)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.data, {"error": "Sealed letters cannot be modified."})
|
||||
|
||||
def test_encrypted_dek_is_required_when_storing_encrypted_content_and_metadata(self):
|
||||
"""encrypted_dek is required when encrypted_content and encrypted_metadata are present"""
|
||||
"""
|
||||
Test that encrypted_dek is required when encrypted_content and encrypted_metadata are added to the letter.
|
||||
"""
|
||||
payload = {"type": "KEPT", "encrypted_content": "enc_xyz==", "encrypted_metadata": "enc_meta=="}
|
||||
|
||||
response = self.client.post(self.url, payload)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(Letter.objects.count(), 0)
|
||||
self.assertEqual(
|
||||
@@ -131,13 +152,14 @@ class LetterAPITest(APITestCase):
|
||||
)
|
||||
|
||||
def test_create_letter_with_images_api(self):
|
||||
"""Test API can create a letter and attach encrypted images in one request"""
|
||||
"""
|
||||
Test that the API can create a letter and attach encrypted images in one request.
|
||||
"""
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
|
||||
# Simulate local encryption files
|
||||
# Simulate local files upload
|
||||
image1 = SimpleUploadedFile("enc_img1.bin", b"encrypted_bytes_1", content_type="application/octet-stream")
|
||||
image2 = SimpleUploadedFile("enc_img2.bin", b"encrypted_bytes_2", content_type="application/octet-stream")
|
||||
|
||||
payload = {
|
||||
"public_id": "4281edcc-5459-4ff2-bb5e-669fb44e0757",
|
||||
"type": "SENT",
|
||||
@@ -159,6 +181,9 @@ class LetterAPITest(APITestCase):
|
||||
self.assertTrue(default_storage.exists("encrypted-images/enc_img2.bin"))
|
||||
|
||||
def test_cleanup_images_when_letter_is_updated(self):
|
||||
"""
|
||||
Test that the old images are cleaned up when a letter is updated with new images.
|
||||
"""
|
||||
letter = Letter.objects.create(user=self.user, type="KEPT", status="DRAFT")
|
||||
LetterImage.objects.create(letter=letter, file_name="old1.bin", file=ContentFile(b"data", name="old1.bin"))
|
||||
LetterImage.objects.create(letter=letter, file_name="old2.bin", file=ContentFile(b"data", name="old2.bin"))
|
||||
@@ -176,7 +201,7 @@ class LetterAPITest(APITestCase):
|
||||
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
# Verify that the old files are cleared from storage
|
||||
# Verify that the old files are cleared from storage directory as well
|
||||
self.assertTrue(LetterImage.objects.filter(file_name="new.bin").exists())
|
||||
self.assertEqual(LetterImage.objects.count(), 1)
|
||||
self.assertFalse(default_storage.exists("encrypted-images/old1.bin"))
|
||||
@@ -190,20 +215,27 @@ class LetterImageModelTest(TestCase):
|
||||
self.letter = Letter.objects.create(user=self.user, type="KEPT", status="DRAFT")
|
||||
|
||||
def test_create_letter_image(self):
|
||||
"""Test images can be associated with a letter (many to 1)"""
|
||||
"""
|
||||
Test that images can be associated with a letter (many to 1).
|
||||
"""
|
||||
image_content = ContentFile(b"fake-encrypted-data", name="test_image.bin")
|
||||
|
||||
letter_image = LetterImage.objects.create(
|
||||
letter=self.letter, file_name="encrypted_image.enc", file=image_content
|
||||
)
|
||||
|
||||
self.assertEqual(letter_image.letter, self.letter)
|
||||
self.assertTrue(letter_image.file.name.startswith("encrypted-images/"))
|
||||
self.assertIsNotNone(letter_image.public_id)
|
||||
|
||||
def test_letter_cascade_deletes_images(self):
|
||||
"""TTest when a letter is deleted, its encrypted images are also removed"""
|
||||
"""
|
||||
TTest that when a letter is deleted, its encrypted images are also removed.
|
||||
"""
|
||||
LetterImage.objects.create(
|
||||
letter=self.letter, file_name="will_be_deleted.jpg", file=ContentFile(b"data", name="del.bin")
|
||||
)
|
||||
|
||||
self.assertEqual(LetterImage.objects.count(), 1)
|
||||
self.letter.delete()
|
||||
self.assertEqual(LetterImage.objects.count(), 0)
|
||||
|
||||
+19
-15
@@ -8,11 +8,12 @@ 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"""
|
||||
"""
|
||||
Returns the letters of the authenticated user.
|
||||
"""
|
||||
return Letter.objects.filter(user=self.request.user)
|
||||
|
||||
|
||||
@@ -21,33 +22,37 @@ class LetterDetailView(generics.RetrieveUpdateDestroyAPIView):
|
||||
lookup_field = "public_id"
|
||||
|
||||
def get_permissions(self):
|
||||
"""
|
||||
Allow any letter GET requests for guest access and enforce authentication for other operations.
|
||||
"""
|
||||
if self.request.method == "GET":
|
||||
return [AllowAny()]
|
||||
return [IsAuthenticated()]
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Returns the letters of the authenticated user.
|
||||
Guests can only see SEALED letters.
|
||||
"""
|
||||
if self.request.user.is_authenticated:
|
||||
# author can see all their letters (DRAFT, SEALED, etc.)
|
||||
return Letter.objects.filter(user=self.request.user)
|
||||
# guests can ONLY see SEALED letters
|
||||
return Letter.objects.filter(status=Letter.Status.SEALED)
|
||||
|
||||
def put(self, request, public_id):
|
||||
# upsert: create if doesn't exist, else update
|
||||
"""
|
||||
Upserts letters: create if doesn't exist, else update.
|
||||
Validates the payload data, cleans up old images, and returns the upserted data.
|
||||
"""
|
||||
letter, created = Letter.objects.get_or_create(public_id=public_id, user=request.user)
|
||||
|
||||
# check if already sealed
|
||||
if not created and letter.status == Letter.Status.SEALED:
|
||||
return Response({"error": "Sealed letters cannot be modified."}, status=400)
|
||||
|
||||
# request.data handles both JSON and Multipart automatically in DRF
|
||||
serializer = self.get_serializer(letter, data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
write_serializer = self.get_serializer(letter, data=request.data, partial=True)
|
||||
write_serializer.is_valid(raise_exception=True)
|
||||
write_serializer.save()
|
||||
|
||||
# Note: image_files is a list of binary files in request.FILES
|
||||
if "image_files" in request.FILES:
|
||||
# Delete old image files from storage and database
|
||||
for old_image in letter.images.all():
|
||||
old_image.file.delete(save=False)
|
||||
old_image.delete()
|
||||
@@ -55,6 +60,5 @@ class LetterDetailView(generics.RetrieveUpdateDestroyAPIView):
|
||||
for image_file in request.FILES.getlist("image_files"):
|
||||
LetterImage.objects.create(letter=letter, file=image_file, file_name=image_file.name)
|
||||
|
||||
# Return fresh data including the new image URLs
|
||||
serializer = self.get_serializer(letter)
|
||||
return Response(serializer.data, status=201 if created else 200)
|
||||
response_serializer = self.get_serializer(letter)
|
||||
return Response(response_serializer.data, status=201 if created else 200)
|
||||
|
||||
Reference in New Issue
Block a user