Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
c895a3a
Add document/image redaction prototype (PyMuPDF burn, immutable origi…
level09 Jun 7, 2026
1b9bf86
Redaction: tag copies with Redaction category, label field, collapsib…
level09 Jun 9, 2026
dbd6995
Merge branch 'main' into document-redaction-prototype
level09 Jun 9, 2026
565c550
add support for moving, resizing and deleting boxes, update toolbar, …
apodacaduron Jun 10, 2026
0c1b4f5
feat(redaction): improve UI clarity and polish for redaction tool
apodacaduron Jun 10, 2026
fcc4f7f
Improvements to make zoom behave smoothly
apodacaduron Jun 10, 2026
78119b0
add source_media_id to group redacted files on each document
apodacaduron Jun 10, 2026
a330a06
group redactions by same media type
apodacaduron Jun 10, 2026
29f5f60
fix PDF blank page on reopen and clean up stale load state
apodacaduron Jun 10, 2026
9cc0ac9
add new original media id field
apodacaduron Jun 10, 2026
c6725c3
populate original media id
apodacaduron Jun 10, 2026
4173575
group redactions by original media id
apodacaduron Jun 10, 2026
cf106be
call visibleBoxes once per page instead of per box
apodacaduron Jun 10, 2026
8e43c72
add in-place edit option for redacted copies
level09 Jun 11, 2026
62d5041
detect redacted copies via redaction backref, not backfilled original id
level09 Jun 11, 2026
e583fe4
image orientation improvements
apodacaduron Jun 17, 2026
e7d3015
Merge branch 'document-redaction-prototype' of https://github.com/sja…
apodacaduron Jun 17, 2026
05bf0b5
propagate orientation to redacted copies and correct box coordinates
apodacaduron Jun 17, 2026
8c7dd37
refresh thumbnail on save
apodacaduron Jun 17, 2026
15396e9
docs: add Redaction guide
level09 Jun 19, 2026
47efc20
Support deleting redactions (#354)
apodacaduron Jun 26, 2026
54aea01
rename endpoint to match ticket
apodacaduron Jun 26, 2026
64e9751
implement redactor tool on actor page
apodacaduron Jun 26, 2026
3bd1b44
Merge branch 'main' into document-redaction-prototype
level09 Jun 27, 2026
3f48daa
Rename redaction change event and refresh media after delete to fix s…
apodacaduron Jun 29, 2026
924b3e4
feat(redaction): soft-delete endpoint for redacted copies
level09 Jun 29, 2026
0afdcca
refactor redaction save/delete handlers and guard shared media actions
apodacaduron Jun 29, 2026
e239eb3
Merge branch 'document-redaction-prototype' of https://github.com/sja…
apodacaduron Jun 29, 2026
2896f24
fix(redaction): exclude soft-deleted media from bulletin/actor serial…
level09 Jun 29, 2026
3a296e5
fix(redaction): exclude soft-deleted media from the media dashboard list
level09 Jun 29, 2026
1c517c5
refactor(media): global soft-delete query filter, drop per-site filters
level09 Jun 29, 2026
f901f9c
improve generic confirm dialog text when deleting redaction
apodacaduron Jun 29, 2026
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
1 change: 1 addition & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export default withMermaid(
{ text: "Data Import", link: "/guide/data-import" },
{ text: "Bulk Operations", link: "/guide/bulk-operations" },
{ text: "Media Management", link: "/guide/media" },
{ text: "Redaction", link: "/guide/redaction" },
{ text: "Notifications", link: "/guide/notifications" },
{ text: "Map Visualization", link: "/guide/map-visualization" },
{ text: "Dynamic Fields", link: "/guide/dynamic-fields" },
Expand Down
50 changes: 50 additions & 0 deletions docs/guide/redaction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Redaction

Bayanat includes a built-in redaction tool for permanently removing sensitive information from documents and images, directly in the browser. Use it to black out names, faces, addresses, signatures, or any detail that must be protected before a file is shared, exported, or published.

Redaction is available to Admin and Data Analyst roles, from any PDF or image attached to a Bulletin.

## True Redaction, Not a Cover-Up

The distinction matters for human rights work, where a leaked identity can put someone at risk. Bayanat does not simply draw a black box on top of the content:

- **PDFs** — the text and graphics beneath each box are permanently deleted from the file, and document metadata is scrubbed. There is no hidden layer to copy, search, or peel back.
- **Images** — the selected regions are painted out and the image is re-encoded, so the original pixels are gone from the redacted file.

What you see is what remains. Nothing sensitive survives underneath.

## The Original Is Never Touched

Redaction always produces a **separate redacted copy**. Your original, unredacted file stays intact and attached to the Bulletin as the authoritative master. This means you can:

- Keep the complete evidence internally while sharing only a safe version
- Produce different redactions of the same source for different audiences
- Re-redact later if more information needs protecting, always working from the clean original

::: tip Immutable by design
The original media can never be overwritten by the redaction tool. Edits only ever apply to a redacted copy.
:::

## How to Redact a File

1. Open a PDF or image from a Bulletin's media
2. Choose the redact option to open the redaction editor
3. Draw boxes over the regions to remove. For multi-page PDFs, move through the pages and mark regions on each.
4. Save as a redacted copy

The new copy is created alongside the original, tagged with the **Redaction** media category and grouped with its source in the media view so the relationship is always clear.

## Revising a Redacted Copy

If you need to adjust a redaction, you can reopen a redacted copy and either save the result as another new copy or overwrite that copy in place. Overwriting is only ever permitted on a redacted copy; the original remains protected.

## Supported Files

- **Documents:** PDF
- **Images:** JPG, JPEG, PNG

## Good Practice

- Redact from the original each time rather than redacting a redaction, so you always start from a known-clean source.
- Review the saved copy before sharing it, especially multi-page documents, to confirm every sensitive region was covered.
- Share or export only the redacted copy. Keep the original within Bayanat under its normal access controls.
2 changes: 2 additions & 0 deletions enferno/admin/models/Media.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ 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,
"isRedaction": self.redaction is not None,
"originalMediaId": self.redaction.original_media_id if self.redaction else None,
}

def to_json(self) -> str:
Expand Down
46 changes: 46 additions & 0 deletions enferno/admin/models/MediaRedaction.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
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,
)
original_media_id = db.Column(
db.Integer,
db.ForeignKey("media.id"),
nullable=True,
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])
original_media = db.relationship("Media", foreign_keys=[original_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 {
"id": self.id,
"source_media_id": self.source_media_id,
"original_media_id": self.original_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),
}
1 change: 1 addition & 0 deletions enferno/admin/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions enferno/admin/templates/admin/actors.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
<link rel="stylesheet" href="/static/js/croppr/croppr.min.css">
{% endblock %} {% block content %}

<media-redactor v-model="redaction.dialog" :media="redaction.media" @redacted="onRedactionSaved"></media-redactor>
{% include 'admin/partials/media_transcription_dialog.html' %}
{% include 'admin/partials/actor_drawer.html' %}
{% include 'admin/partials/bulk_actor_drawer.html' %}
Expand Down Expand Up @@ -402,6 +403,7 @@
<script src="/static/js/components/FieldRenderer.js"></script>
<script src="/static/js/components/OcrTextLayer.js"></script>
<script src="/static/js/components/MediaTranscriptionDialog.js"></script>
<script src="/static/js/components/MediaRedactor.js"></script>

<script src="/static/js/components/PdfViewer.js"></script>
<script src="/static/js/components/NativePdfViewer.js"></script>
Expand Down Expand Up @@ -449,6 +451,7 @@
valid: false,
leftDialogProps: null,
rightDialogProps: null,
redaction: { dialog: false, media: null },
translations: window.translations,
validationRules: validationRules,
advFeatures: ('{{ config.ADV_ANALYSIS }}' === 'True'),
Expand Down Expand Up @@ -834,6 +837,41 @@
this.refreshEditedMedia(media.id);
if (this.$route.params.id) this.showActor(this.$route.params.id);
},
openRedactor(media) {
this.redaction.media = media;
this.redaction.dialog = true;
},
removeRedaction(redaction) {
if (!redaction.isRedaction) {
this.showSnack(window.translations.onlyRedactionsCanBeDeleted_);
return;
}
api.delete(`/admin/api/media/${redaction.id}/redact`)
.then(() => {
this.onRedactionDeleted(redaction);
})
.catch(err => {
this.showSnack(handleRequestError(err));
});
},
refreshRedactionState() {
const id = this.$route.params.id || this.actor?.id;
if (id) {
this.showActor(id);
} else if (this.editedItem?.id) {
api.get(`/admin/api/actor/${this.editedItem.id}`).then(response => {
this.editedItem.medias = response.data?.medias;
}).catch(err => this.showSnack(handleRequestError(err)));
}
},
onRedactionSaved() {
this.showSnack("{{ _('Redaction saved') }}");
this.refreshRedactionState();
},
onRedactionDeleted() {
this.showSnack("{{ _('Redaction deleted') }}");
this.refreshRedactionState();
},
handleRoute({ initialLoad = false } = {}) {
if (this.$route.params.id) this.showActor(this.$route.params.id);
if (this.$route.query.reltob) return this.filter_related_to_bulletin(this.$route.query.reltob);
Expand Down Expand Up @@ -1816,6 +1854,7 @@
app.component('FieldRenderer', FieldRenderer);
app.component('OcrTextLayer', OcrTextLayer);
app.component('MediaTranscriptionDialog', MediaTranscriptionDialog);
app.component('MediaRedactor', MediaRedactor);

app.component('RelatedBulletinsCard', RelatedBulletinsCard);
app.component('RelatedActorsCard', RelatedActorsCard);
Expand Down
61 changes: 41 additions & 20 deletions enferno/admin/templates/admin/bulletins.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@


<v-main>
<media-redactor v-model="redaction.dialog" :media="redaction.media" @redacted="onRedactionSaved"></media-redactor>
{% include 'admin/partials/media_transcription_dialog.html' %}
{% include 'admin/partials/bulletin_advsearch.html' %}
{% include 'admin/partials/bulletin_drawer.html' %}
Expand Down Expand Up @@ -423,6 +424,7 @@
<script src="/static/js/components/GeoLocations.js"></script>
<script src="/static/js/components/MediaGrid.js"></script>
<script src="/static/js/components/MediaCard.js"></script>
<script src="/static/js/components/MediaRedactor.js"></script>
<script src="/static/js/components/MediaThumbnail.js"></script>
<script src="/static/js/components/PreviewCard.js"></script>
<script src="/static/js/components/RelationEditorCard.js"></script>
Expand Down Expand Up @@ -478,6 +480,7 @@
data: () => ({
itemsPerPageOptions: window.itemsPerPageOptions,
helper: {}, // helper object for review items
redaction: { dialog: false, media: null },

valid: false,
leftDialogProps: {
Expand Down Expand Up @@ -916,6 +919,43 @@
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;
},
removeRedaction(redaction) {
if (!redaction.isRedaction) {
this.showSnack(window.translations.onlyRedactionsCanBeDeleted_);
return;
}
api.delete(`/admin/api/media/${redaction.id}/redact`)
.then(() => {
this.onRedactionDeleted(redaction);
})
.catch(err => {
this.showSnack(handleRequestError(err));
});
},
refreshRedactionState() {
const id = this.$route.params.id || this.bulletin?.id;
if (id) {
// Media redacted on bulletin drawer, refresh the bulletin to get the updated media list
this.showBulletin(id);
} else if (this.editedItem?.id) {
// Media redacted on bulletin editor dialog, refresh the bulletin to get the updated media list
api.get(`/admin/api/bulletin/${this.editedItem.id}`).then(response => {
this.editedItem.medias = response.data?.medias;
}).catch(err => this.showSnack(handleRequestError(err)));
}
},
onRedactionSaved() {
this.showSnack("{{ _('Redaction saved') }}");
this.refreshRedactionState();
},
onRedactionDeleted() {
this.showSnack("{{ _('Redaction deleted') }}");
this.refreshRedactionState();
},
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);
Expand Down Expand Up @@ -1346,29 +1386,9 @@

showBulletin(id) {
this.bulletinLoader = true

// Save current s3urls before reload
const s3urlCache = {};
if (this.bulletin?.medias) {
this.bulletin.medias.forEach(media => {
if (media.s3url) {
s3urlCache[media.id] = media.s3url;
}
});
}

api.get(`/admin/api/bulletin/${id}?mode=3`).then(response => {
this.bulletin = response.data;
this.bulletinDrawer = true;

// Restore s3urls after reload
if (this.bulletin?.medias) {
this.bulletin.medias.forEach(media => {
if (s3urlCache[media.id]) {
media.s3url = s3urlCache[media.id];
}
});
}
}).catch(error => {
this.bulletinDrawer = false;
}).finally(() => {
Expand Down Expand Up @@ -1817,6 +1837,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);
Expand Down
6 changes: 5 additions & 1 deletion enferno/admin/templates/admin/jsapi.jinja2
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,11 @@ etag_ : "{{ _('File Hash') }}",
filename_ : "{{ _('Filename') }}",
previewNotAvailable_ : "{{ _('Preview not available') }}",
downloadFile_ : "{{ _('Download file') }}",
onlyRedactionsCanBeDeleted_: "{{ _('Only redactions can be deleted') }}",
redact_: "{{ _('Redact') }}",
youreAboutToDeleteARedactedCopy_: "{{ _('You\'re about to delete a redacted copy') }}",
deletingTheRedactedCopyWillNotDeleteTheOriginalMedia_: "{{ _('Deleting this redacted copy will not delete the original media.') }}",
deleteRedaction_: "{{ _('Delete Redaction') }}",

// System Administration
defaultsLoaded_: "{{ _('Default settings loaded. Click Save to apply changes.') }}",
Expand Down Expand Up @@ -843,4 +848,3 @@ translations['whisperModels'] = [
{"en": "{{ model['model_label'] }}", "tr": "{{ _(model['model_label']) }}"},
{% endfor %}
];

Loading
Loading