Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
e397e37
Remove leading zeroes from query formatted COs if using separator
alesan99 Jul 15, 2025
20e1dbf
Redo fix; Restore original functionality
alesan99 Jul 15, 2025
1b5d243
Fix batch edit crash
alesan99 Jul 16, 2025
9412fd0
Add exceptions for batch_edit
alesan99 Jul 16, 2025
8fa66d8
Merge branch 'main' into issue-7037
alesan99 Aug 4, 2025
02f670f
Lint code with ESLint and Prettier
alesan99 Aug 4, 2025
b3966de
Add record formatter option to convert field into number
alesan99 Aug 6, 2025
8e5f073
Restore old behavior
alesan99 Aug 6, 2025
c290b07
Format on frontend
alesan99 Aug 6, 2025
8b03481
Format on frontend
alesan99 Aug 6, 2025
20cb2e9
Update tests
alesan99 Aug 6, 2025
d5c4a3f
Lint code with ESLint and Prettier
alesan99 Aug 6, 2025
ae7ec2e
Improve labels
alesan99 Aug 11, 2025
3dfae51
Merge branch 'issue-7037' of https://github.com/specify/specify7 into…
alesan99 Aug 11, 2025
76c4ea7
Fallback on NaN values
alesan99 Aug 11, 2025
bc7aac9
Refactor
alesan99 Aug 11, 2025
85c847a
Rename numeric to trimZeros on frontend
alesan99 Aug 11, 2025
96d8556
Fix toggling trimZeros on backend
alesan99 Aug 11, 2025
487bda8
Merge branch 'main' into issue-7037
alesan99 Aug 11, 2025
43f1f17
Add commnet
alesan99 Aug 11, 2025
6f53a35
Fix tests
alesan99 Aug 11, 2025
1dcc68b
Remove temp uiformatters fallback
alesan99 Aug 12, 2025
d9e9e13
Merge remote-tracking branch 'origin/main' into issue-7037
alesan99 Aug 12, 2025
bfe6b7c
Lint code with ESLint and Prettier
alesan99 Aug 12, 2025
8ad7289
Revert merge overwrite
alesan99 Aug 12, 2025
909ef37
Lint code with ESLint and Prettier
alesan99 Aug 12, 2025
98f3898
Merge branch 'main' into issue-7037
alesan99 Aug 18, 2025
c349ff1
Undo
alesan99 Aug 18, 2025
07e0d88
Lint code with ESLint and Prettier
alesan99 Aug 18, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions specifyweb/backend/stored_queries/batch_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
from specifyweb.specify.func import Func
from . import models
import json
from .format import ObjectFormatterProps

from specifyweb.backend.workbench.upload.upload_plan_schema import schema
from jsonschema import validate
Expand Down Expand Up @@ -1133,10 +1134,15 @@ def run_batch_edit_query(props: BatchEditProps):
field_specs=query_with_hidden,
limit=limit,
offset=offset,
format_agent_type=True,
recordsetid=recordsetid,
formatauditobjs=False,
format_picklist=True,
formatter_props=ObjectFormatterProps(
format_agent_type=True,
format_picklist=True,
format_types=False,
numeric_catalog_number=False,
format_expr=False,
)
)

to_many_planner = indexed.to_many_planner()
Expand Down
21 changes: 11 additions & 10 deletions specifyweb/backend/stored_queries/execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from specifyweb.specify.tree_utils import get_search_filters

from . import models
from .format import ObjectFormatter
from .format import ObjectFormatter, ObjectFormatterProps
from .query_construct import QueryConstruct
from .queryfield import QueryField
from .relative_date_utils import apply_absolute_date
Expand Down Expand Up @@ -54,16 +54,20 @@ class QuerySort:
def by_id(sort_id: int):
return QuerySort.SORT_TYPES[sort_id]


class BuildQueryProps(NamedTuple):
recordsetid: int | None = None
replace_nulls: bool = False
formatauditobjs: bool = False
distinct: bool = False
series: bool = False
implicit_or: bool = True
format_agent_type: bool = False
format_picklist: bool = False
formatter_props: ObjectFormatterProps = ObjectFormatterProps(
format_agent_type = False,
format_picklist = False,
format_types = True,
numeric_catalog_number = True,
format_expr = True,
)


