mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	Enhancement: allow specifying default currency for Monetary custom field (#7381)
This commit is contained in:
		| @@ -525,7 +525,7 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> | ||||
|           <context context-type="linenumber">347</context> | ||||
|           <context context-type="linenumber">348</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="3768927257183755959" datatype="html"> | ||||
| @@ -544,7 +544,7 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html</context> | ||||
|           <context context-type="linenumber">36</context> | ||||
|           <context context-type="linenumber">41</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component.html</context> | ||||
| @@ -584,7 +584,7 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> | ||||
|           <context context-type="linenumber">339</context> | ||||
|           <context context-type="linenumber">340</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.html</context> | ||||
| @@ -718,7 +718,7 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> | ||||
|           <context context-type="linenumber">356</context> | ||||
|           <context context-type="linenumber">357</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
| @@ -1080,7 +1080,7 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> | ||||
|           <context context-type="linenumber">315</context> | ||||
|           <context context-type="linenumber">316</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> | ||||
| @@ -1628,7 +1628,7 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html</context> | ||||
|           <context context-type="linenumber">35</context> | ||||
|           <context context-type="linenumber">40</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component.html</context> | ||||
| @@ -3456,6 +3456,27 @@ | ||||
|           <context context-type="linenumber">20</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="2739003406164860877" datatype="html"> | ||||
|         <source>Default Currency</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html</context> | ||||
|           <context context-type="linenumber">33</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="7615210738790237590" datatype="html"> | ||||
|         <source>3-character currency code</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html</context> | ||||
|           <context context-type="linenumber">33</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="607636736207886379" datatype="html"> | ||||
|         <source>Use locale</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html</context> | ||||
|           <context context-type="linenumber">33</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="528950215505228201" datatype="html"> | ||||
|         <source>Create new custom field</source> | ||||
|         <context-group purpose="location"> | ||||
| @@ -5874,14 +5895,14 @@ | ||||
|         <source>Content</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> | ||||
|           <context context-type="linenumber">211</context> | ||||
|           <context context-type="linenumber">212</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="218403386307979629" datatype="html"> | ||||
|         <source>Metadata</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> | ||||
|           <context context-type="linenumber">220</context> | ||||
|           <context context-type="linenumber">221</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-detail/metadata-collapse/metadata-collapse.component.ts</context> | ||||
| @@ -5892,119 +5913,119 @@ | ||||
|         <source>Date modified</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> | ||||
|           <context context-type="linenumber">227</context> | ||||
|           <context context-type="linenumber">228</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="6392918669949841614" datatype="html"> | ||||
|         <source>Date added</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> | ||||
|           <context context-type="linenumber">231</context> | ||||
|           <context context-type="linenumber">232</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="146828917013192897" datatype="html"> | ||||
|         <source>Media filename</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> | ||||
|           <context context-type="linenumber">235</context> | ||||
|           <context context-type="linenumber">236</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="4500855521601039868" datatype="html"> | ||||
|         <source>Original filename</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> | ||||
|           <context context-type="linenumber">239</context> | ||||
|           <context context-type="linenumber">240</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="7985558498848210210" datatype="html"> | ||||
|         <source>Original MD5 checksum</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> | ||||
|           <context context-type="linenumber">243</context> | ||||
|           <context context-type="linenumber">244</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="5888243105821763422" datatype="html"> | ||||
|         <source>Original file size</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> | ||||
|           <context context-type="linenumber">247</context> | ||||
|           <context context-type="linenumber">248</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="2696647325713149563" datatype="html"> | ||||
|         <source>Original mime type</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> | ||||
|           <context context-type="linenumber">251</context> | ||||
|           <context context-type="linenumber">252</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="342875990758166588" datatype="html"> | ||||
|         <source>Archive MD5 checksum</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> | ||||
|           <context context-type="linenumber">256</context> | ||||
|           <context context-type="linenumber">257</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="6033581412811562084" datatype="html"> | ||||
|         <source>Archive file size</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> | ||||
|           <context context-type="linenumber">262</context> | ||||
|           <context context-type="linenumber">263</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="6992781481378431874" datatype="html"> | ||||
|         <source>Original document metadata</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> | ||||
|           <context context-type="linenumber">271</context> | ||||
|           <context context-type="linenumber">272</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="2846565152091361585" datatype="html"> | ||||
|         <source>Archived document metadata</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> | ||||
|           <context context-type="linenumber">274</context> | ||||
|           <context context-type="linenumber">275</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="1295614462098694869" datatype="html"> | ||||
|         <source>Preview</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> | ||||
|           <context context-type="linenumber">281</context> | ||||
|           <context context-type="linenumber">282</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="7206723502037428235" datatype="html"> | ||||
|         <source>Notes <x id="START_BLOCK_IF" equiv-text="@if (document?.notes.length) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span class="badge text-bg-secondary ms-1">"/><x id="INTERPOLATION" equiv-text="ngth}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> | ||||
|           <context context-type="linenumber">293,296</context> | ||||
|           <context context-type="linenumber">294,297</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="186236568870281953" datatype="html"> | ||||
|         <source>History</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> | ||||
|           <context context-type="linenumber">304</context> | ||||
|           <context context-type="linenumber">305</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="5129524307369213584" datatype="html"> | ||||
|         <source>Save & next</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> | ||||
|           <context context-type="linenumber">341</context> | ||||
|           <context context-type="linenumber">342</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="4910102545766233758" datatype="html"> | ||||
|         <source>Save & close</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> | ||||
|           <context context-type="linenumber">344</context> | ||||
|           <context context-type="linenumber">345</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="8191371354890763172" datatype="html"> | ||||
|         <source>Enter Password</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> | ||||
|           <context context-type="linenumber">395</context> | ||||
|           <context context-type="linenumber">396</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="2218903673684131427" datatype="html"> | ||||
|   | ||||
| @@ -20,6 +20,12 @@ const customFields: CustomField[] = [ | ||||
|       select_options: ['Option 1', 'Option 2', 'Option 3'], | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     id: 5, | ||||
|     name: 'Field 5', | ||||
|     data_type: CustomFieldDataType.Monetary, | ||||
|     extra_data: { default_currency: 'JPY' }, | ||||
|   }, | ||||
| ] | ||||
| const document: Document = { | ||||
|   id: 1, | ||||
| @@ -112,6 +118,18 @@ describe('CustomFieldDisplayComponent', () => { | ||||
|     expect(component.value).toEqual(100) | ||||
|   }) | ||||
|  | ||||
|   it('should respect explicit default currency', () => { | ||||
|     component['defaultCurrencyCode'] = 'EUR' // mock default locale injection | ||||
|     component.fieldId = 5 | ||||
|     component.document = { | ||||
|       id: 1, | ||||
|       title: 'Doc 1', | ||||
|       custom_fields: [{ field: 5, document: 1, created: null, value: '100' }], | ||||
|     } | ||||
|     expect(component.currency).toEqual('JPY') | ||||
|     expect(component.value).toEqual(100) | ||||
|   }) | ||||
|  | ||||
|   it('should show select value', () => { | ||||
|     expect(component.getSelectValue(customFields[3], 2)).toEqual('Option 3') | ||||
|   }) | ||||
|   | ||||
| @@ -90,7 +90,9 @@ export class CustomFieldDisplayComponent implements OnInit, OnDestroy { | ||||
|     )?.value | ||||
|     if (this.value && this.field.data_type === CustomFieldDataType.Monetary) { | ||||
|       this.currency = | ||||
|         this.value.match(/([A-Z]{3})/)?.[0] ?? this.defaultCurrencyCode | ||||
|         this.value.match(/([A-Z]{3})/)?.[0] ?? | ||||
|         this.field.extra_data?.default_currency ?? | ||||
|         this.defaultCurrencyCode | ||||
|       this.value = parseFloat(this.value.replace(this.currency, '')) | ||||
|     } else if ( | ||||
|       this.value?.length && | ||||
|   | ||||
| @@ -28,6 +28,11 @@ | ||||
|             } | ||||
|           </div> | ||||
|         } | ||||
|         @case (CustomFieldDataType.Monetary) { | ||||
|           <div class="my-3"> | ||||
|             <pngx-input-text i18n-title title="Default Currency" hint="3-character currency code" i18n-hint formControlName="default_currency" placeholder="Use locale" i18n-placeholder autocomplete="off"></pngx-input-text> | ||||
|           </div> | ||||
|         } | ||||
|       } | ||||
|     </div> | ||||
|   </div> | ||||
|   | ||||
| @@ -90,6 +90,7 @@ export class CustomFieldEditDialogComponent | ||||
|       data_type: new FormControl(null), | ||||
|       extra_data: new FormGroup({ | ||||
|         select_options: new FormArray([new FormControl(null)]), | ||||
|         default_currency: new FormControl(null), | ||||
|       }), | ||||
|     }) | ||||
|   } | ||||
|   | ||||
| @@ -52,6 +52,11 @@ describe('MonetaryComponent', () => { | ||||
|     expect(component.defaultCurrencyCode).toEqual('BRL') | ||||
|   }) | ||||
|  | ||||
|   it('should support setting a default currency code', () => { | ||||
|     component.defaultCurrency = 'EUR' | ||||
|     expect(component.defaultCurrencyCode).toEqual('EUR') | ||||
|   }) | ||||
|  | ||||
|   it('should parse monetary value correctly', () => { | ||||
|     expect(component['parseMonetaryValue']('123.4')).toEqual('123.4') | ||||
|     expect(component['parseMonetaryValue']('123.4', true)).toEqual('123.40') | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { Component, forwardRef, Inject, LOCALE_ID } from '@angular/core' | ||||
| import { Component, forwardRef, Inject, Input, LOCALE_ID } from '@angular/core' | ||||
| import { NG_VALUE_ACCESSOR } from '@angular/forms' | ||||
| import { AbstractInputComponent } from '../abstract-input' | ||||
| import { getLocaleCurrencyCode } from '@angular/common' | ||||
| @@ -29,11 +29,16 @@ export class MonetaryComponent extends AbstractInputComponent<string> { | ||||
|  | ||||
|   defaultCurrencyCode: string | ||||
|  | ||||
|   @Input() | ||||
|   set defaultCurrency(currency: string) { | ||||
|     if (currency) this.defaultCurrencyCode = currency | ||||
|   } | ||||
|  | ||||
|   constructor(@Inject(LOCALE_ID) currentLocale: string) { | ||||
|     super() | ||||
|  | ||||
|     this.currency = this.defaultCurrencyCode = | ||||
|       getLocaleCurrencyCode(currentLocale) | ||||
|       this.defaultCurrency ?? getLocaleCurrencyCode(currentLocale) | ||||
|   } | ||||
|  | ||||
|   writeValue(newValue: any): void { | ||||
|   | ||||
| @@ -11,7 +11,7 @@ | ||||
|         } | ||||
|       </div> | ||||
|       <div class="position-relative" [class.col-md-9]="horizontal"> | ||||
|         <input #inputField type="text" class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [disabled]="disabled" [autocomplete]="autocomplete"> | ||||
|         <input #inputField type="text" class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [disabled]="disabled" [autocomplete]="autocomplete" [placeholder]="placeholder"> | ||||
|         @if (hint) { | ||||
|           <small class="form-text text-muted" [innerHTML]="hint | safeHtml"></small> | ||||
|         } | ||||
|   | ||||
| @@ -18,6 +18,9 @@ export class TextComponent extends AbstractInputComponent<string> { | ||||
|   @Input() | ||||
|   autocomplete: string | ||||
|  | ||||
|   @Input() | ||||
|   placeholder: string = '' | ||||
|  | ||||
|   constructor() { | ||||
|     super() | ||||
|   } | ||||
|   | ||||
| @@ -157,6 +157,7 @@ | ||||
|                     @case (CustomFieldDataType.Monetary) { | ||||
|                       <pngx-input-monetary formControlName="value" | ||||
|                       [title]="getCustomFieldFromInstance(fieldInstance)?.name" | ||||
|                       [defaultCurrency]="getCustomFieldFromInstance(fieldInstance)?.extra_data?.default_currency" | ||||
|                       [removable]="userIsOwner" | ||||
|                       (removed)="removeField(fieldInstance)" | ||||
|                       [horizontal]="true" | ||||
|   | ||||
| @@ -57,5 +57,6 @@ export interface CustomField extends ObjectWithId { | ||||
|   created?: Date | ||||
|   extra_data?: { | ||||
|     select_options?: string[] | ||||
|     default_currency?: string | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -507,6 +507,23 @@ class CustomFieldSerializer(serializers.ModelSerializer): | ||||
|             raise serializers.ValidationError( | ||||
|                 {"error": "extra_data.select_options must be a valid list"}, | ||||
|             ) | ||||
|         elif ( | ||||
|             "data_type" in attrs | ||||
|             and attrs["data_type"] == CustomField.FieldDataType.MONETARY | ||||
|             and "extra_data" in attrs | ||||
|             and "default_currency" in attrs["extra_data"] | ||||
|             and attrs["extra_data"]["default_currency"] is not None | ||||
|             and ( | ||||
|                 not isinstance(attrs["extra_data"]["default_currency"], str) | ||||
|                 or ( | ||||
|                     len(attrs["extra_data"]["default_currency"]) > 0 | ||||
|                     and len(attrs["extra_data"]["default_currency"]) != 3 | ||||
|                 ) | ||||
|             ) | ||||
|         ): | ||||
|             raise serializers.ValidationError( | ||||
|                 {"error": "extra_data.default_currency must be a 3-character string"}, | ||||
|             ) | ||||
|         return super().validate(attrs) | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -137,6 +137,66 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase): | ||||
|         ) | ||||
|         self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) | ||||
|  | ||||
|     def test_create_custom_field_monetary_validation(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - Custom field does not exist | ||||
|         WHEN: | ||||
|             - API request to create custom field with invalid default currency option | ||||
|             - API request to create custom field with valid default currency option | ||||
|         THEN: | ||||
|             - HTTP 400 is returned | ||||
|             - HTTP 201 is returned | ||||
|         """ | ||||
|  | ||||
|         # not a string | ||||
|         resp = self.client.post( | ||||
|             self.ENDPOINT, | ||||
|             json.dumps( | ||||
|                 { | ||||
|                     "data_type": "monetary", | ||||
|                     "name": "Monetary Field", | ||||
|                     "extra_data": { | ||||
|                         "default_currency": 123, | ||||
|                     }, | ||||
|                 }, | ||||
|             ), | ||||
|             content_type="application/json", | ||||
|         ) | ||||
|         self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) | ||||
|  | ||||
|         # not a 3-letter currency code | ||||
|         resp = self.client.post( | ||||
|             self.ENDPOINT, | ||||
|             json.dumps( | ||||
|                 { | ||||
|                     "data_type": "monetary", | ||||
|                     "name": "Monetary Field", | ||||
|                     "extra_data": { | ||||
|                         "default_currency": "EU", | ||||
|                     }, | ||||
|                 }, | ||||
|             ), | ||||
|             content_type="application/json", | ||||
|         ) | ||||
|         self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) | ||||
|  | ||||
|         # valid currency code | ||||
|         resp = self.client.post( | ||||
|             self.ENDPOINT, | ||||
|             json.dumps( | ||||
|                 { | ||||
|                     "data_type": "monetary", | ||||
|                     "name": "Monetary Field", | ||||
|                     "extra_data": { | ||||
|                         "default_currency": "EUR", | ||||
|                     }, | ||||
|                 }, | ||||
|             ), | ||||
|             content_type="application/json", | ||||
|         ) | ||||
|         self.assertEqual(resp.status_code, status.HTTP_201_CREATED) | ||||
|  | ||||
|     def test_create_custom_field_instance(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 shamoon
					shamoon