Add download functionality for items and images with presigned URLs
This commit is contained in:
@@ -493,6 +493,6 @@ All tests use Django `TestCase`. External services (LLM APIs, Neo4j) are mocked.
|
|||||||
- [ ] Celery tasks handle async embedding with progress tracking
|
- [ ] Celery tasks handle async embedding with progress tracking
|
||||||
- [ ] Re-embedding works (delete old chunks, re-process)
|
- [ ] Re-embedding works (delete old chunks, re-process)
|
||||||
- [ ] Content hash prevents redundant re-embedding
|
- [ ] Content hash prevents redundant re-embedding
|
||||||
- [ ] Prometheus metrics exposed at `/metrics` for pipeline monitoring
|
- [x] Prometheus metrics exposed at `/metrics` for pipeline monitoring
|
||||||
- [ ] All tests pass with mocked LLM/embedding APIs
|
- [ ] All tests pass with mocked LLM/embedding APIs
|
||||||
- [ ] Bedrock embedding works via Bearer token HTTP (no boto3)
|
- [ ] Bedrock embedding works via Bearer token HTTP (no boto3)
|
||||||
|
|||||||
@@ -56,6 +56,7 @@
|
|||||||
<td>{{ item.file_type|default:"-" }}</td>
|
<td>{{ item.file_type|default:"-" }}</td>
|
||||||
<td>
|
<td>
|
||||||
<a href="{% url 'library:item-detail' uid=item.uid %}" class="btn btn-xs btn-ghost">View</a>
|
<a href="{% url 'library:item-detail' uid=item.uid %}" class="btn btn-xs btn-ghost">View</a>
|
||||||
|
{% if item.s3_key %}<a href="{% url 'library:item-download' uid=item.uid %}" class="btn btn-xs btn-ghost" title="Download original file">↓ Download</a>{% endif %}
|
||||||
<a href="{% url 'library:item-edit' uid=item.uid %}" class="btn btn-xs btn-ghost">Edit</a>
|
<a href="{% url 'library:item-edit' uid=item.uid %}" class="btn btn-xs btn-ghost">Edit</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -15,6 +15,11 @@
|
|||||||
{% if item.file_type %}<div class="badge badge-ghost mt-2 ml-1">{{ item.file_type }}</div>{% endif %}
|
{% if item.file_type %}<div class="badge badge-ghost mt-2 ml-1">{{ item.file_type }}</div>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
|
{% if item.s3_key %}
|
||||||
|
<a href="{% url 'library:item-download' uid=item.uid %}" class="btn btn-sm btn-outline btn-primary" title="Download original file">
|
||||||
|
↓ Download
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
<a href="{% url 'library:item-edit' uid=item.uid %}" class="btn btn-sm btn-outline">Edit</a>
|
<a href="{% url 'library:item-edit' uid=item.uid %}" class="btn btn-sm btn-outline">Edit</a>
|
||||||
<form method="post" action="{% url 'library:item-reembed' uid=item.uid %}" class="inline">
|
<form method="post" action="{% url 'library:item-reembed' uid=item.uid %}" class="inline">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
@@ -94,15 +99,22 @@
|
|||||||
<h2 class="text-xl font-bold mb-3">Images ({{ images|length }})</h2>
|
<h2 class="text-xl font-bold mb-3">Images ({{ images|length }})</h2>
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
{% for img in images %}
|
{% for img in images %}
|
||||||
<div class="card bg-base-200">
|
<a href="{% url 'library:image-serve' uid=img.uid %}" target="_blank" rel="noopener" class="card bg-base-200 hover:shadow-lg hover:bg-base-300 transition-all cursor-pointer">
|
||||||
|
{% if img.s3_key %}
|
||||||
|
<figure class="px-3 pt-3">
|
||||||
|
<img src="{% url 'library:image-serve' uid=img.uid %}" alt="{{ img.description|default:'Image' }}" class="rounded-lg object-cover w-full h-32" loading="lazy" />
|
||||||
|
</figure>
|
||||||
|
{% endif %}
|
||||||
<div class="card-body p-3">
|
<div class="card-body p-3">
|
||||||
<span class="badge badge-sm">{{ img.image_type|default:"image" }}</span>
|
<span class="badge badge-sm">{{ img.image_type|default:"image" }}</span>
|
||||||
{% if img.description %}
|
{% if img.description %}
|
||||||
<p class="text-xs opacity-60 mt-1">{{ img.description|truncatewords:10 }}</p>
|
<p class="text-xs opacity-60 mt-1">{{ img.description|truncatewords:10 }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<p class="text-xs opacity-40 mt-1">{{ img.s3_key }}</p>
|
<p class="text-xs opacity-40 mt-1 flex items-center gap-1">
|
||||||
</div>
|
<span>🔍</span> Click to view
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -50,7 +50,10 @@ urlpatterns = [
|
|||||||
path("items/<str:uid>/", views.item_detail, name="item-detail"),
|
path("items/<str:uid>/", views.item_detail, name="item-detail"),
|
||||||
path("items/<str:uid>/edit/", views.item_edit, name="item-edit"),
|
path("items/<str:uid>/edit/", views.item_edit, name="item-edit"),
|
||||||
path("items/<str:uid>/reembed/", views.item_reembed, name="item-reembed"),
|
path("items/<str:uid>/reembed/", views.item_reembed, name="item-reembed"),
|
||||||
|
path("items/<str:uid>/download/", views.item_download, name="item-download"),
|
||||||
path("items/<str:uid>/delete/", views.item_delete, name="item-delete"),
|
path("items/<str:uid>/delete/", views.item_delete, name="item-delete"),
|
||||||
|
# Image views
|
||||||
|
path("images/<str:uid>/view/", views.image_serve, name="image-serve"),
|
||||||
# DRF API
|
# DRF API
|
||||||
path("api/", include("library.api.urls")),
|
path("api/", include("library.api.urls")),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from django.contrib import messages
|
|||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.core.files.storage import default_storage
|
from django.core.files.storage import default_storage
|
||||||
|
from django.http import Http404
|
||||||
from django.shortcuts import redirect, render
|
from django.shortcuts import redirect, render
|
||||||
|
|
||||||
from .content_types import get_library_type_config
|
from .content_types import get_library_type_config
|
||||||
@@ -485,6 +486,55 @@ def item_delete(request, uid):
|
|||||||
return render(request, "library/item_confirm_delete.html", {"item": item})
|
return render(request, "library/item_confirm_delete.html", {"item": item})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def item_download(request, uid):
|
||||||
|
"""Redirect to a presigned/storage URL for the item's original file."""
|
||||||
|
try:
|
||||||
|
from .models import Item
|
||||||
|
|
||||||
|
item = Item.nodes.get(uid=uid)
|
||||||
|
except Exception:
|
||||||
|
raise Http404("Item not found.")
|
||||||
|
|
||||||
|
if not item.s3_key:
|
||||||
|
raise Http404("No file available for this item.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
url = default_storage.url(item.s3_key)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to generate download URL for %s: %s", item.s3_key, e)
|
||||||
|
raise Http404("File not accessible.")
|
||||||
|
|
||||||
|
return redirect(url)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Image views
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def image_serve(request, uid):
|
||||||
|
"""Redirect to a presigned/storage URL for an image file."""
|
||||||
|
try:
|
||||||
|
from .models import Image
|
||||||
|
|
||||||
|
img = Image.nodes.get(uid=uid)
|
||||||
|
except Exception:
|
||||||
|
raise Http404("Image not found.")
|
||||||
|
|
||||||
|
if not img.s3_key:
|
||||||
|
raise Http404("No file available for this image.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
url = default_storage.url(img.s3_key)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to generate image URL for %s: %s", img.s3_key, e)
|
||||||
|
raise Http404("Image not accessible.")
|
||||||
|
|
||||||
|
return redirect(url)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Embedding Pipeline Dashboard
|
# Embedding Pipeline Dashboard
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user