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
128 changes: 128 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,134 @@ Running the below commands should get the development environment running using
2. `$ make fixtures` to create a small number of fixture file versions.
3. `$ make serve` to start the development server on port 8001.
4. `$ make test` to run the limited test suite via PyTest.

### Backend API Testing (Copy/Paste Guide)
Use the steps below exactly as written.

#### 1) Open a terminal and go to the project root
```sh
cd /home/zainab/Desktop/document-manager-assessment/document-manager-assessment
```

#### 2) Install/sync dependencies
```sh
make build
```

#### 3) Apply migrations
```sh
uv run django-admin migrate
```

#### 4) Create two users and API tokens
Run this command and copy the two printed tokens:
```sh
uv run django-admin shell -c "from django.contrib.auth import get_user_model; from rest_framework.authtoken.models import Token; U=get_user_model(); u1,_=U.objects.get_or_create(email='owner@example.com', defaults={'name':'Owner'}); u1.set_password('ownerpass123'); u1.save(); t1,_=Token.objects.get_or_create(user=u1); u2,_=U.objects.get_or_create(email='other@example.com', defaults={'name':'Other'}); u2.set_password('otherpass123'); u2.save(); t2,_=Token.objects.get_or_create(user=u2); print('OWNER_TOKEN=' + t1.key); print('OTHER_TOKEN=' + t2.key)"
```

Export the tokens in your shell (replace values):
```sh
export OWNER_TOKEN="paste_owner_token_here"
export OTHER_TOKEN="paste_other_token_here"
```

#### 5) Start the server
Keep this terminal running:
```sh
make serve
```

#### 6) Open a second terminal and go to the project root
```sh
cd /home/zainab/Desktop/document-manager-assessment/document-manager-assessment
```

#### 7) Confirm unauthenticated requests are blocked
```sh
curl -i http://127.0.0.1:8001/api/file_versions/
curl -i http://127.0.0.1:8001/api/documents/reviews/review.pdf
```
Expected: `401` or `403`.

#### 8) Upload any file type to any URL as owner
```sh
curl -i -X POST http://127.0.0.1:8001/api/documents/reviews/review.pdf \
-H "Authorization: Token $OWNER_TOKEN" \
-F "file=@/home/zainab/Downloads/kittys.jpg"
```
Expected: `201` and JSON with fields including `url_path`, `version_number`, `content_hash`.

#### 9) Upload second version to the same URL
```sh
curl -i -X POST http://127.0.0.1:8001/api/documents/reviews/review.pdf \
-H "Authorization: Token $OWNER_TOKEN" \
-F "file=@/home/zainab/Downloads/kittys.jpg"
```
Expected: `201` and `version_number` should be `2`.

#### 10) List owner files
```sh
curl -s http://127.0.0.1:8001/api/file_versions/ \
-H "Authorization: Token $OWNER_TOKEN"
```
Expected: JSON list with your records only.

#### 11) Fetch latest file bytes by URL
```sh
curl -i http://127.0.0.1:8001/api/documents/reviews/review.pdf \
-H "Authorization: Token $OWNER_TOKEN" \
-o /tmp/latest_review.bin
```

#### 12) Fetch specific revisions by URL
```sh
curl -i "http://127.0.0.1:8001/api/documents/reviews/review.pdf?revision=0" \
-H "Authorization: Token $OWNER_TOKEN" \
-o /tmp/review_rev0.bin

curl -i "http://127.0.0.1:8001/api/documents/reviews/review.pdf?revision=1" \
-H "Authorization: Token $OWNER_TOKEN" \
-o /tmp/review_rev1.bin
```

#### 13) Fetch metadata JSON
```sh
curl -s http://127.0.0.1:8001/api/documents/reviews/review.pdf/metadata \
-H "Authorization: Token $OWNER_TOKEN"

curl -s "http://127.0.0.1:8001/api/documents/reviews/review.pdf/metadata?revision=0" \
-H "Authorization: Token $OWNER_TOKEN"
```

