From 300f1d4cdab66881e573270918484b2d6d1c80ce Mon Sep 17 00:00:00 2001 From: Darrell Malone Jr Date: Sun, 29 Sep 2024 13:50:10 -0500 Subject: [PATCH] Move Neomodel class extensions to schemas.py --- backend/database/models/agency.py | 2 +- backend/database/models/attachment.py | 2 +- backend/database/models/complaint.py | 2 +- backend/database/models/litigation.py | 2 +- backend/database/models/officer.py | 2 +- backend/database/models/partner.py | 6 +- backend/database/models/user.py | 18 +- backend/database/neo_classes.py | 192 -------------------- backend/schemas.py | 243 +++++++++++++++++++++++++- 9 files changed, 265 insertions(+), 204 deletions(-) delete mode 100644 backend/database/neo_classes.py diff --git a/backend/database/models/agency.py b/backend/database/models/agency.py index 98fd1d019..66a612abf 100644 --- a/backend/database/models/agency.py +++ b/backend/database/models/agency.py @@ -1,4 +1,4 @@ -from backend.database.neo_classes import JsonSerializable, PropertyEnum +from backend.schemas import JsonSerializable, PropertyEnum from neomodel import ( StructuredNode, StructuredRel, diff --git a/backend/database/models/attachment.py b/backend/database/models/attachment.py index f3af9f30a..d7bbaf116 100644 --- a/backend/database/models/attachment.py +++ b/backend/database/models/attachment.py @@ -1,4 +1,4 @@ -from backend.database.neo_classes import JsonSerializable +from backend.schemas import JsonSerializable from neomodel import ( StringProperty, UniqueIdProperty, diff --git a/backend/database/models/complaint.py b/backend/database/models/complaint.py index 2f561937e..02339d5ca 100644 --- a/backend/database/models/complaint.py +++ b/backend/database/models/complaint.py @@ -1,5 +1,5 @@ """Define the Classes for Complaints.""" -from backend.database.neo_classes import JsonSerializable, PropertyEnum +from backend.schemas import JsonSerializable, PropertyEnum from neomodel import ( StructuredNode, StructuredRel, diff --git a/backend/database/models/litigation.py b/backend/database/models/litigation.py index 71465b424..056620ed6 100644 --- a/backend/database/models/litigation.py +++ b/backend/database/models/litigation.py @@ -1,4 +1,4 @@ -from backend.database.neo_classes import JsonSerializable, PropertyEnum +from backend.schemas import JsonSerializable, PropertyEnum from neomodel import ( StructuredNode, StringProperty, diff --git a/backend/database/models/officer.py b/backend/database/models/officer.py index bd8a8b33a..eed2a54af 100644 --- a/backend/database/models/officer.py +++ b/backend/database/models/officer.py @@ -1,4 +1,4 @@ -from backend.database.neo_classes import JsonSerializable, PropertyEnum +from backend.schemas import JsonSerializable, PropertyEnum from neomodel import ( StructuredNode, diff --git a/backend/database/models/partner.py b/backend/database/models/partner.py index 091723513..fe6d6300b 100644 --- a/backend/database/models/partner.py +++ b/backend/database/models/partner.py @@ -1,6 +1,6 @@ from __future__ import annotations # allows type hinting of class itself # from ..core import db, CrudMixin -from backend.database.neo_classes import JsonSerializable, PropertyEnum +from backend.schemas import JsonSerializable, PropertyEnum from datetime import datetime from neomodel import ( StructuredNode, StructuredRel, @@ -95,6 +95,10 @@ def __repr__(self): class Partner(StructuredNode, JsonSerializable): + __property_order__ = [ + "uid", "name", "url", + "contact_email" + ] uid = UniqueIdProperty() name = StringProperty(unique_index=True) diff --git a/backend/database/models/user.py b/backend/database/models/user.py index e27d873a4..9f2cf2838 100644 --- a/backend/database/models/user.py +++ b/backend/database/models/user.py @@ -2,7 +2,7 @@ import bcrypt from werkzeug.security import generate_password_hash, check_password_hash -from backend.database.neo_classes import JsonSerializable, PropertyEnum +from backend.schemas import JsonSerializable, PropertyEnum from neomodel import ( Relationship, StructuredNode, StringProperty, DateProperty, BooleanProperty, @@ -30,6 +30,13 @@ def get_value(self): # Define the User data-model. class User(StructuredNode, JsonSerializable): + __hidden_properties__ = ["password_hash"] + __property_order__ = [ + "uid", "first_name", "last_name", + "email", "email_confirmed_at", + "phone_number", "role", "active" + ] + uid = UniqueIdProperty() active = BooleanProperty(default=True) @@ -94,6 +101,15 @@ def send_password_reset(self): """ pass + @property + def role_enum(self) -> UserRole: + """ + Get the user's role as an enum. + Returns: + UserRole: The user's role as an enum. + """ + return UserRole(self.role) + @classmethod def hash_password(cls, pw: str) -> str: """ diff --git a/backend/database/neo_classes.py b/backend/database/neo_classes.py deleted file mode 100644 index c86b32399..000000000 --- a/backend/database/neo_classes.py +++ /dev/null @@ -1,192 +0,0 @@ -import json -from typing import Any, Optional, TypeVar, Type, List -from collections import OrderedDict -from enum import Enum -from flask import abort, jsonify -from neomodel import ( - RelationshipTo, - RelationshipFrom, Relationship, - RelationshipManager, RelationshipDefinition -) -from neomodel.exceptions import DoesNotExist - - -T = TypeVar("T", bound="JsonSerializable") - - -# Update Enums to work well with NeoModel -class PropertyEnum(Enum): - """Use this Enum to convert the options to a dictionary.""" - @classmethod - def choices(cls): - return {item.value: item.name for item in cls} - - -# Makes a StructuredNode convertible to and from JSON and Dicts -class JsonSerializable: - """Mix me into a database model to make it JSON serializable.""" - __hidden_properties__ = [] - __property_order__ = [] - - def to_dict(self, include_relationships=True, - exclude_fields=None): - """ - Convert the node instance into a dictionary. - Args: - include_relationships (bool): Whether to include - relationships in the output. - - exclude_fields (list): List of fields to exclude - from serialization. - - field_order (list): List of fields to order the - output by. - - Returns: - dict: A dictionary representation of the node. - """ - exclude_fields = exclude_fields or [] - field_order = self.__property_order__ - - all_excludes = set( - self.__hidden_properties__).union(set(exclude_fields)) - - all_props = self.defined_properties() - node_props = OrderedDict() - - if field_order: - ordered_props = [prop for prop in field_order if prop in all_props] - else: - ordered_props = list(all_props.keys()) - - # Serialize node properties - for prop_name in ordered_props: - if prop_name not in all_excludes: - value = getattr(self, prop_name, None) - node_props[prop_name] = value - - # Optionally add related nodes - if include_relationships: - relationships = { - key: value for key, value in self.__class__.__dict__.items() - if isinstance(value, RelationshipDefinition) - } - for key in relationships: - if key in all_excludes: - continue - rel_manager = getattr(self, key, None) - if isinstance(rel_manager, RelationshipManager): - related_nodes = rel_manager.all() - node_props[key] = [ - node.to_dict(include_relationships=False) - for node in related_nodes - ] - return node_props - - def to_json(self): - """Convert the node instance into a JSON string.""" - return jsonify(self.to_dict()) - - @classmethod - def from_dict(cls: Type[T], data: dict) -> T: - """ - Creates or updates an instance of the model from a dictionary. - - Args: - data (dict): A dictionary containing data for the model instance. - - Returns: - Instance of the model. - """ - instance = None - all_props = cls.defined_properties() - - # Handle unique properties to find existing instances - unique_props = { - prop: data.get(prop) - for prop in all_props if prop in data - } - - if unique_props: - try: - instance = cls.nodes.get(**unique_props) - # Update existing instance - for key, value in data.items(): - if key in instance.__all_properties__: - setattr(instance, key, value) - except DoesNotExist: - # No existing instance, create a new one - instance = cls(**unique_props) - else: - instance = cls() - - # Set properties - for key, value in data.items(): - if key in all_props: - setattr(instance, key, value) - - # Handle relationships if they exist in the dictionary - for key, value in data.items(): - if key.endswith("_uid"): - rel_name = key[:-4] - - # See if a relationship manager exists for the pair - if isinstance( - getattr(cls, rel_name, None), RelationshipManager - ): - rel_manager = getattr(instance, rel_name) - - # Fetch the related node by its unique identifier - related_node_class = rel_manager.definition['node_class'] - try: - related_instance = related_node_class.nodes.get( - uid=value) - rel_manager.connect(related_instance) - except DoesNotExist: - raise ValueError(f"Related {related_node_class.__name__} with UID {value} not found.") - # Handle relationship properties - if key.endswith("_details"): - rel_name = key[:-8] - if isinstance(getattr(cls, rel_name, None), RelationshipManager): - rel_manager = getattr(instance, rel_name) - if rel_manager.exists(): - relationship = rel_manager.relationship(related_instance) - setattr(relationship, key, value) - relationship.save() - # Save the instance - instance.save() - return instance - - @classmethod - def __all_properties__(cls) -> List[str]: - """Get a list of all properties defined in the class.""" - return [prop_name for prop_name in cls.__dict__ if isinstance( - cls.__dict__[prop_name], property)] - - @classmethod - def __all_relationships__(cls) -> dict: - """Get all relationships defined in the class.""" - return { - rel_name: rel_manager for rel_name, rel_manager in cls.__dict__.items() - if isinstance( - rel_manager, (RelationshipTo, RelationshipFrom, Relationship)) - } - - @classmethod - def get(cls: Type[T], uid: Any, abort_if_null: bool = True) -> Optional[T]: - """ - Get a model instance by its UID, returning None if - not found (or aborting). - - Args: - uid: Unique identifier for the node (could be Neo4j internal ID - or custom UUID). - abort_if_null (bool): Whether to abort if the node is not found. - - Returns: - Optional[T]: An instance of the model or None. - """ - obj = cls.nodes.get_or_none(uid=uid) - if obj is None and abort_if_null: - abort(404) - return obj # type: ignore diff --git a/backend/schemas.py b/backend/schemas.py index 753488894..0d533ac14 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -1,12 +1,20 @@ from __future__ import annotations +import json +import textwrap from functools import wraps -from flask import request, jsonify +from enum import Enum +from collections import OrderedDict +from typing import Any, Optional, TypeVar, Type, List +from flask import abort, request, jsonify, current_app from pydantic import BaseModel, ValidationError -from .database.neo_classes import JsonSerializable - -import textwrap from spectree import SecurityScheme, SpecTree from spectree.models import Server +from neomodel import ( + RelationshipTo, + RelationshipFrom, Relationship, + RelationshipManager, RelationshipDefinition +) +from neomodel.exceptions import DoesNotExist spec = SpecTree( @@ -73,6 +81,52 @@ ) +T = TypeVar("T", bound="JsonSerializable") + + +# Function that replaces jsonify to properly handle OrderedDicts +def ordered_jsonify(*args, **kwargs): + """ + Return a JSON response with OrderedDict objects properly serialized, + preserving their order. Behaves like Flask's jsonify. + + Args: + *args: The arguments to pass to the function. + **kwargs: The keyword arguments to pass to the function. + + Returns: + Response: A Flask Response object with the JSON data. + """ + # Determine the indentation and separators based on the app configuration + indent = None + separators = (',', ':') + if current_app.config.get('JSONIFY_PRETTYPRINT_REGULAR', False): + indent = 2 + separators = (', ', ': ') + + # Handle the arguments similar to how Flask's jsonify does + if args and kwargs: + raise TypeError('ordered_jsonify() behavior undefined when passed both args and kwargs') + elif len(args) == 1: + data = args[0] + else: + # For multiple arguments, create a list; for kwargs, create a dict + data = args if args else kwargs + + # Serialize the data to JSON, ensuring that OrderedDicts preserve order + json_str = json.dumps( + data, + indent=indent, + separators=separators, + ) + + # Create and return the response + return current_app.response_class( + json_str, + mimetype=current_app.config.get('JSONIFY_MIMETYPE', 'application/json') + ) + + # A decorator to validate request bodies using Pydantic models def validate_request(model: BaseModel): """ @@ -102,7 +156,7 @@ def decorated_function(*args, **kwargs): return decorator -def paginate_response( +def paginate_results( data: list[JsonSerializable], page: int, per_page: int, max_per_page: int = 100): """ @@ -133,3 +187,182 @@ def paginate_response( "per_page": per_page, "total": len(data), } + + +# Update Enums to work well with NeoModel +class PropertyEnum(Enum): + """Use this Enum to convert the options to a dictionary.""" + @classmethod + def choices(cls): + return {item.value: item.name for item in cls} + + +# Makes a StructuredNode convertible to and from JSON and Dicts +class JsonSerializable: + """Mix me into a database model to make it JSON serializable.""" + __hidden_properties__ = [] + __property_order__ = [] + + def to_dict(self, include_relationships=True, + exclude_fields=None): + """ + Convert the node instance into a dictionary. + Args: + include_relationships (bool): Whether to include + relationships in the output. + + exclude_fields (list): List of fields to exclude + from serialization. + + field_order (list): List of fields to order the + output by. + + Returns: + dict: A dictionary representation of the node. + """ + exclude_fields = exclude_fields or [] + field_order = self.__property_order__ + + all_excludes = set( + self.__hidden_properties__).union(set(exclude_fields)) + + all_props = self.defined_properties() + node_props = OrderedDict() + + if field_order: + ordered_props = [prop for prop in field_order if prop in all_props] + else: + ordered_props = list(all_props.keys()) + + # Serialize node properties + for prop_name in ordered_props: + if prop_name not in all_excludes: + value = getattr(self, prop_name, None) + node_props[prop_name] = value + + # Optionally add related nodes + if include_relationships: + relationships = { + key: value for key, value in self.__class__.__dict__.items() + if isinstance(value, RelationshipDefinition) + } + for key in relationships: + if key in all_excludes: + continue + rel_manager = getattr(self, key, None) + if isinstance(rel_manager, RelationshipManager): + related_nodes = rel_manager.all() + node_props[key] = [ + node.to_dict(include_relationships=False) + for node in related_nodes + ] + return node_props + + def to_json(self): + """Convert the node instance into a JSON string.""" + return ordered_jsonify(self.to_dict()) + + @classmethod + def from_dict(cls: Type[T], data: dict) -> T: + """ + Creates or updates an instance of the model from a dictionary. + + Args: + data (dict): A dictionary containing data for the model instance. + + Returns: + Instance of the model. + """ + instance = None + all_props = cls.defined_properties() + + # Handle unique properties to find existing instances + unique_props = { + prop: data.get(prop) + for prop in all_props if prop in data + } + + if unique_props: + try: + instance = cls.nodes.get(**unique_props) + # Update existing instance + for key, value in data.items(): + if key in instance.__all_properties__: + setattr(instance, key, value) + except DoesNotExist: + # No existing instance, create a new one + instance = cls(**unique_props) + else: + instance = cls() + + # Set properties + for key, value in data.items(): + if key in all_props: + setattr(instance, key, value) + + # Handle relationships if they exist in the dictionary + for key, value in data.items(): + if key.endswith("_uid"): + rel_name = key[:-4] + + # See if a relationship manager exists for the pair + if isinstance( + getattr(cls, rel_name, None), RelationshipManager + ): + rel_manager = getattr(instance, rel_name) + + # Fetch the related node by its unique identifier + related_node_class = rel_manager.definition['node_class'] + try: + related_instance = related_node_class.nodes.get( + uid=value) + rel_manager.connect(related_instance) + except DoesNotExist: + raise ValueError(f"Related {related_node_class.__name__} with UID {value} not found.") + # Handle relationship properties + if key.endswith("_details"): + rel_name = key[:-8] + if isinstance(getattr(cls, rel_name, None), RelationshipManager): + rel_manager = getattr(instance, rel_name) + if rel_manager.exists(): + relationship = rel_manager.relationship( + related_instance) + setattr(relationship, key, value) + relationship.save() + # Save the instance + instance.save() + return instance + + @classmethod + def __all_properties__(cls) -> List[str]: + """Get a list of all properties defined in the class.""" + return [prop_name for prop_name in cls.__dict__ if isinstance( + cls.__dict__[prop_name], property)] + + @classmethod + def __all_relationships__(cls) -> dict: + """Get all relationships defined in the class.""" + return { + rel_name: rel_manager for rel_name, rel_manager in cls.__dict__.items() + if isinstance( + rel_manager, (RelationshipTo, RelationshipFrom, Relationship)) + } + + @classmethod + def get(cls: Type[T], uid: Any, abort_if_null: bool = True) -> Optional[T]: + """ + Get a model instance by its UID, returning None if + not found (or aborting). + + Args: + uid: Unique identifier for the node (could be Neo4j internal ID + or custom UUID). + abort_if_null (bool): Whether to abort if the node is not found. + + Returns: + Optional[T]: An instance of the model or None. + """ + obj = cls.nodes.get_or_none(uid=uid) + if obj is None and abort_if_null: + abort(404) + return obj # type: ignore