From bb116dcdd38886307eaf0922d99a0e7cc95d7475 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 22 Mar 2026 15:28:24 +0000
Subject: [PATCH 1/2] Initial plan
From 9458055661e5458256b51cfe1965b0607d6a478e Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 22 Mar 2026 15:46:30 +0000
Subject: [PATCH 2/2] feat(ui): show file owner, add claim ownership on file
summary, detail, and annotations pages
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/0ce1d83e-500c-473f-bbb6-ccf510ba953a
---
app/views/files.py | 56 +++++++---
frontend/static/js/claim.js | 44 ++++++++
frontend/templates/file_annotations.html | 29 ++++++
frontend/templates/file_summary.html | 30 ++++++
frontend/templates/file_view.html | 27 +++++
frontend/translations/en.json | 6 ++
tests/test_views_files_comprehensive.py | 126 +++++++++++++++++++++++
7 files changed, 306 insertions(+), 12 deletions(-)
create mode 100644 frontend/static/js/claim.js
diff --git a/app/views/files.py b/app/views/files.py
index 5281618e..3d44efcc 100644
--- a/app/views/files.py
+++ b/app/views/files.py
@@ -19,6 +19,43 @@ router = APIRouter()
_FILE_NOT_FOUND = "File not found"
+def _resolve_owner_context(request: Request, file_record, db: Session) -> dict:
+ """Return owner display info and the current user's effective role.
+
+ Returns a dict with:
+ - ``current_user_role``: one of "owner" / "editor" / "viewer" / None
+ - ``owner_display``: human-readable owner string (display_name or user_id)
+ - ``multi_user_enabled``: whether multi-user mode is active
+ """
+ from app.config import settings
+ from app.models import UserProfile
+ from app.utils.user_scope import get_current_owner_id, get_file_role
+
+ multi_user_enabled = settings.multi_user_enabled
+
+ current_owner_id = get_current_owner_id(request)
+ user_session = request.session.get("user")
+ is_admin = isinstance(user_session, dict) and bool(user_session.get("is_admin"))
+
+ if is_admin:
+ current_user_role: str | None = "owner"
+ else:
+ current_user_role = get_file_role(file_record, current_owner_id, db)
+
+ # Build a human-readable owner label
+ if file_record.owner_id:
+ profile = db.query(UserProfile).filter(UserProfile.user_id == file_record.owner_id).first()
+ owner_display: str | None = profile.display_name if profile and profile.display_name else file_record.owner_id
+ else:
+ owner_display = None # No owner (unowned)
+
+ return {
+ "current_user_role": current_user_role,
+ "owner_display": owner_display,
+ "multi_user_enabled": multi_user_enabled,
+ }
+
+
@router.get("/files")
@require_login
def files_page(
@@ -268,6 +305,7 @@ def file_summary_page(request: Request, file_id: int, db: Session = Depends(get_
step_summary = None
pipeline_info = _resolve_pipeline(db, file_record)
+ owner_ctx = _resolve_owner_context(request, file_record, db)
return templates.TemplateResponse(
"file_summary.html",
@@ -279,6 +317,7 @@ def file_summary_page(request: Request, file_id: int, db: Session = Depends(get_
"processed_file_exists": processed_file_exists,
"step_summary": step_summary,
"pipeline_info": pipeline_info,
+ **owner_ctx,
},
)
except Exception as e:
@@ -353,6 +392,7 @@ def file_view_page(request: Request, file_id: int, db: Session = Depends(get_db)
# Resolve the pipeline assigned to this file (explicit or system default)
pipeline_info = _resolve_pipeline(db, file_record)
+ owner_ctx = _resolve_owner_context(request, file_record, db)
return templates.TemplateResponse(
"file_view.html",
@@ -364,6 +404,7 @@ def file_view_page(request: Request, file_id: int, db: Session = Depends(get_db)
"processed_file_exists": processed_file_exists,
"step_summary": step_summary,
"pipeline_info": pipeline_info,
+ **owner_ctx,
},
)
except Exception as e:
@@ -497,17 +538,8 @@ def file_annotations_page(request: Request, file_id: int, db: Session = Depends(
mime = file_record.mime_type or ""
is_pdf = mime == "application/pdf" or (file_record.original_filename or "").lower().endswith(".pdf")
- # Determine the current user's role on this file
- from app.utils.user_scope import get_current_owner_id, get_file_role
-
- current_owner_id = get_current_owner_id(request)
- user_session = request.session.get("user")
- is_admin = isinstance(user_session, dict) and bool(user_session.get("is_admin"))
- if is_admin:
- current_user_role: str | None = "owner"
- else:
- current_user_role = get_file_role(file_record, current_owner_id, db)
- # None means no access — the template will not show owner-only UI
+ # Determine the current user's role on this file (and owner display info)
+ owner_ctx = _resolve_owner_context(request, file_record, db)
return templates.TemplateResponse(
"file_annotations.html",
@@ -517,7 +549,7 @@ def file_annotations_page(request: Request, file_id: int, db: Session = Depends(
"original_file_exists": original_file_exists,
"processed_file_exists": processed_file_exists,
"is_pdf": is_pdf,
- "current_user_role": current_user_role,
+ **owner_ctx,
},
)
except Exception as e:
diff --git a/frontend/static/js/claim.js b/frontend/static/js/claim.js
new file mode 100644
index 00000000..115265bd
--- /dev/null
+++ b/frontend/static/js/claim.js
@@ -0,0 +1,44 @@
+/**
+ * claim.js — Claim-ownership UI helper for unowned documents.
+ *
+ * Usage: call initClaimOwnership(fileId, i18n) after DOMContentLoaded.
+ * The i18n object must contain:
+ * confirm, success, failed
+ */
+function initClaimOwnership(fileId, i18n) {
+ var btn = document.getElementById('claim-btn');
+ var msg = document.getElementById('claim-msg');
+ if (!btn) return;
+
+ btn.addEventListener('click', function () {
+ if (!confirm(i18n.confirm)) return;
+ btn.disabled = true;
+ fetch('/api/files/' + fileId + '/claim', { method: 'POST' })
+ .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, data: d }; }); })
+ .then(function (result) {
+ if (result.ok || (result.data && result.data.status === 'already_owned')) {
+ if (msg) {
+ msg.textContent = i18n.success;
+ msg.style.color = '#059669';
+ msg.style.display = msg.tagName === 'SPAN' ? 'inline' : 'block';
+ }
+ setTimeout(function () { location.reload(); }, 1200);
+ } else {
+ if (msg) {
+ msg.textContent = (result.data && result.data.detail) || i18n.failed;
+ msg.style.color = '#dc2626';
+ msg.style.display = msg.tagName === 'SPAN' ? 'inline' : 'block';
+ }
+ btn.disabled = false;
+ }
+ })
+ .catch(function () {
+ if (msg) {
+ msg.textContent = i18n.failed;
+ msg.style.color = '#dc2626';
+ msg.style.display = msg.tagName === 'SPAN' ? 'inline' : 'block';
+ }
+ btn.disabled = false;
+ });
+ });
+}
diff --git a/frontend/templates/file_annotations.html b/frontend/templates/file_annotations.html
index 63cb4c3f..8c4ec528 100644
--- a/frontend/templates/file_annotations.html
+++ b/frontend/templates/file_annotations.html
@@ -586,6 +586,23 @@
Comments & Annotations
{{ file.original_filename }}
+ {% if multi_user_enabled %}
+
+
+ {{ _("file.owner_label") }}: {{ owner_display or _("file.owner_unowned") }}
+ {% if file.owner_id is none %}
+ —
+
+
+ {% endif %}
+
+ {% endif %}
@@ -912,5 +929,17 @@
}
{% endif %}
+{% if multi_user_enabled and file and file.owner_id is none %}
+
+
+{% endif %}
{% endblock %}
diff --git a/frontend/templates/file_summary.html b/frontend/templates/file_summary.html
index b76489f2..ef91e2ae 100644
--- a/frontend/templates/file_summary.html
+++ b/frontend/templates/file_summary.html
@@ -165,6 +165,12 @@
{% if file.document_title %}
Document Title{{ file.document_title }}
{% endif %}
+ {% if multi_user_enabled %}
+
+ {{ _("file.owner_label") }}
+ {{ owner_display or _("file.owner_unowned") }}
+
+ {% endif %}
@@ -184,7 +190,18 @@
View Detail
+ {% if multi_user_enabled and file.owner_id is none %}
+
+ {% endif %}
+
{% else %}
@@ -193,4 +210,17 @@
{% endif %}
+
+{% if multi_user_enabled and file and file.owner_id is none %}
+
+
+{% endif %}
{% endblock %}
diff --git a/frontend/templates/file_view.html b/frontend/templates/file_view.html
index efba524a..2fad87e6 100644
--- a/frontend/templates/file_view.html
+++ b/frontend/templates/file_view.html
@@ -316,6 +316,12 @@
{% endif %}
+ {% if multi_user_enabled %}
+
+ {{ _("file.owner_label") }}
+ {{ owner_display or _("file.owner_unowned") }}
+
+ {% endif %}
@@ -344,7 +350,18 @@
Share
+ {% if multi_user_enabled and file.owner_id is none %}
+
+ {% endif %}
+
@@ -937,6 +954,16 @@
pdfInit('/api/files/{{ file.id }}/preview?version={{ pv }}');
{% endif %}
{% endif %}
+ {% if multi_user_enabled and file and file.owner_id is none %}
+ initClaimOwnership({{ file.id | tojson }}, {
+ confirm: {{ _("file.claim_ownership_confirm") | tojson }},
+ success: {{ _("file.claim_ownership_success") | tojson }},
+ failed: {{ _("file.claim_ownership_failed") | tojson }}
+ });
+ {% endif %}
});
+{% if multi_user_enabled and file and file.owner_id is none %}
+
+{% endif %}
{% endblock %}
diff --git a/frontend/translations/en.json b/frontend/translations/en.json
index fbdc7939..ca1f3125 100644
--- a/frontend/translations/en.json
+++ b/frontend/translations/en.json
@@ -1757,6 +1757,12 @@
"sharing.role_viewer": "Viewer",
"sharing.user_id_label": "User ID or email",
"sharing.user_id_placeholder": "e.g. alice@example.com",
+ "file.owner_label": "Owner",
+ "file.owner_unowned": "Unowned",
+ "file.claim_ownership": "Claim Ownership",
+ "file.claim_ownership_confirm": "Claim this document as yours? You will become the owner and can manage sharing.",
+ "file.claim_ownership_success": "You are now the owner of this document.",
+ "file.claim_ownership_failed": "Could not claim ownership. The document may already have an owner.",
"similarity.backfill_auto": "The background task will compute them automatically every 5 minutes, or you can",
"similarity.files_missing_text": "file(s) have OCR text but no embedding yet.",
"similarity.find_pairs_btn": "Find Pairs",
diff --git a/tests/test_views_files_comprehensive.py b/tests/test_views_files_comprehensive.py
index c36c62ee..acaa8acb 100644
--- a/tests/test_views_files_comprehensive.py
+++ b/tests/test_views_files_comprehensive.py
@@ -6,6 +6,7 @@ Target: Bring coverage from 8.77% to 70%+
"""
import json
+import uuid
from datetime import datetime, timedelta
from unittest.mock import Mock, patch
@@ -1981,3 +1982,128 @@ class TestPipelineInfoInViews:
assert response.status_code == 200
assert b"Standard" in response.content
+
+
+# ---------------------------------------------------------------------------
+# Owner display and claim ownership tests
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestOwnerDisplayAndClaim:
+ """Tests that owner info and claim button appear correctly on file views."""
+
+ def _make_file(self, db_session, owner_id=None) -> FileRecord:
+ file_rec = FileRecord(
+ filehash=uuid.uuid4().hex,
+ original_filename="doc.pdf",
+ local_filename="/tmp/doc.pdf",
+ file_size=512,
+ mime_type="application/pdf",
+ owner_id=owner_id,
+ )
+ db_session.add(file_rec)
+ db_session.commit()
+ db_session.refresh(file_rec)
+ return file_rec
+
+ # ── /files/{id} (file_summary.html) ──────────────────────────────────
+
+ def test_summary_shows_owner_when_multi_user_enabled(self, client, db_session):
+ """Owner ID is rendered in file summary when multi-user mode is on."""
+ file_rec = self._make_file(db_session, owner_id="alice@example.com")
+ with patch("app.config.settings.multi_user_enabled", True):
+ response = client.get(f"/files/{file_rec.id}")
+ assert response.status_code == 200
+ assert b"alice@example.com" in response.content
+
+ def test_summary_shows_unowned_label_for_unowned_file(self, client, db_session):
+ """'Unowned' label is rendered in file summary for files without an owner."""
+ file_rec = self._make_file(db_session, owner_id=None)
+ with patch("app.config.settings.multi_user_enabled", True):
+ response = client.get(f"/files/{file_rec.id}")
+ assert response.status_code == 200
+ assert b"Unowned" in response.content
+
+ def test_summary_shows_claim_button_for_unowned_file(self, client, db_session):
+ """Claim Ownership button appears on file summary for an unowned file."""
+ file_rec = self._make_file(db_session, owner_id=None)
+ with patch("app.config.settings.multi_user_enabled", True):
+ response = client.get(f"/files/{file_rec.id}")
+ assert response.status_code == 200
+ assert b"Claim Ownership" in response.content
+
+ def test_summary_no_claim_button_when_owned(self, client, db_session):
+ """No Claim Ownership button when the file already has an owner."""
+ file_rec = self._make_file(db_session, owner_id="bob@example.com")
+ with patch("app.config.settings.multi_user_enabled", True):
+ response = client.get(f"/files/{file_rec.id}")
+ assert response.status_code == 200
+ assert b"Claim Ownership" not in response.content
+
+ def test_summary_no_owner_row_in_single_user_mode(self, client, db_session):
+ """Owner row is hidden in single-user mode."""
+ file_rec = self._make_file(db_session, owner_id=None)
+ with patch("app.config.settings.multi_user_enabled", False):
+ response = client.get(f"/files/{file_rec.id}")
+ assert response.status_code == 200
+ # Claim button and Unowned label should not appear in single-user mode
+ assert b"Claim Ownership" not in response.content
+
+ # ── /files/{id}/detail (file_view.html) ──────────────────────────────
+
+ def test_detail_shows_owner_when_multi_user_enabled(self, client, db_session):
+ """Owner ID is rendered in file detail view when multi-user mode is on."""
+ file_rec = self._make_file(db_session, owner_id="charlie@example.com")
+ with patch("app.config.settings.multi_user_enabled", True):
+ response = client.get(f"/files/{file_rec.id}/detail")
+ assert response.status_code == 200
+ assert b"charlie@example.com" in response.content
+
+ def test_detail_shows_claim_button_for_unowned_file(self, client, db_session):
+ """Claim Ownership button appears in file detail view for an unowned file."""
+ file_rec = self._make_file(db_session, owner_id=None)
+ with patch("app.config.settings.multi_user_enabled", True):
+ response = client.get(f"/files/{file_rec.id}/detail")
+ assert response.status_code == 200
+ assert b"Claim Ownership" in response.content
+
+ # ── /files/{id}/annotations (file_annotations.html) ──────────────────
+
+ def test_annotations_shows_owner_info(self, client, db_session):
+ """Owner info is rendered on the annotations page in multi-user mode."""
+ file_rec = self._make_file(db_session, owner_id="dave@example.com")
+ with patch("app.config.settings.multi_user_enabled", True):
+ response = client.get(f"/files/{file_rec.id}/annotations")
+ assert response.status_code == 200
+ assert b"dave@example.com" in response.content
+
+ def test_annotations_shows_claim_button_for_unowned_file(self, client, db_session):
+ """Claim Ownership button appears on annotations page for unowned file."""
+ file_rec = self._make_file(db_session, owner_id=None)
+ with patch("app.config.settings.multi_user_enabled", True):
+ response = client.get(f"/files/{file_rec.id}/annotations")
+ assert response.status_code == 200
+ assert b"Claim Ownership" in response.content
+
+ def test_annotations_no_claim_button_when_owned(self, client, db_session):
+ """No Claim Ownership button on annotations page when file has an owner."""
+ file_rec = self._make_file(db_session, owner_id="eve@example.com")
+ with patch("app.config.settings.multi_user_enabled", True):
+ response = client.get(f"/files/{file_rec.id}/annotations")
+ assert response.status_code == 200
+ assert b"Claim Ownership" not in response.content
+
+ def test_display_name_used_when_profile_exists(self, client, db_session):
+ """UserProfile.display_name overrides raw user_id in the owner display."""
+ from app.models import UserProfile
+
+ file_rec = self._make_file(db_session, owner_id="frank@example.com")
+ profile = UserProfile(user_id="frank@example.com", display_name="Frank Lastname")
+ db_session.add(profile)
+ db_session.commit()
+
+ with patch("app.config.settings.multi_user_enabled", True):
+ response = client.get(f"/files/{file_rec.id}")
+ assert response.status_code == 200
+ assert b"Frank Lastname" in response.content