JSON field stuff, and make nice on the frontend

This commit is contained in:
shamoon
2026-01-26 13:29:41 -08:00
parent 9db89bac3e
commit b1fc0b79fa
10 changed files with 71 additions and 21 deletions

View File

@@ -51,14 +51,39 @@
</td> </td>
<td> <td>
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
@if (bundle.status === statuses.Failed && bundle.last_error) {
<button
type="button"
class="btn btn-link p-0 text-danger"
[ngbPopover]="errorDetail"
popoverClass="popover-sm"
triggers="mouseover:mouseleave"
placement="auto"
aria-label="View error details"
i18n-aria-label
>
<span class="badge text-bg-warning text-uppercase me-2">{{ statusLabel(bundle.status) }}</span>
<i-bs name="exclamation-triangle-fill" class="text-warning"></i-bs>
</button>
<ng-template #errorDetail>
@if (bundle.last_error.timestamp) {
<div class="text-muted small mb-1">
{{ bundle.last_error.timestamp | date: 'short' }}
</div>
}
<h6>{{ bundle.last_error.exception_type || ($localize`Unknown error`) }}</h6>
@if (bundle.last_error.message) {
<pre class="text-muted small"><code>{{ bundle.last_error.message }}</code></pre>
}
</ng-template>
}
@if (bundle.status === statuses.Processing || bundle.status === statuses.Pending) { @if (bundle.status === statuses.Processing || bundle.status === statuses.Pending) {
<span class="spinner-border spinner-border-sm" role="status"></span> <span class="spinner-border spinner-border-sm" role="status"></span>
} }
@if (bundle.status !== statuses.Failed) {
<span class="badge text-bg-secondary text-uppercase">{{ statusLabel(bundle.status) }}</span> <span class="badge text-bg-secondary text-uppercase">{{ statusLabel(bundle.status) }}</span>
</div>
@if (bundle.last_error && bundle.status === statuses.Failed) {
<div class="small text-danger mt-1">{{ bundle.last_error }}</div>
} }
</div>
</td> </td>
<td> <td>
@if (bundle.size_bytes !== undefined && bundle.size_bytes !== null) { @if (bundle.size_bytes !== undefined && bundle.size_bytes !== null) {

View File

@@ -0,0 +1,4 @@
:host ::ng-deep .popover {
min-width: 300px;
max-width: 400px;
}

View File

@@ -81,6 +81,7 @@ describe('ShareLinkBundleManageDialogComponent', () => {
documents: [1], documents: [1],
status: ShareLinkBundleStatus.Pending, status: ShareLinkBundleStatus.Pending,
file_version: FileVersion.Archive, file_version: FileVersion.Archive,
last_error: undefined,
...overrides, ...overrides,
}) as ShareLinkBundleSummary }) as ShareLinkBundleSummary

View File

