mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-09-16 21:55:37 -05:00
Compare commits
7 Commits
feature-po
...
chore/sona
Author | SHA1 | Date | |
---|---|---|---|
![]() |
567393921b | ||
![]() |
158f8f6656 | ||
![]() |
372f0bd893 | ||
![]() |
dd563a8259 | ||
![]() |
f6a1d0c3b1 | ||
![]() |
bb3c513b6a | ||
![]() |
d8b1bde1c3 |
68
.github/workflows/ci.yml
vendored
68
.github/workflows/ci.yml
vendored
@@ -151,6 +151,18 @@ jobs:
|
|||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
flags: backend-python-${{ matrix.python-version }}
|
flags: backend-python-${{ matrix.python-version }}
|
||||||
files: coverage.xml
|
files: coverage.xml
|
||||||
|
- name: Upload coverage artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: backend-coverage-${{ matrix.python-version }}
|
||||||
|
path: |
|
||||||
|
.coverage
|
||||||
|
coverage.xml
|
||||||
|
junit.xml
|
||||||
|
retention-days: 1
|
||||||
|
include-hidden-files: true
|
||||||
|
if-no-files-found: error
|
||||||
- name: Stop containers
|
- name: Stop containers
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
@@ -233,6 +245,17 @@ jobs:
|
|||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
flags: frontend-node-${{ matrix.node-version }}
|
flags: frontend-node-${{ matrix.node-version }}
|
||||||
directory: src-ui/coverage/
|
directory: src-ui/coverage/
|
||||||
|
- name: Upload coverage artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: frontend-coverage-${{ matrix.shard-index }}
|
||||||
|
path: |
|
||||||
|
src-ui/coverage/lcov.info
|
||||||
|
src-ui/coverage/coverage-final.json
|
||||||
|
src-ui/junit.xml
|
||||||
|
retention-days: 1
|
||||||
|
if-no-files-found: error
|
||||||
tests-frontend-e2e:
|
tests-frontend-e2e:
|
||||||
name: "Frontend E2E Tests (Node ${{ matrix.node-version }} - ${{ matrix.shard-index }}/${{ matrix.shard-count }})"
|
name: "Frontend E2E Tests (Node ${{ matrix.node-version }} - ${{ matrix.shard-index }}/${{ matrix.shard-count }})"
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
@@ -313,6 +336,51 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
run: cd src-ui && pnpm run build --configuration=production
|
run: cd src-ui && pnpm run build --configuration=production
|
||||||
|
sonarqube-analysis:
|
||||||
|
name: "SonarQube Analysis"
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
needs:
|
||||||
|
- tests-backend
|
||||||
|
- tests-frontend
|
||||||
|
if: github.repository_owner == 'paperless-ngx'
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Download all backend coverage
|
||||||
|
uses: actions/download-artifact@v5.0.0
|
||||||
|
with:
|
||||||
|
pattern: backend-coverage-*
|
||||||
|
path: ./coverage/
|
||||||
|
- name: Download all frontend coverage
|
||||||
|
uses: actions/download-artifact@v5.0.0
|
||||||
|
with:
|
||||||
|
pattern: frontend-coverage-*
|
||||||
|
path: ./coverage/
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
|
- name: Install coverage tools
|
||||||
|
run: |
|
||||||
|
pip install coverage
|
||||||
|
npm install -g nyc
|
||||||
|
# Merge backend coverage from all Python versions
|
||||||
|
- name: Merge backend coverage
|
||||||
|
run: |
|
||||||
|
coverage combine coverage/backend-coverage-*/.coverage
|
||||||
|
coverage xml -o merged-backend-coverage.xml
|
||||||
|
# Merge frontend coverage from all shards
|
||||||
|
- name: Merge frontend coverage
|
||||||
|
run: |
|
||||||
|
mkdir -p .nyc_output
|
||||||
|
npx nyc merge coverage .nyc_output/out.json
|
||||||
|
npx nyc report --reporter=lcovonly --report-dir coverage
|
||||||
|
- name: SonarQube Analysis
|
||||||
|
uses: SonarSource/sonarqube-scan-action@v5
|
||||||
|
env:
|
||||||
|
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||||
build-docker-image:
|
build-docker-image:
|
||||||
name: Build Docker image for ${{ github.ref_name }}
|
name: Build Docker image for ${{ github.ref_name }}
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
|
@@ -192,8 +192,8 @@ The endpoint supports the following optional form fields:
|
|||||||
- `tags`: Similar to correspondent. Specify this multiple times to
|
- `tags`: Similar to correspondent. Specify this multiple times to
|
||||||
have multiple tags added to the document.
|
have multiple tags added to the document.
|
||||||
- `archive_serial_number`: An optional archive serial number to set.
|
- `archive_serial_number`: An optional archive serial number to set.
|
||||||
- `custom_fields`: Either an array of custom field ids to assign (with an empty
|
- `custom_fields`: An array of custom field ids to assign (with an empty
|
||||||
value) to the document or an object mapping field id -> value.
|
value) to the document.
|
||||||
|
|
||||||
The endpoint will immediately return HTTP 200 if the document consumption
|
The endpoint will immediately return HTTP 200 if the document consumption
|
||||||
process was started successfully, with the UUID of the consumption task
|
process was started successfully, with the UUID of the consumption task
|
||||||
|
25
sonar-project.properties
Normal file
25
sonar-project.properties
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
sonar.projectKey=paperless-ngx_paperless-ngx
|
||||||
|
sonar.organization=paperless-ngx
|
||||||
|
sonar.projectName=Paperless-ngx
|
||||||
|
sonar.projectVersion=1.0
|
||||||
|
|
||||||
|
# Source and test directories
|
||||||
|
sonar.sources=src/,src-ui/src/
|
||||||
|
sonar.tests=src/,src-ui/src/
|
||||||
|
sonar.test.inclusions=**/test_*.py,**/tests.py,**/*.spec.ts,**/*.test.ts
|
||||||
|
|
||||||
|
# Language specific settings
|
||||||
|
sonar.python.version=3.10,3.11,3.12,3.13
|
||||||
|
|
||||||
|
# Coverage reports
|
||||||
|
sonar.python.coverage.reportPaths=merged-backend-coverage.xml
|
||||||
|
sonar.javascript.lcov.reportPaths=coverage/lcov.info
|
||||||
|
|
||||||
|
# Test execution reports
|
||||||
|
sonar.junit.reportPaths=**/junit.xml,**/test-results.xml
|
||||||
|
|
||||||
|
# Encoding
|
||||||
|
sonar.sourceEncoding=UTF-8
|
||||||
|
|
||||||
|
# Exclusions
|
||||||
|
sonar.exclusions=**/migrations/**,**/node_modules/**,**/static/**,**/venv/**,**/.venv/**,**/dist/**
|
@@ -1668,8 +1668,9 @@ class PostDocumentSerializer(serializers.Serializer):
|
|||||||
max_value=Document.ARCHIVE_SERIAL_NUMBER_MAX,
|
max_value=Document.ARCHIVE_SERIAL_NUMBER_MAX,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Accept either a list of custom field ids or a dict mapping id -> value
|
custom_fields = serializers.PrimaryKeyRelatedField(
|
||||||
custom_fields = serializers.JSONField(
|
many=True,
|
||||||
|
queryset=CustomField.objects.all(),
|
||||||
label="Custom fields",
|
label="Custom fields",
|
||||||
write_only=True,
|
write_only=True,
|
||||||
required=False,
|
required=False,
|
||||||
@@ -1726,60 +1727,11 @@ class PostDocumentSerializer(serializers.Serializer):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def validate_custom_fields(self, custom_fields):
|
def validate_custom_fields(self, custom_fields):
|
||||||
if not custom_fields:
|
if custom_fields:
|
||||||
|
return [custom_field.id for custom_field in custom_fields]
|
||||||
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Normalize single values to a list
|
|
||||||
if isinstance(custom_fields, int):
|
|
||||||
custom_fields = [custom_fields]
|
|
||||||
if isinstance(custom_fields, dict):
|
|
||||||
custom_field_serializer = CustomFieldInstanceSerializer()
|
|
||||||
normalized = {}
|
|
||||||
for field_id, value in custom_fields.items():
|
|
||||||
try:
|
|
||||||
field_id_int = int(field_id)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
raise serializers.ValidationError(
|
|
||||||
_("Custom field id must be an integer: %(id)s")
|
|
||||||
% {"id": field_id},
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
field = CustomField.objects.get(id=field_id_int)
|
|
||||||
except CustomField.DoesNotExist:
|
|
||||||
raise serializers.ValidationError(
|
|
||||||
_("Custom field with id %(id)s does not exist")
|
|
||||||
% {"id": field_id_int},
|
|
||||||
)
|
|
||||||
custom_field_serializer.validate(
|
|
||||||
{
|
|
||||||
"field": field,
|
|
||||||
"value": value,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
normalized[field_id_int] = value
|
|
||||||
return normalized
|
|
||||||
elif isinstance(custom_fields, list):
|
|
||||||
try:
|
|
||||||
ids = [int(i) for i in custom_fields]
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
raise serializers.ValidationError(
|
|
||||||
_(
|
|
||||||
"Custom fields must be a list of integers or an object mapping ids to values.",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if CustomField.objects.filter(id__in=ids).count() != len(set(ids)):
|
|
||||||
raise serializers.ValidationError(
|
|
||||||
_("Some custom fields don't exist or were specified twice."),
|
|
||||||
)
|
|
||||||
return ids
|
|
||||||
raise serializers.ValidationError(
|
|
||||||
_(
|
|
||||||
"Custom fields must be a list of integers or an object mapping ids to values.",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# custom_fields_w_values handled via validate_custom_fields
|
|
||||||
|
|
||||||
def validate_created(self, created):
|
def validate_created(self, created):
|
||||||
# support datetime format for created for backwards compatibility
|
# support datetime format for created for backwards compatibility
|
||||||
if isinstance(created, datetime):
|
if isinstance(created, datetime):
|
||||||
|
@@ -1,5 +1,4 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import json
|
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
import uuid
|
import uuid
|
||||||
@@ -1538,86 +1537,6 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
|||||||
overrides.update(new_overrides)
|
overrides.update(new_overrides)
|
||||||
self.assertEqual(overrides.custom_fields, {cf.id: None, cf2.id: 123})
|
self.assertEqual(overrides.custom_fields, {cf.id: None, cf2.id: 123})
|
||||||
|
|
||||||
def test_upload_with_custom_field_values(self):
|
|
||||||
"""
|
|
||||||
GIVEN: A document with a source file
|
|
||||||
WHEN: Upload the document with custom fields and values
|
|
||||||
THEN: Metadata is set correctly
|
|
||||||
"""
|
|
||||||
self.consume_file_mock.return_value = celery.result.AsyncResult(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
)
|
|
||||||
|
|
||||||
cf_string = CustomField.objects.create(
|
|
||||||
name="stringfield",
|
|
||||||
data_type=CustomField.FieldDataType.STRING,
|
|
||||||
)
|
|
||||||
cf_int = CustomField.objects.create(
|
|
||||||
name="intfield",
|
|
||||||
data_type=CustomField.FieldDataType.INT,
|
|
||||||
)
|
|
||||||
|
|
||||||
with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
|
|
||||||
response = self.client.post(
|
|
||||||
"/api/documents/post_document/",
|
|
||||||
{
|
|
||||||
"document": f,
|
|
||||||
"custom_fields": json.dumps(
|
|
||||||
{
|
|
||||||
str(cf_string.id): "a string",
|
|
||||||
str(cf_int.id): 123,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
|
|
||||||
self.consume_file_mock.assert_called_once()
|
|
||||||
|
|
||||||
input_doc, overrides = self.get_last_consume_delay_call_args()
|
|
||||||
|
|
||||||
self.assertEqual(input_doc.original_file.name, "simple.pdf")
|
|
||||||
self.assertEqual(overrides.filename, "simple.pdf")
|
|
||||||
self.assertEqual(
|
|
||||||
overrides.custom_fields,
|
|
||||||
{cf_string.id: "a string", cf_int.id: 123},
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_upload_with_custom_fields_errors(self):
|
|
||||||
"""
|
|
||||||
GIVEN: A document with a source file
|
|
||||||
WHEN: Upload the document with invalid custom fields payloads
|
|
||||||
THEN: The upload is rejected
|
|
||||||
"""
|
|
||||||
self.consume_file_mock.return_value = celery.result.AsyncResult(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
)
|
|
||||||
|
|
||||||
error_payloads = [
|
|
||||||
# Non-integer key in mapping
|
|
||||||
{"custom_fields": json.dumps({"abc": "a string"})},
|
|
||||||
# List with non-integer entry
|
|
||||||
{"custom_fields": json.dumps(["abc"])},
|
|
||||||
# Nonexistent id in mapping
|
|
||||||
{"custom_fields": json.dumps({99999999: "a string"})},
|
|
||||||
# Nonexistent id in list
|
|
||||||
{"custom_fields": json.dumps([99999999])},
|
|
||||||
# Invalid type (JSON string, not list/dict/int)
|
|
||||||
{"custom_fields": json.dumps("not-a-supported-structure")},
|
|
||||||
]
|
|
||||||
|
|
||||||
for payload in error_payloads:
|
|
||||||
with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
|
|
||||||
data = {"document": f, **payload}
|
|
||||||
response = self.client.post(
|
|
||||||
"/api/documents/post_document/",
|
|
||||||
data,
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
self.consume_file_mock.assert_not_called()
|
|
||||||
|
|
||||||
def test_upload_with_webui_source(self):
|
def test_upload_with_webui_source(self):
|
||||||
"""
|
"""
|
||||||
GIVEN: A document with a source file
|
GIVEN: A document with a source file
|
||||||
|
@@ -1497,7 +1497,7 @@ class PostDocumentView(GenericAPIView):
|
|||||||
title = serializer.validated_data.get("title")
|
title = serializer.validated_data.get("title")
|
||||||
created = serializer.validated_data.get("created")
|
created = serializer.validated_data.get("created")
|
||||||
archive_serial_number = serializer.validated_data.get("archive_serial_number")
|
archive_serial_number = serializer.validated_data.get("archive_serial_number")
|
||||||
cf = serializer.validated_data.get("custom_fields")
|
custom_field_ids = serializer.validated_data.get("custom_fields")
|
||||||
from_webui = serializer.validated_data.get("from_webui")
|
from_webui = serializer.validated_data.get("from_webui")
|
||||||
|
|
||||||
t = int(mktime(datetime.now().timetuple()))
|
t = int(mktime(datetime.now().timetuple()))
|
||||||
@@ -1516,11 +1516,6 @@ class PostDocumentView(GenericAPIView):
|
|||||||
source=DocumentSource.WebUI if from_webui else DocumentSource.ApiUpload,
|
source=DocumentSource.WebUI if from_webui else DocumentSource.ApiUpload,
|
||||||
original_file=temp_file_path,
|
original_file=temp_file_path,
|
||||||
)
|
)
|
||||||
custom_fields = None
|
|
||||||
if isinstance(cf, dict) and cf:
|
|
||||||
custom_fields = cf
|
|
||||||
elif isinstance(cf, list) and cf:
|
|
||||||
custom_fields = dict.fromkeys(cf, None)
|
|
||||||
input_doc_overrides = DocumentMetadataOverrides(
|
input_doc_overrides = DocumentMetadataOverrides(
|
||||||
filename=doc_name,
|
filename=doc_name,
|
||||||
title=title,
|
title=title,
|
||||||
@@ -1531,7 +1526,10 @@ class PostDocumentView(GenericAPIView):
|
|||||||
created=created,
|
created=created,
|
||||||
asn=archive_serial_number,
|
asn=archive_serial_number,
|
||||||
owner_id=request.user.id,
|
owner_id=request.user.id,
|
||||||
custom_fields=custom_fields,
|
# TODO: set values
|
||||||
|
custom_fields={cf_id: None for cf_id in custom_field_ids}
|
||||||
|
if custom_field_ids
|
||||||
|
else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
async_task = consume_file.delay(
|
async_task = consume_file.delay(
|
||||||
|
Reference in New Issue
Block a user