diff --git a/.github/workflows/repo-maintenance.yml b/.github/workflows/repo-maintenance.yml index c7379ae4a..6e9136e71 100644 --- a/.github/workflows/repo-maintenance.yml +++ b/.github/workflows/repo-maintenance.yml @@ -28,7 +28,7 @@ jobs: stale-issue-message: > This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you - for your contributions. + for your contributions. See our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-respoistory-maintenance) for more details. lock-threads: name: 'Lock Old Threads' runs-on: ubuntu-latest @@ -43,14 +43,17 @@ jobs: This issue has been automatically locked since there has not been any recent activity after it was closed. Please open a new discussion or issue for related concerns. + See our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-respoistory-maintenance) for more details. pr-comment: > This pull request has been automatically locked since there has not been any recent activity after it was closed. Please open a new discussion or issue for related concerns. + See our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-respoistory-maintenance) for more details. discussion-comment: > This discussion has been automatically locked since there has not been any recent activity after it was closed. Please open a new discussion for related concerns. + See our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-respoistory-maintenance) for more details. close-answered-discussions: name: 'Close Answered Discussions' runs-on: ubuntu-latest @@ -90,7 +93,7 @@ jobs: }`; const commentVariables = { discussion: discussion.id, - body: 'This discussion has been automatically closed because it was marked as answered.', + body: 'This discussion has been automatically closed because it was marked as answered. Please see our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-respoistory-maintenance) for more details.', } await github.graphql(addCommentMutation, commentVariables) @@ -180,7 +183,7 @@ jobs: }`; const commentVariables = { discussion: discussion.id, - body: 'This discussion has been automatically closed due to inactivity.', + body: 'This discussion has been automatically closed due to inactivity. Please see our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-respoistory-maintenance) for more details.', } await github.graphql(addCommentMutation, commentVariables); @@ -258,7 +261,7 @@ jobs: }`; const commentVariables = { discussion: discussion.id, - body: 'This discussion has been automatically closed due to lack of community interest.', + body: 'This discussion has been automatically closed due to lack of community support. Please see our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-respoistory-maintenance) for more details.', } await github.graphql(addCommentMutation, commentVariables); diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 953d19fb8..3ff605c19 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -137,3 +137,19 @@ All team members are notified when mentioned or assigned to a relevant issue or We are not overly strict with inviting people to the organization. If you have read the [team permissions](#permissions) and think having additional access would enhance your contributions, please reach out to an [admin](#structure) of the team. The admins occasionally invite contributors directly if we believe having them on a team will accelerate their work. + +# Automatic Respoistory Maintenance + +The Paperless-ngx team appreciates all effort and interest from the community in filing bug reports, creating feature requests, sharing ideas and helping other +community members. That said, in an effort to keep the repository organized and managebale the project uses automatic handling of certain areas: + +- Issues that cannot be reproduced will be marked 'stale' after 7 days of inactivity and closed after 14 further days of inactivity. +- Issues, pull requests and discussions that are closed will be locked after 30 days of inactivity. +- Discussions with a marked answer will be automatically closed. +- Discussions in the 'General' or 'Support' categories will be closed after 180 days of inactivity. +- Feature requests that do not meet the following thresholds will be closed: 5 "up-votes" after 180 days of inactivity or 10 "up-votes" after 365 days. + +In all cases, threads can be re-opened by project maintainers and, of course, users can always create a new discussion for related concerns. +Finally, remember that all information remains searchable and 'closed' feature requests can still serve as inspiration for new features. + +Thank you all for your contributions. diff --git a/Pipfile.lock b/Pipfile.lock index 2dfe5f2ed..ff9c6af88 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -392,41 +392,42 @@ }, "cryptography": { "hashes": [ - "sha256:087887e55e0b9c8724cf05361357875adb5c20dec27e5816b653492980d20380", - "sha256:09a77e5b2e8ca732a19a90c5bca2d124621a1edb5438c5daa2d2738bfeb02589", - "sha256:130c0f77022b2b9c99d8cebcdd834d81705f61c68e91ddd614ce74c657f8b3ea", - "sha256:141e2aa5ba100d3788c0ad7919b288f89d1fe015878b9659b307c9ef867d3a65", - "sha256:28cb2c41f131a5758d6ba6a0504150d644054fd9f3203a1e8e8d7ac3aea7f73a", - "sha256:2f9f14185962e6a04ab32d1abe34eae8a9001569ee4edb64d2304bf0d65c53f3", - "sha256:320948ab49883557a256eab46149df79435a22d2fefd6a66fe6946f1b9d9d008", - "sha256:36d4b7c4be6411f58f60d9ce555a73df8406d484ba12a63549c88bd64f7967f1", - "sha256:3b15c678f27d66d247132cbf13df2f75255627bcc9b6a570f7d2fd08e8c081d2", - "sha256:3dbd37e14ce795b4af61b89b037d4bc157f2cb23e676fa16932185a04dfbf635", - "sha256:4383b47f45b14459cab66048d384614019965ba6c1a1a141f11b5a551cace1b2", - "sha256:44c95c0e96b3cb628e8452ec060413a49002a247b2b9938989e23a2c8291fc90", - "sha256:4b063d3413f853e056161eb0c7724822a9740ad3caa24b8424d776cebf98e7ee", - "sha256:52ed9ebf8ac602385126c9a2fe951db36f2cb0c2538d22971487f89d0de4065a", - "sha256:55d1580e2d7e17f45d19d3b12098e352f3a37fe86d380bf45846ef257054b242", - "sha256:5ef9bc3d046ce83c4bbf4c25e1e0547b9c441c01d30922d812e887dc5f125c12", - "sha256:5fa82a26f92871eca593b53359c12ad7949772462f887c35edaf36f87953c0e2", - "sha256:61321672b3ac7aade25c40449ccedbc6db72c7f5f0fdf34def5e2f8b51ca530d", - "sha256:701171f825dcab90969596ce2af253143b93b08f1a716d4b2a9d2db5084ef7be", - "sha256:841ec8af7a8491ac76ec5a9522226e287187a3107e12b7d686ad354bb78facee", - "sha256:8a06641fb07d4e8f6c7dda4fc3f8871d327803ab6542e33831c7ccfdcb4d0ad6", - "sha256:8e88bb9eafbf6a4014d55fb222e7360eef53e613215085e65a13290577394529", - "sha256:a00aee5d1b6c20620161984f8ab2ab69134466c51f58c052c11b076715e72929", - "sha256:a047682d324ba56e61b7ea7c7299d51e61fd3bca7dad2ccc39b72bd0118d60a1", - "sha256:a7ef8dd0bf2e1d0a27042b231a3baac6883cdd5557036f5e8df7139255feaac6", - "sha256:ad28cff53f60d99a928dfcf1e861e0b2ceb2bc1f08a074fdd601b314e1cc9e0a", - "sha256:b9097a208875fc7bbeb1286d0125d90bdfed961f61f214d3f5be62cd4ed8a446", - "sha256:b97fe7d7991c25e6a31e5d5e795986b18fbbb3107b873d5f3ae6dc9a103278e9", - "sha256:e0ec52ba3c7f1b7d813cd52649a5b3ef1fc0d433219dc8c93827c57eab6cf888", - "sha256:ea2c3ffb662fec8bbbfce5602e2c159ff097a4631d96235fcf0fb00e59e3ece4", - "sha256:fa3dec4ba8fb6e662770b74f62f1a0c7d4e37e25b58b2bf2c1be4c95372b4a33", - "sha256:fbeb725c9dc799a574518109336acccaf1303c30d45c075c665c0793c2f79a7f" + "sha256:01911714117642a3f1792c7f376db572aadadbafcd8d75bb527166009c9f1d1b", + "sha256:0e89f7b84f421c56e7ff69f11c441ebda73b8a8e6488d322ef71746224c20fce", + "sha256:12d341bd42cdb7d4937b0cabbdf2a94f949413ac4504904d0cdbdce4a22cbf88", + "sha256:15a1fb843c48b4a604663fa30af60818cd28f895572386e5f9b8a665874c26e7", + "sha256:1cdcdbd117681c88d717437ada72bdd5be9de117f96e3f4d50dab3f59fd9ab20", + "sha256:1df6fcbf60560d2113b5ed90f072dc0b108d64750d4cbd46a21ec882c7aefce9", + "sha256:3c6048f217533d89f2f8f4f0fe3044bf0b2090453b7b73d0b77db47b80af8dff", + "sha256:3e970a2119507d0b104f0a8e281521ad28fc26f2820687b3436b8c9a5fcf20d1", + "sha256:44a64043f743485925d3bcac548d05df0f9bb445c5fcca6681889c7c3ab12764", + "sha256:4e36685cb634af55e0677d435d425043967ac2f3790ec652b2b88ad03b85c27b", + "sha256:5f8907fcf57392cd917892ae83708761c6ff3c37a8e835d7246ff0ad251d9298", + "sha256:69b22ab6506a3fe483d67d1ed878e1602bdd5912a134e6202c1ec672233241c1", + "sha256:6bfadd884e7280df24d26f2186e4e07556a05d37393b0f220a840b083dc6a824", + "sha256:6d0fbe73728c44ca3a241eff9aefe6496ab2656d6e7a4ea2459865f2e8613257", + "sha256:6ffb03d419edcab93b4b19c22ee80c007fb2d708429cecebf1dd3258956a563a", + "sha256:810bcf151caefc03e51a3d61e53335cd5c7316c0a105cc695f0959f2c638b129", + "sha256:831a4b37accef30cccd34fcb916a5d7b5be3cbbe27268a02832c3e450aea39cb", + "sha256:887623fe0d70f48ab3f5e4dbf234986b1329a64c066d719432d0698522749929", + "sha256:a0298bdc6e98ca21382afe914c642620370ce0470a01e1bef6dd9b5354c36854", + "sha256:a1327f280c824ff7885bdeef8578f74690e9079267c1c8bd7dc5cc5aa065ae52", + "sha256:c1f25b252d2c87088abc8bbc4f1ecbf7c919e05508a7e8628e6875c40bc70923", + "sha256:c3a5cbc620e1e17009f30dd34cb0d85c987afd21c41a74352d1719be33380885", + "sha256:ce8613beaffc7c14f091497346ef117c1798c202b01153a8cc7b8e2ebaaf41c0", + "sha256:d2a27aca5597c8a71abbe10209184e1a8e91c1fd470b5070a2ea60cafec35bcd", + "sha256:dad9c385ba8ee025bb0d856714f71d7840020fe176ae0229de618f14dae7a6e2", + "sha256:db4b65b02f59035037fde0998974d84244a64c3265bdef32a827ab9b63d61b18", + "sha256:e09469a2cec88fb7b078e16d4adec594414397e8879a4341c6ace96013463d5b", + "sha256:e53dc41cda40b248ebc40b83b31516487f7db95ab8ceac1f042626bc43a2f992", + "sha256:f1e85a178384bf19e36779d91ff35c7617c885da487d689b05c1366f9933ad74", + "sha256:f47be41843200f7faec0683ad751e5ef11b9a56a220d57f300376cd8aba81660", + "sha256:fb0cef872d8193e487fc6bdb08559c3aa41b659a7d9be48b2e10747f47863925", + "sha256:ffc73996c4fca3d2b6c1c8c12bfd3ad00def8621da24f547626bf06441400449" ], + "index": "pypi", "markers": "python_version >= '3.7'", - "version": "==42.0.2" + "version": "==42.0.4" }, "dateparser": { "hashes": [ diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf index 3a6d6f8df..b102e9567 100644 --- a/src-ui/messages.xlf +++ b/src-ui/messages.xlf @@ -1491,6 +1491,10 @@ src/app/components/manage/management-list/management-list.component.html 96 + + src/app/components/manage/management-list/management-list.component.ts + 208 + src/app/components/manage/workflows/workflows.component.html 38 @@ -2017,7 +2021,7 @@ src/app/components/manage/management-list/management-list.component.ts - 304 + 320 src/app/components/manage/workflows/workflows.component.ts @@ -2056,7 +2060,7 @@ src/app/components/manage/management-list/management-list.component.ts - 306 + 322 src/app/components/manage/workflows/workflows.component.ts @@ -5025,7 +5029,11 @@ src/app/components/manage/management-list/management-list.component.ts - 302 + 204 + + + src/app/components/manage/management-list/management-list.component.ts + 318 @@ -6219,7 +6227,7 @@ src/app/components/manage/management-list/management-list.component.ts - 289 + 305 @@ -6302,26 +6310,26 @@ {VAR_PLURAL, plural, =1 {One } other { total }} src/app/components/manage/management-list/management-list.component.html - 116 + 110 src/app/components/manage/management-list/management-list.component.html - 116 + 110 src/app/components/manage/management-list/management-list.component.html - 116 + 110 src/app/components/manage/management-list/management-list.component.html - 116 + 110 Automatic src/app/components/manage/management-list/management-list.component.ts - 113 + 116 src/app/data/matching-model.ts @@ -6332,7 +6340,7 @@ None src/app/components/manage/management-list/management-list.component.ts - 115 + 118 src/app/data/matching-model.ts @@ -6343,63 +6351,70 @@ Successfully created . src/app/components/manage/management-list/management-list.component.ts - 158 + 161 Error occurred while creating . src/app/components/manage/management-list/management-list.component.ts - 163 + 166 Successfully updated . src/app/components/manage/management-list/management-list.component.ts - 178 + 181 Error occurred while saving . src/app/components/manage/management-list/management-list.component.ts - 183 + 186 + + + + Associated documents will not be deleted. + + src/app/components/manage/management-list/management-list.component.ts + 206 Error while deleting element src/app/components/manage/management-list/management-list.component.ts - 207 + 222 Permissions updated successfully src/app/components/manage/management-list/management-list.component.ts - 282 + 298 This operation will permanently delete all objects. src/app/components/manage/management-list/management-list.component.ts - 303 + 319 Objects deleted successfully src/app/components/manage/management-list/management-list.component.ts - 317 + 333 Error deleting objects src/app/components/manage/management-list/management-list.component.ts - 323 + 339 diff --git a/src-ui/package-lock.json b/src-ui/package-lock.json index e027085f1..e5d125cc9 100644 --- a/src-ui/package-lock.json +++ b/src-ui/package-lock.json @@ -11124,9 +11124,9 @@ } }, "node_modules/ip": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", - "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.1.tgz", + "integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==", "dev": true }, "node_modules/ipaddr.js": { diff --git a/src-ui/setup-jest.ts b/src-ui/setup-jest.ts index f2767ebf0..39f018c6d 100644 --- a/src-ui/setup-jest.ts +++ b/src-ui/setup-jest.ts @@ -94,6 +94,10 @@ Object.defineProperty(navigator, 'clipboard', { }) Object.defineProperty(navigator, 'canShare', { value: () => true }) Object.defineProperty(window, 'ResizeObserver', { value: mock() }) +Object.defineProperty(window, 'location', { + configurable: true, + value: { reload: jest.fn() }, +}) HTMLCanvasElement.prototype.getContext = < typeof HTMLCanvasElement.prototype.getContext diff --git a/src-ui/src/app/components/admin/settings/settings.component.spec.ts b/src-ui/src/app/components/admin/settings/settings.component.spec.ts index 6a9ca36da..6256f646b 100644 --- a/src-ui/src/app/components/admin/settings/settings.component.spec.ts +++ b/src-ui/src/app/components/admin/settings/settings.component.spec.ts @@ -309,10 +309,15 @@ describe('SettingsComponent', () => { component.store.getValue()['displayLanguage'] = 'en-US' component.store.getValue()['updateCheckingEnabled'] = false component.settingsForm.value.displayLanguage = 'en-GB' - component.settingsForm.value.updateCheckingEnabled = true - jest.spyOn(settingsService, 'storeSettings').mockReturnValueOnce(of(true)) + jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true)) component.saveSettings() expect(toast.actionName).toEqual('Reload now') + + component.settingsForm.value.updateCheckingEnabled = true + component.saveSettings() + + expect(toast.actionName).toEqual('Reload now') + toast.action() }) it('should allow setting theme color, visually apply change immediately but not save', () => { diff --git a/src-ui/src/app/components/app-frame/app-frame.component.html b/src-ui/src/app/components/app-frame/app-frame.component.html index 3dec0f691..fbc1a8b89 100644 --- a/src-ui/src/app/components/app-frame/app-frame.component.html +++ b/src-ui/src/app/components/app-frame/app-frame.component.html @@ -4,16 +4,16 @@ (click)="isMenuCollapsed = !isMenuCollapsed"> - - + -
+
@if (customAppTitle?.length) {
{{customAppTitle}} diff --git a/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.spec.ts b/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.spec.ts index 58aa029ee..fe377cc70 100644 --- a/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.spec.ts +++ b/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.spec.ts @@ -493,12 +493,17 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => expect(changedResult.getExcludedItems()).toEqual(items) })) - it('FilterableDropdownSelectionModel should sort items by state', () => { - component.items = items + it('selection model should sort items by state', () => { + component.items = items.concat([{ id: null, name: 'Null B' }]) component.selectionModel = selectionModel selectionModel.toggle(items[1].id) selectionModel.apply() - expect(selectionModel.itemsSorted).toEqual([nullItem, items[1], items[0]]) + expect(selectionModel.itemsSorted).toEqual([ + nullItem, + { id: null, name: 'Null B' }, + items[1], + items[0], + ]) }) it('should set support create, keep open model and call createRef method', fakeAsync(() => { @@ -542,4 +547,34 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => tick(300) expect(createSpy).toHaveBeenCalled() })) + + it('should exclude item and trigger change event', () => { + const id = 1 + const state = ToggleableItemState.Selected + component.selectionModel = selectionModel + component.manyToOne = true + component.selectionModel.singleSelect = true + component.selectionModel.intersection = Intersection.Include + component.selectionModel['temporarySelectionStates'].set(id, state) + const changedSpy = jest.spyOn(component.selectionModel.changed, 'next') + component.selectionModel.exclude(id) + expect(component.selectionModel.temporaryLogicalOperator).toBe( + LogicalOperator.And + ) + expect(component.selectionModel['temporarySelectionStates'].get(id)).toBe( + ToggleableItemState.Excluded + ) + expect(component.selectionModel['temporarySelectionStates'].size).toBe(1) + expect(changedSpy).toHaveBeenCalled() + }) + + it('should initialize selection states and apply changes', () => { + selectionModel.items = items + const map = new Map() + map.set(1, ToggleableItemState.Selected) + map.set(2, ToggleableItemState.Excluded) + selectionModel.init(map) + expect(selectionModel.getSelectedItems()).toEqual([items[0]]) + expect(selectionModel.getExcludedItems()).toEqual([items[1]]) + }) }) diff --git a/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts b/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts index bb1a9da27..4f39d32c3 100644 --- a/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts +++ b/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts @@ -275,7 +275,7 @@ export class FilterableDropdownSelectionModel { ) } - init(map) { + init(map: Map) { this.temporarySelectionStates = map this.apply() } diff --git a/src-ui/src/app/components/common/input/select/select.component.spec.ts b/src-ui/src/app/components/common/input/select/select.component.spec.ts index e9eee1648..79eec16e8 100644 --- a/src-ui/src/app/components/common/input/select/select.component.spec.ts +++ b/src-ui/src/app/components/common/input/select/select.component.spec.ts @@ -118,4 +118,18 @@ describe('SelectComponent', () => { tick(3000) expect(clearSpy).toHaveBeenCalled() })) + + it('should emit filtered documents', () => { + component.value = 10 + component.items = items + const emitSpy = jest.spyOn(component.filterDocuments, 'emit') + component.onFilterDocuments() + expect(emitSpy).toHaveBeenCalledWith([items[2]]) + }) + + it('should return the correct filter button title', () => { + component.title = 'Tag' + const expectedTitle = `Filter documents with this ${component.title}` + expect(component.filterButtonTitle).toEqual(expectedTitle) + }) }) diff --git a/src-ui/src/app/components/common/input/tags/tags.component.spec.ts b/src-ui/src/app/components/common/input/tags/tags.component.spec.ts index af321ab9e..f08fed4f8 100644 --- a/src-ui/src/app/components/common/input/tags/tags.component.spec.ts +++ b/src-ui/src/app/components/common/input/tags/tags.component.spec.ts @@ -169,4 +169,12 @@ describe('TagsComponent', () => { expect(component.getTag(2)).toEqual(tags[1]) expect(component.getTag(4)).toBeUndefined() }) + + it('should emit filtered documents', () => { + component.value = [10] + component.tags = tags + const emitSpy = jest.spyOn(component.filterDocuments, 'emit') + component.onFilterDocuments() + expect(emitSpy).toHaveBeenCalledWith([tags[2]]) + }) }) diff --git a/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.spec.ts b/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.spec.ts index 5ecc116c2..172239dbb 100644 --- a/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.spec.ts +++ b/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.spec.ts @@ -119,6 +119,8 @@ describe('UploadFileWidgetComponent', () => { const processingStatus = new FileStatus() processingStatus.phase = FileStatusPhase.WORKING expect(component.getStatusColor(processingStatus)).toEqual('primary') + processingStatus.phase = FileStatusPhase.UPLOADING + expect(component.getStatusColor(processingStatus)).toEqual('primary') const failedStatus = new FileStatus() failedStatus.phase = FileStatusPhase.FAILED expect(component.getStatusColor(failedStatus)).toEqual('danger') diff --git a/src-ui/src/app/components/manage/management-list/management-list.component.html b/src-ui/src/app/components/manage/management-list/management-list.component.html index d627a1540..58101c388 100644 --- a/src-ui/src/app/components/manage/management-list/management-list.component.html +++ b/src-ui/src/app/components/manage/management-list/management-list.component.html @@ -88,39 +88,33 @@
- - - -
- - - } - - -
- - @if (!isLoading) { -
- @if (collectionSize > 0) { -
- {collectionSize, plural, =1 {One {{typeName}}} other {{{collectionSize || 0}} total {{typeNamePlural}}}} - @if (selectedObjects.size > 0) { -  ({{selectedObjects.size}} selected) - } + + + +
+ + + } + +
- } - @if (collectionSize > 20) { - - } -
- } + + @if (!isLoading) { +
+ @if (collectionSize > 0) { +
+ {collectionSize, plural, =1 {One {{typeName}}} other {{{collectionSize || 0}} total {{typeNamePlural}}}} + @if (selectedObjects.size > 0) { +  ({{selectedObjects.size}} selected) + } +
+ } + @if (collectionSize > 20) { + + } +
+ } diff --git a/src-ui/src/app/components/manage/management-list/management-list.component.spec.ts b/src-ui/src/app/components/manage/management-list/management-list.component.spec.ts index 280c40ca8..710d3018a 100644 --- a/src-ui/src/app/components/manage/management-list/management-list.component.spec.ts +++ b/src-ui/src/app/components/manage/management-list/management-list.component.spec.ts @@ -13,7 +13,6 @@ import { NgbModalModule, NgbModalRef, NgbPaginationModule, - NgbPopoverModule, } from '@ng-bootstrap/ng-bootstrap' import { of, throwError } from 'rxjs' import { Tag } from 'src/app/data/tag' @@ -38,7 +37,6 @@ import { MATCH_NONE } from 'src/app/data/matching-model' import { MATCH_LITERAL } from 'src/app/data/matching-model' import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' -import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component' import { BulkEditObjectOperation } from 'src/app/services/rest/abstract-name-filter-service' const tags: Tag[] = [ @@ -78,7 +76,6 @@ describe('ManagementListComponent', () => { SafeHtmlPipe, ConfirmDialogComponent, PermissionsDialogComponent, - ConfirmButtonComponent, ], providers: [ { @@ -100,7 +97,6 @@ describe('ManagementListComponent', () => { NgbModalModule, RouterTestingModule.withRoutes(routes), NgxBootstrapIconsModule.pick(allIcons), - NgbPopoverModule, ], }).compileComponents() @@ -197,23 +193,27 @@ describe('ManagementListComponent', () => { }) it('should support delete, show notification on error / success', () => { + let modal: NgbModalRef + modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1])) const toastErrorSpy = jest.spyOn(toastService, 'showError') const deleteSpy = jest.spyOn(tagService, 'delete') const reloadSpy = jest.spyOn(component, 'reloadData') - const deleteButton = fixture.debugElement.query( - By.directive(ConfirmButtonComponent) - ) + const deleteButton = fixture.debugElement.queryAll(By.css('button'))[8] + deleteButton.triggerEventHandler('click') + + expect(modal).not.toBeUndefined() + const editDialog = modal.componentInstance as ConfirmDialogComponent // fail first deleteSpy.mockReturnValueOnce(throwError(() => new Error('error deleting'))) - deleteButton.nativeElement.dispatchEvent(new Event('confirm')) + editDialog.confirmClicked.emit() expect(toastErrorSpy).toHaveBeenCalled() expect(reloadSpy).not.toHaveBeenCalled() // succeed deleteSpy.mockReturnValueOnce(of(true)) - deleteButton.nativeElement.dispatchEvent(new Event('confirm')) + editDialog.confirmClicked.emit() expect(reloadSpy).toHaveBeenCalled() }) diff --git a/src-ui/src/app/components/manage/management-list/management-list.component.ts b/src-ui/src/app/components/manage/management-list/management-list.component.ts index 8f0947f1c..0b0365f06 100644 --- a/src-ui/src/app/components/manage/management-list/management-list.component.ts +++ b/src-ui/src/app/components/manage/management-list/management-list.component.ts @@ -15,7 +15,10 @@ import { MATCH_NONE, } from 'src/app/data/matching-model' import { ObjectWithId } from 'src/app/data/object-with-id' -import { ObjectWithPermissions } from 'src/app/data/object-with-permissions' +import { + ObjectWithPermissions, + PermissionsObject, +} from 'src/app/data/object-with-permissions' import { SortableDirective, SortEvent, @@ -194,21 +197,34 @@ export abstract class ManagementListComponent ]) } - deleteObject(object: T) { - this.service - .delete(object) - .pipe(takeUntil(this.unsubscribeNotifier)) - .subscribe({ - next: () => { - this.reloadData() - }, - error: (error) => { - this.toastService.showError( - $localize`Error while deleting element`, - error - ) - }, - }) + openDeleteDialog(object: T) { + var activeModal = this.modalService.open(ConfirmDialogComponent, { + backdrop: 'static', + }) + activeModal.componentInstance.title = $localize`Confirm delete` + activeModal.componentInstance.messageBold = this.getDeleteMessage(object) + activeModal.componentInstance.message = $localize`Associated documents will not be deleted.` + activeModal.componentInstance.btnClass = 'btn-danger' + activeModal.componentInstance.btnCaption = $localize`Delete` + activeModal.componentInstance.confirmClicked.subscribe(() => { + activeModal.componentInstance.buttonsEnabled = false + this.service + .delete(object) + .pipe(takeUntil(this.unsubscribeNotifier)) + .subscribe({ + next: () => { + activeModal.close() + this.reloadData() + }, + error: (error) => { + activeModal.componentInstance.buttonsEnabled = true + this.toastService.showError( + $localize`Error while deleting element`, + error + ) + }, + }) + }) } get nameFilter() { diff --git a/src-ui/src/app/guards/dirty-form.guard.spec.ts b/src-ui/src/app/guards/dirty-form.guard.spec.ts index 24ee24f74..c5c473b27 100644 --- a/src-ui/src/app/guards/dirty-form.guard.spec.ts +++ b/src-ui/src/app/guards/dirty-form.guard.spec.ts @@ -17,6 +17,7 @@ describe('DirtyFormGuard', () => { let guard: DirtyFormGuard let component: DirtyComponent let route: ActivatedRoute + let modalService: NgbModal beforeEach(() => { TestBed.configureTestingModule({ @@ -37,6 +38,7 @@ describe('DirtyFormGuard', () => { guard = TestBed.inject(DirtyFormGuard) route = TestBed.inject(ActivatedRoute) + modalService = TestBed.inject(NgbModal) const fixture = TestBed.createComponent(GenericDirtyComponent) component = fixture.componentInstance @@ -57,9 +59,14 @@ describe('DirtyFormGuard', () => { component.isDirty$ = true const confirmSpy = jest.spyOn(guard, 'confirmChanges') const canDeactivate = guard.canDeactivate(component, route.snapshot) + let modal + modalService.activeInstances.subscribe((instances) => { + modal = instances[0] + }) canDeactivate.subscribe() expect(canDeactivate).toHaveProperty('source') // Observable expect(confirmSpy).toHaveBeenCalled() + modal.componentInstance.confirmClicked.next() }) }) diff --git a/src-ui/src/app/services/open-documents.service.spec.ts b/src-ui/src/app/services/open-documents.service.spec.ts index 3c8e29edd..69d2a4a37 100644 --- a/src-ui/src/app/services/open-documents.service.spec.ts +++ b/src-ui/src/app/services/open-documents.service.spec.ts @@ -108,6 +108,7 @@ describe('OpenDocumentsService', () => { }) it('should close documents', () => { + openDocumentsService.closeDocument({ id: 999 } as any) subscriptions.push( openDocumentsService.openDocument(documents[0]).subscribe() ) @@ -128,15 +129,21 @@ describe('OpenDocumentsService', () => { subscriptions.push( openDocumentsService.openDocument(documents[0]).subscribe() ) + openDocumentsService.setDirty({ id: 999 }, true) // coverage openDocumentsService.setDirty(documents[0], false) expect(openDocumentsService.hasDirty()).toBeFalsy() openDocumentsService.setDirty(documents[0], true) expect(openDocumentsService.hasDirty()).toBeTruthy() + let openModal + modalService.activeInstances.subscribe((instances) => { + openModal = instances[0] + }) const modalSpy = jest.spyOn(modalService, 'open') subscriptions.push( openDocumentsService.closeDocument(documents[0]).subscribe() ) expect(modalSpy).toHaveBeenCalled() + openModal.componentInstance.confirmClicked.next() }) it('should allow set dirty status, warn on closeAll', () => { @@ -148,9 +155,14 @@ describe('OpenDocumentsService', () => { ) openDocumentsService.setDirty(documents[0], true) expect(openDocumentsService.hasDirty()).toBeTruthy() + let openModal + modalService.activeInstances.subscribe((instances) => { + openModal = instances[0] + }) const modalSpy = jest.spyOn(modalService, 'open') subscriptions.push(openDocumentsService.closeAll().subscribe()) expect(modalSpy).toHaveBeenCalled() + openModal.componentInstance.confirmClicked.next() }) it('should load open documents from localStorage', () => { diff --git a/src-ui/src/app/services/rest/mail-account.service.spec.ts b/src-ui/src/app/services/rest/mail-account.service.spec.ts index 80a66f28b..64974d834 100644 --- a/src-ui/src/app/services/rest/mail-account.service.spec.ts +++ b/src-ui/src/app/services/rest/mail-account.service.spec.ts @@ -58,12 +58,25 @@ describe(`Additional service tests for MailAccountService`, () => { it('should support patchMany', () => { subscription = service.patchMany(mail_accounts).subscribe() mail_accounts.forEach((mail_account) => { - const reqs = httpTestingController.match( + const req = httpTestingController.expectOne( `${environment.apiBaseUrl}${endpoint}/${mail_account.id}/` ) - expect(reqs).toHaveLength(1) - expect(reqs[0].request.method).toEqual('PATCH') + expect(req.request.method).toEqual('PATCH') + req.flush(mail_account) }) + httpTestingController.expectOne( + `${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000` + ) + }) + + it('should support reload', () => { + service['reload']() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000` + ) + expect(req.request.method).toEqual('GET') + req.flush({ results: mail_accounts }) + expect(service.allAccounts).toEqual(mail_accounts) }) beforeEach(() => { diff --git a/src-ui/src/app/services/rest/mail-rule.service.spec.ts b/src-ui/src/app/services/rest/mail-rule.service.spec.ts index cc5ac9928..ea84e8b86 100644 --- a/src-ui/src/app/services/rest/mail-rule.service.spec.ts +++ b/src-ui/src/app/services/rest/mail-rule.service.spec.ts @@ -76,12 +76,26 @@ describe(`Additional service tests for MailRuleService`, () => { it('should support patchMany', () => { subscription = service.patchMany(mail_rules).subscribe() mail_rules.forEach((mail_rule) => { - const reqs = httpTestingController.match( + const req = httpTestingController.expectOne( `${environment.apiBaseUrl}${endpoint}/${mail_rule.id}/` ) - expect(reqs).toHaveLength(1) - expect(reqs[0].request.method).toEqual('PATCH') + expect(req.request.method).toEqual('PATCH') + req.flush(mail_rule) }) + const reloadReq = httpTestingController.expectOne( + `${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000` + ) + reloadReq.flush({ results: mail_rules }) + }) + + it('should support reload', () => { + service['reload']() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000` + ) + expect(req.request.method).toEqual('GET') + req.flush({ results: mail_rules }) + expect(service.allRules).toEqual(mail_rules) }) beforeEach(() => { diff --git a/src-ui/src/styles.scss b/src-ui/src/styles.scss index 1b982b2b0..bc1efaf23 100644 --- a/src-ui/src/styles.scss +++ b/src-ui/src/styles.scss @@ -262,7 +262,7 @@ a.btn-link:focus-visible, } .ng-dropdown-panel .ng-dropdown-panel-items .ng-option.ng-option-marked { - background-color: var(--pngx-bg-darker) !important; + background-color: var(--pngx-bg-alt) !important; color: var(--pngx-body-color-accent) !important; } @@ -439,7 +439,7 @@ ul.pagination { color: var(--bs-body-color); &:hover, &:focus { - background-color: var(--pngx-bg-darker); + background-color: var(--pngx-bg-alt); color: var(--bs-body-color); } diff --git a/src/documents/management/commands/document_consumer.py b/src/documents/management/commands/document_consumer.py index 919530544..97f9fcc59 100644 --- a/src/documents/management/commands/document_consumer.py +++ b/src/documents/management/commands/document_consumer.py @@ -286,10 +286,10 @@ class Command(BaseCommand): def handle_inotify(self, directory, recursive, is_testing: bool): logger.info(f"Using inotify to watch directory for changes: {directory}") - timeout = None + timeout_ms = None if is_testing: - timeout = self.testing_timeout_ms - logger.debug(f"Configuring timeout to {timeout}ms") + timeout_ms = self.testing_timeout_ms + logger.debug(f"Configuring timeout to {timeout_ms}ms") inotify = INotify() inotify_flags = flags.CLOSE_WRITE | flags.MOVED_TO | flags.MODIFY @@ -298,7 +298,8 @@ class Command(BaseCommand): else: descriptor = inotify.add_watch(directory, inotify_flags) - inotify_debounce: Final[float] = settings.CONSUMER_INOTIFY_DELAY + inotify_debounce_secs: Final[float] = settings.CONSUMER_INOTIFY_DELAY + inotify_debounce_ms: Final[int] = inotify_debounce_secs * 1000 finished = False @@ -306,7 +307,7 @@ class Command(BaseCommand): while not finished: try: - for event in inotify.read(timeout=timeout): + for event in inotify.read(timeout=timeout_ms): path = inotify.get_path(event.wd) if recursive else directory filepath = os.path.join(path, event.name) if flags.MODIFY in flags.from_mask(event.mask): @@ -323,7 +324,7 @@ class Command(BaseCommand): # Current time - last time over the configured timeout waited_long_enough = ( monotonic() - last_event_time - ) > inotify_debounce + ) > inotify_debounce_secs # Also make sure the file exists still, some scanners might write a # temporary file first @@ -342,11 +343,11 @@ class Command(BaseCommand): # If files are waiting, need to exit read() to check them # Otherwise, go back to infinite sleep time, but only if not testing if len(notified_files) > 0: - timeout = inotify_debounce + timeout_ms = inotify_debounce_ms elif is_testing: - timeout = self.testing_timeout_ms + timeout_ms = self.testing_timeout_ms else: - timeout = None + timeout_ms = None if self.stop_flag.is_set(): logger.debug("Finishing because event is set") diff --git a/src/documents/migrations/1044_workflow_workflowaction_workflowtrigger_and_more.py b/src/documents/migrations/1044_workflow_workflowaction_workflowtrigger_and_more.py index 521de61b8..2cdd631bb 100644 --- a/src/documents/migrations/1044_workflow_workflowaction_workflowtrigger_and_more.py +++ b/src/documents/migrations/1044_workflow_workflowaction_workflowtrigger_and_more.py @@ -4,26 +4,17 @@ import django.db.models.deletion import multiselectfield.db.fields from django.conf import settings from django.contrib.auth.management import create_permissions -from django.contrib.auth.models import Group -from django.contrib.auth.models import Permission -from django.contrib.auth.models import User from django.db import migrations from django.db import models from django.db import transaction from django.db.models import Q -from documents.models import Correspondent -from documents.models import CustomField -from documents.models import DocumentType -from documents.models import StoragePath -from documents.models import Tag -from documents.models import Workflow -from documents.models import WorkflowAction -from documents.models import WorkflowTrigger -from paperless_mail.models import MailRule - def add_workflow_permissions(apps, schema_editor): + app_name = "auth" + User = apps.get_model(app_label=app_name, model_name="User") + Group = apps.get_model(app_label=app_name, model_name="Group") + Permission = apps.get_model(app_label=app_name, model_name="Permission") # create permissions without waiting for post_migrate signal for app_config in apps.get_app_configs(): app_config.models_module = True @@ -43,6 +34,10 @@ def add_workflow_permissions(apps, schema_editor): def remove_workflow_permissions(apps, schema_editor): + app_name = "auth" + User = apps.get_model(app_label=app_name, model_name="User") + Group = apps.get_model(app_label=app_name, model_name="Group") + Permission = apps.get_model(app_label=app_name, model_name="Permission") workflow_permissions = Permission.objects.filter( codename__contains="workflow", ) @@ -59,15 +54,28 @@ def migrate_consumption_templates(apps, schema_editor): Migrate consumption templates to workflows. At this point ConsumptionTemplate still exists but objects are not returned as their true model so we have to manually do that """ - model_name = "ConsumptionTemplate" app_name = "documents" - ConsumptionTemplate = apps.get_model(app_label=app_name, model_name=model_name) + ConsumptionTemplate = apps.get_model( + app_label=app_name, + model_name="ConsumptionTemplate", + ) + Workflow = apps.get_model(app_label=app_name, model_name="Workflow") + WorkflowAction = apps.get_model(app_label=app_name, model_name="WorkflowAction") + WorkflowTrigger = apps.get_model(app_label=app_name, model_name="WorkflowTrigger") + DocumentType = apps.get_model(app_label=app_name, model_name="DocumentType") + Correspondent = apps.get_model(app_label=app_name, model_name="Correspondent") + StoragePath = apps.get_model(app_label=app_name, model_name="StoragePath") + Tag = apps.get_model(app_label=app_name, model_name="Tag") + CustomField = apps.get_model(app_label=app_name, model_name="CustomField") + MailRule = apps.get_model(app_label="paperless_mail", model_name="MailRule") + User = apps.get_model(app_label="auth", model_name="User") + Group = apps.get_model(app_label="auth", model_name="Group") with transaction.atomic(): for template in ConsumptionTemplate.objects.all(): trigger = WorkflowTrigger( - type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION, + type=1, # WorkflowTriggerType.CONSUMPTION sources=template.sources, filter_path=template.filter_path, filter_filename=template.filter_filename, @@ -143,10 +151,13 @@ def migrate_consumption_templates(apps, schema_editor): def unmigrate_consumption_templates(apps, schema_editor): - model_name = "ConsumptionTemplate" app_name = "documents" - ConsumptionTemplate = apps.get_model(app_label=app_name, model_name=model_name) + ConsumptionTemplate = apps.get_model( + app_label=app_name, + model_name="ConsumptionTemplate", + ) + Workflow = apps.get_model(app_label=app_name, model_name="Workflow") for workflow in Workflow.objects.all(): template = ConsumptionTemplate.objects.create( diff --git a/src/paperless/views.py b/src/paperless/views.py index 1151ceed5..974830d83 100644 --- a/src/paperless/views.py +++ b/src/paperless/views.py @@ -14,7 +14,7 @@ from rest_framework.authtoken.models import Token from rest_framework.filters import OrderingFilter from rest_framework.generics import GenericAPIView from rest_framework.pagination import PageNumberPagination -from rest_framework.permissions import DjangoObjectPermissions +from rest_framework.permissions import DjangoModelPermissions from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView @@ -171,7 +171,7 @@ class ApplicationConfigurationViewSet(ModelViewSet): queryset = ApplicationConfiguration.objects serializer_class = ApplicationConfigurationSerializer - permission_classes = (IsAuthenticated, DjangoObjectPermissions) + permission_classes = (IsAuthenticated, DjangoModelPermissions) class DisconnectSocialAccountView(GenericAPIView):