mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-02-16 00:19:32 -06:00
Compare commits
7 Commits
feature-da
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7959ef8210 | ||
|
|
56d1b5677a | ||
|
|
6622349b5f | ||
|
|
b050fab77f | ||
|
|
a467df0755 | ||
|
|
728c5ea07b | ||
|
|
4f2e16fdc7 |
@@ -45,7 +45,7 @@ dependencies = [
|
|||||||
"drf-spectacular-sidecar~=2026.1.1",
|
"drf-spectacular-sidecar~=2026.1.1",
|
||||||
"drf-writable-nested~=0.7.1",
|
"drf-writable-nested~=0.7.1",
|
||||||
"faiss-cpu>=1.10",
|
"faiss-cpu>=1.10",
|
||||||
"filelock~=3.20.0",
|
"filelock~=3.21.2",
|
||||||
"flower~=2.0.1",
|
"flower~=2.0.1",
|
||||||
"gotenberg-client~=0.13.1",
|
"gotenberg-client~=0.13.1",
|
||||||
"httpx-oauth~=0.16",
|
"httpx-oauth~=0.16",
|
||||||
@@ -115,7 +115,7 @@ testing = [
|
|||||||
"pytest~=9.0.0",
|
"pytest~=9.0.0",
|
||||||
"pytest-cov~=7.0.0",
|
"pytest-cov~=7.0.0",
|
||||||
"pytest-django~=4.11.1",
|
"pytest-django~=4.11.1",
|
||||||
"pytest-env~=1.2.0",
|
"pytest-env~=1.3.2",
|
||||||
"pytest-httpx",
|
"pytest-httpx",
|
||||||
"pytest-mock~=3.15.1",
|
"pytest-mock~=3.15.1",
|
||||||
#"pytest-randomly~=4.0.1",
|
#"pytest-randomly~=4.0.1",
|
||||||
|
|||||||
@@ -52,11 +52,11 @@ test('dashboard saved view document links', async ({ page }) => {
|
|||||||
test('test slim sidebar', async ({ page }) => {
|
test('test slim sidebar', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR1, { notFound: 'fallback' })
|
await page.routeFromHAR(REQUESTS_HAR1, { notFound: 'fallback' })
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
await page.locator('#sidebarMenu').getByRole('button').click()
|
await page.locator('.sidebar-slim-toggler').click()
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('link', { name: 'Dashboard' }).getByText('Dashboard')
|
page.getByRole('link', { name: 'Dashboard' }).getByText('Dashboard')
|
||||||
).toBeHidden()
|
).toBeHidden()
|
||||||
await page.locator('#sidebarMenu').getByRole('button').click()
|
await page.locator('.sidebar-slim-toggler').click()
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('link', { name: 'Dashboard' }).getByText('Dashboard')
|
page.getByRole('link', { name: 'Dashboard' }).getByText('Dashboard')
|
||||||
).toBeVisible()
|
).toBeVisible()
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ test('should not allow user to view correspondents', async ({ page }) => {
|
|||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('link', { name: 'Correspondents' })
|
page.getByRole('link', { name: 'Attributes' })
|
||||||
).not.toBeAttached()
|
).not.toBeAttached()
|
||||||
await page.goto('/correspondents')
|
await page.goto('/attributes/correspondents')
|
||||||
await expect(page.locator('body')).toHaveText(
|
await expect(page.locator('body')).toHaveText(
|
||||||
/You don't have permissions to do that/i
|
/You don't have permissions to do that/i
|
||||||
)
|
)
|
||||||
@@ -44,8 +44,10 @@ test('should not allow user to view correspondents', async ({ page }) => {
|
|||||||
test('should not allow user to view tags', async ({ page }) => {
|
test('should not allow user to view tags', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
await expect(page.getByRole('link', { name: 'Tags' })).not.toBeAttached()
|
await expect(
|
||||||
await page.goto('/tags')
|
page.getByRole('link', { name: 'Attributes' })
|
||||||
|
).not.toBeAttached()
|
||||||
|
await page.goto('/attributes/tags')
|
||||||
await expect(page.locator('body')).toHaveText(
|
await expect(page.locator('body')).toHaveText(
|
||||||
/You don't have permissions to do that/i
|
/You don't have permissions to do that/i
|
||||||
)
|
)
|
||||||
@@ -55,9 +57,9 @@ test('should not allow user to view document types', async ({ page }) => {
|
|||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('link', { name: 'Document Types' })
|
page.getByRole('link', { name: 'Attributes' })
|
||||||
).not.toBeAttached()
|
).not.toBeAttached()
|
||||||
await page.goto('/documenttypes')
|
await page.goto('/attributes/documenttypes')
|
||||||
await expect(page.locator('body')).toHaveText(
|
await expect(page.locator('body')).toHaveText(
|
||||||
/You don't have permissions to do that/i
|
/You don't have permissions to do that/i
|
||||||
)
|
)
|
||||||
@@ -67,9 +69,9 @@ test('should not allow user to view storage paths', async ({ page }) => {
|
|||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('link', { name: 'Storage Paths' })
|
page.getByRole('link', { name: 'Attributes' })
|
||||||
).not.toBeAttached()
|
).not.toBeAttached()
|
||||||
await page.goto('/storagepaths')
|
await page.goto('/attributes/storagepaths')
|
||||||
await expect(page.locator('body')).toHaveText(
|
await expect(page.locator('body')).toHaveText(
|
||||||
/You don't have permissions to do that/i
|
/You don't have permissions to do that/i
|
||||||
)
|
)
|
||||||
|
|||||||
1569
src-ui/messages.xlf
1569
src-ui/messages.xlf
File diff suppressed because it is too large
Load Diff
@@ -11,13 +11,9 @@ import { DashboardComponent } from './components/dashboard/dashboard.component'
|
|||||||
import { DocumentAsnComponent } from './components/document-asn/document-asn.component'
|
import { DocumentAsnComponent } from './components/document-asn/document-asn.component'
|
||||||
import { DocumentDetailComponent } from './components/document-detail/document-detail.component'
|
import { DocumentDetailComponent } from './components/document-detail/document-detail.component'
|
||||||
import { DocumentListComponent } from './components/document-list/document-list.component'
|
import { DocumentListComponent } from './components/document-list/document-list.component'
|
||||||
import { CorrespondentListComponent } from './components/manage/correspondent-list/correspondent-list.component'
|
import { DocumentAttributesComponent } from './components/manage/document-attributes/document-attributes.component'
|
||||||
import { CustomFieldsComponent } from './components/manage/custom-fields/custom-fields.component'
|
|
||||||
import { DocumentTypeListComponent } from './components/manage/document-type-list/document-type-list.component'
|
|
||||||
import { MailComponent } from './components/manage/mail/mail.component'
|
import { MailComponent } from './components/manage/mail/mail.component'
|
||||||
import { SavedViewsComponent } from './components/manage/saved-views/saved-views.component'
|
import { SavedViewsComponent } from './components/manage/saved-views/saved-views.component'
|
||||||
import { StoragePathListComponent } from './components/manage/storage-path-list/storage-path-list.component'
|
|
||||||
import { TagListComponent } from './components/manage/tag-list/tag-list.component'
|
|
||||||
import { WorkflowsComponent } from './components/manage/workflows/workflows.component'
|
import { WorkflowsComponent } from './components/manage/workflows/workflows.component'
|
||||||
import { NotFoundComponent } from './components/not-found/not-found.component'
|
import { NotFoundComponent } from './components/not-found/not-found.component'
|
||||||
import { DirtyDocGuard } from './guards/dirty-doc.guard'
|
import { DirtyDocGuard } from './guards/dirty-doc.guard'
|
||||||
@@ -106,52 +102,76 @@ export const routes: Routes = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'tags',
|
path: 'attributes',
|
||||||
component: TagListComponent,
|
component: DocumentAttributesComponent,
|
||||||
canActivate: [PermissionsGuard],
|
canActivate: [PermissionsGuard],
|
||||||
data: {
|
data: {
|
||||||
requiredPermission: {
|
requiredPermissionAny: [
|
||||||
action: PermissionAction.View,
|
{ action: PermissionAction.View, type: PermissionType.Tag },
|
||||||
type: PermissionType.Tag,
|
{
|
||||||
},
|
action: PermissionAction.View,
|
||||||
componentName: 'TagListComponent',
|
type: PermissionType.Correspondent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: PermissionAction.View,
|
||||||
|
type: PermissionType.DocumentType,
|
||||||
|
},
|
||||||
|
{ action: PermissionAction.View, type: PermissionType.StoragePath },
|
||||||
|
{ action: PermissionAction.View, type: PermissionType.CustomField },
|
||||||
|
],
|
||||||
|
componentName: 'DocumentAttributesComponent',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'documenttypes',
|
path: 'attributes/:section',
|
||||||
component: DocumentTypeListComponent,
|
component: DocumentAttributesComponent,
|
||||||
canActivate: [PermissionsGuard],
|
canActivate: [PermissionsGuard],
|
||||||
data: {
|
data: {
|
||||||
requiredPermission: {
|
requiredPermissionAny: [
|
||||||
action: PermissionAction.View,
|
{ action: PermissionAction.View, type: PermissionType.Tag },
|
||||||
type: PermissionType.DocumentType,
|
{
|
||||||
},
|
action: PermissionAction.View,
|
||||||
componentName: 'DocumentTypeListComponent',
|
type: PermissionType.Correspondent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: PermissionAction.View,
|
||||||
|
type: PermissionType.DocumentType,
|
||||||
|
},
|
||||||
|
{ action: PermissionAction.View, type: PermissionType.StoragePath },
|
||||||
|
{ action: PermissionAction.View, type: PermissionType.CustomField },
|
||||||
|
],
|
||||||
|
componentName: 'DocumentAttributesComponent',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'documentproperties',
|
||||||
|
redirectTo: '/attributes',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'documentproperties/:section',
|
||||||
|
redirectTo: '/attributes/:section',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'tags',
|
||||||
|
redirectTo: '/attributes/tags',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'correspondents',
|
path: 'correspondents',
|
||||||
component: CorrespondentListComponent,
|
redirectTo: '/attributes/correspondents',
|
||||||
canActivate: [PermissionsGuard],
|
pathMatch: 'full',
|
||||||
data: {
|
},
|
||||||
requiredPermission: {
|
{
|
||||||
action: PermissionAction.View,
|
path: 'documenttypes',
|
||||||
type: PermissionType.Correspondent,
|
redirectTo: '/attributes/documenttypes',
|
||||||
},
|
pathMatch: 'full',
|
||||||
componentName: 'CorrespondentListComponent',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'storagepaths',
|
path: 'storagepaths',
|
||||||
component: StoragePathListComponent,
|
redirectTo: '/attributes/storagepaths',
|
||||||
canActivate: [PermissionsGuard],
|
pathMatch: 'full',
|
||||||
data: {
|
|
||||||
requiredPermission: {
|
|
||||||
action: PermissionAction.View,
|
|
||||||
type: PermissionType.StoragePath,
|
|
||||||
},
|
|
||||||
componentName: 'StoragePathListComponent',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'logs',
|
path: 'logs',
|
||||||
@@ -239,15 +259,8 @@ export const routes: Routes = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'customfields',
|
path: 'customfields',
|
||||||
component: CustomFieldsComponent,
|
redirectTo: '/attributes/customfields',
|
||||||
canActivate: [PermissionsGuard],
|
pathMatch: 'full',
|
||||||
data: {
|
|
||||||
requiredPermission: {
|
|
||||||
action: PermissionAction.View,
|
|
||||||
type: PermissionType.CustomField,
|
|
||||||
},
|
|
||||||
componentName: 'CustomFieldsComponent',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'workflows',
|
path: 'workflows',
|
||||||
|
|||||||
@@ -195,8 +195,8 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
anchorId: 'tour.tags',
|
anchorId: 'tour.tags',
|
||||||
content: $localize`Tags, correspondents, document types and storage paths can all be managed using these pages. They can also be created from the document edit view.`,
|
content: $localize`Attributes like tags, correspondents, document types, storage paths and custom fields can all be managed here. They can also be created from the document edit view.`,
|
||||||
route: '/tags',
|
route: '/attributes/tags',
|
||||||
backdropConfig: {
|
backdropConfig: {
|
||||||
offset: 0,
|
offset: 0,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -175,44 +175,60 @@
|
|||||||
<span i18n>Manage</span>
|
<span i18n>Manage</span>
|
||||||
</h6>
|
</h6>
|
||||||
<ul class="nav flex-column mb-2">
|
<ul class="nav flex-column mb-2">
|
||||||
<li class="nav-item app-link"
|
@if (canManageAttributes) {
|
||||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
|
<li class="nav-item app-link" tourAnchor="tour.tags">
|
||||||
<a class="nav-link" routerLink="correspondents" routerLinkActive="active" (click)="closeMenu()"
|
<div class="d-flex align-items-center attributes-row">
|
||||||
ngbPopover="Correspondents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
<a class="nav-link flex-fill" routerLink="attributes" routerLinkActive="active" (click)="closeMenu()"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
ngbPopover="Attributes" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
<i-bs class="me-1" name="person"></i-bs><span> <ng-container i18n>Correspondents</ng-container></span>
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
</a>
|
<i-bs class="me-1" name="stack"></i-bs><span> <ng-container i18n>Attributes</ng-container></span>
|
||||||
</li>
|
</a>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"
|
@if (!slimSidebarEnabled) {
|
||||||
tourAnchor="tour.tags">
|
<button
|
||||||
<a class="nav-link" routerLink="tags" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Tags"
|
type="button"
|
||||||
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
class="btn btn-link btn-sm text-muted p-0 me-3 attributes-expand-btn"
|
||||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
(click)="toggleAttributesSections($event)"
|
||||||
<i-bs class="me-1" name="tags"></i-bs><span> <ng-container i18n>Tags</ng-container></span>
|
[attr.aria-label]="attributesSectionsCollapsed ? 'Expand attributes sections' : 'Collapse attributes sections'"
|
||||||
</a>
|
i18n-aria-label
|
||||||
</li>
|
>
|
||||||
<li class="nav-item app-link"
|
<i-bs [name]="attributesSectionsCollapsed ? 'plus-circle' : 'dash-circle'"></i-bs>
|
||||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
|
</button>
|
||||||
<a class="nav-link" routerLink="documenttypes" routerLinkActive="active" (click)="closeMenu()"
|
}
|
||||||
ngbPopover="Document Types" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
</div>
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
<div
|
||||||
<i-bs class="me-1" name="hash"></i-bs><span> <ng-container i18n>Document Types</ng-container></span>
|
class="attributes-submenu ms-2"
|
||||||
</a>
|
[ngbCollapse]="slimSidebarEnabled || attributesSectionsCollapsed"
|
||||||
</li>
|
>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
|
<ul class="nav flex-column">
|
||||||
<a class="nav-link" routerLink="storagepaths" routerLinkActive="active" (click)="closeMenu()"
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }">
|
||||||
ngbPopover="Storage Paths" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
<a class="nav-link py-1" routerLink="attributes/tags" routerLinkActive="active" (click)="closeMenu()">
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
<i-bs class="me-1" name="tags"></i-bs><span> <ng-container i18n>Tags</ng-container></span>
|
||||||
<i-bs class="me-1" name="folder"></i-bs><span> <ng-container i18n>Storage Paths</ng-container></span>
|
</a>
|
||||||
</a>
|
</li>
|
||||||
</li>
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
|
<a class="nav-link py-1" routerLink="attributes/correspondents" routerLinkActive="active" (click)="closeMenu()">
|
||||||
<a class="nav-link" routerLink="customfields" routerLinkActive="active" (click)="closeMenu()"
|
<i-bs class="me-1" name="person"></i-bs><span> <ng-container i18n>Correspondents</ng-container></span>
|
||||||
ngbPopover="Custom Fields" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
</a>
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
</li>
|
||||||
<i-bs class="me-1" name="ui-radios"></i-bs><span> <ng-container i18n>Custom Fields</ng-container></span>
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
|
||||||
</a>
|
<a class="nav-link py-1" routerLink="attributes/documenttypes" routerLinkActive="active" (click)="closeMenu()">
|
||||||
</li>
|
<i-bs class="me-1" name="hash"></i-bs><span> <ng-container i18n>Document types</ng-container></span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
|
||||||
|
<a class="nav-link py-1" routerLink="attributes/storagepaths" routerLinkActive="active" (click)="closeMenu()">
|
||||||
|
<i-bs class="me-1" name="folder"></i-bs><span> <ng-container i18n>Storage paths</ng-container></span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
|
||||||
|
<a class="nav-link py-1" routerLink="attributes/customfields" routerLinkActive="active" (click)="closeMenu()">
|
||||||
|
<i-bs class="me-1" name="ui-radios"></i-bs><span> <ng-container i18n>Custom fields</ng-container></span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
||||||
<a class="nav-link" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
|
|||||||
@@ -177,6 +177,15 @@ main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.attributes-row .attributes-expand-btn {
|
||||||
|
opacity: 0.2;
|
||||||
|
transition: opacity 0.15s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attributes-row:hover .attributes-expand-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-heading {
|
.sidebar-heading {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ import {
|
|||||||
DjangoMessagesService,
|
DjangoMessagesService,
|
||||||
} from 'src/app/services/django-messages.service'
|
} from 'src/app/services/django-messages.service'
|
||||||
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
||||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
import {
|
||||||
|
PermissionType,
|
||||||
|
PermissionsService,
|
||||||
|
} from 'src/app/services/permissions.service'
|
||||||
import { RemoteVersionService } from 'src/app/services/rest/remote-version.service'
|
import { RemoteVersionService } from 'src/app/services/rest/remote-version.service'
|
||||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||||
import { SearchService } from 'src/app/services/rest/search.service'
|
import { SearchService } from 'src/app/services/rest/search.service'
|
||||||
@@ -258,7 +261,7 @@ describe('AppFrameComponent', () => {
|
|||||||
const toastSpy = jest.spyOn(toastService, 'showError')
|
const toastSpy = jest.spyOn(toastService, 'showError')
|
||||||
component.toggleSlimSidebar()
|
component.toggleSlimSidebar()
|
||||||
httpTestingController
|
httpTestingController
|
||||||
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
|
.match(`${environment.apiBaseUrl}ui_settings/`)[0]
|
||||||
.flush('error', {
|
.flush('error', {
|
||||||
status: 500,
|
status: 500,
|
||||||
statusText: 'error',
|
statusText: 'error',
|
||||||
@@ -373,4 +376,103 @@ describe('AppFrameComponent', () => {
|
|||||||
it('should call maybeRefreshDocumentCounts after saved views reload', () => {
|
it('should call maybeRefreshDocumentCounts after saved views reload', () => {
|
||||||
expect(maybeRefreshSpy).toHaveBeenCalled()
|
expect(maybeRefreshSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should indicate attributes management availability when any permission is granted', () => {
|
||||||
|
jest
|
||||||
|
.spyOn(permissionsService, 'currentUserCan')
|
||||||
|
.mockImplementation((action, type) => {
|
||||||
|
return type === PermissionType.Tag
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(component.canManageAttributes).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should indicate attributes management availability for other permission types', () => {
|
||||||
|
const canSpy = jest
|
||||||
|
.spyOn(permissionsService, 'currentUserCan')
|
||||||
|
.mockImplementation((action, type) => {
|
||||||
|
return type === PermissionType.Correspondent
|
||||||
|
})
|
||||||
|
expect(component.canManageAttributes).toBe(true)
|
||||||
|
|
||||||
|
canSpy.mockImplementation((action, type) => {
|
||||||
|
return type === PermissionType.DocumentType
|
||||||
|
})
|
||||||
|
expect(component.canManageAttributes).toBe(true)
|
||||||
|
|
||||||
|
canSpy.mockImplementation((action, type) => {
|
||||||
|
return type === PermissionType.StoragePath
|
||||||
|
})
|
||||||
|
expect(component.canManageAttributes).toBe(true)
|
||||||
|
|
||||||
|
canSpy.mockImplementation((action, type) => {
|
||||||
|
return type === PermissionType.CustomField
|
||||||
|
})
|
||||||
|
expect(component.canManageAttributes).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should toggle attributes sections and stop event bubbling', () => {
|
||||||
|
const preventDefault = jest.fn()
|
||||||
|
const stopPropagation = jest.fn()
|
||||||
|
const setSpy = jest.spyOn(settingsService, 'set')
|
||||||
|
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
|
||||||
|
|
||||||
|
component.toggleAttributesSections({
|
||||||
|
preventDefault,
|
||||||
|
stopPropagation,
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
expect(preventDefault).toHaveBeenCalled()
|
||||||
|
expect(stopPropagation).toHaveBeenCalled()
|
||||||
|
expect(setSpy).toHaveBeenCalledWith(
|
||||||
|
SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED,
|
||||||
|
['attributes']
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show error when saving slim sidebar setting fails', () => {
|
||||||
|
const toastSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
jest.spyOn(console, 'warn').mockImplementation(() => {})
|
||||||
|
jest
|
||||||
|
.spyOn(settingsService, 'storeSettings')
|
||||||
|
.mockReturnValue(throwError(() => new Error('boom')))
|
||||||
|
|
||||||
|
component.slimSidebarEnabled = true
|
||||||
|
|
||||||
|
expect(toastSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show error when saving attributes collapsed setting fails', () => {
|
||||||
|
const toastSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
jest.spyOn(console, 'warn').mockImplementation(() => {})
|
||||||
|
jest
|
||||||
|
.spyOn(settingsService, 'storeSettings')
|
||||||
|
.mockReturnValue(throwError(() => new Error('boom')))
|
||||||
|
|
||||||
|
component.attributesSectionsCollapsed = true
|
||||||
|
|
||||||
|
expect(toastSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should persist attributes section collapse state', () => {
|
||||||
|
const setSpy = jest.spyOn(settingsService, 'set')
|
||||||
|
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
|
||||||
|
|
||||||
|
component.attributesSectionsCollapsed = true
|
||||||
|
|
||||||
|
expect(setSpy).toHaveBeenCalledWith(
|
||||||
|
SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED,
|
||||||
|
['attributes']
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should collapse attributes sections when enabling slim sidebar', () => {
|
||||||
|
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
|
||||||
|
settingsService.set(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED, [])
|
||||||
|
settingsService.set(SETTINGS_KEYS.SLIM_SIDEBAR, false)
|
||||||
|
|
||||||
|
component.toggleSlimSidebar()
|
||||||
|
|
||||||
|
expect(component.attributesSectionsCollapsed).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { Observable } from 'rxjs'
|
|||||||
import { first } from 'rxjs/operators'
|
import { first } from 'rxjs/operators'
|
||||||
import { Document } from 'src/app/data/document'
|
import { Document } from 'src/app/data/document'
|
||||||
import { SavedView } from 'src/app/data/saved-view'
|
import { SavedView } from 'src/app/data/saved-view'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { CollapsibleSection, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { ComponentCanDeactivate } from 'src/app/guards/dirty-doc.guard'
|
import { ComponentCanDeactivate } from 'src/app/guards/dirty-doc.guard'
|
||||||
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
|
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
|
||||||
@@ -141,11 +141,20 @@ export class AppFrameComponent
|
|||||||
toggleSlimSidebar(): void {
|
toggleSlimSidebar(): void {
|
||||||
this.slimSidebarAnimating = true
|
this.slimSidebarAnimating = true
|
||||||
this.slimSidebarEnabled = !this.slimSidebarEnabled
|
this.slimSidebarEnabled = !this.slimSidebarEnabled
|
||||||
|
if (this.slimSidebarEnabled) {
|
||||||
|
this.attributesSectionsCollapsed = true
|
||||||
|
}
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.slimSidebarAnimating = false
|
this.slimSidebarAnimating = false
|
||||||
}, 200) // slightly longer than css animation for slim sidebar
|
}, 200) // slightly longer than css animation for slim sidebar
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggleAttributesSections(event?: Event): void {
|
||||||
|
event?.preventDefault()
|
||||||
|
event?.stopPropagation()
|
||||||
|
this.attributesSectionsCollapsed = !this.attributesSectionsCollapsed
|
||||||
|
}
|
||||||
|
|
||||||
get versionString(): string {
|
get versionString(): string {
|
||||||
return `${environment.appTitle} v${this.settingsService.get(SETTINGS_KEYS.VERSION)}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
|
return `${environment.appTitle} v${this.settingsService.get(SETTINGS_KEYS.VERSION)}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
|
||||||
}
|
}
|
||||||
@@ -167,6 +176,31 @@ export class AppFrameComponent
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get canManageAttributes(): boolean {
|
||||||
|
return (
|
||||||
|
this.permissionsService.currentUserCan(
|
||||||
|
PermissionAction.View,
|
||||||
|
PermissionType.Tag
|
||||||
|
) ||
|
||||||
|
this.permissionsService.currentUserCan(
|
||||||
|
PermissionAction.View,
|
||||||
|
PermissionType.Correspondent
|
||||||
|
) ||
|
||||||
|
this.permissionsService.currentUserCan(
|
||||||
|
PermissionAction.View,
|
||||||
|
PermissionType.DocumentType
|
||||||
|
) ||
|
||||||
|
this.permissionsService.currentUserCan(
|
||||||
|
PermissionAction.View,
|
||||||
|
PermissionType.StoragePath
|
||||||
|
) ||
|
||||||
|
this.permissionsService.currentUserCan(
|
||||||
|
PermissionAction.View,
|
||||||
|
PermissionType.CustomField
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
get slimSidebarEnabled(): boolean {
|
get slimSidebarEnabled(): boolean {
|
||||||
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
|
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
|
||||||
}
|
}
|
||||||
@@ -186,6 +220,31 @@ export class AppFrameComponent
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get attributesSectionsCollapsed(): boolean {
|
||||||
|
return this.settingsService
|
||||||
|
.get(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED)
|
||||||
|
?.includes(CollapsibleSection.ATTRIBUTES)
|
||||||
|
}
|
||||||
|
|
||||||
|
set attributesSectionsCollapsed(collapsed: boolean) {
|
||||||
|
// TODO: refactor to be able to toggle individual sections, if implemented
|
||||||
|
this.settingsService.set(
|
||||||
|
SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED,
|
||||||
|
collapsed ? [CollapsibleSection.ATTRIBUTES] : []
|
||||||
|
)
|
||||||
|
this.settingsService
|
||||||
|
.storeSettings()
|
||||||
|
.pipe(first())
|
||||||
|
.subscribe({
|
||||||
|
error: (error) => {
|
||||||
|
this.toastService.showError(
|
||||||
|
$localize`An error occurred while saving settings.`
|
||||||
|
)
|
||||||
|
console.warn(error)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
get aiEnabled(): boolean {
|
get aiEnabled(): boolean {
|
||||||
return this.settingsService.get(SETTINGS_KEYS.AI_ENABLED)
|
return this.settingsService.get(SETTINGS_KEYS.AI_ENABLED)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,19 +9,24 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-4">
|
<div class="col-md-6">
|
||||||
<pngx-input-text [horizontal]="true" i18n-title title="Name" formControlName="name" [error]="error?.name" autocomplete="off"></pngx-input-text>
|
<pngx-input-text [horizontal]="true" i18n-title title="Name" formControlName="name" [error]="error?.name" autocomplete="off"></pngx-input-text>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-4">
|
||||||
<pngx-input-select [horizontal]="true" i18n-title title="Account" [items]="accounts" formControlName="account"></pngx-input-select>
|
<pngx-input-select [horizontal]="true" i18n-title title="Account" [items]="accounts" formControlName="account"></pngx-input-select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
|
||||||
<pngx-input-number [horizontal]="true" i18n-title title="Order" formControlName="order" [showAdd]="false" [error]="error?.order"></pngx-input-number>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2 pt-2">
|
<div class="col-md-2 pt-2">
|
||||||
<pngx-input-switch [horizontal]="true" i18n-title title="Enabled" formControlName="enabled"></pngx-input-switch>
|
<pngx-input-switch [horizontal]="true" i18n-title title="Enabled" formControlName="enabled"></pngx-input-switch>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<pngx-input-number [horizontal]="true" i18n-title title="Order" formControlName="order" [showAdd]="false" [error]="error?.order"></pngx-input-number>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<pngx-input-switch [horizontal]="true" i18n-title title="Stop further processing" formControlName="stop_processing" i18n-hint hint="Stop processing further rules if this rule queues any document(s)."></pngx-input-switch>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<hr class="mt-0"/>
|
<hr class="mt-0"/>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<p class="small" i18n>Paperless will only process mails that match <em>all</em> of the criteria specified below.</p>
|
<p class="small" i18n>Paperless will only process mails that match <em>all</em> of the criteria specified below.</p>
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ export class MailRuleEditDialogComponent extends EditDialogComponent<MailRule> {
|
|||||||
),
|
),
|
||||||
assign_correspondent: new FormControl(null),
|
assign_correspondent: new FormControl(null),
|
||||||
assign_owner_from_rule: new FormControl(true),
|
assign_owner_from_rule: new FormControl(true),
|
||||||
|
stop_processing: new FormControl(false),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -150,4 +150,8 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|
||||||
|
& .annotationTextContent {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,13 @@ describe('PngxPdfViewerComponent', () => {
|
|||||||
const pageSpy = jest.fn()
|
const pageSpy = jest.fn()
|
||||||
component.pageChange.subscribe(pageSpy)
|
component.pageChange.subscribe(pageSpy)
|
||||||
|
|
||||||
|
// In real usage the viewer may have multiple pages; our pdfjs mock defaults
|
||||||
|
// to a single page, so explicitly simulate a multi-page document here.
|
||||||
|
const pdf = (component as any).pdf as { numPages: number }
|
||||||
|
pdf.numPages = 3
|
||||||
|
const viewer = (component as any).pdfViewer as PDFViewer
|
||||||
|
viewer.setDocument(pdf)
|
||||||
|
|
||||||
component.zoomScale = PdfZoomScale.PageFit
|
component.zoomScale = PdfZoomScale.PageFit
|
||||||
component.zoom = PdfZoomLevel.Two
|
component.zoom = PdfZoomLevel.Two
|
||||||
component.rotation = 90
|
component.rotation = 90
|
||||||
@@ -81,7 +88,6 @@ describe('PngxPdfViewerComponent', () => {
|
|||||||
page: new SimpleChange(undefined, 2, false),
|
page: new SimpleChange(undefined, 2, false),
|
||||||
})
|
})
|
||||||
|
|
||||||
const viewer = (component as any).pdfViewer as PDFViewer
|
|
||||||
expect(viewer.pagesRotation).toBe(90)
|
expect(viewer.pagesRotation).toBe(90)
|
||||||
expect(viewer.currentPageNumber).toBe(2)
|
expect(viewer.currentPageNumber).toBe(2)
|
||||||
expect(pageSpy).toHaveBeenCalledWith(2)
|
expect(pageSpy).toHaveBeenCalledWith(2)
|
||||||
@@ -196,6 +202,8 @@ describe('PngxPdfViewerComponent', () => {
|
|||||||
const scaleSpy = jest.spyOn(component as any, 'applyViewerState')
|
const scaleSpy = jest.spyOn(component as any, 'applyViewerState')
|
||||||
const resizeSpy = jest.spyOn(component as any, 'setupResizeObserver')
|
const resizeSpy = jest.spyOn(component as any, 'setupResizeObserver')
|
||||||
|
|
||||||
|
// Angular sets the input value before calling ngOnChanges; mirror that here.
|
||||||
|
component.src = 'test.pdf'
|
||||||
component.ngOnChanges({
|
component.ngOnChanges({
|
||||||
src: new SimpleChange(undefined, 'test.pdf', true),
|
src: new SimpleChange(undefined, 'test.pdf', true),
|
||||||
zoomScale: new SimpleChange(
|
zoomScale: new SimpleChange(
|
||||||
@@ -211,6 +219,25 @@ describe('PngxPdfViewerComponent', () => {
|
|||||||
expect(scaleSpy).not.toHaveBeenCalled()
|
expect(scaleSpy).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('resets viewer state on src change', () => {
|
||||||
|
const mockViewer = {
|
||||||
|
setDocument: jest.fn(),
|
||||||
|
currentPageNumber: 7,
|
||||||
|
cleanup: jest.fn(),
|
||||||
|
}
|
||||||
|
;(component as any).pdfViewer = mockViewer
|
||||||
|
;(component as any).loadingTask = { destroy: jest.fn() }
|
||||||
|
jest.spyOn(component as any, 'loadDocument').mockImplementation(() => {})
|
||||||
|
|
||||||
|
component.src = 'test.pdf'
|
||||||
|
component.ngOnChanges({
|
||||||
|
src: new SimpleChange(undefined, 'test.pdf', true),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockViewer.setDocument).toHaveBeenCalledWith(null)
|
||||||
|
expect(mockViewer.currentPageNumber).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
it('applies viewer state after view init when already loaded', () => {
|
it('applies viewer state after view init when already loaded', () => {
|
||||||
const applySpy = jest.spyOn(component as any, 'applyViewerState')
|
const applySpy = jest.spyOn(component as any, 'applyViewerState')
|
||||||
;(component as any).hasLoaded = true
|
;(component as any).hasLoaded = true
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export class PngxPdfViewerComponent
|
|||||||
this.dispatchFindIfReady()
|
this.dispatchFindIfReady()
|
||||||
this.rendered.emit()
|
this.rendered.emit()
|
||||||
}
|
}
|
||||||
private readonly onPagesInit = () => this.applyScale()
|
private readonly onPagesInit = () => this.applyViewerState()
|
||||||
private readonly onPageChanging = (evt: { pageNumber: number }) => {
|
private readonly onPageChanging = (evt: { pageNumber: number }) => {
|
||||||
// Avoid [(page)] two-way binding re-triggers navigation
|
// Avoid [(page)] two-way binding re-triggers navigation
|
||||||
this.lastViewerPage = evt.pageNumber
|
this.lastViewerPage = evt.pageNumber
|
||||||
@@ -90,8 +90,10 @@ export class PngxPdfViewerComponent
|
|||||||
|
|
||||||
ngOnChanges(changes: SimpleChanges): void {
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
if (changes['src']) {
|
if (changes['src']) {
|
||||||
this.hasLoaded = false
|
this.resetViewerState()
|
||||||
this.loadDocument()
|
if (this.src) {
|
||||||
|
this.loadDocument()
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +141,21 @@ export class PngxPdfViewerComponent
|
|||||||
this.pdfViewer = undefined
|
this.pdfViewer = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resetViewerState(): void {
|
||||||
|
this.hasLoaded = false
|
||||||
|
this.hasRenderedPage = false
|
||||||
|
this.lastFindQuery = ''
|
||||||
|
this.lastViewerPage = undefined
|
||||||
|
this.loadingTask?.destroy()
|
||||||
|
this.loadingTask = undefined
|
||||||
|
this.pdf = undefined
|
||||||
|
this.linkService.setDocument(null)
|
||||||
|
if (this.pdfViewer) {
|
||||||
|
this.pdfViewer.setDocument(null)
|
||||||
|
this.pdfViewer.currentPageNumber = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async loadDocument(): Promise<void> {
|
private async loadDocument(): Promise<void> {
|
||||||
if (this.hasLoaded) {
|
if (this.hasLoaded) {
|
||||||
return
|
return
|
||||||
@@ -222,7 +239,11 @@ export class PngxPdfViewerComponent
|
|||||||
hasPages &&
|
hasPages &&
|
||||||
this.page !== this.lastViewerPage
|
this.page !== this.lastViewerPage
|
||||||
) {
|
) {
|
||||||
this.pdfViewer.currentPageNumber = this.page
|
const nextPage = Math.min(
|
||||||
|
Math.max(Math.trunc(this.page), 1),
|
||||||
|
this.pdfViewer.pagesCount
|
||||||
|
)
|
||||||
|
this.pdfViewer.currentPageNumber = nextPage
|
||||||
}
|
}
|
||||||
if (this.page === this.lastViewerPage) {
|
if (this.page === this.lastViewerPage) {
|
||||||
this.lastViewerPage = undefined
|
this.lastViewerPage = undefined
|
||||||
|
|||||||
@@ -457,7 +457,7 @@
|
|||||||
@if (!useNativePdfViewer) {
|
@if (!useNativePdfViewer) {
|
||||||
<div class="preview-sticky pdf-viewer-container">
|
<div class="preview-sticky pdf-viewer-container">
|
||||||
<pngx-pdf-viewer
|
<pngx-pdf-viewer
|
||||||
[src]="{ url: previewUrl, password: password }"
|
[src]="pdfSource"
|
||||||
[renderMode]="PdfRenderMode.All"
|
[renderMode]="PdfRenderMode.All"
|
||||||
[(page)]="previewCurrentPage"
|
[(page)]="previewCurrentPage"
|
||||||
[zoomScale]="previewZoomScale"
|
[zoomScale]="previewZoomScale"
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ import { PDFEditorComponent } from '../common/pdf-editor/pdf-editor.component'
|
|||||||
import { PngxPdfViewerComponent } from '../common/pdf-viewer/pdf-viewer.component'
|
import { PngxPdfViewerComponent } from '../common/pdf-viewer/pdf-viewer.component'
|
||||||
import {
|
import {
|
||||||
PdfRenderMode,
|
PdfRenderMode,
|
||||||
|
PdfSource,
|
||||||
PdfZoomLevel,
|
PdfZoomLevel,
|
||||||
PdfZoomScale,
|
PdfZoomScale,
|
||||||
PngxPdfDocumentProxy,
|
PngxPdfDocumentProxy,
|
||||||
@@ -227,6 +228,7 @@ export class DocumentDetailComponent
|
|||||||
title: string
|
title: string
|
||||||
titleSubject: Subject<string> = new Subject()
|
titleSubject: Subject<string> = new Subject()
|
||||||
previewUrl: string
|
previewUrl: string
|
||||||
|
pdfSource?: PdfSource
|
||||||
thumbUrl: string
|
thumbUrl: string
|
||||||
previewText: string
|
previewText: string
|
||||||
previewLoaded: boolean = false
|
previewLoaded: boolean = false
|
||||||
@@ -345,6 +347,17 @@ export class DocumentDetailComponent
|
|||||||
return ContentRenderType.Other
|
return ContentRenderType.Other
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private updatePdfSource() {
|
||||||
|
if (!this.previewUrl) {
|
||||||
|
this.pdfSource = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.pdfSource = {
|
||||||
|
url: this.previewUrl,
|
||||||
|
password: this.password || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
get isRTL() {
|
get isRTL() {
|
||||||
if (!this.metadata || !this.metadata.lang) return false
|
if (!this.metadata || !this.metadata.lang) return false
|
||||||
else {
|
else {
|
||||||
@@ -421,6 +434,7 @@ export class DocumentDetailComponent
|
|||||||
|
|
||||||
private loadDocument(documentId: number): void {
|
private loadDocument(documentId: number): void {
|
||||||
this.previewUrl = this.documentsService.getPreviewUrl(documentId)
|
this.previewUrl = this.documentsService.getPreviewUrl(documentId)
|
||||||
|
this.updatePdfSource()
|
||||||
this.http
|
this.http
|
||||||
.get(this.previewUrl, { responseType: 'text' })
|
.get(this.previewUrl, { responseType: 'text' })
|
||||||
.pipe(
|
.pipe(
|
||||||
@@ -1230,6 +1244,7 @@ export class DocumentDetailComponent
|
|||||||
onPasswordKeyUp(event: KeyboardEvent) {
|
onPasswordKeyUp(event: KeyboardEvent) {
|
||||||
if ('Enter' == event.key) {
|
if ('Enter' == event.key) {
|
||||||
this.password = (event.target as HTMLInputElement).value
|
this.password = (event.target as HTMLInputElement).value
|
||||||
|
this.updatePdfSource()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,3 @@
|
|||||||
<pngx-page-header
|
|
||||||
title="Custom Fields"
|
|
||||||
i18n-title
|
|
||||||
info="Customize the data fields that can be attached to documents."
|
|
||||||
i18n-info
|
|
||||||
infoLink="usage/#custom-fields"
|
|
||||||
>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="editField()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.CustomField }">
|
|
||||||
<i-bs name="plus-circle"></i-bs> <ng-container i18n>Add Field</ng-container>
|
|
||||||
</button>
|
|
||||||
</pngx-page-header>
|
|
||||||
|
|
||||||
<ul class="list-group">
|
<ul class="list-group">
|
||||||
|
|
||||||
<li class="list-group-item">
|
<li class="list-group-item">
|
||||||
@@ -26,9 +26,9 @@ import { PermissionsService } from 'src/app/services/permissions.service'
|
|||||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
import { ConfirmDialogComponent } from '../../../common/confirm-dialog/confirm-dialog.component'
|
||||||
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
import { CustomFieldEditDialogComponent } from '../../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
import { PageHeaderComponent } from '../../../common/page-header/page-header.component'
|
||||||
import { CustomFieldsComponent } from './custom-fields.component'
|
import { CustomFieldsComponent } from './custom-fields.component'
|
||||||
|
|
||||||
const fields: CustomField[] = [
|
const fields: CustomField[] = [
|
||||||
@@ -110,10 +110,7 @@ describe('CustomFieldsComponent', () => {
|
|||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
const reloadSpy = jest.spyOn(component, 'reload')
|
const reloadSpy = jest.spyOn(component, 'reload')
|
||||||
|
|
||||||
const createButton = fixture.debugElement
|
component.editField()
|
||||||
.queryAll(By.css('button'))
|
|
||||||
.find((btn) => btn.nativeElement.textContent.trim().includes('Add Field'))
|
|
||||||
createButton.triggerEventHandler('click')
|
|
||||||
|
|
||||||
expect(modal).not.toBeUndefined()
|
expect(modal).not.toBeUndefined()
|
||||||
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
|
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
|
||||||
@@ -7,6 +7,10 @@ import {
|
|||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { delay, takeUntil, tap } from 'rxjs'
|
import { delay, takeUntil, tap } from 'rxjs'
|
||||||
|
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
|
||||||
|
import { CustomFieldEditDialogComponent } from 'src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||||
|
import { EditDialogMode } from 'src/app/components/common/edit-dialog/edit-dialog.component'
|
||||||
|
import { LoadingComponentWithPermissions } from 'src/app/components/loading-component/loading.component'
|
||||||
import { CustomField, DATA_TYPE_LABELS } from 'src/app/data/custom-field'
|
import { CustomField, DATA_TYPE_LABELS } from 'src/app/data/custom-field'
|
||||||
import {
|
import {
|
||||||
CustomFieldQueryLogicalOperator,
|
CustomFieldQueryLogicalOperator,
|
||||||
@@ -21,18 +25,12 @@ import { DocumentService } from 'src/app/services/rest/document.service'
|
|||||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
|
||||||
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
|
||||||
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
|
|
||||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
|
||||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-custom-fields',
|
selector: 'pngx-custom-fields',
|
||||||
templateUrl: './custom-fields.component.html',
|
templateUrl: './custom-fields.component.html',
|
||||||
styleUrls: ['./custom-fields.component.scss'],
|
styleUrls: ['./custom-fields.component.scss'],
|
||||||
imports: [
|
imports: [
|
||||||
PageHeaderComponent,
|
|
||||||
IfPermissionsDirective,
|
IfPermissionsDirective,
|
||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
NgbPaginationModule,
|
NgbPaginationModule,
|
||||||
@@ -44,14 +42,14 @@ export class CustomFieldsComponent
|
|||||||
extends LoadingComponentWithPermissions
|
extends LoadingComponentWithPermissions
|
||||||
implements OnInit
|
implements OnInit
|
||||||
{
|
{
|
||||||
private customFieldsService = inject(CustomFieldsService)
|
private readonly customFieldsService = inject(CustomFieldsService)
|
||||||
permissionsService = inject(PermissionsService)
|
public readonly permissionsService = inject(PermissionsService)
|
||||||
private modalService = inject(NgbModal)
|
private readonly modalService = inject(NgbModal)
|
||||||
private toastService = inject(ToastService)
|
private readonly toastService = inject(ToastService)
|
||||||
private documentListViewService = inject(DocumentListViewService)
|
private readonly documentListViewService = inject(DocumentListViewService)
|
||||||
private settingsService = inject(SettingsService)
|
private readonly settingsService = inject(SettingsService)
|
||||||
private documentService = inject(DocumentService)
|
private readonly documentService = inject(DocumentService)
|
||||||
private savedViewService = inject(SavedViewService)
|
private readonly savedViewService = inject(SavedViewService)
|
||||||
|
|
||||||
public fields: CustomField[] = []
|
public fields: CustomField[] = []
|
||||||
|
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<pngx-page-header
|
||||||
|
[title]="activeTabLabel"
|
||||||
|
info="Manage tags, correspondents, document types, storage paths, and custom fields."
|
||||||
|
i18n-info
|
||||||
|
[infoLink]="activeInfoLink"
|
||||||
|
[loading]="activeHeaderLoading"
|
||||||
|
>
|
||||||
|
@if (activeManagementList) {
|
||||||
|
<div ngbDropdown class="btn-group flex-fill d-sm-none">
|
||||||
|
<button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle>
|
||||||
|
<i-bs name="text-indent-left"></i-bs>
|
||||||
|
<div class="d-none d-sm-inline"> <ng-container i18n>Select</ng-container></div>
|
||||||
|
@if (activeManagementList.selectedObjects.size > 0) {
|
||||||
|
<pngx-clearable-badge [selected]="activeManagementList.selectedObjects.size > 0" [number]="activeManagementList.selectedObjects.size" (cleared)="activeManagementList.selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
<div ngbDropdownMenu aria-labelledby="dropdownSelectMobile" class="shadow">
|
||||||
|
<button ngbDropdownItem (click)="activeManagementList.selectNone()" i18n>Select none</button>
|
||||||
|
<button ngbDropdownItem (click)="activeManagementList.selectPage(true)" i18n>Select page</button>
|
||||||
|
<button ngbDropdownItem (click)="activeManagementList.selectAll()" i18n>Select all</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-none d-sm-flex flex-fill me-3">
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<span class="input-group-text border-0" i18n>Select:</span>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group btn-group-sm flex-nowrap">
|
||||||
|
@if (activeManagementList.selectedObjects.size > 0) {
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" (click)="activeManagementList.selectNone()">
|
||||||
|
<i-bs name="slash-circle"></i-bs> <ng-container i18n>None</ng-container>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button class="btn btn-sm btn-outline-primary" (click)="activeManagementList.selectPage(true)">
|
||||||
|
<i-bs name="file-earmark-check"></i-bs> <ng-container i18n>Page</ng-container>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" (click)="activeManagementList.selectAll()">
|
||||||
|
<i-bs name="check-all"></i-bs> <ng-container i18n>All</ng-container>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary" (click)="activeManagementList.setPermissions()"
|
||||||
|
[disabled]="!activeManagementList.userCanBulkEdit(PermissionAction.Change) || activeManagementList.selectedObjects.size === 0">
|
||||||
|
<i-bs name="person-fill-lock"></i-bs> <ng-container i18n>Permissions</ng-container>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger" (click)="activeManagementList.delete()"
|
||||||
|
[disabled]="!activeManagementList.userCanBulkEdit(PermissionAction.Delete) || activeManagementList.selectedObjects.size === 0">
|
||||||
|
<i-bs name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary ms-md-5" (click)="activeManagementList.openCreateDialog()"
|
||||||
|
*pngxIfPermissions="{ action: PermissionAction.Add, type: activeManagementList.permissionType }">
|
||||||
|
<i-bs name="plus-circle"></i-bs> <ng-container i18n>Create</ng-container>
|
||||||
|
</button>
|
||||||
|
} @else if (activeCustomFields) {
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary" (click)="activeCustomFields.editField()"
|
||||||
|
*pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.CustomField }">
|
||||||
|
<i-bs name="plus-circle"></i-bs> <ng-container i18n>Add Field</ng-container>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</pngx-page-header>
|
||||||
|
|
||||||
|
<ul ngbNav #nav="ngbNav" (navChange)="onNavChange($event)" [(activeId)]="activeNavID" class="nav-underline">
|
||||||
|
@for (section of visibleSections; track section.id) {
|
||||||
|
<li [ngbNavItem]="section.id">
|
||||||
|
<a ngbNavLink >
|
||||||
|
<i-bs class="me-2" [name]="section.icon"></i-bs>{{ section.label }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="my-3 shadow-sm">
|
||||||
|
<ng-container
|
||||||
|
[ngComponentOutlet]="activeSection?.component"
|
||||||
|
#activeOutlet="ngComponentOutlet"
|
||||||
|
></ng-container>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { Component } from '@angular/core'
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
|
import {
|
||||||
|
ActivatedRoute,
|
||||||
|
convertToParamMap,
|
||||||
|
ParamMap,
|
||||||
|
Router,
|
||||||
|
} from '@angular/router'
|
||||||
|
import { RouterTestingModule } from '@angular/router/testing'
|
||||||
|
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
|
import { Subject } from 'rxjs'
|
||||||
|
import {
|
||||||
|
PermissionAction,
|
||||||
|
PermissionsService,
|
||||||
|
PermissionType,
|
||||||
|
} from 'src/app/services/permissions.service'
|
||||||
|
import {
|
||||||
|
DocumentAttributesComponent,
|
||||||
|
DocumentAttributesSectionKind,
|
||||||
|
} from './document-attributes.component'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-dummy-section',
|
||||||
|
template: '',
|
||||||
|
standalone: true,
|
||||||
|
})
|
||||||
|
class DummySectionComponent {}
|
||||||
|
|
||||||
|
describe('DocumentAttributesComponent', () => {
|
||||||
|
let component: DocumentAttributesComponent
|
||||||
|
let fixture: ComponentFixture<DocumentAttributesComponent>
|
||||||
|
let router: Router
|
||||||
|
let paramMapSubject: Subject<ParamMap>
|
||||||
|
let permissionsService: PermissionsService
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
paramMapSubject = new Subject<ParamMap>()
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [
|
||||||
|
RouterTestingModule,
|
||||||
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
|
DocumentAttributesComponent,
|
||||||
|
DummySectionComponent,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: ActivatedRoute,
|
||||||
|
useValue: {
|
||||||
|
paramMap: paramMapSubject.asObservable(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: PermissionsService,
|
||||||
|
useValue: {
|
||||||
|
currentUserCan: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compileComponents()
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(DocumentAttributesComponent)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
router = TestBed.inject(Router)
|
||||||
|
permissionsService = TestBed.inject(PermissionsService)
|
||||||
|
|
||||||
|
jest.spyOn(router, 'navigate').mockResolvedValue(true)
|
||||||
|
;(component as any).sections = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
path: 'tags',
|
||||||
|
label: 'Tags',
|
||||||
|
icon: 'tags',
|
||||||
|
permissionType: PermissionType.Tag,
|
||||||
|
kind: DocumentAttributesSectionKind.ManagementList,
|
||||||
|
component: DummySectionComponent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
path: 'customfields',
|
||||||
|
label: 'Custom fields',
|
||||||
|
icon: 'ui-radios',
|
||||||
|
permissionType: PermissionType.CustomField,
|
||||||
|
kind: DocumentAttributesSectionKind.CustomFields,
|
||||||
|
component: DummySectionComponent,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should navigate to default section when no section is provided', () => {
|
||||||
|
jest
|
||||||
|
.spyOn(permissionsService, 'currentUserCan')
|
||||||
|
.mockImplementation((action, type) => {
|
||||||
|
return action === PermissionAction.View && type === PermissionType.Tag
|
||||||
|
})
|
||||||
|
|
||||||
|
fixture.detectChanges()
|
||||||
|
paramMapSubject.next(convertToParamMap({}))
|
||||||
|
|
||||||
|
expect(router.navigate).toHaveBeenCalledWith(['attributes', 'tags'], {
|
||||||
|
replaceUrl: true,
|
||||||
|
})
|
||||||
|
expect(component.activeNavID).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should set active section from route param when valid', () => {
|
||||||
|
jest
|
||||||
|
.spyOn(permissionsService, 'currentUserCan')
|
||||||
|
.mockImplementation((action, type) => {
|
||||||
|
return (
|
||||||
|
action === PermissionAction.View &&
|
||||||
|
type === PermissionType.CustomField
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
fixture.detectChanges()
|
||||||
|
paramMapSubject.next(convertToParamMap({ section: 'customfields' }))
|
||||||
|
|
||||||
|
expect(component.activeNavID).toBe(2)
|
||||||
|
expect(router.navigate).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should update active nav id when route section changes', () => {
|
||||||
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
|
|
||||||
|
fixture.detectChanges()
|
||||||
|
component.activeNavID = 1
|
||||||
|
paramMapSubject.next(convertToParamMap({ section: 'customfields' }))
|
||||||
|
|
||||||
|
expect(component.activeNavID).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should redirect to dashboard when no sections are visible', () => {
|
||||||
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(false)
|
||||||
|
|
||||||
|
fixture.detectChanges()
|
||||||
|
paramMapSubject.next(convertToParamMap({}))
|
||||||
|
|
||||||
|
expect(router.navigate).toHaveBeenCalledWith(['/dashboard'], {
|
||||||
|
replaceUrl: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should navigate when a nav change occurs', () => {
|
||||||
|
jest
|
||||||
|
.spyOn(permissionsService, 'currentUserCan')
|
||||||
|
.mockImplementation(() => true)
|
||||||
|
|
||||||
|
fixture.detectChanges()
|
||||||
|
paramMapSubject.next(convertToParamMap({ section: 'tags' }))
|
||||||
|
|
||||||
|
component.onNavChange({ nextId: 2 } as any)
|
||||||
|
|
||||||
|
expect(router.navigate).toHaveBeenCalledWith(['attributes', 'customfields'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should ignore nav changes for unknown sections', () => {
|
||||||
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
|
|
||||||
|
fixture.detectChanges()
|
||||||
|
paramMapSubject.next(convertToParamMap({ section: 'tags' }))
|
||||||
|
|
||||||
|
component.onNavChange({ nextId: 999 } as any)
|
||||||
|
|
||||||
|
expect(router.navigate).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return activeManagementList correctly', () => {
|
||||||
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
|
expect(component.activeManagementList).toBeNull()
|
||||||
|
|
||||||
|
component.activeNavID = 1
|
||||||
|
expect(component.activeSection.kind).toBe(
|
||||||
|
DocumentAttributesSectionKind.ManagementList
|
||||||
|
)
|
||||||
|
expect(component.activeManagementList).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return activeCustomFields correctly', () => {
|
||||||
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
|
expect(component.activeCustomFields).toBeNull()
|
||||||
|
|
||||||
|
component.activeNavID = 2
|
||||||
|
expect(component.activeSection.kind).toBe(
|
||||||
|
DocumentAttributesSectionKind.CustomFields
|
||||||
|
)
|
||||||
|
expect(component.activeCustomFields).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import { NgComponentOutlet } from '@angular/common'
|
||||||
|
import {
|
||||||
|
AfterViewChecked,
|
||||||
|
ChangeDetectorRef,
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit,
|
||||||
|
Type,
|
||||||
|
ViewChild,
|
||||||
|
} from '@angular/core'
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router'
|
||||||
|
import {
|
||||||
|
NgbDropdownModule,
|
||||||
|
NgbNavChangeEvent,
|
||||||
|
NgbNavModule,
|
||||||
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
|
import { Subject, takeUntil } from 'rxjs'
|
||||||
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
|
import {
|
||||||
|
PermissionAction,
|
||||||
|
PermissionsService,
|
||||||
|
PermissionType,
|
||||||
|
} from 'src/app/services/permissions.service'
|
||||||
|
import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component'
|
||||||
|
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||||
|
import { CustomFieldsComponent } from './custom-fields/custom-fields.component'
|
||||||
|
import { CorrespondentListComponent } from './management-list/correspondent-list/correspondent-list.component'
|
||||||
|
import { DocumentTypeListComponent } from './management-list/document-type-list/document-type-list.component'
|
||||||
|
import { ManagementListComponent } from './management-list/management-list.component'
|
||||||
|
import { StoragePathListComponent } from './management-list/storage-path-list/storage-path-list.component'
|
||||||
|
import { TagListComponent } from './management-list/tag-list/tag-list.component'
|
||||||
|
|
||||||
|
enum DocumentAttributesNavIDs {
|
||||||
|
Tags = 1,
|
||||||
|
Correspondents = 2,
|
||||||
|
DocumentTypes = 3,
|
||||||
|
StoragePaths = 4,
|
||||||
|
CustomFields = 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum DocumentAttributesSectionKind {
|
||||||
|
ManagementList = 'managementList',
|
||||||
|
CustomFields = 'customFields',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DocumentAttributesSection {
|
||||||
|
id: DocumentAttributesNavIDs
|
||||||
|
path: string
|
||||||
|
label: string
|
||||||
|
icon: string
|
||||||
|
infoLink?: string
|
||||||
|
permissionType: PermissionType
|
||||||
|
kind: DocumentAttributesSectionKind
|
||||||
|
component: Type<any>
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-document-attributes',
|
||||||
|
templateUrl: './document-attributes.component.html',
|
||||||
|
styleUrls: ['./document-attributes.component.scss'],
|
||||||
|
imports: [
|
||||||
|
PageHeaderComponent,
|
||||||
|
NgbNavModule,
|
||||||
|
NgbDropdownModule,
|
||||||
|
NgComponentOutlet,
|
||||||
|
NgxBootstrapIconsModule,
|
||||||
|
IfPermissionsDirective,
|
||||||
|
ClearableBadgeComponent,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class DocumentAttributesComponent
|
||||||
|
implements OnInit, OnDestroy, AfterViewChecked
|
||||||
|
{
|
||||||
|
private readonly permissionsService = inject(PermissionsService)
|
||||||
|
private readonly activatedRoute = inject(ActivatedRoute)
|
||||||
|
private readonly router = inject(Router)
|
||||||
|
private readonly cdr = inject(ChangeDetectorRef)
|
||||||
|
private readonly unsubscribeNotifier = new Subject<void>()
|
||||||
|
|
||||||
|
protected readonly PermissionAction = PermissionAction
|
||||||
|
protected readonly PermissionType = PermissionType
|
||||||
|
|
||||||
|
readonly sections: DocumentAttributesSection[] = [
|
||||||
|
{
|
||||||
|
id: DocumentAttributesNavIDs.Tags,
|
||||||
|
path: 'tags',
|
||||||
|
label: $localize`Tags`,
|
||||||
|
icon: 'tags',
|
||||||
|
infoLink: 'usage/#terms-and-definitions',
|
||||||
|
permissionType: PermissionType.Tag,
|
||||||
|
kind: DocumentAttributesSectionKind.ManagementList,
|
||||||
|
component: TagListComponent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: DocumentAttributesNavIDs.Correspondents,
|
||||||
|
path: 'correspondents',
|
||||||
|
label: $localize`Correspondents`,
|
||||||
|
icon: 'person',
|
||||||
|
infoLink: 'usage/#terms-and-definitions',
|
||||||
|
permissionType: PermissionType.Correspondent,
|
||||||
|
kind: DocumentAttributesSectionKind.ManagementList,
|
||||||
|
component: CorrespondentListComponent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: DocumentAttributesNavIDs.DocumentTypes,
|
||||||
|
path: 'documenttypes',
|
||||||
|
label: $localize`Document types`,
|
||||||
|
icon: 'hash',
|
||||||
|
infoLink: 'usage/#terms-and-definitions',
|
||||||
|
permissionType: PermissionType.DocumentType,
|
||||||
|
kind: DocumentAttributesSectionKind.ManagementList,
|
||||||
|
component: DocumentTypeListComponent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: DocumentAttributesNavIDs.StoragePaths,
|
||||||
|
path: 'storagepaths',
|
||||||
|
label: $localize`Storage paths`,
|
||||||
|
icon: 'folder',
|
||||||
|
infoLink: 'usage/#terms-and-definitions',
|
||||||
|
permissionType: PermissionType.StoragePath,
|
||||||
|
kind: DocumentAttributesSectionKind.ManagementList,
|
||||||
|
component: StoragePathListComponent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: DocumentAttributesNavIDs.CustomFields,
|
||||||
|
path: 'customfields',
|
||||||
|
label: $localize`Custom fields`,
|
||||||
|
icon: 'ui-radios',
|
||||||
|
infoLink: 'usage/#custom-fields',
|
||||||
|
permissionType: PermissionType.CustomField,
|
||||||
|
kind: DocumentAttributesSectionKind.CustomFields,
|
||||||
|
component: CustomFieldsComponent,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
@ViewChild('activeOutlet', { read: NgComponentOutlet })
|
||||||
|
private readonly activeOutlet?: NgComponentOutlet
|
||||||
|
|
||||||
|
private lastHeaderLoading: boolean
|
||||||
|
|
||||||
|
activeNavID: number = null
|
||||||
|
|
||||||
|
get visibleSections(): DocumentAttributesSection[] {
|
||||||
|
return this.sections.filter((section) =>
|
||||||
|
this.permissionsService.currentUserCan(
|
||||||
|
PermissionAction.View,
|
||||||
|
section.permissionType
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
get activeSection(): DocumentAttributesSection | null {
|
||||||
|
return (
|
||||||
|
this.visibleSections.find((section) => section.id === this.activeNavID) ??
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
get activeManagementList(): ManagementListComponent<any> | null {
|
||||||
|
if (
|
||||||
|
this.activeSection?.kind !== DocumentAttributesSectionKind.ManagementList
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
const instance = this.activeOutlet?.componentInstance
|
||||||
|
return instance instanceof ManagementListComponent ? instance : null
|
||||||
|
}
|
||||||
|
|
||||||
|
get activeCustomFields(): CustomFieldsComponent | null {
|
||||||
|
if (this.activeSection?.kind !== DocumentAttributesSectionKind.CustomFields)
|
||||||
|
return null
|
||||||
|
const instance = this.activeOutlet?.componentInstance
|
||||||
|
return instance instanceof CustomFieldsComponent ? instance : null
|
||||||
|
}
|
||||||
|
|
||||||
|
get activeTabLabel(): string {
|
||||||
|
return this.activeSection?.label ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
get activeInfoLink(): string {
|
||||||
|
return this.activeSection?.infoLink ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
get activeHeaderLoading(): boolean {
|
||||||
|
return (
|
||||||
|
this.activeManagementList?.loading ??
|
||||||
|
this.activeCustomFields?.loading ??
|
||||||
|
false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.activatedRoute.paramMap
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe((paramMap) => {
|
||||||
|
const section = paramMap.get('section')
|
||||||
|
const navIDFromSection =
|
||||||
|
this.getNavIDForSection(section) ?? this.getDefaultNavID()
|
||||||
|
|
||||||
|
if (navIDFromSection == null) {
|
||||||
|
this.router.navigate(['/dashboard'], { replaceUrl: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.activeNavID !== navIDFromSection) {
|
||||||
|
this.activeNavID = navIDFromSection
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!section || this.getNavIDForSection(section) == null) {
|
||||||
|
this.router.navigate(
|
||||||
|
['attributes', this.getSectionForNavID(this.activeNavID)],
|
||||||
|
{ replaceUrl: true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.unsubscribeNotifier.next()
|
||||||
|
this.unsubscribeNotifier.complete()
|
||||||
|
}
|
||||||
|
|
||||||
|
ngAfterViewChecked(): void {
|
||||||
|
const current = this.activeHeaderLoading
|
||||||
|
if (this.lastHeaderLoading !== current) {
|
||||||
|
this.lastHeaderLoading = current
|
||||||
|
this.cdr.detectChanges()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onNavChange(navChangeEvent: NgbNavChangeEvent): void {
|
||||||
|
const nextSection = this.getSectionForNavID(navChangeEvent.nextId)
|
||||||
|
if (!nextSection) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.router.navigate(['attributes', nextSection])
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDefaultNavID(): DocumentAttributesNavIDs | null {
|
||||||
|
return this.visibleSections[0]?.id ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
private getNavIDForSection(section: string): DocumentAttributesNavIDs | null {
|
||||||
|
const path = section?.toLowerCase()
|
||||||
|
if (!path) return null
|
||||||
|
|
||||||
|
const found = this.visibleSections.find((s) => s.path === path)
|
||||||
|
return found?.id ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSectionForNavID(navID: number): string | null {
|
||||||
|
const section = this.visibleSections.find((s) => s.id === navID)
|
||||||
|
return section?.path ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import { of } from 'rxjs'
|
|||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
||||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
||||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
import { PageHeaderComponent } from '../../../../common/page-header/page-header.component'
|
||||||
import { CorrespondentListComponent } from './correspondent-list.component'
|
import { CorrespondentListComponent } from './correspondent-list.component'
|
||||||
|
|
||||||
describe('CorrespondentListComponent', () => {
|
describe('CorrespondentListComponent', () => {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { NgClass, NgTemplateOutlet, TitleCasePipe } from '@angular/common'
|
import { NgClass, NgTemplateOutlet } from '@angular/common'
|
||||||
import { Component, inject } from '@angular/core'
|
import { Component, inject } from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { RouterModule } from '@angular/router'
|
import { RouterModule } from '@angular/router'
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
NgbPaginationModule,
|
NgbPaginationModule,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
|
import { CorrespondentEditDialogComponent } from 'src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||||
import { Correspondent } from 'src/app/data/correspondent'
|
import { Correspondent } from 'src/app/data/correspondent'
|
||||||
import { FILTER_HAS_CORRESPONDENT_ANY } from 'src/app/data/filter-rule-type'
|
import { FILTER_HAS_CORRESPONDENT_ANY } from 'src/app/data/filter-rule-type'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
@@ -14,21 +15,16 @@ import { SortableDirective } from 'src/app/directives/sortable.directive'
|
|||||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||||
import { PermissionType } from 'src/app/services/permissions.service'
|
import { PermissionType } from 'src/app/services/permissions.service'
|
||||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
||||||
import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component'
|
import { ManagementListComponent } from '../management-list.component'
|
||||||
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
|
||||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
|
||||||
import { ManagementListComponent } from '../management-list/management-list.component'
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-correspondent-list',
|
selector: 'pngx-correspondent-list',
|
||||||
templateUrl: './../management-list/management-list.component.html',
|
templateUrl: './../management-list.component.html',
|
||||||
styleUrls: ['./../management-list/management-list.component.scss'],
|
styleUrls: ['./../management-list.component.scss'],
|
||||||
providers: [{ provide: CustomDatePipe }],
|
providers: [{ provide: CustomDatePipe }],
|
||||||
imports: [
|
imports: [
|
||||||
SortableDirective,
|
SortableDirective,
|
||||||
IfPermissionsDirective,
|
IfPermissionsDirective,
|
||||||
PageHeaderComponent,
|
|
||||||
TitleCasePipe,
|
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
RouterModule,
|
RouterModule,
|
||||||
@@ -37,11 +33,10 @@ import { ManagementListComponent } from '../management-list/management-list.comp
|
|||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
NgbPaginationModule,
|
NgbPaginationModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
ClearableBadgeComponent,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class CorrespondentListComponent extends ManagementListComponent<Correspondent> {
|
export class CorrespondentListComponent extends ManagementListComponent<Correspondent> {
|
||||||
private datePipe = inject(CustomDatePipe)
|
private readonly datePipe = inject(CustomDatePipe)
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
@@ -9,7 +9,7 @@ import { of } from 'rxjs'
|
|||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
||||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
import { PageHeaderComponent } from '../../../../common/page-header/page-header.component'
|
||||||
import { DocumentTypeListComponent } from './document-type-list.component'
|
import { DocumentTypeListComponent } from './document-type-list.component'
|
||||||
|
|
||||||
describe('DocumentTypeListComponent', () => {
|
describe('DocumentTypeListComponent', () => {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { NgClass, NgTemplateOutlet, TitleCasePipe } from '@angular/common'
|
import { NgClass, NgTemplateOutlet } from '@angular/common'
|
||||||
import { Component, inject } from '@angular/core'
|
import { Component, inject } from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { RouterModule } from '@angular/router'
|
import { RouterModule } from '@angular/router'
|
||||||
@@ -7,25 +7,21 @@ import {
|
|||||||
NgbPaginationModule,
|
NgbPaginationModule,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
|
import { DocumentTypeEditDialogComponent } from 'src/app/components/common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
||||||
import { DocumentType } from 'src/app/data/document-type'
|
import { DocumentType } from 'src/app/data/document-type'
|
||||||
import { FILTER_HAS_DOCUMENT_TYPE_ANY } from 'src/app/data/filter-rule-type'
|
import { FILTER_HAS_DOCUMENT_TYPE_ANY } from 'src/app/data/filter-rule-type'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
||||||
import { PermissionType } from 'src/app/services/permissions.service'
|
import { PermissionType } from 'src/app/services/permissions.service'
|
||||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||||
import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component'
|
import { ManagementListComponent } from '../management-list.component'
|
||||||
import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
|
||||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
|
||||||
import { ManagementListComponent } from '../management-list/management-list.component'
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-document-type-list',
|
selector: 'pngx-document-type-list',
|
||||||
templateUrl: './../management-list/management-list.component.html',
|
templateUrl: './../management-list.component.html',
|
||||||
styleUrls: ['./../management-list/management-list.component.scss'],
|
styleUrls: ['./../management-list.component.scss'],
|
||||||
imports: [
|
imports: [
|
||||||
SortableDirective,
|
SortableDirective,
|
||||||
PageHeaderComponent,
|
|
||||||
TitleCasePipe,
|
|
||||||
IfPermissionsDirective,
|
IfPermissionsDirective,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
@@ -35,7 +31,6 @@ import { ManagementListComponent } from '../management-list/management-list.comp
|
|||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
NgbPaginationModule,
|
NgbPaginationModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
ClearableBadgeComponent,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class DocumentTypeListComponent extends ManagementListComponent<DocumentType> {
|
export class DocumentTypeListComponent extends ManagementListComponent<DocumentType> {
|
||||||
@@ -1,50 +1,3 @@
|
|||||||
<pngx-page-header title="{{ typeNamePlural | titlecase }}" info="View, add, edit and delete {{ typeNamePlural }}." infoLink="usage/#terms-and-definitions" [loading]="loading">
|
|
||||||
|
|
||||||
<div ngbDropdown class="btn-group flex-fill d-sm-none">
|
|
||||||
<button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle>
|
|
||||||
<i-bs name="text-indent-left"></i-bs>
|
|
||||||
<div class="d-none d-sm-inline"> <ng-container i18n>Select</ng-container></div>
|
|
||||||
@if (selectedObjects.size > 0) {
|
|
||||||
<pngx-clearable-badge [selected]="selectedObjects.size > 0" [number]="selectedObjects.size" (cleared)="selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
<div ngbDropdownMenu aria-labelledby="dropdownSelectMobile" class="shadow">
|
|
||||||
<button ngbDropdownItem (click)="selectNone()" i18n>Select none</button>
|
|
||||||
<button ngbDropdownItem (click)="selectPage(true)" i18n>Select page</button>
|
|
||||||
<button ngbDropdownItem (click)="selectAll()" i18n>Select all</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="d-none d-sm-flex flex-fill me-3">
|
|
||||||
<div class="input-group input-group-sm">
|
|
||||||
<span class="input-group-text border-0" i18n>Select:</span>
|
|
||||||
</div>
|
|
||||||
<div class="btn-group btn-group-sm flex-nowrap">
|
|
||||||
@if (selectedObjects.size > 0) {
|
|
||||||
<button class="btn btn-sm btn-outline-secondary" (click)="selectNone()">
|
|
||||||
<i-bs name="slash-circle"></i-bs> <ng-container i18n>None</ng-container>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
<button class="btn btn-sm btn-outline-primary" (click)="selectPage(true)">
|
|
||||||
<i-bs name="file-earmark-check"></i-bs> <ng-container i18n>Page</ng-container>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-outline-primary" (click)="selectAll()">
|
|
||||||
<i-bs name="check-all"></i-bs> <ng-container i18n>All</ng-container>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="setPermissions()" [disabled]="!userCanBulkEdit(PermissionAction.Change) || selectedObjects.size === 0">
|
|
||||||
<i-bs name="person-fill-lock"></i-bs> <ng-container i18n>Permissions</ng-container>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger" (click)="delete()" [disabled]="!userCanBulkEdit(PermissionAction.Delete) || selectedObjects.size === 0">
|
|
||||||
<i-bs name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary ms-md-5" (click)="openCreateDialog()" *pngxIfPermissions="{ action: PermissionAction.Add, type: permissionType }">
|
|
||||||
<i-bs name="plus-circle"></i-bs> <ng-container i18n>Create</ng-container>
|
|
||||||
</button>
|
|
||||||
</pngx-page-header>
|
|
||||||
|
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col mb-2 mb-xl-0">
|
<div class="col mb-2 mb-xl-0">
|
||||||
<div class="form-inline d-flex align-items-center">
|
<div class="form-inline d-flex align-items-center">
|
||||||
@@ -76,19 +29,19 @@
|
|||||||
<table class="table table-striped align-middle shadow-sm mb-0">
|
<table class="table table-striped align-middle shadow-sm mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">
|
<th>
|
||||||
<div class="form-check m-0 ms-2 me-n2">
|
<div class="form-check m-0 ms-2 me-n2">
|
||||||
<input type="checkbox" class="form-check-input" id="all-objects" [(ngModel)]="togggleAll" [disabled]="data.length === 0" (change)="selectPage($event.target.checked); $event.stopPropagation();">
|
<input type="checkbox" class="form-check-input" id="all-objects" [(ngModel)]="togggleAll" [disabled]="data.length === 0" (change)="$event.target.checked ? selectPage() : clearSelection(); $event.stopPropagation();">
|
||||||
<label class="form-check-label" for="all-objects"></label>
|
<label class="form-check-label" for="all-objects"></label>
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" class="fw-normal" pngxSortable="name" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Name</th>
|
<th class="fw-normal" pngxSortable="name" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Name</th>
|
||||||
<th scope="col" class="fw-normal d-none d-sm-table-cell" pngxSortable="matching_algorithm" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Matching</th>
|
<th class="fw-normal d-none d-sm-table-cell" pngxSortable="matching_algorithm" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Matching</th>
|
||||||
<th scope="col" class="fw-normal" pngxSortable="document_count" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Document count</th>
|
<th class="fw-normal" pngxSortable="document_count" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Document count</th>
|
||||||
@for (column of extraColumns; track column) {
|
@for (column of extraColumns; track column) {
|
||||||
<th scope="col" class="fw-normal" [ngClass]="{ 'd-none d-sm-table-cell' : column.hideOnMobile }" pngxSortable="{{column.key}}" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)">{{column.name}}</th>
|
<th class="fw-normal" [ngClass]="{ 'd-none d-sm-table-cell' : column.hideOnMobile }" pngxSortable="{{column.key}}" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)">{{column.name}}</th>
|
||||||
}
|
}
|
||||||
<th scope="col" class="fw-normal" i18n>Actions</th>
|
<th class="fw-normal" i18n>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -131,16 +84,16 @@
|
|||||||
<label class="form-check-label" for="{{typeName}}{{object.id}}"></label>
|
<label class="form-check-label" for="{{typeName}}{{object.id}}"></label>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td scope="row" class="name-cell" style="--depth: {{depth}}">
|
<td class="name-cell" style="--depth: {{depth}}">
|
||||||
@if (depth > 0) {
|
@if (depth > 0) {
|
||||||
<div class="indicator"></div>
|
<div class="indicator"></div>
|
||||||
}
|
}
|
||||||
<button class="btn btn-link ms-0 ps-0 text-start" (click)="userCanEdit(object) ? openEditDialog(object) : null; $event.stopPropagation()">{{ object.name }}</button>
|
<button class="btn btn-link ms-0 ps-0 text-start" (click)="userCanEdit(object) ? openEditDialog(object) : null; $event.stopPropagation()">{{ object.name }}</button>
|
||||||
</td>
|
</td>
|
||||||
<td scope="row" class="d-none d-sm-table-cell">{{ getMatching(object) }}</td>
|
<td class="d-none d-sm-table-cell">{{ getMatching(object) }}</td>
|
||||||
<td scope="row">{{ getDocumentCount(object) }}</td>
|
<td>{{ getDocumentCount(object) }}</td>
|
||||||
@for (column of extraColumns; track column) {
|
@for (column of extraColumns; track column) {
|
||||||
<td scope="row" [ngClass]="{ 'd-none d-sm-table-cell' : column.hideOnMobile }">
|
<td [ngClass]="{ 'd-none d-sm-table-cell' : column.hideOnMobile }">
|
||||||
@if (column.badgeFn) {
|
@if (column.badgeFn) {
|
||||||
<span
|
<span
|
||||||
class="badge"
|
class="badge"
|
||||||
@@ -156,7 +109,7 @@
|
|||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
}
|
}
|
||||||
<td scope="row">
|
<td>
|
||||||
<div class="btn-toolbar gap-2">
|
<div class="btn-toolbar gap-2">
|
||||||
<div class="btn-group d-block d-sm-none">
|
<div class="btn-group d-block d-sm-none">
|
||||||
<div ngbDropdown container="body" class="d-inline-block">
|
<div ngbDropdown container="body" class="d-inline-block">
|
||||||
@@ -44,12 +44,12 @@ import { BulkEditObjectOperation } from 'src/app/services/rest/abstract-name-fil
|
|||||||
import { TagService } from 'src/app/services/rest/tag.service'
|
import { TagService } from 'src/app/services/rest/tag.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
import { ConfirmDialogComponent } from '../../../common/confirm-dialog/confirm-dialog.component'
|
||||||
import { EditDialogComponent } from '../../common/edit-dialog/edit-dialog.component'
|
import { EditDialogComponent } from '../../../common/edit-dialog/edit-dialog.component'
|
||||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
import { PageHeaderComponent } from '../../../common/page-header/page-header.component'
|
||||||
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
|
import { PermissionsDialogComponent } from '../../../common/permissions-dialog/permissions-dialog.component'
|
||||||
import { TagListComponent } from '../tag-list/tag-list.component'
|
|
||||||
import { ManagementListComponent } from './management-list.component'
|
import { ManagementListComponent } from './management-list.component'
|
||||||
|
import { TagListComponent } from './tag-list/tag-list.component'
|
||||||
|
|
||||||
const tags: Tag[] = [
|
const tags: Tag[] = [
|
||||||
{
|
{
|
||||||
@@ -304,12 +304,12 @@ describe('ManagementListComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('selectPage should select current page items or clear selection', () => {
|
it('selectPage should select current page items or clear selection', () => {
|
||||||
component.selectPage(true)
|
component.selectPage()
|
||||||
expect(component.selectedObjects).toEqual(new Set(tags.map((t) => t.id)))
|
expect(component.selectedObjects).toEqual(new Set(tags.map((t) => t.id)))
|
||||||
expect(component.togggleAll).toBe(true)
|
expect(component.togggleAll).toBe(true)
|
||||||
|
|
||||||
component.togggleAll = true
|
component.togggleAll = true
|
||||||
component.selectPage(false)
|
component.clearSelection()
|
||||||
expect(component.selectedObjects.size).toBe(0)
|
expect(component.selectedObjects.size).toBe(0)
|
||||||
expect(component.togggleAll).toBe(false)
|
expect(component.togggleAll).toBe(false)
|
||||||
})
|
})
|
||||||
@@ -16,6 +16,10 @@ import {
|
|||||||
takeUntil,
|
takeUntil,
|
||||||
tap,
|
tap,
|
||||||
} from 'rxjs/operators'
|
} from 'rxjs/operators'
|
||||||
|
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
|
||||||
|
import { EditDialogMode } from 'src/app/components/common/edit-dialog/edit-dialog.component'
|
||||||
|
import { PermissionsDialogComponent } from 'src/app/components/common/permissions-dialog/permissions-dialog.component'
|
||||||
|
import { LoadingComponentWithPermissions } from 'src/app/components/loading-component/loading.component'
|
||||||
import {
|
import {
|
||||||
MATCH_AUTO,
|
MATCH_AUTO,
|
||||||
MATCH_NONE,
|
MATCH_NONE,
|
||||||
@@ -40,10 +44,6 @@ import {
|
|||||||
} from 'src/app/services/rest/abstract-name-filter-service'
|
} from 'src/app/services/rest/abstract-name-filter-service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
|
||||||
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
|
|
||||||
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
|
|
||||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
|
||||||
|
|
||||||
export interface ManagementListColumn {
|
export interface ManagementListColumn {
|
||||||
key: string
|
key: string
|
||||||
@@ -69,13 +69,14 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
implements OnInit, OnDestroy
|
implements OnInit, OnDestroy
|
||||||
{
|
{
|
||||||
protected service: AbstractNameFilterService<T>
|
protected service: AbstractNameFilterService<T>
|
||||||
private modalService: NgbModal = inject(NgbModal)
|
private readonly modalService: NgbModal = inject(NgbModal)
|
||||||
protected editDialogComponent: any
|
protected editDialogComponent: any
|
||||||
private toastService: ToastService = inject(ToastService)
|
private readonly toastService: ToastService = inject(ToastService)
|
||||||
private documentListViewService: DocumentListViewService = inject(
|
private readonly documentListViewService: DocumentListViewService = inject(
|
||||||
DocumentListViewService
|
DocumentListViewService
|
||||||
)
|
)
|
||||||
private permissionsService: PermissionsService = inject(PermissionsService)
|
private readonly permissionsService: PermissionsService =
|
||||||
|
inject(PermissionsService)
|
||||||
protected filterRuleType: number
|
protected filterRuleType: number
|
||||||
public typeName: string
|
public typeName: string
|
||||||
public typeNamePlural: string
|
public typeNamePlural: string
|
||||||
@@ -196,7 +197,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
}
|
}
|
||||||
|
|
||||||
openCreateDialog() {
|
openCreateDialog() {
|
||||||
var activeModal = this.modalService.open(this.editDialogComponent, {
|
const activeModal = this.modalService.open(this.editDialogComponent, {
|
||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
})
|
})
|
||||||
activeModal.componentInstance.dialogMode = EditDialogMode.CREATE
|
activeModal.componentInstance.dialogMode = EditDialogMode.CREATE
|
||||||
@@ -215,7 +216,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
}
|
}
|
||||||
|
|
||||||
openEditDialog(object: T) {
|
openEditDialog(object: T) {
|
||||||
var activeModal = this.modalService.open(this.editDialogComponent, {
|
const activeModal = this.modalService.open(this.editDialogComponent, {
|
||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
})
|
})
|
||||||
activeModal.componentInstance.object = object
|
activeModal.componentInstance.object = object
|
||||||
@@ -243,7 +244,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
}
|
}
|
||||||
|
|
||||||
openDeleteDialog(object: T) {
|
openDeleteDialog(object: T) {
|
||||||
var activeModal = this.modalService.open(ConfirmDialogComponent, {
|
const activeModal = this.modalService.open(ConfirmDialogComponent, {
|
||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
})
|
})
|
||||||
activeModal.componentInstance.title = $localize`Confirm delete`
|
activeModal.componentInstance.title = $localize`Confirm delete`
|
||||||
@@ -343,13 +344,9 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
this.clearSelection()
|
this.clearSelection()
|
||||||
}
|
}
|
||||||
|
|
||||||
selectPage(select: boolean) {
|
selectPage() {
|
||||||
if (select) {
|
this.selectedObjects = new Set(this.getSelectableIDs(this.data))
|
||||||
this.selectedObjects = new Set(this.getSelectableIDs(this.data))
|
this.togggleAll = this.areAllPageItemsSelected()
|
||||||
this.togggleAll = this.areAllPageItemsSelected()
|
|
||||||
} else {
|
|
||||||
this.clearSelection()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
selectAll() {
|
selectAll() {
|
||||||
@@ -10,7 +10,7 @@ import { StoragePath } from 'src/app/data/storage-path'
|
|||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
||||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
import { PageHeaderComponent } from '../../../../common/page-header/page-header.component'
|
||||||
import { StoragePathListComponent } from './storage-path-list.component'
|
import { StoragePathListComponent } from './storage-path-list.component'
|
||||||
|
|
||||||
describe('StoragePathListComponent', () => {
|
describe('StoragePathListComponent', () => {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { NgClass, NgTemplateOutlet, TitleCasePipe } from '@angular/common'
|
import { NgClass, NgTemplateOutlet } from '@angular/common'
|
||||||
import { Component, inject } from '@angular/core'
|
import { Component, inject } from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { RouterModule } from '@angular/router'
|
import { RouterModule } from '@angular/router'
|
||||||
@@ -7,25 +7,21 @@ import {
|
|||||||
NgbPaginationModule,
|
NgbPaginationModule,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
|
import { StoragePathEditDialogComponent } from 'src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
|
||||||
import { FILTER_HAS_STORAGE_PATH_ANY } from 'src/app/data/filter-rule-type'
|
import { FILTER_HAS_STORAGE_PATH_ANY } from 'src/app/data/filter-rule-type'
|
||||||
import { StoragePath } from 'src/app/data/storage-path'
|
import { StoragePath } from 'src/app/data/storage-path'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
||||||
import { PermissionType } from 'src/app/services/permissions.service'
|
import { PermissionType } from 'src/app/services/permissions.service'
|
||||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||||
import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component'
|
import { ManagementListComponent } from '../management-list.component'
|
||||||
import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
|
|
||||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
|
||||||
import { ManagementListComponent } from '../management-list/management-list.component'
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-storage-path-list',
|
selector: 'pngx-storage-path-list',
|
||||||
templateUrl: './../management-list/management-list.component.html',
|
templateUrl: './../management-list.component.html',
|
||||||
styleUrls: ['./../management-list/management-list.component.scss'],
|
styleUrls: ['./../management-list.component.scss'],
|
||||||
imports: [
|
imports: [
|
||||||
SortableDirective,
|
SortableDirective,
|
||||||
PageHeaderComponent,
|
|
||||||
TitleCasePipe,
|
|
||||||
IfPermissionsDirective,
|
IfPermissionsDirective,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
@@ -35,7 +31,6 @@ import { ManagementListComponent } from '../management-list/management-list.comp
|
|||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
NgbPaginationModule,
|
NgbPaginationModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
ClearableBadgeComponent,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class StoragePathListComponent extends ManagementListComponent<StoragePath> {
|
export class StoragePathListComponent extends ManagementListComponent<StoragePath> {
|
||||||
@@ -9,7 +9,7 @@ import { of } from 'rxjs'
|
|||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
||||||
import { TagService } from 'src/app/services/rest/tag.service'
|
import { TagService } from 'src/app/services/rest/tag.service'
|
||||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
import { PageHeaderComponent } from '../../../../common/page-header/page-header.component'
|
||||||
import { TagListComponent } from './tag-list.component'
|
import { TagListComponent } from './tag-list.component'
|
||||||
|
|
||||||
describe('TagListComponent', () => {
|
describe('TagListComponent', () => {
|
||||||
@@ -138,12 +138,12 @@ describe('TagListComponent', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
component.data = [parent as any]
|
component.data = [parent as any]
|
||||||
component.selectPage(true)
|
component.selectPage()
|
||||||
|
|
||||||
expect(component.selectedObjects.has(10)).toBe(true)
|
expect(component.selectedObjects.has(10)).toBe(true)
|
||||||
expect(component.selectedObjects.has(11)).toBe(true)
|
expect(component.selectedObjects.has(11)).toBe(true)
|
||||||
|
|
||||||
component.selectPage(false)
|
component.clearSelection()
|
||||||
expect(component.selectedObjects.size).toBe(0)
|
expect(component.selectedObjects.size).toBe(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { NgClass, NgTemplateOutlet, TitleCasePipe } from '@angular/common'
|
import { NgClass, NgTemplateOutlet } from '@angular/common'
|
||||||
import { Component, inject } from '@angular/core'
|
import { Component, inject } from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { RouterModule } from '@angular/router'
|
import { RouterModule } from '@angular/router'
|
||||||
@@ -7,25 +7,21 @@ import {
|
|||||||
NgbPaginationModule,
|
NgbPaginationModule,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
|
import { TagEditDialogComponent } from 'src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
|
||||||
import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type'
|
import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type'
|
||||||
import { Tag } from 'src/app/data/tag'
|
import { Tag } from 'src/app/data/tag'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
||||||
import { PermissionType } from 'src/app/services/permissions.service'
|
import { PermissionType } from 'src/app/services/permissions.service'
|
||||||
import { TagService } from 'src/app/services/rest/tag.service'
|
import { TagService } from 'src/app/services/rest/tag.service'
|
||||||
import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component'
|
import { ManagementListComponent } from '../management-list.component'
|
||||||
import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
|
|
||||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
|
||||||
import { ManagementListComponent } from '../management-list/management-list.component'
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-tag-list',
|
selector: 'pngx-tag-list',
|
||||||
templateUrl: './../management-list/management-list.component.html',
|
templateUrl: './../management-list.component.html',
|
||||||
styleUrls: ['./../management-list/management-list.component.scss'],
|
styleUrls: ['./../management-list.component.scss'],
|
||||||
imports: [
|
imports: [
|
||||||
SortableDirective,
|
SortableDirective,
|
||||||
PageHeaderComponent,
|
|
||||||
TitleCasePipe,
|
|
||||||
IfPermissionsDirective,
|
IfPermissionsDirective,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
@@ -35,7 +31,6 @@ import { ManagementListComponent } from '../management-list/management-list.comp
|
|||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
NgbPaginationModule,
|
NgbPaginationModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
ClearableBadgeComponent,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class TagListComponent extends ManagementListComponent<Tag> {
|
export class TagListComponent extends ManagementListComponent<Tag> {
|
||||||
@@ -84,4 +84,6 @@ export interface MailRule extends ObjectWithPermissions {
|
|||||||
assign_correspondent?: number // PaperlessCorrespondent.id
|
assign_correspondent?: number // PaperlessCorrespondent.id
|
||||||
|
|
||||||
assign_owner_from_rule: boolean
|
assign_owner_from_rule: boolean
|
||||||
|
|
||||||
|
stop_processing: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ export enum GlobalSearchType {
|
|||||||
TITLE_CONTENT = 'title-content',
|
TITLE_CONTENT = 'title-content',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum CollapsibleSection {
|
||||||
|
ATTRIBUTES = 'attributes',
|
||||||
|
}
|
||||||
|
|
||||||
export const PAPERLESS_GREEN_HEX = '#17541f'
|
export const PAPERLESS_GREEN_HEX = '#17541f'
|
||||||
|
|
||||||
export const SETTINGS_KEYS = {
|
export const SETTINGS_KEYS = {
|
||||||
@@ -51,6 +55,8 @@ export const SETTINGS_KEYS = {
|
|||||||
NOTES_ENABLED: 'general-settings:notes-enabled',
|
NOTES_ENABLED: 'general-settings:notes-enabled',
|
||||||
AUDITLOG_ENABLED: 'general-settings:auditlog-enabled',
|
AUDITLOG_ENABLED: 'general-settings:auditlog-enabled',
|
||||||
SLIM_SIDEBAR: 'general-settings:slim-sidebar',
|
SLIM_SIDEBAR: 'general-settings:slim-sidebar',
|
||||||
|
ATTRIBUTES_SECTIONS_COLLAPSED:
|
||||||
|
'general-settings:attributes-sections-collapsed',
|
||||||
UPDATE_CHECKING_ENABLED: 'general-settings:update-checking:enabled',
|
UPDATE_CHECKING_ENABLED: 'general-settings:update-checking:enabled',
|
||||||
UPDATE_CHECKING_BACKEND_SETTING:
|
UPDATE_CHECKING_BACKEND_SETTING:
|
||||||
'general-settings:update-checking:backend-setting',
|
'general-settings:update-checking:backend-setting',
|
||||||
@@ -112,6 +118,11 @@ export const SETTINGS: UiSetting[] = [
|
|||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED,
|
||||||
|
type: 'array',
|
||||||
|
default: [],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: SETTINGS_KEYS.DOCUMENT_LIST_SIZE,
|
key: SETTINGS_KEYS.DOCUMENT_LIST_SIZE,
|
||||||
type: 'number',
|
type: 'number',
|
||||||
|
|||||||
@@ -96,4 +96,52 @@ describe('PermissionsGuard', () => {
|
|||||||
expect(canActivate).toHaveProperty('root') // returns UrlTree
|
expect(canActivate).toHaveProperty('root') // returns UrlTree
|
||||||
expect(toastSpy).toHaveBeenCalled()
|
expect(toastSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should activate when any required permission is granted', () => {
|
||||||
|
jest
|
||||||
|
.spyOn(permissionsService, 'currentUserCan')
|
||||||
|
.mockImplementation((action, type) => {
|
||||||
|
return type === PermissionType.Tag
|
||||||
|
})
|
||||||
|
|
||||||
|
const canActivate = guard.canActivate(
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
requiredPermissionAny: [
|
||||||
|
{ action: PermissionAction.View, type: PermissionType.Tag },
|
||||||
|
{
|
||||||
|
action: PermissionAction.View,
|
||||||
|
type: PermissionType.DocumentType,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
routerState.snapshot
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(canActivate).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not activate when no required permission is granted', () => {
|
||||||
|
jest
|
||||||
|
.spyOn(permissionsService, 'currentUserCan')
|
||||||
|
.mockImplementation(() => false)
|
||||||
|
|
||||||
|
const canActivate = guard.canActivate(
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
requiredPermissionAny: [
|
||||||
|
{ action: PermissionAction.View, type: PermissionType.Tag },
|
||||||
|
{
|
||||||
|
action: PermissionAction.View,
|
||||||
|
type: PermissionType.DocumentType,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
routerState.snapshot
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(canActivate).toHaveProperty('root')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -20,12 +20,20 @@ export class PermissionsGuard {
|
|||||||
route: ActivatedRouteSnapshot,
|
route: ActivatedRouteSnapshot,
|
||||||
state: RouterStateSnapshot
|
state: RouterStateSnapshot
|
||||||
): boolean | UrlTree {
|
): boolean | UrlTree {
|
||||||
|
const requiredPermissionAny: { action: any; type: any }[] =
|
||||||
|
route.data.requiredPermissionAny
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(route.data.requireAdmin && !this.permissionsService.isAdmin()) ||
|
(route.data.requireAdmin && !this.permissionsService.isAdmin()) ||
|
||||||
(route.data.requiredPermission &&
|
(route.data.requiredPermission &&
|
||||||
!this.permissionsService.currentUserCan(
|
!this.permissionsService.currentUserCan(
|
||||||
route.data.requiredPermission.action,
|
route.data.requiredPermission.action,
|
||||||
route.data.requiredPermission.type
|
route.data.requiredPermission.type
|
||||||
|
)) ||
|
||||||
|
(Array.isArray(requiredPermissionAny) &&
|
||||||
|
requiredPermissionAny.length > 0 &&
|
||||||
|
!requiredPermissionAny.some((p) =>
|
||||||
|
this.permissionsService.currentUserCan(p.action, p.type)
|
||||||
))
|
))
|
||||||
) {
|
) {
|
||||||
// Check if tour is running 1 = TourState.ON
|
// Check if tour is running 1 = TourState.ON
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const mail_rules = [
|
|||||||
action: MailAction.MarkRead,
|
action: MailAction.MarkRead,
|
||||||
assign_title_from: MailMetadataTitleOption.FromSubject,
|
assign_title_from: MailMetadataTitleOption.FromSubject,
|
||||||
assign_owner_from_rule: true,
|
assign_owner_from_rule: true,
|
||||||
|
stop_processing: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Mail Rule 2',
|
name: 'Mail Rule 2',
|
||||||
@@ -52,6 +53,7 @@ const mail_rules = [
|
|||||||
action: MailAction.Delete,
|
action: MailAction.Delete,
|
||||||
assign_title_from: MailMetadataTitleOption.FromSubject,
|
assign_title_from: MailMetadataTitleOption.FromSubject,
|
||||||
assign_owner_from_rule: true,
|
assign_owner_from_rule: true,
|
||||||
|
stop_processing: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Mail Rule 3',
|
name: 'Mail Rule 3',
|
||||||
@@ -71,6 +73,7 @@ const mail_rules = [
|
|||||||
action: MailAction.Flag,
|
action: MailAction.Flag,
|
||||||
assign_title_from: MailMetadataTitleOption.FromSubject,
|
assign_title_from: MailMetadataTitleOption.FromSubject,
|
||||||
assign_owner_from_rule: false,
|
assign_owner_from_rule: false,
|
||||||
|
stop_processing: false,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ import {
|
|||||||
sliders2Vertical,
|
sliders2Vertical,
|
||||||
sortAlphaDown,
|
sortAlphaDown,
|
||||||
sortAlphaUpAlt,
|
sortAlphaUpAlt,
|
||||||
|
stack,
|
||||||
stars,
|
stars,
|
||||||
tag,
|
tag,
|
||||||
tagFill,
|
tagFill,
|
||||||
@@ -343,6 +344,7 @@ const icons = {
|
|||||||
sliders2Vertical,
|
sliders2Vertical,
|
||||||
sortAlphaDown,
|
sortAlphaDown,
|
||||||
sortAlphaUpAlt,
|
sortAlphaUpAlt,
|
||||||
|
stack,
|
||||||
stars,
|
stars,
|
||||||
tagFill,
|
tagFill,
|
||||||
tag,
|
tag,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: paperless-ngx\n"
|
"Project-Id-Version: paperless-ngx\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-02-03 20:10+0000\n"
|
"POT-Creation-Date: 2026-02-13 17:37+0000\n"
|
||||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: English\n"
|
"Language-Team: English\n"
|
||||||
@@ -2220,7 +2220,7 @@ msgstr ""
|
|||||||
msgid "account"
|
msgid "account"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless_mail/models.py:157 paperless_mail/models.py:318
|
#: paperless_mail/models.py:157 paperless_mail/models.py:326
|
||||||
msgid "folder"
|
msgid "folder"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -2312,26 +2312,36 @@ msgstr ""
|
|||||||
msgid "Assign the rule owner to documents"
|
msgid "Assign the rule owner to documents"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless_mail/models.py:326
|
#: paperless_mail/models.py:305
|
||||||
msgid "uid"
|
msgid "Stop processing further rules"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: paperless_mail/models.py:308
|
||||||
|
msgid ""
|
||||||
|
"If True, no further rules will be processed after this one if any document "
|
||||||
|
"is queued."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless_mail/models.py:334
|
#: paperless_mail/models.py:334
|
||||||
msgid "subject"
|
msgid "uid"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless_mail/models.py:342
|
#: paperless_mail/models.py:342
|
||||||
|
msgid "subject"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: paperless_mail/models.py:350
|
||||||
msgid "received"
|
msgid "received"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless_mail/models.py:349
|
#: paperless_mail/models.py:357
|
||||||
msgid "processed"
|
msgid "processed"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless_mail/models.py:355
|
#: paperless_mail/models.py:363
|
||||||
msgid "status"
|
msgid "status"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless_mail/models.py:363
|
#: paperless_mail/models.py:371
|
||||||
msgid "error"
|
msgid "error"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|||||||
@@ -202,51 +202,3 @@ def audit_log_check(app_configs, **kwargs):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@register()
|
|
||||||
def check_deprecated_db_settings(app_configs, **kwargs) -> list[Warning]:
|
|
||||||
"""Check for deprecated database environment variables.
|
|
||||||
|
|
||||||
Detects legacy advanced options that should be migrated to
|
|
||||||
PAPERLESS_DB_OPTIONS.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of Django Warning instances for any deprecated vars found.
|
|
||||||
"""
|
|
||||||
deprecated_vars = {
|
|
||||||
"PAPERLESS_DB_TIMEOUT": "timeout (or connect_timeout for Postgres/MariaDB)",
|
|
||||||
"PAPERLESS_DB_POOLSIZE": "pool.min_size,pool.max_size",
|
|
||||||
"PAPERLESS_DBSSLMODE": "sslmode (or ssl_mode for MariaDB)",
|
|
||||||
"PAPERLESS_DBSSLROOTCERT": "sslrootcert (or ssl.ca for MariaDB)",
|
|
||||||
"PAPERLESS_DBSSLCERT": "sslcert (or ssl.cert for MariaDB)",
|
|
||||||
"PAPERLESS_DBSSLKEY": "sslkey (or ssl.key for MariaDB)",
|
|
||||||
}
|
|
||||||
|
|
||||||
found_vars = []
|
|
||||||
for var_name in deprecated_vars:
|
|
||||||
if os.getenv(var_name):
|
|
||||||
found_vars.append(var_name)
|
|
||||||
|
|
||||||
if not found_vars:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# Build migration example
|
|
||||||
examples = []
|
|
||||||
for var in found_vars:
|
|
||||||
examples.append(f"{var} -> PAPERLESS_DB_OPTIONS={deprecated_vars[var]}=<value>")
|
|
||||||
|
|
||||||
return [
|
|
||||||
Warning(
|
|
||||||
"Deprecated database environment variables detected",
|
|
||||||
# TODO: Need to check this URL
|
|
||||||
hint=(
|
|
||||||
f"Found: {', '.join(found_vars)}. "
|
|
||||||
"These will be removed in v3.2. "
|
|
||||||
"Migrate to PAPERLESS_DB_OPTIONS instead. "
|
|
||||||
f"Examples: {'; '.join(examples[:3])}. "
|
|
||||||
"See https://docs.paperless-ngx.com/migration/"
|
|
||||||
),
|
|
||||||
id="paperless.W001",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import math
|
|||||||
import multiprocessing
|
import multiprocessing
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from os import PathLike
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Final
|
from typing import Final
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
@@ -16,13 +17,6 @@ from dateparser.languages.loader import LocaleDataLoader
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
from paperless.settings.custom import parse_db_settings
|
|
||||||
from paperless.settings.parsers import get_bool_from_env
|
|
||||||
from paperless.settings.parsers import get_float_from_env
|
|
||||||
from paperless.settings.parsers import get_int_from_env
|
|
||||||
from paperless.settings.parsers import get_list_from_env
|
|
||||||
from paperless.settings.parsers import get_path_from_env
|
|
||||||
|
|
||||||
logger = logging.getLogger("paperless.settings")
|
logger = logging.getLogger("paperless.settings")
|
||||||
|
|
||||||
# Tap paperless.conf if it's available
|
# Tap paperless.conf if it's available
|
||||||
@@ -49,6 +43,76 @@ for path in [
|
|||||||
os.environ["OMP_THREAD_LIMIT"] = "1"
|
os.environ["OMP_THREAD_LIMIT"] = "1"
|
||||||
|
|
||||||
|
|
||||||
|
def __get_boolean(key: str, default: str = "NO") -> bool:
|
||||||
|
"""
|
||||||
|
Return a boolean value based on whatever the user has supplied in the
|
||||||
|
environment based on whether the value "looks like" it's True or not.
|
||||||
|
"""
|
||||||
|
return bool(os.getenv(key, default).lower() in ("yes", "y", "1", "t", "true"))
|
||||||
|
|
||||||
|
|
||||||
|
def __get_int(key: str, default: int) -> int:
|
||||||
|
"""
|
||||||
|
Return an integer value based on the environment variable or a default
|
||||||
|
"""
|
||||||
|
return int(os.getenv(key, default))
|
||||||
|
|
||||||
|
|
||||||
|
def __get_optional_int(key: str) -> int | None:
|
||||||
|
"""
|
||||||
|
Returns None if the environment key is not present, otherwise an integer
|
||||||
|
"""
|
||||||
|
if key in os.environ:
|
||||||
|
return __get_int(key, -1) # pragma: no cover
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def __get_float(key: str, default: float) -> float:
|
||||||
|
"""
|
||||||
|
Return an integer value based on the environment variable or a default
|
||||||
|
"""
|
||||||
|
return float(os.getenv(key, default))
|
||||||
|
|
||||||
|
|
||||||
|
def __get_path(
|
||||||
|
key: str,
|
||||||
|
default: PathLike | str,
|
||||||
|
) -> Path:
|
||||||
|
"""
|
||||||
|
Return a normalized, absolute path based on the environment variable or a default,
|
||||||
|
if provided
|
||||||
|
"""
|
||||||
|
if key in os.environ:
|
||||||
|
return Path(os.environ[key]).resolve()
|
||||||
|
return Path(default).resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def __get_optional_path(key: str) -> Path | None:
|
||||||
|
"""
|
||||||
|
Returns None if the environment key is not present, otherwise a fully resolved Path
|
||||||
|
"""
|
||||||
|
if key in os.environ:
|
||||||
|
return __get_path(key, "")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def __get_list(
|
||||||
|
key: str,
|
||||||
|
default: list[str] | None = None,
|
||||||
|
sep: str = ",",
|
||||||
|
) -> list[str]:
|
||||||
|
"""
|
||||||
|
Return a list of elements from the environment, as separated by the given
|
||||||
|
string, or the default if the key does not exist
|
||||||
|
"""
|
||||||
|
if key in os.environ:
|
||||||
|
return list(filter(None, os.environ[key].split(sep)))
|
||||||
|
elif default is not None:
|
||||||
|
return default
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
def _parse_redis_url(env_redis: str | None) -> tuple[str, str]:
|
def _parse_redis_url(env_redis: str | None) -> tuple[str, str]:
|
||||||
"""
|
"""
|
||||||
Gets the Redis information from the environment or a default and handles
|
Gets the Redis information from the environment or a default and handles
|
||||||
@@ -211,7 +275,7 @@ def _parse_beat_schedule() -> dict:
|
|||||||
|
|
||||||
|
|
||||||
# NEVER RUN WITH DEBUG IN PRODUCTION.
|
# NEVER RUN WITH DEBUG IN PRODUCTION.
|
||||||
DEBUG = get_bool_from_env("PAPERLESS_DEBUG", "NO")
|
DEBUG = __get_boolean("PAPERLESS_DEBUG", "NO")
|
||||||
|
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
@@ -220,21 +284,21 @@ DEBUG = get_bool_from_env("PAPERLESS_DEBUG", "NO")
|
|||||||
|
|
||||||
BASE_DIR: Path = Path(__file__).resolve().parent.parent
|
BASE_DIR: Path = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
STATIC_ROOT = get_path_from_env("PAPERLESS_STATICDIR", BASE_DIR.parent / "static")
|
STATIC_ROOT = __get_path("PAPERLESS_STATICDIR", BASE_DIR.parent / "static")
|
||||||
|
|
||||||
MEDIA_ROOT = get_path_from_env("PAPERLESS_MEDIA_ROOT", BASE_DIR.parent / "media")
|
MEDIA_ROOT = __get_path("PAPERLESS_MEDIA_ROOT", BASE_DIR.parent / "media")
|
||||||
ORIGINALS_DIR = MEDIA_ROOT / "documents" / "originals"
|
ORIGINALS_DIR = MEDIA_ROOT / "documents" / "originals"
|
||||||
ARCHIVE_DIR = MEDIA_ROOT / "documents" / "archive"
|
ARCHIVE_DIR = MEDIA_ROOT / "documents" / "archive"
|
||||||
THUMBNAIL_DIR = MEDIA_ROOT / "documents" / "thumbnails"
|
THUMBNAIL_DIR = MEDIA_ROOT / "documents" / "thumbnails"
|
||||||
SHARE_LINK_BUNDLE_DIR = MEDIA_ROOT / "documents" / "share_link_bundles"
|
SHARE_LINK_BUNDLE_DIR = MEDIA_ROOT / "documents" / "share_link_bundles"
|
||||||
|
|
||||||
DATA_DIR = get_path_from_env("PAPERLESS_DATA_DIR", BASE_DIR.parent / "data")
|
DATA_DIR = __get_path("PAPERLESS_DATA_DIR", BASE_DIR.parent / "data")
|
||||||
|
|
||||||
NLTK_DIR = get_path_from_env("PAPERLESS_NLTK_DIR", "/usr/share/nltk_data")
|
NLTK_DIR = __get_path("PAPERLESS_NLTK_DIR", "/usr/share/nltk_data")
|
||||||
|
|
||||||
# Check deprecated setting first
|
# Check deprecated setting first
|
||||||
EMPTY_TRASH_DIR = (
|
EMPTY_TRASH_DIR = (
|
||||||
get_path_from_env("PAPERLESS_TRASH_DIR", os.getenv("PAPERLESS_EMPTY_TRASH_DIR"))
|
__get_path("PAPERLESS_TRASH_DIR", os.getenv("PAPERLESS_EMPTY_TRASH_DIR"))
|
||||||
if os.getenv("PAPERLESS_TRASH_DIR") or os.getenv("PAPERLESS_EMPTY_TRASH_DIR")
|
if os.getenv("PAPERLESS_TRASH_DIR") or os.getenv("PAPERLESS_EMPTY_TRASH_DIR")
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
@@ -243,21 +307,21 @@ EMPTY_TRASH_DIR = (
|
|||||||
# threads.
|
# threads.
|
||||||
MEDIA_LOCK = MEDIA_ROOT / "media.lock"
|
MEDIA_LOCK = MEDIA_ROOT / "media.lock"
|
||||||
INDEX_DIR = DATA_DIR / "index"
|
INDEX_DIR = DATA_DIR / "index"
|
||||||
MODEL_FILE = get_path_from_env(
|
MODEL_FILE = __get_path(
|
||||||
"PAPERLESS_MODEL_FILE",
|
"PAPERLESS_MODEL_FILE",
|
||||||
DATA_DIR / "classification_model.pickle",
|
DATA_DIR / "classification_model.pickle",
|
||||||
)
|
)
|
||||||
LLM_INDEX_DIR = DATA_DIR / "llm_index"
|
LLM_INDEX_DIR = DATA_DIR / "llm_index"
|
||||||
|
|
||||||
LOGGING_DIR = get_path_from_env("PAPERLESS_LOGGING_DIR", DATA_DIR / "log")
|
LOGGING_DIR = __get_path("PAPERLESS_LOGGING_DIR", DATA_DIR / "log")
|
||||||
|
|
||||||
CONSUMPTION_DIR = get_path_from_env(
|
CONSUMPTION_DIR = __get_path(
|
||||||
"PAPERLESS_CONSUMPTION_DIR",
|
"PAPERLESS_CONSUMPTION_DIR",
|
||||||
BASE_DIR.parent / "consume",
|
BASE_DIR.parent / "consume",
|
||||||
)
|
)
|
||||||
|
|
||||||
# This will be created if it doesn't exist
|
# This will be created if it doesn't exist
|
||||||
SCRATCH_DIR = get_path_from_env(
|
SCRATCH_DIR = __get_path(
|
||||||
"PAPERLESS_SCRATCH_DIR",
|
"PAPERLESS_SCRATCH_DIR",
|
||||||
Path(tempfile.gettempdir()) / "paperless",
|
Path(tempfile.gettempdir()) / "paperless",
|
||||||
)
|
)
|
||||||
@@ -266,7 +330,7 @@ SCRATCH_DIR = get_path_from_env(
|
|||||||
# Application Definition #
|
# Application Definition #
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
env_apps = get_list_from_env("PAPERLESS_APPS")
|
env_apps = __get_list("PAPERLESS_APPS")
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
"whitenoise.runserver_nostatic",
|
"whitenoise.runserver_nostatic",
|
||||||
@@ -339,7 +403,7 @@ MIDDLEWARE = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
# Optional to enable compression
|
# Optional to enable compression
|
||||||
if get_bool_from_env("PAPERLESS_ENABLE_COMPRESSION", "yes"): # pragma: no cover
|
if __get_boolean("PAPERLESS_ENABLE_COMPRESSION", "yes"): # pragma: no cover
|
||||||
MIDDLEWARE.insert(0, "compression_middleware.middleware.CompressionMiddleware")
|
MIDDLEWARE.insert(0, "compression_middleware.middleware.CompressionMiddleware")
|
||||||
|
|
||||||
# Workaround to not compress streaming responses (e.g. chat).
|
# Workaround to not compress streaming responses (e.g. chat).
|
||||||
@@ -448,8 +512,8 @@ EMAIL_PORT: Final[int] = int(os.getenv("PAPERLESS_EMAIL_PORT", 25))
|
|||||||
EMAIL_HOST_USER: Final[str] = os.getenv("PAPERLESS_EMAIL_HOST_USER", "")
|
EMAIL_HOST_USER: Final[str] = os.getenv("PAPERLESS_EMAIL_HOST_USER", "")
|
||||||
EMAIL_HOST_PASSWORD: Final[str] = os.getenv("PAPERLESS_EMAIL_HOST_PASSWORD", "")
|
EMAIL_HOST_PASSWORD: Final[str] = os.getenv("PAPERLESS_EMAIL_HOST_PASSWORD", "")
|
||||||
DEFAULT_FROM_EMAIL: Final[str] = os.getenv("PAPERLESS_EMAIL_FROM", EMAIL_HOST_USER)
|
DEFAULT_FROM_EMAIL: Final[str] = os.getenv("PAPERLESS_EMAIL_FROM", EMAIL_HOST_USER)
|
||||||
EMAIL_USE_TLS: Final[bool] = get_bool_from_env("PAPERLESS_EMAIL_USE_TLS")
|
EMAIL_USE_TLS: Final[bool] = __get_boolean("PAPERLESS_EMAIL_USE_TLS")
|
||||||
EMAIL_USE_SSL: Final[bool] = get_bool_from_env("PAPERLESS_EMAIL_USE_SSL")
|
EMAIL_USE_SSL: Final[bool] = __get_boolean("PAPERLESS_EMAIL_USE_SSL")
|
||||||
EMAIL_SUBJECT_PREFIX: Final[str] = "[Paperless-ngx] "
|
EMAIL_SUBJECT_PREFIX: Final[str] = "[Paperless-ngx] "
|
||||||
EMAIL_TIMEOUT = 30.0
|
EMAIL_TIMEOUT = 30.0
|
||||||
EMAIL_ENABLED = EMAIL_HOST != "localhost" or EMAIL_HOST_USER != ""
|
EMAIL_ENABLED = EMAIL_HOST != "localhost" or EMAIL_HOST_USER != ""
|
||||||
@@ -474,22 +538,20 @@ ACCOUNT_DEFAULT_HTTP_PROTOCOL = os.getenv(
|
|||||||
)
|
)
|
||||||
|
|
||||||
ACCOUNT_ADAPTER = "paperless.adapter.CustomAccountAdapter"
|
ACCOUNT_ADAPTER = "paperless.adapter.CustomAccountAdapter"
|
||||||
ACCOUNT_ALLOW_SIGNUPS = get_bool_from_env("PAPERLESS_ACCOUNT_ALLOW_SIGNUPS")
|
ACCOUNT_ALLOW_SIGNUPS = __get_boolean("PAPERLESS_ACCOUNT_ALLOW_SIGNUPS")
|
||||||
ACCOUNT_DEFAULT_GROUPS = get_list_from_env("PAPERLESS_ACCOUNT_DEFAULT_GROUPS")
|
ACCOUNT_DEFAULT_GROUPS = __get_list("PAPERLESS_ACCOUNT_DEFAULT_GROUPS")
|
||||||
|
|
||||||
SOCIALACCOUNT_ADAPTER = "paperless.adapter.CustomSocialAccountAdapter"
|
SOCIALACCOUNT_ADAPTER = "paperless.adapter.CustomSocialAccountAdapter"
|
||||||
SOCIALACCOUNT_ALLOW_SIGNUPS = get_bool_from_env(
|
SOCIALACCOUNT_ALLOW_SIGNUPS = __get_boolean(
|
||||||
"PAPERLESS_SOCIALACCOUNT_ALLOW_SIGNUPS",
|
"PAPERLESS_SOCIALACCOUNT_ALLOW_SIGNUPS",
|
||||||
"yes",
|
"yes",
|
||||||
)
|
)
|
||||||
SOCIALACCOUNT_AUTO_SIGNUP = get_bool_from_env("PAPERLESS_SOCIAL_AUTO_SIGNUP")
|
SOCIALACCOUNT_AUTO_SIGNUP = __get_boolean("PAPERLESS_SOCIAL_AUTO_SIGNUP")
|
||||||
SOCIALACCOUNT_PROVIDERS = json.loads(
|
SOCIALACCOUNT_PROVIDERS = json.loads(
|
||||||
os.getenv("PAPERLESS_SOCIALACCOUNT_PROVIDERS", "{}"),
|
os.getenv("PAPERLESS_SOCIALACCOUNT_PROVIDERS", "{}"),
|
||||||
)
|
)
|
||||||
SOCIAL_ACCOUNT_DEFAULT_GROUPS = get_list_from_env(
|
SOCIAL_ACCOUNT_DEFAULT_GROUPS = __get_list("PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS")
|
||||||
"PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS",
|
SOCIAL_ACCOUNT_SYNC_GROUPS = __get_boolean("PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS")
|
||||||
)
|
|
||||||
SOCIAL_ACCOUNT_SYNC_GROUPS = get_bool_from_env("PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS")
|
|
||||||
SOCIAL_ACCOUNT_SYNC_GROUPS_CLAIM: Final[str] = os.getenv(
|
SOCIAL_ACCOUNT_SYNC_GROUPS_CLAIM: Final[str] = os.getenv(
|
||||||
"PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS_CLAIM",
|
"PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS_CLAIM",
|
||||||
"groups",
|
"groups",
|
||||||
@@ -501,8 +563,8 @@ MFA_TOTP_ISSUER = "Paperless-ngx"
|
|||||||
|
|
||||||
ACCOUNT_EMAIL_SUBJECT_PREFIX = "[Paperless-ngx] "
|
ACCOUNT_EMAIL_SUBJECT_PREFIX = "[Paperless-ngx] "
|
||||||
|
|
||||||
DISABLE_REGULAR_LOGIN = get_bool_from_env("PAPERLESS_DISABLE_REGULAR_LOGIN")
|
DISABLE_REGULAR_LOGIN = __get_boolean("PAPERLESS_DISABLE_REGULAR_LOGIN")
|
||||||
REDIRECT_LOGIN_TO_SSO = get_bool_from_env("PAPERLESS_REDIRECT_LOGIN_TO_SSO")
|
REDIRECT_LOGIN_TO_SSO = __get_boolean("PAPERLESS_REDIRECT_LOGIN_TO_SSO")
|
||||||
|
|
||||||
AUTO_LOGIN_USERNAME = os.getenv("PAPERLESS_AUTO_LOGIN_USERNAME")
|
AUTO_LOGIN_USERNAME = os.getenv("PAPERLESS_AUTO_LOGIN_USERNAME")
|
||||||
|
|
||||||
@@ -515,15 +577,12 @@ ACCOUNT_EMAIL_VERIFICATION = (
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
ACCOUNT_EMAIL_UNKNOWN_ACCOUNTS = get_bool_from_env(
|
ACCOUNT_EMAIL_UNKNOWN_ACCOUNTS = __get_boolean(
|
||||||
"PAPERLESS_ACCOUNT_EMAIL_UNKNOWN_ACCOUNTS",
|
"PAPERLESS_ACCOUNT_EMAIL_UNKNOWN_ACCOUNTS",
|
||||||
"True",
|
"True",
|
||||||
)
|
)
|
||||||
|
|
||||||
ACCOUNT_SESSION_REMEMBER = get_bool_from_env(
|
ACCOUNT_SESSION_REMEMBER = __get_boolean("PAPERLESS_ACCOUNT_SESSION_REMEMBER", "True")
|
||||||
"PAPERLESS_ACCOUNT_SESSION_REMEMBER",
|
|
||||||
"True",
|
|
||||||
)
|
|
||||||
SESSION_EXPIRE_AT_BROWSER_CLOSE = not ACCOUNT_SESSION_REMEMBER
|
SESSION_EXPIRE_AT_BROWSER_CLOSE = not ACCOUNT_SESSION_REMEMBER
|
||||||
SESSION_COOKIE_AGE = int(
|
SESSION_COOKIE_AGE = int(
|
||||||
os.getenv("PAPERLESS_SESSION_COOKIE_AGE", 60 * 60 * 24 * 7 * 3),
|
os.getenv("PAPERLESS_SESSION_COOKIE_AGE", 60 * 60 * 24 * 7 * 3),
|
||||||
@@ -540,8 +599,8 @@ if AUTO_LOGIN_USERNAME:
|
|||||||
|
|
||||||
def _parse_remote_user_settings() -> str:
|
def _parse_remote_user_settings() -> str:
|
||||||
global MIDDLEWARE, AUTHENTICATION_BACKENDS, REST_FRAMEWORK
|
global MIDDLEWARE, AUTHENTICATION_BACKENDS, REST_FRAMEWORK
|
||||||
enable = get_bool_from_env("PAPERLESS_ENABLE_HTTP_REMOTE_USER")
|
enable = __get_boolean("PAPERLESS_ENABLE_HTTP_REMOTE_USER")
|
||||||
enable_api = get_bool_from_env("PAPERLESS_ENABLE_HTTP_REMOTE_USER_API")
|
enable_api = __get_boolean("PAPERLESS_ENABLE_HTTP_REMOTE_USER_API")
|
||||||
if enable or enable_api:
|
if enable or enable_api:
|
||||||
MIDDLEWARE.append("paperless.auth.HttpRemoteUserMiddleware")
|
MIDDLEWARE.append("paperless.auth.HttpRemoteUserMiddleware")
|
||||||
AUTHENTICATION_BACKENDS.insert(
|
AUTHENTICATION_BACKENDS.insert(
|
||||||
@@ -569,16 +628,16 @@ HTTP_REMOTE_USER_HEADER_NAME = _parse_remote_user_settings()
|
|||||||
X_FRAME_OPTIONS = "SAMEORIGIN"
|
X_FRAME_OPTIONS = "SAMEORIGIN"
|
||||||
|
|
||||||
# The next 3 settings can also be set using just PAPERLESS_URL
|
# The next 3 settings can also be set using just PAPERLESS_URL
|
||||||
CSRF_TRUSTED_ORIGINS = get_list_from_env("PAPERLESS_CSRF_TRUSTED_ORIGINS")
|
CSRF_TRUSTED_ORIGINS = __get_list("PAPERLESS_CSRF_TRUSTED_ORIGINS")
|
||||||
|
|
||||||
if DEBUG:
|
if DEBUG:
|
||||||
# Allow access from the angular development server during debugging
|
# Allow access from the angular development server during debugging
|
||||||
CSRF_TRUSTED_ORIGINS.append("http://localhost:4200")
|
CSRF_TRUSTED_ORIGINS.append("http://localhost:4200")
|
||||||
|
|
||||||
# We allow CORS from localhost:8000
|
# We allow CORS from localhost:8000
|
||||||
CORS_ALLOWED_ORIGINS = get_list_from_env(
|
CORS_ALLOWED_ORIGINS = __get_list(
|
||||||
"PAPERLESS_CORS_ALLOWED_HOSTS",
|
"PAPERLESS_CORS_ALLOWED_HOSTS",
|
||||||
default=["http://localhost:8000"],
|
["http://localhost:8000"],
|
||||||
)
|
)
|
||||||
|
|
||||||
if DEBUG:
|
if DEBUG:
|
||||||
@@ -591,7 +650,7 @@ CORS_EXPOSE_HEADERS = [
|
|||||||
"Content-Disposition",
|
"Content-Disposition",
|
||||||
]
|
]
|
||||||
|
|
||||||
ALLOWED_HOSTS = get_list_from_env("PAPERLESS_ALLOWED_HOSTS", default=["*"])
|
ALLOWED_HOSTS = __get_list("PAPERLESS_ALLOWED_HOSTS", ["*"])
|
||||||
if ALLOWED_HOSTS != ["*"]:
|
if ALLOWED_HOSTS != ["*"]:
|
||||||
# always allow localhost. Necessary e.g. for healthcheck in docker.
|
# always allow localhost. Necessary e.g. for healthcheck in docker.
|
||||||
ALLOWED_HOSTS.append("localhost")
|
ALLOWED_HOSTS.append("localhost")
|
||||||
@@ -611,10 +670,10 @@ def _parse_paperless_url():
|
|||||||
PAPERLESS_URL = _parse_paperless_url()
|
PAPERLESS_URL = _parse_paperless_url()
|
||||||
|
|
||||||
# For use with trusted proxies
|
# For use with trusted proxies
|
||||||
TRUSTED_PROXIES = get_list_from_env("PAPERLESS_TRUSTED_PROXIES")
|
TRUSTED_PROXIES = __get_list("PAPERLESS_TRUSTED_PROXIES")
|
||||||
|
|
||||||
USE_X_FORWARDED_HOST = get_bool_from_env("PAPERLESS_USE_X_FORWARD_HOST", "false")
|
USE_X_FORWARDED_HOST = __get_boolean("PAPERLESS_USE_X_FORWARD_HOST", "false")
|
||||||
USE_X_FORWARDED_PORT = get_bool_from_env("PAPERLESS_USE_X_FORWARD_PORT", "false")
|
USE_X_FORWARDED_PORT = __get_boolean("PAPERLESS_USE_X_FORWARD_PORT", "false")
|
||||||
SECURE_PROXY_SSL_HEADER = (
|
SECURE_PROXY_SSL_HEADER = (
|
||||||
tuple(json.loads(os.environ["PAPERLESS_PROXY_SSL_HEADER"]))
|
tuple(json.loads(os.environ["PAPERLESS_PROXY_SSL_HEADER"]))
|
||||||
if "PAPERLESS_PROXY_SSL_HEADER" in os.environ
|
if "PAPERLESS_PROXY_SSL_HEADER" in os.environ
|
||||||
@@ -657,15 +716,98 @@ CSRF_COOKIE_NAME = f"{COOKIE_PREFIX}csrftoken"
|
|||||||
SESSION_COOKIE_NAME = f"{COOKIE_PREFIX}sessionid"
|
SESSION_COOKIE_NAME = f"{COOKIE_PREFIX}sessionid"
|
||||||
LANGUAGE_COOKIE_NAME = f"{COOKIE_PREFIX}django_language"
|
LANGUAGE_COOKIE_NAME = f"{COOKIE_PREFIX}django_language"
|
||||||
|
|
||||||
EMAIL_CERTIFICATE_FILE = get_path_from_env("PAPERLESS_EMAIL_CERTIFICATE_LOCATION", None)
|
EMAIL_CERTIFICATE_FILE = __get_optional_path("PAPERLESS_EMAIL_CERTIFICATE_LOCATION")
|
||||||
|
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
# Database #
|
# Database #
|
||||||
###############################################################################
|
###############################################################################
|
||||||
DATABASES = parse_db_settings(DATA_DIR)
|
def _parse_db_settings() -> dict:
|
||||||
|
databases = {
|
||||||
|
"default": {
|
||||||
|
"ENGINE": "django.db.backends.sqlite3",
|
||||||
|
"NAME": DATA_DIR / "db.sqlite3",
|
||||||
|
"OPTIONS": {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if os.getenv("PAPERLESS_DBHOST"):
|
||||||
|
# Have sqlite available as a second option for management commands
|
||||||
|
# This is important when migrating to/from sqlite
|
||||||
|
databases["sqlite"] = databases["default"].copy()
|
||||||
|
|
||||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
databases["default"] = {
|
||||||
|
"HOST": os.getenv("PAPERLESS_DBHOST"),
|
||||||
|
"NAME": os.getenv("PAPERLESS_DBNAME", "paperless"),
|
||||||
|
"USER": os.getenv("PAPERLESS_DBUSER", "paperless"),
|
||||||
|
"PASSWORD": os.getenv("PAPERLESS_DBPASS", "paperless"),
|
||||||
|
"OPTIONS": {},
|
||||||
|
}
|
||||||
|
if os.getenv("PAPERLESS_DBPORT"):
|
||||||
|
databases["default"]["PORT"] = os.getenv("PAPERLESS_DBPORT")
|
||||||
|
|
||||||
|
# Leave room for future extensibility
|
||||||
|
if os.getenv("PAPERLESS_DBENGINE") == "mariadb":
|
||||||
|
engine = "django.db.backends.mysql"
|
||||||
|
# Contrary to Postgres, Django does not natively support connection pooling for MariaDB.
|
||||||
|
# However, since MariaDB uses threads instead of forks, establishing connections is significantly faster
|
||||||
|
# compared to PostgreSQL, so the lack of pooling is not an issue
|
||||||
|
options = {
|
||||||
|
"read_default_file": "/etc/mysql/my.cnf",
|
||||||
|
"charset": "utf8mb4",
|
||||||
|
"ssl_mode": os.getenv("PAPERLESS_DBSSLMODE", "PREFERRED"),
|
||||||
|
"ssl": {
|
||||||
|
"ca": os.getenv("PAPERLESS_DBSSLROOTCERT", None),
|
||||||
|
"cert": os.getenv("PAPERLESS_DBSSLCERT", None),
|
||||||
|
"key": os.getenv("PAPERLESS_DBSSLKEY", None),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
else: # Default to PostgresDB
|
||||||
|
engine = "django.db.backends.postgresql"
|
||||||
|
options = {
|
||||||
|
"sslmode": os.getenv("PAPERLESS_DBSSLMODE", "prefer"),
|
||||||
|
"sslrootcert": os.getenv("PAPERLESS_DBSSLROOTCERT", None),
|
||||||
|
"sslcert": os.getenv("PAPERLESS_DBSSLCERT", None),
|
||||||
|
"sslkey": os.getenv("PAPERLESS_DBSSLKEY", None),
|
||||||
|
}
|
||||||
|
if int(os.getenv("PAPERLESS_DB_POOLSIZE", 0)) > 0:
|
||||||
|
options.update(
|
||||||
|
{
|
||||||
|
"pool": {
|
||||||
|
"min_size": 1,
|
||||||
|
"max_size": int(os.getenv("PAPERLESS_DB_POOLSIZE")),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
databases["default"]["ENGINE"] = engine
|
||||||
|
databases["default"]["OPTIONS"].update(options)
|
||||||
|
|
||||||
|
if os.getenv("PAPERLESS_DB_TIMEOUT") is not None:
|
||||||
|
if databases["default"]["ENGINE"] == "django.db.backends.sqlite3":
|
||||||
|
databases["default"]["OPTIONS"].update(
|
||||||
|
{"timeout": int(os.getenv("PAPERLESS_DB_TIMEOUT"))},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
databases["default"]["OPTIONS"].update(
|
||||||
|
{"connect_timeout": int(os.getenv("PAPERLESS_DB_TIMEOUT"))},
|
||||||
|
)
|
||||||
|
databases["sqlite"]["OPTIONS"].update(
|
||||||
|
{"timeout": int(os.getenv("PAPERLESS_DB_TIMEOUT"))},
|
||||||
|
)
|
||||||
|
return databases
|
||||||
|
|
||||||
|
|
||||||
|
DATABASES = _parse_db_settings()
|
||||||
|
|
||||||
|
if os.getenv("PAPERLESS_DBENGINE") == "mariadb":
|
||||||
|
# Silence Django error on old MariaDB versions.
|
||||||
|
# VARCHAR can support > 255 in modern versions
|
||||||
|
# https://docs.djangoproject.com/en/4.1/ref/checks/#database
|
||||||
|
# https://mariadb.com/kb/en/innodb-system-variables/#innodb_large_prefix
|
||||||
|
SILENCED_SYSTEM_CHECKS = ["mysql.W003"]
|
||||||
|
|
||||||
|
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
# Internationalization #
|
# Internationalization #
|
||||||
@@ -800,7 +942,7 @@ CELERY_BROKER_URL = _CELERY_REDIS_URL
|
|||||||
CELERY_TIMEZONE = TIME_ZONE
|
CELERY_TIMEZONE = TIME_ZONE
|
||||||
|
|
||||||
CELERY_WORKER_HIJACK_ROOT_LOGGER = False
|
CELERY_WORKER_HIJACK_ROOT_LOGGER = False
|
||||||
CELERY_WORKER_CONCURRENCY: Final[int] = get_int_from_env("PAPERLESS_TASK_WORKERS", 1)
|
CELERY_WORKER_CONCURRENCY: Final[int] = __get_int("PAPERLESS_TASK_WORKERS", 1)
|
||||||
TASK_WORKERS = CELERY_WORKER_CONCURRENCY
|
TASK_WORKERS = CELERY_WORKER_CONCURRENCY
|
||||||
CELERY_WORKER_MAX_TASKS_PER_CHILD = 1
|
CELERY_WORKER_MAX_TASKS_PER_CHILD = 1
|
||||||
CELERY_WORKER_SEND_TASK_EVENTS = True
|
CELERY_WORKER_SEND_TASK_EVENTS = True
|
||||||
@@ -813,7 +955,7 @@ CELERY_BROKER_TRANSPORT_OPTIONS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
CELERY_TASK_TRACK_STARTED = True
|
CELERY_TASK_TRACK_STARTED = True
|
||||||
CELERY_TASK_TIME_LIMIT: Final[int] = get_int_from_env("PAPERLESS_WORKER_TIMEOUT", 1800)
|
CELERY_TASK_TIME_LIMIT: Final[int] = __get_int("PAPERLESS_WORKER_TIMEOUT", 1800)
|
||||||
|
|
||||||
CELERY_RESULT_EXTENDED = True
|
CELERY_RESULT_EXTENDED = True
|
||||||
CELERY_RESULT_BACKEND = "django-db"
|
CELERY_RESULT_BACKEND = "django-db"
|
||||||
@@ -833,14 +975,14 @@ CELERY_BEAT_SCHEDULE_FILENAME = str(DATA_DIR / "celerybeat-schedule.db")
|
|||||||
|
|
||||||
# Cachalot: Database read cache.
|
# Cachalot: Database read cache.
|
||||||
def _parse_cachalot_settings():
|
def _parse_cachalot_settings():
|
||||||
ttl = get_int_from_env("PAPERLESS_READ_CACHE_TTL", 3600)
|
ttl = __get_int("PAPERLESS_READ_CACHE_TTL", 3600)
|
||||||
ttl = min(ttl, 31536000) if ttl > 0 else 3600
|
ttl = min(ttl, 31536000) if ttl > 0 else 3600
|
||||||
_, redis_url = _parse_redis_url(
|
_, redis_url = _parse_redis_url(
|
||||||
os.getenv("PAPERLESS_READ_CACHE_REDIS_URL", _CHANNELS_REDIS_URL),
|
os.getenv("PAPERLESS_READ_CACHE_REDIS_URL", _CHANNELS_REDIS_URL),
|
||||||
)
|
)
|
||||||
result = {
|
result = {
|
||||||
"CACHALOT_CACHE": "read-cache",
|
"CACHALOT_CACHE": "read-cache",
|
||||||
"CACHALOT_ENABLED": get_bool_from_env(
|
"CACHALOT_ENABLED": __get_boolean(
|
||||||
"PAPERLESS_DB_READ_CACHE_ENABLED",
|
"PAPERLESS_DB_READ_CACHE_ENABLED",
|
||||||
default="no",
|
default="no",
|
||||||
),
|
),
|
||||||
@@ -925,9 +1067,9 @@ CONSUMER_POLLING_INTERVAL = float(os.getenv("PAPERLESS_CONSUMER_POLLING_INTERVAL
|
|||||||
|
|
||||||
CONSUMER_STABILITY_DELAY = float(os.getenv("PAPERLESS_CONSUMER_STABILITY_DELAY", 5))
|
CONSUMER_STABILITY_DELAY = float(os.getenv("PAPERLESS_CONSUMER_STABILITY_DELAY", 5))
|
||||||
|
|
||||||
CONSUMER_DELETE_DUPLICATES = get_bool_from_env("PAPERLESS_CONSUMER_DELETE_DUPLICATES")
|
CONSUMER_DELETE_DUPLICATES = __get_boolean("PAPERLESS_CONSUMER_DELETE_DUPLICATES")
|
||||||
|
|
||||||
CONSUMER_RECURSIVE = get_bool_from_env("PAPERLESS_CONSUMER_RECURSIVE")
|
CONSUMER_RECURSIVE = __get_boolean("PAPERLESS_CONSUMER_RECURSIVE")
|
||||||
|
|
||||||
# Ignore regex patterns, matched against filename only
|
# Ignore regex patterns, matched against filename only
|
||||||
CONSUMER_IGNORE_PATTERNS = list(
|
CONSUMER_IGNORE_PATTERNS = list(
|
||||||
@@ -949,13 +1091,13 @@ CONSUMER_IGNORE_DIRS = list(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
CONSUMER_SUBDIRS_AS_TAGS = get_bool_from_env("PAPERLESS_CONSUMER_SUBDIRS_AS_TAGS")
|
CONSUMER_SUBDIRS_AS_TAGS = __get_boolean("PAPERLESS_CONSUMER_SUBDIRS_AS_TAGS")
|
||||||
|
|
||||||
CONSUMER_ENABLE_BARCODES: Final[bool] = get_bool_from_env(
|
CONSUMER_ENABLE_BARCODES: Final[bool] = __get_boolean(
|
||||||
"PAPERLESS_CONSUMER_ENABLE_BARCODES",
|
"PAPERLESS_CONSUMER_ENABLE_BARCODES",
|
||||||
)
|
)
|
||||||
|
|
||||||
CONSUMER_BARCODE_TIFF_SUPPORT: Final[bool] = get_bool_from_env(
|
CONSUMER_BARCODE_TIFF_SUPPORT: Final[bool] = __get_boolean(
|
||||||
"PAPERLESS_CONSUMER_BARCODE_TIFF_SUPPORT",
|
"PAPERLESS_CONSUMER_BARCODE_TIFF_SUPPORT",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -964,7 +1106,7 @@ CONSUMER_BARCODE_STRING: Final[str] = os.getenv(
|
|||||||
"PATCHT",
|
"PATCHT",
|
||||||
)
|
)
|
||||||
|
|
||||||
CONSUMER_ENABLE_ASN_BARCODE: Final[bool] = get_bool_from_env(
|
CONSUMER_ENABLE_ASN_BARCODE: Final[bool] = __get_boolean(
|
||||||
"PAPERLESS_CONSUMER_ENABLE_ASN_BARCODE",
|
"PAPERLESS_CONSUMER_ENABLE_ASN_BARCODE",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -973,26 +1115,23 @@ CONSUMER_ASN_BARCODE_PREFIX: Final[str] = os.getenv(
|
|||||||
"ASN",
|
"ASN",
|
||||||
)
|
)
|
||||||
|
|
||||||
CONSUMER_BARCODE_UPSCALE: Final[float] = get_float_from_env(
|
CONSUMER_BARCODE_UPSCALE: Final[float] = __get_float(
|
||||||
"PAPERLESS_CONSUMER_BARCODE_UPSCALE",
|
"PAPERLESS_CONSUMER_BARCODE_UPSCALE",
|
||||||
0.0,
|
0.0,
|
||||||
)
|
)
|
||||||
|
|
||||||
CONSUMER_BARCODE_DPI: Final[int] = get_int_from_env(
|
CONSUMER_BARCODE_DPI: Final[int] = __get_int("PAPERLESS_CONSUMER_BARCODE_DPI", 300)
|
||||||
"PAPERLESS_CONSUMER_BARCODE_DPI",
|
|
||||||
300,
|
|
||||||
)
|
|
||||||
|
|
||||||
CONSUMER_BARCODE_MAX_PAGES: Final[int] = get_int_from_env(
|
CONSUMER_BARCODE_MAX_PAGES: Final[int] = __get_int(
|
||||||
"PAPERLESS_CONSUMER_BARCODE_MAX_PAGES",
|
"PAPERLESS_CONSUMER_BARCODE_MAX_PAGES",
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
|
|
||||||
CONSUMER_BARCODE_RETAIN_SPLIT_PAGES = get_bool_from_env(
|
CONSUMER_BARCODE_RETAIN_SPLIT_PAGES = __get_boolean(
|
||||||
"PAPERLESS_CONSUMER_BARCODE_RETAIN_SPLIT_PAGES",
|
"PAPERLESS_CONSUMER_BARCODE_RETAIN_SPLIT_PAGES",
|
||||||
)
|
)
|
||||||
|
|
||||||
CONSUMER_ENABLE_TAG_BARCODE: Final[bool] = get_bool_from_env(
|
CONSUMER_ENABLE_TAG_BARCODE: Final[bool] = __get_boolean(
|
||||||
"PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE",
|
"PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1005,11 +1144,11 @@ CONSUMER_TAG_BARCODE_MAPPING = dict(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
CONSUMER_TAG_BARCODE_SPLIT: Final[bool] = get_bool_from_env(
|
CONSUMER_TAG_BARCODE_SPLIT: Final[bool] = __get_boolean(
|
||||||
"PAPERLESS_CONSUMER_TAG_BARCODE_SPLIT",
|
"PAPERLESS_CONSUMER_TAG_BARCODE_SPLIT",
|
||||||
)
|
)
|
||||||
|
|
||||||
CONSUMER_ENABLE_COLLATE_DOUBLE_SIDED: Final[bool] = get_bool_from_env(
|
CONSUMER_ENABLE_COLLATE_DOUBLE_SIDED: Final[bool] = __get_boolean(
|
||||||
"PAPERLESS_CONSUMER_ENABLE_COLLATE_DOUBLE_SIDED",
|
"PAPERLESS_CONSUMER_ENABLE_COLLATE_DOUBLE_SIDED",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1018,13 +1157,13 @@ CONSUMER_COLLATE_DOUBLE_SIDED_SUBDIR_NAME: Final[str] = os.getenv(
|
|||||||
"double-sided",
|
"double-sided",
|
||||||
)
|
)
|
||||||
|
|
||||||
CONSUMER_COLLATE_DOUBLE_SIDED_TIFF_SUPPORT: Final[bool] = get_bool_from_env(
|
CONSUMER_COLLATE_DOUBLE_SIDED_TIFF_SUPPORT: Final[bool] = __get_boolean(
|
||||||
"PAPERLESS_CONSUMER_COLLATE_DOUBLE_SIDED_TIFF_SUPPORT",
|
"PAPERLESS_CONSUMER_COLLATE_DOUBLE_SIDED_TIFF_SUPPORT",
|
||||||
)
|
)
|
||||||
|
|
||||||
CONSUMER_PDF_RECOVERABLE_MIME_TYPES = ("application/octet-stream",)
|
CONSUMER_PDF_RECOVERABLE_MIME_TYPES = ("application/octet-stream",)
|
||||||
|
|
||||||
OCR_PAGES = get_int_from_env("PAPERLESS_OCR_PAGES", None)
|
OCR_PAGES = __get_optional_int("PAPERLESS_OCR_PAGES")
|
||||||
|
|
||||||
# The default language that tesseract will attempt to use when parsing
|
# The default language that tesseract will attempt to use when parsing
|
||||||
# documents. It should be a 3-letter language code consistent with ISO 639.
|
# documents. It should be a 3-letter language code consistent with ISO 639.
|
||||||
@@ -1038,22 +1177,21 @@ OCR_MODE = os.getenv("PAPERLESS_OCR_MODE", "skip")
|
|||||||
|
|
||||||
OCR_SKIP_ARCHIVE_FILE = os.getenv("PAPERLESS_OCR_SKIP_ARCHIVE_FILE", "never")
|
OCR_SKIP_ARCHIVE_FILE = os.getenv("PAPERLESS_OCR_SKIP_ARCHIVE_FILE", "never")
|
||||||
|
|
||||||
OCR_IMAGE_DPI = get_int_from_env("PAPERLESS_OCR_IMAGE_DPI", None)
|
OCR_IMAGE_DPI = __get_optional_int("PAPERLESS_OCR_IMAGE_DPI")
|
||||||
|
|
||||||
OCR_CLEAN = os.getenv("PAPERLESS_OCR_CLEAN", "clean")
|
OCR_CLEAN = os.getenv("PAPERLESS_OCR_CLEAN", "clean")
|
||||||
|
|
||||||
OCR_DESKEW: Final[bool] = get_bool_from_env("PAPERLESS_OCR_DESKEW", "true")
|
OCR_DESKEW: Final[bool] = __get_boolean("PAPERLESS_OCR_DESKEW", "true")
|
||||||
|
|
||||||
OCR_ROTATE_PAGES: Final[bool] = get_bool_from_env("PAPERLESS_OCR_ROTATE_PAGES", "true")
|
OCR_ROTATE_PAGES: Final[bool] = __get_boolean("PAPERLESS_OCR_ROTATE_PAGES", "true")
|
||||||
|
|
||||||
OCR_ROTATE_PAGES_THRESHOLD: Final[float] = get_float_from_env(
|
OCR_ROTATE_PAGES_THRESHOLD: Final[float] = __get_float(
|
||||||
"PAPERLESS_OCR_ROTATE_PAGES_THRESHOLD",
|
"PAPERLESS_OCR_ROTATE_PAGES_THRESHOLD",
|
||||||
12.0,
|
12.0,
|
||||||
)
|
)
|
||||||
|
|
||||||
OCR_MAX_IMAGE_PIXELS: Final[int | None] = get_int_from_env(
|
OCR_MAX_IMAGE_PIXELS: Final[int | None] = __get_optional_int(
|
||||||
"PAPERLESS_OCR_MAX_IMAGE_PIXELS",
|
"PAPERLESS_OCR_MAX_IMAGE_PIXELS",
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
OCR_COLOR_CONVERSION_STRATEGY = os.getenv(
|
OCR_COLOR_CONVERSION_STRATEGY = os.getenv(
|
||||||
@@ -1063,9 +1201,8 @@ OCR_COLOR_CONVERSION_STRATEGY = os.getenv(
|
|||||||
|
|
||||||
OCR_USER_ARGS = os.getenv("PAPERLESS_OCR_USER_ARGS")
|
OCR_USER_ARGS = os.getenv("PAPERLESS_OCR_USER_ARGS")
|
||||||
|
|
||||||
MAX_IMAGE_PIXELS: Final[int | None] = get_int_from_env(
|
MAX_IMAGE_PIXELS: Final[int | None] = __get_optional_int(
|
||||||
"PAPERLESS_MAX_IMAGE_PIXELS",
|
"PAPERLESS_MAX_IMAGE_PIXELS",
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# GNUPG needs a home directory for some reason
|
# GNUPG needs a home directory for some reason
|
||||||
@@ -1079,7 +1216,7 @@ CONVERT_MEMORY_LIMIT = os.getenv("PAPERLESS_CONVERT_MEMORY_LIMIT")
|
|||||||
GS_BINARY = os.getenv("PAPERLESS_GS_BINARY", "gs")
|
GS_BINARY = os.getenv("PAPERLESS_GS_BINARY", "gs")
|
||||||
|
|
||||||
# Fallback layout for .eml consumption
|
# Fallback layout for .eml consumption
|
||||||
EMAIL_PARSE_DEFAULT_LAYOUT = get_int_from_env(
|
EMAIL_PARSE_DEFAULT_LAYOUT = __get_int(
|
||||||
"PAPERLESS_EMAIL_PARSE_DEFAULT_LAYOUT",
|
"PAPERLESS_EMAIL_PARSE_DEFAULT_LAYOUT",
|
||||||
1, # MailRule.PdfLayout.TEXT_HTML but that can't be imported here
|
1, # MailRule.PdfLayout.TEXT_HTML but that can't be imported here
|
||||||
)
|
)
|
||||||
@@ -1120,7 +1257,7 @@ DATE_PARSER_LANGUAGES = (
|
|||||||
# Maximum number of dates taken from document start to end to show as suggestions for
|
# Maximum number of dates taken from document start to end to show as suggestions for
|
||||||
# `created` date in the frontend. Duplicates are removed, which can result in
|
# `created` date in the frontend. Duplicates are removed, which can result in
|
||||||
# fewer dates shown.
|
# fewer dates shown.
|
||||||
NUMBER_OF_SUGGESTED_DATES = get_int_from_env("PAPERLESS_NUMBER_OF_SUGGESTED_DATES", 3)
|
NUMBER_OF_SUGGESTED_DATES = __get_int("PAPERLESS_NUMBER_OF_SUGGESTED_DATES", 3)
|
||||||
|
|
||||||
# Specify the filename format for out files
|
# Specify the filename format for out files
|
||||||
FILENAME_FORMAT = os.getenv("PAPERLESS_FILENAME_FORMAT")
|
FILENAME_FORMAT = os.getenv("PAPERLESS_FILENAME_FORMAT")
|
||||||
@@ -1128,7 +1265,7 @@ FILENAME_FORMAT = os.getenv("PAPERLESS_FILENAME_FORMAT")
|
|||||||
# If this is enabled, variables in filename format will resolve to
|
# If this is enabled, variables in filename format will resolve to
|
||||||
# empty-string instead of 'none'.
|
# empty-string instead of 'none'.
|
||||||
# Directories with 'empty names' are omitted, too.
|
# Directories with 'empty names' are omitted, too.
|
||||||
FILENAME_FORMAT_REMOVE_NONE = get_bool_from_env(
|
FILENAME_FORMAT_REMOVE_NONE = __get_boolean(
|
||||||
"PAPERLESS_FILENAME_FORMAT_REMOVE_NONE",
|
"PAPERLESS_FILENAME_FORMAT_REMOVE_NONE",
|
||||||
"NO",
|
"NO",
|
||||||
)
|
)
|
||||||
@@ -1139,7 +1276,7 @@ THUMBNAIL_FONT_NAME = os.getenv(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Tika settings
|
# Tika settings
|
||||||
TIKA_ENABLED = get_bool_from_env("PAPERLESS_TIKA_ENABLED", "NO")
|
TIKA_ENABLED = __get_boolean("PAPERLESS_TIKA_ENABLED", "NO")
|
||||||
TIKA_ENDPOINT = os.getenv("PAPERLESS_TIKA_ENDPOINT", "http://localhost:9998")
|
TIKA_ENDPOINT = os.getenv("PAPERLESS_TIKA_ENDPOINT", "http://localhost:9998")
|
||||||
TIKA_GOTENBERG_ENDPOINT = os.getenv(
|
TIKA_GOTENBERG_ENDPOINT = os.getenv(
|
||||||
"PAPERLESS_TIKA_GOTENBERG_ENDPOINT",
|
"PAPERLESS_TIKA_GOTENBERG_ENDPOINT",
|
||||||
@@ -1149,7 +1286,7 @@ TIKA_GOTENBERG_ENDPOINT = os.getenv(
|
|||||||
if TIKA_ENABLED:
|
if TIKA_ENABLED:
|
||||||
INSTALLED_APPS.append("paperless_tika.apps.PaperlessTikaConfig")
|
INSTALLED_APPS.append("paperless_tika.apps.PaperlessTikaConfig")
|
||||||
|
|
||||||
AUDIT_LOG_ENABLED = get_bool_from_env("PAPERLESS_AUDIT_LOG_ENABLED", "true")
|
AUDIT_LOG_ENABLED = __get_boolean("PAPERLESS_AUDIT_LOG_ENABLED", "true")
|
||||||
if AUDIT_LOG_ENABLED:
|
if AUDIT_LOG_ENABLED:
|
||||||
INSTALLED_APPS.append("auditlog")
|
INSTALLED_APPS.append("auditlog")
|
||||||
MIDDLEWARE.append("auditlog.middleware.AuditlogMiddleware")
|
MIDDLEWARE.append("auditlog.middleware.AuditlogMiddleware")
|
||||||
@@ -1194,7 +1331,7 @@ if os.getenv("PAPERLESS_IGNORE_DATES") is not None:
|
|||||||
|
|
||||||
ENABLE_UPDATE_CHECK = os.getenv("PAPERLESS_ENABLE_UPDATE_CHECK", "default")
|
ENABLE_UPDATE_CHECK = os.getenv("PAPERLESS_ENABLE_UPDATE_CHECK", "default")
|
||||||
if ENABLE_UPDATE_CHECK != "default":
|
if ENABLE_UPDATE_CHECK != "default":
|
||||||
ENABLE_UPDATE_CHECK = get_bool_from_env("PAPERLESS_ENABLE_UPDATE_CHECK")
|
ENABLE_UPDATE_CHECK = __get_boolean("PAPERLESS_ENABLE_UPDATE_CHECK")
|
||||||
|
|
||||||
APP_TITLE = os.getenv("PAPERLESS_APP_TITLE", None)
|
APP_TITLE = os.getenv("PAPERLESS_APP_TITLE", None)
|
||||||
APP_LOGO = os.getenv("PAPERLESS_APP_LOGO", None)
|
APP_LOGO = os.getenv("PAPERLESS_APP_LOGO", None)
|
||||||
@@ -1239,7 +1376,7 @@ def _get_nltk_language_setting(ocr_lang: str) -> str | None:
|
|||||||
return iso_code_to_nltk.get(ocr_lang)
|
return iso_code_to_nltk.get(ocr_lang)
|
||||||
|
|
||||||
|
|
||||||
NLTK_ENABLED: Final[bool] = get_bool_from_env("PAPERLESS_ENABLE_NLTK", "yes")
|
NLTK_ENABLED: Final[bool] = __get_boolean("PAPERLESS_ENABLE_NLTK", "yes")
|
||||||
|
|
||||||
NLTK_LANGUAGE: str | None = _get_nltk_language_setting(OCR_LANGUAGE)
|
NLTK_LANGUAGE: str | None = _get_nltk_language_setting(OCR_LANGUAGE)
|
||||||
|
|
||||||
@@ -1248,7 +1385,7 @@ NLTK_LANGUAGE: str | None = _get_nltk_language_setting(OCR_LANGUAGE)
|
|||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
EMAIL_GNUPG_HOME: Final[str | None] = os.getenv("PAPERLESS_EMAIL_GNUPG_HOME")
|
EMAIL_GNUPG_HOME: Final[str | None] = os.getenv("PAPERLESS_EMAIL_GNUPG_HOME")
|
||||||
EMAIL_ENABLE_GPG_DECRYPTOR: Final[bool] = get_bool_from_env(
|
EMAIL_ENABLE_GPG_DECRYPTOR: Final[bool] = __get_boolean(
|
||||||
"PAPERLESS_ENABLE_GPG_DECRYPTOR",
|
"PAPERLESS_ENABLE_GPG_DECRYPTOR",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1256,7 +1393,7 @@ EMAIL_ENABLE_GPG_DECRYPTOR: Final[bool] = get_bool_from_env(
|
|||||||
###############################################################################
|
###############################################################################
|
||||||
# Soft Delete #
|
# Soft Delete #
|
||||||
###############################################################################
|
###############################################################################
|
||||||
EMPTY_TRASH_DELAY = max(get_int_from_env("PAPERLESS_EMPTY_TRASH_DELAY", 30), 1)
|
EMPTY_TRASH_DELAY = max(__get_int("PAPERLESS_EMPTY_TRASH_DELAY", 30), 1)
|
||||||
|
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
@@ -1283,19 +1420,19 @@ OUTLOOK_OAUTH_ENABLED = bool(
|
|||||||
###############################################################################
|
###############################################################################
|
||||||
WEBHOOKS_ALLOWED_SCHEMES = set(
|
WEBHOOKS_ALLOWED_SCHEMES = set(
|
||||||
s.lower()
|
s.lower()
|
||||||
for s in get_list_from_env(
|
for s in __get_list(
|
||||||
"PAPERLESS_WEBHOOKS_ALLOWED_SCHEMES",
|
"PAPERLESS_WEBHOOKS_ALLOWED_SCHEMES",
|
||||||
default=["http", "https"],
|
["http", "https"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
WEBHOOKS_ALLOWED_PORTS = set(
|
WEBHOOKS_ALLOWED_PORTS = set(
|
||||||
int(p)
|
int(p)
|
||||||
for p in get_list_from_env(
|
for p in __get_list(
|
||||||
"PAPERLESS_WEBHOOKS_ALLOWED_PORTS",
|
"PAPERLESS_WEBHOOKS_ALLOWED_PORTS",
|
||||||
default=[],
|
[],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
WEBHOOKS_ALLOW_INTERNAL_REQUESTS = get_bool_from_env(
|
WEBHOOKS_ALLOW_INTERNAL_REQUESTS = __get_boolean(
|
||||||
"PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS",
|
"PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS",
|
||||||
"true",
|
"true",
|
||||||
)
|
)
|
||||||
@@ -1310,7 +1447,7 @@ REMOTE_OCR_ENDPOINT = os.getenv("PAPERLESS_REMOTE_OCR_ENDPOINT")
|
|||||||
################################################################################
|
################################################################################
|
||||||
# AI Settings #
|
# AI Settings #
|
||||||
################################################################################
|
################################################################################
|
||||||
AI_ENABLED = get_bool_from_env("PAPERLESS_AI_ENABLED", "NO")
|
AI_ENABLED = __get_boolean("PAPERLESS_AI_ENABLED", "NO")
|
||||||
LLM_EMBEDDING_BACKEND = os.getenv(
|
LLM_EMBEDDING_BACKEND = os.getenv(
|
||||||
"PAPERLESS_AI_LLM_EMBEDDING_BACKEND",
|
"PAPERLESS_AI_LLM_EMBEDDING_BACKEND",
|
||||||
) # "huggingface" or "openai"
|
) # "huggingface" or "openai"
|
||||||
@@ -1,278 +0,0 @@
|
|||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import TypeAlias
|
|
||||||
|
|
||||||
from celery.schedules import crontab
|
|
||||||
|
|
||||||
from paperless.settings.parsers import get_choice_from_env
|
|
||||||
from paperless.settings.parsers import get_int_from_env
|
|
||||||
from paperless.settings.parsers import parse_dict_from_str
|
|
||||||
|
|
||||||
# Covers: ENGINE/NAME/HOST/USER/PASSWORD (str), PORT (int), OPTIONS (dict)
|
|
||||||
DatabaseConfig: TypeAlias = dict[str, str | int | dict[str, str | int | dict | None]]
|
|
||||||
|
|
||||||
|
|
||||||
def parse_hosting_settings() -> tuple[str | None, str, str, str, str]:
|
|
||||||
script_name = os.getenv("PAPERLESS_FORCE_SCRIPT_NAME")
|
|
||||||
base_url = (script_name or "") + "/"
|
|
||||||
login_url = base_url + "accounts/login/"
|
|
||||||
login_redirect_url = base_url + "dashboard"
|
|
||||||
logout_redirect_url = os.getenv(
|
|
||||||
"PAPERLESS_LOGOUT_REDIRECT_URL",
|
|
||||||
login_url + "?loggedout=1",
|
|
||||||
)
|
|
||||||
return script_name, base_url, login_url, login_redirect_url, logout_redirect_url
|
|
||||||
|
|
||||||
|
|
||||||
def parse_redis_url(env_redis: str | None) -> tuple[str, str]:
|
|
||||||
"""
|
|
||||||
Gets the Redis information from the environment or a default and handles
|
|
||||||
converting from incompatible django_channels and celery formats.
|
|
||||||
|
|
||||||
Returns a tuple of (celery_url, channels_url)
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Not set, return a compatible default
|
|
||||||
if env_redis is None:
|
|
||||||
return ("redis://localhost:6379", "redis://localhost:6379")
|
|
||||||
|
|
||||||
if "unix" in env_redis.lower():
|
|
||||||
# channels_redis socket format, looks like:
|
|
||||||
# "unix:///path/to/redis.sock"
|
|
||||||
_, path = env_redis.split(":")
|
|
||||||
# Optionally setting a db number
|
|
||||||
if "?db=" in env_redis:
|
|
||||||
path, number = path.split("?db=")
|
|
||||||
return (f"redis+socket:{path}?virtual_host={number}", env_redis)
|
|
||||||
else:
|
|
||||||
return (f"redis+socket:{path}", env_redis)
|
|
||||||
|
|
||||||
elif "+socket" in env_redis.lower():
|
|
||||||
# celery socket style, looks like:
|
|
||||||
# "redis+socket:///path/to/redis.sock"
|
|
||||||
_, path = env_redis.split(":")
|
|
||||||
if "?virtual_host=" in env_redis:
|
|
||||||
# Virtual host (aka db number)
|
|
||||||
path, number = path.split("?virtual_host=")
|
|
||||||
return (env_redis, f"unix:{path}?db={number}")
|
|
||||||
else:
|
|
||||||
return (env_redis, f"unix:{path}")
|
|
||||||
|
|
||||||
# Not a socket
|
|
||||||
return (env_redis, env_redis)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_beat_schedule() -> dict:
|
|
||||||
"""
|
|
||||||
Configures the scheduled tasks, according to default or
|
|
||||||
environment variables. Task expiration is configured so the task will
|
|
||||||
expire (and not run), shortly before the default frequency will put another
|
|
||||||
of the same task into the queue
|
|
||||||
|
|
||||||
|
|
||||||
https://docs.celeryq.dev/en/stable/userguide/periodic-tasks.html#beat-entries
|
|
||||||
https://docs.celeryq.dev/en/latest/userguide/calling.html#expiration
|
|
||||||
"""
|
|
||||||
schedule = {}
|
|
||||||
tasks = [
|
|
||||||
{
|
|
||||||
"name": "Check all e-mail accounts",
|
|
||||||
"env_key": "PAPERLESS_EMAIL_TASK_CRON",
|
|
||||||
# Default every ten minutes
|
|
||||||
"env_default": "*/10 * * * *",
|
|
||||||
"task": "paperless_mail.tasks.process_mail_accounts",
|
|
||||||
"options": {
|
|
||||||
# 1 minute before default schedule sends again
|
|
||||||
"expires": 9.0 * 60.0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Train the classifier",
|
|
||||||
"env_key": "PAPERLESS_TRAIN_TASK_CRON",
|
|
||||||
# Default hourly at 5 minutes past the hour
|
|
||||||
"env_default": "5 */1 * * *",
|
|
||||||
"task": "documents.tasks.train_classifier",
|
|
||||||
"options": {
|
|
||||||
# 1 minute before default schedule sends again
|
|
||||||
"expires": 59.0 * 60.0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Optimize the index",
|
|
||||||
"env_key": "PAPERLESS_INDEX_TASK_CRON",
|
|
||||||
# Default daily at midnight
|
|
||||||
"env_default": "0 0 * * *",
|
|
||||||
"task": "documents.tasks.index_optimize",
|
|
||||||
"options": {
|
|
||||||
# 1 hour before default schedule sends again
|
|
||||||
"expires": 23.0 * 60.0 * 60.0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Perform sanity check",
|
|
||||||
"env_key": "PAPERLESS_SANITY_TASK_CRON",
|
|
||||||
# Default Sunday at 00:30
|
|
||||||
"env_default": "30 0 * * sun",
|
|
||||||
"task": "documents.tasks.sanity_check",
|
|
||||||
"options": {
|
|
||||||
# 1 hour before default schedule sends again
|
|
||||||
"expires": ((7.0 * 24.0) - 1.0) * 60.0 * 60.0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Empty trash",
|
|
||||||
"env_key": "PAPERLESS_EMPTY_TRASH_TASK_CRON",
|
|
||||||
# Default daily at 01:00
|
|
||||||
"env_default": "0 1 * * *",
|
|
||||||
"task": "documents.tasks.empty_trash",
|
|
||||||
"options": {
|
|
||||||
# 1 hour before default schedule sends again
|
|
||||||
"expires": 23.0 * 60.0 * 60.0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Check and run scheduled workflows",
|
|
||||||
"env_key": "PAPERLESS_WORKFLOW_SCHEDULED_TASK_CRON",
|
|
||||||
# Default hourly at 5 minutes past the hour
|
|
||||||
"env_default": "5 */1 * * *",
|
|
||||||
"task": "documents.tasks.check_scheduled_workflows",
|
|
||||||
"options": {
|
|
||||||
# 1 minute before default schedule sends again
|
|
||||||
"expires": 59.0 * 60.0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
for task in tasks:
|
|
||||||
# Either get the environment setting or use the default
|
|
||||||
value = os.getenv(task["env_key"], task["env_default"])
|
|
||||||
# Don't add disabled tasks to the schedule
|
|
||||||
if value == "disable":
|
|
||||||
continue
|
|
||||||
# I find https://crontab.guru/ super helpful
|
|
||||||
# crontab(5) format
|
|
||||||
# - five time-and-date fields
|
|
||||||
# - separated by at least one blank
|
|
||||||
minute, hour, day_month, month, day_week = value.split(" ")
|
|
||||||
|
|
||||||
schedule[task["name"]] = {
|
|
||||||
"task": task["task"],
|
|
||||||
"schedule": crontab(minute, hour, day_week, day_month, month),
|
|
||||||
"options": task["options"],
|
|
||||||
}
|
|
||||||
|
|
||||||
return schedule
|
|
||||||
|
|
||||||
|
|
||||||
def parse_db_settings(data_dir: Path) -> dict[str, DatabaseConfig]:
|
|
||||||
"""Parse database settings from environment variables.
|
|
||||||
|
|
||||||
Core connection variables (no deprecation):
|
|
||||||
- PAPERLESS_DBENGINE (sqlite/postgresql/mariadb)
|
|
||||||
- PAPERLESS_DBHOST, PAPERLESS_DBPORT
|
|
||||||
- PAPERLESS_DBNAME, PAPERLESS_DBUSER, PAPERLESS_DBPASS
|
|
||||||
|
|
||||||
Advanced options can be set via:
|
|
||||||
- Legacy individual env vars (deprecated in v3.0, removed in v3.2)
|
|
||||||
- PAPERLESS_DB_OPTIONS (recommended v3+ approach)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
data_dir: The data directory path for SQLite database location.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A databases dict suitable for Django DATABASES setting.
|
|
||||||
"""
|
|
||||||
engine = get_choice_from_env(
|
|
||||||
"PAPERLESS_DBENGINE",
|
|
||||||
{"sqlite", "postgresql", "mariadb"},
|
|
||||||
default="sqlite",
|
|
||||||
)
|
|
||||||
|
|
||||||
match engine:
|
|
||||||
case "sqlite":
|
|
||||||
db_config = {
|
|
||||||
"ENGINE": "django.db.backends.sqlite3",
|
|
||||||
"NAME": str((data_dir / "db.sqlite3").resolve()),
|
|
||||||
}
|
|
||||||
base_options = {}
|
|
||||||
|
|
||||||
case "postgresql":
|
|
||||||
db_config = {
|
|
||||||
"ENGINE": "django.db.backends.postgresql",
|
|
||||||
"HOST": os.getenv("PAPERLESS_DBHOST"),
|
|
||||||
"NAME": os.getenv("PAPERLESS_DBNAME", "paperless"),
|
|
||||||
"USER": os.getenv("PAPERLESS_DBUSER", "paperless"),
|
|
||||||
"PASSWORD": os.getenv("PAPERLESS_DBPASS", "paperless"),
|
|
||||||
}
|
|
||||||
|
|
||||||
base_options = {
|
|
||||||
"sslmode": os.getenv("PAPERLESS_DBSSLMODE", "prefer"),
|
|
||||||
"sslrootcert": os.getenv("PAPERLESS_DBSSLROOTCERT"),
|
|
||||||
"sslcert": os.getenv("PAPERLESS_DBSSLCERT"),
|
|
||||||
"sslkey": os.getenv("PAPERLESS_DBSSLKEY"),
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pool_size := get_int_from_env("PAPERLESS_DB_POOLSIZE")) is not None:
|
|
||||||
base_options["pool"] = {
|
|
||||||
"min_size": 1,
|
|
||||||
"max_size": pool_size,
|
|
||||||
}
|
|
||||||
|
|
||||||
case "mariadb":
|
|
||||||
db_config = {
|
|
||||||
"ENGINE": "django.db.backends.mysql",
|
|
||||||
"HOST": os.getenv("PAPERLESS_DBHOST"),
|
|
||||||
"NAME": os.getenv("PAPERLESS_DBNAME", "paperless"),
|
|
||||||
"USER": os.getenv("PAPERLESS_DBUSER", "paperless"),
|
|
||||||
"PASSWORD": os.getenv("PAPERLESS_DBPASS", "paperless"),
|
|
||||||
}
|
|
||||||
|
|
||||||
base_options = {
|
|
||||||
"read_default_file": "/etc/mysql/my.cnf",
|
|
||||||
"charset": "utf8mb4",
|
|
||||||
"collation": "utf8mb4_unicode_ci",
|
|
||||||
"ssl_mode": os.getenv("PAPERLESS_DBSSLMODE", "PREFERRED"),
|
|
||||||
"ssl": {
|
|
||||||
"ca": os.getenv("PAPERLESS_DBSSLROOTCERT"),
|
|
||||||
"cert": os.getenv("PAPERLESS_DBSSLCERT"),
|
|
||||||
"key": os.getenv("PAPERLESS_DBSSLKEY"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
# Handle port setting for external databases
|
|
||||||
if (
|
|
||||||
engine in ("postgresql", "mariadb")
|
|
||||||
and (port := get_int_from_env("PAPERLESS_DBPORT")) is not None
|
|
||||||
):
|
|
||||||
db_config["PORT"] = port
|
|
||||||
|
|
||||||
# Handle timeout setting (common across all engines, different key names)
|
|
||||||
if (timeout := get_int_from_env("PAPERLESS_DB_TIMEOUT")) is not None:
|
|
||||||
timeout_key = "timeout" if engine == "sqlite" else "connect_timeout"
|
|
||||||
base_options[timeout_key] = timeout
|
|
||||||
|
|
||||||
# Apply PAPERLESS_DB_OPTIONS overrides
|
|
||||||
db_config["OPTIONS"] = parse_dict_from_str(
|
|
||||||
os.getenv("PAPERLESS_DB_OPTIONS"),
|
|
||||||
defaults=base_options,
|
|
||||||
type_map={
|
|
||||||
# SQLite options
|
|
||||||
"timeout": int,
|
|
||||||
# Postgres/MariaDB options
|
|
||||||
"connect_timeout": int,
|
|
||||||
"pool.min_size": int,
|
|
||||||
"pool.max_size": int,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
databases = {"default": db_config}
|
|
||||||
|
|
||||||
# Add SQLite fallback for PostgreSQL/MariaDB
|
|
||||||
# TODO: Is this really useful/used?
|
|
||||||
if engine in ("postgresql", "mariadb"):
|
|
||||||
databases["sqlite"] = {
|
|
||||||
"ENGINE": "django.db.backends.sqlite3",
|
|
||||||
"NAME": str((data_dir / "db.sqlite3").resolve()),
|
|
||||||
"OPTIONS": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
return databases
|
|
||||||
@@ -1,294 +0,0 @@
|
|||||||
import copy
|
|
||||||
import os
|
|
||||||
from collections.abc import Callable
|
|
||||||
from collections.abc import Mapping
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
from typing import TypeVar
|
|
||||||
from typing import overload
|
|
||||||
|
|
||||||
T = TypeVar("T")
|
|
||||||
|
|
||||||
|
|
||||||
def str_to_bool(value: str) -> bool:
|
|
||||||
"""
|
|
||||||
Converts a string representation of truth to a boolean value.
|
|
||||||
|
|
||||||
Recognizes 'true', '1', 't', 'y', 'yes' as True, and
|
|
||||||
'false', '0', 'f', 'n', 'no' as False. Case-insensitive.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
value: The string to convert.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The boolean representation of the string.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If the string is not a recognized boolean value.
|
|
||||||
"""
|
|
||||||
val_lower = value.strip().lower()
|
|
||||||
if val_lower in ("true", "1", "t", "y", "yes"):
|
|
||||||
return True
|
|
||||||
elif val_lower in ("false", "0", "f", "n", "no"):
|
|
||||||
return False
|
|
||||||
raise ValueError(f"Cannot convert '{value}' to a boolean.")
|
|
||||||
|
|
||||||
|
|
||||||
def parse_dict_from_str(
|
|
||||||
env_str: str | None,
|
|
||||||
defaults: dict[str, Any] | None = None,
|
|
||||||
type_map: Mapping[str, Callable[[str], Any]] | None = None,
|
|
||||||
separator: str = ",",
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Parses a key-value string into a dictionary, applying defaults and casting types.
|
|
||||||
|
|
||||||
Supports nested keys via dot-notation, e.g.:
|
|
||||||
"database.host=localhost,database.port=5432"
|
|
||||||
|
|
||||||
Args:
|
|
||||||
env_str: The string from the environment variable (e.g., "port=9090,debug=true").
|
|
||||||
defaults: A dictionary of default values (can contain nested dicts).
|
|
||||||
type_map: A dictionary mapping keys (dot-notation allowed) to a type or a parsing
|
|
||||||
function (e.g., {'port': int, 'debug': bool, 'database.port': int}).
|
|
||||||
The special `bool` type triggers custom boolean parsing.
|
|
||||||
separator: The character used to separate key-value pairs. Defaults to ','.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A dictionary with the parsed and correctly-typed settings.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If a value cannot be cast to its specified type.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _set_nested(d: dict, keys: list[str], value: Any) -> None:
|
|
||||||
"""Set a nested value, creating intermediate dicts as needed."""
|
|
||||||
cur = d
|
|
||||||
for k in keys[:-1]:
|
|
||||||
if k not in cur or not isinstance(cur[k], dict):
|
|
||||||
cur[k] = {}
|
|
||||||
cur = cur[k]
|
|
||||||
cur[keys[-1]] = value
|
|
||||||
|
|
||||||
def _get_nested(d: dict, keys: list[str]) -> Any:
|
|
||||||
"""Get nested value or raise KeyError if not present."""
|
|
||||||
cur = d
|
|
||||||
for k in keys:
|
|
||||||
if not isinstance(cur, dict) or k not in cur:
|
|
||||||
raise KeyError
|
|
||||||
cur = cur[k]
|
|
||||||
return cur
|
|
||||||
|
|
||||||
def _has_nested(d: dict, keys: list[str]) -> bool:
|
|
||||||
try:
|
|
||||||
_get_nested(d, keys)
|
|
||||||
return True
|
|
||||||
except KeyError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
settings: dict[str, Any] = copy.deepcopy(defaults) if defaults else {}
|
|
||||||
_type_map = type_map if type_map else {}
|
|
||||||
|
|
||||||
if not env_str:
|
|
||||||
return settings
|
|
||||||
|
|
||||||
# Parse the environment string using the specified separator
|
|
||||||
pairs = [p.strip() for p in env_str.split(separator) if p.strip()]
|
|
||||||
for pair in pairs:
|
|
||||||
if "=" not in pair:
|
|
||||||
# ignore malformed pairs
|
|
||||||
continue
|
|
||||||
key, val = pair.split("=", 1)
|
|
||||||
key = key.strip()
|
|
||||||
val = val.strip()
|
|
||||||
if not key:
|
|
||||||
continue
|
|
||||||
parts = key.split(".")
|
|
||||||
_set_nested(settings, parts, val)
|
|
||||||
|
|
||||||
# Apply type casting to the updated settings (supports nested keys in type_map)
|
|
||||||
for key, caster in _type_map.items():
|
|
||||||
key_parts = key.split(".")
|
|
||||||
if _has_nested(settings, key_parts):
|
|
||||||
raw_val = _get_nested(settings, key_parts)
|
|
||||||
# Only cast if it's a string (i.e. from env parsing). If defaults already provided
|
|
||||||
# a different type we leave it as-is.
|
|
||||||
if isinstance(raw_val, str):
|
|
||||||
try:
|
|
||||||
if caster is bool:
|
|
||||||
parsed = str_to_bool(raw_val)
|
|
||||||
elif caster is Path:
|
|
||||||
parsed = Path(raw_val).resolve()
|
|
||||||
else:
|
|
||||||
parsed = caster(raw_val)
|
|
||||||
except (ValueError, TypeError) as e:
|
|
||||||
caster_name = getattr(caster, "__name__", repr(caster))
|
|
||||||
raise ValueError(
|
|
||||||
f"Error casting key '{key}' with value '{raw_val}' "
|
|
||||||
f"to type '{caster_name}'",
|
|
||||||
) from e
|
|
||||||
_set_nested(settings, key_parts, parsed)
|
|
||||||
|
|
||||||
return settings
|
|
||||||
|
|
||||||
|
|
||||||
def get_bool_from_env(key: str, default: str = "NO") -> bool:
|
|
||||||
"""
|
|
||||||
Return a boolean value based on whatever the user has supplied in the
|
|
||||||
environment based on whether the value "looks like" it's True or not.
|
|
||||||
"""
|
|
||||||
return str_to_bool(os.getenv(key, default))
|
|
||||||
|
|
||||||
|
|
||||||
@overload
|
|
||||||
def get_int_from_env(key: str) -> int | None: ...
|
|
||||||
|
|
||||||
|
|
||||||
@overload
|
|
||||||
def get_int_from_env(key: str, default: None) -> int | None: ...
|
|
||||||
|
|
||||||
|
|
||||||
@overload
|
|
||||||
def get_int_from_env(key: str, default: int) -> int: ...
|
|
||||||
|
|
||||||
|
|
||||||
def get_int_from_env(key: str, default: int | None = None) -> int | None:
|
|
||||||
"""
|
|
||||||
Return an integer value based on the environment variable.
|
|
||||||
If default is provided, returns that value when key is missing.
|
|
||||||
If default is None, returns None when key is missing.
|
|
||||||
"""
|
|
||||||
if key not in os.environ:
|
|
||||||
return default
|
|
||||||
|
|
||||||
return int(os.environ[key])
|
|
||||||
|
|
||||||
|
|
||||||
@overload
|
|
||||||
def get_float_from_env(key: str) -> float | None: ...
|
|
||||||
|
|
||||||
|
|
||||||
@overload
|
|
||||||
def get_float_from_env(key: str, default: None) -> float | None: ...
|
|
||||||
|
|
||||||
|
|
||||||
@overload
|
|
||||||
def get_float_from_env(key: str, default: float) -> float: ...
|
|
||||||
|
|
||||||
|
|
||||||
def get_float_from_env(key: str, default: float | None = None) -> float | None:
|
|
||||||
"""
|
|
||||||
Return a float value based on the environment variable.
|
|
||||||
If default is provided, returns that value when key is missing.
|
|
||||||
If default is None, returns None when key is missing.
|
|
||||||
"""
|
|
||||||
if key not in os.environ:
|
|
||||||
return default
|
|
||||||
|
|
||||||
return float(os.environ[key])
|
|
||||||
|
|
||||||
|
|
||||||
@overload
|
|
||||||
def get_path_from_env(key: str) -> Path | None: ...
|
|
||||||
|
|
||||||
|
|
||||||
@overload
|
|
||||||
def get_path_from_env(key: str, default: None) -> Path | None: ...
|
|
||||||
|
|
||||||
|
|
||||||
@overload
|
|
||||||
def get_path_from_env(key: str, default: Path | str) -> Path: ...
|
|
||||||
|
|
||||||
|
|
||||||
def get_path_from_env(key: str, default: Path | str | None = None) -> Path | None:
|
|
||||||
"""
|
|
||||||
Return a Path object based on the environment variable.
|
|
||||||
If default is provided, returns that value when key is missing.
|
|
||||||
If default is None, returns None when key is missing.
|
|
||||||
"""
|
|
||||||
if key not in os.environ:
|
|
||||||
return default if default is None else Path(default).resolve()
|
|
||||||
|
|
||||||
return Path(os.environ[key]).resolve()
|
|
||||||
|
|
||||||
|
|
||||||
def get_list_from_env(
|
|
||||||
key: str,
|
|
||||||
separator: str = ",",
|
|
||||||
default: list[T] | None = None,
|
|
||||||
*,
|
|
||||||
strip_whitespace: bool = True,
|
|
||||||
remove_empty: bool = True,
|
|
||||||
required: bool = False,
|
|
||||||
) -> list[str] | list[T]:
|
|
||||||
"""
|
|
||||||
Get and parse a list from an environment variable or return a default.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
key: Environment variable name
|
|
||||||
separator: Character(s) to split on (default: ',')
|
|
||||||
default: Default value to return if env var is not set or empty
|
|
||||||
strip_whitespace: Whether to strip whitespace from each element
|
|
||||||
remove_empty: Whether to remove empty strings from the result
|
|
||||||
required: If True, raise an error when the env var is missing and no default provided
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of strings, the default if env var is empty/None or an empty list
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If required=True and env var is missing and there is no default
|
|
||||||
"""
|
|
||||||
# Get the environment variable value
|
|
||||||
env_value = os.environ.get(key)
|
|
||||||
|
|
||||||
# Handle required environment variables
|
|
||||||
if required and env_value is None and default is None:
|
|
||||||
raise ValueError(f"Required environment variable '{key}' is not set")
|
|
||||||
|
|
||||||
if env_value:
|
|
||||||
items = env_value.split(separator)
|
|
||||||
if strip_whitespace:
|
|
||||||
items = [item.strip() for item in items]
|
|
||||||
if remove_empty:
|
|
||||||
items = [item for item in items if item]
|
|
||||||
return items
|
|
||||||
elif default is not None:
|
|
||||||
return default
|
|
||||||
else:
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def get_choice_from_env(
|
|
||||||
env_key: str,
|
|
||||||
choices: set[str],
|
|
||||||
default: str | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""
|
|
||||||
Gets and validates an environment variable against a set of allowed choices.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
env_key: The environment variable key to validate
|
|
||||||
choices: Set of valid choices for the environment variable
|
|
||||||
default: Optional default value if environment variable is not set
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The validated environment variable value
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If the environment variable value is not in choices
|
|
||||||
or if no default is provided and env var is missing
|
|
||||||
"""
|
|
||||||
value = os.environ.get(env_key, default)
|
|
||||||
|
|
||||||
if value is None:
|
|
||||||
raise ValueError(
|
|
||||||
f"Environment variable '{env_key}' is required but not set.",
|
|
||||||
)
|
|
||||||
|
|
||||||
if value not in choices:
|
|
||||||
raise ValueError(
|
|
||||||
f"Environment variable '{env_key}' has invalid value '{value}'. "
|
|
||||||
f"Valid choices are: {', '.join(sorted(choices))}",
|
|
||||||
)
|
|
||||||
|
|
||||||
return value
|
|
||||||
@@ -2,16 +2,13 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
from pytest_mock import MockerFixture
|
|
||||||
|
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from documents.tests.utils import DirectoriesMixin
|
||||||
from documents.tests.utils import FileSystemAssertsMixin
|
from documents.tests.utils import FileSystemAssertsMixin
|
||||||
from paperless.checks import audit_log_check
|
from paperless.checks import audit_log_check
|
||||||
from paperless.checks import binaries_check
|
from paperless.checks import binaries_check
|
||||||
from paperless.checks import check_deprecated_db_settings
|
|
||||||
from paperless.checks import debug_mode_check
|
from paperless.checks import debug_mode_check
|
||||||
from paperless.checks import paths_check
|
from paperless.checks import paths_check
|
||||||
from paperless.checks import settings_values_check
|
from paperless.checks import settings_values_check
|
||||||
@@ -240,67 +237,3 @@ class TestAuditLogChecks(TestCase):
|
|||||||
("auditlog table was found but audit log is disabled."),
|
("auditlog table was found but audit log is disabled."),
|
||||||
msg.msg,
|
msg.msg,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestDeprecatedDbSettings:
|
|
||||||
"""Test suite for deprecated database settings system check."""
|
|
||||||
|
|
||||||
def test_no_deprecated_vars_no_warning(
|
|
||||||
self,
|
|
||||||
mocker: MockerFixture,
|
|
||||||
) -> None:
|
|
||||||
"""Test that no warning is raised when no deprecated vars are set."""
|
|
||||||
mocker.patch.dict(os.environ, {}, clear=True)
|
|
||||||
|
|
||||||
warnings = check_deprecated_db_settings(None)
|
|
||||||
assert warnings == []
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("env_var", "expected_hint_fragment"),
|
|
||||||
[
|
|
||||||
("PAPERLESS_DB_TIMEOUT", "timeout"),
|
|
||||||
("PAPERLESS_DB_POOLSIZE", "pool.min_size,pool.max_size"),
|
|
||||||
("PAPERLESS_DBSSLMODE", "sslmode"),
|
|
||||||
("PAPERLESS_DBSSLROOTCERT", "sslrootcert"),
|
|
||||||
("PAPERLESS_DBSSLCERT", "sslcert"),
|
|
||||||
("PAPERLESS_DBSSLKEY", "sslkey"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_deprecated_var_triggers_warning(
|
|
||||||
self,
|
|
||||||
mocker: MockerFixture,
|
|
||||||
env_var: str,
|
|
||||||
expected_hint_fragment: str,
|
|
||||||
) -> None:
|
|
||||||
"""Test that each deprecated var triggers appropriate warning."""
|
|
||||||
mocker.patch.dict(os.environ, {env_var: "some_value"}, clear=True)
|
|
||||||
|
|
||||||
warnings = check_deprecated_db_settings(None)
|
|
||||||
|
|
||||||
assert len(warnings) == 1
|
|
||||||
assert warnings[0].id == "paperless.W001"
|
|
||||||
assert env_var in warnings[0].hint
|
|
||||||
assert expected_hint_fragment in warnings[0].hint
|
|
||||||
assert "v3.2" in warnings[0].hint
|
|
||||||
|
|
||||||
def test_multiple_deprecated_vars(
|
|
||||||
self,
|
|
||||||
mocker: MockerFixture,
|
|
||||||
) -> None:
|
|
||||||
"""Test that multiple deprecated vars are all listed in warning."""
|
|
||||||
mocker.patch.dict(
|
|
||||||
os.environ,
|
|
||||||
{
|
|
||||||
"PAPERLESS_DB_TIMEOUT": "30",
|
|
||||||
"PAPERLESS_DB_POOLSIZE": "10",
|
|
||||||
"PAPERLESS_DBSSLMODE": "require",
|
|
||||||
},
|
|
||||||
clear=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
warnings = check_deprecated_db_settings(None)
|
|
||||||
|
|
||||||
assert len(warnings) == 1
|
|
||||||
assert "PAPERLESS_DB_TIMEOUT" in warnings[0].hint
|
|
||||||
assert "PAPERLESS_DB_POOLSIZE" in warnings[0].hint
|
|
||||||
assert "PAPERLESS_DBSSLMODE" in warnings[0].hint
|
|
||||||
|
|||||||
@@ -1,21 +1,19 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
|
||||||
from unittest import TestCase
|
from unittest import TestCase
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from celery.schedules import crontab
|
from celery.schedules import crontab
|
||||||
from pytest_mock import MockerFixture
|
|
||||||
|
|
||||||
from paperless.settings import _parse_base_paths
|
from paperless.settings import _parse_base_paths
|
||||||
from paperless.settings import _parse_beat_schedule
|
from paperless.settings import _parse_beat_schedule
|
||||||
from paperless.settings import _parse_dateparser_languages
|
from paperless.settings import _parse_dateparser_languages
|
||||||
|
from paperless.settings import _parse_db_settings
|
||||||
from paperless.settings import _parse_ignore_dates
|
from paperless.settings import _parse_ignore_dates
|
||||||
from paperless.settings import _parse_paperless_url
|
from paperless.settings import _parse_paperless_url
|
||||||
from paperless.settings import _parse_redis_url
|
from paperless.settings import _parse_redis_url
|
||||||
from paperless.settings import default_threads_per_worker
|
from paperless.settings import default_threads_per_worker
|
||||||
from paperless.settings.custom import parse_db_settings
|
|
||||||
|
|
||||||
|
|
||||||
class TestIgnoreDateParsing(TestCase):
|
class TestIgnoreDateParsing(TestCase):
|
||||||
@@ -380,302 +378,62 @@ class TestCeleryScheduleParsing(TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestParseDbSettings:
|
class TestDBSettings(TestCase):
|
||||||
"""Test suite for parse_db_settings function."""
|
def test_db_timeout_with_sqlite(self) -> None:
|
||||||
|
"""
|
||||||
@pytest.mark.parametrize(
|
GIVEN:
|
||||||
("env_vars", "expected_database_settings"),
|
- PAPERLESS_DB_TIMEOUT is set
|
||||||
[
|
WHEN:
|
||||||
pytest.param(
|
- Settings are parsed
|
||||||
{},
|
THEN:
|
||||||
{
|
- PAPERLESS_DB_TIMEOUT set for sqlite
|
||||||
"default": {
|
"""
|
||||||
"ENGINE": "django.db.backends.sqlite3",
|
with mock.patch.dict(
|
||||||
"NAME": None, # Will be replaced with tmp_path
|
os.environ,
|
||||||
"OPTIONS": {},
|
{
|
||||||
},
|
"PAPERLESS_DB_TIMEOUT": "10",
|
||||||
},
|
},
|
||||||
id="default-sqlite",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
{
|
|
||||||
"PAPERLESS_DBENGINE": "sqlite",
|
|
||||||
"PAPERLESS_DB_OPTIONS": "timeout=30",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"default": {
|
|
||||||
"ENGINE": "django.db.backends.sqlite3",
|
|
||||||
"NAME": None, # Will be replaced with tmp_path
|
|
||||||
"OPTIONS": {
|
|
||||||
"timeout": 30,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
id="sqlite-with-timeout-override",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
{
|
|
||||||
"PAPERLESS_DBENGINE": "postgresql",
|
|
||||||
"PAPERLESS_DBHOST": "localhost",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"default": {
|
|
||||||
"ENGINE": "django.db.backends.postgresql",
|
|
||||||
"HOST": "localhost",
|
|
||||||
"NAME": "paperless",
|
|
||||||
"USER": "paperless",
|
|
||||||
"PASSWORD": "paperless",
|
|
||||||
"OPTIONS": {
|
|
||||||
"sslmode": "prefer",
|
|
||||||
"sslrootcert": None,
|
|
||||||
"sslcert": None,
|
|
||||||
"sslkey": None,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"sqlite": {
|
|
||||||
"ENGINE": "django.db.backends.sqlite3",
|
|
||||||
"NAME": None, # Will be replaced with tmp_path
|
|
||||||
"OPTIONS": {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
id="postgresql-defaults",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
{
|
|
||||||
"PAPERLESS_DBENGINE": "postgresql",
|
|
||||||
"PAPERLESS_DBHOST": "paperless-db-host",
|
|
||||||
"PAPERLESS_DBPORT": "1111",
|
|
||||||
"PAPERLESS_DBNAME": "customdb",
|
|
||||||
"PAPERLESS_DBUSER": "customuser",
|
|
||||||
"PAPERLESS_DBPASS": "custompass",
|
|
||||||
"PAPERLESS_DB_OPTIONS": "pool.max_size=50,pool.min_size=2,sslmode=require",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"default": {
|
|
||||||
"ENGINE": "django.db.backends.postgresql",
|
|
||||||
"HOST": "paperless-db-host",
|
|
||||||
"PORT": 1111,
|
|
||||||
"NAME": "customdb",
|
|
||||||
"USER": "customuser",
|
|
||||||
"PASSWORD": "custompass",
|
|
||||||
"OPTIONS": {
|
|
||||||
"sslmode": "require",
|
|
||||||
"sslrootcert": None,
|
|
||||||
"sslcert": None,
|
|
||||||
"sslkey": None,
|
|
||||||
"pool": {
|
|
||||||
"min_size": 2,
|
|
||||||
"max_size": 50,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"sqlite": {
|
|
||||||
"ENGINE": "django.db.backends.sqlite3",
|
|
||||||
"NAME": None, # Will be replaced with tmp_path
|
|
||||||
"OPTIONS": {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
id="postgresql-overrides",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
{
|
|
||||||
"PAPERLESS_DBENGINE": "postgresql",
|
|
||||||
"PAPERLESS_DBHOST": "pghost",
|
|
||||||
"PAPERLESS_DB_POOLSIZE": "10",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"default": {
|
|
||||||
"ENGINE": "django.db.backends.postgresql",
|
|
||||||
"HOST": "pghost",
|
|
||||||
"NAME": "paperless",
|
|
||||||
"USER": "paperless",
|
|
||||||
"PASSWORD": "paperless",
|
|
||||||
"OPTIONS": {
|
|
||||||
"sslmode": "prefer",
|
|
||||||
"sslrootcert": None,
|
|
||||||
"sslcert": None,
|
|
||||||
"sslkey": None,
|
|
||||||
"pool": {
|
|
||||||
"min_size": 1,
|
|
||||||
"max_size": 10,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"sqlite": {
|
|
||||||
"ENGINE": "django.db.backends.sqlite3",
|
|
||||||
"NAME": None, # Will be replaced with tmp_path
|
|
||||||
"OPTIONS": {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
id="postgresql-legacy-poolsize",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
{
|
|
||||||
"PAPERLESS_DBENGINE": "postgresql",
|
|
||||||
"PAPERLESS_DBHOST": "pghost",
|
|
||||||
"PAPERLESS_DBSSLMODE": "require",
|
|
||||||
"PAPERLESS_DBSSLROOTCERT": "/certs/ca.crt",
|
|
||||||
"PAPERLESS_DB_TIMEOUT": "30",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"default": {
|
|
||||||
"ENGINE": "django.db.backends.postgresql",
|
|
||||||
"HOST": "pghost",
|
|
||||||
"NAME": "paperless",
|
|
||||||
"USER": "paperless",
|
|
||||||
"PASSWORD": "paperless",
|
|
||||||
"OPTIONS": {
|
|
||||||
"sslmode": "require",
|
|
||||||
"sslrootcert": "/certs/ca.crt",
|
|
||||||
"sslcert": None,
|
|
||||||
"sslkey": None,
|
|
||||||
"connect_timeout": 30,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"sqlite": {
|
|
||||||
"ENGINE": "django.db.backends.sqlite3",
|
|
||||||
"NAME": None, # Will be replaced with tmp_path
|
|
||||||
"OPTIONS": {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
id="postgresql-legacy-ssl-and-timeout",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
{
|
|
||||||
"PAPERLESS_DBENGINE": "mariadb",
|
|
||||||
"PAPERLESS_DBHOST": "localhost",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"default": {
|
|
||||||
"ENGINE": "django.db.backends.mysql",
|
|
||||||
"HOST": "localhost",
|
|
||||||
"NAME": "paperless",
|
|
||||||
"USER": "paperless",
|
|
||||||
"PASSWORD": "paperless",
|
|
||||||
"OPTIONS": {
|
|
||||||
"read_default_file": "/etc/mysql/my.cnf",
|
|
||||||
"charset": "utf8mb4",
|
|
||||||
"collation": "utf8mb4_unicode_ci",
|
|
||||||
"ssl_mode": "PREFERRED",
|
|
||||||
"ssl": {
|
|
||||||
"ca": None,
|
|
||||||
"cert": None,
|
|
||||||
"key": None,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"sqlite": {
|
|
||||||
"ENGINE": "django.db.backends.sqlite3",
|
|
||||||
"NAME": None, # Will be replaced with tmp_path
|
|
||||||
"OPTIONS": {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
id="mariadb-defaults",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
{
|
|
||||||
"PAPERLESS_DBENGINE": "mariadb",
|
|
||||||
"PAPERLESS_DBHOST": "paperless-mariadb-host",
|
|
||||||
"PAPERLESS_DBPORT": "5555",
|
|
||||||
"PAPERLESS_DBUSER": "my-cool-user",
|
|
||||||
"PAPERLESS_DBPASS": "my-secure-password",
|
|
||||||
"PAPERLESS_DB_OPTIONS": "ssl.ca=/path/to/ca.pem,ssl_mode=REQUIRED",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"default": {
|
|
||||||
"ENGINE": "django.db.backends.mysql",
|
|
||||||
"HOST": "paperless-mariadb-host",
|
|
||||||
"PORT": 5555,
|
|
||||||
"NAME": "paperless",
|
|
||||||
"USER": "my-cool-user",
|
|
||||||
"PASSWORD": "my-secure-password",
|
|
||||||
"OPTIONS": {
|
|
||||||
"read_default_file": "/etc/mysql/my.cnf",
|
|
||||||
"charset": "utf8mb4",
|
|
||||||
"collation": "utf8mb4_unicode_ci",
|
|
||||||
"ssl_mode": "REQUIRED",
|
|
||||||
"ssl": {
|
|
||||||
"ca": "/path/to/ca.pem",
|
|
||||||
"cert": None,
|
|
||||||
"key": None,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"sqlite": {
|
|
||||||
"ENGINE": "django.db.backends.sqlite3",
|
|
||||||
"NAME": None, # Will be replaced with tmp_path
|
|
||||||
"OPTIONS": {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
id="mariadb-overrides",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
{
|
|
||||||
"PAPERLESS_DBENGINE": "mariadb",
|
|
||||||
"PAPERLESS_DBHOST": "mariahost",
|
|
||||||
"PAPERLESS_DBSSLMODE": "REQUIRED",
|
|
||||||
"PAPERLESS_DBSSLROOTCERT": "/certs/ca.pem",
|
|
||||||
"PAPERLESS_DBSSLCERT": "/certs/client.pem",
|
|
||||||
"PAPERLESS_DBSSLKEY": "/certs/client.key",
|
|
||||||
"PAPERLESS_DB_TIMEOUT": "25",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"default": {
|
|
||||||
"ENGINE": "django.db.backends.mysql",
|
|
||||||
"HOST": "mariahost",
|
|
||||||
"NAME": "paperless",
|
|
||||||
"USER": "paperless",
|
|
||||||
"PASSWORD": "paperless",
|
|
||||||
"OPTIONS": {
|
|
||||||
"read_default_file": "/etc/mysql/my.cnf",
|
|
||||||
"charset": "utf8mb4",
|
|
||||||
"collation": "utf8mb4_unicode_ci",
|
|
||||||
"ssl_mode": "REQUIRED",
|
|
||||||
"ssl": {
|
|
||||||
"ca": "/certs/ca.pem",
|
|
||||||
"cert": "/certs/client.pem",
|
|
||||||
"key": "/certs/client.key",
|
|
||||||
},
|
|
||||||
"connect_timeout": 25,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"sqlite": {
|
|
||||||
"ENGINE": "django.db.backends.sqlite3",
|
|
||||||
"NAME": None, # Will be replaced with tmp_path
|
|
||||||
"OPTIONS": {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
id="mariadb-legacy-ssl-and-timeout",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_parse_db_settings(
|
|
||||||
self,
|
|
||||||
tmp_path: Path,
|
|
||||||
mocker: MockerFixture,
|
|
||||||
env_vars: dict[str, str],
|
|
||||||
expected_database_settings: dict[str, dict],
|
|
||||||
) -> None:
|
|
||||||
"""Test various database configurations with defaults and overrides."""
|
|
||||||
# Clear environment and set test vars
|
|
||||||
mocker.patch.dict(os.environ, env_vars, clear=True)
|
|
||||||
|
|
||||||
# Update expected paths with actual tmp_path
|
|
||||||
if (
|
|
||||||
"default" in expected_database_settings
|
|
||||||
and expected_database_settings["default"]["NAME"] is None
|
|
||||||
):
|
):
|
||||||
expected_database_settings["default"]["NAME"] = str(
|
databases = _parse_db_settings()
|
||||||
tmp_path / "db.sqlite3",
|
|
||||||
)
|
self.assertDictEqual(
|
||||||
if "sqlite" in expected_database_settings:
|
{
|
||||||
expected_database_settings["sqlite"]["NAME"] = str(
|
"timeout": 10.0,
|
||||||
tmp_path / "db.sqlite3",
|
},
|
||||||
|
databases["default"]["OPTIONS"],
|
||||||
)
|
)
|
||||||
|
|
||||||
settings = parse_db_settings(tmp_path)
|
def test_db_timeout_with_not_sqlite(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- PAPERLESS_DB_TIMEOUT is set but db is not sqlite
|
||||||
|
WHEN:
|
||||||
|
- Settings are parsed
|
||||||
|
THEN:
|
||||||
|
- PAPERLESS_DB_TIMEOUT set correctly in non-sqlite db & for fallback sqlite db
|
||||||
|
"""
|
||||||
|
with mock.patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"PAPERLESS_DBHOST": "127.0.0.1",
|
||||||
|
"PAPERLESS_DB_TIMEOUT": "10",
|
||||||
|
},
|
||||||
|
):
|
||||||
|
databases = _parse_db_settings()
|
||||||
|
|
||||||
assert settings == expected_database_settings
|
self.assertDictEqual(
|
||||||
|
databases["default"]["OPTIONS"],
|
||||||
|
databases["default"]["OPTIONS"]
|
||||||
|
| {
|
||||||
|
"connect_timeout": 10.0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertDictEqual(
|
||||||
|
{
|
||||||
|
"timeout": 10.0,
|
||||||
|
},
|
||||||
|
databases["sqlite"]["OPTIONS"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestPaperlessURLSettings(TestCase):
|
class TestPaperlessURLSettings(TestCase):
|
||||||
|
|||||||
@@ -575,6 +575,11 @@ class MailAccountHandler(LoggingMixin):
|
|||||||
rule,
|
rule,
|
||||||
supports_gmail_labels=supports_gmail_labels,
|
supports_gmail_labels=supports_gmail_labels,
|
||||||
)
|
)
|
||||||
|
if total_processed_files > 0 and rule.stop_processing:
|
||||||
|
self.log.debug(
|
||||||
|
f"Rule {rule}: Stopping processing rules due to stop_processing flag",
|
||||||
|
)
|
||||||
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log.exception(
|
self.log.exception(
|
||||||
f"Rule {rule}: Error while processing rule: {e}",
|
f"Rule {rule}: Error while processing rule: {e}",
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 5.1.6 on 2025-02-24 16:07
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("paperless_mail", "0002_optimize_integer_field_sizes"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="mailrule",
|
||||||
|
name="stop_processing",
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="If True, no further rules will be processed after this one if any document is consumed.",
|
||||||
|
verbose_name="Stop processing further rules",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -301,6 +301,14 @@ class MailRule(document_models.ModelWithOwner):
|
|||||||
default=True,
|
default=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
stop_processing = models.BooleanField(
|
||||||
|
_("Stop processing further rules"),
|
||||||
|
default=False,
|
||||||
|
help_text=_(
|
||||||
|
"If True, no further rules will be processed after this one if any document is queued.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.account.name}.{self.name}"
|
return f"{self.account.name}.{self.name}"
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ class MailRuleSerializer(OwnedObjectSerializer):
|
|||||||
"user_can_change",
|
"user_can_change",
|
||||||
"permissions",
|
"permissions",
|
||||||
"set_permissions",
|
"set_permissions",
|
||||||
|
"stop_processing",
|
||||||
]
|
]
|
||||||
|
|
||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
|
|||||||
@@ -1692,6 +1692,39 @@ class TestTasks(TestCase):
|
|||||||
result = tasks.process_mail_accounts(account_ids=[account_b.id])
|
result = tasks.process_mail_accounts(account_ids=[account_b.id])
|
||||||
self.assertIn("No new", result)
|
self.assertIn("No new", result)
|
||||||
|
|
||||||
|
@mock.patch("paperless_mail.tasks.MailAccountHandler.handle_mail_account")
|
||||||
|
def test_rule_with_stop_processing(self, m):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Mail account with a rule with stop_processing=True
|
||||||
|
WHEN:
|
||||||
|
- Mail account is processed
|
||||||
|
THEN:
|
||||||
|
- Should only process the first rule
|
||||||
|
"""
|
||||||
|
m.side_effect = lambda account: 6
|
||||||
|
|
||||||
|
account = MailAccount.objects.create(
|
||||||
|
name="A",
|
||||||
|
imap_server="A",
|
||||||
|
username="A",
|
||||||
|
password="A",
|
||||||
|
)
|
||||||
|
MailRule.objects.create(
|
||||||
|
name="A",
|
||||||
|
account=account,
|
||||||
|
stop_processing=True,
|
||||||
|
)
|
||||||
|
MailRule.objects.create(
|
||||||
|
name="B",
|
||||||
|
account=account,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = tasks.process_mail_accounts()
|
||||||
|
|
||||||
|
self.assertEqual(m.call_count, 1)
|
||||||
|
self.assertIn("Added 6", result)
|
||||||
|
|
||||||
|
|
||||||
class TestMailAccountTestView(APITestCase):
|
class TestMailAccountTestView(APITestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
@@ -1777,8 +1810,8 @@ class TestMailAccountTestView(APITestCase):
|
|||||||
)
|
)
|
||||||
def test_mail_account_test_view_refresh_token_fails(
|
def test_mail_account_test_view_refresh_token_fails(
|
||||||
self,
|
self,
|
||||||
mock_mock_refresh_account_oauth_token,
|
mock_mock_refresh_account_oauth_token: mock.MagicMock,
|
||||||
):
|
) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- Mail account with expired token
|
- Mail account with expired token
|
||||||
|
|||||||
39
uv.lock
generated
39
uv.lock
generated
@@ -1350,11 +1350,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "filelock"
|
name = "filelock"
|
||||||
version = "3.20.3"
|
version = "3.21.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/73/71/74364ff065ca78914d8bd90b312fe78ddc5e11372d38bc9cb7104f887ce1/filelock-3.21.2.tar.gz", hash = "sha256:cfd218cfccf8b947fce7837da312ec3359d10ef2a47c8602edd59e0bacffb708", size = 31486, upload-time = "2026-02-13T01:27:15.223Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" },
|
{ url = "https://files.pythonhosted.org/packages/98/73/3a18f1e1276810e81477c431009b55eeccebbd7301d28a350b77aacf3c33/filelock-3.21.2-py3-none-any.whl", hash = "sha256:d6cd4dbef3e1bb63bc16500fc5aa100f16e405bbff3fb4231711851be50c1560", size = 21479, upload-time = "2026-02-13T01:27:13.611Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2991,7 +2991,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openai"
|
name = "openai"
|
||||||
version = "2.17.0"
|
version = "2.20.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
{ name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
@@ -3003,9 +3003,9 @@ dependencies = [
|
|||||||
{ name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
{ name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
{ name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
{ name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/9c/a2/677f22c4b487effb8a09439fb6134034b5f0a39ca27df8b95fac23a93720/openai-2.17.0.tar.gz", hash = "sha256:47224b74bd20f30c6b0a6a329505243cb2f26d5cf84d9f8d0825ff8b35e9c999", size = 631445, upload-time = "2026-02-05T16:27:40.953Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/6e/5a/f495777c02625bfa18212b6e3b73f1893094f2bf660976eb4bc6f43a1ca2/openai-2.20.0.tar.gz", hash = "sha256:2654a689208cd0bf1098bb9462e8d722af5cbe961e6bba54e6f19fb843d88db1", size = 642355, upload-time = "2026-02-10T19:02:54.145Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/44/97/284535aa75e6e84ab388248b5a323fc296b1f70530130dee37f7f4fbe856/openai-2.17.0-py3-none-any.whl", hash = "sha256:4f393fd886ca35e113aac7ff239bcd578b81d8f104f5aedc7d3693eb2af1d338", size = 1069524, upload-time = "2026-02-05T16:27:38.941Z" },
|
{ url = "https://files.pythonhosted.org/packages/b5/a0/cf4297aa51bbc21e83ef0ac018947fa06aea8f2364aad7c96cbf148590e6/openai-2.20.0-py3-none-any.whl", hash = "sha256:38d989c4b1075cd1f76abc68364059d822327cf1a932531d429795f4fc18be99", size = 1098479, upload-time = "2026-02-10T19:02:52.157Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3194,7 +3194,7 @@ requires-dist = [
|
|||||||
{ name = "drf-spectacular-sidecar", specifier = "~=2026.1.1" },
|
{ name = "drf-spectacular-sidecar", specifier = "~=2026.1.1" },
|
||||||
{ name = "drf-writable-nested", specifier = "~=0.7.1" },
|
{ name = "drf-writable-nested", specifier = "~=0.7.1" },
|
||||||
{ name = "faiss-cpu", specifier = ">=1.10" },
|
{ name = "faiss-cpu", specifier = ">=1.10" },
|
||||||
{ name = "filelock", specifier = "~=3.20.0" },
|
{ name = "filelock", specifier = "~=3.21.2" },
|
||||||
{ name = "flower", specifier = "~=2.0.1" },
|
{ name = "flower", specifier = "~=2.0.1" },
|
||||||
{ name = "gotenberg-client", specifier = "~=0.13.1" },
|
{ name = "gotenberg-client", specifier = "~=0.13.1" },
|
||||||
{ name = "granian", extras = ["uvloop"], marker = "extra == 'webserver'", specifier = "~=2.7.0" },
|
{ name = "granian", extras = ["uvloop"], marker = "extra == 'webserver'", specifier = "~=2.7.0" },
|
||||||
@@ -3249,7 +3249,7 @@ dev = [
|
|||||||
{ name = "pytest", specifier = "~=9.0.0" },
|
{ name = "pytest", specifier = "~=9.0.0" },
|
||||||
{ name = "pytest-cov", specifier = "~=7.0.0" },
|
{ name = "pytest-cov", specifier = "~=7.0.0" },
|
||||||
{ name = "pytest-django", specifier = "~=4.11.1" },
|
{ name = "pytest-django", specifier = "~=4.11.1" },
|
||||||
{ name = "pytest-env", specifier = "~=1.2.0" },
|
{ name = "pytest-env", specifier = "~=1.3.2" },
|
||||||
{ name = "pytest-httpx" },
|
{ name = "pytest-httpx" },
|
||||||
{ name = "pytest-mock", specifier = "~=3.15.1" },
|
{ name = "pytest-mock", specifier = "~=3.15.1" },
|
||||||
{ name = "pytest-rerunfailures", specifier = "~=16.1" },
|
{ name = "pytest-rerunfailures", specifier = "~=16.1" },
|
||||||
@@ -3270,7 +3270,7 @@ testing = [
|
|||||||
{ name = "pytest", specifier = "~=9.0.0" },
|
{ name = "pytest", specifier = "~=9.0.0" },
|
||||||
{ name = "pytest-cov", specifier = "~=7.0.0" },
|
{ name = "pytest-cov", specifier = "~=7.0.0" },
|
||||||
{ name = "pytest-django", specifier = "~=4.11.1" },
|
{ name = "pytest-django", specifier = "~=4.11.1" },
|
||||||
{ name = "pytest-env", specifier = "~=1.2.0" },
|
{ name = "pytest-env", specifier = "~=1.3.2" },
|
||||||
{ name = "pytest-httpx" },
|
{ name = "pytest-httpx" },
|
||||||
{ name = "pytest-mock", specifier = "~=3.15.1" },
|
{ name = "pytest-mock", specifier = "~=3.15.1" },
|
||||||
{ name = "pytest-rerunfailures", specifier = "~=16.1" },
|
{ name = "pytest-rerunfailures", specifier = "~=16.1" },
|
||||||
@@ -3941,15 +3941,15 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyrefly"
|
name = "pyrefly"
|
||||||
version = "0.51.0"
|
version = "0.52.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/e9/bd/b8065b801b4058954577afa3f78bc1dda5f119f7ea353570ba9029db5109/pyrefly-0.51.0.tar.gz", hash = "sha256:99467db60f148bb6965c45cdc3e769d94b704100e9d57b6455cc6796e5a9e7b1", size = 4918889, upload-time = "2026-02-02T15:32:58.45Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/93/bc/a65b3f8a04b941121868c07f1e65db223c1a101b6adf0ff3db5240ad24ea/pyrefly-0.52.0.tar.gz", hash = "sha256:abe022b68e67a2fd9adad4f8fe2deced2a786df32601b0eecbb00b40ea1f3b93", size = 4967100, upload-time = "2026-02-09T15:30:03.745Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/42/c1/0aa9b4cf5180f481e9f07a8fbfe9c3bc6044ec97612373fdd4f9f6aa49a4/pyrefly-0.51.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4013f914d3b523a9b1afc25a620a011406f7745ad5cfc5781ec95235bc9cd583", size = 11900057, upload-time = "2026-02-02T15:32:34.353Z" },
|
{ url = "https://files.pythonhosted.org/packages/e7/32/74a3b3ed6b38fef8aba3437e02824bf670b017123126bb83597c0aa42e98/pyrefly-0.52.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:90d7bf2fb812ee3a920a962da2aa2387e2f4109c62604e5be1a736046a3260c7", size = 11773462, upload-time = "2026-02-09T15:29:44.995Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8d/07/6a576ec997845bc8e7d89afebe12bc6386092446330194789d120f6a73f7/pyrefly-0.51.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4a6eeffd5649d393bf457b7c1253f89b33295d475b1cae0f9a21377986708804", size = 11480421, upload-time = "2026-02-02T15:32:37.314Z" },
|
{ url = "https://files.pythonhosted.org/packages/31/d4/efb4aecca57bc42871b3004af04324e637057902417d89757c4077474b98/pyrefly-0.52.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:848764fdbc474fd36412d7ccf230d13a12ab3b2c28968124d9e9d51df79b7b8e", size = 11355651, upload-time = "2026-02-09T15:29:46.992Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c5/0e/1b4675289a29b72818c812d7456031a7cab98532826d207d39465f75712c/pyrefly-0.51.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:beace17854735136134848e5a0e8678b6862ee1144eaeb27f1bb70ff1f8fd9ca", size = 32511878, upload-time = "2026-02-02T15:32:40.136Z" },
|
{ url = "https://files.pythonhosted.org/packages/d8/b9/80e0becaaafe0ca55b06868e942aa7f68a42644a156fdc7bedf2ae851d65/pyrefly-0.52.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43b712830df1247798fb79f478a236b0ffbe5983bdde5eb2f5b99a9411e09f35", size = 31906389, upload-time = "2026-02-09T15:29:49.138Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1b/4e/d564711718e4158339397123085da6afcad1c62222efa483cb7db5dab58b/pyrefly-0.51.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40055df65c184d825081e7177b99d277c8a1cb29c6e41a54ff40828d355aa467", size = 34797013, upload-time = "2026-02-02T15:32:43.687Z" },
|
{ url = "https://files.pythonhosted.org/packages/44/78/f6ff1e9c86eebad5feef97301789bb9ef22a5816931809cbb063e5e6acb9/pyrefly-0.52.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:baa4130c460ad7c8d7efcff9017b7bc74c71736c5959ebfc2b7e405c2ce07d5d", size = 34292755, upload-time = "2026-02-09T15:29:52.12Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b3/db/961162ec2bb74a0cd5d0ef988f71695581449b3c6fce76ede9a984cdc8d1/pyrefly-0.51.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65689401e35b7d01a1394cdb1bafd46e2f49369b0f9891a333bce3568f100ce2", size = 35915591, upload-time = "2026-02-02T15:32:47.64Z" },
|
{ url = "https://files.pythonhosted.org/packages/c6/d4/5798fbec917aa2481de9ed4dc550824383b115c67b57be2ca6da43a91850/pyrefly-0.52.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3297751b1b13ecb582af48c8798e0b652c41c33a7e4ed72676164b29561655f6", size = 36943447, upload-time = "2026-02-09T15:29:54.858Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3997,15 +3997,16 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytest-env"
|
name = "pytest-env"
|
||||||
version = "1.2.0"
|
version = "1.3.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
{ name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
|
{ name = "python-dotenv", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
{ name = "tomli", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux')" },
|
{ name = "tomli", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux')" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/13/12/9c87d0ca45d5992473208bcef2828169fa7d39b8d7fc6e3401f5c08b8bf7/pytest_env-1.2.0.tar.gz", hash = "sha256:475e2ebe8626cee01f491f304a74b12137742397d6c784ea4bc258f069232b80", size = 8973, upload-time = "2025-10-09T19:15:47.42Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/43/ad/dd32e4614fb68ad980c949fd4299f8c6a8d4874e24ec8d222c056efb4741/pytest_env-1.3.2.tar.gz", hash = "sha256:f091a2c6a8eb91befcae2b4c1bd2905a51f33bc1c6567707b7feed4e51b76b47", size = 12009, upload-time = "2026-02-11T22:09:49.168Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/27/98/822b924a4a3eb58aacba84444c7439fce32680592f394de26af9c76e2569/pytest_env-1.2.0-py3-none-any.whl", hash = "sha256:d7e5b7198f9b83c795377c09feefa45d56083834e60d04767efd64819fc9da00", size = 6251, upload-time = "2025-10-09T19:15:46.077Z" },
|
{ url = "https://files.pythonhosted.org/packages/e8/ad/d793670b26f4fb82e974dbff20d05782ebb23490b08987976cdc62d854bb/pytest_env-1.3.2-py3-none-any.whl", hash = "sha256:e8626b776a035112a8ad58fcc9e04926868c58f15225de484de7c8af4b4b526c", size = 7864, upload-time = "2026-02-11T22:09:47.775Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
Reference in New Issue
Block a user