]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Feature: document history (audit log UI) (#6388)
authorshamoon <4887959+shamoon@users.noreply.github.com>
Tue, 23 Apr 2024 15:16:28 +0000 (08:16 -0700)
committerGitHub <noreply@github.com>
Tue, 23 Apr 2024 15:16:28 +0000 (15:16 +0000)
29 files changed:
Pipfile.lock
docs/api.md
docs/usage.md
src-ui/messages.xlf
src-ui/src/app/app.module.ts
src-ui/src/app/components/common/permissions-select/permissions-select.component.html
src-ui/src/app/components/common/permissions-select/permissions-select.component.spec.ts
src-ui/src/app/components/common/permissions-select/permissions-select.component.ts
src-ui/src/app/components/document-detail/document-detail.component.html
src-ui/src/app/components/document-detail/document-detail.component.ts
src-ui/src/app/components/document-history/document-history.component.html [new file with mode: 0644]
src-ui/src/app/components/document-history/document-history.component.scss [new file with mode: 0644]
src-ui/src/app/components/document-history/document-history.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/document-history/document-history.component.ts [new file with mode: 0644]
src-ui/src/app/data/auditlog-entry.ts [new file with mode: 0644]
src-ui/src/app/data/ui-settings.ts
src-ui/src/app/pipes/custom-date.pipe.spec.ts
src-ui/src/app/pipes/custom-date.pipe.ts
src-ui/src/app/services/permissions.service.ts
src-ui/src/app/services/rest/document.service.spec.ts
src-ui/src/app/services/rest/document.service.ts
src-ui/src/app/services/settings.service.spec.ts
src/documents/models.py
src/documents/serialisers.py
src/documents/tests/test_api_documents.py
src/documents/tests/test_api_uisettings.py
src/documents/tests/test_file_handling.py
src/documents/views.py
src/locale/en_US/LC_MESSAGES/django.po

index da4a08ea0c8f01054dc179e2f2ec90e3d5cec036..bd641aa43b43a27f07b519f2f5b8d62e20dd2124 100644 (file)
         },
         "django-auditlog": {
             "hashes": [
-                "sha256:7bc2c87e4aff62dec9785d1b2359a2b27148f8c286f8a52b9114fc7876c5a9f7",
-                "sha256:b9d3acebb64f3f2785157efe3f2f802e0929aafc579d85bbfb9827db4adab532"
+                "sha256:92db1cf4a51ceca5c26b3ff46997d9e3305a02da1bd435e2efb5b8b6d300ce1f",
+                "sha256:9de49f80a4911135d136017123cd73461f869b4947eec14d5e76db4b88182f3f"
             ],
             "index": "pypi",
-            "markers": "python_version >= '3.7'",
-            "version": "==2.3.0"
+            "markers": "python_version >= '3.8'",
+            "version": "==3.0.0"
         },
         "django-celery-results": {
             "hashes": [
index 0e98b210d850ba8370db502063c6efc05bb0d61c..6a275be619a7734db25d5cbe0a75cdfb23f7b66b 100644 (file)
@@ -140,6 +140,7 @@ document. Paperless only reports PDF metadata at this point.
 
 - `/api/documents/<id>/notes/`: Retrieve notes for a document.
 - `/api/documents/<id>/share_links/`: Retrieve share links for a document.
+- `/api/documents/<id>/history/`: Retrieve history of changes for a document.
 
 ## Authorization
 
index 7cedb976a998ae64bccf90e92ddadcf6fd7cd6d0..c9003d35d2bf5ccbfc49588cc408f8997a9e7274 100644 (file)
@@ -472,6 +472,12 @@ Paperless-ngx supports 3 basic editing operations for PDFs (these operations can
 
     Note that rotation alters the Paperless-ngx _original_ file, which would, for example, invalidate a digital signature.
 
+## Document History
+
+As of version 2.7, Paperless-ngx automatically records all changes to a document and records this in an audit log. The feature requires [`PAPERLESS_AUDIT_LOG_ENABLED`](configuration.md#PAPERLESS_AUDIT_LOG_ENABLED) be enabled, which it is by default as of version 2.7.
+Changes to documents are visible under the "History" tab. Note that certain changes such as those made by workflows, record the 'actor'
+as "System".
+
 ## Best practices {#basic-searching}
 
 Paperless offers a couple tools that help you organize your document
index 6ec2b3815cce2ecabc0f293b022d986ab897612d..eaa2d7bfef9a90f77ba266807cc19f4bb1cdb925 100644 (file)
           <context context-type="sourcefile">src/app/components/common/permissions-select/permissions-select.component.html</context>
           <context context-type="linenumber">22</context>
         </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-history/document-history.component.html</context>
+          <context context-type="linenumber">35</context>
+        </context-group>
       </trans-unit>
       <trans-unit id="7991430199894172363" datatype="html">
         <source>Read the documentation about this setting</source>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">322</context>
+          <context context-type="linenumber">333</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3768927257183755959" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">314</context>
+          <context context-type="linenumber">325</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.html</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">331</context>
+          <context context-type="linenumber">342</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/rest/document.service.ts</context>
-          <context context-type="linenumber">32</context>
+          <context context-type="linenumber">33</context>
         </context-group>
       </trans-unit>
       <trans-unit id="293524471897878391" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">290</context>
+          <context context-type="linenumber">301</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/rest/document.service.ts</context>
-          <context context-type="linenumber">29</context>
+          <context context-type="linenumber">30</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5968132631442328843" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">768</context>
+          <context context-type="linenumber">769</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">770</context>
+          <context context-type="linenumber">771</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">1052</context>
+          <context context-type="linenumber">1064</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">1090</context>
+          <context context-type="linenumber">1102</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
         <source>Inherited from group</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/permissions-select/permissions-select.component.ts</context>
-          <context context-type="linenumber">61</context>
+          <context context-type="linenumber">63</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6418218602775540217" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/rest/document.service.ts</context>
-          <context context-type="linenumber">27</context>
+          <context context-type="linenumber">28</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2691296884221415710" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/rest/document.service.ts</context>
-          <context context-type="linenumber">26</context>
+          <context context-type="linenumber">27</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8911158217491828773" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">1108</context>
+          <context context-type="linenumber">1120</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/guards/dirty-saved-view.guard.ts</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/rest/document.service.ts</context>
-          <context context-type="linenumber">28</context>
+          <context context-type="linenumber">29</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2091353339965748767" datatype="html">
           <context context-type="linenumber">279,282</context>
         </context-group>
       </trans-unit>
+      <trans-unit id="186236568870281953" datatype="html">
+        <source>History</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
+          <context context-type="linenumber">290</context>
+        </context-group>
+      </trans-unit>
       <trans-unit id="5129524307369213584" datatype="html">
         <source>Save &amp; next</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">316</context>
+          <context context-type="linenumber">327</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4910102545766233758" datatype="html">
         <source>Save &amp; close</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">319</context>
+          <context context-type="linenumber">330</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8191371354890763172" datatype="html">
         <source>Enter Password</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">370</context>
+          <context context-type="linenumber">381</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2218903673684131427" datatype="html">
         <source>An error occurred loading content: <x id="PH" equiv-text="err.message ?? err.toString()"/></source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">327,329</context>
+          <context context-type="linenumber">328,330</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3200733026060976258" datatype="html">
         <source>Document changes detected</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">350</context>
+          <context context-type="linenumber">351</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2887155916749964" datatype="html">
         <source>The version of this document in your browser session appears older than the existing version.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">351</context>
+          <context context-type="linenumber">352</context>
         </context-group>
       </trans-unit>
       <trans-unit id="237142428785956348" datatype="html">
         <source>Saving the document here may overwrite other changes that were made. To restore the existing version, discard your changes or close the document.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">352</context>
+          <context context-type="linenumber">353</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8720977247725652816" datatype="html">
         <source>Ok</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">354</context>
+          <context context-type="linenumber">355</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5758784066858623886" datatype="html">
         <source>Error retrieving metadata</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">494</context>
+          <context context-type="linenumber">495</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3456881259945295697" datatype="html">
         <source>Error retrieving suggestions.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">519</context>
+          <context context-type="linenumber">520</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8348337312757497317" datatype="html">
         <source>Document saved successfully.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">640</context>
+          <context context-type="linenumber">641</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">651</context>
+          <context context-type="linenumber">652</context>
         </context-group>
       </trans-unit>
       <trans-unit id="448882439049417053" datatype="html">
         <source>Error saving document</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">655</context>
+          <context context-type="linenumber">656</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">696</context>
+          <context context-type="linenumber">697</context>
         </context-group>
       </trans-unit>
       <trans-unit id="9021887951960049161" datatype="html">
         <source>Confirm delete</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">723</context>
+          <context context-type="linenumber">724</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
         <source>Do you really want to delete document &quot;<x id="PH" equiv-text="this.document.title"/>&quot;?</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">724</context>
+          <context context-type="linenumber">725</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6691075929777935948" datatype="html">
         <source>The files for this document will be deleted permanently. This operation cannot be undone.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">725</context>
+          <context context-type="linenumber">726</context>
         </context-group>
       </trans-unit>
       <trans-unit id="719892092227206532" datatype="html">
         <source>Delete document</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">727</context>
+          <context context-type="linenumber">728</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7295637485862454066" datatype="html">
         <source>Error deleting document</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">746</context>
+          <context context-type="linenumber">747</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7362691899087997122" datatype="html">
         <source>Redo OCR confirm</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">766</context>
+          <context context-type="linenumber">767</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
         <source>This operation will permanently redo OCR for this document.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">767</context>
+          <context context-type="linenumber">768</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5729001209753056399" datatype="html">
         <source>Redo OCR operation will begin in the background. Close and re-open or reload this document after the operation has completed to see new content.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">778</context>
+          <context context-type="linenumber">779</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4409560272830824468" datatype="html">
         <source>Error executing operation</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">789</context>
+          <context context-type="linenumber">790</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4458954481601077369" datatype="html">
         <source>Page Fit</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">858</context>
+          <context context-type="linenumber">859</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1217563727923422413" datatype="html">
         <source>Split confirm</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">1050</context>
+          <context context-type="linenumber">1062</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2805304563009985503" datatype="html">
         <source>This operation will split the selected document(s) into new documents.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">1051</context>
+          <context context-type="linenumber">1063</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4158171846914923744" datatype="html">
         <source>Split operation will begin in the background.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">1066</context>
+          <context context-type="linenumber">1078</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3235014591864339926" datatype="html">
         <source>Error executing split operation</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">1075</context>
+          <context context-type="linenumber">1087</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6555329262222566158" datatype="html">
         <source>Rotate confirm</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">1087</context>
+          <context context-type="linenumber">1099</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
         <source>This operation will permanently rotate the original version of the current document.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">1088</context>
+          <context context-type="linenumber">1100</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4233432423256408453" datatype="html">
         <source>This will alter the original copy.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">1089</context>
+          <context context-type="linenumber">1101</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
         <source>Rotation will begin in the background. Close and re-open the document after the operation has completed to see the changes.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">1105</context>
+          <context context-type="linenumber">1117</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2962674215361798818" datatype="html">
         <source>Error executing rotate operation</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">1117</context>
+          <context context-type="linenumber">1129</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="4958946940233632319" datatype="html">
+        <source>No entries found.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-history/document-history.component.html</context>
+          <context context-type="linenumber">10</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6857598786757174736" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/rest/document.service.ts</context>
-          <context context-type="linenumber">25</context>
+          <context context-type="linenumber">26</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6954625430271090777" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/rest/document.service.ts</context>
-          <context context-type="linenumber">33</context>
+          <context context-type="linenumber">34</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3557446856808034218" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/rest/document.service.ts</context>
-          <context context-type="linenumber">30</context>
+          <context context-type="linenumber">31</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2179847500064178686" datatype="html">
           <context context-type="linenumber">36</context>
         </context-group>
       </trans-unit>
+      <trans-unit id="4272436583644511364" datatype="html">
+        <source>Just now</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/pipes/custom-date.pipe.ts</context>
+          <context context-type="linenumber">39</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="8456127468852940807" datatype="html">
+        <source>year ago</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/pipes/custom-date.pipe.ts</context>
+          <context context-type="linenumber">42</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="963494111451627204" datatype="html">
+        <source>years ago</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/pipes/custom-date.pipe.ts</context>
+          <context context-type="linenumber">43</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="1919405338795657780" datatype="html">
+        <source>month ago</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/pipes/custom-date.pipe.ts</context>
+          <context context-type="linenumber">47</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="6041340836190906216" datatype="html">
+        <source>months ago</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/pipes/custom-date.pipe.ts</context>
+          <context context-type="linenumber">48</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="4072659649620334828" datatype="html">
+        <source>week ago</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/pipes/custom-date.pipe.ts</context>
+          <context context-type="linenumber">52</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="2871318661796659216" datatype="html">
+        <source>weeks ago</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/pipes/custom-date.pipe.ts</context>
+          <context context-type="linenumber">53</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="1328378419272652134" datatype="html">
+        <source>day ago</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/pipes/custom-date.pipe.ts</context>
+          <context context-type="linenumber">57</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="5620397708418210833" datatype="html">
+        <source>days ago</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/pipes/custom-date.pipe.ts</context>
+          <context context-type="linenumber">58</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="4259498317457105735" datatype="html">
+        <source>hour ago</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/pipes/custom-date.pipe.ts</context>
+          <context context-type="linenumber">62</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="7576594819545407052" datatype="html">
+        <source>hours ago</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/pipes/custom-date.pipe.ts</context>
+          <context context-type="linenumber">63</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="4063456036422970205" datatype="html">
+        <source>minute ago</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/pipes/custom-date.pipe.ts</context>
+          <context context-type="linenumber">67</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="6906829094715901970" datatype="html">
+        <source>minutes ago</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/pipes/custom-date.pipe.ts</context>
+          <context context-type="linenumber">68</context>
+        </context-group>
+      </trans-unit>
       <trans-unit id="7536524521722799066" datatype="html">
         <source>(no title)</source>
         <context-group purpose="location">
         <source>Modified</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/rest/document.service.ts</context>
-          <context context-type="linenumber">31</context>
+          <context context-type="linenumber">32</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4460262093225954455" datatype="html">
         <source>Search score</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/rest/document.service.ts</context>
-          <context context-type="linenumber">40</context>
+          <context context-type="linenumber">41</context>
         </context-group>
         <note priority="1" from="description">Score is a value returned by the full text search engine and specifies how well a result matches the given query</note>
       </trans-unit>
index f990122dda111055456d42dcc6c8e133aa2feb8c..d7263de82f59867b3dd6d75be61bfd1eb56fc719 100644 (file)
@@ -119,6 +119,7 @@ import { NgxFilesizeModule } from 'ngx-filesize'
 import { RotateConfirmDialogComponent } from './components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
 import { MergeConfirmDialogComponent } from './components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
 import { SplitConfirmDialogComponent } from './components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component'
+import { DocumentHistoryComponent } from './components/document-history/document-history.component'
 import {
   airplane,
   archive,
@@ -472,6 +473,7 @@ function initializeApp(settings: SettingsService) {
     RotateConfirmDialogComponent,
     MergeConfirmDialogComponent,
     SplitConfirmDialogComponent,
+    DocumentHistoryComponent,
   ],
   imports: [
     BrowserModule,
index a2aa4a5c0cf3fd9590b04746a216262cd7630bbe..049d0e7769e9cbf5d313b9d573f18a5a8fbefc14 100644 (file)
@@ -9,17 +9,17 @@
       <div class="col" i18n>Delete</div>
       <div class="col" i18n>View</div>
     </li>
-    @for (type of PermissionType | keyvalue; track type) {
-      <li class="list-group-item d-flex" [formGroupName]="type.key">
-        <div class="col-3">{{type.key}}:</div>
-        <div class="col form-check form-check-inline form-switch" [ngbPopover]="inheritedWarning" [disablePopover]="!isInherited(type.key)" placement="left" triggers="mouseenter:mouseleave">
-          <input type="checkbox" class="form-check-input" id="{{type.key}}_all" (change)="toggleAll($event, type.key)" [checked]="typesWithAllActions.has(type.key) || isInherited(type.key)" [attr.disabled]="disabled || isInherited(type.key) ? true : null">
-          <label class="form-check-label visually-hidden" for="{{type.key}}_all" i18n>All</label>
+    @for (type of allowedTypes; track type) {
+      <li class="list-group-item d-flex" [formGroupName]="type">
+        <div class="col-3">{{type}}:</div>
+        <div class="col form-check form-check-inline form-switch" [ngbPopover]="inheritedWarning" [disablePopover]="!isInherited(type)" placement="left" triggers="mouseenter:mouseleave">
+          <input type="checkbox" class="form-check-input" id="{{type}}_all" (change)="toggleAll($event, type)" [checked]="typesWithAllActions.has(type) || isInherited(type)" [attr.disabled]="disabled || isInherited(type) ? true : null">
+          <label class="form-check-label visually-hidden" for="{{type}}_all" i18n>All</label>
         </div>
         @for (action of PermissionAction | keyvalue; track action) {
-          <div class="col form-check form-check-inline" [ngbPopover]="inheritedWarning" [disablePopover]="!isInherited(type.key, action.key)" placement="left" triggers="mouseenter:mouseleave">
-            <input type="checkbox" class="form-check-input" id="{{type.key}}_{{action.key}}" formControlName="{{action.key}}">
-            <label class="form-check-label visually-hidden" for="{{type.key}}_{{action.key}}" i18n>{{action.key}}</label>
+          <div class="col form-check form-check-inline" [ngbPopover]="inheritedWarning" [disablePopover]="!isInherited(type, action.key)" placement="left" triggers="mouseenter:mouseleave">
+            <input type="checkbox" class="form-check-input" id="{{type}}_{{action.key}}" formControlName="{{action.key}}">
+            <label class="form-check-label visually-hidden" for="{{type}}_{{action.key}}" i18n>{{action.key}}</label>
           </div>
         }
       </li>
index a01630e001d1ceff5b2c1dbd6303962256382638..879a4d9bfed14cb5ab0e5059bbd8e8cec2878d40 100644 (file)
@@ -12,6 +12,9 @@ import {
 } from 'src/app/services/permissions.service'
 import { By } from '@angular/platform-browser'
 import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
+import { SettingsService } from 'src/app/services/settings.service'
+import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
+import { HttpClientTestingModule } from '@angular/common/http/testing'
 
 const permissions = [
   'add_document',
@@ -28,6 +31,7 @@ describe('PermissionsSelectComponent', () => {
   let component: PermissionsSelectComponent
   let fixture: ComponentFixture<PermissionsSelectComponent>
   let permissionsChangeResult: Permissions
+  let settingsService: SettingsService
 
   beforeEach(async () => {
     TestBed.configureTestingModule({
@@ -38,9 +42,11 @@ describe('PermissionsSelectComponent', () => {
         ReactiveFormsModule,
         NgbModule,
         NgxBootstrapIconsModule.pick(allIcons),
+        HttpClientTestingModule,
       ],
     }).compileComponents()
 
+    settingsService = TestBed.inject(SettingsService)
     fixture = TestBed.createComponent(PermissionsSelectComponent)
     fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
     component = fixture.componentInstance
@@ -99,4 +105,11 @@ describe('PermissionsSelectComponent', () => {
     const input2 = fixture.debugElement.query(By.css('input#Tag_Change'))
     expect(input2.nativeElement.disabled).toBeTruthy()
   })
+
+  it('should exclude history permissions if disabled', () => {
+    settingsService.set(SETTINGS_KEYS.AUDITLOG_ENABLED, false)
+    fixture = TestBed.createComponent(PermissionsSelectComponent)
+    component = fixture.componentInstance
+    expect(component.allowedTypes).not.toContain('History')
+  })
 })
index 49d879677ffd6f51e959b96ab8c55b94f7dd3d6e..977eec5ac5da719b97894694407d19ebe36edc0c 100644 (file)
@@ -12,6 +12,8 @@ import {
   PermissionType,
 } from 'src/app/services/permissions.service'
 import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
+import { SettingsService } from 'src/app/services/settings.service'
+import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
 
 @Component({
   providers: [
@@ -60,15 +62,23 @@ export class PermissionsSelectComponent
 
   inheritedWarning: string = $localize`Inherited from group`
 
-  constructor(private readonly permissionsService: PermissionsService) {
+  public allowedTypes = Object.keys(PermissionType)
+
+  constructor(
+    private readonly permissionsService: PermissionsService,
+    private readonly settingsService: SettingsService
+  ) {
     super()
-    for (const type in PermissionType) {
+    if (!this.settingsService.get(SETTINGS_KEYS.AUDITLOG_ENABLED)) {
+      this.allowedTypes.splice(this.allowedTypes.indexOf('History'), 1)
+    }
+    this.allowedTypes.forEach((type) => {
       const control = new FormGroup({})
       for (const action in PermissionAction) {
         control.addControl(action, new FormControl(null))
       }
       this.form.addControl(type, control)
-    }
+    })
   }
 
   writeValue(permissions: string[]): void {
@@ -92,7 +102,7 @@ export class PermissionsSelectComponent
         }
       }
     })
-    Object.keys(PermissionType).forEach((type) => {
+    this.allowedTypes.forEach((type) => {
       if (
         Object.values(this.form.get(type).value).every((val) => val == true)
       ) {
@@ -191,7 +201,7 @@ export class PermissionsSelectComponent
   }
 
   updateDisabledStates() {
-    for (const type in PermissionType) {
+    this.allowedTypes.forEach((type) => {
       const control = this.form.get(type)
       let actionControl: AbstractControl
       for (const action in PermissionAction) {
@@ -200,6 +210,6 @@ export class PermissionsSelectComponent
           ? actionControl.disable()
           : actionControl.enable()
       }
-    }
+    })
   }
 }
index 14b235fb73c65b97b58a9f9f31e8bff8437dfe8f..6e2d47e2b34bc9004c8d98da4bc6a2cb74931e95 100644 (file)
           </li>
         }
 
+        @if (historyEnabled) {
+          <li [ngbNavItem]="DocumentDetailNavIDs.History">
+            <a ngbNavLink i18n>History</a>
+            <ng-template ngbNavContent>
+              <div class="mb-3">
+                <pngx-document-history [documentId]="documentId"></pngx-document-history>
+              </div>
+            </ng-template>
+          </li>
+        }
+
         @if (showPermissions) {
           <li [ngbNavItem]="DocumentDetailNavIDs.Permissions">
             <a ngbNavLink i18n>Permissions</a>
index d8f63faf2459279ccd856a0a7e3fd3d60eab30bc..db0d16f5a549964e441111492930752f5a971a10 100644 (file)
@@ -77,6 +77,7 @@ enum DocumentDetailNavIDs {
   Preview = 4,
   Notes = 5,
   Permissions = 6,
+  History = 7,
 }
 
 enum ContentRenderType {
@@ -902,6 +903,17 @@ export class DocumentDetailComponent
     )
   }
 
+  get historyEnabled(): boolean {
+    return (
+      this.settings.get(SETTINGS_KEYS.AUDITLOG_ENABLED) &&
+      this.userIsOwner &&
+      this.permissionsService.currentUserCan(
+        PermissionAction.View,
+        PermissionType.History
+      )
+    )
+  }
+
   notesUpdated(notes: DocumentNote[]) {
     this.document.notes = notes
     this.openDocumentService.refreshDocument(this.documentId)
diff --git a/src-ui/src/app/components/document-history/document-history.component.html b/src-ui/src/app/components/document-history/document-history.component.html
new file mode 100644 (file)
index 0000000..65ecaff
--- /dev/null
@@ -0,0 +1,59 @@
+@if (loading) {
+    <div class="d-flex">
+        <div class="spinner-border spinner-border-sm fw-normal" role="status"></div>
+    </div>
+} @else {
+    <ul class="list-group">
+        @if (entries.length === 0) {
+            <li class="list-group-item">
+                <div class="d-flex justify-content-center">
+                    <span class="fst-italic" i18n>No entries found.</span>
+                </div>
+            </li>
+        } @else {
+            @for (entry of entries; track entry.id) {
+                <li class="list-group-item">
+                    <div class="d-flex justify-content-between align-items-center">
+                        <ng-template #timestamp>
+                            <div class="text-light">
+                                {{ entry.timestamp | customDate:'longDate' }} {{ entry.timestamp | date:'shortTime' }}
+                            </div>
+                        </ng-template>
+                        <span class="text-muted" [ngbTooltip]="timestamp">{{ entry.timestamp | customDate:'relative' }}</span>
+                        @if (entry.actor) {
+                            <span class="ms-3 fst-italic">{{ entry.actor.username }}</span>
+                        } @else {
+                            <span class="ms-3 fst-italic">System</span>
+                        }
+                        <span class="badge bg-secondary ms-auto" [class.bg-primary]="entry.action === AuditLogAction.Create">{{ entry.action | titlecase }}</span>
+                    </div>
+                    @if (entry.action === AuditLogAction.Update) {
+                        <ul class="mt-2">
+                            @for (change of entry.changes | keyvalue; track change.key) {
+                                @if (change.value["type"] === 'm2m') {
+                                    <li>
+                                        <span class="fst-italic" i18n>{{ change.value["operation"] | titlecase }}</span>&nbsp;
+                                        <span class="text-light">{{ change.key | titlecase }}</span>:&nbsp;
+                                        <code class="text-primary">{{ change.value["objects"].join(', ') }}</code>
+                                    </li>
+                                }
+                                @else if (change.value["type"] === 'custom_field') {
+                                    <li>
+                                        <span class="text-light">{{ change.value["field"] }}</span>:&nbsp;
+                                        <code class="text-primary">{{ change.value["value"] }}</code>
+                                    </li>
+                                }
+                                @else {
+                                    <li>
+                                        <span class="text-light">{{ change.key | titlecase }}</span>:&nbsp;
+                                        <code class="text-primary">{{ change.value[1] }}</code>
+                                    </li>
+                                }
+                            }
+                        </ul>
+                    }
+                </li>
+            }
+        }
+    </ul>
+}
diff --git a/src-ui/src/app/components/document-history/document-history.component.scss b/src-ui/src/app/components/document-history/document-history.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src-ui/src/app/components/document-history/document-history.component.spec.ts b/src-ui/src/app/components/document-history/document-history.component.spec.ts
new file mode 100644 (file)
index 0000000..3a26c8a
--- /dev/null
@@ -0,0 +1,57 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+
+import { DocumentHistoryComponent } from './document-history.component'
+import { DocumentService } from 'src/app/services/rest/document.service'
+import { of } from 'rxjs'
+import { AuditLogAction } from 'src/app/data/auditlog-entry'
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
+import { DatePipe } from '@angular/common'
+import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap'
+import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
+
+describe('DocumentHistoryComponent', () => {
+  let component: DocumentHistoryComponent
+  let fixture: ComponentFixture<DocumentHistoryComponent>
+  let documentService: DocumentService
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [DocumentHistoryComponent, CustomDatePipe],
+      providers: [DatePipe],
+      imports: [
+        HttpClientTestingModule,
+        NgbCollapseModule,
+        NgxBootstrapIconsModule.pick(allIcons),
+      ],
+    }).compileComponents()
+
+    fixture = TestBed.createComponent(DocumentHistoryComponent)
+    documentService = TestBed.inject(DocumentService)
+    component = fixture.componentInstance
+  })
+
+  it('should get audit log entries on init', () => {
+    const getHistorySpy = jest.spyOn(documentService, 'getHistory')
+    getHistorySpy.mockReturnValue(
+      of([
+        {
+          id: 1,
+          actor: {
+            id: 1,
+            username: 'user1',
+          },
+          action: AuditLogAction.Create,
+          timestamp: '2021-01-01T00:00:00Z',
+          remote_addr: '1.2.3.4',
+          changes: {
+            title: ['old title', 'new title'],
+          },
+        },
+      ])
+    )
+    component.documentId = 1
+    fixture.detectChanges()
+    expect(getHistorySpy).toHaveBeenCalledWith(1)
+  })
+})
diff --git a/src-ui/src/app/components/document-history/document-history.component.ts b/src-ui/src/app/components/document-history/document-history.component.ts
new file mode 100644 (file)
index 0000000..7870c17
--- /dev/null
@@ -0,0 +1,36 @@
+import { Component, Input, OnInit } from '@angular/core'
+import { AuditLogAction, AuditLogEntry } from 'src/app/data/auditlog-entry'
+import { DocumentService } from 'src/app/services/rest/document.service'
+
+@Component({
+  selector: 'pngx-document-history',
+  templateUrl: './document-history.component.html',
+  styleUrl: './document-history.component.scss',
+})
+export class DocumentHistoryComponent implements OnInit {
+  public AuditLogAction = AuditLogAction
+
+  private _documentId: number
+  @Input()
+  set documentId(id: number) {
+    this._documentId = id
+    this.ngOnInit()
+  }
+
+  public loading: boolean = true
+  public entries: AuditLogEntry[] = []
+
+  constructor(private documentService: DocumentService) {}
+
+  ngOnInit(): void {
+    if (this._documentId) {
+      this.loading = true
+      this.documentService
+        .getHistory(this._documentId)
+        .subscribe((auditLogEntries) => {
+          this.entries = auditLogEntries
+          this.loading = false
+        })
+    }
+  }
+}
diff --git a/src-ui/src/app/data/auditlog-entry.ts b/src-ui/src/app/data/auditlog-entry.ts
new file mode 100644 (file)
index 0000000..dc45a65
--- /dev/null
@@ -0,0 +1,18 @@
+import { User } from './user'
+
+export enum AuditLogAction {
+  Create = 'create',
+  Update = 'update',
+  Delete = 'delete',
+}
+
+export interface AuditLogEntry {
+  id: number
+  timestamp: string
+  action: AuditLogAction
+  changes: {
+    [key: string]: string[]
+  }
+  remote_addr: string
+  actor?: User
+}
index e55f252780441ddf1875c802e9c6c2432ed6885b..41f9ba361ba5b5ef2e715890e39a1d4d325c095f 100644 (file)
@@ -37,6 +37,7 @@ export const SETTINGS_KEYS = {
   NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD:
     'general-settings:notifications:consumer-suppress-on-dashboard',
   NOTES_ENABLED: 'general-settings:notes-enabled',
+  AUDITLOG_ENABLED: 'general-settings:auditlog-enabled',
   SLIM_SIDEBAR: 'general-settings:slim-sidebar',
   UPDATE_CHECKING_ENABLED: 'general-settings:update-checking:enabled',
   UPDATE_CHECKING_BACKEND_SETTING:
@@ -143,6 +144,11 @@ export const SETTINGS: UiSetting[] = [
     type: 'boolean',
     default: true,
   },
+  {
+    key: SETTINGS_KEYS.AUDITLOG_ENABLED,
+    type: 'boolean',
+    default: true,
+  },
   {
     key: SETTINGS_KEYS.UPDATE_CHECKING_ENABLED,
     type: 'boolean',
index 5b9d0b1762831341695f07a19a7f168116960926..87e99212b03470ba173e1674e7c6509bef1cc2f4 100644 (file)
@@ -30,4 +30,14 @@ describe('CustomDatePipe', () => {
       )
     ).toEqual('2023-05-04')
   })
+
+  it('should support relative date formatting', () => {
+    const now = new Date()
+    const notNow = new Date(now)
+    notNow.setDate(now.getDate() - 1)
+    expect(datePipe.transform(notNow, 'relative')).toEqual('1 day ago')
+    notNow.setDate(now.getDate() - 2)
+    expect(datePipe.transform(notNow, 'relative')).toEqual('2 days ago')
+    expect(datePipe.transform(now, 'relative')).toEqual('Just now')
+  })
 })
index 079091fa91a5eaef81ce36c41cfda04e39fa9245..e6034c59bc5bdf3a2274d65c49781280d994fc0f 100644 (file)
@@ -34,6 +34,51 @@ export class CustomDatePipe implements PipeTransform {
       this.settings.get(SETTINGS_KEYS.DATE_LOCALE) ||
       this.defaultLocale
     let f = format || this.settings.get(SETTINGS_KEYS.DATE_FORMAT)
+    if (format === 'relative') {
+      const seconds = Math.floor((+new Date() - +new Date(value)) / 1000)
+      if (seconds < 60) return $localize`Just now`
+      const intervals = {
+        year: {
+          label: $localize`year ago`,
+          labelPlural: $localize`years ago`,
+          interval: 31536000,
+        },
+        month: {
+          label: $localize`month ago`,
+          labelPlural: $localize`months ago`,
+          interval: 2592000,
+        },
+        week: {
+          label: $localize`week ago`,
+          labelPlural: $localize`weeks ago`,
+          interval: 604800,
+        },
+        day: {
+          label: $localize`day ago`,
+          labelPlural: $localize`days ago`,
+          interval: 86400,
+        },
+        hour: {
+          label: $localize`hour ago`,
+          labelPlural: $localize`hours ago`,
+          interval: 3600,
+        },
+        minute: {
+          label: $localize`minute ago`,
+          labelPlural: $localize`minutes ago`,
+          interval: 60,
+        },
+      }
+      let counter
+      for (const i in intervals) {
+        counter = Math.floor(seconds / intervals[i].interval)
+        if (counter > 0) {
+          const label =
+            counter > 1 ? intervals[i].labelPlural : intervals[i].label
+          return `${counter} ${label}`
+        }
+      }
+    }
     if (l == 'iso-8601') {
       return this.datePipe.transform(value, FORMAT_TO_ISO_FORMAT[f], timezone)
     } else {
index 0648f461f53c8e17dd3112aa34239edae8ed8380..c80bc763df76da286c6dc1ba22eace9e9848e1c1 100644 (file)
@@ -19,6 +19,7 @@ export enum PermissionType {
   PaperlessTask = '%s_paperlesstask',
   AppConfig = '%s_applicationconfiguration',
   UISettings = '%s_uisettings',
+  History = '%s_logentry',
   Note = '%s_note',
   MailAccount = '%s_mailaccount',
   MailRule = '%s_mailrule',
index 1f3ccc0af2a522e1915a0fdad041e480ee072dac..c379ba0102a7e6ee45abd4492f7bc100b109db0c 100644 (file)
@@ -266,6 +266,13 @@ describe(`DocumentService`, () => {
     )
     expect(req.request.body.remove_inbox_tags).toEqual(true)
   })
+
+  it('should call appropriate api endpoint for getting audit log', () => {
+    subscription = service.getHistory(documents[0].id).subscribe()
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}${endpoint}/${documents[0].id}/history/`
+    )
+  })
 })
 
 afterEach(() => {
index 5c0f0a1dc67ed038ce7841cadf8d4779697990c4..f078a8de59eab1c2043a5086b873b4638fcdd0ae 100644 (file)
@@ -19,7 +19,8 @@ import {
   PermissionsService,
 } from '../permissions.service'
 import { SettingsService } from '../settings.service'
-import { SETTINGS, SETTINGS_KEYS } from 'src/app/data/ui-settings'
+import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
+import { AuditLogEntry } from 'src/app/data/auditlog-entry'
 
 export const DOCUMENT_SORT_FIELDS = [
   { field: 'archive_serial_number', name: $localize`ASN` },
@@ -222,6 +223,10 @@ export class DocumentService extends AbstractPaperlessService<Document> {
     )
   }
 
+  getHistory(id: number): Observable<AuditLogEntry[]> {
+    return this.http.get<AuditLogEntry[]>(this.getResourceUrl(id, 'history'))
+  }
+
   bulkDownload(
     ids: number[],
     content = 'both',
index ff0a9837bd096d847df43e3d26113e76871b1ba2..71568dc4bf3c8e1df9c8c90c2e240d57794f7d24 100644 (file)
@@ -47,6 +47,7 @@ describe('SettingsService', () => {
       update_checking: { enabled: false, backend_setting: 'default' },
       saved_views: { warn_on_unsaved_change: true },
       notes_enabled: true,
+      auditlog_enabled: true,
       tour_complete: false,
       permissions: {
         default_owner: null,
index 8e7a16a604bcf4efa4f1987de889818b7e7be618..5cb35a8f7db4a99a8b057715fed535ac7900063d 100644 (file)
@@ -882,7 +882,12 @@ class CustomFieldInstance(models.Model):
 
 
 if settings.AUDIT_LOG_ENABLED:
-    auditlog.register(Document, m2m_fields={"tags"})
+    auditlog.register(
+        Document,
+        m2m_fields={"tags"},
+        mask_fields=["content"],
+        exclude_fields=["modified"],
+    )
     auditlog.register(Correspondent)
     auditlog.register(Tag)
     auditlog.register(DocumentType)
index 26930ccec5a86afe3fee6dad9b23e1422270fd71..c7e86a7bf63b2a925b39b89bdef315dd8a4835ba 100644 (file)
@@ -5,6 +5,7 @@ import zoneinfo
 from decimal import Decimal
 
 import magic
+from auditlog.context import set_actor
 from celery import states
 from django.conf import settings
 from django.contrib.auth.models import Group
@@ -746,7 +747,11 @@ class DocumentSerializer(
                     for tag in instance.tags.all()
                     if tag not in inbox_tags_not_being_added
                 ]
-        super().update(instance, validated_data)
+        if settings.AUDIT_LOG_ENABLED:
+            with set_actor(self.user):
+                super().update(instance, validated_data)
+        else:
+            super().update(instance, validated_data)
         return instance
 
     def __init__(self, *args, **kwargs):
index 0a94a5677458e2e3ffddc3f94d564f454942f7ef..9ae0b8bc3fcdd88de87c955d8fe7934e7908e288 100644 (file)
@@ -316,6 +316,133 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
         response = self.client.get(f"/api/documents/{doc.pk}/thumb/")
         self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
 
+    def test_document_history_action(self):
+        """
+        GIVEN:
+            - Document
+        WHEN:
+            - Document is updated
+        THEN:
+            - Audit log contains changes
+        """
+        doc = Document.objects.create(
+            title="First title",
+            checksum="123",
+            mime_type="application/pdf",
+        )
+        self.client.force_login(user=self.user)
+        self.client.patch(
+            f"/api/documents/{doc.pk}/",
+            {"title": "New title"},
+            format="json",
+        )
+
+        response = self.client.get(f"/api/documents/{doc.pk}/history/")
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(len(response.data), 2)
+        self.assertEqual(response.data[0]["actor"]["id"], self.user.id)
+        self.assertEqual(response.data[0]["action"], "update")
+        self.assertEqual(
+            response.data[0]["changes"],
+            {"title": ["First title", "New title"]},
+        )
+
+    def test_document_history_action_w_custom_fields(self):
+        """
+        GIVEN:
+            - Document with custom fields
+        WHEN:
+            - Document is updated
+        THEN:
+            - Audit log contains custom field changes
+        """
+        doc = Document.objects.create(
+            title="First title",
+            checksum="123",
+            mime_type="application/pdf",
+        )
+        custom_field = CustomField.objects.create(
+            name="custom field str",
+            data_type=CustomField.FieldDataType.STRING,
+        )
+        self.client.force_login(user=self.user)
+        self.client.patch(
+            f"/api/documents/{doc.pk}/",
+            data={
+                "custom_fields": [
+                    {
+                        "field": custom_field.pk,
+                        "value": "custom value",
+                    },
+                ],
+            },
+            format="json",
+        )
+
+        response = self.client.get(f"/api/documents/{doc.pk}/history/")
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(response.data[1]["actor"]["id"], self.user.id)
+        self.assertEqual(response.data[1]["action"], "create")
+        self.assertEqual(
+            response.data[1]["changes"],
+            {
+                "custom_fields": {
+                    "type": "custom_field",
+                    "field": "custom field str",
+                    "value": "custom value",
+                },
+            },
+        )
+
+    @override_settings(AUDIT_LOG_ENABLED=False)
+    def test_document_history_action_disabled(self):
+        """
+        GIVEN:
+            - Audit log is disabled
+        WHEN:
+            - Document is updated
+            - Audit log is requested
+        THEN:
+            - Audit log returns HTTP 400 Bad Request
+        """
+        doc = Document.objects.create(
+            title="First title",
+            checksum="123",
+            mime_type="application/pdf",
+        )
+        self.client.force_login(user=self.user)
+        self.client.patch(
+            f"/api/documents/{doc.pk}/",
+            {"title": "New title"},
+            format="json",
+        )
+
+        response = self.client.get(f"/api/documents/{doc.pk}/history/")
+        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+
+    def test_document_history_insufficient_perms(self):
+        """
+        GIVEN:
+            - Audit log is disabled
+        WHEN:
+            - Document is updated
+            - Audit log is requested
+        THEN:
+            - Audit log returns HTTP 400 Bad Request
+        """
+        user = User.objects.create_user(username="test")
+        user.user_permissions.add(*Permission.objects.filter(codename="view_document"))
+        self.client.force_login(user=user)
+        doc = Document.objects.create(
+            title="First title",
+            checksum="123",
+            mime_type="application/pdf",
+            owner=user,
+        )
+
+        response = self.client.get(f"/api/documents/{doc.pk}/history/")
+        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
     def test_document_filters(self):
         doc1 = Document.objects.create(
             title="none1",
index 2cb6af6f21ab50dcd0c2af6a54c121f6d31489a4..0a52ea41c1509b44586e13f2ac60cfc0cb33225b 100644 (file)
@@ -39,6 +39,7 @@ class TestApiUiSettings(DirectoriesMixin, APITestCase):
             {
                 "app_title": None,
                 "app_logo": None,
+                "auditlog_enabled": True,
                 "update_checking": {
                     "backend_setting": "default",
                 },
index a924e377becd1b62df06c9dca4fa05f0c888c3df..fe5a5b589db9009ed3146c518920d6e22a282f9f 100644 (file)
@@ -4,6 +4,7 @@ import tempfile
 from pathlib import Path
 from unittest import mock
 
+from auditlog.context import disable_auditlog
 from django.conf import settings
 from django.contrib.auth.models import User
 from django.db import DatabaseError
@@ -143,7 +144,9 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
         # Set a correspondent and save the document
         document.correspondent = Correspondent.objects.get_or_create(name="test")[0]
 
-        with mock.patch("documents.signals.handlers.Document.objects.filter") as m:
+        with mock.patch(
+            "documents.signals.handlers.Document.objects.filter",
+        ) as m, disable_auditlog():
             m.side_effect = DatabaseError()
             document.save()
 
@@ -557,20 +560,21 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
     @override_settings(FILENAME_FORMAT="{title}")
     @mock.patch("documents.signals.handlers.Document.objects.filter")
     def test_no_update_without_change(self, m):
-        doc = Document.objects.create(
-            title="document",
-            filename="document.pdf",
-            archive_filename="document.pdf",
-            checksum="A",
-            archive_checksum="B",
-            mime_type="application/pdf",
-        )
-        Path(doc.source_path).touch()
-        Path(doc.archive_path).touch()
+        with disable_auditlog():
+            doc = Document.objects.create(
+                title="document",
+                filename="document.pdf",
+                archive_filename="document.pdf",
+                checksum="A",
+                archive_checksum="B",
+                mime_type="application/pdf",
+            )
+            Path(doc.source_path).touch()
+            Path(doc.archive_path).touch()
 
-        doc.save()
+            doc.save()
 
-        m.assert_not_called()
+            m.assert_not_called()
 
 
 class TestFileHandlingWithArchive(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
index 5841649d087e76a0d852570953fe12bac0125752..8e58d9019b127d13d284def7b5ad96bda0c6241f 100644 (file)
@@ -18,6 +18,7 @@ import pathvalidate
 from django.apps import apps
 from django.conf import settings
 from django.contrib.auth.models import User
+from django.contrib.contenttypes.models import ContentType
 from django.db import connections
 from django.db.migrations.loader import MigrationLoader
 from django.db.migrations.recorder import MigrationRecorder
@@ -105,6 +106,7 @@ from documents.matching import match_storage_paths
 from documents.matching import match_tags
 from documents.models import Correspondent
 from documents.models import CustomField
+from documents.models import CustomFieldInstance
 from documents.models import Document
 from documents.models import DocumentType
 from documents.models import Note
@@ -729,6 +731,66 @@ class DocumentViewSet(
             ]
             return Response(links)
 
+    @action(methods=["get"], detail=True, name="Audit Trail")
+    def history(self, request, pk=None):
+        if not settings.AUDIT_LOG_ENABLED:
+            return HttpResponseBadRequest("Audit log is disabled")
+        try:
+            doc = Document.objects.get(pk=pk)
+            if not request.user.has_perm("auditlog.view_logentry") or (
+                doc.owner is not None and doc.owner != request.user
+            ):
+                return HttpResponseForbidden(
+                    "Insufficient permissions",
+                )
+        except Document.DoesNotExist:  # pragma: no cover
+            raise Http404
+
+        # documents
+        entries = [
+            {
+                "id": entry.id,
+                "timestamp": entry.timestamp,
+                "action": entry.get_action_display(),
+                "changes": entry.changes,
+                "actor": (
+                    {"id": entry.actor.id, "username": entry.actor.username}
+                    if entry.actor
+                    else None
+                ),
+            }
+            for entry in LogEntry.objects.filter(object_pk=doc.pk).select_related(
+                "actor",
+            )
+        ]
+
+        # custom fields
+        for entry in LogEntry.objects.filter(
+            object_pk__in=doc.custom_fields.values_list("id", flat=True),
+            content_type=ContentType.objects.get_for_model(CustomFieldInstance),
+        ).select_related("actor"):
+            entries.append(
+                {
+                    "id": entry.id,
+                    "timestamp": entry.timestamp,
+                    "action": entry.get_action_display(),
+                    "changes": {
+                        "custom_fields": {
+                            "type": "custom_field",
+                            "field": str(entry.object_repr).split(":")[0].strip(),
+                            "value": str(entry.object_repr).split(":")[1].strip(),
+                        },
+                    },
+                    "actor": (
+                        {"id": entry.actor.id, "username": entry.actor.username}
+                        if entry.actor
+                        else None
+                    ),
+                },
+            )
+
+        return Response(sorted(entries, key=lambda x: x["timestamp"], reverse=True))
+
 
 class SearchResultSerializer(DocumentSerializer, PassUserMixin):
     def to_representation(self, instance):
@@ -1267,6 +1329,8 @@ class UiSettingsView(GenericAPIView):
         if general_config.app_logo is not None and len(general_config.app_logo) > 0:
             ui_settings["app_logo"] = general_config.app_logo
 
+        ui_settings["auditlog_enabled"] = settings.AUDIT_LOG_ENABLED
+
         user_resp = {
             "id": user.id,
             "username": user.username,
index 219a433bed2bc166ac60b881ef745f32be87a2ab..6496b56b36e1cdb194aa23d113ced6f8505a6755 100644 (file)
@@ -2,7 +2,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: paperless-ngx\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2024-04-19 01:13-0700\n"
+"POT-Creation-Date: 2024-04-19 01:15-0700\n"
 "PO-Revision-Date: 2022-02-17 04:17\n"
 "Last-Translator: \n"
 "Language-Team: English\n"
@@ -25,27 +25,27 @@ msgstr ""
 msgid "owner"
 msgstr ""
 
-#: documents/models.py:53 documents/models.py:897
+#: documents/models.py:53 documents/models.py:902
 msgid "None"
 msgstr ""
 
-#: documents/models.py:54 documents/models.py:898
+#: documents/models.py:54 documents/models.py:903
 msgid "Any word"
 msgstr ""
 
-#: documents/models.py:55 documents/models.py:899
+#: documents/models.py:55 documents/models.py:904
 msgid "All words"
 msgstr ""
 
-#: documents/models.py:56 documents/models.py:900
+#: documents/models.py:56 documents/models.py:905
 msgid "Exact match"
 msgstr ""
 
-#: documents/models.py:57 documents/models.py:901
+#: documents/models.py:57 documents/models.py:906
 msgid "Regular expression"
 msgstr ""
 
-#: documents/models.py:58 documents/models.py:902
+#: documents/models.py:58 documents/models.py:907
 msgid "Fuzzy word"
 msgstr ""
 
@@ -53,20 +53,20 @@ msgstr ""
 msgid "Automatic"
 msgstr ""
 
-#: documents/models.py:62 documents/models.py:397 documents/models.py:1218
+#: documents/models.py:62 documents/models.py:397 documents/models.py:1223
 #: paperless_mail/models.py:18 paperless_mail/models.py:93
 msgid "name"
 msgstr ""
 
-#: documents/models.py:64 documents/models.py:958
+#: documents/models.py:64 documents/models.py:963
 msgid "match"
 msgstr ""
 
-#: documents/models.py:67 documents/models.py:961
+#: documents/models.py:67 documents/models.py:966
 msgid "matching algorithm"
 msgstr ""
 
-#: documents/models.py:72 documents/models.py:966
+#: documents/models.py:72 documents/models.py:971
 msgid "is insensitive"
 msgstr ""
 
@@ -615,246 +615,246 @@ msgstr ""
 msgid "custom field instances"
 msgstr ""
 
-#: documents/models.py:905
+#: documents/models.py:910
 msgid "Consumption Started"
 msgstr ""
 
-#: documents/models.py:906
+#: documents/models.py:911
 msgid "Document Added"
 msgstr ""
 
-#: documents/models.py:907
+#: documents/models.py:912
 msgid "Document Updated"
 msgstr ""
 
-#: documents/models.py:910
+#: documents/models.py:915
 msgid "Consume Folder"
 msgstr ""
 
-#: documents/models.py:911
+#: documents/models.py:916
 msgid "Api Upload"
 msgstr ""
 
-#: documents/models.py:912
+#: documents/models.py:917
 msgid "Mail Fetch"
 msgstr ""
 
-#: documents/models.py:915
+#: documents/models.py:920
 msgid "Workflow Trigger Type"
 msgstr ""
 
-#: documents/models.py:927
+#: documents/models.py:932
 msgid "filter path"
 msgstr ""
 
-#: documents/models.py:932
+#: documents/models.py:937
 msgid ""
 "Only consume documents with a path that matches this if specified. Wildcards "
 "specified as * are allowed. Case insensitive."
 msgstr ""
 
-#: documents/models.py:939
+#: documents/models.py:944
 msgid "filter filename"
 msgstr ""
 
-#: documents/models.py:944 paperless_mail/models.py:148
+#: documents/models.py:949 paperless_mail/models.py:148
 msgid ""
 "Only consume documents which entirely match this filename if specified. "
 "Wildcards such as *.pdf or *invoice* are allowed. Case insensitive."
 msgstr ""
 
-#: documents/models.py:955
+#: documents/models.py:960
 msgid "filter documents from this mail rule"
 msgstr ""
 
-#: documents/models.py:971
+#: documents/models.py:976
 msgid "has these tag(s)"
 msgstr ""
 
-#: documents/models.py:979
+#: documents/models.py:984
 msgid "has this document type"
 msgstr ""
 
-#: documents/models.py:987
+#: documents/models.py:992
 msgid "has this correspondent"
 msgstr ""
 
-#: documents/models.py:991
+#: documents/models.py:996
 msgid "workflow trigger"
 msgstr ""
 
-#: documents/models.py:992
+#: documents/models.py:997
 msgid "workflow triggers"
 msgstr ""
 
-#: documents/models.py:1002
+#: documents/models.py:1007
 msgid "Assignment"
 msgstr ""
 
-#: documents/models.py:1006
+#: documents/models.py:1011
 msgid "Removal"
 msgstr ""
 
-#: documents/models.py:1010
+#: documents/models.py:1015
 msgid "Workflow Action Type"
 msgstr ""
 
-#: documents/models.py:1016
+#: documents/models.py:1021
 msgid "assign title"
 msgstr ""
 
-#: documents/models.py:1021
+#: documents/models.py:1026
 msgid ""
 "Assign a document title, can include some placeholders, see documentation."
 msgstr ""
 
-#: documents/models.py:1030 paperless_mail/models.py:216
+#: documents/models.py:1035 paperless_mail/models.py:216
 msgid "assign this tag"
 msgstr ""
 
-#: documents/models.py:1039 paperless_mail/models.py:224
+#: documents/models.py:1044 paperless_mail/models.py:224
 msgid "assign this document type"
 msgstr ""
 
-#: documents/models.py:1048 paperless_mail/models.py:238
+#: documents/models.py:1053 paperless_mail/models.py:238
 msgid "assign this correspondent"
 msgstr ""
 
-#: documents/models.py:1057
+#: documents/models.py:1062
 msgid "assign this storage path"
 msgstr ""
 
-#: documents/models.py:1066
+#: documents/models.py:1071
 msgid "assign this owner"
 msgstr ""
 
-#: documents/models.py:1073
+#: documents/models.py:1078
 msgid "grant view permissions to these users"
 msgstr ""
 
-#: documents/models.py:1080
+#: documents/models.py:1085
 msgid "grant view permissions to these groups"
 msgstr ""
 
-#: documents/models.py:1087
+#: documents/models.py:1092
 msgid "grant change permissions to these users"
 msgstr ""
 
-#: documents/models.py:1094
+#: documents/models.py:1099
 msgid "grant change permissions to these groups"
 msgstr ""
 
-#: documents/models.py:1101
+#: documents/models.py:1106
 msgid "assign these custom fields"
 msgstr ""
 
-#: documents/models.py:1108
+#: documents/models.py:1113
 msgid "remove these tag(s)"
 msgstr ""
 
-#: documents/models.py:1113
+#: documents/models.py:1118
 msgid "remove all tags"
 msgstr ""
 
-#: documents/models.py:1120
+#: documents/models.py:1125
 msgid "remove these document type(s)"
 msgstr ""
 
-#: documents/models.py:1125
+#: documents/models.py:1130
 msgid "remove all document types"
 msgstr ""
 
-#: documents/models.py:1132
+#: documents/models.py:1137
 msgid "remove these correspondent(s)"
 msgstr ""
 
-#: documents/models.py:1137
+#: documents/models.py:1142
 msgid "remove all correspondents"
 msgstr ""
 
-#: documents/models.py:1144
+#: documents/models.py:1149
 msgid "remove these storage path(s)"
 msgstr ""
 
-#: documents/models.py:1149
+#: documents/models.py:1154
 msgid "remove all storage paths"
 msgstr ""
 
-#: documents/models.py:1156
+#: documents/models.py:1161
 msgid "remove these owner(s)"
 msgstr ""
 
-#: documents/models.py:1161
+#: documents/models.py:1166
 msgid "remove all owners"
 msgstr ""
 
-#: documents/models.py:1168
+#: documents/models.py:1173
 msgid "remove view permissions for these users"
 msgstr ""
 
-#: documents/models.py:1175
+#: documents/models.py:1180
 msgid "remove view permissions for these groups"
 msgstr ""
 
-#: documents/models.py:1182
+#: documents/models.py:1187
 msgid "remove change permissions for these users"
 msgstr ""
 
-#: documents/models.py:1189
+#: documents/models.py:1194
 msgid "remove change permissions for these groups"
 msgstr ""
 
-#: documents/models.py:1194
+#: documents/models.py:1199
 msgid "remove all permissions"
 msgstr ""
 
-#: documents/models.py:1201
+#: documents/models.py:1206
 msgid "remove these custom fields"
 msgstr ""
 
-#: documents/models.py:1206
+#: documents/models.py:1211
 msgid "remove all custom fields"
 msgstr ""
 
-#: documents/models.py:1210
+#: documents/models.py:1215
 msgid "workflow action"
 msgstr ""
 
-#: documents/models.py:1211
+#: documents/models.py:1216
 msgid "workflow actions"
 msgstr ""
 
-#: documents/models.py:1220 paperless_mail/models.py:95
+#: documents/models.py:1225 paperless_mail/models.py:95
 msgid "order"
 msgstr ""
 
-#: documents/models.py:1226
+#: documents/models.py:1231
 msgid "triggers"
 msgstr ""
 
-#: documents/models.py:1233
+#: documents/models.py:1238
 msgid "actions"
 msgstr ""
 
-#: documents/models.py:1236
+#: documents/models.py:1241
 msgid "enabled"
 msgstr ""
 
-#: documents/serialisers.py:114
+#: documents/serialisers.py:115
 #, python-format
 msgid "Invalid regular expression: %(error)s"
 msgstr ""
 
-#: documents/serialisers.py:417
+#: documents/serialisers.py:418
 msgid "Invalid color."
 msgstr ""
 
-#: documents/serialisers.py:1143
+#: documents/serialisers.py:1148
 #, python-format
 msgid "File type %(type)s not supported"
 msgstr ""
 
-#: documents/serialisers.py:1252
+#: documents/serialisers.py:1257
 msgid "Invalid variable detected."
 msgstr ""