#### 14) Fetch by CAS hash
First copy `content_hash` from metadata response above, then:
```sh
export CONTENT_HASH="paste_content_hash_here"

curl -i http://127.0.0.1:8001/api/cas/$CONTENT_HASH \
-H "Authorization: Token $OWNER_TOKEN" \
-o /tmp/cas_file.bin
```

#### 15) Verify other users cannot access owner files
```sh
curl -i http://127.0.0.1:8001/api/documents/reviews/review.pdf \
-H "Authorization: Token $OTHER_TOKEN"

curl -i http://127.0.0.1:8001/api/documents/reviews/review.pdf/metadata \
-H "Authorization: Token $OTHER_TOKEN"

curl -i http://127.0.0.1:8001/api/cas/$CONTENT_HASH \
-H "Authorization: Token $OTHER_TOKEN"
```
Expected: `404` for these owner-only resources.

#### 16) Run unit tests
```sh
PYTHONPATH=src python3 -m pytest tests/test_file_versions.py -q
```
Expected: all tests pass.

### Client Development
See the Readme [here](https://github.com/propylon/document-manager-assessment/blob/main/client/doc-manager/README.md)

Expand Down
41 changes: 40 additions & 1 deletion src/propylon_document_manager/file_versions/api/serializers.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,47 @@
import os

from rest_framework import serializers

from ..models import FileVersion


class FileVersionSerializer(serializers.ModelSerializer):
# Serializer for upload + metadata responses; version/content hash stay server-controlled.
file = serializers.FileField(write_only=True)
file_name = serializers.CharField(required=False, allow_blank=True)
url_path = serializers.CharField(required=False, allow_blank=True, default="")
file_url = serializers.SerializerMethodField(read_only=True)

class Meta:
model = FileVersion
fields = "__all__"
fields = ["id", "file", "file_name", "url_path", "content_hash", "version_number", "content_type", "file_size", "file_url", "uploaded_at"]
read_only_fields = ["id", "content_hash", "version_number", "content_type", "file_size", "file_url", "uploaded_at"]
validators = []

# get_file_url method to return the absolute URL of the uploaded file
def get_file_url(self, obj):
request = self.context.get("request")
if request is not None and obj.file:
return request.build_absolute_uri(obj.file.url)
if obj.file:
return obj.file.url
return None

# create method to handle file upload and versioning logic
def create(self, validated_data):
# Normalize URL path and infer file_name when caller omits it.
uploaded_file = validated_data.pop("file")
owner = validated_data.pop("owner", None)
url_path = validated_data.pop("url_path", "") or ""
url_path = url_path.strip("/")
file_name = validated_data.pop("file_name", None) or os.path.basename(url_path) or uploaded_file.name
file_version = FileVersion(
owner=owner,
url_path=url_path,
file=uploaded_file,
file_name=file_name,
content_type=getattr(uploaded_file, "content_type", None) or "application/octet-stream",
file_size=uploaded_file.size,
)
file_version.save()
return file_version
131 changes: 125 additions & 6 deletions src/propylon_document_manager/file_versions/api/views.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,133 @@
from django.shortcuts import render
import os

from rest_framework.mixins import RetrieveModelMixin, ListModelMixin
from django.http import FileResponse, Http404
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.mixins import CreateModelMixin, ListModelMixin, RetrieveModelMixin
from rest_framework.viewsets import GenericViewSet

from ..models import FileVersion
from .serializers import FileVersionSerializer

class FileVersionViewSet(RetrieveModelMixin, ListModelMixin, GenericViewSet):
authentication_classes = []
permission_classes = []

class FileVersionViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, GenericViewSet):
# Generic file-version API: authenticated users can only list/retrieve their own records.
permission_classes = [IsAuthenticated]
parser_classes = (MultiPartParser, FormParser)
serializer_class = FileVersionSerializer
queryset = FileVersion.objects.all()
queryset = FileVersion.objects.none()
lookup_field = "id"

def get_queryset(self):
return FileVersion.objects.filter(owner=self.request.user).order_by("-uploaded_at", "-version_number")

def perform_create(self, serializer):
serializer.save(owner=self.request.user)


class DocumentView(APIView):
# URL-addressed upload/retrieval endpoint with revision support.
permission_classes = [IsAuthenticated]
parser_classes = (MultiPartParser, FormParser)

