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] 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