@@ -1,7 +1,7 @@
import { Clipboard } from '@angular/cdk/clipboard' import { Clipboard } from '@angular/cdk/clipboard'
import { CommonModule } from '@angular/common' import { CommonModule } from '@angular/common'
import { Component, OnDestroy, OnInit, inject } from '@angular/core' import { Component, OnDestroy, OnInit, inject } from '@angular/core'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' import { NgbActiveModal, NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Subject, catchError, of, switchMap, takeUntil, timer } from 'rxjs' import { Subject, catchError, of, switchMap, takeUntil, timer } from 'rxjs'
import { FileVersion } from 'src/app/data/share-link' import { FileVersion } from 'src/app/data/share-link'
@@ -21,9 +21,11 @@ import { ConfirmButtonComponent } from '../confirm-button/confirm-button.compone
@Component({ @Component({
selector: 'pngx-share-link-bundle-manage-dialog', selector: 'pngx-share-link-bundle-manage-dialog',
templateUrl: './share-link-bundle-manage-dialog.component.html', templateUrl: './share-link-bundle-manage-dialog.component.html',
styleUrls: ['./share-link-bundle-manage-dialog.component.scss'],
imports: [ imports: [
ConfirmButtonComponent, ConfirmButtonComponent,
CommonModule, CommonModule,
NgbPopoverModule,
NgxBootstrapIconsModule, NgxBootstrapIconsModule,
FileSizePipe, FileSizePipe,
], ],

View File

@@ -7,6 +7,13 @@ export enum ShareLinkBundleStatus {
Failed = 'failed', Failed = 'failed',
} }
export type ShareLinkBundleError = {
bundle_id: number
message?: string
exception_type?: string
timestamp?: string
}
export interface ShareLinkBundleSummary { export interface ShareLinkBundleSummary {
id: number id: number
slug: string slug: string
@@ -18,7 +25,7 @@ export interface ShareLinkBundleSummary {
status: ShareLinkBundleStatus status: ShareLinkBundleStatus
built_at?: string built_at?: string
size_bytes?: number size_bytes?: number
last_error?: string last_error?: ShareLinkBundleError
} }
export interface ShareLinkBundleCreatePayload { export interface ShareLinkBundleCreatePayload {

View File

@@ -120,8 +120,10 @@ class Migration(migrations.Migration):
), ),
( (
"last_error", "last_error",
models.TextField( models.JSONField(
blank=True, blank=True,
null=True,
default=None,
verbose_name="last error", verbose_name="last error",
), ),
), ),

View File

@@ -817,9 +817,11 @@ class ShareLinkBundle(SoftDeleteModel):
null=True, null=True,
) )
last_error = models.TextField( last_error = models.JSONField(
_("last error"), _("last error"),
blank=True, blank=True,
null=True,
default=None,
) )
file_path = models.CharField( file_path = models.CharField(

View File

@@ -647,7 +647,7 @@ def build_share_link_bundle(bundle_id: int):
bundle.remove_file() bundle.remove_file()
bundle.status = ShareLinkBundle.Status.PROCESSING bundle.status = ShareLinkBundle.Status.PROCESSING
bundle.last_error = "" bundle.last_error = None
bundle.size_bytes = None bundle.size_bytes = None
bundle.built_at = None bundle.built_at = None
bundle.file_path = "" bundle.file_path = ""
@@ -695,7 +695,7 @@ def build_share_link_bundle(bundle_id: int):
bundle.size_bytes = final_path.stat().st_size bundle.size_bytes = final_path.stat().st_size
bundle.status = ShareLinkBundle.Status.READY bundle.status = ShareLinkBundle.Status.READY
bundle.built_at = timezone.now() bundle.built_at = timezone.now()
bundle.last_error = "" bundle.last_error = None
bundle.save( bundle.save(
update_fields=[ update_fields=[
"file_path", "file_path",
@@ -713,7 +713,12 @@ def build_share_link_bundle(bundle_id: int):
exc, exc,
) )
bundle.status = ShareLinkBundle.Status.FAILED bundle.status = ShareLinkBundle.Status.FAILED
bundle.last_error = str(exc) bundle.last_error = {
"bundle_id": bundle_id,
"exception_type": exc.__class__.__name__,
"message": str(exc),
"timestamp": timezone.now().isoformat(),
}
bundle.save(update_fields=["status", "last_error"]) bundle.save(update_fields=["status", "last_error"])
try: try:
temp_zip_path.unlink() temp_zip_path.unlink()

View File

@@ -81,7 +81,7 @@ class ShareLinkBundleAPITests(DirectoriesMixin, APITestCase):
status=ShareLinkBundle.Status.FAILED, status=ShareLinkBundle.Status.FAILED,
) )
bundle.documents.set([self.document]) bundle.documents.set([self.document])
bundle.last_error = "Something went wrong" bundle.last_error = {"message": "Something went wrong"}
bundle.size_bytes = 100 bundle.size_bytes = 100
bundle.file_path = "path/to/file.zip" bundle.file_path = "path/to/file.zip"
bundle.save() bundle.save()
@@ -91,7 +91,7 @@ class ShareLinkBundleAPITests(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
bundle.refresh_from_db() bundle.refresh_from_db()
self.assertEqual(bundle.status, ShareLinkBundle.Status.PENDING) self.assertEqual(bundle.status, ShareLinkBundle.Status.PENDING)
self.assertEqual(bundle.last_error, "") self.assertIsNone(bundle.last_error)
self.assertIsNone(bundle.size_bytes) self.assertIsNone(bundle.size_bytes)
self.assertEqual(bundle.file_path, "") self.assertEqual(bundle.file_path, "")
delay_mock.assert_called_once_with(bundle.pk) delay_mock.assert_called_once_with(bundle.pk)
@@ -172,7 +172,7 @@ class ShareLinkBundleAPITests(DirectoriesMixin, APITestCase):
file_version=ShareLink.FileVersion.ARCHIVE, file_version=ShareLink.FileVersion.ARCHIVE,
status=ShareLinkBundle.Status.FAILED, status=ShareLinkBundle.Status.FAILED,
file_path=str(bundle_path.relative_to(settings.MEDIA_ROOT)), file_path=str(bundle_path.relative_to(settings.MEDIA_ROOT)),
last_error="Boom", last_error={"message": "Boom"},
size_bytes=10, size_bytes=10,
) )
bundle.documents.set([self.document]) bundle.documents.set([self.document])
@@ -183,7 +183,7 @@ class ShareLinkBundleAPITests(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE) self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE)
bundle.refresh_from_db() bundle.refresh_from_db()
self.assertEqual(bundle.status, ShareLinkBundle.Status.PENDING) self.assertEqual(bundle.status, ShareLinkBundle.Status.PENDING)
self.assertEqual(bundle.last_error, "") self.assertIsNone(bundle.last_error)
self.assertIsNone(bundle.size_bytes) self.assertIsNone(bundle.size_bytes)
self.assertEqual(bundle.file_path, "") self.assertEqual(bundle.file_path, "")
delay_mock.assert_called_once_with(bundle.pk) delay_mock.assert_called_once_with(bundle.pk)
@@ -335,7 +335,7 @@ class ShareLinkBundleBuildTaskTests(DirectoriesMixin, APITestCase):
bundle.refresh_from_db() bundle.refresh_from_db()
self.assertEqual(bundle.status, ShareLinkBundle.Status.READY) self.assertEqual(bundle.status, ShareLinkBundle.Status.READY)
self.assertEqual(bundle.last_error, "") self.assertIsNone(bundle.last_error)
self.assertIsNotNone(bundle.built_at) self.assertIsNotNone(bundle.built_at)
self.assertGreater(bundle.size_bytes or 0, 0) self.assertGreater(bundle.size_bytes or 0, 0)
final_path = bundle.absolute_file_path final_path = bundle.absolute_file_path
@@ -404,7 +404,9 @@ class ShareLinkBundleBuildTaskTests(DirectoriesMixin, APITestCase):
bundle.refresh_from_db() bundle.refresh_from_db()
self.assertEqual(bundle.status, ShareLinkBundle.Status.FAILED) self.assertEqual(bundle.status, ShareLinkBundle.Status.FAILED)
self.assertEqual(bundle.last_error, "zip failure") self.assertIsInstance(bundle.last_error, dict)
self.assertEqual(bundle.last_error.get("message"), "zip failure")
self.assertEqual(bundle.last_error.get("exception_type"), "RuntimeError")
scratch_zips = list(Path(settings.SCRATCH_DIR).glob("*.zip")) scratch_zips = list(Path(settings.SCRATCH_DIR).glob("*.zip"))
self.assertTrue(scratch_zips) self.assertTrue(scratch_zips)
for path in scratch_zips: for path in scratch_zips:

View File

@@ -2865,7 +2865,7 @@ class ShareLinkBundleViewSet(ModelViewSet, PassUserMixin):
) )
bundle.remove_file() bundle.remove_file()
bundle.status = ShareLinkBundle.Status.PENDING bundle.status = ShareLinkBundle.Status.PENDING
bundle.last_error = "" bundle.last_error = None
bundle.size_bytes = None bundle.size_bytes = None
bundle.built_at = None bundle.built_at = None
bundle.file_path = "" bundle.file_path = ""
@@ -2898,7 +2898,7 @@ class ShareLinkBundleViewSet(ModelViewSet, PassUserMixin):
) )
bundle.remove_file() bundle.remove_file()
bundle.status = ShareLinkBundle.Status.PENDING bundle.status = ShareLinkBundle.Status.PENDING
bundle.last_error = "" bundle.last_error = None
bundle.size_bytes = None bundle.size_bytes = None
bundle.built_at = None bundle.built_at = None
bundle.file_path = "" bundle.file_path = ""
@@ -2958,7 +2958,7 @@ class SharedLinkView(View):
if bundle.status == ShareLinkBundle.Status.FAILED: if bundle.status == ShareLinkBundle.Status.FAILED:
bundle.remove_file() bundle.remove_file()
bundle.status = ShareLinkBundle.Status.PENDING bundle.status = ShareLinkBundle.Status.PENDING
bundle.last_error = "" bundle.last_error = None
bundle.size_bytes = None bundle.size_bytes = None
bundle.built_at = None bundle.built_at = None
bundle.file_path = "" bundle.file_path = ""
@@ -2982,7 +2982,7 @@ class SharedLinkView(View):
file_path = bundle.absolute_file_path file_path = bundle.absolute_file_path
if file_path is None or not file_path.exists(): if file_path is None or not file_path.exists():
bundle.status = ShareLinkBundle.Status.PENDING bundle.status = ShareLinkBundle.Status.PENDING
bundle.last_error = "" bundle.last_error = None
bundle.size_bytes = None bundle.size_bytes = None
bundle.built_at = None bundle.built_at = None
bundle.file_path = "" bundle.file_path = ""