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 typing import Any, Optional, Union
from pydantic import ValidationInfo, field_validator
from typing import Any, Optional, Union
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):
"""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):
if not self.ordering_values:
return None
@@ -130,6 +108,51 @@ class Filter(BaseFilterModel):
query[field_name] = value
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):
label__ilike: Optional[str] = None
order_by: Optional[list[str]] = None

View File

@@ -78,13 +78,16 @@ class CrudDocument(BaseModel):
@classmethod
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 = {
"$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)
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
async def delete(cls, db, model):

View File

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

View File

@@ -1,6 +1,7 @@
import { RJSFSchema } from '@rjsf/utils';
import i18n from '../../../i18n'
import { JSONSchema7Definition } from "json-schema";
import { GridColDef } from "@mui/x-data-grid";
const API_URL = "/api/v1";
@@ -51,6 +52,10 @@ export const jsonschemaProvider = {
return readSchema
},
getReadResourceSchema: async (resourceName: string): Promise<CrudRJSFSchema> => {
return getResourceSchema(`${resourceName}Read`)
},
getUpdateResourceSchema: async (resourceName: string): Promise<CrudRJSFSchema> => {
return getResourceSchema(`${resourceName}Update`)
},
@@ -58,21 +63,88 @@ export const jsonschemaProvider = {
getCreateResourceSchema: async (resourceName: string): Promise<CrudRJSFSchema> => {
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> => {
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 {
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.`);
}
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]);
resource.components = { schemas: {} };
for (let prop_name in resource.properties) {
@@ -118,6 +190,15 @@ function buildResource(rawSchemas: RJSFSchema, resourceName: string) {
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) {
const subresourceName = get_reference_name(prop_reference);
const subresource = buildResource(rawSchemas, subresourceName);

View File

@@ -24,9 +24,9 @@ export const ContractRoutes = () => {
const ListContract = () => {
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 = () => {

View File

@@ -29,9 +29,9 @@ export const DraftRoutes = () => {
const ListDraft = () => {
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 = () => {

View File

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

View File

@@ -21,9 +21,9 @@ export const ProvisionRoutes = () => {
const ListProvision = () => {
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 = () => {

View File

@@ -20,9 +20,9 @@ export const TemplateRoutes = () => {
const ListTemplate = () => {
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 = () => {

View File

@@ -1,21 +1,55 @@
import { Link, useNavigate } from "react-router"
import { UiSchema } from "@rjsf/utils";
import { useTranslation } from "@refinedev/core";
import { List as RefineList, useDataGrid } from "@refinedev/mui";
import { DataGrid, GridColDef, GridValidRowModel } from "@mui/x-data-grid";
import { Link, useNavigate } from "react-router"
import React, { useContext } from "react";
import { Button } from "@mui/material";
import { DataGrid, GridColDef, GridValidRowModel, GridColumnVisibilityModel } from "@mui/x-data-grid";
import React, { useContext, useEffect, useState } from "react";
import { Button, CircularProgress } from "@mui/material";
import { FirmContext } from "../../../contexts/FirmContext";
import { jsonschemaProvider } from "../../../lib/crud/providers/jsonschema-provider";
type ListProps<T extends GridValidRowModel> = {
type ListProps = {
resource: string,
columns: GridColDef<T>[],
schemaName?: string,
columns: ColumnDefinition[],
schemaName: string,
uiSchema?: UiSchema,
}
const List = <T extends GridValidRowModel>(props: ListProps<T>) => {
const { resource, columns } = props;
type ColumnSchema<T extends GridValidRowModel> = {
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 { currentFirm } = useContext(FirmContext);
const resourceBasePath = `firm/${currentFirm.instance}/${currentFirm.firm}`
@@ -25,15 +59,32 @@ const List = <T extends GridValidRowModel>(props: ListProps<T>) => {
});
const navigate = useNavigate();
const cols = React.useMemo<GridColDef<T>[]>(
() => columns,
[],
);
const [columnSchema, setColumnSchema] = useState<ColumnSchema<T>>()
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) => {
navigate(`edit/${params.id}`)
}
if (schemaLoading || columnSchema === undefined) {
return <CircularProgress />
}
return (
<RefineList>
<Link to={"create"} >
@@ -41,9 +92,14 @@ const List = <T extends GridValidRowModel>(props: ListProps<T>) => {
</Link>
<DataGrid
{...dataGridProps}
columns={cols}
columns={columnSchema.columns}
onRowClick={handleRowClick}
pageSizeOptions={[10, 15, 20, 50, 100]}
initialState={{
columns: {
columnVisibilityModel: columnSchema.columnVisibilityModel
}
}}
/>
</RefineList>
)