Merge branch 'dev' into feature-8271

This commit is contained in:
shamoon 2024-12-01 19:36:37 -08:00 committed by GitHub
commit 5ef31859c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
113 changed files with 3792 additions and 1233 deletions

View File

@ -406,7 +406,7 @@ jobs:
-
name: Login to Docker Hub
uses: docker/login-action@v3
# Don't attempt to login is not pushing to Docker Hub
# Don't attempt to login if not pushing to Docker Hub
if: steps.push-other-places.outputs.enable == 'true'
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
@ -414,7 +414,7 @@ jobs:
-
name: Login to Quay.io
uses: docker/login-action@v3
# Don't attempt to login is not pushing to Quay.io
# Don't attempt to login if not pushing to Quay.io
if: steps.push-other-places.outputs.enable == 'true'
with:
registry: quay.io

View File

@ -48,7 +48,7 @@ repos:
exclude: "(^Pipfile\\.lock$)"
# Python hooks
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: 'v0.7.3'
rev: 'v0.8.0'
hooks:
- id: ruff
- id: ruff-format

View File

@ -8,7 +8,7 @@ dateparser = "~=1.2"
# WARNING: django does not use semver.
# Only patch versions are guaranteed to not introduce breaking changes.
django = "~=5.1.3"
django-allauth = {extras = ["socialaccount"], version = "*"}
django-allauth = {extras = ["mfa", "socialaccount"], version = "*"}
django-auditlog = "*"
django-celery-results = "*"
django-compression-middleware = "*"
@ -23,7 +23,7 @@ djangorestframework-guardian = "*"
drf-writable-nested = "*"
bleach = "*"
celery = {extras = ["redis"], version = "*"}
channels = "~=4.1"
channels = "~=4.2"
channels-redis = "*"
concurrent-log-handler = "*"
filelock = "*"
@ -55,7 +55,7 @@ tika-client = "*"
tqdm = "*"
# See https://github.com/paperless-ngx/paperless-ngx/issues/5494
uvicorn = {extras = ["standard"], version = "==0.25.0"}
watchdog = "~=5.0"
watchdog = "~=6.0"
whitenoise = "~=6.8"
whoosh = "~=2.7"
zxing-cpp = {version = "*", platform_machine = "== 'x86_64'"}

868
Pipfile.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -19,6 +19,8 @@
#
# - Open portainer Stacks list and click 'Add stack'
# - Paste the contents of this file and assign a name, e.g. 'paperless'
# - Upload 'docker-compose.env' by clicking on 'Load variables from .env file'
# - Modify the environment variables as needed
# - Click 'Deploy the stack' and wait for it to be deployed
# - Open the list of containers, select paperless_webserver_1
# - Click 'Console' and then 'Connect' to open the command line inside the container
@ -61,28 +63,8 @@ services:
environment:
PAPERLESS_REDIS: redis://broker:6379
PAPERLESS_DBHOST: db
# The UID and GID of the user used to run paperless in the container. Set this
# to your UID and GID on the host so that you have write access to the
# consumption directory.
USERMAP_UID: 1000
USERMAP_GID: 100
# Additional languages to install for text recognition, separated by a
# whitespace. Note that this is
# different from PAPERLESS_OCR_LANGUAGE (default=eng), which defines the
# language used for OCR.
# The container installs English, German, Italian, Spanish and French by
# default.
# See https://packages.debian.org/search?keywords=tesseract-ocr-&searchon=names&suite=buster
# for available languages.
#PAPERLESS_OCR_LANGUAGES: tur ces
# Adjust this key if you plan to make paperless available publicly. It should
# be a very long sequence of random characters. You don't need to remember it.
#PAPERLESS_SECRET_KEY: change-me
# Use this variable to set a timezone for the Paperless Docker containers. If not specified, defaults to UTC.
#PAPERLESS_TIME_ZONE: America/Los_Angeles
# The default language to use for OCR. Set this to the language most of your
# documents are written in.
#PAPERLESS_OCR_LANGUAGE: eng
env_file:
- stack.env
volumes:
data:

View File

@ -241,6 +241,7 @@ document_exporter target [-c] [-d] [-f] [-na] [-nt] [-p] [-sm] [-z]
optional arguments:
-c, --compare-checksums
-cj, --compare-json
-d, --delete
-f, --use-filename-format
-na, --no-archive
@ -269,7 +270,8 @@ only export changed and added files. Paperless determines whether a file
has changed by inspecting the file attributes "date/time modified" and
"size". If that does not work out for you, specify `-c` or
`--compare-checksums` and paperless will attempt to compare file
checksums instead. This is slower.
checksums instead. This is slower. The manifest and metadata json files
are always updated, unless `cj` or `--compare-json` is specified.
Paperless will not remove any existing files in the export directory. If
you want paperless to also remove files that do not belong to the

View File

@ -556,3 +556,11 @@ Initial API version.
- Consumption templates were refactored to workflows and API endpoints
changed as such.
#### Version 5
- Added bulk deletion methods for documents and objects.
#### Version 6
- Moved acknowledge tasks endpoint to be under `/api/tasks/acknowledge/`.

View File

