fix: cleaned interfaces to work all with rbac
This commit is contained in:
parent
abbed64463
commit
5380e30f0d
12 changed files with 415 additions and 1616 deletions
|
|
@ -29,6 +29,13 @@ DB_MANAGEMENT_USER=heeshkdlby
|
|||
DB_MANAGEMENT_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjTnJKNlJMNmEwQ0Y5dVNrR3pkZk9SQXVvLTRTNW9lQ1g3TTE5cFhBNTd5UENqWW9qdWd3NWNseWhnUHJveDJyd1Z3X1czS3VuZnAwZHBXYVNQWlZsRy12ME42NndEVlR5X3ZPdFBNNmhLYm89
|
||||
DB_MANAGEMENT_PORT=5432
|
||||
|
||||
# PostgreSQL Storage (new)
|
||||
DB_REALESTATE_HOST=localhost
|
||||
DB_REALESTATE_DATABASE=poweron_realestate
|
||||
DB_REALESTATE_USER=poweron_dev
|
||||
DB_REALESTATE_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjTnJKNlJMNmEwQ0Y5dVNrR3pkZk9SQXVvLTRTNW9lQ1g3TTE5cFhBNTd5UENqWW9qdWd3NWNseWhnUHJveDJyd1Z3X1czS3VuZnAwZHBXYVNQWlZsRy12ME42NndEVlR5X3ZPdFBNNmhLYm89
|
||||
DB_REALESTATE_PORT=5432
|
||||
|
||||
# Security Configuration
|
||||
APP_JWT_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNUctb2RwU25iR3ZnanBOdHZhWUtIajZ1RnZzTEp4aDR0MktWRjNoeVBrY1Npd1R0VE9YVHp3M2w1cXRzbUxNaU82QUJvaDNFeVQyN05KblRWblBvbWtoT0VXbkNBbDQ5OHhwSUFnaDZGRG10Vmgtdm1YUkRsYUhFMzRVZURmSFlDTFIzVWg4MXNueDZyMGc5aVpFdWRxY3dkTExGM093ZTVUZVl5LUhGWnlRPQ==
|
||||
APP_TOKEN_EXPIRY=300
|
||||
|
|
|
|||
|
|
@ -29,6 +29,13 @@ DB_MANAGEMENT_USER=gzxxmcrdhn
|
|||
DB_MANAGEMENT_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3ZWpySThqdlVmWWd5dGxmWE91RVBsenZrQmNhSzVxbktmYzZ1RlM3cXhTMUdXRV9wX1lfLTJXLTFzeUo0R3pWLXlmUWdrZ2x6QkFlZVRXaEF6aUdRbDlzb1FfcWtub0dxSGp3OVVQWGg3enM9
|
||||
DB_MANAGEMENT_PORT=5432
|
||||
|
||||
# PostgreSQL Storage (new)
|
||||
DB_REALESTATE_HOST=localhost
|
||||
DB_REALESTATE_DATABASE=poweron_realestate
|
||||
DB_REALESTATE_USER=poweron_dev
|
||||
DB_REALESTATE_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3ZWpySThqdlVmWWd5dGxmWE91RVBsenZrQmNhSzVxbktmYzZ1RlM3cXhTMUdXRV9wX1lfLTJXLTFzeUo0R3pWLXlmUWdrZ2x6QkFlZVRXaEF6aUdRbDlzb1FfcWtub0dxSGp3OVVQWGg3enM9
|
||||
DB_REALESTATE_PORT=5432
|
||||
|
||||
# Security Configuration
|
||||
APP_JWT_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3elhfV0Rnd2pQRjlMdkVwX1FnSmRhSzNZUlV5SVpaWXBNX1hpa2xPZGdMSWpnN2ZINHQxeGZnNHJweU5pZjlyYlY5Qm9zOUZEbl9wUEgtZHZXd1NhR19JSG9kbFU4MnFGQnllbFhRQVphRGQyNHlFVWR5VHQyUUpqN0stUmRuY2QyTi1oalczRHpLTEJqWURjZWs4YjZvT2U5YnFqcXEwdEpxV05fX05QMmtrPQ==
|
||||
APP_TOKEN_EXPIRY=300
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ def _get_model_fields(model_class) -> Dict[str, str]:
|
|||
field_type = field_info.annotation
|
||||
|
||||
# Check for JSONB fields (Dict, List, or complex types)
|
||||
# Purely type-based detection - no hardcoded field names
|
||||
if (
|
||||
field_type == dict
|
||||
or field_type == list
|
||||
|
|
@ -58,23 +59,7 @@ def _get_model_fields(model_class) -> Dict[str, str]:
|
|||
hasattr(field_type, "__origin__")
|
||||
and field_type.__origin__ in (dict, list)
|
||||
)
|
||||
or field_name
|
||||
in [
|
||||
"execParameters",
|
||||
"expectedDocumentFormats",
|
||||
"resultDocuments",
|
||||
"logs",
|
||||
"messages",
|
||||
"stats",
|
||||
"tasks",
|
||||
"perimeter", # GeoPolylinie objects
|
||||
"baulinie", # GeoPolylinie objects
|
||||
"kontextInformationen", # List of Kontext objects
|
||||
"parzellenNachbarschaft", # List of dictionaries
|
||||
"dokumente", # List of Dokument objects
|
||||
"parzellen", # List of Parzelle objects (in Projekt)
|
||||
]
|
||||
# Check if field type is a Pydantic BaseModel (for nested models like GeoPolylinie)
|
||||
# Check if field type is a Pydantic BaseModel (for nested models)
|
||||
or (hasattr(field_type, "__origin__") and get_origin(field_type) is Union
|
||||
and any(hasattr(arg, "__bases__") and BaseModel in getattr(arg, "__bases__", ())
|
||||
for arg in get_args(field_type)))
|
||||
|
|
@ -691,21 +676,28 @@ class DatabaseConnector:
|
|||
|
||||
# Handle JSONB fields for all records
|
||||
fields = _get_model_fields(model_class)
|
||||
model_fields = model_class.model_fields # Get Pydantic model fields
|
||||
for record in records:
|
||||
for field_name, field_type in fields.items():
|
||||
if field_type == "JSONB" and field_name in record:
|
||||
if record[field_name] is None:
|
||||
# Convert None to appropriate default based on field name
|
||||
if field_name in [
|
||||
"logs",
|
||||
"messages",
|
||||
"tasks",
|
||||
"expectedDocumentFormats",
|
||||
"resultDocuments",
|
||||
]:
|
||||
record[field_name] = []
|
||||
elif field_name in ["execParameters", "stats"]:
|
||||
record[field_name] = {}
|
||||
# Generic type-based default: List types -> [], Dict types -> {}
|
||||
# Interfaces handle domain-specific defaults
|
||||
field_info = model_fields.get(field_name)
|
||||
if field_info:
|
||||
field_annotation = field_info.annotation
|
||||
# Check if it's a List type
|
||||
if (field_annotation == list or
|
||||
(hasattr(field_annotation, "__origin__") and
|
||||
field_annotation.__origin__ is list)):
|
||||
record[field_name] = []
|
||||
# Check if it's a Dict type
|
||||
elif (field_annotation == dict or
|
||||
(hasattr(field_annotation, "__origin__") and
|
||||
field_annotation.__origin__ is dict)):
|
||||
record[field_name] = {}
|
||||
else:
|
||||
record[field_name] = None
|
||||
else:
|
||||
record[field_name] = None
|
||||
else:
|
||||
|
|
@ -878,6 +870,7 @@ class DatabaseConnector:
|
|||
|
||||
# Handle JSONB fields and ensure numeric types are correct
|
||||
fields = _get_model_fields(model_class)
|
||||
model_fields = model_class.model_fields # Get Pydantic model fields
|
||||
for record in records:
|
||||
for field_name, field_type in fields.items():
|
||||
# Ensure numeric fields (float/int) are properly typed
|
||||
|
|
@ -897,17 +890,23 @@ class DatabaseConnector:
|
|||
)
|
||||
elif field_type == "JSONB" and field_name in record:
|
||||
if record[field_name] is None:
|
||||
# Convert None to appropriate default based on field name
|
||||
if field_name in [
|
||||
"logs",
|
||||
"messages",
|
||||
"tasks",
|
||||
"expectedDocumentFormats",
|
||||
"resultDocuments",
|
||||
]:
|
||||
record[field_name] = []
|
||||
elif field_name in ["execParameters", "stats"]:
|
||||
record[field_name] = {}
|
||||
# Generic type-based default: List types -> [], Dict types -> {}
|
||||
# Interfaces handle domain-specific defaults
|
||||
field_info = model_fields.get(field_name)
|
||||
if field_info:
|
||||
field_annotation = field_info.annotation
|
||||
# Check if it's a List type
|
||||
if (field_annotation == list or
|
||||
(hasattr(field_annotation, "__origin__") and
|
||||
field_annotation.__origin__ is list)):
|
||||
record[field_name] = []
|
||||
# Check if it's a Dict type
|
||||
elif (field_annotation == dict or
|
||||
(hasattr(field_annotation, "__origin__") and
|
||||
field_annotation.__origin__ is dict)):
|
||||
record[field_name] = {}
|
||||
else:
|
||||
record[field_name] = None
|
||||
else:
|
||||
record[field_name] = None
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -878,7 +878,7 @@ class SwissTopoMapServerConnector:
|
|||
return None
|
||||
|
||||
x, y = coords
|
||||
logger.info(f"Parzelle gefunden: {parcel.get('label', 'Unknown')}, Zentrum: E={x}, N={y}")
|
||||
logger.info(f"Parzelle gefunden: {search_result.get('label', 'Unknown')}, Zentrum: E={x}, N={y}")
|
||||
|
||||
# Schritt 2: Polygon-Geometrie abrufen
|
||||
identify_params = {
|
||||
|
|
|
|||
|
|
@ -645,6 +645,54 @@ def createTableSpecificRules(db: DatabaseConnector) -> None:
|
|||
delete=AccessLevel.NONE,
|
||||
))
|
||||
|
||||
# Real Estate tables - Projekt, Parzelle, Dokument, Gemeinde, Kanton, Land
|
||||
realEstateTables = ["Projekt", "Parzelle", "Dokument", "Gemeinde", "Kanton", "Land"]
|
||||
for table in realEstateTables:
|
||||
# Sysadmin - full access
|
||||
tableRules.append(AccessRule(
|
||||
roleLabel="sysadmin",
|
||||
context=AccessRuleContext.DATA,
|
||||
item=table,
|
||||
view=True,
|
||||
read=AccessLevel.ALL,
|
||||
create=AccessLevel.ALL,
|
||||
update=AccessLevel.ALL,
|
||||
delete=AccessLevel.ALL,
|
||||
))
|
||||
# Admin - group access
|
||||
tableRules.append(AccessRule(
|
||||
roleLabel="admin",
|
||||
context=AccessRuleContext.DATA,
|
||||
item=table,
|
||||
view=True,
|
||||
read=AccessLevel.GROUP,
|
||||
create=AccessLevel.GROUP,
|
||||
update=AccessLevel.GROUP,
|
||||
delete=AccessLevel.GROUP,
|
||||
))
|
||||
# User - my records only
|
||||
tableRules.append(AccessRule(
|
||||
roleLabel="user",
|
||||
context=AccessRuleContext.DATA,
|
||||
item=table,
|
||||
view=True,
|
||||
read=AccessLevel.MY,
|
||||
create=AccessLevel.MY,
|
||||
update=AccessLevel.MY,
|
||||
delete=AccessLevel.MY,
|
||||
))
|
||||
# Viewer - read-only my records
|
||||
tableRules.append(AccessRule(
|
||||
roleLabel="viewer",
|
||||
context=AccessRuleContext.DATA,
|
||||
item=table,
|
||||
view=True,
|
||||
read=AccessLevel.MY,
|
||||
create=AccessLevel.NONE,
|
||||
update=AccessLevel.NONE,
|
||||
delete=AccessLevel.NONE,
|
||||
))
|
||||
|
||||
# Create all table-specific rules
|
||||
for rule in tableRules:
|
||||
db.recordCreate(AccessRule, rule)
|
||||
|
|
@ -903,7 +951,7 @@ def _addMissingTableRules(db: DatabaseConnector, existingRules: List[Dict[str, A
|
|||
existingRoles = {rule.get("roleLabel") for rule in existingRules}
|
||||
|
||||
# Tables that need rules
|
||||
requiredTables = ["ChatWorkflow", "Prompt"]
|
||||
requiredTables = ["ChatWorkflow", "Prompt", "Projekt", "Parzelle", "Dokument", "Gemeinde", "Kanton", "Land"]
|
||||
requiredRoles = ["sysadmin", "admin", "user", "viewer"]
|
||||
|
||||
newRules = []
|
||||
|
|
@ -1005,6 +1053,53 @@ def _addMissingTableRules(db: DatabaseConnector, existingRules: List[Dict[str, A
|
|||
update=AccessLevel.NONE,
|
||||
delete=AccessLevel.NONE,
|
||||
))
|
||||
# Real Estate tables rules (Projekt, Parzelle, Dokument, Gemeinde, Kanton, Land)
|
||||
elif table in ["Projekt", "Parzelle", "Dokument", "Gemeinde", "Kanton", "Land"]:
|
||||
for roleLabel in requiredRoles:
|
||||
if roleLabel == "sysadmin":
|
||||
newRules.append(AccessRule(
|
||||
roleLabel=roleLabel,
|
||||
context=AccessRuleContext.DATA,
|
||||
item=table,
|
||||
view=True,
|
||||
read=AccessLevel.ALL,
|
||||
create=AccessLevel.ALL,
|
||||
update=AccessLevel.ALL,
|
||||
delete=AccessLevel.ALL,
|
||||
))
|
||||
elif roleLabel == "admin":
|
||||
newRules.append(AccessRule(
|
||||
roleLabel=roleLabel,
|
||||
context=AccessRuleContext.DATA,
|
||||
item=table,
|
||||
view=True,
|
||||
read=AccessLevel.GROUP,
|
||||
create=AccessLevel.GROUP,
|
||||
update=AccessLevel.GROUP,
|
||||
delete=AccessLevel.GROUP,
|
||||
))
|
||||
elif roleLabel == "user":
|
||||
newRules.append(AccessRule(
|
||||
roleLabel=roleLabel,
|
||||
context=AccessRuleContext.DATA,
|
||||
item=table,
|
||||
view=True,
|
||||
read=AccessLevel.MY,
|
||||
create=AccessLevel.MY,
|
||||
update=AccessLevel.MY,
|
||||
delete=AccessLevel.MY,
|
||||
))
|
||||
elif roleLabel == "viewer":
|
||||
newRules.append(AccessRule(
|
||||
roleLabel=roleLabel,
|
||||
context=AccessRuleContext.DATA,
|
||||
item=table,
|
||||
view=True,
|
||||
read=AccessLevel.MY,
|
||||
create=AccessLevel.NONE,
|
||||
update=AccessLevel.NONE,
|
||||
delete=AccessLevel.NONE,
|
||||
))
|
||||
|
||||
# Create missing rules
|
||||
if newRules:
|
||||
|
|
|
|||
|
|
@ -134,6 +134,47 @@ class AppObjects:
|
|||
logger.error(f"Failed to initialize database: {str(e)}")
|
||||
raise
|
||||
|
||||
def _separateObjectFields(self, model_class, data: Dict[str, Any]) -> tuple[Dict[str, Any], Dict[str, Any]]:
|
||||
"""Separate simple fields from object fields based on Pydantic model structure."""
|
||||
simpleFields = {}
|
||||
objectFields = {}
|
||||
|
||||
# Get field information from the Pydantic model
|
||||
modelFields = model_class.model_fields
|
||||
|
||||
for fieldName, value in data.items():
|
||||
# Check if this field should be stored as JSONB in the database
|
||||
if fieldName in modelFields:
|
||||
fieldInfo = modelFields[fieldName]
|
||||
# Pydantic v2 only
|
||||
fieldType = fieldInfo.annotation
|
||||
|
||||
# Check if this is a JSONB field (Dict, List, or complex types)
|
||||
# Purely type-based detection - no hardcoded field names
|
||||
if (fieldType == dict or
|
||||
fieldType == list or
|
||||
(hasattr(fieldType, '__origin__') and fieldType.__origin__ in (dict, list))):
|
||||
# Store as JSONB - include in simple_fields for database storage
|
||||
simpleFields[fieldName] = value
|
||||
elif isinstance(value, (str, int, float, bool, type(None))):
|
||||
# Simple scalar types
|
||||
simpleFields[fieldName] = value
|
||||
else:
|
||||
# Complex objects that should be filtered out
|
||||
objectFields[fieldName] = value
|
||||
else:
|
||||
# Field not in model - treat as scalar if simple, otherwise filter out
|
||||
# BUT: always include metadata fields (_createdBy, _createdAt, etc.) as they're handled by connector
|
||||
if fieldName.startswith("_"):
|
||||
# Metadata fields should be passed through to connector
|
||||
simpleFields[fieldName] = value
|
||||
elif isinstance(value, (str, int, float, bool, type(None))):
|
||||
simpleFields[fieldName] = value
|
||||
else:
|
||||
objectFields[fieldName] = value
|
||||
|
||||
return simpleFields, objectFields
|
||||
|
||||
def _initRecords(self):
|
||||
"""Initialize standard records if they don't exist."""
|
||||
initBootstrap(self.db)
|
||||
|
|
|
|||
|
|
@ -223,15 +223,16 @@ class ChatObjects:
|
|||
fieldType = fieldInfo.annotation
|
||||
|
||||
# Always route relational/object fields to object_fields for separate handling
|
||||
if fieldName in ['documents', 'stats']:
|
||||
# These fields are stored in separate normalized tables, not as JSONB
|
||||
if fieldName in ['documents', 'stats', 'logs', 'messages']:
|
||||
objectFields[fieldName] = value
|
||||
continue
|
||||
|
||||
# Check if this is a JSONB field (Dict, List, or complex types)
|
||||
# Purely type-based detection - no hardcoded field names
|
||||
if (fieldType == dict or
|
||||
fieldType == list or
|
||||
(hasattr(fieldType, '__origin__') and fieldType.__origin__ in (dict, list)) or
|
||||
fieldName in ['execParameters', 'expectedDocumentFormats', 'resultDocuments']):
|
||||
(hasattr(fieldType, '__origin__') and fieldType.__origin__ in (dict, list))):
|
||||
# Store as JSONB - include in simple_fields for database storage
|
||||
simpleFields[fieldName] = value
|
||||
elif isinstance(value, (str, int, float, bool, type(None))):
|
||||
|
|
|
|||
|
|
@ -140,6 +140,47 @@ class ComponentObjects:
|
|||
logger.error(f"Failed to initialize database: {str(e)}")
|
||||
raise
|
||||
|
||||
def _separateObjectFields(self, model_class, data: Dict[str, Any]) -> tuple[Dict[str, Any], Dict[str, Any]]:
|
||||
"""Separate simple fields from object fields based on Pydantic model structure."""
|
||||
simpleFields = {}
|
||||
objectFields = {}
|
||||
|
||||
# Get field information from the Pydantic model
|
||||
modelFields = model_class.model_fields
|
||||
|
||||
for fieldName, value in data.items():
|
||||
# Check if this field should be stored as JSONB in the database
|
||||
if fieldName in modelFields:
|
||||
fieldInfo = modelFields[fieldName]
|
||||
# Pydantic v2 only
|
||||
fieldType = fieldInfo.annotation
|
||||
|
||||
# Check if this is a JSONB field (Dict, List, or complex types)
|
||||
# Purely type-based detection - no hardcoded field names
|
||||
if (fieldType == dict or
|
||||
fieldType == list or
|
||||
(hasattr(fieldType, '__origin__') and fieldType.__origin__ in (dict, list))):
|
||||
# Store as JSONB - include in simple_fields for database storage
|
||||
simpleFields[fieldName] = value
|
||||
elif isinstance(value, (str, int, float, bool, type(None))):
|
||||
# Simple scalar types
|
||||
simpleFields[fieldName] = value
|
||||
else:
|
||||
# Complex objects that should be filtered out
|
||||
objectFields[fieldName] = value
|
||||
else:
|
||||
# Field not in model - treat as scalar if simple, otherwise filter out
|
||||
# BUT: always include metadata fields (_createdBy, _createdAt, etc.) as they're handled by connector
|
||||
if fieldName.startswith("_"):
|
||||
# Metadata fields should be passed through to connector
|
||||
simpleFields[fieldName] = value
|
||||
elif isinstance(value, (str, int, float, bool, type(None))):
|
||||
simpleFields[fieldName] = value
|
||||
else:
|
||||
objectFields[fieldName] = value
|
||||
|
||||
return simpleFields, objectFields
|
||||
|
||||
def _initRecords(self):
|
||||
"""Initializes standard records in the database if they don't exist."""
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -1,90 +0,0 @@
|
|||
"""
|
||||
Access control for Real Estate interface.
|
||||
Handles user access management and permission checks.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, Any, List, Optional
|
||||
from modules.datamodels.datamodelUam import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RealEstateAccess:
|
||||
"""
|
||||
Access control class for Real Estate interface.
|
||||
Handles user access management and permission checks.
|
||||
"""
|
||||
|
||||
def __init__(self, currentUser: User, db):
|
||||
"""Initialize with user context."""
|
||||
self.currentUser = currentUser
|
||||
self.mandateId = currentUser.mandateId
|
||||
self.userId = currentUser.id
|
||||
self.roleLabels = currentUser.roleLabels or []
|
||||
|
||||
if not self.mandateId or not self.userId:
|
||||
raise ValueError("Invalid user context: mandateId and userId are required")
|
||||
|
||||
self.db = db
|
||||
|
||||
def uam(self, model_class: type, recordset: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Unified user access management function that filters data based on user privileges.
|
||||
|
||||
Args:
|
||||
model_class: Pydantic model class for the table
|
||||
recordset: Recordset to filter based on access rules
|
||||
|
||||
Returns:
|
||||
Filtered recordset with access control attributes
|
||||
"""
|
||||
filtered_records = []
|
||||
|
||||
# System admins see all records
|
||||
if "sysadmin" in self.roleLabels:
|
||||
filtered_records = recordset
|
||||
# Admins see records in their mandate
|
||||
elif "admin" in self.roleLabels:
|
||||
filtered_records = [r for r in recordset if r.get("mandateId", "-") == self.mandateId]
|
||||
# Regular users see only their records
|
||||
else:
|
||||
filtered_records = [
|
||||
r for r in recordset
|
||||
if r.get("mandateId", "-") == self.mandateId and r.get("_createdBy") == self.userId
|
||||
]
|
||||
|
||||
# Add access control attributes
|
||||
for record in filtered_records:
|
||||
record["_hideView"] = False
|
||||
record["_hideEdit"] = not self.canModify(model_class, record.get("id"))
|
||||
record["_hideDelete"] = not self.canModify(model_class, record.get("id"))
|
||||
|
||||
return filtered_records
|
||||
|
||||
def canModify(self, model_class: type, recordId: Optional[str] = None) -> bool:
|
||||
"""Checks if the current user can modify records."""
|
||||
# System admins can modify all records
|
||||
if "sysadmin" in self.roleLabels:
|
||||
return True
|
||||
|
||||
if recordId is not None:
|
||||
records = self.db.getRecordset(model_class, recordFilter={"id": recordId})
|
||||
if not records:
|
||||
return False
|
||||
|
||||
record = records[0]
|
||||
|
||||
# Admins can modify records in their mandate
|
||||
if "admin" in self.roleLabels and record.get("mandateId", "-") == self.mandateId:
|
||||
return True
|
||||
|
||||
# Regular users can modify their own records
|
||||
if (record.get("mandateId", "-") == self.mandateId and
|
||||
record.get("_createdBy") == self.userId):
|
||||
return True
|
||||
|
||||
return False
|
||||
else:
|
||||
return True # Regular users can create records
|
||||
|
||||
|
|
@ -21,8 +21,10 @@ from modules.datamodels.datamodelRealEstate import (
|
|||
from modules.datamodels.datamodelUam import User
|
||||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||
from modules.shared.configuration import APP_CONFIG
|
||||
# Import Access-Klasse aus separater Datei
|
||||
from modules.interfaces.interfaceDbRealEstateAccess import RealEstateAccess
|
||||
from modules.security.rbac import RbacClass
|
||||
from modules.datamodels.datamodelRbac import AccessRuleContext
|
||||
from modules.datamodels.datamodelUam import AccessLevel
|
||||
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -42,7 +44,7 @@ class RealEstateObjects:
|
|||
self.currentUser = currentUser
|
||||
self.userId = currentUser.id if currentUser else None
|
||||
self.mandateId = currentUser.mandateId if currentUser else None
|
||||
self.access = None
|
||||
self.rbac = None # RBAC interface
|
||||
|
||||
# Initialize database
|
||||
self._initializeDatabase()
|
||||
|
|
@ -108,8 +110,13 @@ class RealEstateObjects:
|
|||
if not self.userId or not self.mandateId:
|
||||
raise ValueError("Invalid user context: id and mandateId are required")
|
||||
|
||||
# Initialize access control
|
||||
self.access = RealEstateAccess(self.currentUser, self.db)
|
||||
# Initialize RBAC interface
|
||||
if not self.currentUser:
|
||||
raise ValueError("User context is required for RBAC")
|
||||
# Get DbApp connection for RBAC AccessRule queries
|
||||
from modules.security.rootAccess import getRootDbAppConnector
|
||||
dbApp = getRootDbAppConnector()
|
||||
self.rbac = RbacClass(self.db, dbApp=dbApp)
|
||||
|
||||
# Update database context
|
||||
self.db.updateContext(self.userId)
|
||||
|
|
@ -118,13 +125,14 @@ class RealEstateObjects:
|
|||
|
||||
def createProjekt(self, projekt: Projekt) -> Projekt:
|
||||
"""Create a new project."""
|
||||
# Check RBAC permission
|
||||
if not self.checkRbacPermission(Projekt, "create"):
|
||||
raise PermissionError(f"User {self.userId} cannot create projects")
|
||||
|
||||
# Ensure mandateId is set
|
||||
if not projekt.mandateId:
|
||||
projekt.mandateId = self.mandateId
|
||||
|
||||
# Apply access control
|
||||
self.access.uam(Projekt, [])
|
||||
|
||||
# Save to database - use mode='json' to ensure nested Pydantic models are serialized
|
||||
self.db.recordCreate(Projekt, projekt.model_dump(mode='json'))
|
||||
|
||||
|
|
@ -132,30 +140,28 @@ class RealEstateObjects:
|
|||
|
||||
def getProjekt(self, projektId: str) -> Optional[Projekt]:
|
||||
"""Get a project by ID."""
|
||||
records = self.db.getRecordset(
|
||||
records = getRecordsetWithRBAC(
|
||||
self.db,
|
||||
Projekt,
|
||||
self.currentUser,
|
||||
recordFilter={"id": projektId}
|
||||
)
|
||||
|
||||
if not records:
|
||||
return None
|
||||
|
||||
# Apply access control
|
||||
filtered = self.access.uam(Projekt, records)
|
||||
|
||||
if not filtered:
|
||||
return None
|
||||
|
||||
return Projekt(**filtered[0])
|
||||
return Projekt(**records[0])
|
||||
|
||||
def getProjekte(self, recordFilter: Optional[Dict[str, Any]] = None) -> List[Projekt]:
|
||||
"""Get all projects matching the filter."""
|
||||
records = self.db.getRecordset(Projekt, recordFilter=recordFilter or {})
|
||||
records = getRecordsetWithRBAC(
|
||||
self.db,
|
||||
Projekt,
|
||||
self.currentUser,
|
||||
recordFilter=recordFilter or {}
|
||||
)
|
||||
|
||||
# Apply access control
|
||||
filtered = self.access.uam(Projekt, records)
|
||||
|
||||
return [Projekt(**r) for r in filtered]
|
||||
return [Projekt(**r) for r in records]
|
||||
|
||||
def updateProjekt(self, projektId_or_projekt: Union[str, Projekt], updateData: Optional[Dict[str, Any]] = None) -> Optional[Projekt]:
|
||||
"""Update a project.
|
||||
|
|
@ -180,8 +186,8 @@ class RealEstateObjects:
|
|||
if hasattr(projekt, key):
|
||||
setattr(projekt, key, value)
|
||||
|
||||
# Check if user can modify
|
||||
if not self.access.canModify(Projekt, projektId):
|
||||
# Check RBAC permission
|
||||
if not self.checkRbacPermission(Projekt, "update", projektId):
|
||||
raise PermissionError(f"User {self.userId} cannot modify project {projektId}")
|
||||
|
||||
# Save to database
|
||||
|
|
@ -195,8 +201,8 @@ class RealEstateObjects:
|
|||
if not projekt:
|
||||
return False
|
||||
|
||||
# Check if user can modify
|
||||
if not self.access.canModify(Projekt, projektId):
|
||||
# Check RBAC permission
|
||||
if not self.checkRbacPermission(Projekt, "delete", projektId):
|
||||
raise PermissionError(f"User {self.userId} cannot delete project {projektId}")
|
||||
|
||||
return self.db.recordDelete(Projekt, projektId)
|
||||
|
|
@ -205,10 +211,13 @@ class RealEstateObjects:
|
|||
|
||||
def createParzelle(self, parzelle: Parzelle) -> Parzelle:
|
||||
"""Create a new plot."""
|
||||
# Check RBAC permission
|
||||
if not self.checkRbacPermission(Parzelle, "create"):
|
||||
raise PermissionError(f"User {self.userId} cannot create plots")
|
||||
|
||||
if not parzelle.mandateId:
|
||||
parzelle.mandateId = self.mandateId
|
||||
|
||||
self.access.uam(Parzelle, [])
|
||||
# Use mode='json' to ensure nested Pydantic models (like GeoPolylinie) are serialized
|
||||
self.db.recordCreate(Parzelle, parzelle.model_dump(mode='json'))
|
||||
|
||||
|
|
@ -216,20 +225,17 @@ class RealEstateObjects:
|
|||
|
||||
def getParzelle(self, parzelleId: str) -> Optional[Parzelle]:
|
||||
"""Get a plot by ID."""
|
||||
records = self.db.getRecordset(
|
||||
records = getRecordsetWithRBAC(
|
||||
self.db,
|
||||
Parzelle,
|
||||
self.currentUser,
|
||||
recordFilter={"id": parzelleId}
|
||||
)
|
||||
|
||||
if not records:
|
||||
return None
|
||||
|
||||
filtered = self.access.uam(Parzelle, records)
|
||||
|
||||
if not filtered:
|
||||
return None
|
||||
|
||||
return Parzelle(**filtered[0])
|
||||
return Parzelle(**records[0])
|
||||
|
||||
def getParzellen(self, recordFilter: Optional[Dict[str, Any]] = None) -> List[Parzelle]:
|
||||
"""Get all plots matching the filter."""
|
||||
|
|
@ -243,7 +249,12 @@ class RealEstateObjects:
|
|||
|
||||
recordFilter = self._resolveLocationFilters(recordFilter)
|
||||
|
||||
records = self.db.getRecordset(Parzelle, recordFilter=recordFilter or {})
|
||||
records = getRecordsetWithRBAC(
|
||||
self.db,
|
||||
Parzelle,
|
||||
self.currentUser,
|
||||
recordFilter=recordFilter or {}
|
||||
)
|
||||
|
||||
# Fallback: If no records found and we resolved a Gemeinde name,
|
||||
# try searching with the original name for backwards compatibility
|
||||
|
|
@ -253,14 +264,16 @@ class RealEstateObjects:
|
|||
logger.info(f"No results with resolved UUID, trying with original name '{original_gemeinde_value}'")
|
||||
fallback_filter = recordFilter.copy()
|
||||
fallback_filter["kontextGemeinde"] = original_gemeinde_value
|
||||
records = self.db.getRecordset(Parzelle, recordFilter=fallback_filter)
|
||||
records = getRecordsetWithRBAC(
|
||||
self.db,
|
||||
Parzelle,
|
||||
self.currentUser,
|
||||
recordFilter=fallback_filter
|
||||
)
|
||||
if records:
|
||||
logger.info(f"Found {len(records)} records using original name (legacy data format)")
|
||||
|
||||
# Apply access control
|
||||
filtered = self.access.uam(Parzelle, records)
|
||||
|
||||
return [Parzelle(**r) for r in filtered]
|
||||
return [Parzelle(**r) for r in records]
|
||||
|
||||
def _resolveLocationFilters(self, recordFilter: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
|
|
@ -402,7 +415,7 @@ class RealEstateObjects:
|
|||
if not parzelle:
|
||||
return None
|
||||
|
||||
if not self.access.canModify(Parzelle, parzelleId):
|
||||
if not self.checkRbacPermission(Parzelle, "update", parzelleId):
|
||||
raise PermissionError(f"User {self.userId} cannot modify plot {parzelleId}")
|
||||
|
||||
for key, value in updateData.items():
|
||||
|
|
@ -419,7 +432,7 @@ class RealEstateObjects:
|
|||
if not parzelle:
|
||||
return False
|
||||
|
||||
if not self.access.canModify(Parzelle, parzelleId):
|
||||
if not self.checkRbacPermission(Parzelle, "delete", parzelleId):
|
||||
raise PermissionError(f"User {self.userId} cannot delete plot {parzelleId}")
|
||||
|
||||
return self.db.recordDelete(Parzelle, parzelleId)
|
||||
|
|
@ -428,36 +441,40 @@ class RealEstateObjects:
|
|||
|
||||
def createDokument(self, dokument: Dokument) -> Dokument:
|
||||
"""Create a new document."""
|
||||
# Check RBAC permission
|
||||
if not self.checkRbacPermission(Dokument, "create"):
|
||||
raise PermissionError(f"User {self.userId} cannot create documents")
|
||||
|
||||
if not dokument.mandateId:
|
||||
dokument.mandateId = self.mandateId
|
||||
|
||||
self.access.uam(Dokument, [])
|
||||
self.db.recordCreate(Dokument, dokument.model_dump())
|
||||
|
||||
return dokument
|
||||
|
||||
def getDokument(self, dokumentId: str) -> Optional[Dokument]:
|
||||
"""Get a document by ID."""
|
||||
records = self.db.getRecordset(
|
||||
records = getRecordsetWithRBAC(
|
||||
self.db,
|
||||
Dokument,
|
||||
self.currentUser,
|
||||
recordFilter={"id": dokumentId}
|
||||
)
|
||||
|
||||
if not records:
|
||||
return None
|
||||
|
||||
filtered = self.access.uam(Dokument, records)
|
||||
|
||||
if not filtered:
|
||||
return None
|
||||
|
||||
return Dokument(**filtered[0])
|
||||
return Dokument(**records[0])
|
||||
|
||||
def getDokumente(self, recordFilter: Optional[Dict[str, Any]] = None) -> List[Dokument]:
|
||||
"""Get all documents matching the filter."""
|
||||
records = self.db.getRecordset(Dokument, recordFilter=recordFilter or {})
|
||||
filtered = self.access.uam(Dokument, records)
|
||||
return [Dokument(**r) for r in filtered]
|
||||
records = getRecordsetWithRBAC(
|
||||
self.db,
|
||||
Dokument,
|
||||
self.currentUser,
|
||||
recordFilter=recordFilter or {}
|
||||
)
|
||||
return [Dokument(**r) for r in records]
|
||||
|
||||
def updateDokument(self, dokumentId: str, updateData: Dict[str, Any]) -> Optional[Dokument]:
|
||||
"""Update a document."""
|
||||
|
|
@ -465,7 +482,7 @@ class RealEstateObjects:
|
|||
if not dokument:
|
||||
return None
|
||||
|
||||
if not self.access.canModify(Dokument, dokumentId):
|
||||
if not self.checkRbacPermission(Dokument, "update", dokumentId):
|
||||
raise PermissionError(f"User {self.userId} cannot modify document {dokumentId}")
|
||||
|
||||
for key, value in updateData.items():
|
||||
|
|
@ -481,7 +498,7 @@ class RealEstateObjects:
|
|||
if not dokument:
|
||||
return False
|
||||
|
||||
if not self.access.canModify(Dokument, dokumentId):
|
||||
if not self.checkRbacPermission(Dokument, "delete", dokumentId):
|
||||
raise PermissionError(f"User {self.userId} cannot delete document {dokumentId}")
|
||||
|
||||
return self.db.recordDelete(Dokument, dokumentId)
|
||||
|
|
@ -490,36 +507,40 @@ class RealEstateObjects:
|
|||
|
||||
def createGemeinde(self, gemeinde: Gemeinde) -> Gemeinde:
|
||||
"""Create a new municipality."""
|
||||
# Check RBAC permission
|
||||
if not self.checkRbacPermission(Gemeinde, "create"):
|
||||
raise PermissionError(f"User {self.userId} cannot create municipalities")
|
||||
|
||||
if not gemeinde.mandateId:
|
||||
gemeinde.mandateId = self.mandateId
|
||||
|
||||
self.access.uam(Gemeinde, [])
|
||||
self.db.recordCreate(Gemeinde, gemeinde.model_dump())
|
||||
|
||||
return gemeinde
|
||||
|
||||
def getGemeinde(self, gemeindeId: str) -> Optional[Gemeinde]:
|
||||
"""Get a municipality by ID."""
|
||||
records = self.db.getRecordset(
|
||||
records = getRecordsetWithRBAC(
|
||||
self.db,
|
||||
Gemeinde,
|
||||
self.currentUser,
|
||||
recordFilter={"id": gemeindeId}
|
||||
)
|
||||
|
||||
if not records:
|
||||
return None
|
||||
|
||||
filtered = self.access.uam(Gemeinde, records)
|
||||
|
||||
if not filtered:
|
||||
return None
|
||||
|
||||
return Gemeinde(**filtered[0])
|
||||
return Gemeinde(**records[0])
|
||||
|
||||
def getGemeinden(self, recordFilter: Optional[Dict[str, Any]] = None) -> List[Gemeinde]:
|
||||
"""Get all municipalities matching the filter."""
|
||||
records = self.db.getRecordset(Gemeinde, recordFilter=recordFilter or {})
|
||||
filtered = self.access.uam(Gemeinde, records)
|
||||
return [Gemeinde(**r) for r in filtered]
|
||||
records = getRecordsetWithRBAC(
|
||||
self.db,
|
||||
Gemeinde,
|
||||
self.currentUser,
|
||||
recordFilter=recordFilter or {}
|
||||
)
|
||||
return [Gemeinde(**r) for r in records]
|
||||
|
||||
def updateGemeinde(self, gemeindeId: str, updateData: Dict[str, Any]) -> Optional[Gemeinde]:
|
||||
"""Update a municipality."""
|
||||
|
|
@ -527,7 +548,7 @@ class RealEstateObjects:
|
|||
if not gemeinde:
|
||||
return None
|
||||
|
||||
if not self.access.canModify(Gemeinde, gemeindeId):
|
||||
if not self.checkRbacPermission(Gemeinde, "update", gemeindeId):
|
||||
raise PermissionError(f"User {self.userId} cannot modify municipality {gemeindeId}")
|
||||
|
||||
for key, value in updateData.items():
|
||||
|
|
@ -543,7 +564,7 @@ class RealEstateObjects:
|
|||
if not gemeinde:
|
||||
return False
|
||||
|
||||
if not self.access.canModify(Gemeinde, gemeindeId):
|
||||
if not self.checkRbacPermission(Gemeinde, "delete", gemeindeId):
|
||||
raise PermissionError(f"User {self.userId} cannot delete municipality {gemeindeId}")
|
||||
|
||||
return self.db.recordDelete(Gemeinde, gemeindeId)
|
||||
|
|
@ -552,36 +573,40 @@ class RealEstateObjects:
|
|||
|
||||
def createKanton(self, kanton: Kanton) -> Kanton:
|
||||
"""Create a new canton."""
|
||||
# Check RBAC permission
|
||||
if not self.checkRbacPermission(Kanton, "create"):
|
||||
raise PermissionError(f"User {self.userId} cannot create cantons")
|
||||
|
||||
if not kanton.mandateId:
|
||||
kanton.mandateId = self.mandateId
|
||||
|
||||
self.access.uam(Kanton, [])
|
||||
self.db.recordCreate(Kanton, kanton.model_dump())
|
||||
|
||||
return kanton
|
||||
|
||||
def getKanton(self, kantonId: str) -> Optional[Kanton]:
|
||||
"""Get a canton by ID."""
|
||||
records = self.db.getRecordset(
|
||||
records = getRecordsetWithRBAC(
|
||||
self.db,
|
||||
Kanton,
|
||||
self.currentUser,
|
||||
recordFilter={"id": kantonId}
|
||||
)
|
||||
|
||||
if not records:
|
||||
return None
|
||||
|
||||
filtered = self.access.uam(Kanton, records)
|
||||
|
||||
if not filtered:
|
||||
return None
|
||||
|
||||
return Kanton(**filtered[0])
|
||||
return Kanton(**records[0])
|
||||
|
||||
def getKantone(self, recordFilter: Optional[Dict[str, Any]] = None) -> List[Kanton]:
|
||||
"""Get all cantons matching the filter."""
|
||||
records = self.db.getRecordset(Kanton, recordFilter=recordFilter or {})
|
||||
filtered = self.access.uam(Kanton, records)
|
||||
return [Kanton(**r) for r in filtered]
|
||||
records = getRecordsetWithRBAC(
|
||||
self.db,
|
||||
Kanton,
|
||||
self.currentUser,
|
||||
recordFilter=recordFilter or {}
|
||||
)
|
||||
return [Kanton(**r) for r in records]
|
||||
|
||||
def updateKanton(self, kantonId: str, updateData: Dict[str, Any]) -> Optional[Kanton]:
|
||||
"""Update a canton."""
|
||||
|
|
@ -589,7 +614,7 @@ class RealEstateObjects:
|
|||
if not kanton:
|
||||
return None
|
||||
|
||||
if not self.access.canModify(Kanton, kantonId):
|
||||
if not self.checkRbacPermission(Kanton, "update", kantonId):
|
||||
raise PermissionError(f"User {self.userId} cannot modify canton {kantonId}")
|
||||
|
||||
for key, value in updateData.items():
|
||||
|
|
@ -605,7 +630,7 @@ class RealEstateObjects:
|
|||
if not kanton:
|
||||
return False
|
||||
|
||||
if not self.access.canModify(Kanton, kantonId):
|
||||
if not self.checkRbacPermission(Kanton, "delete", kantonId):
|
||||
raise PermissionError(f"User {self.userId} cannot delete canton {kantonId}")
|
||||
|
||||
return self.db.recordDelete(Kanton, kantonId)
|
||||
|
|
@ -614,36 +639,40 @@ class RealEstateObjects:
|
|||
|
||||
def createLand(self, land: Land) -> Land:
|
||||
"""Create a new country."""
|
||||
# Check RBAC permission
|
||||
if not self.checkRbacPermission(Land, "create"):
|
||||
raise PermissionError(f"User {self.userId} cannot create countries")
|
||||
|
||||
if not land.mandateId:
|
||||
land.mandateId = self.mandateId
|
||||
|
||||
self.access.uam(Land, [])
|
||||
self.db.recordCreate(Land, land.model_dump())
|
||||
|
||||
return land
|
||||
|
||||
def getLand(self, landId: str) -> Optional[Land]:
|
||||
"""Get a country by ID."""
|
||||
records = self.db.getRecordset(
|
||||
records = getRecordsetWithRBAC(
|
||||
self.db,
|
||||
Land,
|
||||
self.currentUser,
|
||||
recordFilter={"id": landId}
|
||||
)
|
||||
|
||||
if not records:
|
||||
return None
|
||||
|
||||
filtered = self.access.uam(Land, records)
|
||||
|
||||
if not filtered:
|
||||
return None
|
||||
|
||||
return Land(**filtered[0])
|
||||
return Land(**records[0])
|
||||
|
||||
def getLaender(self, recordFilter: Optional[Dict[str, Any]] = None) -> List[Land]:
|
||||
"""Get all countries matching the filter."""
|
||||
records = self.db.getRecordset(Land, recordFilter=recordFilter or {})
|
||||
filtered = self.access.uam(Land, records)
|
||||
return [Land(**r) for r in filtered]
|
||||
records = getRecordsetWithRBAC(
|
||||
self.db,
|
||||
Land,
|
||||
self.currentUser,
|
||||
recordFilter=recordFilter or {}
|
||||
)
|
||||
return [Land(**r) for r in records]
|
||||
|
||||
def updateLand(self, landId: str, updateData: Dict[str, Any]) -> Optional[Land]:
|
||||
"""Update a country."""
|
||||
|
|
@ -651,7 +680,7 @@ class RealEstateObjects:
|
|||
if not land:
|
||||
return None
|
||||
|
||||
if not self.access.canModify(Land, landId):
|
||||
if not self.checkRbacPermission(Land, "update", landId):
|
||||
raise PermissionError(f"User {self.userId} cannot modify country {landId}")
|
||||
|
||||
for key, value in updateData.items():
|
||||
|
|
@ -667,11 +696,51 @@ class RealEstateObjects:
|
|||
if not land:
|
||||
return False
|
||||
|
||||
if not self.access.canModify(Land, landId):
|
||||
if not self.checkRbacPermission(Land, "delete", landId):
|
||||
raise PermissionError(f"User {self.userId} cannot delete country {landId}")
|
||||
|
||||
return self.db.recordDelete(Land, landId)
|
||||
|
||||
# ===== RBAC Permission Checks =====
|
||||
|
||||
def checkRbacPermission(
|
||||
self,
|
||||
modelClass: type,
|
||||
operation: str,
|
||||
recordId: Optional[str] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Check RBAC permission for a specific operation on a table.
|
||||
|
||||
Args:
|
||||
modelClass: Pydantic model class for the table
|
||||
operation: Operation to check ('create', 'update', 'delete', 'read')
|
||||
recordId: Optional record ID for specific record check
|
||||
|
||||
Returns:
|
||||
Boolean indicating permission
|
||||
"""
|
||||
if not self.rbac or not self.currentUser:
|
||||
return False
|
||||
|
||||
tableName = modelClass.__name__
|
||||
permissions = self.rbac.getUserPermissions(
|
||||
self.currentUser,
|
||||
AccessRuleContext.DATA,
|
||||
tableName
|
||||
)
|
||||
|
||||
if operation == "create":
|
||||
return permissions.create != AccessLevel.NONE
|
||||
elif operation == "update":
|
||||
return permissions.update != AccessLevel.NONE
|
||||
elif operation == "delete":
|
||||
return permissions.delete != AccessLevel.NONE
|
||||
elif operation == "read":
|
||||
return permissions.read != AccessLevel.NONE
|
||||
else:
|
||||
return False
|
||||
|
||||
# ===== Direct Query Execution (stateless) =====
|
||||
|
||||
def executeQuery(self, queryText: str, parameters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
|
|
|
|||
|
|
@ -112,10 +112,24 @@ def getRecordsetWithRBAC(
|
|||
)
|
||||
elif fieldType == "JSONB" and fieldName in record:
|
||||
if record[fieldName] is None:
|
||||
if fieldName in ["logs", "messages", "tasks", "expectedDocumentFormats", "resultDocuments"]:
|
||||
record[fieldName] = []
|
||||
elif fieldName in ["execParameters", "stats"]:
|
||||
record[fieldName] = {}
|
||||
# Generic type-based default: List types -> [], Dict types -> {}
|
||||
# Interfaces handle domain-specific defaults
|
||||
modelFields = modelClass.model_fields
|
||||
fieldInfo = modelFields.get(fieldName)
|
||||
if fieldInfo:
|
||||
fieldAnnotation = fieldInfo.annotation
|
||||
# Check if it's a List type
|
||||
if (fieldAnnotation == list or
|
||||
(hasattr(fieldAnnotation, "__origin__") and
|
||||
fieldAnnotation.__origin__ is list)):
|
||||
record[fieldName] = []
|
||||
# Check if it's a Dict type
|
||||
elif (fieldAnnotation == dict or
|
||||
(hasattr(fieldAnnotation, "__origin__") and
|
||||
fieldAnnotation.__origin__ is dict)):
|
||||
record[fieldName] = {}
|
||||
else:
|
||||
record[fieldName] = None
|
||||
else:
|
||||
record[fieldName] = None
|
||||
else:
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue