mdi-magnify-plus
@@ -360,12 +365,11 @@ const MediaThumbnail = Vue.defineComponent({
style="z-index: 2;">
-
![]()
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 rotate_rect_to_original(rect: dict, orientation: int) -> dict:
+ """Map a normalized rect drawn on a rotated image back to the original image's coordinate space."""
+ x, y, w, h = rect["x"], rect["y"], rect["w"], rect["h"]
+ if orientation == 90:
+ return {"x": y, "y": 1 - x - w, "w": h, "h": w}
+ if orientation == 180:
+ return {"x": 1 - x - w, "y": 1 - y - h, "w": w, "h": h}
+ if orientation == 270:
+ return {"x": 1 - y - h, "y": x, "w": h, "h": w}
+ return rect
+
+
+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/enferno/utils/soft_delete.py b/enferno/utils/soft_delete.py
new file mode 100644
index 000000000..2dd189aae
--- /dev/null
+++ b/enferno/utils/soft_delete.py
@@ -0,0 +1,35 @@
+"""Global soft-delete filter for Media.
+
+`deleted` lives on BaseMixin so every model carries it, but only Media is
+actually soft-deleted in practice. Rather than remembering a manual
+`.filter(deleted == False)` at every read site (the source of recurring
+"deleted media still shows up" bugs), this injects the predicate into every
+ORM SELECT that touches Media, including lazy relationship loads such as
+`bulletin.medias`.
+
+Opt out for the rare route that must reach an already-deleted row by id:
+
+ Media.query.execution_options(include_deleted=True).get(id)
+"""
+
+from sqlalchemy import event
+from sqlalchemy.orm import Session, with_loader_criteria
+
+from enferno.admin.models import Media
+
+
+def _filter_soft_deleted_media(state):
+ if (
+ state.is_select
+ and not state.is_column_load
+ and not state.execution_options.get("include_deleted", False)
+ ):
+ state.statement = state.statement.options(
+ with_loader_criteria(Media, Media.deleted.is_(False), include_aliases=True)
+ )
+
+
+def register_soft_delete(db):
+ """Attach the soft-delete filter to the session. Idempotent."""
+ if not event.contains(Session, "do_orm_execute", _filter_soft_deleted_media):
+ event.listen(Session, "do_orm_execute", _filter_soft_deleted_media)
diff --git a/migrations/versions/68396035f041_add_original_media_id_to_media_redaction.py b/migrations/versions/68396035f041_add_original_media_id_to_media_redaction.py
new file mode 100644
index 000000000..d2d09c244
--- /dev/null
+++ b/migrations/versions/68396035f041_add_original_media_id_to_media_redaction.py
@@ -0,0 +1,36 @@
+"""add original_media_id to media_redaction
+
+Revision ID: 68396035f041
+Revises: e4f2a9c8d7b1
+Create Date: 2026-06-10 14:16:17.368157
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '68396035f041'
+down_revision = 'e4f2a9c8d7b1'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table('media_redaction', schema=None) as batch_op:
+ batch_op.add_column(sa.Column('original_media_id', sa.Integer(), nullable=True))
+ batch_op.create_index(batch_op.f('ix_media_redaction_original_media_id'), ['original_media_id'], unique=False)
+ batch_op.create_foreign_key(None, 'media', ['original_media_id'], ['id'])
+
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table('media_redaction', schema=None) as batch_op:
+ batch_op.drop_constraint('media_redaction_original_media_id_fkey', type_='foreignkey')
+ batch_op.drop_index(batch_op.f('ix_media_redaction_original_media_id'))
+ batch_op.drop_column('original_media_id')
+
+ # ### end Alembic commands ###
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..b8451988e
--- /dev/null
+++ b/tests/admin/test_redaction.py
@@ -0,0 +1,229 @@
+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)
+
+
+def test_delete_endpoint_soft_deletes_redacted_copy_only(admin_client, session):
+ from enferno.admin.models import MediaRedaction
+
+ bulletin = Bulletin(title="Soft-delete test")
+ session.add(bulletin)
+ session.flush()
+ original = Media(
+ media_file="orig.pdf",
+ media_file_type="application/pdf",
+ etag="orig",
+ bulletin_id=bulletin.id,
+ )
+ redacted = Media(
+ media_file="red.pdf", media_file_type="application/pdf", etag="red", bulletin_id=bulletin.id
+ )
+ session.add_all([original, redacted])
+ session.flush()
+ audit = MediaRedaction(
+ source_media_id=original.id,
+ original_media_id=original.id,
+ result_media_id=redacted.id,
+ regions=[],
+ )
+ session.add(audit)
+ session.commit()
+ original_id, redacted_id = original.id, redacted.id
+
+ try:
+ # Original (no redaction backref) is rejected.
+ resp = admin_client.delete(f"/admin/api/media/{original_id}/redact")
+ assert resp.status_code == 400
+ assert Media.query.get(original_id).deleted is False
+
+ # Redacted copy is soft-deleted: hidden from normal queries, still
+ # reachable via the include_deleted opt-out with deleted set.
+ resp = admin_client.delete(f"/admin/api/media/{redacted_id}/redact")
+ assert resp.status_code == 200
+ assert resp.get_json()["data"] == {"id": redacted_id, "deleted": True}
+ assert Media.query.get(redacted_id) is None
+ row = Media.query.execution_options(include_deleted=True).get(redacted_id)
+ assert row.deleted is True
+ finally:
+ MediaRedaction.query.filter_by(source_media_id=original_id).delete()
+ Media.query.filter(Media.id.in_([original_id, redacted_id])).delete(
+ synchronize_session=False
+ )
+ session.commit()
+
+
+def test_bulletin_to_dict_excludes_deleted_media(session):
+ bulletin = Bulletin(title="Deleted media exclusion")
+ session.add(bulletin)
+ session.flush()
+ live = Media(
+ media_file="live.pdf",
+ media_file_type="application/pdf",
+ etag="live",
+ bulletin_id=bulletin.id,
+ )
+ gone = Media(
+ media_file="gone.pdf",
+ media_file_type="application/pdf",
+ etag="gone",
+ bulletin_id=bulletin.id,
+ deleted=True,
+ )
+ session.add_all([live, gone])
+ session.commit()
+ live_id, gone_id = live.id, gone.id
+
+ try:
+ media_ids = {m["id"] for m in bulletin.to_dict()["medias"]}
+ assert live_id in media_ids
+ assert gone_id not in media_ids
+ finally:
+ Media.query.filter(Media.id.in_([live_id, gone_id])).delete(synchronize_session=False)
+ session.commit()
+
+
+def test_media_dashboard_excludes_deleted(admin_client, session):
+ bulletin = Bulletin(title="Dashboard exclusion")
+ session.add(bulletin)
+ session.flush()
+ live = Media(
+ media_file="dlive.pdf",
+ media_file_type="application/pdf",
+ etag="dlive",
+ bulletin_id=bulletin.id,
+ )
+ gone = Media(
+ media_file="dgone.pdf",
+ media_file_type="application/pdf",
+ etag="dgone",
+ bulletin_id=bulletin.id,
+ deleted=True,
+ )
+ session.add_all([live, gone])
+ session.commit()
+ live_id, gone_id = live.id, gone.id
+
+ try:
+ resp = admin_client.get(f"/admin/api/media/dashboard?bulletin_id={bulletin.id}")
+ assert resp.status_code == 200
+ ids = {m["id"] for m in resp.get_json()["items"]}
+ assert live_id in ids
+ assert gone_id not in ids
+ finally:
+ Media.query.filter(Media.id.in_([live_id, gone_id])).delete(synchronize_session=False)
+ session.commit()