def post(self, request, url_path):
url_path = url_path.strip("/")
uploaded_file = request.FILES.get("file")
if not uploaded_file:
return Response({"detail": "No file provided."}, status=status.HTTP_400_BAD_REQUEST)

file_name = os.path.basename(url_path) or uploaded_file.name
file_version = FileVersion(
owner=request.user,
url_path=url_path,
file=uploaded_file,
file_name=file_name,
content_type=getattr(uploaded_file, "content_type", None) or "application/octet-stream",
file_size=uploaded_file.size,
)
file_version.save()

serializer = FileVersionSerializer(file_version, context={"request": request})
return Response(serializer.data, status=status.HTTP_201_CREATED)

def get(self, request, url_path):
url_path = url_path.strip("/")
versions = FileVersion.objects.filter(owner=request.user, url_path=url_path).order_by("version_number")
if not versions.exists():
raise Http404("Document not found.")

revision = request.query_params.get("revision")
if revision is not None:
try:
revision_index = int(revision)
except ValueError:
return Response({"detail": "Invalid revision."}, status=status.HTTP_400_BAD_REQUEST)

if revision_index < 0 or revision_index >= versions.count():
raise Http404("Revision not found.")
file_version = versions[revision_index]
else:
file_version = versions.last()

if not file_version.file:
raise Http404("File not found.")

return FileResponse(
file_version.file.open("rb"),
content_type=file_version.content_type or "application/octet-stream",
as_attachment=True,
filename=file_version.file_name,
)


class DocumentMetadataView(APIView):
# Metadata-only representation for latest or specific revision.
permission_classes = [IsAuthenticated]

def get(self, request, url_path):
url_path = url_path.strip("/")
versions = FileVersion.objects.filter(owner=request.user, url_path=url_path).order_by("version_number")
if not versions.exists():
raise Http404("Document not found.")
Comment thread
zainabbaker290 marked this conversation as resolved.

revision = request.query_params.get("revision")
if revision is not None:
try:
revision_index = int(revision)
except ValueError:
return Response({"detail": "Invalid revision."}, status=status.HTTP_400_BAD_REQUEST)

if revision_index < 0 or revision_index >= versions.count():
raise Http404("Revision not found.")
file_version = versions[revision_index]
else:
file_version = versions.last()

serializer = FileVersionSerializer(file_version, context={"request": request})
return Response(serializer.data, status=status.HTTP_200_OK)


class CASDocumentView(APIView):
# Content-addressable retrieval by SHA-256 hash, scoped to the requesting owner.
permission_classes = [IsAuthenticated]

def get(self, request, content_hash):
file_version = (
FileVersion.objects.filter(owner=request.user, content_hash=content_hash)
.order_by("-uploaded_at", "-version_number")
.first()
)
if file_version is None:
raise Http404("Document not found.")
if not file_version.file:
raise Http404("File not found.")

return FileResponse(
file_version.file.open("rb"),
content_type=file_version.content_type or "application/octet-stream",
as_attachment=True,
filename=file_version.file_name,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import django.utils.timezone
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("file_versions", "0001_initial"),
]

operations = [
migrations.AddField(
model_name="fileversion",
name="url_path",
field=models.CharField(blank=True, default="", db_index=True, max_length=1024),
),
migrations.AddField(
model_name="fileversion",
name="content_type",
field=models.CharField(blank=True, default="", max_length=255),
),
migrations.AddField(
model_name="fileversion",
name="file",
field=models.FileField(blank=True, null=True, upload_to="uploads/%Y/%m/%d"),
),
migrations.AddField(
model_name="fileversion",
name="file_size",
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name="fileversion",
name="uploaded_at",
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AlterField(
model_name="fileversion",
name="version_number",
field=models.PositiveIntegerField(default=1),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 6.0.6 on 2026-06-30 09:26

from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
("file_versions", "0002_fileversion_storage_fields"),
]

operations = [
migrations.AlterModelOptions(
name="fileversion",
options={"ordering": ["-uploaded_at", "-version_number"]},
),
]
Loading