def set_group_concat_max_len(connection):
Expand Down Expand Up @@ -786,10 +790,9 @@ def execute(
field_specs,
limit,
offset,
format_agent_type=False,
recordsetid=None,
formatauditobjs=False,
format_picklist=False,
formatter_props=ObjectFormatterProps(),
):
"Build and execute a query, returning the results as a data structure for json serialization"

Expand All @@ -805,8 +808,7 @@ def execute(
formatauditobjs=formatauditobjs,
distinct=distinct,
series=series,
format_agent_type=format_agent_type,
format_picklist=format_picklist,
formatter_props=formatter_props,
),
)

Expand Down Expand Up @@ -921,8 +923,7 @@ def build_query(
collection,
user,
props.replace_nulls,
format_agent_type=props.format_agent_type,
format_picklist=props.format_picklist,
props=props.formatter_props,
),
query=query_construct_query
)
Expand Down
35 changes: 28 additions & 7 deletions specifyweb/backend/stored_queries/format.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,25 @@
from .blank_nulls import blank_nulls
from .query_construct import QueryConstruct
from .queryfieldspec import QueryFieldSpec
from typing import NamedTuple

logger = logging.getLogger(__name__)

CollectionObject_model = datamodel.get_table('CollectionObject')
Agent_model = datamodel.get_table('Agent')
Spauditlog_model = datamodel.get_table('SpAuditLog')

class ObjectFormatterProps(NamedTuple):
format_agent_type: bool = False,
format_picklist: bool = False,
format_types: bool = True,
numeric_catalog_number: bool = True,
# format_expr determines if make_expr should call _fieldformat, like in versions before 7.10.2.
# Batch edit expects it to be false to correctly handle some edge cases.
format_expr: bool = False

class ObjectFormatter:
def __init__(self, collection, user, replace_nulls, format_agent_type=False, format_picklist=False):
def __init__(self, collection, user, replace_nulls, props: ObjectFormatterProps = ObjectFormatterProps()):

