Compare commits

...

4 Commits

Author SHA1 Message Date
2fed7fa4e7 Dynamics list columns with a lot of work ahead 2025-05-06 00:58:47 +02:00
0613efa846 Correcting label update on updating 2025-05-04 17:30:28 +02:00
c8466c557d Adding filter on Entity type 2025-05-04 17:30:08 +02:00
0d7cad945c Handling order by by determinant fields 2025-05-04 16:26:53 +02:00
10 changed files with 244 additions and 64 deletions

View File

@@ -1,7 +1,7 @@
from collections import defaultdict
from collections.abc import Callable, Mapping from collections.abc import Callable, Mapping
from typing import Any, Optional, Union
from pydantic import ValidationInfo, field_validator from pydantic import ValidationInfo, field_validator
from typing import Any, Optional, Union
from fastapi_filter.base.filter import BaseFilterModel from fastapi_filter.base.filter import BaseFilterModel
@@ -24,28 +24,6 @@ _odm_operator_transformer: dict[str, Callable[[Optional[str]], Optional[dict[str
class Filter(BaseFilterModel): class Filter(BaseFilterModel):
"""Base filter for beanie related filters.
Example:
```python
class MyModel:
id: PrimaryKey()
name: StringField(null=True)
count: IntField()
created_at: DatetimeField()
class MyModelFilter(Filter):
id: Optional[int]
id__in: Optional[str]
count: Optional[int]
count__lte: Optional[int]
created_at__gt: Optional[datetime]
name__ne: Optional[str]
name__nin: Optional[list[str]]
name__isnull: Optional[bool]
```
"""
def sort(self): def sort(self):
if not self.ordering_values: if not self.ordering_values:
return None return None
@@ -130,6 +108,51 @@ class Filter(BaseFilterModel):
query[field_name] = value query[field_name] = value
return query return query
@staticmethod
def field_exists(model, field_path: str) -> bool:
if "." in field_path:
[root, field] = field_path.split(".", 1)
return hasattr(model, "model_fields") and root in model.model_fields \
and model.model_fields[root].discriminator == field
return hasattr(model, field_path) or (hasattr(model, "model_fields") and field_path in model.model_fields)
@field_validator("*", mode="before", check_fields=False)
def validate_order_by(cls, value, field: ValidationInfo):
if field.field_name != cls.Constants.ordering_field_name:
return value
if not value:
return None
field_name_usages = defaultdict(list)
duplicated_field_names = set()
for field_name_with_direction in value:
field_name = field_name_with_direction.replace("-", "").replace("+", "")
if not cls.field_exists(cls.Constants.model, field_name):
raise ValueError(f"{field_name} is not a valid ordering field.")
field_name_usages[field_name].append(field_name_with_direction)
if len(field_name_usages[field_name]) > 1:
duplicated_field_names.add(field_name)
if duplicated_field_names:
ambiguous_field_names = ", ".join(
[
field_name_with_direction
for field_name in sorted(duplicated_field_names)
for field_name_with_direction in field_name_usages[field_name]
]
)
raise ValueError(
f"Field names can appear at most once for {cls.Constants.ordering_field_name}. "
f"The following was ambiguous: {ambiguous_field_names}."
)
return value
class FilterSchema(Filter): class FilterSchema(Filter):
label__ilike: Optional[str] = None label__ilike: Optional[str] = None
order_by: Optional[list[str]] = None order_by: Optional[list[str]] = None

View File

@@ -78,13 +78,16 @@ class CrudDocument(BaseModel):
@classmethod @classmethod
async def update(cls, db, model, update_schema): async def update(cls, db, model, update_schema):
model_dict = update_schema.model_dump(mode="json") | {"updated_by": db.partner.id} model_dict = update_schema.model_dump(mode="json") | {"updated_by": db.partner.id, "updated_at": datetime.now(UTC)}
update_query = { update_query = {
"$set": {field: value for field, value in model_dict.items() if field!= "id" } "$set": {field: value for field, value in model_dict.items() if field!= "id" }
} }
await cls._get_collection(db).update_one({"_id": model.id}, update_query) await cls._get_collection(db).update_one({"_id": model.id}, update_query)
return await cls.get(db, model.id) new_model = await cls.get(db, model.id)
if new_model.label != model.label:
await cls._get_collection(db).update_one({"_id": model.id}, {"$set": {"label": new_model.label}})
return new_model
@classmethod @classmethod
async def delete(cls, db, model): async def delete(cls, db, model):

View File

@@ -1,10 +1,12 @@
from datetime import date from datetime import date
from enum import Enum
from typing import List, Literal, Optional from typing import List, Literal, Optional
from fastapi_filter import FilterDepends, with_prefix
from pydantic import Field, BaseModel, ConfigDict from pydantic import Field, BaseModel, ConfigDict
from beanie import PydanticObjectId from beanie import PydanticObjectId
from firm.core.models import CrudDocument, ForeignKey from firm.core.models import CrudDocument, ForeignKey, CrudDocumentConfig
from firm.core.filter import Filter, FilterSchema from firm.core.filter import Filter, FilterSchema
@@ -13,6 +15,10 @@ class EntityType(BaseModel):
def label(self) -> str: def label(self) -> str:
return self.title return self.title
class EntityTypeEnum(str, Enum):
individual = 'individual'
corporation = 'corporation'
institution = 'institution'
class Individual(EntityType): class Individual(EntityType):
model_config = ConfigDict(title='Particulier') model_config = ConfigDict(title='Particulier')
@@ -63,6 +69,9 @@ class Entity(CrudDocument):
Fiche d'un client Fiche d'un client
""" """
model_config = ConfigDict(title='Client') model_config = ConfigDict(title='Client')
document_config = CrudDocumentConfig(
indexes=["entity_data.type"],
)
entity_data: Individual | Corporation | Institution = Field(..., discriminator='type') entity_data: Individual | Corporation | Institution = Field(..., discriminator='type')
address: str = Field(default="", title='Adresse') address: str = Field(default="", title='Adresse')
@@ -73,7 +82,16 @@ class Entity(CrudDocument):
return self.entity_data.label return self.entity_data.label
class EntityDataFilter(Filter):
type__in: Optional[list[EntityTypeEnum]] = None
class Constants(Filter.Constants):
model = EntityType
class EntityFilters(FilterSchema): class EntityFilters(FilterSchema):
entity_data: Optional[EntityDataFilter] = FilterDepends(with_prefix("entity_data", EntityDataFilter))
class Constants(Filter.Constants): class Constants(Filter.Constants):
model = Entity model = Entity
search_model_fields = ["label"] search_model_fields = ["label"]

View File

@@ -1,6 +1,7 @@
import { RJSFSchema } from '@rjsf/utils'; import { RJSFSchema } from '@rjsf/utils';
import i18n from '../../../i18n' import i18n from '../../../i18n'
import { JSONSchema7Definition } from "json-schema"; import { JSONSchema7Definition } from "json-schema";
import { GridColDef } from "@mui/x-data-grid";
const API_URL = "/api/v1"; const API_URL = "/api/v1";
@@ -51,6 +52,10 @@ export const jsonschemaProvider = {
return readSchema return readSchema
}, },
getReadResourceSchema: async (resourceName: string): Promise<CrudRJSFSchema> => {
return getResourceSchema(`${resourceName}Read`)
},
getUpdateResourceSchema: async (resourceName: string): Promise<CrudRJSFSchema> => { getUpdateResourceSchema: async (resourceName: string): Promise<CrudRJSFSchema> => {
return getResourceSchema(`${resourceName}Update`) return getResourceSchema(`${resourceName}Update`)
}, },
@@ -58,21 +63,88 @@ export const jsonschemaProvider = {
getCreateResourceSchema: async (resourceName: string): Promise<CrudRJSFSchema> => { getCreateResourceSchema: async (resourceName: string): Promise<CrudRJSFSchema> => {
return getResourceSchema(`${resourceName}Create`) return getResourceSchema(`${resourceName}Create`)
}, },
getReadResourceColumns: async (resourceName: string): Promise<GridColDef[]> => {
return getColumns(`${resourceName}Read`)
},
}
const getColumns = async (resourceName: string): Promise<GridColDef[]> => {
return buildColumns(await getJsonschema(), resourceName)
}
function buildColumns (rawSchemas: RJSFSchema, resourceName: string, prefix: string|undefined = undefined): GridColDef[] {
if (rawSchemas.components.schemas[resourceName] === undefined) {
throw new Error(`Resource "${resourceName}" not found in schema.`);
}
const shortResourceName = convertCamelToSnake(resourceName.replace(/(-Input|-Output|Create|Update|Read)$/g, ""));
let resource = rawSchemas.components.schemas[resourceName];
let result: GridColDef[] = [];
for (const prop_name in resource.properties) {
let prop = resource.properties[prop_name];
if (is_reference(prop)) {
const subresourceName = get_reference_name(prop);
result = result.concat(buildColumns(rawSchemas, subresourceName, prefix ? `${prefix}.${prop_name}` : prop_name))
} else if (is_union(prop)) {
const union = prop.hasOwnProperty("oneOf") ? prop.oneOf : prop.anyOf;
let seen = new Set<string>();
for (let i in union) {
if (is_reference(union[i])) {
const subresourceName = get_reference_name(union[i]);
const subcolumns = buildColumns(rawSchemas, subresourceName, prefix ? `${prefix}.${prop_name}` : prop_name);
for (const s of subcolumns) {
if (! seen.has(s.field)) {
result.push(s);
seen.add(s.field);
}
}
}
}
} else if (is_enum(prop)) {
let seen = new Set<string>();
for (let i in prop.allOf) {
if (is_reference(prop.allOf[i])) {
const subresourceName = get_reference_name(prop.allOf[i]);
const subcolumns = buildColumns(rawSchemas, subresourceName, prefix ? `${prefix}.${prop_name}` : prop_name);
for (const s of subcolumns) {
if (! seen.has(s.field)) {
result.push(s);
seen.add(s.field);
}
}
}
}
} else if (is_array(prop) && is_reference(prop.items)) {
const subresourceName = get_reference_name(prop.items);
result = result.concat(buildColumns(rawSchemas, subresourceName, prefix ? `${prefix}.${prop_name}` : prop_name))
} else {
let column: GridColDef = {
field: prefix ? `${prefix}.${prop_name}` : prop_name,
headerName: i18n.t(`schemas.${shortResourceName}.${convertCamelToSnake(prop_name)}`, prop.title) as string,
valueGetter: (value: any, row: any ) => {
if (prefix === undefined) {
return value;
}
let parent = row;
for (const column of prefix.split(".")) {
parent = parent[column];
}
return parent ? parent[prop_name] : "";
}
}
result.push(column);
}
}
return result;
} }
const getResourceSchema = async (resourceName: string): Promise<CrudRJSFSchema> => { const getResourceSchema = async (resourceName: string): Promise<CrudRJSFSchema> => {
return buildResource(await getJsonschema(), resourceName) return buildResource(await getJsonschema(), resourceName)
} }
let rawSchema: RJSFSchema;
const getJsonschema = async (): Promise<RJSFSchema> => {
if (rawSchema === undefined) {
const response = await fetch(`${API_URL}/openapi.json`,);
rawSchema = await response.json();
}
return rawSchema;
}
function convertCamelToSnake(str: string): string { function convertCamelToSnake(str: string): string {
return str.replace(/([a-zA-Z])(?=[A-Z])/g,'$1_').toLowerCase() return str.replace(/([a-zA-Z])(?=[A-Z])/g,'$1_').toLowerCase()
} }
@@ -82,7 +154,7 @@ function buildResource(rawSchemas: RJSFSchema, resourceName: string) {
throw new Error(`Resource "${resourceName}" not found in schema.`); throw new Error(`Resource "${resourceName}" not found in schema.`);
} }
const shortResourceName = convertCamelToSnake(resourceName.replace(/(-Input|Create|Update)$/g, "")); const shortResourceName = convertCamelToSnake(resourceName.replace(/(-Input|-Output|Create|Update|Read)$/g, ""));
let resource = structuredClone(rawSchemas.components.schemas[resourceName]); let resource = structuredClone(rawSchemas.components.schemas[resourceName]);
resource.components = { schemas: {} }; resource.components = { schemas: {} };
for (let prop_name in resource.properties) { for (let prop_name in resource.properties) {
@@ -118,6 +190,15 @@ function buildResource(rawSchemas: RJSFSchema, resourceName: string) {
return resource; return resource;
} }
let rawSchema: RJSFSchema;
const getJsonschema = async (): Promise<RJSFSchema> => {
if (rawSchema === undefined) {
const response = await fetch(`${API_URL}/openapi.json`,);
rawSchema = await response.json();
}
return rawSchema;
}
function resolveReference(rawSchemas: RJSFSchema, resource: any, prop_reference: any) { function resolveReference(rawSchemas: RJSFSchema, resource: any, prop_reference: any) {
const subresourceName = get_reference_name(prop_reference); const subresourceName = get_reference_name(prop_reference);
const subresource = buildResource(rawSchemas, subresourceName); const subresource = buildResource(rawSchemas, subresourceName);

View File

@@ -24,9 +24,9 @@ export const ContractRoutes = () => {
const ListContract = () => { const ListContract = () => {
const columns = [ const columns = [
{ field: "label", headerName: "Label", flex: 1 }, { field: "label", column: { flex: 1 }},
]; ];
return <List<Contract> resource={`contracts`} columns={columns} /> return <List<Contract> resource={`contracts`} schemaName={"Contract"} columns={columns} />
} }
const EditContract = () => { const EditContract = () => {

View File

@@ -29,9 +29,9 @@ export const DraftRoutes = () => {
const ListDraft = () => { const ListDraft = () => {
const columns = [ const columns = [
{ field: "label", headerName: "Label", flex: 1 }, { field: "label", column: { flex: 1 }},
]; ];
return <List<Draft> resource={`contracts/drafts`} columns={columns} /> return <List<Draft> resource={`contracts/drafts`} columns={columns} schemaName={"Contract"} />
} }
const EditDraft = () => { const EditDraft = () => {

View File

@@ -1,5 +1,4 @@
import { Route, Routes } from "react-router"; import { Route, Routes } from "react-router";
import { useTranslation } from "@refinedev/core";
import List from "./base-page/List"; import List from "./base-page/List";
import Edit from "./base-page/Edit"; import Edit from "./base-page/Edit";
import New from "./base-page/New"; import New from "./base-page/New";
@@ -22,12 +21,12 @@ export const EntityRoutes = () => {
} }
const ListEntity = () => { const ListEntity = () => {
const { translate: t } = useTranslation();
const columns = [ const columns = [
{ field: "entity_data", headerName: t("schemas.type"), width: 110, valueFormatter: ({ type }: {type: string}) => type }, { field: "entity_data.type", column: { width: 110 }},
{ field: "label", headerName: t("schemas.label"), flex: 1 }, { field: "label", column: { flex: 1 }},
{ field: "updated_at", column: { flex: 1 }},
]; ];
return <List<Entity> resource={`entities`} columns={columns} /> return <List<Entity> resource={`entities`} schemaName={"Entity"} columns={columns} />
} }
const EditEntity = () => { const EditEntity = () => {

View File

@@ -21,9 +21,9 @@ export const ProvisionRoutes = () => {
const ListProvision = () => { const ListProvision = () => {
const columns = [ const columns = [
{ field: "label", headerName: "Label", flex: 1 }, { field: "label", column: { flex: 1 }},
]; ];
return <List<Provision> resource={`templates/provisions`} columns={columns} /> return <List<Provision> resource={`templates/provisions`} schemaName={"ProvisionTemplate"} columns={columns} />
} }
const EditProvision = () => { const EditProvision = () => {

View File

@@ -20,9 +20,9 @@ export const TemplateRoutes = () => {
const ListTemplate = () => { const ListTemplate = () => {
const columns = [ const columns = [
{ field: "label", headerName: "Label", flex: 1 }, { field: "label", column: { flex: 1 }},
]; ];
return <List<Template> resource={`templates/contracts`} columns={columns} /> return <List<Template> resource={`templates/contracts`} schemaName={"ContractTemplate"} columns={columns} />
} }
const EditTemplate = () => { const EditTemplate = () => {

View File

@@ -1,21 +1,55 @@
import { Link, useNavigate } from "react-router"
import { UiSchema } from "@rjsf/utils"; import { UiSchema } from "@rjsf/utils";
import { useTranslation } from "@refinedev/core"; import { useTranslation } from "@refinedev/core";
import { List as RefineList, useDataGrid } from "@refinedev/mui"; import { List as RefineList, useDataGrid } from "@refinedev/mui";
import { DataGrid, GridColDef, GridValidRowModel } from "@mui/x-data-grid"; import { DataGrid, GridColDef, GridValidRowModel, GridColumnVisibilityModel } from "@mui/x-data-grid";
import { Link, useNavigate } from "react-router" import React, { useContext, useEffect, useState } from "react";
import React, { useContext } from "react"; import { Button, CircularProgress } from "@mui/material";
import { Button } from "@mui/material";
import { FirmContext } from "../../../contexts/FirmContext"; import { FirmContext } from "../../../contexts/FirmContext";
import { jsonschemaProvider } from "../../../lib/crud/providers/jsonschema-provider";
type ListProps<T extends GridValidRowModel> = { type ListProps = {
resource: string, resource: string,
columns: GridColDef<T>[], columns: ColumnDefinition[],
schemaName?: string, schemaName: string,
uiSchema?: UiSchema, uiSchema?: UiSchema,
} }
const List = <T extends GridValidRowModel>(props: ListProps<T>) => { type ColumnSchema<T extends GridValidRowModel> = {
const { resource, columns } = props; columns: GridColDef<T>[],
columnVisibilityModel: GridColumnVisibilityModel
}
type ColumnDefinition = {
field: string,
column: Partial<GridColDef>,
hide?: boolean
}
function computeColumnSchema<T extends GridValidRowModel>(definitionColumns: ColumnDefinition[], resourceColumns: GridColDef[]): ColumnSchema<T> {
//reorder resourceColumns as in definition
definitionColumns.slice().reverse().forEach(first => {
resourceColumns.sort(function(x,y){ return x.field == first.field ? -1 : y.field == first.field ? 1 : 0; });
})
let visibilityModel: GridColumnVisibilityModel = {}
resourceColumns.forEach((resource, index) =>{
visibilityModel[resource.field] = definitionColumns.some(col => col.field == resource.field && !col.hide)
definitionColumns.forEach((def) => {
if (def.field == resource.field) {
resourceColumns[index] = {...resource, ...def.column};
}
})
})
return {
columns: resourceColumns,
columnVisibilityModel: visibilityModel
}
}
const List = <T extends GridValidRowModel>(props: ListProps) => {
const { resource, columns, schemaName } = props;
const { translate: t } = useTranslation(); const { translate: t } = useTranslation();
const { currentFirm } = useContext(FirmContext); const { currentFirm } = useContext(FirmContext);
const resourceBasePath = `firm/${currentFirm.instance}/${currentFirm.firm}` const resourceBasePath = `firm/${currentFirm.instance}/${currentFirm.firm}`
@@ -25,15 +59,32 @@ const List = <T extends GridValidRowModel>(props: ListProps<T>) => {
}); });
const navigate = useNavigate(); const navigate = useNavigate();
const cols = React.useMemo<GridColDef<T>[]>( const [columnSchema, setColumnSchema] = useState<ColumnSchema<T>>()
() => columns, const [schemaLoading, setSchemaLoading] = useState(true);
[], useEffect(() => {
); const fetchSchema = async () => {
try {
const resourceColumns = await jsonschemaProvider.getReadResourceColumns(schemaName)
const definedColumns = computeColumnSchema<T>(columns, resourceColumns)
setColumnSchema(definedColumns);
console.log(resourceColumns);
setSchemaLoading(false);
} catch (error) {
console.error('Error fetching data:', error);
setSchemaLoading(false);
}
};
fetchSchema();
}, []);
const handleRowClick = (params: any, event: any) => { const handleRowClick = (params: any, event: any) => {
navigate(`edit/${params.id}`) navigate(`edit/${params.id}`)
} }
if (schemaLoading || columnSchema === undefined) {
return <CircularProgress />
}
return ( return (
<RefineList> <RefineList>
<Link to={"create"} > <Link to={"create"} >
@@ -41,9 +92,14 @@ const List = <T extends GridValidRowModel>(props: ListProps<T>) => {
</Link> </Link>
<DataGrid <DataGrid
{...dataGridProps} {...dataGridProps}
columns={cols} columns={columnSchema.columns}
onRowClick={handleRowClick} onRowClick={handleRowClick}
pageSizeOptions={[10, 15, 20, 50, 100]} pageSizeOptions={[10, 15, 20, 50, 100]}
initialState={{
columns: {
columnVisibilityModel: columnSchema.columnVisibilityModel
}
}}
/> />
</RefineList> </RefineList>
) )