@ -1,5 +1,58 @@
# Changelog
## paperless-ngx 2.13.5
### Bug Fixes
- Fix: handle page count exception for pw-protected files [@shamoon](https://github.com/shamoon) ([#8240](https://github.com/paperless-ngx/paperless-ngx/pull/8240))
- Fix: correctly track task id in list for change detection [@shamoon](https://github.com/shamoon) ([#8230](https://github.com/paperless-ngx/paperless-ngx/pull/8230))
- Fix: Admin pages should show trashed documents [@stumpylog](https://github.com/stumpylog) ([#8068](https://github.com/paperless-ngx/paperless-ngx/pull/8068))
- Fix: tag colors shouldn't change when selected in list [@shamoon](https://github.com/shamoon) ([#8225](https://github.com/paperless-ngx/paperless-ngx/pull/8225))
- Fix: fix re-activation of save button when changing array items [@shamoon](https://github.com/shamoon) ([#8208](https://github.com/paperless-ngx/paperless-ngx/pull/8208))
- Fix: fix thumbnail clipping, select inverted color in safari dark mode not system [@shamoon](https://github.com/shamoon) ([#8193](https://github.com/paperless-ngx/paperless-ngx/pull/8193))
- Fix: select checkbox should remain visible [@shamoon](https://github.com/shamoon) ([#8185](https://github.com/paperless-ngx/paperless-ngx/pull/8185))
- Fix: warn with proper error on ASN exists in trash [@shamoon](https://github.com/shamoon) ([#8176](https://github.com/paperless-ngx/paperless-ngx/pull/8176))
### Maintenance
- Chore: Updates all runner images to use Ubuntu Noble [@stumpylog](https://github.com/stumpylog) ([#8213](https://github.com/paperless-ngx/paperless-ngx/pull/8213))
- Chore(deps): Bump stumpylog/image-cleaner-action from 0.8.0 to 0.9.0 in the actions group [@dependabot](https://github.com/dependabot) ([#8142](https://github.com/paperless-ngx/paperless-ngx/pull/8142))
### Dependencies
- Chore(deps): Bump stumpylog/image-cleaner-action from 0.8.0 to 0.9.0 in the actions group [@dependabot](https://github.com/dependabot) ([#8142](https://github.com/paperless-ngx/paperless-ngx/pull/8142))
### All App Changes
<details>
<summary>7 changes</summary>
- Fix: handle page count exception for pw-protected files [@shamoon](https://github.com/shamoon) ([#8240](https://github.com/paperless-ngx/paperless-ngx/pull/8240))
- Fix: correctly track task id in list for change detection [@shamoon](https://github.com/shamoon) ([#8230](https://github.com/paperless-ngx/paperless-ngx/pull/8230))
- Fix: Admin pages should show trashed documents [@stumpylog](https://github.com/stumpylog) ([#8068](https://github.com/paperless-ngx/paperless-ngx/pull/8068))
- Fix: tag colors shouldn't change when selected in list [@shamoon](https://github.com/shamoon) ([#8225](https://github.com/paperless-ngx/paperless-ngx/pull/8225))
- Fix: fix re-activation of save button when changing array items [@shamoon](https://github.com/shamoon) ([#8208](https://github.com/paperless-ngx/paperless-ngx/pull/8208))
- Fix: fix thumbnail clipping, select inverted color in safari dark mode not system [@shamoon](https://github.com/shamoon) ([#8193](https://github.com/paperless-ngx/paperless-ngx/pull/8193))
- Fix: select checkbox should remain visible [@shamoon](https://github.com/shamoon) ([#8185](https://github.com/paperless-ngx/paperless-ngx/pull/8185))
- Fix: warn with proper error on ASN exists in trash [@shamoon](https://github.com/shamoon) ([#8176](https://github.com/paperless-ngx/paperless-ngx/pull/8176))
</details>
## paperless-ngx 2.13.4
### Bug Fixes
- Fix: fix dark mode icon blend mode in 2.13.3 [@shamoon](https://github.com/shamoon) ([#8166](https://github.com/paperless-ngx/paperless-ngx/pull/8166))
- Fix: fix clipped popup preview in 2.13.3 [@shamoon](https://github.com/shamoon) ([#8165](https://github.com/paperless-ngx/paperless-ngx/pull/8165))
### All App Changes
<details>
<summary>2 changes</summary>
- Fix: fix dark mode icon blend mode in 2.13.3 [@shamoon](https://github.com/shamoon) ([#8166](https://github.com/paperless-ngx/paperless-ngx/pull/8166))
- Fix: fix clipped popup preview in 2.13.3 [@shamoon](https://github.com/shamoon) ([#8165](https://github.com/paperless-ngx/paperless-ngx/pull/8165))
</details>
## paperless-ngx 2.13.3
### Bug Fixes

View File

@ -299,6 +299,12 @@ In order to enable the password reset feature you will need to setup an SMTP bac
[`PAPERLESS_EMAIL_HOST`](configuration.md#PAPERLESS_EMAIL_HOST). If your installation does not have
[`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) set, the reset link included in emails will use the server host.
### Two-factor authentication
Users can enable two-factor authentication (2FA) for their accounts from the 'My Profile' dialog. Opening the dropdown reveals a QR code that can be scanned by a 2FA app (e.g. Google Authenticator) to generate a code. The code must then be entered in the dialog to enable 2FA. If the code is accepted and 2FA is enabled, the user will be shown a set of 10 recovery codes that can be used to login in the event that the 2FA device is lost or unavailable. These codes should be stored securely and cannot be retrieved again. Once enabled, users will be required to enter a code from their 2FA app when logging in.
Should a user lose access to their 2FA device and all recovery codes, a superuser can disable 2FA for the user from the 'Users & Groups' management screen.
## Workflows
!!! note
@ -325,8 +331,10 @@ Currently, there are three events that correspond to workflow trigger 'types':
be used for filtering.
3. **Document Updated**: when a document is updated. Similar to 'added' events, triggers can include filtering by content matching,
tags, doc type, or correspondent.
4. **Scheduled**: a scheduled trigger that can be used to run workflows at a specific time. The date used can be either the document
added, created, updated date or you can specify a (date) custom field. You can also specify a day offset from the date.
The following flow diagram illustrates the three trigger types:
The following flow diagram illustrates the three document trigger types:
```mermaid
flowchart TD

View File

@ -330,8 +330,13 @@ SECRET_KEY=$(LC_ALL=C tr -dc 'a-zA-Z0-9!#$%&()*+,-./:;<=>?@[\]^_`{|}~' < /dev/ur
DEFAULT_LANGUAGES=("deu eng fra ita spa")
_split_langs="${OCR_LANGUAGE//+/ }"
read -r -a OCR_LANGUAGES_ARRAY <<< "${_split_langs}"
# OCR_LANG requires underscores, replace dashes if the user gave them with underscores
readonly ocr_langs=${OCR_LANGUAGE//-/_}
# OCR_LANGS (the install version) uses dashes, not underscores, so convert underscore to dash and plus to space
install_langs=${OCR_LANGUAGE//_/-} # First convert any underscores to dashes
install_langs=${install_langs//+/ } # Then convert plus signs to spaces
read -r -a install_langs_array <<< "${install_langs}"
{
if [[ ! $URL == "" ]] ; then
@ -344,10 +349,10 @@ read -r -a OCR_LANGUAGES_ARRAY <<< "${_split_langs}"
echo "USERMAP_GID=$USERMAP_GID"
fi
echo "PAPERLESS_TIME_ZONE=$TIME_ZONE"
echo "PAPERLESS_OCR_LANGUAGE=$OCR_LANGUAGE"
echo "PAPERLESS_OCR_LANGUAGE=$ocr_langs"
echo "PAPERLESS_SECRET_KEY='$SECRET_KEY'"
if [[ ! ${DEFAULT_LANGUAGES[*]} =~ ${OCR_LANGUAGES_ARRAY[*]} ]] ; then
echo "PAPERLESS_OCR_LANGUAGES=${OCR_LANGUAGES_ARRAY[*]}"
if [[ ! ${DEFAULT_LANGUAGES[*]} =~ ${install_langs_array[*]} ]] ; then
echo "PAPERLESS_OCR_LANGUAGES=${install_langs_array[*]}"
fi
} > docker-compose.env

File diff suppressed because it is too large Load Diff

View File

@ -33,6 +33,7 @@
"ngx-ui-tour-ng-bootstrap": "^15.0.0",
"rxjs": "^7.8.1",
"tslib": "^2.8.1",
"utif": "^3.1.0",
"uuid": "^11.0.2",
"zone.js": "^0.14.8"
},
@ -13758,6 +13759,12 @@
"node": "^16.14.0 || >=18.0.0"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@ -16563,6 +16570,15 @@
"requires-port": "^1.0.0"
}
},
"node_modules/utif": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/utif/-/utif-3.1.0.tgz",
"integrity": "sha512-WEo4D/xOvFW53K5f5QTaTbbiORcm2/pCL9P6qmJnup+17eYfKaEhDeX9PeQkuyEoIxlbGklDuGl8xwuXYMrrXQ==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.5"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",

View File

@ -35,6 +35,7 @@
"ngx-ui-tour-ng-bootstrap": "^15.0.0",
"rxjs": "^7.8.1",
"tslib": "^2.8.1",
"utif": "^3.1.0",
"uuid": "^11.0.2",
"zone.js": "^0.14.8"
},

View File

@ -145,7 +145,6 @@ import {
asterisk,
braces,
bodyText,
boxArrowInRight,
boxArrowUp,
boxArrowUpRight,
boxes,
@ -186,6 +185,7 @@ import {
fileEarmarkFill,
fileEarmarkLock,
fileEarmarkMinus,
fileEarmarkRichtext,
files,
fileText,
filter,
@ -253,7 +253,6 @@ const icons = {
asterisk,
braces,
bodyText,
boxArrowInRight,
boxArrowUp,
boxArrowUpRight,
boxes,
@ -294,6 +293,7 @@ const icons = {
fileEarmarkFill,
fileEarmarkLock,
fileEarmarkMinus,
fileEarmarkRichtext,
files,
fileText,
filter,

View File

@ -36,9 +36,7 @@
<ng-container i18n>Loading...</ng-container>
</div>
}
@for (log of logs; track log) {
<p
class="m-0 p-0 log-entry-{{getLogLevel(log)}}"
>{{log}}</p>
@for (log of logs; track $index) {
<p class="m-0 p-0 log-entry-{{getLogLevel(log)}}">{{log}}</p>
}
</div>

View File

@ -47,14 +47,19 @@
</tr>
}
@for (document of documentsInTrash; track document.id) {
<tr (click)="toggleSelected(document); $event.stopPropagation();">
<tr (click)="toggleSelected(document); $event.stopPropagation();" (mouseleave)="popupPreview.close()">
<td>
<div class="form-check m-0 ms-2 me-n2">
<input type="checkbox" class="form-check-input" id="{{document.id}}" [checked]="selectedDocuments.has(document.id)" (click)="toggleSelected(document); $event.stopPropagation();">
<label class="form-check-label" for="{{document.id}}"></label>
</div>
</td>
<td scope="row">{{ document.title }}</td>
<td scope="row">
{{ document.title }}
<pngx-preview-popup [document]="document" linkClasses="btn btn-sm btn-link" #popupPreview>
<i-bs name="eye"></i-bs>
</pngx-preview-popup>
</td>
<td scope="row" i18n>{{ getDaysRemaining(document) }} days</td>
<td scope="row">
<div class="btn-group d-block d-sm-none">

View File

@ -16,6 +16,7 @@ import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dial
import { By } from '@angular/platform-browser'
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
import { ToastService } from 'src/app/services/toast.service'
import { Router } from '@angular/router'
const documentsInTrash = [
{
@ -38,6 +39,7 @@ describe('TrashComponent', () => {
let trashService: TrashService
let modalService: NgbModal
let toastService: ToastService
let router: Router
beforeEach(async () => {
await TestBed.configureTestingModule({
@ -61,6 +63,7 @@ describe('TrashComponent', () => {
trashService = TestBed.inject(TrashService)
modalService = TestBed.inject(NgbModal)
toastService = TestBed.inject(ToastService)
router = TestBed.inject(Router)
component = fixture.componentInstance
fixture.detectChanges()
})
@ -161,6 +164,22 @@ describe('TrashComponent', () => {
expect(restoreSpy).toHaveBeenCalledWith([1, 2])
})
it('should offer link to restored document', () => {
let toasts
const navigateSpy = jest.spyOn(router, 'navigate')
toastService.getToasts().subscribe((allToasts) => {
toasts = [...allToasts]
})
jest.spyOn(trashService, 'restoreDocuments').mockReturnValue(of('OK'))
component.restore(documentsInTrash[0])
expect(toasts.length).toEqual(1)
toasts[0].action()
expect(navigateSpy).toHaveBeenCalledWith([
'documents',
documentsInTrash[0].id,
])
})
it('should support toggle all items in view', () => {
component.documentsInTrash = documentsInTrash
expect(component.selectedDocuments.size).toEqual(0)

View File

@ -7,6 +7,7 @@ import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dial
import { Subject, takeUntil } from 'rxjs'
import { SettingsService } from 'src/app/services/settings.service'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { Router } from '@angular/router'
@Component({
selector: 'pngx-trash',
@ -26,7 +27,8 @@ export class TrashComponent implements OnDestroy {
private trashService: TrashService,
private toastService: ToastService,
private modalService: NgbModal,
private settingsService: SettingsService
private settingsService: SettingsService,
private router: Router
) {
this.reload()
}
@ -110,7 +112,14 @@ export class TrashComponent implements OnDestroy {
restore(document: Document) {
this.trashService.restoreDocuments([document.id]).subscribe({
next: () => {
this.toastService.showInfo($localize`Document restored`)
this.toastService.show({
content: $localize`Document restored`,
delay: 5000,
actionName: $localize`Open document`,
action: () => {
this.router.navigate(['documents', document.id])
},
})
this.reload()
},
error: (err) => {

View File

@ -1,4 +1,4 @@
<nav class="navbar navbar-dark sticky-top bg-primary flex-md-nowrap p-0 shadow-sm">
<nav class="navbar navbar-dark fixed-top bg-primary flex-md-nowrap p-0 shadow-sm">
<button class="navbar-toggler d-md-none collapsed border-0" type="button" data-toggle="collapse"
data-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation"
(click)="isMenuCollapsed = !isMenuCollapsed">

View File

@ -48,6 +48,13 @@
main {
transition: all .2s ease;
padding-top: 110px;
}
@media (min-width: 768px) {
main {
padding-top: 56px;
}
}
.sidebar-slim-toggler {

View File

@ -343,6 +343,7 @@ describe('AppFrameComponent', () => {
component.editProfile()
expect(modalSpy).toHaveBeenCalledWith(ProfileEditDialogComponent, {
backdrop: 'static',
size: 'xl',
})
})

View File

@ -136,6 +136,7 @@ export class AppFrameComponent
editProfile() {
this.modalService.open(ProfileEditDialogComponent, {
backdrop: 'static',
size: 'xl',
})
this.closeMenu()
}

View File

@ -49,7 +49,7 @@
[disabled]="disablePrimaryButton(type, item)"
(mouseenter)="onButtonHover($event)">
@if (type === DataType.Document) {
<i-bs width="1em" height="1em" name="box-arrow-in-right"></i-bs>
<i-bs width="1em" height="1em" name="file-earmark-richtext"></i-bs>
<span>&nbsp;<ng-container i18n>Open</ng-container></span>
} @else if (type === DataType.SavedView) {
<i-bs width="1em" height="1em" name="eye"></i-bs>
@ -72,7 +72,7 @@
<i-bs width="1em" height="1em" name="download"></i-bs>
<span>&nbsp;<ng-container i18n>Download</ng-container></span>
} @else {
<i-bs width="1em" height="1em" name="box-arrow-in-right"></i-bs>
<i-bs width="1em" height="1em" name="file-earmark-richtext"></i-bs>
<span>&nbsp;<ng-container i18n>Open</ng-container></span>
}
</button>

View File

@ -1,6 +1,6 @@
.pdf-viewer-container {
background-color: gray;
height: 350px;
height: 550px;
pdf-viewer {
width: 100%;

View File

@ -6,7 +6,7 @@
<div class="modal-body">
<p>{{message}}</p>
<div class="row mb-2">
<div class="col-8">
<div class="col-7">
<div class="input-group input-group-sm">
<div class="input-group-text" i18n>Page</div>
<input class="form-control" type="number" min="1" [(ngModel)]="page" />
@ -21,7 +21,7 @@
</pdf-viewer>
</div>
</div>
<div class="col-4">
<div class="col-5">
<div class="d-grid">
<button class="btn btn-sm btn-primary" (click)="addSplit()" [disabled]="!canSplit">
<i-bs name="plus-circle"></i-bs>&nbsp;
@ -44,12 +44,12 @@
</ul>
</div>
</div>
<div class="form-check form-switch mt-4">
</div>
<div class="modal-footer">
<div class="form-check form-switch me-auto">
<input class="form-check-input" type="checkbox" role="switch" id="deleteOriginalSwitch" [(ngModel)]="deleteOriginal" [disabled]="!userOwnsDocument">
<label class="form-check-label" for="deleteOriginalSwitch" i18n>Delete original document after successful split</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
</button>

View File

@ -1,6 +1,6 @@
.pdf-viewer-container {
background-color: gray;
height: 350px;
height: 500px;
pdf-viewer {
width: 100%;

View File

@ -28,6 +28,9 @@
</div>
}
</div>
@if (object?.id) {
<small class="d-block mt-2" i18n>Warning: existing instances of this field will retain their current value index (e.g. option #1, #2, #3) after editing the options here</small>
}
}
@case (CustomFieldDataType.Monetary) {
<div class="my-3">

View File

@ -32,6 +32,20 @@
</div>
<pngx-input-select i18n-title title="Groups" [items]="groups" multiple="true" formControlName="groups"></pngx-input-select>
@if (object?.is_mfa_enabled && currentUserIsSuperUser) {
<label class="form-label" i18n>Two-factor Authentication</label>
<pngx-confirm-button
label="Disable Two-factor Authentication"
i18n-label
title="Disable Two-factor Authentication"
i18n-title
buttonClasses="btn-outline-danger btn-sm"
iconName="trash"
[disabled]="totpLoading"
(confirm)="deactivateTotp()">
</pngx-confirm-button>
}
</div>
<div class="col">
<pngx-permissions-select i18n-title title="Permissions" formControlName="user_permissions" [error]="error?.user_permissions" [inheritedPermissions]="inheritedPermissions"></pngx-permissions-select>

View File

@ -7,7 +7,7 @@ import {
} from '@angular/forms'
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select'
import { of } from 'rxjs'
import { of, throwError } from 'rxjs'
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { GroupService } from 'src/app/services/rest/group.service'
@ -21,10 +21,15 @@ import { EditDialogMode } from '../edit-dialog.component'
import { UserEditDialogComponent } from './user-edit-dialog.component'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { ToastService } from 'src/app/services/toast.service'
import { UserService } from 'src/app/services/rest/user.service'
import { PermissionsService } from 'src/app/services/permissions.service'
describe('UserEditDialogComponent', () => {
let component: UserEditDialogComponent
let settingsService: SettingsService
let permissionsService: PermissionsService
let toastService: ToastService
let fixture: ComponentFixture<UserEditDialogComponent>
beforeEach(async () => {
@ -71,6 +76,8 @@ describe('UserEditDialogComponent', () => {
fixture = TestBed.createComponent(UserEditDialogComponent)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser = { id: 99, username: 'user99' }
permissionsService = TestBed.inject(PermissionsService)
toastService = TestBed.inject(ToastService)
component = fixture.componentInstance
fixture.detectChanges()
@ -121,4 +128,38 @@ describe('UserEditDialogComponent', () => {
component.save()
expect(component.passwordIsSet).toBeTruthy()
})
it('should support deactivation of TOTP', () => {
component.object = { id: 99, username: 'user99' }
const deactivateSpy = jest.spyOn(
component['service'] as UserService,
'deactivateTotp'
)
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
deactivateSpy.mockReturnValueOnce(throwError(() => new Error('error')))
component.deactivateTotp()
expect(deactivateSpy).toHaveBeenCalled()
expect(toastErrorSpy).toHaveBeenCalled()
deactivateSpy.mockReturnValueOnce(of(false))
component.deactivateTotp()
expect(deactivateSpy).toHaveBeenCalled()
expect(toastErrorSpy).toHaveBeenCalled()
deactivateSpy.mockReturnValueOnce(of(true))
component.deactivateTotp()
expect(deactivateSpy).toHaveBeenCalled()
expect(toastInfoSpy).toHaveBeenCalled()
})
it('should check superuser status of current user', () => {
expect(component.currentUserIsSuperUser).toBeFalsy()
permissionsService.initialize([], {
id: 99,
username: 'user99',
is_superuser: true,
})
expect(component.currentUserIsSuperUser).toBeTruthy()
})
})

View File

@ -5,9 +5,11 @@ import { first } from 'rxjs'
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
import { Group } from 'src/app/data/group'
import { User } from 'src/app/data/user'
import { PermissionsService } from 'src/app/services/permissions.service'
import { GroupService } from 'src/app/services/rest/group.service'
import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
@Component({
selector: 'pngx-user-edit-dialog',
@ -20,12 +22,15 @@ export class UserEditDialogComponent
{
groups: Group[]
passwordIsSet: boolean = false
public totpLoading: boolean = false
constructor(
service: UserService,
activeModal: NgbActiveModal,
groupsService: GroupService,
settingsService: SettingsService
settingsService: SettingsService,
private toastService: ToastService,
private permissionsService: PermissionsService
) {
super(service, activeModal, service, settingsService)
@ -87,4 +92,30 @@ export class UserEditDialogComponent
.length > 0
super.save()
}
get currentUserIsSuperUser(): boolean {
return this.permissionsService.isSuperUser()
}
deactivateTotp() {
this.totpLoading = true
;(this.service as UserService)
.deactivateTotp(this.object)
.pipe(first())
.subscribe({
next: (result) => {
this.totpLoading = false
if (result) {
this.toastService.showInfo($localize`Totp deactivated`)
this.object.is_mfa_enabled = false
} else {
this.toastService.showError($localize`Totp deactivation failed`)
}
},
error: (e) => {
this.totpLoading = false
this.toastService.showError($localize`Totp deactivation failed`, e)
},
})
}
}

View File

@ -119,6 +119,32 @@
<div [formGroup]="formGroup">
<input type="hidden" formControlName="id" />
<pngx-input-select i18n-title title="Trigger type" [horizontal]="true" [items]="triggerTypeOptions" formControlName="type"></pngx-input-select>
@if (formGroup.get('type').value === WorkflowTriggerType.Scheduled) {
<p class="small" i18n>Set scheduled trigger offset and which field to use.</p>
<div class="row">
<div class="col-4">
<pngx-input-number i18n-title title="Offset days" formControlName="schedule_offset_days" i18n-hint hint="Use 0 for immediate." [showAdd]="false" [error]="error?.schedule_offset_days"></pngx-input-number>
</div>
<div class="col-4">
<pngx-input-select i18n-title title="Relative to" formControlName="schedule_date_field" [items]="scheduleDateFieldOptions" [error]="error?.schedule_date_field"></pngx-input-select>
</div>
@if (formGroup.get('schedule_date_field').value === 'custom_field') {
<div class="col-4">
<pngx-input-select i18n-title title="Delay custom field" formControlName="schedule_date_custom_field" [items]="dateCustomFields" i18n-hint hint="Custom field to use for date." [error]="error?.schedule_date_custom_field"></pngx-input-select>
</div>
}
</div>
<div class="row">
<div class="col-4">
<pngx-input-check i18n-title title="Recurring" formControlName="schedule_is_recurring" i18n-hint hint="Trigger is recurring." [error]="error?.schedule_is_recurring"></pngx-input-check>
</div>
<div class="col-4">
@if (formGroup.get('schedule_is_recurring').value === true) {
<pngx-input-number i18n-title title="Recurring interval days" formControlName="schedule_recurring_interval_days" i18n-hint hint="Repeat the trigger every n days." [showAdd]="false" [error]="error?.schedule_recurring_interval_days"></pngx-input-number>
}
</div>
</div>
}
<p class="small" i18n>Trigger for documents that match <em>all</em> filters specified below.</p>
<div class="row">
<div class="col">
@ -128,7 +154,7 @@
<pngx-input-text i18n-title title="Filter path" formControlName="filter_path" i18n-hint hint="Apply to documents that match this path. Wildcards specified as * are allowed. Case-normalized.</a>" [error]="error?.filter_path"></pngx-input-text>
<pngx-input-select i18n-title title="Filter mail rule" [items]="mailRules" [allowNull]="true" formControlName="filter_mailrule" i18n-hint hint="Apply to documents consumed via this mail rule." [error]="error?.filter_mailrule"></pngx-input-select>
}
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated) {
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) {
<pngx-input-select i18n-title title="Content matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
@if (patternRequired) {
<pngx-input-text i18n-title title="Content matching pattern" formControlName="match" [error]="error?.match"></pngx-input-text>
@ -138,7 +164,7 @@
}
}
</div>
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated) {
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) {
<div class="col-md-6">
<pngx-input-tags [allowCreate]="false" i18n-title title="Has any of tags" formControlName="filter_has_tags"></pngx-input-tags>
<pngx-input-select i18n-title title="Has correspondent" [items]="correspondents" [allowNull]="true" formControlName="filter_has_correspondent"></pngx-input-select>

View File

@ -22,6 +22,7 @@ import { SwitchComponent } from '../../input/switch/switch.component'
import { EditDialogMode } from '../edit-dialog.component'
import {
DOCUMENT_SOURCE_OPTIONS,
SCHEDULE_DATE_FIELD_OPTIONS,
WORKFLOW_ACTION_OPTIONS,
WORKFLOW_TYPE_OPTIONS,
WorkflowEditDialogComponent,
@ -40,6 +41,7 @@ import {
import { MATCHING_ALGORITHMS, MATCH_AUTO } from 'src/app/data/matching-model'
import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { CustomFieldDataType } from 'src/app/data/custom-field'
const workflow: Workflow = {
name: 'Workflow 1',
@ -148,7 +150,18 @@ describe('WorkflowEditDialogComponent', () => {
useValue: {
listAll: () =>
of({
results: [],
results: [
{
id: 1,
name: 'cf1',
data_type: CustomFieldDataType.String,
},
{
id: 2,
name: 'cf2',
data_type: CustomFieldDataType.Date,
},
],
}),
},
},
@ -186,7 +199,7 @@ describe('WorkflowEditDialogComponent', () => {
expect(editTitleSpy).toHaveBeenCalled()
})
it('should return source options, type options, type name', () => {
it('should return source options, type options, type name, schedule date field options', () => {
// coverage
expect(component.sourceOptions).toEqual(DOCUMENT_SOURCE_OPTIONS)
expect(component.triggerTypeOptions).toEqual(WORKFLOW_TYPE_OPTIONS)
@ -200,6 +213,9 @@ describe('WorkflowEditDialogComponent', () => {
component.getActionTypeOptionName(WorkflowActionType.Assignment)
).toEqual('Assignment')
expect(component.getActionTypeOptionName(null)).toEqual('')
expect(component.scheduleDateFieldOptions).toEqual(
SCHEDULE_DATE_FIELD_OPTIONS
)
})
it('should support add and remove triggers and actions', () => {

View File

@ -16,9 +16,10 @@ import { EditDialogComponent } from '../edit-dialog.component'
import { MailRuleService } from 'src/app/services/rest/mail-rule.service'
import { MailRule } from 'src/app/data/mail-rule'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { CustomField } from 'src/app/data/custom-field'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import {
DocumentSource,
ScheduleDateField,
WorkflowTrigger,
WorkflowTriggerType,
} from 'src/app/data/workflow-trigger'
@ -48,6 +49,25 @@ export const DOCUMENT_SOURCE_OPTIONS = [
},
]
export const SCHEDULE_DATE_FIELD_OPTIONS = [
{
id: ScheduleDateField.Added,
name: $localize`Added`,
},
{
id: ScheduleDateField.Created,
name: $localize`Created`,
},
{
id: ScheduleDateField.Modified,
name: $localize`Modified`,
},
{
id: ScheduleDateField.CustomField,
name: $localize`Custom Field`,
},
]
export const WORKFLOW_TYPE_OPTIONS = [
{
id: WorkflowTriggerType.Consumption,
@ -61,6 +81,10 @@ export const WORKFLOW_TYPE_OPTIONS = [
id: WorkflowTriggerType.DocumentUpdated,
name: $localize`Document Updated`,
},
{
id: WorkflowTriggerType.Scheduled,
name: $localize`Scheduled`,
},
]
export const WORKFLOW_ACTION_OPTIONS = [
@ -96,6 +120,7 @@ export class WorkflowEditDialogComponent
storagePaths: StoragePath[]
mailRules: MailRule[]
customFields: CustomField[]
dateCustomFields: CustomField[]
expandedItem: number = null
@ -135,7 +160,12 @@ export class WorkflowEditDialogComponent
customFieldsService
.listAll()
.pipe(first())
.subscribe((result) => (this.customFields = result.results))
.subscribe((result) => {
this.customFields = result.results
this.dateCustomFields = this.customFields?.filter(
(f) => f.data_type === CustomFieldDataType.Date
)
})
}
getCreateTitle() {
@ -314,6 +344,15 @@ export class WorkflowEditDialogComponent
filter_has_document_type: new FormControl(
trigger.filter_has_document_type
),
schedule_offset_days: new FormControl(trigger.schedule_offset_days),
schedule_is_recurring: new FormControl(trigger.schedule_is_recurring),
schedule_recurring_interval_days: new FormControl(
trigger.schedule_recurring_interval_days
),
schedule_date_field: new FormControl(trigger.schedule_date_field),
schedule_date_custom_field: new FormControl(
trigger.schedule_date_custom_field
),
}),
{ emitEvent }
)
@ -388,6 +427,10 @@ export class WorkflowEditDialogComponent
return WORKFLOW_TYPE_OPTIONS
}
get scheduleDateFieldOptions() {
return SCHEDULE_DATE_FIELD_OPTIONS
}
getTriggerTypeOptionName(type: WorkflowTriggerType): string {
return this.triggerTypeOptions.find((t) => t.id === type)?.name ?? ''
}
@ -408,6 +451,11 @@ export class WorkflowEditDialogComponent
matching_algorithm: MATCH_NONE,
match: '',
is_insensitive: true,
schedule_offset_days: 0,
schedule_is_recurring: false,
schedule_recurring_interval_days: 1,
schedule_date_field: ScheduleDateField.Added,
schedule_date_custom_field: null,
}
this.object.triggers.push(trigger)
this.createTriggerField(trigger)

View File

@ -35,23 +35,31 @@
</div>
@if (selectionModel.items) {
<div class="items" #buttonItems>
@for (item of selectionModel.itemsSorted | filter: filterText:'name'; track item; let i = $index) {
@for (item of selectionModel.items | filter: filterText:'name'; track item; let i = $index) {
@if (allowSelectNone || item.id) {
<pngx-toggleable-dropdown-button
[item]="item" [hideCount]="hideCount(item)" [state]="selectionModel.get(item.id)" [count]="getUpdatedDocumentCount(item.id)" (toggled)="selectionModel.toggle(item.id)" (exclude)="excludeClicked(item.id)" (click)="setButtonItemIndex(i - 1)" [disabled]="disabled">
[item]="item"
[hideCount]="hideCount(item)"
[opacifyCount]="!editing"
[state]="selectionModel.get(item.id)"
[count]="getUpdatedDocumentCount(item.id)"
(toggled)="selectionModel.toggle(item.id)"
(exclude)="excludeClicked(item.id)"
(click)="setButtonItemIndex(i - 1)"
[disabled]="disabled">
</pngx-toggleable-dropdown-button>
}
}
</div>
}
@if (editing) {
@if ((selectionModel.itemsSorted | filter: filterText:'name').length === 0 && createRef !== undefined) {
@if ((selectionModel.items | filter: filterText:'name').length === 0 && createRef !== undefined) {
<button class="list-group-item list-group-item-action bg-light" (click)="createClicked()" [disabled]="disabled">
<small class="ms-2"><ng-container i18n>Create</ng-container> "{{filterText}}"</small>
<i-bs width="1.5em" height="1em" name="plus"></i-bs>
</button>
}
@if ((selectionModel.itemsSorted | filter: filterText:'name').length > 0) {
@if ((selectionModel.items | filter: filterText:'name').length > 0) {
<button class="list-group-item list-group-item-action bg-light" (click)="applyClicked()" [disabled]="!modelIsDirty || disabled">
<small class="ms-2" [ngClass]="{'fw-bold': modelIsDirty}" i18n>Apply</small>
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>

View File

@ -501,7 +501,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
component.selectionModel = selectionModel
selectionModel.toggle(items[1].id)
selectionModel.apply()
expect(selectionModel.itemsSorted).toEqual([
expect(selectionModel.items).toEqual([
nullItem,
{ id: null, name: 'Null B' },
items[1],
@ -509,6 +509,37 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
])
})
it('selection model should sort items by state and document counts, if set', () => {
component.items = items.concat([{ id: 4, name: 'Item D' }])
component.selectionModel = selectionModel
component.documentCounts = [
{ id: 1, document_count: 0 }, // Tag1
{ id: 2, document_count: 1 }, // Tag2
{ id: 4, document_count: 2 },
]
component.selectionModel.apply()
expect(selectionModel.items).toEqual([
nullItem,
{ id: 4, name: 'Item D' },
items[1], // Tag2
items[0], // Tag1
])
selectionModel.toggle(items[1].id)
component.documentCounts = [
{ id: 1, document_count: 0 },
{ id: 2, document_count: 1 },
{ id: 4, document_count: 0 },
]
selectionModel.apply()
expect(selectionModel.items).toEqual([
nullItem,
items[1], // Tag2
{ id: 4, name: 'Item D' },
items[0], // Tag1
])
})
it('should set support create, keep open model and call createRef method', fakeAsync(() => {
component.items = items
component.icon = 'tag-fill'

View File

@ -43,11 +43,23 @@ export class FilterableDropdownSelectionModel {
private _intersection: Intersection = Intersection.Include
temporaryIntersection: Intersection = this._intersection
items: MatchingModel[] = []
private _documentCounts: SelectionDataItem[] = []
public set documentCounts(counts: SelectionDataItem[]) {
this._documentCounts = counts
}
get itemsSorted(): MatchingModel[] {
// TODO: this is getting called very often
return this.items.sort((a, b) => {
private _items: MatchingModel[] = []
get items(): MatchingModel[] {
return this._items
}
set items(items: MatchingModel[]) {
this._items = items
this.sortItems()
}
private sortItems() {
this._items.sort((a, b) => {
if (a.id == null && b.id != null) {
return -1
} else if (a.id != null && b.id == null) {
@ -62,6 +74,16 @@ export class FilterableDropdownSelectionModel {
this.getNonTemporary(b.id) == ToggleableItemState.NotSelected
) {
return -1
} else if (
this._documentCounts.length &&
this.getDocumentCount(a.id) > this.getDocumentCount(b.id)
) {
return -1
} else if (
this._documentCounts.length &&
this.getDocumentCount(a.id) < this.getDocumentCount(b.id)
) {
return 1
} else {
return a.name.localeCompare(b.name)
}
@ -279,6 +301,10 @@ export class FilterableDropdownSelectionModel {
)
}
getDocumentCount(id: number) {
return this._documentCounts.find((c) => c.id === id)?.document_count
}
init(map: Map<number, ToggleableItemState>) {
this.temporarySelectionStates = map
this.apply()
@ -291,6 +317,7 @@ export class FilterableDropdownSelectionModel {
})
this._logicalOperator = this.temporaryLogicalOperator
this._intersection = this.temporaryIntersection
this.sortItems()
}
reset(complete: boolean = false) {
@ -423,7 +450,11 @@ export class FilterableDropdownComponent implements OnDestroy, OnInit {
}
@Input()
documentCounts: SelectionDataItem[]
set documentCounts(counts: SelectionDataItem[]) {
if (counts) {
this.selectionModel.documentCounts = counts
}
}
@Input()
shortcutKey: string
@ -536,9 +567,7 @@ export class FilterableDropdownComponent implements OnDestroy, OnInit {
}
getUpdatedDocumentCount(id: number) {
if (this.documentCounts) {
return this.documentCounts.find((c) => c.id === id)?.document_count
}
return this.selectionModel.getDocumentCount(id)
}
listKeyDown(event: KeyboardEvent) {

View File

@ -1,4 +1,9 @@
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="toggleItem($event)" [disabled]="disabled">
<button
class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom"
[class.opacity-50]="opacifyCount && !hideCount && currentCount === 0"
role="menuitem"
(click)="toggleItem($event)"
[disabled]="disabled">
<div class="selected-icon me-1">
@if (isChecked()) {
<i-bs width="1em" height="1em" name="check"></i-bs>
@ -18,6 +23,6 @@
}
</div>
@if (!hideCount) {
<div class="badge bg-light text-dark rounded-pill ms-auto me-1">{{count ?? item.document_count}}</div>
<div class="badge bg-light text-dark rounded-pill ms-auto me-1">{{currentCount}}</div>
}
</button>

View File

@ -29,6 +29,9 @@ export class ToggleableDropdownButtonComponent {
@Input()
hideCount: boolean = false
@Input()
opacifyCount: boolean = true
@Output()
toggled = new EventEmitter()
@ -39,6 +42,10 @@ export class ToggleableDropdownButtonComponent {
return 'is_inbox_tag' in this.item
}
get currentCount(): number {
return this.count ?? this.item.document_count
}
toggleItem(event: MouseEvent): void {
if (this.state == ToggleableItemState.Selected) {
this.exclude.emit()

View File

@ -1,3 +1,9 @@
<a [href]="link ?? previewUrl" class="{{linkClasses}}" [target]="linkTarget" [title]="linkTitle"
[ngbPopover]="previewContent" [popoverTitle]="document.title | documentTitle" container="body"
autoClose="true" [popoverClass]="popoverClass" (mouseenter)="mouseEnterPreview()" (mouseleave)="mouseLeavePreview()" #popover="ngbPopover">
<ng-content></ng-content>
</a>
<ng-template #previewContent>
<div class="preview-popup-container">
@if (error) {
<div class="w-100 h-100 position-relative">
@ -28,3 +34,4 @@
}
}
</div>
</ng-template>

View File

@ -1,4 +1,9 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import {
ComponentFixture,
fakeAsync,
TestBed,
tick,
} from '@angular/core/testing'
import { PreviewPopupComponent } from './preview-popup.component'
import { By } from '@angular/platform-browser'
@ -15,6 +20,8 @@ import {
withInterceptorsFromDi,
} from '@angular/common/http'
import { of, throwError } from 'rxjs'
import { NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
const doc = {
id: 10,
@ -34,8 +41,12 @@ describe('PreviewPopupComponent', () => {
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [PreviewPopupComponent, SafeUrlPipe],
imports: [NgxBootstrapIconsModule.pick(allIcons), PdfViewerModule],
declarations: [PreviewPopupComponent, SafeUrlPipe, DocumentTitlePipe],
imports: [
NgxBootstrapIconsModule.pick(allIcons),
PdfViewerModule,
NgbPopoverModule,
],
providers: [
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
@ -70,12 +81,14 @@ describe('PreviewPopupComponent', () => {
it('should render object if native PDF viewer enabled', () => {
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, true)
component.popover.open()
fixture.detectChanges()
expect(fixture.debugElement.query(By.css('object'))).not.toBeNull()
})
it('should render pngx viewer if native PDF viewer disabled', () => {
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
component.popover.open()
fixture.detectChanges()
expect(fixture.debugElement.query(By.css('object'))).toBeNull()
expect(fixture.debugElement.query(By.css('pdf-viewer'))).not.toBeNull()
@ -83,6 +96,7 @@ describe('PreviewPopupComponent', () => {
it('should show lock icon on password error', () => {
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
component.popover.open()
component.onError({ name: 'PasswordException' })
fixture.detectChanges()
expect(component.requiresPassword).toBeTruthy()
@ -93,16 +107,18 @@ describe('PreviewPopupComponent', () => {
component.document.original_file_name = 'sample.png'
component.document.mime_type = 'image/png'
component.document.archived_file_name = undefined
component.popover.open()
fixture.detectChanges()
expect(fixture.debugElement.query(By.css('object'))).not.toBeNull()
})
it('should show message on error', () => {
component.popover.open()
component.onError({})
fixture.detectChanges()
expect(fixture.debugElement.nativeElement.textContent).toContain(
'Error loading preview'
)
expect(
fixture.debugElement.query(By.css('.popover')).nativeElement.textContent
).toContain('Error loading preview')
})
it('should get text content from http if appropriate', () => {
@ -122,4 +138,17 @@ describe('PreviewPopupComponent', () => {
component.init()
expect(component.previewText).toEqual('Preview text')
})
it('should show preview on mouseover after delay to preload content', fakeAsync(() => {
component.mouseEnterPreview()
expect(component.popover.isOpen()).toBeTruthy()
tick(600)
component.close()
component.mouseEnterPreview()
tick(100)
component.mouseLeavePreview()
tick(600)
expect(component.popover.isOpen()).toBeFalsy()
}))
})

View File

@ -1,5 +1,6 @@
import { HttpClient } from '@angular/common/http'
import { Component, Input, OnDestroy } from '@angular/core'
import { Component, Input, OnDestroy, ViewChild } from '@angular/core'
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
import { first, Subject, takeUntil } from 'rxjs'
import { Document } from 'src/app/data/document'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
@ -23,6 +24,18 @@ export class PreviewPopupComponent implements OnDestroy {
return this._document
}
@Input()
link: string
@Input()
linkClasses: string = 'btn btn-sm btn-outline-secondary'
@Input()
linkTarget: string = '_blank'
@Input()
linkTitle: string = $localize`Open preview`
unsubscribeNotifier: Subject<any> = new Subject()
error = false
@ -31,6 +44,12 @@ export class PreviewPopupComponent implements OnDestroy {
previewText: string
@ViewChild('popover') popover: NgbPopover
mouseOnPreview: boolean
popoverClass: string = 'shadow popover-preview'
get renderAsObject(): boolean {
return (this.isPdf && this.useNativePdfViewer) || !this.isPdf
}
@ -83,4 +102,33 @@ export class PreviewPopupComponent implements OnDestroy {
this.error = true
}
}
get previewUrl() {
return this.documentService.getPreviewUrl(this.document.id)
}
mouseEnterPreview() {
this.mouseOnPreview = true
if (!this.popover.isOpen()) {
// we're going to open but hide to pre-load content during hover delay
this.popover.open()
this.popoverClass = 'shadow popover-preview pe-none opacity-0'
setTimeout(() => {
if (this.mouseOnPreview) {
// show popover
this.popoverClass = this.popoverClass.replace('pe-none opacity-0', '')
} else {
this.popover.close()
}
}, 600)
}
}
mouseLeavePreview() {
this.mouseOnPreview = false
}
public close() {
this.popover.close(false)
}
}

View File

@ -5,6 +5,8 @@
</button>
</div>
<div class="modal-body">
<div class="row">
<div class="col-12 col-md-6">
<pngx-input-text i18n-title title="Email" formControlName="email" (keyup)="onEmailKeyUp($event)" [error]="error?.email"></pngx-input-text>
<div ngbAccordion>
<div ngbAccordionItem="first" [collapsed]="!showEmailConfirm" class="border-0 bg-transparent">
@ -54,6 +56,8 @@
</div>
<div class="form-text text-muted text-end fst-italic" i18n>Warning: changing the token cannot be undone</div>
</div>
</div>
<div class="col-12 col-md-6">
@if (socialAccounts?.length > 0) {
<div class="mb-3">
<p i18n>Connected social accounts</p>
@ -93,6 +97,87 @@
</div>
</div>
}
@if (!isTotpEnabled) {
<div ngbAccordion>
<div ngbAccordionItem>
<h2 ngbAccordionHeader>
<button ngbAccordionButton (click)="gettotpSettings()" i18n>Two-factor Authentication</button>
</h2>
<div ngbAccordionCollapse>
<div ngbAccordionBody>
<ng-template>
@if (totpSettingsLoading) {
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div>
} @else if (totpSettings) {
<figure class="figure">
<div class="bg-white d-inline-block" [innerHTML]="totpSettings.qr_svg | safeHtml"></div>
<figcaption class="figure-caption text-end mt-2" i18n>Scan the QR code with your authenticator app and then enter the code below</figcaption>
</figure>
<p>
<ng-container i18n>Authenticator secret</ng-container>: <code>{{totpSettings.secret}}</code>.
<ng-container i18n>You can store this secret and use it to reinstall your authenticator app at a later time.</ng-container>
</p>
<div class="input-group mb-3">
<input type="text" class="form-control" formControlName="totp_code" placeholder="Code" i18n-placeholder>
<button type="button" class="btn btn-primary ml-auto" (click)="activateTotp()" [disabled]="totpLoading">
<ng-container i18n>Enable</ng-container>
@if (totpLoading) {
<div class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div>
}
</button>
</div>
}
</ng-template>
</div>
</div>
</div>
</div>
} @else {
<label class="d-block mb-2" i18n>Two-factor Authentication</label>
@if (recoveryCodes) {
<div class="alert alert-warning" role="alert">
<i-bs name="exclamation-triangle"></i-bs>&nbsp;<ng-container i18n>Recovery codes will not be shown again, make sure to save them.</ng-container>
</div>
<div class="d-flex flex-row align-items-start mb-3">
<ul class="list-group w-50">
@for (code of recoveryCodes; track code; let i = $index) {
@if (i % 2 === 0) {
<li class="list-group-item d-flex justify-content-around align-items-center">
<code>{{code}}</code>
@if (recoveryCodes[i + 1]) {
<code>{{recoveryCodes[i + 1]}}</code>
}
</li>
}
}
</ul>
<button type="button" class="btn btn-sm btn-outline-secondary ms-2" (click)="copyRecoveryCodes()" i18n-title title="Copy">
@if (!codesCopied) {
<i-bs width="1em" height="1em" name="clipboard-fill"></i-bs>
&nbsp;<span i18n>Copy codes</span>
}
@if (codesCopied) {
<i-bs width="1em" height="1em" name="clipboard-check-fill" class="text-primary"></i-bs>
&nbsp;<span class="text-primary" i18n>Copied!</span>
}
</button>
</div>
}
<pngx-confirm-button
label="Disable Two-factor Authentication"
i18n-label
title="Disable Two-factor Authentication"
i18n-title
buttonClasses="btn-outline-danger btn-sm"
iconName="trash"
[disabled]="totpLoading"
(confirm)="deactivateTotp()">
</pngx-confirm-button>
}
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button>

View File

@ -294,4 +294,85 @@ describe('ProfileEditDialogComponent', () => {
expect(disconnectSpy).toHaveBeenCalled()
expect(component.socialAccounts).not.toContainEqual(socialAccount)
})
it('should get totp settings', () => {
const settings = {
url: 'http://localhost/',
qr_svg: 'svg',
secret: 'secret',
}
const getSpy = jest.spyOn(profileService, 'getTotpSettings')
const toastSpy = jest.spyOn(toastService, 'showError')
getSpy.mockReturnValueOnce(
throwError(() => new Error('failed to get settings'))
)
component.gettotpSettings()
expect(getSpy).toHaveBeenCalled()
expect(toastSpy).toHaveBeenCalled()
getSpy.mockReturnValue(of(settings))
component.gettotpSettings()
expect(getSpy).toHaveBeenCalled()
expect(component.totpSettings).toEqual(settings)
})
it('should activate totp', () => {
const activateSpy = jest.spyOn(profileService, 'activateTotp')
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const error = new Error('failed to activate totp')
activateSpy.mockReturnValueOnce(throwError(() => error))
component.totpSettings = {
url: 'http://localhost/',
qr_svg: 'svg',
secret: 'secret',
}
component.form.get('totp_code').patchValue('123456')
component.activateTotp()
expect(activateSpy).toHaveBeenCalledWith(
component.totpSettings.secret,
component.form.get('totp_code').value
)
expect(toastErrorSpy).toHaveBeenCalled()
activateSpy.mockReturnValueOnce(of({ success: false, recovery_codes: [] }))
component.activateTotp()
expect(toastErrorSpy).toHaveBeenCalledWith('Error activating TOTP', error)
activateSpy.mockReturnValueOnce(
of({ success: true, recovery_codes: ['1', '2', '3'] })
)
component.activateTotp()
expect(toastInfoSpy).toHaveBeenCalled()
expect(component.isTotpEnabled).toBeTruthy()
expect(component.recoveryCodes).toEqual(['1', '2', '3'])
})
it('should deactivate totp', () => {
const deactivateSpy = jest.spyOn(profileService, 'deactivateTotp')
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const error = new Error('failed to deactivate totp')
deactivateSpy.mockReturnValueOnce(throwError(() => error))
component.deactivateTotp()
expect(deactivateSpy).toHaveBeenCalled()
expect(toastErrorSpy).toHaveBeenCalled()
deactivateSpy.mockReturnValueOnce(of(false))
component.deactivateTotp()
expect(toastErrorSpy).toHaveBeenCalledWith('Error deactivating TOTP', error)
deactivateSpy.mockReturnValueOnce(of(true))
component.deactivateTotp()
expect(toastInfoSpy).toHaveBeenCalled()
expect(component.isTotpEnabled).toBeFalsy()
})
it('should copy recovery codes', fakeAsync(() => {
const copySpy = jest.spyOn(clipboard, 'copy')
component.recoveryCodes = ['1', '2', '3']
component.copyRecoveryCodes()
expect(copySpy).toHaveBeenCalledWith('1\n2\n3')
tick(3000)
}))
})

View File

@ -2,7 +2,11 @@ import { Component, OnDestroy, OnInit } from '@angular/core'
import { FormControl, FormGroup } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { ProfileService } from 'src/app/services/profile.service'
import { SocialAccount, SocialAccountProvider } from 'src/app/data/user-profile'
import {
TotpSettings,
SocialAccount,
SocialAccountProvider,
} from 'src/app/data/user-profile'
import { ToastService } from 'src/app/services/toast.service'
import { Subject, takeUntil } from 'rxjs'
import { Clipboard } from '@angular/cdk/clipboard'
@ -25,6 +29,7 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
first_name: new FormControl(''),
last_name: new FormControl(''),
auth_token: new FormControl(''),
totp_code: new FormControl(''),
})
private currentPassword: string
@ -38,7 +43,14 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
private emailConfirm: string
public showEmailConfirm: boolean = false
public isTotpEnabled: boolean = false
public totpSettings: TotpSettings
public totpSettingsLoading: boolean = false
public totpLoading: boolean = false
public recoveryCodes: string[]
public copied: boolean = false
public codesCopied: boolean = false
public socialAccounts: SocialAccount[] = []
public socialAccountProviders: SocialAccountProvider[] = []
@ -70,6 +82,7 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
this.onPasswordChange()
})
this.socialAccounts = profile.social_accounts
this.isTotpEnabled = profile.is_mfa_enabled
})
this.profileService
@ -147,6 +160,7 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
const passwordChanged =
this.newPassword && this.currentPassword !== this.newPassword
const profile = Object.assign({}, this.form.value)
delete profile.totp_code
this.networkActive = true
this.profileService
.update(profile)
@ -213,4 +227,81 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
},
})
}
public gettotpSettings(): void {
this.totpSettingsLoading = true
this.profileService
.getTotpSettings()
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (totpSettings) => {
this.totpSettingsLoading = false
this.totpSettings = totpSettings
},
error: (error) => {
this.toastService.showError(
$localize`Error fetching TOTP settings`,
error
)
this.totpSettingsLoading = false
},
})
}
public activateTotp(): void {
this.totpLoading = true
this.form.get('totp_code').disable()
this.profileService
.activateTotp(this.totpSettings.secret, this.form.get('totp_code').value)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (activationResponse) => {
this.totpLoading = false
this.isTotpEnabled = activationResponse.success
this.recoveryCodes = activationResponse.recovery_codes
this.form.get('totp_code').enable()
if (activationResponse.success) {
this.toastService.showInfo($localize`TOTP activated successfully`)
} else {
this.toastService.showError($localize`Error activating TOTP`)
}
},
error: (error) => {
this.totpLoading = false
this.form.get('totp_code').enable()
this.toastService.showError($localize`Error activating TOTP`, error)
},
})
}
public deactivateTotp(): void {
this.totpLoading = true
this.profileService
.deactivateTotp()
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (success) => {
this.totpLoading = false
this.isTotpEnabled = !success
this.recoveryCodes = null
if (success) {
this.toastService.showInfo($localize`TOTP deactivated successfully`)
} else {
this.toastService.showError($localize`Error deactivating TOTP`)
}
},
error: (error) => {
this.totpLoading = false
this.toastService.showError($localize`Error deactivating TOTP`, error)
},
})
}
public copyRecoveryCodes(): void {
this.clipboard.copy(this.recoveryCodes.join('\n'))
this.codesCopied = true
setTimeout(() => {
this.codesCopied = false
}, 3000)
}
}

View File

@ -389,6 +389,15 @@
<img [src]="previewUrl | safeUrl" width="100%" height="100%" alt="{{title}}" />
</div>
}
@case (ContentRenderType.TIFF) {
@if (!tiffError) {
<div class="preview-sticky">
<img [src]="tiffURL" width="100%" height="100%" alt="{{title}}" />
</div>
} @else {
<div class="preview-sticky bg-light p-3 overflow-auto whitespace-preserve" width="100%">{{tiffError}}</div>
}
}
@case (ContentRenderType.Other) {
<object [data]="previewUrl | safeUrl" class="preview-sticky" width="100%"></object>
}

View File

@ -61,6 +61,7 @@ textarea.rtl {
width: 100%;
height: 100%;
object-fit: contain;
object-position: top;
}
.thumb-preview {

View File

@ -1270,4 +1270,46 @@ describe('DocumentDetailComponent', () => {
expect(component.createDisabled(DataType.StoragePath)).toBeFalsy()
expect(component.createDisabled(DataType.Tag)).toBeFalsy()
})
it('should call tryRenderTiff when no archive and file is tiff', () => {
initNormally()
const tiffRenderSpy = jest.spyOn(
DocumentDetailComponent.prototype as any,
'tryRenderTiff'
)
const doc = Object.assign({}, component.document)
doc.archived_file_name = null
doc.mime_type = 'image/tiff'
jest
.spyOn(documentService, 'getMetadata')
.mockReturnValue(
of({ has_archive_version: false, original_mime_type: 'image/tiff' })
)
component.updateComponent(doc)
fixture.detectChanges()
expect(component.archiveContentRenderType).toEqual(
component.ContentRenderType.TIFF
)
expect(tiffRenderSpy).toHaveBeenCalled()
})
it('should try to render tiff and show error if failed', () => {
initNormally()
// just the text request
httpTestingController.expectOne(component.previewUrl)
// invalid tiff
component['tryRenderTiff']()
httpTestingController
.expectOne(component.previewUrl)
.flush(new ArrayBuffer(100)) // arraybuffer
expect(component.tiffError).not.toBeUndefined()
// http error
component['tryRenderTiff']()
httpTestingController
.expectOne(component.previewUrl)
.error(new ErrorEvent('failed'))
expect(component.tiffError).not.toBeUndefined()
})
})

View File

@ -72,6 +72,7 @@ import { DeletePagesConfirmDialogComponent } from '../common/confirm-dialog/dele
import { HotKeyService } from 'src/app/services/hot-key.service'
import { PDFDocumentProxy } from 'ng2-pdf-viewer'
import { DataType } from 'src/app/data/datatype'
import * as UTIF from 'utif'
enum DocumentDetailNavIDs {
Details = 1,
@ -89,6 +90,7 @@ enum ContentRenderType {
Text = 'text',
Other = 'other',
Unknown = 'unknown',
TIFF = 'tiff',
}
enum ZoomSetting {
@ -136,6 +138,8 @@ export class DocumentDetailComponent
downloadUrl: string
downloadOriginalUrl: string
previewLoaded: boolean = false
tiffURL: string
tiffError: string
correspondents: Correspondent[]
documentTypes: DocumentType[]
@ -244,6 +248,8 @@ export class DocumentDetailComponent
['text/plain', 'application/csv', 'text/csv'].includes(mimeType)
) {
return ContentRenderType.Text
} else if (mimeType.indexOf('tiff') >= 0) {
return ContentRenderType.TIFF
} else if (mimeType?.indexOf('image/') === 0) {
return ContentRenderType.Image
}
@ -542,6 +548,9 @@ export class DocumentDetailComponent
this.document = doc
this.requiresPassword = false
this.updateFormForCustomFields()
if (this.archiveContentRenderType === ContentRenderType.TIFF) {
this.tryRenderTiff()
}
this.documentsService
.getMetadata(doc.id)
.pipe(
@ -721,6 +730,7 @@ export class DocumentDetailComponent
save(close: boolean = false) {
this.networkActive = true
;(document.activeElement as HTMLElement)?.dispatchEvent(new Event('change'))
this.documentsService
.update(this.document)
.pipe(first())
@ -1163,6 +1173,7 @@ export class DocumentDetailComponent
splitDocument() {
let modal = this.modalService.open(SplitConfirmDialogComponent, {
backdrop: 'static',
size: 'lg',
})
modal.componentInstance.title = $localize`Split confirm`
modal.componentInstance.messageBold = $localize`This operation will split the selected document(s) into new documents.`
@ -1201,6 +1212,7 @@ export class DocumentDetailComponent
rotateDocument() {
let modal = this.modalService.open(RotateConfirmDialogComponent, {
backdrop: 'static',
size: 'lg',
})
modal.componentInstance.title = $localize`Rotate confirm`
modal.componentInstance.messageBold = $localize`This operation will permanently rotate the original version of the current document.`
@ -1275,4 +1287,45 @@ export class DocumentDetailComponent
})
})
}
private tryRenderTiff() {
this.http.get(this.previewUrl, { responseType: 'arraybuffer' }).subscribe({
next: (res) => {
/* istanbul ignore next */
try {
// See UTIF.js > _imgLoaded
const tiffIfds: any[] = UTIF.decode(res)
var vsns = tiffIfds,
ma = 0,
page = vsns[0]
if (tiffIfds[0].subIFD) vsns = vsns.concat(tiffIfds[0].subIFD)
for (var i = 0; i < vsns.length; i++) {
var img = vsns[i]
if (img['t258'] == null || img['t258'].length < 3) continue
var ar = img['t256'] * img['t257']
if (ar > ma) {
ma = ar
page = img
}
}
UTIF.decodeImage(res, page, tiffIfds)
const rgba = UTIF.toRGBA8(page)
const { width: w, height: h } = page
var cnv = document.createElement('canvas')
cnv.width = w
cnv.height = h
var ctx = cnv.getContext('2d'),
imgd = ctx.createImageData(w, h)
for (var i = 0; i < rgba.length; i++) imgd.data[i] = rgba[i]
ctx.putImageData(imgd, 0, 0)
this.tiffURL = cnv.toDataURL()
} catch (err) {
this.tiffError = $localize`An error occurred loading tiff: ${err.toString()}`
}
},
error: (err) => {
this.tiffError = $localize`An error occurred loading tiff: ${err.toString()}`
},
})
}
}

View File

@ -782,11 +782,11 @@ export class BulkEditorComponent
rotateSelected() {
let modal = this.modalService.open(RotateConfirmDialogComponent, {
backdrop: 'static',
size: 'lg',
})
const rotateDialog = modal.componentInstance as RotateConfirmDialogComponent
rotateDialog.title = $localize`Rotate confirm`
rotateDialog.messageBold = $localize`This operation will permanently rotate the original version of ${this.list.selected.size} document(s).`
rotateDialog.message = $localize`This will alter the original copy.`
rotateDialog.btnClass = 'btn-danger'
rotateDialog.btnCaption = $localize`Proceed`
rotateDialog.documentID = Array.from(this.list.selected)[0]

View File

@ -1,4 +1,4 @@
<div class="card mb-3 shadow-sm bg-light" [class.card-selected]="selected" [class.document-card]="selectable" [class.popover-hidden]="popoverHidden" (mouseleave)="mouseLeaveCard()">
<div class="card mb-3 shadow-sm bg-light" [class.card-selected]="selected" [class.document-card]="selectable" (mouseleave)="mouseLeaveCard()">
<div class="row g-0">
<div class="col-md-2 doc-img-container rounded-start" (click)="this.toggleSelected.emit($event)" (dblclick)="dblClickDocument.emit()">
<img [src]="getThumbUrl()" class="card-img doc-img border-end rounded-start" [class.inverted]="getIsThumbInverted()">
@ -54,16 +54,11 @@
<i-bs name="diagram-3"></i-bs>&nbsp;<span class="d-none d-md-inline" i18n>More like this</span>
</a>
<a routerLink="/documents/{{document.id}}" class="btn btn-sm btn-outline-secondary" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
<i-bs name="box-arrow-in-right"></i-bs>&nbsp;<span class="d-none d-md-inline" i18n>Open</span>
<i-bs name="file-earmark-richtext"></i-bs>&nbsp;<span class="d-none d-md-inline" i18n>Open</span>
</a>
<a class="btn btn-sm btn-outline-secondary" target="_blank" [href]="previewUrl"
[ngbPopover]="previewContent" [popoverTitle]="document.title | documentTitle"
autoClose="true" popoverClass="shadow popover-preview" (mouseenter)="mouseEnterPreview()" (mouseleave)="mouseLeavePreview()" #popover="ngbPopover">
<pngx-preview-popup [document]="document" #popupPreview>
<i-bs name="eye"></i-bs>&nbsp;<span class="d-none d-md-inline" i18n>View</span>
</a>
<ng-template #previewContent>
<pngx-preview-popup [document]="document"></pngx-preview-popup>
</ng-template>
</pngx-preview-popup>
<a class="btn btn-sm btn-outline-secondary" [href]="getDownloadUrl()">
<i-bs name="download"></i-bs>&nbsp;<span class="d-none d-md-inline" i18n>Download</span>
</a>

View File

@ -1,11 +1,6 @@
import { DatePipe } from '@angular/common'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { By } from '@angular/platform-browser'
import { RouterTestingModule } from '@angular/router/testing'
import {
@ -84,21 +79,6 @@ describe('DocumentCardLargeComponent', () => {
expect(fixture.nativeElement.textContent).toContain('8 pages')
})
it('should show preview on mouseover after delay to preload content', fakeAsync(() => {
component.mouseEnterPreview()
expect(component.popover.isOpen()).toBeTruthy()
expect(component.popoverHidden).toBeTruthy()
tick(600)
expect(component.popoverHidden).toBeFalsy()
component.mouseLeaveCard()
component.mouseEnterPreview()
tick(100)
component.mouseLeavePreview()
tick(600)
expect(component.popover.isOpen()).toBeFalsy()
}))
it('should trim content', () => {
expect(component.contentTrimmed).toHaveLength(503) // includes ...
})

View File

@ -12,9 +12,9 @@ import {
} from 'src/app/data/document'
import { DocumentService } from 'src/app/services/rest/document.service'
import { SettingsService } from 'src/app/services/settings.service'
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
import { PreviewPopupComponent } from '../../common/preview-popup/preview-popup.component'
@Component({
selector: 'pngx-document-card-large',
@ -65,7 +65,7 @@ export class DocumentCardLargeComponent extends ComponentWithPermissions {
@Output()
clickMoreLike = new EventEmitter()
@ViewChild('popover') popover: NgbPopover
@ViewChild('popupPreview') popupPreview: PreviewPopupComponent
mouseOnPreview = false
popoverHidden = true
@ -112,29 +112,8 @@ export class DocumentCardLargeComponent extends ComponentWithPermissions {
return this.documentService.getPreviewUrl(this.document.id)
}
mouseEnterPreview() {
this.mouseOnPreview = true
if (!this.popover.isOpen()) {
// we're going to open but hide to pre-load content during hover delay
this.popover.open()
this.popoverHidden = true
setTimeout(() => {
if (this.mouseOnPreview) {
// show popover
this.popoverHidden = false
} else {
this.popover.close()
}
}, 600)
}
}
mouseLeavePreview() {
this.mouseOnPreview = false
}
mouseLeaveCard() {
this.popover.close()
this.popupPreview.close()
}
get contentTrimmed() {

View File

@ -1,5 +1,5 @@
<div class="col p-2 h-100">
<div class="card h-100 shadow-sm document-card" [class.card-selected]="selected" [class.popover-hidden]="popoverHidden" (mouseleave)="mouseLeaveCard()">
<div class="card h-100 shadow-sm document-card" [class.card-selected]="selected" (mouseleave)="mouseLeaveCard()">
<div class="border-bottom doc-img-container rounded-top" (click)="this.toggleSelected.emit($event)" (dblclick)="dblClickDocument.emit(this)">
<img class="card-img doc-img" [class.inverted]="getIsThumbInverted()" [src]="getThumbUrl()">
@ -127,16 +127,11 @@
<div class="d-flex justify-content-between align-items-center">
<div class="btn-group w-100">
<a routerLink="/documents/{{document.id}}" class="btn btn-sm btn-outline-secondary" title="Open" i18n-title *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }" i18n-title>
<i-bs name="box-arrow-in-right"></i-bs>
<i-bs name="file-earmark-richtext"></i-bs>
</a>
<a [href]="previewUrl" target="_blank" class="btn btn-sm btn-outline-secondary"
[ngbPopover]="previewContent" [popoverTitle]="document.title | documentTitle"
autoClose="true" popoverClass="shadow popover-preview" (mouseenter)="mouseEnterPreview()" (mouseleave)="mouseLeavePreview()" #popover="ngbPopover">
<pngx-preview-popup [document]="document" #popupPreview>
<i-bs name="eye"></i-bs>
</a>
<ng-template #previewContent>
<pngx-preview-popup [document]="document"></pngx-preview-popup>
</ng-template>
</pngx-preview-popup>
<a [href]="getDownloadUrl()" class="btn btn-sm btn-outline-secondary" title="Download" i18n-title (click)="$event.stopPropagation()">
<i-bs name="download"></i-bs>
</a>

View File

@ -1,11 +1,6 @@
import { DatePipe } from '@angular/common'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { RouterTestingModule } from '@angular/router/testing'
import {
NgbPopoverModule,
@ -116,19 +111,4 @@ describe('DocumentCardSmallComponent', () => {
fixture.debugElement.queryAll(By.directive(TagComponent))
).toHaveLength(6)
})
it('should show preview on mouseover after delay to preload content', fakeAsync(() => {
component.mouseEnterPreview()
expect(component.popover.isOpen()).toBeTruthy()
expect(component.popoverHidden).toBeTruthy()
tick(600)
expect(component.popoverHidden).toBeFalsy()
component.mouseLeaveCard()
component.mouseEnterPreview()
tick(100)
component.mouseLeavePreview()
tick(600)
expect(component.popover.isOpen()).toBeFalsy()
}))
})

View File

@ -13,9 +13,9 @@ import {
} from 'src/app/data/document'
import { DocumentService } from 'src/app/services/rest/document.service'
import { SettingsService } from 'src/app/services/settings.service'
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
import { PreviewPopupComponent } from '../../common/preview-popup/preview-popup.component'
@Component({
selector: 'pngx-document-card-small',
@ -61,10 +61,7 @@ export class DocumentCardSmallComponent extends ComponentWithPermissions {
moreTags: number = null
@ViewChild('popover') popover: NgbPopover
mouseOnPreview = false
popoverHidden = true
@ViewChild('popupPreview') popupPreview: PreviewPopupComponent
getIsThumbInverted() {
return this.settingsService.get(SETTINGS_KEYS.DARK_MODE_THUMB_INVERTED)
@ -78,10 +75,6 @@ export class DocumentCardSmallComponent extends ComponentWithPermissions {
return this.documentService.getDownloadUrl(this.document.id)
}
get previewUrl() {
return this.documentService.getPreviewUrl(this.document.id)
}
get privateName() {
return $localize`Private`
}
@ -100,29 +93,8 @@ export class DocumentCardSmallComponent extends ComponentWithPermissions {
)
}
mouseEnterPreview() {
this.mouseOnPreview = true
if (!this.popover.isOpen()) {
// we're going to open but hide to pre-load content during hover delay
this.popover.open()
this.popoverHidden = true
setTimeout(() => {
if (this.mouseOnPreview) {
// show popover
this.popoverHidden = false
} else {
this.popover.close()
}
}, 600)
}
}
mouseLeavePreview() {
this.mouseOnPreview = false
}
mouseLeaveCard() {
this.popover.close()
this.popupPreview.close()
}
get notesEnabled(): boolean {

View File

@ -292,7 +292,12 @@
@if (activeDisplayFields.includes(DisplayField.TITLE) || activeDisplayFields.includes(DisplayField.TAGS)) {
<td width="30%">
@if (activeDisplayFields.includes(DisplayField.TITLE)) {
<div class="d-inline-block" (mouseleave)="popupPreview.close()">
<a routerLink="/documents/{{d.id}}" title="Edit document" i18n-title style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a>
<pngx-preview-popup [document]="d" linkClasses="btn btn-sm btn-link text-secondary" linkTitle="Preview document" (click)="$event.stopPropagation()" i18n-linkTitle #popupPreview>
<i-bs name="eye"></i-bs>
</pngx-preview-popup>
</div>
}
@if (activeDisplayFields.includes(DisplayField.TAGS)) {
@for (t of d.tags$ | async; track t) {

View File

@ -72,6 +72,7 @@ import { IsNumberPipe } from 'src/app/pipes/is-number.pipe'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { PermissionsService } from 'src/app/services/permissions.service'
import { NgSelectModule } from '@ng-select/ng-select'
import { PreviewPopupComponent } from '../common/preview-popup/preview-popup.component'
const docs: Document[] = [
{
@ -137,6 +138,7 @@ describe('DocumentListComponent', () => {
UsernamePipe,
SafeHtmlPipe,
IsNumberPipe,
PreviewPopupComponent,
],
imports: [
RouterTestingModule.withRoutes(routes),
@ -698,5 +700,31 @@ describe('DocumentListComponent', () => {
fixture.detectChanges()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'o' }))
expect(detailSpy).toHaveBeenCalledWith(docs[1].id)
const lotsOfDocs: Document[] = Array.from({ length: 100 }, (_, i) => ({
id: i + 1,
title: `Doc${i + 1}`,
notes: [],
tags$: new Subject(),
content: `document content ${i + 1}`,
}))
jest
.spyOn(documentListService, 'documents', 'get')
.mockReturnValue(lotsOfDocs)
jest
.spyOn(documentService, 'listAllFilteredIds')
.mockReturnValue(of(lotsOfDocs.map((d) => d.id)))
jest.spyOn(documentListService, 'getLastPage').mockReturnValue(4)
fixture.detectChanges()
expect(component.list.currentPage).toEqual(1)
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowRight', ctrlKey: true })
)
expect(component.list.currentPage).toEqual(2)
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowLeft', ctrlKey: true })
)
expect(component.list.currentPage).toEqual(1)
})
})

View File

@ -273,6 +273,30 @@ export class DocumentListComponent
}
}
})
this.hotKeyService
.addShortcut({
keys: 'control.arrowleft',
description: $localize`Previous page`,
})
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
if (this.list.currentPage > 1) {
this.list.currentPage--
}
})
this.hotKeyService
.addShortcut({
keys: 'control.arrowright',
description: $localize`Next page`,
})
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
if (this.list.currentPage < this.list.getLastPage()) {
this.list.currentPage++
}
})
}
ngOnDestroy() {

View File

@ -77,14 +77,19 @@ describe('CorrespondentListComponent', () => {
it('should support very old date strings', () => {
jest.spyOn(correspondentsService, 'listFiltered').mockReturnValue(
of({
count: 1,
all: [1],
count: 2,
all: [1, 2],
results: [
{
id: 1,
name: 'Correspondent1',
last_correspondence: '1832-12-31T15:32:54-07:52:58',
},
{
id: 2,
name: 'Correspondent2',
last_correspondence: '1901-07-01T00:00:00+00:09:21',
},
],
})
)

View File

@ -52,7 +52,7 @@ export class CorrespondentListComponent extends ManagementListComponent<Correspo
date = new Date(
c.last_correspondence
?.toString()
.replace(/-(\d\d):\d\d:\d\d/gm, `-$1:00`)
.replace(/([-+])(\d\d):\d\d:\d\d/gm, `$1$2:00`)
)
}
return this.datePipe.transform(date)

View File

@ -30,4 +30,6 @@ export interface PaperlessTask extends ObjectWithId {
result?: string
related_document?: number
owner?: number
}

View File

@ -17,6 +17,8 @@ export enum GlobalSearchType {
TITLE_CONTENT = 'title-content',
}
export const PAPERLESS_GREEN_HEX = '#17541f'
export const SETTINGS_KEYS = {
LANGUAGE: 'language',
APP_LOGO: 'app_logo',

View File

@ -17,4 +17,11 @@ export interface PaperlessUserProfile {
auth_token?: string
social_accounts?: SocialAccount[]
has_usable_password?: boolean
is_mfa_enabled?: boolean
}
export interface TotpSettings {
url: string
qr_svg: string
secret: string
}

View File

@ -11,4 +11,5 @@ export interface User extends ObjectWithId {
groups?: number[] // Group[]
user_permissions?: string[]
inherited_permissions?: string[]
is_mfa_enabled?: boolean
}

View File

@ -10,6 +10,14 @@ export enum WorkflowTriggerType {
Consumption = 1,
DocumentAdded = 2,
DocumentUpdated = 3,
Scheduled = 4,
}
export enum ScheduleDateField {
Added = 'added',
Created = 'created',
Modified = 'modified',
CustomField = 'custom_field',
}
export interface WorkflowTrigger extends ObjectWithId {
@ -34,4 +42,14 @@ export interface WorkflowTrigger extends ObjectWithId {
filter_has_correspondent?: number // Correspondent.id
filter_has_document_type?: number // DocumentType.id
schedule_offset_days?: number
schedule_is_recurring?: boolean
schedule_recurring_interval_days?: number
schedule_date_field?: ScheduleDateField
schedule_date_custom_field?: number // CustomField.id
}

View File

@ -439,4 +439,25 @@ describe('PermissionsService', () => {
expect(permissionsService.isAdmin()).toBeFalsy()
})
it('correctly checks superuser status', () => {
permissionsService.initialize([], {
username: 'testuser',
last_name: 'User',
first_name: 'Test',
id: 1,
is_superuser: true,
})
expect(permissionsService.isSuperUser()).toBeTruthy()
permissionsService.initialize([], {
username: 'testuser',
last_name: 'User',
first_name: 'Test',
id: 1,
})
expect(permissionsService.isSuperUser()).toBeFalsy()
})
})

View File

@ -56,6 +56,10 @@ export class PermissionsService {
return this.currentUser?.is_staff
}
public isSuperUser(): boolean {
return this.currentUser?.is_superuser
}
public currentUserOwnsObject(object: ObjectWithPermissions): boolean {
return (
!object ||

View File

@ -72,4 +72,32 @@ describe('ProfileService', () => {
)
expect(req.request.method).toEqual('GET')
})
it('calls get totp settings endpoint', () => {
service.getTotpSettings().subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}profile/totp/`
)
expect(req.request.method).toEqual('GET')
})
it('calls activate totp endpoint', () => {
service.activateTotp('secret', 'code').subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}profile/totp/`
)
expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({
secret: 'secret',
code: 'code',
})
})
it('calls deactivate totp endpoint', () => {
service.deactivateTotp().subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}profile/totp/`
)
expect(req.request.method).toEqual('DELETE')
})
})

View File

@ -2,6 +2,7 @@ import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { Observable } from 'rxjs'
import {
TotpSettings,
PaperlessUserProfile,
SocialAccountProvider,
} from '../data/user-profile'
@ -47,4 +48,30 @@ export class ProfileService {
`${environment.apiBaseUrl}${this.endpoint}/social_account_providers/`
)
}
getTotpSettings(): Observable<TotpSettings> {
return this.http.get<TotpSettings>(
`${environment.apiBaseUrl}${this.endpoint}/totp/`
)
}
activateTotp(
totpSecret: string,
totpCode: string
): Observable<{ success: boolean; recovery_codes: string[] }> {
return this.http.post<{ success: boolean; recovery_codes: string[] }>(
`${environment.apiBaseUrl}${this.endpoint}/totp/`,
{
secret: totpSecret,
code: totpCode,
}
)
}
deactivateTotp(): Observable<boolean> {
return this.http.delete<boolean>(
`${environment.apiBaseUrl}${this.endpoint}/totp/`,
{}
)
}
}

View File

@ -160,6 +160,18 @@ const user = {
commonAbstractNameFilterPaperlessServiceTests(endpoint, UserService)
describe('Additional service tests for UserService', () => {
beforeEach(() => {
// Dont need to setup again
httpTestingController = TestBed.inject(HttpTestingController)
service = TestBed.inject(UserService)
})
afterEach(() => {
subscription?.unsubscribe()
httpTestingController.verify()
})
it('should retain permissions on update', () => {
subscription = service.listAll().subscribe()
let req = httpTestingController.expectOne(
@ -179,15 +191,11 @@ describe('Additional service tests for UserService', () => {
)
})
beforeEach(() => {
// Dont need to setup again
httpTestingController = TestBed.inject(HttpTestingController)
service = TestBed.inject(UserService)
})
afterEach(() => {
subscription?.unsubscribe()
httpTestingController.verify()
it('should deactivate totp', () => {
subscription = service.deactivateTotp(user).subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/${user.id}/deactivate_totp/`
)
expect(req.request.method).toEqual('POST')
})
})

View File

@ -5,6 +5,7 @@ import { User } from 'src/app/data/user'
import { PermissionsService } from '../permissions.service'
import { AbstractNameFilterService } from './abstract-name-filter-service'
const endpoint = 'users'
@Injectable({
providedIn: 'root',
})
@ -13,7 +14,7 @@ export class UserService extends AbstractNameFilterService<User> {
http: HttpClient,
private permissionService: PermissionsService
) {
super(http, 'users')
super(http, endpoint)
}
update(o: User): Observable<User> {
@ -31,4 +32,11 @@ export class UserService extends AbstractNameFilterService<User> {
})
)
}
deactivateTotp(u: User): Observable<boolean> {
return this.http.post<boolean>(
`${this.getResourceUrl(u.id, 'deactivate_totp')}`,
null
)
}
}

View File

@ -17,7 +17,12 @@ import {
hexToHsl,
} from 'src/app/utils/color'
import { environment } from 'src/environments/environment'
import { UiSettings, SETTINGS, SETTINGS_KEYS } from '../data/ui-settings'
import {
UiSettings,
SETTINGS,
SETTINGS_KEYS,
PAPERLESS_GREEN_HEX,
} from '../data/ui-settings'
import { User } from '../data/user'
import {
PermissionAction,
@ -420,7 +425,7 @@ export class SettingsService {
)
}
if (themeColor) {
if (themeColor?.length) {
const hsl = hexToHsl(themeColor)
const bgBrightnessEstimate = estimateBrightnessForColor(themeColor)
@ -445,6 +450,11 @@ export class SettingsService {
document.documentElement.style.removeProperty('--pngx-primary')
document.documentElement.style.removeProperty('--pngx-primary-lightness')
}
this.meta.updateTag({
name: 'theme-color',
content: themeColor?.length ? themeColor : PAPERLESS_GREEN_HEX,
})
}
getLanguageOptions(): LanguageOption[] {

View File

@ -48,7 +48,7 @@ describe('TasksService', () => {
it('calls acknowledge_tasks api endpoint on dismiss and reloads', () => {
tasksService.dismissTasks(new Set([1, 2, 3]))
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}acknowledge_tasks/`
`${environment.apiBaseUrl}tasks/acknowledge/`
)
expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({

View File

@ -64,7 +64,7 @@ export class TasksService {
public dismissTasks(task_ids: Set<number>) {
this.http
.post(`${this.baseUrl}acknowledge_tasks/`, {
.post(`${this.baseUrl}tasks/acknowledge/`, {
tasks: [...task_ids],
})
.pipe(first())

View File

@ -3,9 +3,9 @@ const base_url = new URL(document.baseURI)
export const environment = {
production: true,
apiBaseUrl: document.baseURI + 'api/',
apiVersion: '5',
apiVersion: '6',
appTitle: 'Paperless-ngx',
version: '2.13.4',
version: '2.13.5',
webSocketHost: window.location.host,
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
webSocketBaseUrl: base_url.pathname + 'ws/',

View File

@ -5,7 +5,7 @@
export const environment = {
production: false,
apiBaseUrl: 'http://localhost:8000/api/',
apiVersion: '5',
apiVersion: '6',
appTitle: 'Paperless-ngx',
version: 'DEVELOPMENT',
webSocketHost: 'localhost:8000',

View File

@ -564,11 +564,6 @@ table.table {
}
}
.popover-hidden .popover {
opacity: 0;
pointer-events: none;
}
// Tour
.tour-active .popover {
min-width: 360px;
@ -728,3 +723,27 @@ i-bs svg {
vertical-align: middle;
}
}
// fixes for buttons in preview popup
.btn-group pngx-preview-popup:not(:last-child) {
// Prevent double borders when buttons are next to each other
> .btn {
margin-left: calc(#{$btn-border-width} * -1);
}
> .btn {
@include border-end-radius(0);
}
}
.btn-group pngx-preview-popup:not(:first-child) {
> .btn {
@include border-start-radius(0);
}
}
.btn-group pngx-preview-popup {
position: relative;
flex: 1 1 auto;
> .btn {
display: block;
}
}

View File

@ -24,7 +24,7 @@ from documents.models import StoragePath
from documents.permissions import set_permissions_for_object
from documents.tasks import bulk_update_documents
from documents.tasks import consume_file
from documents.tasks import update_document_archive_file
from documents.tasks import update_document_content_maybe_archive_file
logger: logging.Logger = logging.getLogger("paperless.bulk_edit")
@ -191,7 +191,7 @@ def delete(doc_ids: list[int]) -> Literal["OK"]:
def reprocess(doc_ids: list[int]) -> Literal["OK"]:
for document_id in doc_ids:
update_document_archive_file.delay(
update_document_content_maybe_archive_file.delay(
document_id=document_id,
)
@ -245,7 +245,7 @@ def rotate(doc_ids: list[int], degrees: int) -> Literal["OK"]:
doc.checksum = hashlib.md5(doc.source_path.read_bytes()).hexdigest()
doc.save()
rotate_tasks.append(
update_document_archive_file.s(
update_document_content_maybe_archive_file.s(
document_id=doc.id,
),
)
@ -423,7 +423,7 @@ def delete_pages(doc_ids: list[int], pages: list[int]) -> Literal["OK"]:
if doc.page_count is not None:
doc.page_count = doc.page_count - len(pages)
doc.save()
update_document_archive_file.delay(document_id=doc.id)
update_document_content_maybe_archive_file.delay(document_id=doc.id)
logger.info(f"Deleted pages {pages} from document {doc.id}")
except Exception as e:
logger.exception(f"Error deleting pages from document {doc.id}: {e}")

View File

@ -14,7 +14,7 @@ def settings(request):
app_logo = (
django_settings.APP_LOGO
if general_config.app_logo is None or len(general_config.app_logo) == 0
else general_config.app_logo
else django_settings.BASE_URL + general_config.app_logo.lstrip("/")
)
return {

View File

@ -9,7 +9,7 @@ from django.core.management.base import BaseCommand
from documents.management.commands.mixins import MultiProcessMixin
from documents.management.commands.mixins import ProgressBarMixin
from documents.models import Document
from documents.tasks import update_document_archive_file
from documents.tasks import update_document_content_maybe_archive_file
logger = logging.getLogger("paperless.management.archiver")
@ -77,13 +77,13 @@ class Command(MultiProcessMixin, ProgressBarMixin, BaseCommand):
if self.process_count == 1:
for doc_id in document_ids:
update_document_archive_file(doc_id)
update_document_content_maybe_archive_file(doc_id)
else: # pragma: no cover
with multiprocessing.Pool(self.process_count) as pool:
list(
tqdm.tqdm(
pool.imap_unordered(
update_document_archive_file,
update_document_content_maybe_archive_file,
document_ids,
),
total=len(document_ids),

View File

@ -317,10 +317,8 @@ class Command(BaseCommand):
# Check the files against the timeout
still_waiting = {}
for filepath in notified_files:
# Time of the last inotify event for this file
last_event_time = notified_files[filepath]
# last_event_time is time of the last inotify event for this file
for filepath, last_event_time in notified_files.items():
# Current time - last time over the configured timeout
waited_long_enough = (
monotonic() - last_event_time

View File

@ -8,6 +8,7 @@ from pathlib import Path
from typing import TYPE_CHECKING
import tqdm
from allauth.mfa.models import Authenticator
from allauth.socialaccount.models import SocialAccount
from allauth.socialaccount.models import SocialApp
from allauth.socialaccount.models import SocialToken
@ -81,6 +82,18 @@ class Command(CryptMixin, BaseCommand):
),
)
parser.add_argument(
"-cj",
"--compare-json",
default=False,
action="store_true",
help=(
"Compare json file checksums when determining whether to "
"export a json file or not (manifest or metadata). "
"If not specified, the file is always exported."
),
)
parser.add_argument(
"-d",
"--delete",
@ -177,6 +190,7 @@ class Command(CryptMixin, BaseCommand):
self.target = Path(options["target"]).resolve()
self.split_manifest: bool = options["split_manifest"]
self.compare_checksums: bool = options["compare_checksums"]
self.compare_json: bool = options["compare_json"]
self.use_filename_format: bool = options["use_filename_format"]
self.use_folder_prefix: bool = options["use_folder_prefix"]
self.delete: bool = options["delete"]
@ -270,6 +284,7 @@ class Command(CryptMixin, BaseCommand):
"social_accounts": SocialAccount.objects.all(),
"social_apps": SocialApp.objects.all(),
"social_tokens": SocialToken.objects.all(),
"authenticators": Authenticator.objects.all(),
}
if settings.AUDIT_LOG_ENABLED:
@ -279,9 +294,9 @@ class Command(CryptMixin, BaseCommand):
manifest_dict = {}
# Build an overall manifest
for key in manifest_key_to_object_query:
for key, object_query in manifest_key_to_object_query.items():
manifest_dict[key] = json.loads(
serializers.serialize("json", manifest_key_to_object_query[key]),
serializers.serialize("json", object_query),
)
self.encrypt_secret_fields(manifest_dict)
@ -341,12 +356,11 @@ class Command(CryptMixin, BaseCommand):
manifest_dict["custom_field_instances"],
),
)
manifest_name.write_text(
json.dumps(content, indent=2, ensure_ascii=False),
encoding="utf-8",
self.check_and_write_json(
content,
manifest_name,
)
if manifest_name in self.files_in_export_dir:
self.files_in_export_dir.remove(manifest_name)
# These were exported already
if self.split_manifest:
@ -356,15 +370,13 @@ class Command(CryptMixin, BaseCommand):
# 4.1 write primary manifest to target folder
manifest = []
for key in manifest_dict:
manifest.extend(manifest_dict[key])
for key, item in manifest_dict.items():
manifest.extend(item)
manifest_path = (self.target / "manifest.json").resolve()
manifest_path.write_text(
json.dumps(manifest, indent=2, ensure_ascii=False),
encoding="utf-8",
self.check_and_write_json(
manifest,
manifest_path,
)
if manifest_path in self.files_in_export_dir:
self.files_in_export_dir.remove(manifest_path)
# 4.2 write version information to target folder
extra_metadata_path = (self.target / "metadata.json").resolve()
@ -376,16 +388,11 @@ class Command(CryptMixin, BaseCommand):
# Django stores most of these in the field itself, we store them once here
if self.passphrase:
metadata.update(self.get_crypt_params())
extra_metadata_path.write_text(
json.dumps(
self.check_and_write_json(
metadata,
indent=2,
ensure_ascii=False,
),
encoding="utf-8",
extra_metadata_path,
)
if extra_metadata_path in self.files_in_export_dir:
self.files_in_export_dir.remove(extra_metadata_path)
if self.delete:
# 5. Remove files which we did not explicitly export in this run
@ -514,6 +521,35 @@ class Command(CryptMixin, BaseCommand):
archive_target,
)
def check_and_write_json(
self,
content: list[dict] | dict,
target: Path,
):
"""
Writes the source content to the target json file.
If --compare-json arg was used, don't write to target file if
the file exists and checksum is identical to content checksum.
This preserves the file timestamps when no changes are made.
"""
target = target.resolve()
perform_write = True
if target in self.files_in_export_dir:
self.files_in_export_dir.remove(target)
if self.compare_json:
target_checksum = hashlib.md5(target.read_bytes()).hexdigest()
src_str = json.dumps(content, indent=2, ensure_ascii=False)
src_checksum = hashlib.md5(src_str.encode("utf-8")).hexdigest()
if src_checksum == target_checksum:
perform_write = False
if perform_write:
target.write_text(
json.dumps(content, indent=2, ensure_ascii=False),
encoding="utf-8",
)
def check_and_copy(
self,
source: Path,

View File

@ -409,6 +409,7 @@ def document_matches_workflow(
elif (
trigger_type == WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED
or trigger_type == WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED
or trigger_type == WorkflowTrigger.WorkflowTriggerType.SCHEDULED
):
trigger_matched, reason = existing_document_matches_workflow(
document,

View File

@ -1,6 +1,7 @@
# Generated by Django 4.2.13 on 2024-06-28 17:57
import django.db.models.deletion
from django.conf import settings
from django.db import migrations
from django.db import models
@ -43,7 +44,7 @@ class Migration(migrations.Migration):
name="content",
field=models.TextField(
blank=True,
db_index=True,
db_index=("mysql" not in settings.DATABASES["default"]["ENGINE"]),
help_text="The raw, text-only data of the document. This field is primarily used for searching.",
),
),

View File

@ -0,0 +1,28 @@
# Generated by Django 5.1.1 on 2024-11-04 21:56
import django.db.models.deletion
from django.conf import settings
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
("documents", "1056_customfieldinstance_deleted_at_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="paperlesstask",
name="owner",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL,
verbose_name="owner",
),
),
]

View File

@ -0,0 +1,143 @@
# Generated by Django 5.1.1 on 2024-11-05 05:19
import django.core.validators
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
("documents", "1057_paperlesstask_owner"),
]
operations = [
migrations.AddField(
model_name="workflowtrigger",
name="schedule_date_custom_field",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="documents.customfield",
verbose_name="schedule date custom field",
),
),
migrations.AddField(
model_name="workflowtrigger",
name="schedule_date_field",
field=models.CharField(
choices=[
("added", "Added"),
("created", "Created"),
("modified", "Modified"),
("custom_field", "Custom Field"),
],
default="added",
help_text="The field to check for a schedule trigger.",
max_length=20,
verbose_name="schedule date field",
),
),
migrations.AddField(
model_name="workflowtrigger",
name="schedule_is_recurring",
field=models.BooleanField(
default=False,
help_text="If the schedule should be recurring.",
verbose_name="schedule is recurring",
),
),
migrations.AddField(
model_name="workflowtrigger",
name="schedule_offset_days",
field=models.PositiveIntegerField(
default=0,
help_text="The number of days to offset the schedule trigger by.",
verbose_name="schedule offset days",
),
),
migrations.AddField(
model_name="workflowtrigger",
name="schedule_recurring_interval_days",
field=models.PositiveIntegerField(
default=1,
help_text="The number of days between recurring schedule triggers.",
validators=[django.core.validators.MinValueValidator(1)],
verbose_name="schedule recurring delay in days",
),
),
migrations.AlterField(
model_name="workflowtrigger",
name="type",
field=models.PositiveIntegerField(
choices=[
(1, "Consumption Started"),
(2, "Document Added"),
(3, "Document Updated"),
(4, "Scheduled"),
],
default=1,
verbose_name="Workflow Trigger Type",
),
),
migrations.CreateModel(
name="WorkflowRun",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"type",
models.PositiveIntegerField(
choices=[
(1, "Consumption Started"),
(2, "Document Added"),
(3, "Document Updated"),
(4, "Scheduled"),
],
null=True,
verbose_name="workflow trigger type",
),
),
(
"run_at",
models.DateTimeField(
db_index=True,
default=django.utils.timezone.now,
verbose_name="date run",
),
),
(
"document",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="workflow_runs",
to="documents.document",
verbose_name="document",
),
),
(
"workflow",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="runs",
to="documents.workflow",
verbose_name="workflow",
),
),
],
options={
"verbose_name": "workflow run",
"verbose_name_plural": "workflow runs",
},
),
]

View File

@ -641,7 +641,7 @@ class UiSettings(models.Model):
return self.user.username
class PaperlessTask(models.Model):
class PaperlessTask(ModelWithOwner):
ALL_STATES = sorted(states.ALL_STATES)
TASK_STATE_CHOICES = sorted(zip(ALL_STATES, ALL_STATES))
@ -1020,12 +1020,19 @@ class WorkflowTrigger(models.Model):
CONSUMPTION = 1, _("Consumption Started")
DOCUMENT_ADDED = 2, _("Document Added")
DOCUMENT_UPDATED = 3, _("Document Updated")
SCHEDULED = 4, _("Scheduled")
class DocumentSourceChoices(models.IntegerChoices):
CONSUME_FOLDER = DocumentSource.ConsumeFolder.value, _("Consume Folder")
API_UPLOAD = DocumentSource.ApiUpload.value, _("Api Upload")
MAIL_FETCH = DocumentSource.MailFetch.value, _("Mail Fetch")
class ScheduleDateField(models.TextChoices):
ADDED = "added", _("Added")
CREATED = "created", _("Created")
MODIFIED = "modified", _("Modified")
CUSTOM_FIELD = "custom_field", _("Custom Field")
type = models.PositiveIntegerField(
_("Workflow Trigger Type"),
choices=WorkflowTriggerType.choices,
@ -1102,6 +1109,49 @@ class WorkflowTrigger(models.Model):
verbose_name=_("has this correspondent"),
)
schedule_offset_days = models.PositiveIntegerField(
_("schedule offset days"),
default=0,
help_text=_(
"The number of days to offset the schedule trigger by.",
),
)
schedule_is_recurring = models.BooleanField(
_("schedule is recurring"),
default=False,
help_text=_(
"If the schedule should be recurring.",
),
)
schedule_recurring_interval_days = models.PositiveIntegerField(
_("schedule recurring delay in days"),
default=1,
validators=[MinValueValidator(1)],
help_text=_(
"The number of days between recurring schedule triggers.",
),
)
schedule_date_field = models.CharField(
_("schedule date field"),
max_length=20,
choices=ScheduleDateField.choices,
default=ScheduleDateField.ADDED,
help_text=_(
"The field to check for a schedule trigger.",
),
)
schedule_date_custom_field = models.ForeignKey(
CustomField,
null=True,
blank=True,
on_delete=models.SET_NULL,
verbose_name=_("schedule date custom field"),
)
class Meta:
verbose_name = _("workflow trigger")
verbose_name_plural = _("workflow triggers")
@ -1352,3 +1402,39 @@ class Workflow(models.Model):
def __str__(self):
return f"Workflow: {self.name}"
class WorkflowRun(models.Model):
workflow = models.ForeignKey(
Workflow,
on_delete=models.CASCADE,
related_name="runs",
verbose_name=_("workflow"),
)
type = models.PositiveIntegerField(
_("workflow trigger type"),
choices=WorkflowTrigger.WorkflowTriggerType.choices,
null=True,
)
document = models.ForeignKey(
Document,
null=True,
on_delete=models.CASCADE,
related_name="workflow_runs",
verbose_name=_("document"),
)
run_at = models.DateTimeField(
_("date run"),
default=timezone.now,
db_index=True,
)
class Meta:
verbose_name = _("workflow run")
verbose_name_plural = _("workflow runs")
def __str__(self):
return f"WorkflowRun of {self.workflow} at {self.run_at} on {self.document}"

View File

@ -160,7 +160,7 @@ class SetPermissionsMixin:
},
}
if set_permissions is not None:
for action in permissions_dict:
for action, _ in permissions_dict.items():
if action in set_permissions:
users = set_permissions[action]["users"]
permissions_dict[action]["users"] = self._validate_user_ids(users)
@ -1578,7 +1578,7 @@ class UiSettingsViewSerializer(serializers.ModelSerializer):
return ui_settings
class TasksViewSerializer(serializers.ModelSerializer):
class TasksViewSerializer(OwnedObjectSerializer):
class Meta:
model = PaperlessTask
depth = 1
@ -1593,6 +1593,7 @@ class TasksViewSerializer(serializers.ModelSerializer):
"result",
"acknowledged",
"related_document",
"owner",
)
type = serializers.SerializerMethodField()
@ -1782,6 +1783,11 @@ class WorkflowTriggerSerializer(serializers.ModelSerializer):
"filter_has_tags",
"filter_has_correspondent",
"filter_has_document_type",
"schedule_offset_days",
"schedule_is_recurring",
"schedule_recurring_interval_days",
"schedule_date_field",
"schedule_date_custom_field",
]
def validate(self, attrs):

View File

@ -37,6 +37,7 @@ from documents.models import PaperlessTask
from documents.models import Tag
from documents.models import Workflow
from documents.models import WorkflowAction
from documents.models import WorkflowRun
from documents.models import WorkflowTrigger
from documents.permissions import get_objects_for_user_owner_aware
from documents.permissions import set_permissions_for_object
@ -899,6 +900,7 @@ def run_workflows(
"triggers",
)
.order_by("order")
.distinct()
)
for workflow in workflows:
@ -928,6 +930,12 @@ def run_workflows(
document.save()
document.tags.set(doc_tag_ids)
WorkflowRun.objects.create(
workflow=workflow,
type=trigger_type,
document=document if not use_overrides else None,
)
if use_overrides:
return overrides, "\n".join(messages)
@ -951,9 +959,10 @@ def before_task_publish_handler(sender=None, headers=None, body=None, **kwargs):
close_old_connections()
task_args = body[0]
input_doc, _ = task_args
input_doc, overrides = task_args
task_file_name = input_doc.original_file.name
user_id = overrides.owner_id if overrides else None
PaperlessTask.objects.create(
task_id=headers["id"],
@ -964,6 +973,7 @@ def before_task_publish_handler(sender=None, headers=None, body=None, **kwargs):
date_created=timezone.now(),
date_started=None,
date_done=None,
owner_id=user_id,
)
except Exception: # pragma: no cover
# Don't let an exception in the signal handlers prevent

View File

@ -31,10 +31,14 @@ from documents.double_sided import CollatePlugin
from documents.file_handling import create_source_path_directory
from documents.file_handling import generate_unique_filename
from documents.models import Correspondent
from documents.models import CustomFieldInstance
from documents.models import Document
from documents.models import DocumentType
from documents.models import StoragePath
from documents.models import Tag
from documents.models import Workflow
from documents.models import WorkflowRun
from documents.models import WorkflowTrigger
from documents.parsers import DocumentParser
from documents.parsers import get_parser_class_for_mime_type
from documents.plugins.base import ConsumeTaskPlugin
@ -44,6 +48,7 @@ from documents.plugins.helpers import ProgressStatusOptions
from documents.sanity_checker import SanityCheckFailedException
from documents.signals import document_updated
from documents.signals.handlers import cleanup_document_deletion
from documents.signals.handlers import run_workflows
if settings.AUDIT_LOG_ENABLED:
from auditlog.models import LogEntry
@ -206,9 +211,10 @@ def bulk_update_documents(document_ids):
@shared_task
def update_document_archive_file(document_id):
def update_document_content_maybe_archive_file(document_id):
"""
Re-creates the archive file of a document, including new OCR content and thumbnail
Re-creates OCR content and thumbnail for a document, and archive file if
it exists.
"""
document = Document.objects.get(id=document_id)
@ -234,8 +240,9 @@ def update_document_archive_file(document_id):
document.get_public_filename(),
)
if parser.get_archive_path():
with transaction.atomic():
oldDocument = Document.objects.get(pk=document.pk)
if parser.get_archive_path():
with open(parser.get_archive_path(), "rb") as f:
checksum = hashlib.md5(f.read()).hexdigest()
# I'm going to save first so that in case the file move
@ -246,7 +253,6 @@ def update_document_archive_file(document_id):
document,
archive_filename=True,
)
oldDocument = Document.objects.get(pk=document.pk)
Document.objects.filter(pk=document.pk).update(
archive_checksum=checksum,
content=parser.get_text(),
@ -268,12 +274,29 @@ def update_document_archive_file(document_id):
],
},
additional_data={
"reason": "Update document archive file",
"reason": "Update document content",
},
action=LogEntry.Action.UPDATE,
)
else:
Document.objects.filter(pk=document.pk).update(
content=parser.get_text(),
)
if settings.AUDIT_LOG_ENABLED:
LogEntry.objects.log_create(
instance=oldDocument,
changes={
"content": [oldDocument.content, parser.get_text()],
},
additional_data={
"reason": "Update document content",
},
action=LogEntry.Action.UPDATE,
)
with FileLock(settings.MEDIA_LOCK):
if parser.get_archive_path():
create_source_path_directory(document.archive_path)
shutil.move(parser.get_archive_path(), document.archive_path)
shutil.move(thumbnail, document.thumbnail_path)
@ -319,3 +342,85 @@ def empty_trash(doc_ids=None):
cleanup_document_deletion,
sender=Document,
)
@shared_task
def check_scheduled_workflows():
scheduled_workflows: list[Workflow] = (
Workflow.objects.filter(
triggers__type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
enabled=True,
)
.distinct()
.prefetch_related("triggers")
)
if scheduled_workflows.count() > 0:
logger.debug(f"Checking {len(scheduled_workflows)} scheduled workflows")
for workflow in scheduled_workflows:
schedule_triggers = workflow.triggers.filter(
type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
)
trigger: WorkflowTrigger
for trigger in schedule_triggers:
documents = Document.objects.none()
offset_td = timedelta(days=trigger.schedule_offset_days)
logger.debug(
f"Checking trigger {trigger} with offset {offset_td} against field: {trigger.schedule_date_field}",
)
match trigger.schedule_date_field:
case WorkflowTrigger.ScheduleDateField.ADDED:
documents = Document.objects.filter(
added__lt=timezone.now() - offset_td,
)
case WorkflowTrigger.ScheduleDateField.CREATED:
documents = Document.objects.filter(
created__lt=timezone.now() - offset_td,
)
case WorkflowTrigger.ScheduleDateField.MODIFIED:
documents = Document.objects.filter(
modified__lt=timezone.now() - offset_td,
)
case WorkflowTrigger.ScheduleDateField.CUSTOM_FIELD:
cf_instances = CustomFieldInstance.objects.filter(
field=trigger.schedule_date_custom_field,
value_date__lt=timezone.now() - offset_td,
)
documents = Document.objects.filter(
id__in=cf_instances.values_list("document", flat=True),
)
if documents.count() > 0:
logger.debug(
f"Found {documents.count()} documents for trigger {trigger}",
)
for document in documents:
workflow_runs = WorkflowRun.objects.filter(
document=document,
type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
workflow=workflow,
).order_by("-run_at")
if not trigger.schedule_is_recurring and workflow_runs.exists():
# schedule is non-recurring and the workflow has already been run
logger.debug(
f"Skipping document {document} for non-recurring workflow {workflow} as it has already been run",
)
continue
elif (
trigger.schedule_is_recurring
and workflow_runs.exists()
and (
workflow_runs.last().run_at
> timezone.now()
- timedelta(
days=trigger.schedule_recurring_interval_days,
)
)
):
# schedule is recurring but the last run was within the number of recurring interval days
logger.debug(
f"Skipping document {document} for recurring workflow {workflow} as the last run was within the recurring interval",
)
continue
run_workflows(
WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
document,
)

View File

@ -0,0 +1,35 @@
{% extends "paperless-ngx/base.html" %}
{% load i18n %}
{% load allauth %}
{% load allauth static %}
{% block head_title %}
{% trans "Paperless-ngx Two-Factor Authentication" %}
{% endblock head_title %}
{% block form_top_content %}
<p>
{% blocktranslate %}Your account is protected by two-factor authentication. Please enter an authenticator code:{% endblocktranslate %}
</p>
{% endblock form_top_content %}
{% block form_content %}
{% translate "Code" as i18n_code %}
<div class="form-floating">
<input type="code" name="code" id="inputCode" autocomplete="one-time-code" placeholder="{{ i18n_code }}" class="form-control" required>
<label for="inputCode">{{ i18n_code }}</label>
</div>
<div class="d-grid mt-3">
<button class="btn btn-lg btn-primary" type="submit">{% translate "Sign in" %}</button>
<button class="btn btn-lg btn-secondary mt-2" type="submit" form="logout-from-stage">{% translate "Cancel" %}</button>
</div>
{% endblock form_content %}
{% block after_form_content %}
<form id="logout-from-stage"
method="post"
action="{% url 'account_logout' %}">
<input type="hidden" name="next" value="{% url 'account_login' %}">
{% csrf_token %}
</form>
{% endblock after_form_content %}

View File

@ -1,7 +1,9 @@
import json
from unittest import mock
from auditlog.models import LogEntry
from django.contrib.auth.models import User
from django.test import override_settings
from guardian.shortcuts import assign_perm
from rest_framework import status
from rest_framework.test import APITestCase
@ -51,8 +53,12 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.doc3.tags.add(self.t2)
self.doc4.tags.add(self.t1, self.t2)
self.sp1 = StoragePath.objects.create(name="sp1", path="Something/{checksum}")
self.cf1 = CustomField.objects.create(name="cf1", data_type="text")
self.cf2 = CustomField.objects.create(name="cf2", data_type="text")
self.cf1 = CustomField.objects.create(name="cf1", data_type="string")
self.cf2 = CustomField.objects.create(name="cf2", data_type="string")
def setup_mock(self, m, method_name, return_value="OK"):
m.return_value = return_value
m.__name__ = method_name
@mock.patch("documents.bulk_edit.bulk_update_documents.delay")
def test_api_set_correspondent(self, bulk_update_task_mock):
@ -178,7 +184,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
@mock.patch("documents.serialisers.bulk_edit.modify_tags")
def test_api_modify_tags(self, m):
m.return_value = "OK"
self.setup_mock(m, "modify_tags")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
@ -211,7 +217,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
- API returns HTTP 400
- modify_tags is not called
"""
m.return_value = "OK"
self.setup_mock(m, "modify_tags")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
@ -230,7 +236,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
@mock.patch("documents.serialisers.bulk_edit.modify_custom_fields")
def test_api_modify_custom_fields(self, m):
m.return_value = "OK"
self.setup_mock(m, "modify_custom_fields")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
@ -263,8 +269,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
- API returns HTTP 400
- modify_custom_fields is not called
"""
m.return_value = "OK"
self.setup_mock(m, "modify_custom_fields")
# Missing add_custom_fields
response = self.client.post(
"/api/documents/bulk_edit/",
@ -359,7 +364,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
@mock.patch("documents.serialisers.bulk_edit.delete")
def test_api_delete(self, m):
m.return_value = "OK"
self.setup_mock(m, "delete")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
@ -383,8 +388,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
THEN:
- set_storage_path is called with correct document IDs and storage_path ID
"""
m.return_value = "OK"
self.setup_mock(m, "set_storage_path")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
@ -414,8 +418,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
THEN:
- set_storage_path is called with correct document IDs and None storage_path
"""
m.return_value = "OK"
self.setup_mock(m, "set_storage_path")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
@ -728,7 +731,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
def test_set_permissions(self, m):
m.return_value = "OK"
self.setup_mock(m, "set_permissions")
user1 = User.objects.create(username="user1")
user2 = User.objects.create(username="user2")
permissions = {
@ -763,7 +766,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
def test_set_permissions_merge(self, m):
m.return_value = "OK"
self.setup_mock(m, "set_permissions")
user1 = User.objects.create(username="user1")
user2 = User.objects.create(username="user2")
permissions = {
@ -823,7 +826,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
THEN:
- User is not able to change permissions
"""
m.return_value = "OK"
self.setup_mock(m, "set_permissions")
self.doc1.owner = User.objects.get(username="temp_admin")
self.doc1.save()
user1 = User.objects.create(username="user1")
@ -875,7 +878,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
THEN:
- set_storage_path only called if user can edit all docs
"""
m.return_value = "OK"
self.setup_mock(m, "set_storage_path")
self.doc1.owner = User.objects.get(username="temp_admin")
self.doc1.save()
user1 = User.objects.create(username="user1")
@ -919,8 +922,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
@mock.patch("documents.serialisers.bulk_edit.rotate")
def test_rotate(self, m):
m.return_value = "OK"
self.setup_mock(m, "rotate")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
@ -974,8 +976,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
@mock.patch("documents.serialisers.bulk_edit.merge")
def test_merge(self, m):
m.return_value = "OK"
self.setup_mock(m, "merge")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
@ -1003,8 +1004,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
user1 = User.objects.create(username="user1")
self.client.force_authenticate(user=user1)
m.return_value = "OK"
self.setup_mock(m, "merge")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
@ -1053,8 +1053,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
THEN:
- The API fails with a correct error code
"""
m.return_value = "OK"
self.setup_mock(m, "merge")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
@ -1074,8 +1073,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
@mock.patch("documents.serialisers.bulk_edit.split")
def test_split(self, m):
m.return_value = "OK"
self.setup_mock(m, "split")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
@ -1165,8 +1163,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
@mock.patch("documents.serialisers.bulk_edit.delete_pages")
def test_delete_pages(self, m):
m.return_value = "OK"
self.setup_mock(m, "delete_pages")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
@ -1254,3 +1251,87 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"pages must be a list of integers", response.content)
@override_settings(AUDIT_LOG_ENABLED=True)
def test_bulk_edit_audit_log_enabled_simple_field(self):
"""
GIVEN:
- Audit log is enabled
WHEN:
- API to bulk edit documents is called
THEN:
- Audit log is created
"""
LogEntry.objects.all().delete()
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc1.id],
"method": "set_correspondent",
"parameters": {"correspondent": self.c2.id},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(LogEntry.objects.filter(object_pk=self.doc1.id).count(), 1)
@override_settings(AUDIT_LOG_ENABLED=True)
def test_bulk_edit_audit_log_enabled_tags(self):
"""
GIVEN:
- Audit log is enabled
WHEN:
- API to bulk edit tags is called
THEN:
- Audit log is created
"""
LogEntry.objects.all().delete()
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc1.id],
"method": "modify_tags",
"parameters": {
"add_tags": [self.t1.id],
"remove_tags": [self.t2.id],
},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(LogEntry.objects.filter(object_pk=self.doc1.id).count(), 1)
@override_settings(AUDIT_LOG_ENABLED=True)
def test_bulk_edit_audit_log_enabled_custom_fields(self):
"""
GIVEN:
- Audit log is enabled
WHEN:
- API to bulk edit custom fields is called
THEN:
- Audit log is created
"""
LogEntry.objects.all().delete()
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc1.id],
"method": "modify_custom_fields",
"parameters": {
"add_custom_fields": [self.cf1.id],
"remove_custom_fields": [],
},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(LogEntry.objects.filter(object_pk=self.doc1.id).count(), 2)

View File

@ -1,5 +1,6 @@
import json
from allauth.mfa.models import Authenticator
from django.contrib.auth.models import Group
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User
@ -601,6 +602,59 @@ class TestApiUser(DirectoriesMixin, APITestCase):
self.assertEqual(returned_user2.first_name, "Updated Name 2")
self.assertNotEqual(returned_user2.password, initial_password)
def test_deactivate_totp(self):
"""
GIVEN:
- Existing user account with TOTP enabled
WHEN:
- API request by a superuser is made to deactivate TOTP
- API request by a regular user is made to deactivate TOTP
THEN:
- TOTP is deactivated, if exists
- Regular user is forbidden from deactivating TOTP
"""
user1 = User.objects.create(
username="testuser",
password="test",
first_name="Test",
last_name="User",
)
Authenticator.objects.create(
user=user1,
type=Authenticator.Type.TOTP,
data={},
)
response = self.client.post(
f"{self.ENDPOINT}{user1.pk}/deactivate_totp/",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(Authenticator.objects.filter(user=user1).count(), 0)
# fail if already deactivated
response = self.client.post(
f"{self.ENDPOINT}{user1.pk}/deactivate_totp/",
)
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
regular_user = User.objects.create_user(username="regular_user")
regular_user.user_permissions.add(
*Permission.objects.all(),
)
self.client.force_authenticate(regular_user)
Authenticator.objects.create(
user=user1,
type=Authenticator.Type.TOTP,
data={},
)
response = self.client.post(
f"{self.ENDPOINT}{user1.pk}/deactivate_totp/",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
class TestApiGroup(DirectoriesMixin, APITestCase):
ENDPOINT = "/api/groups/"

View File

@ -1,5 +1,6 @@
from unittest import mock
from allauth.mfa.models import Authenticator
from allauth.socialaccount.models import SocialAccount
from allauth.socialaccount.models import SocialApp
from django.contrib.auth.models import User
@ -299,3 +300,82 @@ class TestApiProfile(DirectoriesMixin, APITestCase):
len(self.user.socialaccount_set.filter(pk=social_account_id)),
0,
)
class TestApiTOTPViews(APITestCase):
ENDPOINT = "/api/profile/totp/"
def setUp(self):
super().setUp()
self.user = User.objects.create_superuser(username="temp_admin")
self.client.force_authenticate(user=self.user)
def test_get_totp(self):
"""
GIVEN:
- Existing user account
WHEN:
- API request is made to TOTP endpoint
THEN:
- TOTP is generated
"""
response = self.client.get(
self.ENDPOINT,
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn("qr_svg", response.data)
self.assertIn("secret", response.data)
@mock.patch("allauth.mfa.totp.internal.auth.validate_totp_code")
def test_activate_totp(self, mock_validate_totp_code):
"""
GIVEN:
- Existing user account
WHEN:
- API request is made to activate TOTP
THEN:
- TOTP is activated, recovery codes are returned
"""
mock_validate_totp_code.return_value = True
response = self.client.post(
self.ENDPOINT,
data={
"secret": "123",
"code": "456",
},
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue(Authenticator.objects.filter(user=self.user).exists())
self.assertIn("recovery_codes", response.data)
def test_deactivate_totp(self):
"""
GIVEN:
- Existing user account with TOTP enabled
WHEN:
- API request is made to deactivate TOTP
THEN:
- TOTP is deactivated
"""
Authenticator.objects.create(
user=self.user,
type=Authenticator.Type.TOTP,
data={},
)
response = self.client.delete(
self.ENDPOINT,
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(Authenticator.objects.filter(user=self.user).count(), 0)
# test fails
response = self.client.delete(
self.ENDPOINT,
)
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)

View File

@ -1,6 +1,7 @@
import uuid
import celery
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User
from rest_framework import status
from rest_framework.test import APITestCase
@ -11,7 +12,6 @@ from documents.tests.utils import DirectoriesMixin
class TestTasks(DirectoriesMixin, APITestCase):
ENDPOINT = "/api/tasks/"
ENDPOINT_ACKNOWLEDGE = "/api/acknowledge_tasks/"
def setUp(self):
super().setUp()
@ -125,7 +125,7 @@ class TestTasks(DirectoriesMixin, APITestCase):
self.assertEqual(len(response.data), 1)
response = self.client.post(
self.ENDPOINT_ACKNOWLEDGE,
self.ENDPOINT + "acknowledge/",
{"tasks": [task.id]},
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
@ -133,6 +133,52 @@ class TestTasks(DirectoriesMixin, APITestCase):
response = self.client.get(self.ENDPOINT)
self.assertEqual(len(response.data), 0)
def test_tasks_owner_aware(self):
"""
GIVEN:
- Existing PaperlessTasks with owner and with no owner
WHEN:
- API call is made to get tasks
THEN:
- Only tasks with no owner or request user are returned
"""
regular_user = User.objects.create_user(username="test")
regular_user.user_permissions.add(*Permission.objects.all())
self.client.logout()
self.client.force_authenticate(user=regular_user)
task1 = PaperlessTask.objects.create(
task_id=str(uuid.uuid4()),
task_file_name="task_one.pdf",
owner=self.user,
)
task2 = PaperlessTask.objects.create(
task_id=str(uuid.uuid4()),
task_file_name="task_two.pdf",
)
task3 = PaperlessTask.objects.create(
task_id=str(uuid.uuid4()),
task_file_name="task_three.pdf",
owner=regular_user,
)
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 2)
self.assertEqual(response.data[0]["task_id"], task3.task_id)
self.assertEqual(response.data[1]["task_id"], task2.task_id)
acknowledge_response = self.client.post(
self.ENDPOINT + "acknowledge/",
{"tasks": [task1.id, task2.id, task3.id]},
)
self.assertEqual(acknowledge_response.status_code, status.HTTP_200_OK)
self.assertEqual(acknowledge_response.data, {"result": 2})
def test_task_result_no_error(self):
"""
GIVEN:

View File

@ -607,7 +607,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
mock_consume_file.assert_not_called()
@mock.patch("documents.tasks.bulk_update_documents.si")
@mock.patch("documents.tasks.update_document_archive_file.s")
@mock.patch("documents.tasks.update_document_content_maybe_archive_file.s")
@mock.patch("celery.chord.delay")
def test_rotate(self, mock_chord, mock_update_document, mock_update_documents):
"""
@ -626,7 +626,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
self.assertEqual(result, "OK")
@mock.patch("documents.tasks.bulk_update_documents.si")
@mock.patch("documents.tasks.update_document_archive_file.s")
@mock.patch("documents.tasks.update_document_content_maybe_archive_file.s")
@mock.patch("pikepdf.Pdf.save")
def test_rotate_with_error(
self,
@ -654,7 +654,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
mock_update_archive_file.assert_not_called()
@mock.patch("documents.tasks.bulk_update_documents.si")
@mock.patch("documents.tasks.update_document_archive_file.s")
@mock.patch("documents.tasks.update_document_content_maybe_archive_file.s")
@mock.patch("celery.chord.delay")
def test_rotate_non_pdf(
self,
@ -680,7 +680,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
mock_chord.assert_called_once()
self.assertEqual(result, "OK")
@mock.patch("documents.tasks.update_document_archive_file.delay")
@mock.patch("documents.tasks.update_document_content_maybe_archive_file.delay")
@mock.patch("pikepdf.Pdf.save")
def test_delete_pages(self, mock_pdf_save, mock_update_archive_file):
"""
@ -705,7 +705,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
self.doc2.refresh_from_db()
self.assertEqual(self.doc2.page_count, expected_page_count)
@mock.patch("documents.tasks.update_document_archive_file.delay")
@mock.patch("documents.tasks.update_document_content_maybe_archive_file.delay")
@mock.patch("pikepdf.Pdf.save")
def test_delete_pages_with_error(self, mock_pdf_save, mock_update_archive_file):
"""

View File

@ -13,7 +13,7 @@ from django.test import override_settings
from documents.file_handling import generate_filename
from documents.models import Document
from documents.tasks import update_document_archive_file
from documents.tasks import update_document_content_maybe_archive_file
from documents.tests.utils import DirectoriesMixin
from documents.tests.utils import FileSystemAssertsMixin
@ -46,7 +46,7 @@ class TestArchiver(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
os.path.join(self.dirs.originals_dir, f"{doc.id:07}.pdf"),
)
update_document_archive_file(doc.pk)
update_document_content_maybe_archive_file(doc.pk)
doc = Document.objects.get(id=doc.id)
@ -63,7 +63,7 @@ class TestArchiver(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
doc.save()
shutil.copy(sample_file, doc.source_path)
update_document_archive_file(doc.pk)
update_document_content_maybe_archive_file(doc.pk)
doc = Document.objects.get(id=doc.id)
@ -94,8 +94,8 @@ class TestArchiver(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
os.path.join(self.dirs.originals_dir, "document_01.pdf"),
)
update_document_archive_file(doc2.pk)
update_document_archive_file(doc1.pk)
update_document_content_maybe_archive_file(doc2.pk)
update_document_content_maybe_archive_file(doc1.pk)
doc1 = Document.objects.get(id=doc1.id)
doc2 = Document.objects.get(id=doc2.id)

View File

@ -153,6 +153,7 @@ class TestExportImport(
*,
use_filename_format=False,
compare_checksums=False,
compare_json=False,
delete=False,
no_archive=False,
no_thumbnail=False,
@ -165,6 +166,8 @@ class TestExportImport(
args += ["--use-filename-format"]
if compare_checksums:
args += ["--compare-checksums"]
if compare_json:
args += ["--compare-json"]
if delete:
args += ["--delete"]
if no_archive:
@ -340,6 +343,10 @@ class TestExportImport(
self.assertNotEqual(st_mtime_1, st_mtime_2)
self.assertNotEqual(st_mtime_2, st_mtime_3)
self._do_export(compare_json=True)
st_mtime_4 = os.stat(os.path.join(self.target, "manifest.json")).st_mtime
self.assertEqual(st_mtime_3, st_mtime_4)
def test_update_export_changed_checksum(self):
shutil.rmtree(os.path.join(self.dirs.media_dir, "documents"))
shutil.copytree(

View File

@ -5,6 +5,7 @@ import celery
from django.test import TestCase
from documents.data_models import ConsumableDocument
from documents.data_models import DocumentMetadataOverrides
from documents.data_models import DocumentSource
from documents.models import PaperlessTask
from documents.signals.handlers import before_task_publish_handler
@ -48,7 +49,10 @@ class TestTaskSignalHandler(DirectoriesMixin, TestCase):
source=DocumentSource.ConsumeFolder,
original_file="/consume/hello-999.pdf",
),
None,
DocumentMetadataOverrides(
title="Hello world",
owner_id=1,
),
),
# kwargs
{},
@ -65,6 +69,7 @@ class TestTaskSignalHandler(DirectoriesMixin, TestCase):
self.assertEqual(headers["id"], task.task_id)
self.assertEqual("hello-999.pdf", task.task_file_name)
self.assertEqual("documents.tasks.consume_file", task.task_name)
self.assertEqual(1, task.owner_id)
self.assertEqual(celery.states.PENDING, task.status)
def test_task_prerun_handler(self):

View File

@ -1,5 +1,7 @@
import os
import shutil
from datetime import timedelta
from pathlib import Path
from unittest import mock
from django.conf import settings
@ -184,3 +186,75 @@ class TestEmptyTrashTask(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
tasks.empty_trash()
self.assertEqual(Document.global_objects.count(), 0)
class TestUpdateContent(DirectoriesMixin, TestCase):
def test_update_content_maybe_archive_file(self):
"""
GIVEN:
- Existing document with archive file
WHEN:
- Update content task is called
THEN:
- Document is reprocessed, content and checksum are updated
"""
sample1 = self.dirs.scratch_dir / "sample.pdf"
shutil.copy(
Path(__file__).parent
/ "samples"
/ "documents"
/ "originals"
/ "0000001.pdf",
sample1,
)
sample1_archive = self.dirs.archive_dir / "sample_archive.pdf"
shutil.copy(
Path(__file__).parent
/ "samples"
/ "documents"
/ "originals"
/ "0000001.pdf",
sample1_archive,
)
doc = Document.objects.create(
title="test",
content="my document",
checksum="wow",
archive_checksum="wow",
filename=sample1,
mime_type="application/pdf",
archive_filename=sample1_archive,
)
tasks.update_document_content_maybe_archive_file(doc.pk)
self.assertNotEqual(Document.objects.get(pk=doc.pk).content, "test")
self.assertNotEqual(Document.objects.get(pk=doc.pk).archive_checksum, "wow")
def test_update_content_maybe_archive_file_no_archive(self):
"""
GIVEN:
- Existing document without archive file
WHEN:
- Update content task is called
THEN:
- Document is reprocessed, content is updated
"""
sample1 = self.dirs.scratch_dir / "sample.pdf"
shutil.copy(
Path(__file__).parent
/ "samples"
/ "documents"
/ "originals"
/ "0000001.pdf",
sample1,
)
doc = Document.objects.create(
title="test",
content="my document",
checksum="wow",
filename=sample1,
mime_type="application/pdf",
)
tasks.update_document_content_maybe_archive_file(doc.pk)
self.assertNotEqual(Document.objects.get(pk=doc.pk).content, "test")

Some files were not shown because too many files have changed in this diff Show More