formattersXML, _, __ = app_resource.get_app_resource(collection, user, 'DataObjFormatters')
self.formattersDom = ElementTree.fromstring(formattersXML)
Expand All @@ -49,8 +58,11 @@ def __init__(self, collection, user, replace_nulls, format_agent_type=False, for
self.collection = collection
self.replace_nulls = replace_nulls
self.aggregator_count = 0
self.format_agent_type = format_agent_type
self.format_picklist = format_picklist
self.format_agent_type = props.format_agent_type
self.format_picklist = props.format_picklist
self.format_types = props.format_types
self.numeric_catalog_number = props.numeric_catalog_number
self.format_expr = props.format_expr

def getFormatterDef(self, specify_model: Table, formatter_name) -> Element | None:

Expand Down Expand Up @@ -190,6 +202,15 @@ def make_expr(self,
new_query, table, model, specify_field = query.build_join(
specify_model, orm_table, formatter_field_spec.join_path)
new_expr = getattr(table, specify_field.name)

if self.format_expr:
new_expr = self._fieldformat(formatter_field_spec.table, formatter_field_spec.get_field(), new_expr)

if 'trimzeros' in fieldNodeAttrib:
new_expr = case(
[(new_expr.op('REGEXP')('^-?[0-9]+(\\.[0-9]+)?$'), cast(new_expr, types.Numeric(65)))],
else_=new_expr
)

if 'format' in fieldNodeAttrib:
new_expr = self.pseudo_sprintf(fieldNodeAttrib['format'], new_expr)
Expand Down Expand Up @@ -391,21 +412,21 @@ def _fieldformat(self, table: Table, specify_field: Field,

return blank_nulls(_case) if self.replace_nulls else _case

if specify_field.type == "java.lang.Boolean":
if self.format_types and specify_field.type == "java.lang.Boolean":
return field != 0

if specify_field.type in ("java.lang.Integer", "java.lang.Short"):
if self.format_types and specify_field.type in ("java.lang.Integer", "java.lang.Short"):
return field

if specify_field is CollectionObject_model.get_field('catalogNumber') \
if self.numeric_catalog_number and specify_field is CollectionObject_model.get_field('catalogNumber') \
and all_numeric_catnum_formats(self.collection):
# While the frontend can format the catalogNumber if needed,
# processes like reports, labels, and query exports generally
# expect the catalogNumber to be numeric if possible.
# See https://github.com/specify/specify7/issues/6464
return cast(field, types.Numeric(65))

if specify_field.type == 'json' and isinstance(field.comparator.type, types.JSON):
if self.format_types and specify_field.type == 'json' and isinstance(field.comparator.type, types.JSON):
return cast(field, types.Text)

return field
Expand Down
Original file line number Diff line number Diff line change
@@ -1,63 +1,68 @@
import React from 'react';
import { mount } from '../../../tests/reactUtils';
import { UploadAttachment } from '../Plugin';
import { clearIdStore } from '../../../hooks/useId';
import { LoadingContext } from '../../Core/Contexts';
import { f } from '../../../utils/functools';
import { fireEvent, waitFor } from '@testing-library/react';
import { overrideAttachmentSettings } from '../attachments';
import { fireEvent, waitFor } from "@testing-library/react";
import React from "react";

import { clearIdStore } from "../../../hooks/useId";
import { overrideAjax } from "../../../tests/ajax";
import attachmentSettings from '../../../tests/ajax/static/context/attachment_settings.json';
import { overrideAjax } from '../../../tests/ajax';
import * as Attachments from '../attachments';
import { requireContext } from '../../../tests/helpers';
import { deserializeResource } from '../../DataModel/serializers';
import { testAttachment } from './utils';
import { SpecifyResource } from '../../DataModel/legacyTypes';
import { Attachment } from '../../DataModel/types';
import { requireContext } from "../../../tests/helpers";
import { mount } from "../../../tests/reactUtils";
import { f } from "../../../utils/functools";
import { LoadingContext } from "../../Core/Contexts";
import { SpecifyResource } from "../../DataModel/legacyTypes";
import { deserializeResource } from "../../DataModel/serializers";
import { Attachment } from "../../DataModel/types";
import { overrideAttachmentSettings } from "../attachments";
import * as Attachments from "../attachments";
import { UploadAttachment } from "../Plugin";
import { testAttachment } from "./utils";

requireContext();


async function uploadFileMock() {
return deserializeResource(testAttachment) as SpecifyResource<Attachment>;
return deserializeResource(testAttachment) ;
}

beforeEach(() => {
clearIdStore();
clearIdStore();
});

describe('UploadAttachment', () => {
const testToken = 'testToken';
const testAttachmentLocation = 'testLocation';

overrideAjax(
`/attachment_gw/get_upload_params/`,
[{ token: testToken, attachmentLocation: testAttachmentLocation }],
{ method: 'POST' }
);

test('simple render', async () => {
jest.spyOn(Attachments, 'uploadFile').mockImplementation(uploadFileMock);
jest.spyOn(console, 'warn').mockImplementation();
const handleUploaded = jest.fn();

overrideAttachmentSettings(attachmentSettings);
const { asFragment, container, user } = mount(
<LoadingContext.Provider value={f.void}>
<UploadAttachment onUploaded={handleUploaded} />
</LoadingContext.Provider>
describe("UploadAttachment", () => {

const testToken = 'testToken';
const testAttachmentLocation = 'testLocation';

overrideAjax(
`/attachment_gw/get_upload_params/`,
[{ token: testToken, attachmentLocation: testAttachmentLocation }],
{ method: 'POST' }
);
expect(asFragment()).toMatchSnapshot();

const input = Array.from(container.getElementsByTagName('input'))[0];
const testFile = new File(['Some Text Contents'], 'testName', {
type: 'text/plain',
});
test("simple render", async () => {
jest.spyOn(Attachments, 'uploadFile').mockImplementation(uploadFileMock);
jest.spyOn(console, 'warn').mockImplementation();
const handleUploaded = jest.fn();

await user.upload(input, testFile);
fireEvent.change(input, { target: { files: [testFile] } });
overrideAttachmentSettings(attachmentSettings);
const { asFragment, container, user } = mount(
<LoadingContext.Provider value={f.void}>
<UploadAttachment onUploaded={handleUploaded} />
</LoadingContext.Provider>
);
expect(asFragment()).toMatchSnapshot();

const input = Array.from(container.getElementsByTagName('input'))[0];
const testFile = new File(['Some Text Contents'], 'testName', {
type: 'text/plain',
});

await user.upload(input, testFile);
fireEvent.change(input, { target: { files: [testFile] } });

await waitFor(() => {
expect(handleUploaded).toHaveBeenCalled();
});

await waitFor(() => {
expect(handleUploaded).toBeCalled();
});
});
});

})
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ function ConditionalFormatter({
formatter: undefined,
fieldFormatter: undefined,
field: undefined,
trimZeros: false,
},
],
},
Expand Down
Loading