mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-11-01 20:45:35 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
471c4d1cc5 | ||
|
|
5b63c9a0ff | ||
|
|
217c5e681d | ||
|
|
45e99cde5a | ||
|
|
7ea58eaf46 | ||
|
|
a19804a0b7 | ||
|
|
3289be7383 | ||
|
|
d98918dd97 | ||
|
|
34c88613f6 | ||
|
|
13acdabb47 |
No files matched your search
+1
-1
@@ -30,7 +30,7 @@ RUN set -eux \
|
||||
# Purpose: Installs s6-overlay and rootfs
|
||||
# Comments:
|
||||
# - Don't leave anything extra in here either
|
||||
FROM ghcr.io/astral-sh/uv:0.13.5-python3.14-trixie-slim@sha256:d8e732839dfc4225a1ff698803b478b2223c05636c055d3add41ab9dbec0f315 AS s6-overlay-base
|
||||
FROM ghcr.io/astral-sh/uv:0.13.6-python3.14-trixie-slim@sha256:637619278855a77885a94312efa7decd8479ca6cec5f959e134cbab96582821e AS s6-overlay-base
|
||||
|
||||
WORKDIR /usr/src/s6
|
||||
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
# Changelog
|
||||
|
||||
## paperless-ngx 3.4.1
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix the id\_\_ schemae in the right place [@shamoon](https://github.com/shamoon) ([#14604](https://github.com/paperless-ngx/paperless-ngx/pull/14604))
|
||||
- Fix: drop redundant change perms checks on cards open button [@shamoon](https://github.com/shamoon) ([#14601](https://github.com/paperless-ngx/paperless-ngx/pull/14601))
|
||||
- Tweak/fix: make new pdf controls mostly hidden [@shamoon](https://github.com/shamoon) ([#14595](https://github.com/paperless-ngx/paperless-ngx/pull/14595))
|
||||
- Tweak/fix: move preview button in doc list [@shamoon](https://github.com/shamoon) ([#14594](https://github.com/paperless-ngx/paperless-ngx/pull/14594))
|
||||
- Fix: checksum light mode display and cf dropdown mobile position [@shamoon](https://github.com/shamoon) ([#14590](https://github.com/paperless-ngx/paperless-ngx/pull/14590))
|
||||
|
||||
### All App Changes
|
||||
|
||||
<details>
|
||||
<summary>6 changes</summary>
|
||||
|
||||
- Fix the id\_\_ schemae in the right place [@shamoon](https://github.com/shamoon) ([#14604](https://github.com/paperless-ngx/paperless-ngx/pull/14604))
|
||||
- Fix: drop redundant change perms checks on cards open button [@shamoon](https://github.com/shamoon) ([#14601](https://github.com/paperless-ngx/paperless-ngx/pull/14601))
|
||||
- Chore: Improve idle memory via more lazy imports [@stumpylog](https://github.com/stumpylog) ([#14599](https://github.com/paperless-ngx/paperless-ngx/pull/14599))
|
||||
- Tweak/fix: make new pdf controls mostly hidden [@shamoon](https://github.com/shamoon) ([#14595](https://github.com/paperless-ngx/paperless-ngx/pull/14595))
|
||||
- Tweak/fix: move preview button in doc list [@shamoon](https://github.com/shamoon) ([#14594](https://github.com/paperless-ngx/paperless-ngx/pull/14594))
|
||||
- Fix: checksum light mode display and cf dropdown mobile position [@shamoon](https://github.com/shamoon) ([#14590](https://github.com/paperless-ngx/paperless-ngx/pull/14590))
|
||||
|
||||
</details>
|
||||
|
||||
## paperless-ngx 3.4.0
|
||||
|
||||
### Features / Enhancements
|
||||
|
||||
+28
-28
@@ -1402,7 +1402,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">239,240</context>
|
||||
<context context-type="linenumber">240,241</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/data/document.ts</context>
|
||||
@@ -1733,7 +1733,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">248,249</context>
|
||||
<context context-type="linenumber">249,250</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
|
||||
@@ -1768,7 +1768,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">257,258</context>
|
||||
<context context-type="linenumber">258,259</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
|
||||
@@ -1811,7 +1811,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">221,222</context>
|
||||
<context context-type="linenumber">222,223</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
|
||||
@@ -2289,7 +2289,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">266,267</context>
|
||||
<context context-type="linenumber">267,268</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-bundle-list/share-link-bundle-list.component.html</context>
|
||||
@@ -4115,7 +4115,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">388</context>
|
||||
<context context-type="linenumber">391</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7515883357904500238" datatype="html">
|
||||
@@ -4389,7 +4389,7 @@
|
||||
<source>Create new field</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.html</context>
|
||||
<context context-type="linenumber">20</context>
|
||||
<context context-type="linenumber">19</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6973528734330066202" datatype="html">
|
||||
@@ -4786,7 +4786,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">275,276</context>
|
||||
<context context-type="linenumber">276,277</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/data/document.ts</context>
|
||||
@@ -6604,7 +6604,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">218,219</context>
|
||||
<context context-type="linenumber">219,220</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
||||
@@ -8410,7 +8410,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">320</context>
|
||||
<context context-type="linenumber">321</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="78870852467682010" datatype="html">
|
||||
@@ -8425,7 +8425,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">360</context>
|
||||
<context context-type="linenumber">363</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="157572966557284263" datatype="html">
|
||||
@@ -8440,7 +8440,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">367</context>
|
||||
<context context-type="linenumber">370</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="883965278435032344" datatype="html">
|
||||
@@ -8458,7 +8458,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">388</context>
|
||||
<context context-type="linenumber">391</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="872092479747931526" datatype="html">
|
||||
@@ -9919,7 +9919,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">336</context>
|
||||
<context context-type="linenumber">339</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="106713086593101376" datatype="html">
|
||||
@@ -10234,21 +10234,21 @@
|
||||
<source>Sort by title</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">213</context>
|
||||
<context context-type="linenumber">214</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6232673011753681091" datatype="html">
|
||||
<source>Sort by owner</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">226</context>
|
||||
<context context-type="linenumber">227</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3715596725146409911" datatype="html">
|
||||
<source>Owner</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">230,231</context>
|
||||
<context context-type="linenumber">231,232</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/data/document.ts</context>
|
||||
@@ -10263,49 +10263,49 @@
|
||||
<source>Sort by notes</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">235</context>
|
||||
<context context-type="linenumber">236</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5499001829734502606" datatype="html">
|
||||
<source>Sort by document type</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">244</context>
|
||||
<context context-type="linenumber">245</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6213829731736042759" datatype="html">
|
||||
<source>Sort by storage path</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">253</context>
|
||||
<context context-type="linenumber">254</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3406167410329973166" datatype="html">
|
||||
<source>Sort by created date</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">262</context>
|
||||
<context context-type="linenumber">263</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3769035778779263084" datatype="html">
|
||||
<source>Sort by added date</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">271</context>
|
||||
<context context-type="linenumber">272</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4874754501044009042" datatype="html">
|
||||
<source>Sort by number of pages</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">280</context>
|
||||
<context context-type="linenumber">281</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3817498941817715969" datatype="html">
|
||||
<source>Pages</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">284,285</context>
|
||||
<context context-type="linenumber">285,286</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/data/document.ts</context>
|
||||
@@ -10324,28 +10324,28 @@
|
||||
<source> Shared </source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">288</context>
|
||||
<context context-type="linenumber">289</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5083658411133224968" datatype="html">
|
||||
<source>Sort by <x id="INTERPOLATION" equiv-text="{{getDisplayCustomFieldTitle(field_id)}}"/></source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">294,295</context>
|
||||
<context context-type="linenumber">295,296</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
<context context-type="linenumber">327</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="2179847500064178686" datatype="html">
|
||||
<source>Edit document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">331</context>
|
||||
<context context-type="linenumber">335</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4512084577073831437" datatype="html">
|
||||
|
||||
+2
-4
@@ -11,14 +11,12 @@
|
||||
</div>
|
||||
@for (field of filteredFields; track field.id) {
|
||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addField(field)" #button>
|
||||
<small class="d-flex">{{field.name}} <small class="ms-auto text-muted">{{getDataTypeLabel(field.data_type)}}</small></small>
|
||||
<div class="d-flex">{{field.name}} <small class="ms-auto text-muted">{{getDataTypeLabel(field.data_type)}}</small></div>
|
||||
</button>
|
||||
}
|
||||
@if (!filterText?.length || filteredFields.length === 0) {
|
||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="createField(filterText)" [disabled]="!canCreateFields" #button>
|
||||
<small>
|
||||
<i-bs width=".9em" height=".9em" name="asterisk" class="me-1"></i-bs><ng-container i18n>Create new field</ng-container>
|
||||
</small>
|
||||
<i-bs width=".9em" height=".9em" name="asterisk" class="me-1"></i-bs><ng-container i18n>Create new field</ng-container>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -485,7 +485,7 @@
|
||||
</div>
|
||||
}
|
||||
<div class="input-group input-group-sm w-auto flex-nowrap">
|
||||
<button class="btn btn-outline-secondary" (click)="decreaseZoom()" aria-label="Zoom out" i18n-aria-label>
|
||||
<button class="btn btn-outline-secondary" type="button" (click)="decreaseZoom()" aria-label="Zoom out" i18n-aria-label>
|
||||
<i-bs width="1em" height="1em" name="dash"></i-bs>
|
||||
</button>
|
||||
<select class="form-select" (change)="setZoom($event.target.value)" [ngModelOptions]="{standalone: true}" [ngModel]="currentZoom" aria-label="Zoom" i18n-aria-label>
|
||||
@@ -495,7 +495,7 @@
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
<button class="btn btn-outline-secondary" (click)="increaseZoom()" aria-label="Zoom in" i18n-aria-label>
|
||||
<button class="btn btn-outline-secondary" type="button" (click)="increaseZoom()" aria-label="Zoom in" i18n-aria-label>
|
||||
<i-bs width="1em" height="1em" name="plus"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
+2
@@ -47,6 +47,8 @@
|
||||
<span>{{ change.key | titlecase }}</span>:
|
||||
@if (change.key === 'content') {
|
||||
<code class="text-primary">{{ change.value[1]?.substring(0,100) }}...</code>
|
||||
} @else if (change.key === 'tags') {
|
||||
<code class="text-primary">{{ getTagNames($any(change.value[1])) | async }}</code>
|
||||
} @else {
|
||||
<code class="text-primary">{{ getPrettyName(change.key, change.value[1]) | async }}</code>
|
||||
}
|
||||
|
||||
+34
@@ -12,6 +12,7 @@ import { CorrespondentService } from 'src/app/services/rest/correspondent.servic
|
||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||
import { TagService } from 'src/app/services/rest/tag.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { DocumentHistoryComponent } from './document-history.component'
|
||||
|
||||
@@ -174,6 +175,39 @@ describe('DocumentHistoryComponent', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should render bulk tag history with names and preserve missing tag IDs', () => {
|
||||
const getCachedSpy = jest
|
||||
.spyOn(TestBed.inject(TagService), 'getCached')
|
||||
.mockImplementation((id) =>
|
||||
of(id === 1 ? { id: 1, name: 'Invoice' } : null)
|
||||
)
|
||||
component.entries.set([
|
||||
{
|
||||
id: 1,
|
||||
action: AuditLogAction.Update,
|
||||
timestamp: '2021-01-01T00:00:00Z',
|
||||
remote_addr: null,
|
||||
changes: JSON.parse('{"tags":[[],[1,2345]]}'),
|
||||
},
|
||||
])
|
||||
component.loading.set(false)
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(fixture.nativeElement.querySelector('code').textContent).toBe(
|
||||
'Invoice, 2345'
|
||||
)
|
||||
fixture.detectChanges()
|
||||
expect(getCachedSpy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should handle removal of all tags without a lookup', () => {
|
||||
const getCachedSpy = jest.spyOn(TestBed.inject(TagService), 'getCached')
|
||||
component.getTagNames([]).subscribe((result) => {
|
||||
expect(result).toBe('')
|
||||
})
|
||||
expect(getCachedSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('getPrettyName should return the value as is for other types', () => {
|
||||
const id = '123'
|
||||
component.getPrettyName('other', id).subscribe((result) => {
|
||||
|
||||
+27
-1
@@ -2,7 +2,7 @@ import { AsyncPipe, KeyValuePipe, TitleCasePipe } from '@angular/common'
|
||||
import { Component, Input, OnInit, inject, signal } from '@angular/core'
|
||||
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { Observable, first, map, of, shareReplay } from 'rxjs'
|
||||
import { Observable, first, forkJoin, map, of, shareReplay } from 'rxjs'
|
||||
import { AuditLogAction, AuditLogEntry } from 'src/app/data/auditlog-entry'
|
||||
import { DataType } from 'src/app/data/datatype'
|
||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
@@ -10,6 +10,7 @@ import { CorrespondentService } from 'src/app/services/rest/correspondent.servic
|
||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||
import { TagService } from 'src/app/services/rest/tag.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
|
||||
@Component({
|
||||
@@ -30,6 +31,7 @@ export class DocumentHistoryComponent implements OnInit {
|
||||
private readonly correspondentService = inject(CorrespondentService)
|
||||
private readonly storagePathService = inject(StoragePathService)
|
||||
private readonly documentTypeService = inject(DocumentTypeService)
|
||||
private readonly tagService = inject(TagService)
|
||||
private readonly userService = inject(UserService)
|
||||
|
||||
public AuditLogAction = AuditLogAction
|
||||
@@ -63,6 +65,30 @@ export class DocumentHistoryComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
getTagNames(tagIds: number[]): Observable<string> {
|
||||
const cacheKey = `tags:${JSON.stringify(tagIds)}`
|
||||
const cachedNames = this.prettyNameCache.get(cacheKey)
|
||||
if (cachedNames) {
|
||||
return cachedNames
|
||||
}
|
||||
|
||||
const tagNames$ = tagIds.length
|
||||
? forkJoin(
|
||||
tagIds.map((tagId) =>
|
||||
this.tagService.getCached(tagId).pipe(
|
||||
first(),
|
||||
map((tag) => tag?.name ?? String(tagId))
|
||||
)
|
||||
)
|
||||
).pipe(map((names) => names.join(', ')))
|
||||
: of('')
|
||||
const names$ = tagNames$.pipe(
|
||||
shareReplay({ bufferSize: 1, refCount: true })
|
||||
)
|
||||
this.prettyNameCache.set(cacheKey, names$)
|
||||
return names$
|
||||
}
|
||||
|
||||
getPrettyName(type: DataType | string, id: string): Observable<string> {
|
||||
const cacheKey = `${type}:${id}`
|
||||
const cached = this.prettyNameCache.get(cacheKey)
|
||||
|
||||
@@ -208,7 +208,8 @@
|
||||
i18n>Correspondent</th>
|
||||
}
|
||||
@if (activeDisplayFields.includes(DisplayField.TITLE)) {
|
||||
<th class="cursor-pointer ps-5"
|
||||
<th style="width: 2rem"><!-- Preview --></th>
|
||||
<th class="cursor-pointer"
|
||||
pngxSortable="title"
|
||||
title="Sort by title" i18n-title
|
||||
[currentSortField]="list.sortField"
|
||||
@@ -321,15 +322,17 @@
|
||||
}
|
||||
</td>
|
||||
}
|
||||
@if (activeDisplayFields.includes(DisplayField.TITLE)) {
|
||||
<td class="pe-0 text-end align-baseline" (mouseleave)="popupPreview.close()">
|
||||
<pngx-preview-popup [document]="d" linkClasses="btn btn-sm btn-link text-secondary px-1" linkTitle="Preview document" (click)="$event.stopPropagation()" i18n-linkTitle #popupPreview>
|
||||
<i-bs name="eye"></i-bs>
|
||||
</pngx-preview-popup>
|
||||
</td>
|
||||
}
|
||||
@if (activeDisplayFields.includes(DisplayField.TITLE) || activeDisplayFields.includes(DisplayField.TAGS)) {
|
||||
<td width="30%" class="position-relative" [class.ps-5]="activeDisplayFields.includes(DisplayField.TITLE)">
|
||||
<td width="30%" class="align-baseline">
|
||||
@if (activeDisplayFields.includes(DisplayField.TITLE)) {
|
||||
<div class="d-inline-block" (mouseleave)="popupPreview.close()">
|
||||
<pngx-preview-popup class="position-absolute start-0" [document]="d" linkClasses="btn btn-sm btn-link text-secondary" linkTitle="Preview document" (click)="$event.stopPropagation()" i18n-linkTitle #popupPreview>
|
||||
<i-bs name="eye"></i-bs>
|
||||
</pngx-preview-popup>
|
||||
<a routerLink="/documents/{{d.id}}" title="Edit document" i18n-title style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a>
|
||||
</div>
|
||||
<a routerLink="/documents/{{d.id}}" title="Edit document" i18n-title style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a>
|
||||
}
|
||||
@if (activeDisplayFields.includes(DisplayField.TAGS)) {
|
||||
@for (tagID of d.tags; track tagID) {
|
||||
|
||||
@@ -87,10 +87,6 @@ $paperless-card-breakpoints: (
|
||||
}
|
||||
}
|
||||
|
||||
.table .ps-5 {
|
||||
padding-left: 2.2rem !important;
|
||||
}
|
||||
|
||||
a {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export interface SystemStatus {
|
||||
status: SystemStatusItemStatus
|
||||
error?: string
|
||||
migration_status: {
|
||||
latest_migration: string
|
||||
latest_migration: string | null
|
||||
unapplied_migrations: string[]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,13 +476,13 @@ def update_filename_and_move_files(
|
||||
return
|
||||
|
||||
with FileLock(settings.MEDIA_LOCK):
|
||||
try:
|
||||
# If this was waiting for the lock, the filename or archive_filename
|
||||
# of this document may have been updated. This happens if multiple updates
|
||||
# get queued from the UI for the same document
|
||||
# So freshen up the data before doing anything
|
||||
instance.refresh_from_db()
|
||||
# If this was waiting for the lock, the filename or archive_filename
|
||||
# of this document may have been updated. This happens if multiple updates
|
||||
# get queued from the UI for the same document
|
||||
# So freshen up the data before doing anything
|
||||
instance.refresh_from_db()
|
||||
|
||||
try:
|
||||
old_filename = instance.filename
|
||||
old_source_path = instance.source_path
|
||||
move_original = False
|
||||
|
||||
@@ -44,7 +44,10 @@ def localize_date(value: date | datetime | str, format: str, locale: str) -> str
|
||||
|
||||
def format_datetime(value: str | datetime, format: str) -> str:
|
||||
if isinstance(value, str):
|
||||
value = parse_date(value)
|
||||
parsed = parse_datetime(value) or parse_date(value)
|
||||
if parsed is None:
|
||||
raise ValueError(f"Invalid date or datetime string: {value!r}")
|
||||
return parsed.strftime(format=format)
|
||||
return value.strftime(format=format)
|
||||
|
||||
|
||||
|
||||
@@ -539,3 +539,50 @@ class TestSystemStatus(APITestCase):
|
||||
self.assertEqual(response.data["tasks"]["summary"]["success_count"], 1)
|
||||
self.assertEqual(response.data["tasks"]["summary"]["failure_count"], 1)
|
||||
self.assertEqual(response.data["tasks"]["summary"]["pending_count"], 1)
|
||||
|
||||
def test_system_status_reports_database_error(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Reading the migration state fails
|
||||
WHEN:
|
||||
- The system status is requested
|
||||
THEN:
|
||||
- The response reports the database error rather than a server error
|
||||
"""
|
||||
self.client.force_login(self.user)
|
||||
with mock.patch(
|
||||
"documents.views.MigrationLoader",
|
||||
side_effect=Exception("database is down"),
|
||||
):
|
||||
response = self.client.get(self.ENDPOINT)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
database = response.data["database"]
|
||||
self.assertEqual(database["status"], "ERROR")
|
||||
self.assertIsNotNone(database["error"])
|
||||
self.assertEqual(
|
||||
database["migration_status"],
|
||||
{"latest_migration": None, "unapplied_migrations": []},
|
||||
)
|
||||
|
||||
def test_system_status_migration_query_error(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- The migration graph loads but reading the applied migrations fails
|
||||
WHEN:
|
||||
- The system status is requested
|
||||
THEN:
|
||||
- No migrations are reported as unapplied, since the state is unknown
|
||||
"""
|
||||
self.client.force_login(self.user)
|
||||
with mock.patch("documents.views.MigrationRecorder") as recorder:
|
||||
recorder.Migration.objects.all.side_effect = Exception("query failed")
|
||||
response = self.client.get(self.ENDPOINT)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
database = response.data["database"]
|
||||
self.assertEqual(database["status"], "ERROR")
|
||||
self.assertEqual(
|
||||
database["migration_status"],
|
||||
{"latest_migration": None, "unapplied_migrations": []},
|
||||
)
|
||||
@@ -31,6 +31,7 @@ from documents.serialisers import DocumentSerializer
|
||||
from documents.tasks import empty_trash
|
||||
from paperless_testing.assertions import FileSystemAssertsMixin
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
from paperless_testing.dirs import PaperlessDirs
|
||||
from paperless_testing.factories import DocumentFactory
|
||||
from paperless_testing.factories import UserFactory
|
||||
|
||||
@@ -2134,3 +2135,28 @@ class TestPathDateLocalization:
|
||||
with override_settings(FILENAME_FORMAT=filename_format):
|
||||
filename = generate_filename(document)
|
||||
assert filename == Path(expected_filename)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_refresh_failure_surfaces_database_error(
|
||||
paperless_dirs: PaperlessDirs,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A document with a stored filename
|
||||
WHEN:
|
||||
- Saving it triggers a filename update and refreshing it from the database fails
|
||||
THEN:
|
||||
- The database error is raised, not an unbound variable error from the rollback
|
||||
"""
|
||||
document = DocumentFactory(filename="some.pdf")
|
||||
|
||||
with (
|
||||
mock.patch.object(
|
||||
Document,
|
||||
"refresh_from_db",
|
||||
side_effect=DatabaseError("database is locked"),
|
||||
),
|
||||
pytest.raises(DatabaseError, match="database is locked"),
|
||||
):
|
||||
document.save()
|
||||
@@ -4,6 +4,7 @@ from typing import Literal
|
||||
|
||||
import pytest
|
||||
|
||||
from documents.templating.filters import format_datetime
|
||||
from documents.templating.filters import localize_date
|
||||
|
||||
|
||||
@@ -294,3 +295,23 @@ class TestDateLocalization:
|
||||
Tests `localize_date` with `date` string across different locales and formats.
|
||||
"""
|
||||
assert localize_date(value, format_style, locale_str) == expected_output
|
||||
|
||||
|
||||
class TestFormatDatetime:
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
pytest.param("2026-09-21", "2026-09-21 00:00", id="date"),
|
||||
pytest.param(
|
||||
"2026-09-21T13:45:00+00:00",
|
||||
"2026-09-21 13:45",
|
||||
id="datetime",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_parses_iso_strings(self, value: str, expected: str) -> None:
|
||||
assert format_datetime(value, "%Y-%m-%d %H:%M") == expected
|
||||
|
||||
def test_rejects_unparseable_string(self) -> None:
|
||||
with pytest.raises(ValueError, match="not a date"):
|
||||
format_datetime("not a date", "%Y")
|
||||
+10
-3
@@ -5268,7 +5268,9 @@ class CustomFieldViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet[Custom
|
||||
"migration_status": inline_serializer(
|
||||
name="MigrationStatus",
|
||||
fields={
|
||||
"latest_migration": serializers.CharField(),
|
||||
"latest_migration": serializers.CharField(
|
||||
allow_null=True,
|
||||
),
|
||||
"unapplied_migrations": serializers.ListSerializer(
|
||||
child=serializers.CharField(),
|
||||
),
|
||||
@@ -5343,6 +5345,8 @@ class SystemStatusView(PassUserMixin):
|
||||
db_conn = connections["default"]
|
||||
db_url = str(db_conn.settings_dict["NAME"])
|
||||
db_error = None
|
||||
all_migrations: list[str] = []
|
||||
applied_migrations: list[str] = []
|
||||
|
||||
try:
|
||||
db_conn.ensure_connection()
|
||||
@@ -5353,7 +5357,8 @@ class SystemStatusView(PassUserMixin):
|
||||
f"{m.app}.{m.name}"
|
||||
for m in MigrationRecorder.Migration.objects.all().order_by("id")
|
||||
]
|
||||
except Exception as e: # pragma: no cover
|
||||
except Exception as e:
|
||||
all_migrations = []
|
||||
applied_migrations = []
|
||||
db_status = "ERROR"
|
||||
logger.exception(
|
||||
@@ -5549,7 +5554,9 @@ class SystemStatusView(PassUserMixin):
|
||||
"status": db_status,
|
||||
"error": db_error,
|
||||
"migration_status": {
|
||||
"latest_migration": applied_migrations[-1],
|
||||
"latest_migration": (
|
||||
applied_migrations[-1] if applied_migrations else None
|
||||
),
|
||||
"unapplied_migrations": [
|
||||
m for m in all_migrations if m not in applied_migrations
|
||||
],
|
||||
|
||||
@@ -2,7 +2,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-10-31 04:47+0000\n"
|
||||
"POT-Creation-Date: 2026-10-31 16:33+0000\n"
|
||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
@@ -2274,151 +2274,151 @@ msgstr ""
|
||||
msgid "paperless application settings"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:566
|
||||
#: paperless/settings/__init__.py:571
|
||||
msgid "English (US)"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:567
|
||||
#: paperless/settings/__init__.py:572
|
||||
msgid "Arabic"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:568
|
||||
#: paperless/settings/__init__.py:573
|
||||
msgid "Afrikaans"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:569
|
||||
#: paperless/settings/__init__.py:574
|
||||
msgid "Belarusian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:570
|
||||
#: paperless/settings/__init__.py:575
|
||||
msgid "Bulgarian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:571
|
||||
#: paperless/settings/__init__.py:576
|
||||
msgid "Catalan"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:572
|
||||
#: paperless/settings/__init__.py:577
|
||||
msgid "Czech"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:573
|
||||
#: paperless/settings/__init__.py:578
|
||||
msgid "Danish"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:574
|
||||
#: paperless/settings/__init__.py:579
|
||||
msgid "German"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:575
|
||||
#: paperless/settings/__init__.py:580
|
||||
msgid "Greek"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:576
|
||||
#: paperless/settings/__init__.py:581
|
||||
msgid "English (GB)"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:577
|
||||
#: paperless/settings/__init__.py:582
|
||||
msgid "Spanish"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:578
|
||||
#: paperless/settings/__init__.py:583
|
||||
msgid "Persian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:579
|
||||
#: paperless/settings/__init__.py:584
|
||||
msgid "Finnish"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:580
|
||||
#: paperless/settings/__init__.py:585
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:581
|
||||
#: paperless/settings/__init__.py:586
|
||||
msgid "Hungarian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:582
|
||||
#: paperless/settings/__init__.py:587
|
||||
msgid "Indonesian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:583
|
||||
#: paperless/settings/__init__.py:588
|
||||
msgid "Italian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:584
|
||||
#: paperless/settings/__init__.py:589
|
||||
msgid "Japanese"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:585
|
||||
#: paperless/settings/__init__.py:590
|
||||
msgid "Korean"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:586
|
||||
#: paperless/settings/__init__.py:591
|
||||
msgid "Luxembourgish"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:587
|
||||
#: paperless/settings/__init__.py:592
|
||||
msgid "Norwegian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:588
|
||||
#: paperless/settings/__init__.py:593
|
||||
msgid "Dutch"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:589
|
||||
#: paperless/settings/__init__.py:594
|
||||
msgid "Polish"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:590
|
||||
#: paperless/settings/__init__.py:595
|
||||
msgid "Portuguese (Brazil)"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:591
|
||||
#: paperless/settings/__init__.py:596
|
||||
msgid "Portuguese"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:592
|
||||
#: paperless/settings/__init__.py:597
|
||||
msgid "Romanian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:593
|
||||
#: paperless/settings/__init__.py:598
|
||||
msgid "Russian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:594
|
||||
#: paperless/settings/__init__.py:599
|
||||
msgid "Slovak"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:595
|
||||
#: paperless/settings/__init__.py:600
|
||||
msgid "Slovenian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:596
|
||||
#: paperless/settings/__init__.py:601
|
||||
msgid "Serbian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:597
|
||||
#: paperless/settings/__init__.py:602
|
||||
msgid "Swedish"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:598
|
||||
#: paperless/settings/__init__.py:603
|
||||
msgid "Turkish"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:599
|
||||
#: paperless/settings/__init__.py:604
|
||||
msgid "Ukrainian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:600
|
||||
#: paperless/settings/__init__.py:605
|
||||
msgid "Vietnamese"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:601
|
||||
#: paperless/settings/__init__.py:606
|
||||
msgid "Chinese Simplified"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:602
|
||||
#: paperless/settings/__init__.py:607
|
||||
msgid "Chinese Traditional"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -461,9 +461,14 @@ def _parse_paperless_url():
|
||||
global CSRF_TRUSTED_ORIGINS, CORS_ALLOWED_ORIGINS, ALLOWED_HOSTS
|
||||
url = os.getenv("PAPERLESS_URL")
|
||||
if url:
|
||||
hostname = urlparse(url).hostname
|
||||
if not hostname:
|
||||
raise ImproperlyConfigured(
|
||||
f"PAPERLESS_URL {url!r} has no hostname, expected a URL like https://example.com",
|
||||
)
|
||||
CSRF_TRUSTED_ORIGINS.append(url)
|
||||
CORS_ALLOWED_ORIGINS.append(url)
|
||||
ALLOWED_HOSTS.append(urlparse(url).hostname)
|
||||
ALLOWED_HOSTS.append(hostname)
|
||||
|
||||
return url
|
||||
|
||||
|
||||
@@ -166,3 +166,22 @@ class TestPaperlessURLSettings(TestCase):
|
||||
|
||||
self.assertIn(url, settings.CSRF_TRUSTED_ORIGINS)
|
||||
self.assertIn(url, settings.CORS_ALLOWED_ORIGINS)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", ["example.com", "https://", "/just/a/path"])
|
||||
def test_paperless_url_without_hostname_is_rejected(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
url: str,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- PAPERLESS_URL is set to a value with no hostname
|
||||
WHEN:
|
||||
- The URL is parsed
|
||||
THEN:
|
||||
- A configuration error is raised instead of adding None to ALLOWED_HOSTS
|
||||
"""
|
||||
monkeypatch.setenv("PAPERLESS_URL", url)
|
||||
|
||||
with pytest.raises(ImproperlyConfigured, match="no hostname"):
|
||||
_parse_paperless_url()
|
||||
@@ -169,9 +169,9 @@ class TagMailAction(BaseMailAction):
|
||||
# The custom tag should look like "apple:<color>"
|
||||
if "apple:" in parameter.lower():
|
||||
_, self.color = parameter.split(":")
|
||||
self.color = self.color.strip()
|
||||
self.color = self.color.strip().lower()
|
||||
|
||||
if self.color.lower() not in APPLE_MAIL_TAG_COLORS:
|
||||
if self.color not in APPLE_MAIL_TAG_COLORS:
|
||||
raise MailError("Not a valid AppleMail tag color.")
|
||||
|
||||
self.keyword = None
|
||||
|
||||
@@ -89,6 +89,9 @@ class PaperlessMailOAuth2Manager:
|
||||
"""
|
||||
logger = logging.getLogger("paperless_mail")
|
||||
logger.debug(f"Attempting to refresh oauth token for account {account}")
|
||||
if account.refresh_token is None:
|
||||
logger.error(f"Account {account} has no refresh token to use")
|
||||
return False
|
||||
try:
|
||||
result: OAuth2Token
|
||||
if account.account_type == MailAccount.MailAccountType.GMAIL_OAUTH:
|
||||
@@ -103,6 +106,9 @@ class PaperlessMailOAuth2Manager:
|
||||
refresh_token=account.refresh_token,
|
||||
),
|
||||
)
|
||||
else:
|
||||
logger.error(f"Account {account} does not use OAuth")
|
||||
return False
|
||||
if "refresh_token" in result:
|
||||
# Outlook returns a new refresh token on refresh, Gmail does not
|
||||
account.refresh_token = result["refresh_token"]
|
||||
|
||||
@@ -23,6 +23,7 @@ from rest_framework.test import APITestCase
|
||||
from documents.models import Correspondent
|
||||
from documents.models import MatchingModel
|
||||
from paperless_mail import tasks
|
||||
from paperless_mail.mail import APPLE_MAIL_TAG_COLORS
|
||||
from paperless_mail.mail import MailAccountHandler
|
||||
from paperless_mail.mail import MailError
|
||||
from paperless_mail.mail import TagMailAction
|
||||
@@ -1873,6 +1874,32 @@ class TestErrorCallback:
|
||||
assert processed_mails.get().owner == owner
|
||||
|
||||
|
||||
class TestTagMailActionColor:
|
||||
@pytest.mark.parametrize(
|
||||
"parameter",
|
||||
["apple:green", "apple:Green", "APPLE: GREEN "],
|
||||
)
|
||||
def test_color_case_is_ignored(self, parameter: str) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- An Apple Mail tag parameter in any letter case
|
||||
WHEN:
|
||||
- The action tags a message
|
||||
THEN:
|
||||
- The lowercase color's flag bits are set on the message
|
||||
"""
|
||||
action = TagMailAction(parameter, supports_gmail_labels=False)
|
||||
mailbox = mock.Mock()
|
||||
|
||||
action.post_consume(mailbox, "1", parameter)
|
||||
|
||||
assert mailbox.flag.call_args_list[1] == mock.call(
|
||||
"1",
|
||||
APPLE_MAIL_TAG_COLORS["green"],
|
||||
value=True,
|
||||
)
|
||||
|
||||
|
||||
class TestManagementCommand(TestCase):
|
||||
@mock.patch(
|
||||
"paperless_mail.management.commands.mail_fetcher.tasks.process_mail_accounts",
|
||||
|
||||
@@ -264,6 +264,28 @@ class TestMailOAuth(
|
||||
MailAccount.objects.filter(imap_server="outlook.office365.com").exists(),
|
||||
)
|
||||
|
||||
def test_oauth_callback_view_unrecognized_scope(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A valid OAuth state and a callback scope that is not Google's
|
||||
WHEN:
|
||||
- OAuth callback is called
|
||||
THEN:
|
||||
- 400 is returned instead of a server error and no account is created
|
||||
"""
|
||||
session = self.client.session
|
||||
session["oauth_state"] = "test_state"
|
||||
session.save()
|
||||
|
||||
for scope in ("", "openid", "https://example.com/other"):
|
||||
with self.subTest(scope=scope):
|
||||
response = self.client.get(
|
||||
"/api/oauth/callback/",
|
||||
{"code": "test_code", "scope": scope, "state": "test_state"},
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertFalse(MailAccount.objects.exists())
|
||||
|
||||
@mock.patch("paperless_mail.mail.get_mailbox")
|
||||
@mock.patch(
|
||||
"httpx_oauth.oauth2.BaseOAuth2.refresh_token",
|
||||
@@ -366,3 +388,39 @@ class TestMailOAuth(
|
||||
f"Failed to refresh oauth token for account {mail_account}: test_error",
|
||||
cm.output[0],
|
||||
)
|
||||
|
||||
def test_refresh_account_oauth_token_non_oauth_account(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A mail account which does not use OAuth
|
||||
WHEN:
|
||||
- A token refresh is requested
|
||||
THEN:
|
||||
- False is returned rather than an unbound variable error
|
||||
"""
|
||||
account = MailAccountFactory(
|
||||
account_type=MailAccount.MailAccountType.IMAP,
|
||||
refresh_token="token",
|
||||
)
|
||||
|
||||
self.assertFalse(
|
||||
PaperlessMailOAuth2Manager().refresh_account_oauth_token(account),
|
||||
)
|
||||
|
||||
def test_refresh_account_oauth_token_without_refresh_token(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- An OAuth mail account with no refresh token stored
|
||||
WHEN:
|
||||
- A token refresh is requested
|
||||
THEN:
|
||||
- False is returned
|
||||
"""
|
||||
account = MailAccountFactory(
|
||||
account_type=MailAccount.MailAccountType.GMAIL_OAUTH,
|
||||
refresh_token=None,
|
||||
)
|
||||
|
||||
self.assertFalse(
|
||||
PaperlessMailOAuth2Manager().refresh_account_oauth_token(account),
|
||||
)
|
||||
@@ -319,6 +319,14 @@ class OauthCallbackView(GenericAPIView[Any]):
|
||||
|
||||
result = oauth_manager.get_outlook_access_token(code)
|
||||
|
||||
else:
|
||||
logger.error(
|
||||
f"Invalid oauth callback request: unrecognized scope {scope!r}",
|
||||
)
|
||||
return HttpResponseBadRequest(
|
||||
"Invalid request, see logs for more detail",
|
||||
)
|
||||
|
||||
access_token = result["access_token"]
|
||||
refresh_token = result["refresh_token"]
|
||||
expires_in = result["expires_in"]
|
||||
|
||||
Reference in new issue
Block a user