From c895a3a3e92ab28c5ec7119011659ce7d229c008 Mon Sep 17 00:00:00 2001 From: level09 Date: Sun, 7 Jun 2026 17:26:41 +0200 Subject: [PATCH 01/28] Add document/image redaction prototype (PyMuPDF burn, immutable original) --- enferno/admin/models/MediaRedaction.py | 38 +++ enferno/admin/models/__init__.py | 1 + .../templates/admin/media-dashboard.html | 53 +++- enferno/admin/views/media.py | 131 ++++++++- enferno/static/js/components/MediaRedactor.js | 275 ++++++++++++++++++ enferno/utils/redaction_utils.py | 82 ++++++ .../e4f2a9c8d7b1_add_media_redaction_table.py | 58 ++++ tests/admin/test_redaction.py | 118 ++++++++ 8 files changed, 753 insertions(+), 3 deletions(-) create mode 100644 enferno/admin/models/MediaRedaction.py create mode 100644 enferno/static/js/components/MediaRedactor.js create mode 100644 enferno/utils/redaction_utils.py create mode 100644 migrations/versions/e4f2a9c8d7b1_add_media_redaction_table.py create mode 100644 tests/admin/test_redaction.py diff --git a/enferno/admin/models/MediaRedaction.py b/enferno/admin/models/MediaRedaction.py new file mode 100644 index 000000000..f41f28f57 --- /dev/null +++ b/enferno/admin/models/MediaRedaction.py @@ -0,0 +1,38 @@ +from typing import Any + +from enferno.extensions import db +from enferno.utils.base import BaseMixin +from enferno.utils.date_helper import DateHelper + + +class MediaRedaction(db.Model, BaseMixin): + __tablename__ = "media_redaction" + + id = db.Column(db.Integer, primary_key=True) + source_media_id = db.Column( + db.Integer, + db.ForeignKey("media.id"), + nullable=False, + index=True, + ) + result_media_id = db.Column( + db.Integer, + db.ForeignKey("media.id"), + nullable=False, + index=True, + ) + regions = db.Column(db.JSON, nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey("user.id", ondelete="SET NULL"), index=True) + + source_media = db.relationship("Media", foreign_keys=[source_media_id]) + result_media = db.relationship("Media", foreign_keys=[result_media_id]) + + def to_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "source_media_id": self.source_media_id, + "result_media_id": self.result_media_id, + "regions": self.regions, + "user_id": self.user_id, + "created_at": DateHelper.serialize_datetime(self.created_at), + } diff --git a/enferno/admin/models/__init__.py b/enferno/admin/models/__init__.py index a3b57dca8..71a3c33c9 100644 --- a/enferno/admin/models/__init__.py +++ b/enferno/admin/models/__init__.py @@ -38,6 +38,7 @@ from .LocationType import LocationType from .Media import Media from .MediaCategory import MediaCategory +from .MediaRedaction import MediaRedaction from .PotentialViolation import PotentialViolation from .Query import Query from .Settings import Settings diff --git a/enferno/admin/templates/admin/media-dashboard.html b/enferno/admin/templates/admin/media-dashboard.html index e6f603fee..7ae8625da 100644 --- a/enferno/admin/templates/admin/media-dashboard.html +++ b/enferno/admin/templates/admin/media-dashboard.html @@ -48,6 +48,12 @@ + + ${formatDate(item.updated_at)} + + @@ -234,6 +256,7 @@ + @@ -256,6 +279,8 @@ drawer: drawer, loading: true, bulkLoading: false, + isCurrentUserAdmin: window.__isAdmin__ || false, + isCurrentUserDA: window.__isDA__ || false, itemsPerPageOptions: window.itemsPerPageOptions, hasOcrProvider: Boolean('{{ config.OCR_PROVIDER }}'), processingIds: new Set(), // Track media IDs being processed @@ -271,7 +296,8 @@ { title: "{{_('Filename')}}", value: "filename" }, { title: "{{_('Bulletin')}}", value: "bulletin", sortable: false }, { title: "{{_('OCR Status')}}", value: "ocr_status" }, - { title: "{{_('Date')}}", value: "updated_at" } + { title: "{{_('Date')}}", value: "updated_at" }, + { title: "{{_('Actions')}}", value: "actions", sortable: false, align: "end" } ], selected: [], @@ -290,6 +316,10 @@ bulletin_id: null, dateRange: null }, + redaction: { + dialog: false, + media: null + }, lowWordCount: 20 }), @@ -498,6 +528,24 @@ // Also fetch processing IDs in case this item just finished this.fetchProcessingIds(); }, + canRedact(item) { + if (!this.isCurrentUserAdmin && !this.isCurrentUserDA) return false; + const fileType = item.fileType || ''; + const filename = item.filename || ''; + return fileType.includes('pdf') + || fileType.includes('image') + || /\.(pdf|jpe?g|png)$/i.test(filename); + }, + openRedactor(item) { + if (!this.canRedact(item)) return; + this.redaction.media = item; + this.redaction.dialog = true; + }, + onMediaRedacted() { + this.showSnack("{{ _('Redacted copy created') }}"); + this.refresh(this.options); + this.loadOCRStats(); + }, loadOCRStats() { api.get('/admin/api/ocr/stats').then(res => { this.ocrStats = res?.data; @@ -538,6 +586,7 @@ app.component('OcrTextLayer', OcrTextLayer); app.component('MediaTranscriptionDialog', MediaTranscriptionDialog); app.component('PdfViewer', PdfViewer); + app.component('MediaRedactor', MediaRedactor); app.component('NativePdfViewer', NativePdfViewer); app.component('DocxViewer', DocxViewer); app.component('ImageViewer', ImageViewer); @@ -553,4 +602,4 @@ window.app = app; }); -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/enferno/admin/views/media.py b/enferno/admin/views/media.py index 22c5554ba..6daf29a5a 100644 --- a/enferno/admin/views/media.py +++ b/enferno/admin/views/media.py @@ -4,6 +4,7 @@ import shutil from datetime import datetime, timedelta from functools import wraps +from hashlib import md5 from typing import Optional import boto3 @@ -24,13 +25,14 @@ from sqlalchemy import func, desc from werkzeug.utils import safe_join, secure_filename -from enferno.admin.models import Media, Activity, Extraction +from enferno.admin.models import Media, Activity, Extraction, MediaRedaction from enferno.admin.models.tables import bulletin_roles, actor_roles from enferno.extensions import db, rds from enferno.utils.date_helper import DateHelper from enferno.utils.data_helpers import get_file_hash from enferno.utils.http_response import HTTPResponse from enferno.utils.logging_utils import get_logger +from enferno.utils.redaction_utils import RedactionError, redact_image_bytes, redact_pdf_bytes from enferno.utils.text_utils import normalize_arabic from enferno.utils.validation_utils import validate_with from enferno.admin.validation.models import MediaRequestModel @@ -111,6 +113,61 @@ def _media_url(media_file): ) +def _read_media_bytes(media: Media) -> bytes: + if current_app.config.get("FILESYSTEM_LOCAL"): + filepath = safe_join(str(Media.media_dir), media.media_file) + if not filepath or not os.path.exists(filepath): + raise FileNotFoundError(media.media_file) + with open(filepath, "rb") as f: + return f.read() + + s3 = boto3.client( + "s3", + config=BotoConfig(signature_version="s3v4"), + aws_access_key_id=current_app.config["AWS_ACCESS_KEY_ID"], + aws_secret_access_key=current_app.config["AWS_SECRET_ACCESS_KEY"], + region_name=current_app.config["AWS_REGION"], + ) + return s3.get_object(Bucket=current_app.config["S3_BUCKET"], Key=media.media_file)[ + "Body" + ].read() + + +def _write_media_bytes(filename: str, data: bytes, content_type: str) -> None: + if current_app.config.get("FILESYSTEM_LOCAL"): + filepath = safe_join(str(Media.media_dir), filename) + if not filepath: + raise ValueError("Invalid media filename") + with open(filepath, "wb") as f: + f.write(data) + return + + s3 = boto3.resource( + "s3", + aws_access_key_id=current_app.config["AWS_ACCESS_KEY_ID"], + aws_secret_access_key=current_app.config["AWS_SECRET_ACCESS_KEY"], + region_name=current_app.config["AWS_REGION"], + ) + s3.Bucket(current_app.config["S3_BUCKET"]).put_object( + Key=filename, + Body=data, + ContentType=content_type, + ) + + +def _file_extension(media: Media) -> str: + return os.path.splitext(media.media_file)[1].lower().lstrip(".") + + +def _duplicate_redacted_media(etag: str, source: Media) -> Media | None: + query = Media.query.filter(Media.etag == etag, Media.deleted == False) + if source.bulletin_id is not None: + query = query.filter(Media.bulletin_id == source.bulletin_id) + elif source.actor_id is not None: + query = query.filter(Media.actor_id == source.actor_id) + return query.first() + + # Media dashboard page route @admin.route("/media/", defaults={"id": None}) @admin.route("/media/") @@ -611,6 +668,78 @@ def api_media_update(id: t.id, validated_data: dict) -> Response: return HTTPResponse.error("Error updating Media", status=500) +@admin.post("/api/media//redact") +@roles_accepted("Admin", "DA") +def api_media_redact(id: int) -> Response: + media = Media.query.get(id) + if media is None: + return HTTPResponse.not_found("Media not found") + if not current_user.can_access(media): + return HTTPResponse.forbidden("Restricted Access") + + pages = (request.get_json(silent=True) or {}).get("pages", []) + ext = _file_extension(media) + + try: + src = _read_media_bytes(media) + if ext == "pdf": + out = redact_pdf_bytes(src, pages) + out_ext = "pdf" + out_type = "application/pdf" + elif ext in {"jpg", "jpeg", "png"} or (media.media_file_type or "").startswith("image/"): + rects = [rect for page in pages for rect in page.get("rects", [])] + out = redact_image_bytes(src, rects) + out_ext = "jpg" + out_type = "image/jpeg" + else: + return HTTPResponse.error("This file type cannot be redacted", status=415) + except RedactionError as e: + return HTTPResponse.error(str(e), status=400) + except FileNotFoundError: + return HTTPResponse.not_found("Media file not found") + + etag = md5(out).hexdigest() + if _duplicate_redacted_media(etag, media): + return HTTPResponse.error("Redacted media already exists", status=409) + + filename = Media.generate_file_name(f"redacted.{out_ext}") + _write_media_bytes(filename, out, out_type) + + redacted = Media( + media_file=filename, + media_file_type=out_type, + etag=etag, + title=f"{media.title or media.media_file} (redacted)", + title_ar=media.title_ar, + comments=media.comments, + comments_ar=media.comments_ar, + category=media.category, + time=media.time, + user_id=current_user.id, + bulletin_id=media.bulletin_id, + actor_id=media.actor_id, + ) + audit = MediaRedaction( + source_media_id=media.id, + result_media=redacted, + regions=pages, + user_id=current_user.id, + ) + db.session.add(redacted) + db.session.add(audit) + db.session.commit() + + Activity.create( + current_user, + Activity.ACTION_CREATE, + Activity.STATUS_SUCCESS, + redacted.to_mini(), + "media", + details=f"Redacted copy created from media {media.id}", + ) + return HTTPResponse.success(data=redacted.to_dict()) + + # OCR Extraction endpoints @admin.get("/api/media/dashboard") @auth_required("session") diff --git a/enferno/static/js/components/MediaRedactor.js b/enferno/static/js/components/MediaRedactor.js new file mode 100644 index 000000000..3632cb630 --- /dev/null +++ b/enferno/static/js/components/MediaRedactor.js @@ -0,0 +1,275 @@ +const MediaRedactor = Vue.defineComponent({ + props: { + modelValue: { type: Boolean, default: false }, + media: { type: Object, default: null }, + }, + emits: ['update:modelValue', 'redacted'], + + data() { + return { + loading: false, + saving: false, + error: null, + pages: [], + boxes: {}, + draft: null, + }; + }, + + computed: { + show: { + get() { + return this.modelValue; + }, + set(value) { + this.$emit('update:modelValue', value); + }, + }, + src() { + return this.media?.id ? `/admin/api/media/${this.media.id}/proxy` : null; + }, + mediaKind() { + const fileType = this.media?.fileType || ''; + if (fileType.includes('pdf')) return 'pdf'; + if (fileType.includes('image')) return 'image'; + const filename = this.media?.filename || ''; + if (filename.toLowerCase().endsWith('.pdf')) return 'pdf'; + if (/\.(jpe?g|png)$/i.test(filename)) return 'image'; + return 'unsupported'; + }, + canSubmit() { + return Object.values(this.boxes).some(pageBoxes => pageBoxes.length); + }, + }, + + watch: { + show(value) { + if (value) this.load(); + else this.reset(); + }, + media() { + if (this.show) this.load(); + }, + }, + + created() { + this._pdf = null; + }, + + beforeUnmount() { + this._pdf?.destroy?.(); + }, + + methods: { + reset() { + this.loading = false; + this.saving = false; + this.error = null; + this.pages = []; + this.boxes = {}; + this.draft = null; + this._pdf?.destroy?.(); + this._pdf = null; + }, + async load() { + this.reset(); + if (!this.src) return; + if (this.mediaKind === 'pdf') return this.loadPdf(); + if (this.mediaKind === 'image') { + this.pages = [{ index: 0, width: 1, height: 1, rendered: true }]; + this.boxes = { 0: [] }; + return; + } + this.error = 'Unsupported file type'; + }, + async loadPdf() { + this.loading = true; + try { + await loadScript('/static/js/pdf.js/pdf.min.mjs'); + pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/js/pdf.js/pdf.worker.min.mjs'; + const pdf = await pdfjsLib.getDocument(this.src).promise; + this._pdf = pdf; + const pages = []; + for (let index = 0; index < pdf.numPages; index++) { + const page = await pdf.getPage(index + 1); + const viewport = page.getViewport({ scale: 1 }); + pages.push({ index, width: viewport.width, height: viewport.height, rendered: false }); + this.boxes[index] = []; + await page.cleanup(); + } + this.pages = pages; + await this.$nextTick(); + for (const page of pages) await this.renderPdfPage(page.index); + } catch (e) { + console.error('PDF redactor load failed:', e); + this.error = 'Failed to load document'; + } finally { + this.loading = false; + } + }, + canvasRef(index) { + const ref = this.$refs[`redactCanvas-${index}`]; + return Array.isArray(ref) ? ref[0] : ref; + }, + async renderPdfPage(index) { + const canvas = this.canvasRef(index); + if (!canvas || !this._pdf) return; + const pdfPage = await this._pdf.getPage(index + 1); + const viewport = pdfPage.getViewport({ scale: 1.5 }); + const dpr = window.devicePixelRatio || 1; + canvas.width = Math.floor(viewport.width * dpr); + canvas.height = Math.floor(viewport.height * dpr); + canvas.style.width = '100%'; + canvas.style.height = 'auto'; + const ctx = canvas.getContext('2d', { alpha: false }); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.fillStyle = '#fff'; + ctx.fillRect(0, 0, viewport.width, viewport.height); + await pdfPage.render({ canvasContext: ctx, viewport }).promise; + await pdfPage.cleanup(); + this.pages = this.pages.map(page => + page.index === index ? { ...page, width: viewport.width, height: viewport.height, rendered: true } : page + ); + }, + pageRect(index) { + return this.$refs[`page-${index}`]?.[0]?.getBoundingClientRect?.() + || this.$refs[`page-${index}`]?.getBoundingClientRect?.(); + }, + normalizedPoint(index, event) { + const rect = this.pageRect(index); + const x = Math.min(Math.max((event.clientX - rect.left) / rect.width, 0), 1); + const y = Math.min(Math.max((event.clientY - rect.top) / rect.height, 0), 1); + return { x, y }; + }, + startBox(index, event) { + if (event.button !== 0) return; + const point = this.normalizedPoint(index, event); + this.draft = { page: index, startX: point.x, startY: point.y, x: point.x, y: point.y, w: 0, h: 0 }; + }, + moveBox(index, event) { + if (!this.draft || this.draft.page !== index) return; + const point = this.normalizedPoint(index, event); + const x0 = Math.min(this.draft.startX, point.x); + const y0 = Math.min(this.draft.startY, point.y); + const x1 = Math.max(this.draft.startX, point.x); + const y1 = Math.max(this.draft.startY, point.y); + this.draft = { ...this.draft, x: x0, y: y0, w: x1 - x0, h: y1 - y0 }; + }, + finishBox() { + if (!this.draft) return; + if (this.draft.w > 0.005 && this.draft.h > 0.005) { + const pageBoxes = this.boxes[this.draft.page] || []; + pageBoxes.push({ x: this.draft.x, y: this.draft.y, w: this.draft.w, h: this.draft.h }); + this.boxes = { ...this.boxes, [this.draft.page]: pageBoxes }; + } + this.draft = null; + }, + deleteBox(pageIndex, boxIndex) { + const pageBoxes = [...(this.boxes[pageIndex] || [])]; + pageBoxes.splice(boxIndex, 1); + this.boxes = { ...this.boxes, [pageIndex]: pageBoxes }; + }, + visibleBoxes(pageIndex) { + const boxes = [...(this.boxes[pageIndex] || [])]; + if (this.draft?.page === pageIndex) boxes.push(this.draft); + return boxes; + }, + async submit() { + if (!this.canSubmit) return; + this.saving = true; + const pages = Object.entries(this.boxes) + .filter(([, rects]) => rects.length) + .map(([page, rects]) => ({ page: Number(page), rects })); + try { + const response = await api.post(`/admin/api/media/${this.media.id}/redact`, { pages }); + this.$emit('redacted', response.data.data); + this.show = false; + } catch (e) { + this.error = e.response?.data?.message || 'Failed to create redacted copy'; + } finally { + this.saving = false; + } + }, + }, + + template: /*html*/` + + + + + {{ media?.title || media?.filename || 'Redact document' }} + + Create redacted copy + + + {{ error }} + +
+ +
+
+
+ + + +
+ +
+
+
+
+
+
+ `, +}); diff --git a/enferno/utils/redaction_utils.py b/enferno/utils/redaction_utils.py new file mode 100644 index 000000000..037d8b405 --- /dev/null +++ b/enferno/utils/redaction_utils.py @@ -0,0 +1,82 @@ +import io + +import fitz +from PIL import Image, ImageDraw + + +class RedactionError(ValueError): + pass + + +def _validate_rect(rect: dict) -> None: + required = {"x", "y", "w", "h"} + if set(rect) != required: + raise RedactionError("Redaction rectangle must contain x, y, w, and h") + + x = rect["x"] + y = rect["y"] + w = rect["w"] + h = rect["h"] + if not all(isinstance(value, int | float) for value in (x, y, w, h)): + raise RedactionError("Redaction coordinates must be numeric") + if x < 0 or y < 0 or w <= 0 or h <= 0 or x + w > 1 or y + h > 1: + raise RedactionError("Redaction coordinates must be normalized within the page") + + +def _absolute_rect(rect: dict, width: float, height: float) -> fitz.Rect: + _validate_rect(rect) + return fitz.Rect( + rect["x"] * width, + rect["y"] * height, + (rect["x"] + rect["w"]) * width, + (rect["y"] + rect["h"]) * height, + ) + + +def _page_specs_by_index(doc: fitz.Document, pages: list[dict]) -> dict[int, list[dict]]: + specs = {} + for spec in pages: + page_index = spec.get("page") + if not isinstance(page_index, int) or page_index < 0 or page_index >= doc.page_count: + raise RedactionError("Redaction page index is invalid") + rects = spec.get("rects", []) + if not isinstance(rects, list) or not rects: + raise RedactionError("Redaction page must contain at least one rectangle") + specs.setdefault(page_index, []).extend(rects) + if not specs: + raise RedactionError("No redaction regions provided") + return specs + + +def redact_pdf_bytes(src: bytes, pages: list[dict]) -> bytes: + doc = fitz.open(stream=src, filetype="pdf") + try: + for page_index, rects in _page_specs_by_index(doc, pages).items(): + page = doc[page_index] + for rect in rects: + page.add_redact_annot( + _absolute_rect(rect, page.rect.width, page.rect.height), + fill=(0, 0, 0), + ) + page.apply_redactions() + + doc.scrub() + return doc.tobytes(garbage=4, deflate=True) + finally: + doc.close() + + +def redact_image_bytes(src: bytes, rects: list[dict]) -> bytes: + if not rects: + raise RedactionError("No redaction regions provided") + + img = Image.open(io.BytesIO(src)).convert("RGB") + draw = ImageDraw.Draw(img) + width, height = img.size + for rect in rects: + box = _absolute_rect(rect, width, height) + draw.rectangle([box.x0, box.y0, box.x1, box.y1], fill=(0, 0, 0)) + + out = io.BytesIO() + img.save(out, format="JPEG", quality=90) + return out.getvalue() diff --git a/migrations/versions/e4f2a9c8d7b1_add_media_redaction_table.py b/migrations/versions/e4f2a9c8d7b1_add_media_redaction_table.py new file mode 100644 index 000000000..9bdd232ea --- /dev/null +++ b/migrations/versions/e4f2a9c8d7b1_add_media_redaction_table.py @@ -0,0 +1,58 @@ +"""add media redaction table + +Revision ID: e4f2a9c8d7b1 +Revises: b3f1c2a4d5e6 +Create Date: 2026-06-05 00:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +revision = "e4f2a9c8d7b1" +down_revision = "b3f1c2a4d5e6" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "media_redaction", + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.Column("deleted", sa.Boolean(), server_default="false", nullable=False), + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("source_media_id", sa.Integer(), nullable=False), + sa.Column("result_media_id", sa.Integer(), nullable=False), + sa.Column("regions", sa.JSON(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(["result_media_id"], ["media.id"]), + sa.ForeignKeyConstraint(["source_media_id"], ["media.id"]), + sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_media_redaction_result_media_id"), + "media_redaction", + ["result_media_id"], + unique=False, + ) + op.create_index( + op.f("ix_media_redaction_source_media_id"), + "media_redaction", + ["source_media_id"], + unique=False, + ) + op.create_index( + op.f("ix_media_redaction_user_id"), + "media_redaction", + ["user_id"], + unique=False, + ) + + +def downgrade(): + op.drop_index(op.f("ix_media_redaction_user_id"), table_name="media_redaction") + op.drop_index(op.f("ix_media_redaction_source_media_id"), table_name="media_redaction") + op.drop_index(op.f("ix_media_redaction_result_media_id"), table_name="media_redaction") + op.drop_table("media_redaction") diff --git a/tests/admin/test_redaction.py b/tests/admin/test_redaction.py new file mode 100644 index 000000000..25fa4c0c7 --- /dev/null +++ b/tests/admin/test_redaction.py @@ -0,0 +1,118 @@ +import io + +import fitz +import pytest +from PIL import Image + +from enferno.admin.models import Bulletin, Media +from enferno.utils.redaction_utils import RedactionError, redact_image_bytes, redact_pdf_bytes + + +def _one_page_pdf_with_text(text="SECRET NAME"): + doc = fitz.open() + page = doc.new_page(width=200, height=200) + page.insert_text((10, 100), text, fontsize=12) + data = doc.tobytes() + doc.close() + return data + + +def test_redact_pdf_removes_text_under_box(): + src = _one_page_pdf_with_text("SECRET NAME") + pages = [{"page": 0, "rects": [{"x": 0.0, "y": 0.45, "w": 1.0, "h": 0.10}]}] + + out = redact_pdf_bytes(src, pages) + + doc = fitz.open(stream=out, filetype="pdf") + remaining = doc[0].get_text() + doc.close() + assert "SECRET" not in remaining + assert "NAME" not in remaining + + +def test_redact_pdf_blanks_image_pixels_not_whole_page(): + img = Image.new("RGB", (200, 200), "white") + buf = io.BytesIO() + img.save(buf, format="PNG") + doc = fitz.open() + page = doc.new_page(width=200, height=200) + page.insert_image(page.rect, stream=buf.getvalue()) + src = doc.tobytes() + doc.close() + + pages = [{"page": 0, "rects": [{"x": 0.25, "y": 0.25, "w": 0.5, "h": 0.5}]}] + out = redact_pdf_bytes(src, pages) + + doc = fitz.open(stream=out, filetype="pdf") + pix = doc[0].get_pixmap() + center = pix.pixel(100, 100) + corner = pix.pixel(5, 5) + doc.close() + assert center == (0, 0, 0) + assert corner == (255, 255, 255) + + +def test_redact_image_bytes_burns_black_box(): + img = Image.new("RGB", (100, 100), "white") + buf = io.BytesIO() + img.save(buf, format="JPEG") + + out = redact_image_bytes(buf.getvalue(), [{"x": 0.0, "y": 0.0, "w": 0.5, "h": 0.5}]) + + result = Image.open(io.BytesIO(out)).convert("RGB") + assert result.getpixel((10, 10)) == (0, 0, 0) + assert result.getpixel((90, 90)) == (255, 255, 255) + + +def test_redaction_rejects_out_of_bounds_coordinates(): + src = _one_page_pdf_with_text() + pages = [{"page": 0, "rects": [{"x": -0.1, "y": 0, "w": 0.2, "h": 0.2}]}] + + with pytest.raises(RedactionError): + redact_pdf_bytes(src, pages) + + +def test_redact_endpoint_creates_new_media_and_audit_row(admin_client, session): + from enferno.admin.models import MediaRedaction + + src = _one_page_pdf_with_text("SECRET NAME") + filename = Media.generate_file_name("source.pdf") + path = Media.media_dir / filename + path.write_bytes(src) + + bulletin = Bulletin(title="Redaction test") + session.add(bulletin) + session.flush() + media = Media( + media_file=filename, + media_file_type="application/pdf", + etag="source-etag", + title="Source document", + bulletin_id=bulletin.id, + ) + session.add(media) + session.commit() + media_id = media.id + redacted = None + + try: + resp = admin_client.post( + f"/admin/api/media/{media_id}/redact", + json={"pages": [{"page": 0, "rects": [{"x": 0, "y": 0.45, "w": 1, "h": 0.1}]}]}, + ) + + assert resp.status_code == 200 + redacted = resp.get_json()["data"] + assert redacted["id"] != media_id + assert redacted["title"] == "Source document (redacted)" + audit = MediaRedaction.query.filter_by( + source_media_id=media_id, + result_media_id=redacted["id"], + ).one() + assert audit.user_id is not None + finally: + MediaRedaction.query.filter_by(source_media_id=media_id).delete() + session.commit() + path.unlink(missing_ok=True) + if redacted: + (Media.media_dir / redacted["filename"]).unlink(missing_ok=True) From 1b9bf86c32eb9d0c1dfdd914a9b14223dd0a509b Mon Sep 17 00:00:00 2001 From: level09 Date: Tue, 9 Jun 2026 14:36:22 +0200 Subject: [PATCH 02/28] Redaction: tag copies with Redaction category, label field, collapsible grid group, drawer shortcut --- enferno/admin/templates/admin/bulletins.html | 12 ++++++ enferno/admin/views/media.py | 25 +++++++++--- enferno/data/media_categories.csv | 1 + enferno/static/js/components/MediaCard.js | 20 ++++++++++ enferno/static/js/components/MediaGrid.js | 39 +++++++++++++++---- enferno/static/js/components/MediaRedactor.js | 13 ++++++- 6 files changed, 95 insertions(+), 15 deletions(-) diff --git a/enferno/admin/templates/admin/bulletins.html b/enferno/admin/templates/admin/bulletins.html index fc76c6fea..0c6a85c72 100644 --- a/enferno/admin/templates/admin/bulletins.html +++ b/enferno/admin/templates/admin/bulletins.html @@ -11,6 +11,7 @@ + {% include 'admin/partials/media_transcription_dialog.html' %} {% include 'admin/partials/bulletin_advsearch.html' %} {% include 'admin/partials/bulletin_drawer.html' %} @@ -423,6 +424,7 @@ + @@ -478,6 +480,7 @@ data: () => ({ itemsPerPageOptions: window.itemsPerPageOptions, helper: {}, // helper object for review items + redaction: { dialog: false, media: null }, valid: false, leftDialogProps: { @@ -916,6 +919,14 @@ this.refreshEditedMedia(media.id); if (this.$route.params.id) this.showBulletin(this.$route.params.id); }, + openRedactor(media) { + this.redaction.media = media; + this.redaction.dialog = true; + }, + onMediaRedacted() { + this.showSnack("{{ _('Redacted copy created') }}"); + if (this.$route.params.id) this.showBulletin(this.$route.params.id); + }, handleRoute({ initialLoad = false } = {}) { if (this.$route.params.id) this.showBulletin(this.$route.params.id); if (this.$route.query.reltob) return this.filter_related_to_bulletin(this.$route.query.reltob); @@ -1817,6 +1828,7 @@ app.component('EventsSection', EventsSection); app.component('FieldRenderer', FieldRenderer); app.component('OcrTextLayer', OcrTextLayer); + app.component('MediaRedactor', MediaRedactor); app.component('MediaTranscriptionDialog', MediaTranscriptionDialog); app.use(router).use(vuetify); diff --git a/enferno/admin/views/media.py b/enferno/admin/views/media.py index 6daf29a5a..cf7268d1a 100644 --- a/enferno/admin/views/media.py +++ b/enferno/admin/views/media.py @@ -25,7 +25,7 @@ from sqlalchemy import func, desc from werkzeug.utils import safe_join, secure_filename -from enferno.admin.models import Media, Activity, Extraction, MediaRedaction +from enferno.admin.models import Media, Activity, Extraction, MediaRedaction, MediaCategory from enferno.admin.models.tables import bulletin_roles, actor_roles from enferno.extensions import db, rds from enferno.utils.date_helper import DateHelper @@ -668,6 +668,19 @@ def api_media_update(id: t.id, validated_data: dict) -> Response: return HTTPResponse.error("Error updating Media", status=500) +REDACTION_CATEGORY = "Redaction" + + +def _redaction_category_id() -> int: + """Resolve (creating once if missing) the category that tags redacted copies.""" + category = MediaCategory.find_by_title(REDACTION_CATEGORY) + if category is None: + category = MediaCategory(title=REDACTION_CATEGORY) + db.session.add(category) + db.session.flush() + return category.id + + @admin.post("/api/media//redact") @roles_accepted("Admin", "DA") def api_media_redact(id: int) -> Response: @@ -677,7 +690,9 @@ def api_media_redact(id: int) -> Response: if not current_user.can_access(media): return HTTPResponse.forbidden("Restricted Access") - pages = (request.get_json(silent=True) or {}).get("pages", []) + payload = request.get_json(silent=True) or {} + pages = payload.get("pages", []) + title = (payload.get("title") or "").strip() ext = _file_extension(media) try: @@ -709,11 +724,11 @@ def api_media_redact(id: int) -> Response: media_file=filename, media_file_type=out_type, etag=etag, - title=f"{media.title or media.media_file} (redacted)", + title=title or f"{media.title or media.media_file} (redacted)", title_ar=media.title_ar, comments=media.comments, comments_ar=media.comments_ar, - category=media.category, + category=_redaction_category_id(), time=media.time, user_id=current_user.id, bulletin_id=media.bulletin_id, @@ -808,8 +823,6 @@ def api_media_dashboard(): def _media_dashboard_item(media): """Serialize a media item for the dashboard. Bypasses @check_roles since access is already enforced at the query level.""" - from enferno.admin.models import MediaCategory - media_category = MediaCategory.query.get(media.category) if media.category else None item = { "id": media.id, diff --git a/enferno/data/media_categories.csv b/enferno/data/media_categories.csv index d60b6637f..0af080e4a 100644 --- a/enferno/data/media_categories.csv +++ b/enferno/data/media_categories.csv @@ -2,3 +2,4 @@ id,title,title_tr,created_at,updated_at,deleted 1,Generic,,2023-11-07 22:18:28.791477,2023-11-07 22:18:28.791477,f 2,Humans,,2023-11-07 22:18:28.791477,2023-11-07 22:18:28.791477,f 3,Signs/Text,,2023-11-07 22:18:28.791477,2023-11-07 22:18:28.791477,f +4,Redaction,,2023-11-07 22:18:28.791477,2023-11-07 22:18:28.791477,f diff --git a/enferno/static/js/components/MediaCard.js b/enferno/static/js/components/MediaCard.js index 49e188e59..bdb8414e9 100644 --- a/enferno/static/js/components/MediaCard.js +++ b/enferno/static/js/components/MediaCard.js @@ -42,6 +42,15 @@ const toolbarContent = ` {{ ocrButtonState.text }} + + + + {{ redactButtonState.text }} + @@ -165,6 +174,17 @@ const MediaCard = Vue.defineComponent({ visible, disabled }; + }, + redactButtonState() { + // Only on pages that mounted the redactor, for redactable types, Admin/DA + if (typeof this.$root?.openRedactor !== 'function') return; + const isRedactable = this.mediaType === 'pdf' || this.mediaType === 'image'; + const visible = (this.isCurrentUserAdmin || this.isCurrentUserDA) && isRedactable; + return { + text: this.translations.redact_ || 'Redact', + visible, + disabled: !this.media?.id, + }; } }, methods: { diff --git a/enferno/static/js/components/MediaGrid.js b/enferno/static/js/components/MediaGrid.js index 0e7cd9baa..59a12c25d 100644 --- a/enferno/static/js/components/MediaGrid.js +++ b/enferno/static/js/components/MediaGrid.js @@ -7,14 +7,29 @@ const MediaGrid = Vue.defineComponent({ miniMode: Boolean, }, emits: ['remove-media', 'media-click'], + data() { + return { showRedactions: false }; + }, computed: { - sortedMedia() { - if (this.prioritizeVideos) return this.sortMediaByFileType(this.medias); - - return this.medias; - } + primaryMedia() { + const list = this.prioritizeVideos ? this.sortMediaByFileType(this.medias) : (this.medias || []); + return list.filter(media => !this.isRedaction(media)); + }, + redactionMedia() { + return (this.medias || []).filter(media => this.isRedaction(media)); + }, + visibleMedia() { + return this.showRedactions ? [...this.primaryMedia, ...this.redactionMedia] : this.primaryMedia; + }, }, methods: { + isRedaction(media) { + return media?.category?.title === 'Redaction'; + }, + mediaIndex(media) { + // Index into the unfiltered list so deletion stays correct regardless of sort/filter + return this.medias.indexOf(media); + }, sortMediaByFileType(mediaList) { if (!mediaList) return []; // Sort media list by fileType (video first) @@ -31,17 +46,25 @@ const MediaGrid = Vue.defineComponent({
+ + {{ showRedactions ? 'Hide' : 'Show' }} {{ redactionMedia.length }} redaction{{ redactionMedia.length > 1 ? 's' : '' }} +
`, -}); \ No newline at end of file +}); diff --git a/enferno/static/js/components/MediaRedactor.js b/enferno/static/js/components/MediaRedactor.js index 3632cb630..e3058f85d 100644 --- a/enferno/static/js/components/MediaRedactor.js +++ b/enferno/static/js/components/MediaRedactor.js @@ -10,6 +10,7 @@ const MediaRedactor = Vue.defineComponent({ loading: false, saving: false, error: null, + label: '', pages: [], boxes: {}, draft: null, @@ -65,6 +66,7 @@ const MediaRedactor = Vue.defineComponent({ this.loading = false; this.saving = false; this.error = null; + this.label = ''; this.pages = []; this.boxes = {}; this.draft = null; @@ -181,7 +183,7 @@ const MediaRedactor = Vue.defineComponent({ .filter(([, rects]) => rects.length) .map(([page, rects]) => ({ page: Number(page), rects })); try { - const response = await api.post(`/admin/api/media/${this.media.id}/redact`, { pages }); + const response = await api.post(`/admin/api/media/${this.media.id}/redact`, { pages, title: this.label.trim() }); this.$emit('redacted', response.data.data); this.show = false; } catch (e) { @@ -199,6 +201,15 @@ const MediaRedactor = Vue.defineComponent({ {{ media?.title || media?.filename || 'Redact document' }} + Date: Wed, 10 Jun 2026 08:44:29 -0600 Subject: [PATCH 03/28] add support for moving, resizing and deleting boxes, update toolbar, and add basic zoom support --- enferno/static/js/components/MediaRedactor.js | 307 ++++++++++++++++-- 1 file changed, 276 insertions(+), 31 deletions(-) diff --git a/enferno/static/js/components/MediaRedactor.js b/enferno/static/js/components/MediaRedactor.js index e3058f85d..a1618c575 100644 --- a/enferno/static/js/components/MediaRedactor.js +++ b/enferno/static/js/components/MediaRedactor.js @@ -14,6 +14,15 @@ const MediaRedactor = Vue.defineComponent({ pages: [], boxes: {}, draft: null, + drag: null, + resize: null, + hoveredBox: null, + activeBox: null, + spaceDown: false, + panning: null, + zoom: 1, + pinchStartDistance: null, + pinchStartZoom: 1, }; }, @@ -44,9 +53,26 @@ const MediaRedactor = Vue.defineComponent({ }, watch: { - show(value) { - if (value) this.load(); - else this.reset(); + async show(value) { + if (value) { + this.load(); + await this.$nextTick(); + const el = this.$refs.scrollPane?.$el ?? this.$refs.scrollPane; + if (el) { + this._scrollPane = el; + el.addEventListener('touchmove', this._touchMoveHandler, { passive: false }); + el.addEventListener('wheel', this._wheelHandler, { passive: false }); + } + window.addEventListener('keydown', this._keydownHandler); + window.addEventListener('keyup', this._keyupHandler); + } else { + this._scrollPane?.removeEventListener('touchmove', this._touchMoveHandler); + this._scrollPane?.removeEventListener('wheel', this._wheelHandler); + this._scrollPane = null; + window.removeEventListener('keydown', this._keydownHandler); + window.removeEventListener('keyup', this._keyupHandler); + this.reset(); + } }, media() { if (this.show) this.load(); @@ -55,9 +81,17 @@ const MediaRedactor = Vue.defineComponent({ created() { this._pdf = null; + this._touchMoveHandler = (e) => this.onTouchMove(e); + this._wheelHandler = (e) => this.onWheel(e); + this._keydownHandler = (e) => this.onKeydown(e); + this._keyupHandler = (e) => this.onKeyup(e); }, beforeUnmount() { + this._scrollPane?.removeEventListener('touchmove', this._touchMoveHandler); + this._scrollPane?.removeEventListener('wheel', this._wheelHandler); + window.removeEventListener('keydown', this._keydownHandler); + window.removeEventListener('keyup', this._keyupHandler); this._pdf?.destroy?.(); }, @@ -70,6 +104,15 @@ const MediaRedactor = Vue.defineComponent({ this.pages = []; this.boxes = {}; this.draft = null; + this.drag = null; + this.resize = null; + this.hoveredBox = null; + this.activeBox = null; + this.spaceDown = false; + this.panning = null; + this.zoom = 1; + this.pinchStartDistance = null; + this.pinchStartZoom = 1; this._pdf?.destroy?.(); this._pdf = null; }, @@ -144,11 +187,55 @@ const MediaRedactor = Vue.defineComponent({ return { x, y }; }, startBox(index, event) { - if (event.button !== 0) return; + if (event.button !== 0 || this.spaceDown) return; const point = this.normalizedPoint(index, event); this.draft = { page: index, startX: point.x, startY: point.y, x: point.x, y: point.y, w: 0, h: 0 }; }, + startDrag(pageIndex, boxIndex, event) { + if (event.button !== 0) return; + event.stopPropagation(); + this.activeBox = { page: pageIndex, box: boxIndex }; + const point = this.normalizedPoint(pageIndex, event); + const box = this.boxes[pageIndex][boxIndex]; + this.drag = { page: pageIndex, boxIndex, startMouseX: point.x, startMouseY: point.y, origX: box.x, origY: box.y }; + }, + // corner: 'tl' | 'tr' | 'bl' | 'br' — the corner being dragged; opposite corner is the anchor + startResize(pageIndex, boxIndex, corner, event) { + if (event.button !== 0) return; + event.stopPropagation(); + this.activeBox = { page: pageIndex, box: boxIndex }; + const box = this.boxes[pageIndex][boxIndex]; + const anchorX = corner.includes('l') ? box.x + box.w : box.x; + const anchorY = corner.includes('t') ? box.y + box.h : box.y; + this.resize = { page: pageIndex, boxIndex, corner, anchorX, anchorY }; + }, moveBox(index, event) { + if (this.resize && this.resize.page === index) { + const point = this.normalizedPoint(index, event); + const { anchorX, anchorY, boxIndex } = this.resize; + const x = Math.min(Math.max(Math.min(point.x, anchorX), 0), 1); + const y = Math.min(Math.max(Math.min(point.y, anchorY), 0), 1); + const w = Math.min(Math.abs(point.x - anchorX), 1 - x); + const h = Math.min(Math.abs(point.y - anchorY), 1 - y); + const pageBoxes = [...(this.boxes[index] || [])]; + pageBoxes[boxIndex] = { x, y, w, h }; + this.boxes = { ...this.boxes, [index]: pageBoxes }; + return; + } + if (this.drag && this.drag.page === index) { + const point = this.normalizedPoint(index, event); + const dx = point.x - this.drag.startMouseX; + const dy = point.y - this.drag.startMouseY; + const pageBoxes = [...(this.boxes[index] || [])]; + const box = pageBoxes[this.drag.boxIndex]; + pageBoxes[this.drag.boxIndex] = { + ...box, + x: Math.min(Math.max(this.drag.origX + dx, 0), 1 - box.w), + y: Math.min(Math.max(this.drag.origY + dy, 0), 1 - box.h), + }; + this.boxes = { ...this.boxes, [index]: pageBoxes }; + return; + } if (!this.draft || this.draft.page !== index) return; const point = this.normalizedPoint(index, event); const x0 = Math.min(this.draft.startX, point.x); @@ -158,6 +245,8 @@ const MediaRedactor = Vue.defineComponent({ this.draft = { ...this.draft, x: x0, y: y0, w: x1 - x0, h: y1 - y0 }; }, finishBox() { + if (this.resize) { this.resize = null; return; } + if (this.drag) { this.drag = null; return; } if (!this.draft) return; if (this.draft.w > 0.005 && this.draft.h > 0.005) { const pageBoxes = this.boxes[this.draft.page] || []; @@ -170,12 +259,107 @@ const MediaRedactor = Vue.defineComponent({ const pageBoxes = [...(this.boxes[pageIndex] || [])]; pageBoxes.splice(boxIndex, 1); this.boxes = { ...this.boxes, [pageIndex]: pageBoxes }; + if (this.hoveredBox?.page === pageIndex && this.hoveredBox?.box === boxIndex) this.hoveredBox = null; + if (this.activeBox?.page === pageIndex && this.activeBox?.box === boxIndex) this.activeBox = null; + }, + onKeydown(event) { + if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') return; + if (event.code === 'Space' && !this.spaceDown) { + event.preventDefault(); + this.spaceDown = true; + return; + } + if (event.key !== 'Backspace' && event.key !== 'Delete') return; + const target = this.activeBox || this.hoveredBox; + if (!target) return; + event.preventDefault(); + this.deleteBox(target.page, target.box); + }, + onKeyup(event) { + if (event.code === 'Space') { + this.spaceDown = false; + this.panning = null; + } + }, + onPanStart(event) { + if (!this.spaceDown || event.button !== 0) return; + event.preventDefault(); + event.stopPropagation(); + const pane = this._scrollPane; + if (!pane) return; + this.panning = { startX: event.clientX, startY: event.clientY, scrollLeft: pane.scrollLeft, scrollTop: pane.scrollTop }; + }, + onPanMove(event) { + if (!this.panning) return; + event.preventDefault(); + const pane = this._scrollPane; + if (!pane) return; + pane.scrollLeft = this.panning.scrollLeft - (event.clientX - this.panning.startX); + pane.scrollTop = this.panning.scrollTop - (event.clientY - this.panning.startY); + }, + onPanEnd() { + this.panning = null; }, visibleBoxes(pageIndex) { const boxes = [...(this.boxes[pageIndex] || [])]; if (this.draft?.page === pageIndex) boxes.push(this.draft); return boxes; }, + zoomIn() { + this.zoom = Math.min(+(this.zoom + 0.25).toFixed(2), 3); + }, + zoomOut() { + this.zoom = Math.max(+(this.zoom - 0.25).toFixed(2), 1); + }, + zoomFit() { + this.zoom = 1; + }, + pinchDistance(touches) { + const dx = touches[0].clientX - touches[1].clientX; + const dy = touches[0].clientY - touches[1].clientY; + return Math.sqrt(dx * dx + dy * dy); + }, + onTouchStart(event) { + if (event.touches.length === 2) { + this.pinchStartDistance = this.pinchDistance(event.touches); + this.pinchStartZoom = this.zoom; + } + }, + onTouchMove(event) { + if (event.touches.length === 2 && this.pinchStartDistance) { + event.preventDefault(); + const scale = this.pinchDistance(event.touches) / this.pinchStartDistance; + const next = Math.min(Math.max(this.pinchStartZoom * scale, 1), 3); + const midX = (event.touches[0].clientX + event.touches[1].clientX) / 2; + const midY = (event.touches[0].clientY + event.touches[1].clientY) / 2; + this.applyZoom(next, midX, midY); + } + }, + onTouchEnd() { + this.pinchStartDistance = null; + }, + applyZoom(nextZoom, anchorX, anchorY) { + const pane = this._scrollPane; + if (!pane) { this.zoom = nextZoom; return; } + const rect = pane.getBoundingClientRect(); + const localX = anchorX !== undefined ? anchorX - rect.left : pane.clientWidth / 2; + const localY = anchorY !== undefined ? anchorY - rect.top : pane.clientHeight / 2; + const worldX = pane.scrollLeft + localX; + const worldY = pane.scrollTop + localY; + const ratio = nextZoom / this.zoom; + this.zoom = nextZoom; + this.$nextTick(() => { + pane.scrollLeft = worldX * ratio - localX; + pane.scrollTop = worldY * ratio - localY; + }); + }, + onWheel(event) { + if (!event.ctrlKey) return; + event.preventDefault(); + const factor = Math.exp(-event.deltaY * 0.005); + const next = Math.min(Math.max(this.zoom * factor, 1), 3); + this.applyZoom(next, event.clientX, event.clientY); + }, async submit() { if (!this.canSubmit) return; this.saving = true; @@ -195,45 +379,69 @@ const MediaRedactor = Vue.defineComponent({ }, template: /*html*/` - + - - + {{ media?.title || media?.filename || 'Redact document' }} Create redacted copy + + + + + + {{ Math.round(zoom * 100) }}% + + Fit + {{ error }} - +
-
+
- +
+ +
+ + + mdi-close + +
From 0c1b4f518b3119afce7f3576b888d4b56d106902 Mon Sep 17 00:00:00 2001 From: Daniel Apodaca Date: Wed, 10 Jun 2026 09:14:52 -0600 Subject: [PATCH 04/28] feat(redaction): improve UI clarity and polish for redaction tool - MediaRedactor: rename toolbar title to "Redaction Tool", add instruction hint bar (draw/move/delete/pan tips), improve copy name input label, replace "Create redacted copy" with "Save redacted copy" + disabled tooltip - MediaGrid: visually separate redacted copies behind a labeled divider instead of mixing them inline with primary media - MediaCard: expand redact button tooltip with description of what the tool does - Shrink resize handles from 10px to 7px --- enferno/static/js/components/MediaCard.js | 5 +- enferno/static/js/components/MediaGrid.js | 41 +++++++++--- enferno/static/js/components/MediaRedactor.js | 66 ++++++++++++------- 3 files changed, 77 insertions(+), 35 deletions(-) diff --git a/enferno/static/js/components/MediaCard.js b/enferno/static/js/components/MediaCard.js index bdb8414e9..f20f655fd 100644 --- a/enferno/static/js/components/MediaCard.js +++ b/enferno/static/js/components/MediaCard.js @@ -49,7 +49,10 @@ const toolbarContent = ` - {{ redactButtonState.text }} +
+ Redact document + Draw black boxes to censor sensitive areas,
then save a new redacted copy
+
diff --git a/enferno/static/js/components/MediaGrid.js b/enferno/static/js/components/MediaGrid.js index 59a12c25d..c6664fee4 100644 --- a/enferno/static/js/components/MediaGrid.js +++ b/enferno/static/js/components/MediaGrid.js @@ -46,7 +46,7 @@ const MediaGrid = Vue.defineComponent({
- - {{ showRedactions ? 'Hide' : 'Show' }} {{ redactionMedia.length }} redaction{{ redactionMedia.length > 1 ? 's' : '' }} - + +
`, }); diff --git a/enferno/static/js/components/MediaRedactor.js b/enferno/static/js/components/MediaRedactor.js index a1618c575..dcb77a7da 100644 --- a/enferno/static/js/components/MediaRedactor.js +++ b/enferno/static/js/components/MediaRedactor.js @@ -379,39 +379,55 @@ const MediaRedactor = Vue.defineComponent({ }, template: /*html*/` - + - {{ media?.title || media?.filename || 'Redact document' }} + + + Redaction Tool + — {{ media?.title || media?.filename || 'document' }} + - Create redacted copy + + + Draw at least one black box on the document to save + - + + + Click & drag to draw a black box + Drag box to reposition + Click box then Delete to remove + Hold Space + drag to pan {{ Math.round(zoom * 100) }}% - Fit - + Fit {{ error }} @@ -468,8 +484,8 @@ const MediaRedactor = Vue.defineComponent({ top: (box.y * 100) + '%', width: (box.w * 100) + '%', height: (box.h * 100) + '%', - padding: '9px', - margin: '-9px', + padding: '7px', + margin: '-7px', boxSizing: 'content-box', pointerEvents: draft && boxIndex === visibleBoxes(page.index).length - 1 ? 'none' : 'auto', zIndex: 5, @@ -494,14 +510,14 @@ const MediaRedactor = Vue.defineComponent({ :key="corner" class="position-absolute bg-white" :style="{ - width: '10px', height: '10px', - top: corner.includes('t') ? '-5px' : 'auto', - bottom: corner.includes('b') ? '-5px' : 'auto', - left: corner.includes('l') ? '-5px' : 'auto', - right: corner.includes('r') ? '-5px' : 'auto', + width: '7px', height: '7px', + top: corner.includes('t') ? '-4px' : 'auto', + bottom: corner.includes('b') ? '-4px' : 'auto', + left: corner.includes('l') ? '-4px' : 'auto', + right: corner.includes('r') ? '-4px' : 'auto', cursor: corner === 'tl' || corner === 'br' ? 'nwse-resize' : 'nesw-resize', - border: '2px solid #333', - borderRadius: '2px', + border: '1.5px solid #333', + borderRadius: '1px', zIndex: 20, }" @mousedown.prevent.stop="startResize(page.index, boxIndex, corner, $event)" From fcc4f7fa3b2bd95202a4f2c0a74a92f8e5264b49 Mon Sep 17 00:00:00 2001 From: Daniel Apodaca Date: Wed, 10 Jun 2026 09:33:23 -0600 Subject: [PATCH 05/28] Improvements to make zoom behave smoothly --- enferno/static/js/components/MediaRedactor.js | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/enferno/static/js/components/MediaRedactor.js b/enferno/static/js/components/MediaRedactor.js index dcb77a7da..6d3da5ca3 100644 --- a/enferno/static/js/components/MediaRedactor.js +++ b/enferno/static/js/components/MediaRedactor.js @@ -1,3 +1,5 @@ +const REDACTOR_MAX_ZOOM = 6; + const MediaRedactor = Vue.defineComponent({ props: { modelValue: { type: Boolean, default: false }, @@ -21,6 +23,7 @@ const MediaRedactor = Vue.defineComponent({ spaceDown: false, panning: null, zoom: 1, + baseWidth: 0, pinchStartDistance: null, pinchStartZoom: 1, }; @@ -111,6 +114,7 @@ const MediaRedactor = Vue.defineComponent({ this.spaceDown = false; this.panning = null; this.zoom = 1; + this.baseWidth = 0; this.pinchStartDistance = null; this.pinchStartZoom = 1; this._pdf?.destroy?.(); @@ -306,7 +310,7 @@ const MediaRedactor = Vue.defineComponent({ return boxes; }, zoomIn() { - this.zoom = Math.min(+(this.zoom + 0.25).toFixed(2), 3); + this.zoom = Math.min(+(this.zoom + 0.25).toFixed(2), REDACTOR_MAX_ZOOM); }, zoomOut() { this.zoom = Math.max(+(this.zoom - 0.25).toFixed(2), 1); @@ -329,7 +333,7 @@ const MediaRedactor = Vue.defineComponent({ if (event.touches.length === 2 && this.pinchStartDistance) { event.preventDefault(); const scale = this.pinchDistance(event.touches) / this.pinchStartDistance; - const next = Math.min(Math.max(this.pinchStartZoom * scale, 1), 3); + const next = Math.min(Math.max(this.pinchStartZoom * scale, 1), REDACTOR_MAX_ZOOM); const midX = (event.touches[0].clientX + event.touches[1].clientX) / 2; const midY = (event.touches[0].clientY + event.touches[1].clientY) / 2; this.applyZoom(next, midX, midY); @@ -341,6 +345,10 @@ const MediaRedactor = Vue.defineComponent({ applyZoom(nextZoom, anchorX, anchorY) { const pane = this._scrollPane; if (!pane) { this.zoom = nextZoom; return; } + if (!this.baseWidth) { + const wrapper = pane.querySelector('.redactor-pages'); + this.baseWidth = wrapper ? wrapper.getBoundingClientRect().width : pane.clientWidth; + } const rect = pane.getBoundingClientRect(); const localX = anchorX !== undefined ? anchorX - rect.left : pane.clientWidth / 2; const localY = anchorY !== undefined ? anchorY - rect.top : pane.clientHeight / 2; @@ -357,7 +365,7 @@ const MediaRedactor = Vue.defineComponent({ if (!event.ctrlKey) return; event.preventDefault(); const factor = Math.exp(-event.deltaY * 0.005); - const next = Math.min(Math.max(this.zoom * factor, 1), 3); + const next = Math.min(Math.max(this.zoom * factor, 1), REDACTOR_MAX_ZOOM); this.applyZoom(next, event.clientX, event.clientY); }, async submit() { @@ -426,7 +434,7 @@ const MediaRedactor = Vue.defineComponent({ {{ Math.round(zoom * 100) }}% - + Fit @@ -448,14 +456,14 @@ const MediaRedactor = Vue.defineComponent({
Date: Wed, 10 Jun 2026 10:00:10 -0600 Subject: [PATCH 06/28] add source_media_id to group redacted files on each document --- enferno/admin/models/Media.py | 1 + enferno/admin/models/MediaRedaction.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/enferno/admin/models/Media.py b/enferno/admin/models/Media.py index e0d4c7fc9..bf9f0e728 100644 --- a/enferno/admin/models/Media.py +++ b/enferno/admin/models/Media.py @@ -102,6 +102,7 @@ def to_dict(self) -> dict[str, Any]: DateHelper.serialize_datetime(self.updated_at) if self.updated_at else None ), "extraction": self.extraction.to_compact_dict() if self.extraction else None, + "sourceMediaId": self.redaction.source_media_id if self.redaction else None, } def to_json(self) -> str: diff --git a/enferno/admin/models/MediaRedaction.py b/enferno/admin/models/MediaRedaction.py index f41f28f57..a14a27cbb 100644 --- a/enferno/admin/models/MediaRedaction.py +++ b/enferno/admin/models/MediaRedaction.py @@ -25,7 +25,7 @@ class MediaRedaction(db.Model, BaseMixin): user_id = db.Column(db.Integer, db.ForeignKey("user.id", ondelete="SET NULL"), index=True) source_media = db.relationship("Media", foreign_keys=[source_media_id]) - result_media = db.relationship("Media", foreign_keys=[result_media_id]) + result_media = db.relationship("Media", foreign_keys=[result_media_id], backref=db.backref("redaction", uselist=False)) def to_dict(self) -> dict[str, Any]: return { From a330a062af0c9c0b2bd4e530c7b536fd88a631bc Mon Sep 17 00:00:00 2001 From: Daniel Apodaca Date: Wed, 10 Jun 2026 10:01:09 -0600 Subject: [PATCH 07/28] group redactions by same media type --- enferno/static/js/components/MediaCard.js | 54 ++++++++++++- enferno/static/js/components/MediaGrid.js | 92 ++++++++--------------- 2 files changed, 83 insertions(+), 63 deletions(-) diff --git a/enferno/static/js/components/MediaCard.js b/enferno/static/js/components/MediaCard.js index f20f655fd..ff308d976 100644 --- a/enferno/static/js/components/MediaCard.js +++ b/enferno/static/js/components/MediaCard.js @@ -125,7 +125,11 @@ const MediaCard = Vue.defineComponent({ miniMode: { type: Boolean, default: false, - } + }, + redactions: { + type: Array, + default: () => [], + }, }, emits: ['media-click'], data() { @@ -146,6 +150,7 @@ const MediaCard = Vue.defineComponent({ ocrDetails: null, ocrLoading: false, expansionPanel: null, + showRedactions: false, }; }, computed: { @@ -308,6 +313,53 @@ const MediaCard = Vue.defineComponent({ + + diff --git a/enferno/static/js/components/MediaGrid.js b/enferno/static/js/components/MediaGrid.js index c6664fee4..e36cd0f04 100644 --- a/enferno/static/js/components/MediaGrid.js +++ b/enferno/static/js/components/MediaGrid.js @@ -7,87 +7,55 @@ const MediaGrid = Vue.defineComponent({ miniMode: Boolean, }, emits: ['remove-media', 'media-click'], - data() { - return { showRedactions: false }; - }, computed: { primaryMedia() { const list = this.prioritizeVideos ? this.sortMediaByFileType(this.medias) : (this.medias || []); return list.filter(media => !this.isRedaction(media)); }, - redactionMedia() { - return (this.medias || []).filter(media => this.isRedaction(media)); - }, - visibleMedia() { - return this.showRedactions ? [...this.primaryMedia, ...this.redactionMedia] : this.primaryMedia; + redactionsBySource() { + const map = {}; + for (const media of (this.medias || [])) { + if (!this.isRedaction(media)) continue; + const srcId = media.sourceMediaId; + if (srcId == null) continue; + if (!map[srcId]) map[srcId] = []; + map[srcId].push(media); + } + return map; }, }, methods: { isRedaction(media) { - return media?.category?.title === 'Redaction'; + return media?.sourceMediaId != null; }, mediaIndex(media) { - // Index into the unfiltered list so deletion stays correct regardless of sort/filter return this.medias.indexOf(media); }, sortMediaByFileType(mediaList) { if (!mediaList) return []; - // Sort media list by fileType (video first) - const sortedMediaList = [...mediaList].sort((a, b) => { - if (a?.fileType?.includes('video')) return -1; // Video should come first - if (b?.fileType?.includes('video')) return 1; // Then images - return 0; // Leave unchanged if neither is a video + return [...mediaList].sort((a, b) => { + if (a?.fileType?.includes('video')) return -1; + if (b?.fileType?.includes('video')) return 1; + return 0; }); - - return sortedMediaList; }, }, template: /*html*/` -
- - - - - - -