From 5d8b37179d6982ef8ce1fab6ec36fac165b1f17c Mon Sep 17 00:00:00 2001 From: berkay Date: Mon, 13 Jan 2025 22:17:09 +0300 Subject: [PATCH] Service Postgres added --- AllConfigs/main.py | 6 + ApiLibrary/__init__.py | 3 + .../date_time_actions/date_functions.py | 116 ++++ ApiValidations/Request/__init__.py | 13 + ApiValidations/Request/base_validations.py | 33 ++ .../Postgres => ApiValidations}/__init__.py | 0 ApiValidations/handler.py | 22 + ErrorHandlers/ApiErrorHandlers/__init__.py | 10 + .../ApiErrorHandlers/api_exc_handler.py | 45 ++ ErrorHandlers/LanguageModels/__init__.py | 4 + .../LanguageModels/base_languages.py | 38 ++ ErrorHandlers/base.py | 16 + ErrorHandlers/bases.py | 30 + ErrorHandlers/statuses.py | 61 ++ Services/MongoDb/__init__.py | 0 Services/Postgres/Models/base.py | 68 --- Services/Postgres/Models/response.py | 49 -- .../PostgresDb/Models/alchemy_response.py | 379 +++++++++++++ .../Models/base_model.py | 2 +- .../PostgresDb/Models/filter_functions.py | 526 ++++++++++++++++++ .../{Postgres => PostgresDb}/Models/how_to.py | 76 +-- .../Models/mixins.py} | 289 +++++----- .../{Postgres => PostgresDb}/Models/query.py | 3 +- Services/PostgresDb/Models/response.py | 81 +++ Services/PostgresDb/__init__.py | 6 + Services/{Postgres => PostgresDb}/database.py | 1 + 26 files changed, 1569 insertions(+), 308 deletions(-) create mode 100644 ApiLibrary/__init__.py create mode 100644 ApiLibrary/date_time_actions/date_functions.py create mode 100644 ApiValidations/Request/__init__.py create mode 100644 ApiValidations/Request/base_validations.py rename {Services/Postgres => ApiValidations}/__init__.py (100%) create mode 100644 ApiValidations/handler.py create mode 100644 ErrorHandlers/ApiErrorHandlers/__init__.py create mode 100644 ErrorHandlers/ApiErrorHandlers/api_exc_handler.py create mode 100644 ErrorHandlers/LanguageModels/__init__.py create mode 100644 ErrorHandlers/LanguageModels/base_languages.py create mode 100644 ErrorHandlers/base.py create mode 100644 ErrorHandlers/bases.py create mode 100644 ErrorHandlers/statuses.py create mode 100644 Services/MongoDb/__init__.py delete mode 100644 Services/Postgres/Models/base.py delete mode 100644 Services/Postgres/Models/response.py create mode 100644 Services/PostgresDb/Models/alchemy_response.py rename Services/{Postgres => PostgresDb}/Models/base_model.py (99%) create mode 100644 Services/PostgresDb/Models/filter_functions.py rename Services/{Postgres => PostgresDb}/Models/how_to.py (77%) rename Services/{Postgres/Models/my_base_model.py => PostgresDb/Models/mixins.py} (77%) rename Services/{Postgres => PostgresDb}/Models/query.py (98%) create mode 100644 Services/PostgresDb/Models/response.py create mode 100644 Services/PostgresDb/__init__.py rename Services/{Postgres => PostgresDb}/database.py (99%) diff --git a/AllConfigs/main.py b/AllConfigs/main.py index 460ec4e..96412a8 100644 --- a/AllConfigs/main.py +++ b/AllConfigs/main.py @@ -5,3 +5,9 @@ class HostConfig: class MainConfig: DATETIME_FORMAT = "YYYY-MM-DD HH:mm:ss Z" + DATETIME_FORMAT_JS = "YYYY-MM-DD HH:mm:ss +0" + + # Timezone Configuration + DEFAULT_TIMEZONE = "GMT+3" # Default timezone for the application + SYSTEM_TIMEZONE = "GMT+0" # System timezone (used for internal operations) + SUPPORTED_TIMEZONES = ["GMT+0", "GMT+3"] # List of supported timezones diff --git a/ApiLibrary/__init__.py b/ApiLibrary/__init__.py new file mode 100644 index 0000000..7165020 --- /dev/null +++ b/ApiLibrary/__init__.py @@ -0,0 +1,3 @@ +from ApiLibrary.date_time_actions.date_functions import DateTimeLocal, system_arrow + +__all__ = ["DateTimeLocal", "system_arrow"] diff --git a/ApiLibrary/date_time_actions/date_functions.py b/ApiLibrary/date_time_actions/date_functions.py new file mode 100644 index 0000000..533cf08 --- /dev/null +++ b/ApiLibrary/date_time_actions/date_functions.py @@ -0,0 +1,116 @@ +import arrow +import calendar +from AllConfigs.main import MainConfig as Config + + +class DateTimeLocal: + + def __init__(self, timezone: str = None, is_client: bool = True): + if timezone and timezone not in Config.SUPPORTED_TIMEZONES: + raise ValueError( + f"Unsupported timezone: {timezone}. Must be one of {Config.SUPPORTED_TIMEZONES}" + ) + + self.timezone = Config.SYSTEM_TIMEZONE + if is_client: + self.timezone = (timezone or Config.DEFAULT_TIMEZONE).replace("-", "+") + + def find_last_day_of_month(self, date_value): + today = self.get(date_value).date() + _, last_day = calendar.monthrange(today.year, today.month) + return self.get(today.year, today.month, last_day, 23, 59, 59).to(self.timezone) + + def find_first_day_of_month(self, date_value): + today = self.get(date_value).date() + return self.get(today.year, today.month, 1).to(self.timezone) + + def get(self, *args): + return arrow.get(*args).to(str(self.timezone)) + + def now(self): + return arrow.now().to(str(self.timezone)) + + def shift(self, date, **kwargs): + return self.get(date).shift(**kwargs) + + def date(self, date): + return self.get(date).date() + + def time(self, date): + return self.get(date).time() + + def string_date(self, date, splitter: str = "-"): + return str(self.get(date).date()).replace("-", splitter) + + def string_time_only(self, date): + return self.get(date).format("HH:mm:ss") + + def string_date_only(self, date): + return self.get(date).format("YYYY-MM-DD") + + def to_timestamp(self, date): + """Convert datetime to UTC timestamp""" + return self.get(date).timestamp() + + def from_timestamp(self, timestamp): + """Convert timestamp to timezone-aware datetime""" + return arrow.get(timestamp).to(str(self.timezone)) + + def is_timezone_aware(self, date): + """Check if a date is timezone-aware""" + return self.get(date).tzinfo is not None + + def standardize_timezone(self, date): + """Ensure date is in the correct timezone""" + if not self.is_timezone_aware(date): + return self.get(date).to(str(self.timezone)) + return self.get(date) + + def get_expiry_time(self, **kwargs): + """Get future time for cache expiry + Example: get_expiry_time(hours=1, minutes=30) + """ + return self.now().shift(**kwargs) + + def is_expired(self, timestamp): + """Check if a timestamp is expired""" + if not timestamp: + return True + return self.from_timestamp(timestamp) < self.now() + + def get_cache_key(self, base_key, *args): + """Generate a cache key with timezone info + Example: get_cache_key('user_profile', user_id, 'details') + """ + components = [str(base_key)] + components.extend(str(arg) for arg in args) + components.append(f"tz_{self.timezone}") + return ":".join(components) + + def format_for_db(self, date): + """Format date for database storage""" + return self.get(date).format("YYYY-MM-DD HH:mm:ss.SSSZZ") + + def parse_from_db(self, date_str): + """Parse date from database format""" + if not date_str: + return None + return self.get(date_str) + + def get_day_boundaries(self, date=None): + """Get start and end of day in current timezone""" + dt = self.get(date) if date else self.now() + start = dt.floor("day") + end = dt.ceil("day") + return start, end + + def get_month_boundaries(self, date=None): + """Get start and end of month in current timezone""" + dt = self.get(date) if date else self.now() + start = dt.floor("month") + end = dt.ceil("month") + return start, end + + +client_arrow = DateTimeLocal(is_client=True) +system_arrow = DateTimeLocal(is_client=False) diff --git a/ApiValidations/Request/__init__.py b/ApiValidations/Request/__init__.py new file mode 100644 index 0000000..0233ace --- /dev/null +++ b/ApiValidations/Request/__init__.py @@ -0,0 +1,13 @@ +from .base_validations import ( + BaseModelRegular, + PydanticBaseModel, + ListOptions, + CrudRecords, +) + +__all__ = [ + "BaseModelRegular", + "PydanticBaseModel", + "ListOptions", + "CrudRecords", +] diff --git a/ApiValidations/Request/base_validations.py b/ApiValidations/Request/base_validations.py new file mode 100644 index 0000000..27eb461 --- /dev/null +++ b/ApiValidations/Request/base_validations.py @@ -0,0 +1,33 @@ +from typing import Optional +from ApiValidations.handler import BaseModelRegular + + +class ListOptions(BaseModelRegular): + page: Optional[int] = 1 + size: Optional[int] = 10 + order_field: Optional[str] = "id" + order_type: Optional[str] = "asc" + include_joins: Optional[list] = None + query: Optional[dict] = None + + +class CrudRecords: + uu_id: Optional[str] = None + created_at: Optional[str] = None + updated_at: Optional[str] = None + created_by: Optional[str] = None + updated_by: Optional[str] = None + confirmed_by: Optional[str] = None + is_confirmed: Optional[bool] = None + active: Optional[bool] = None + is_notification_send: Optional[bool] = None + is_email_send: Optional[bool] = None + + +class PydanticBaseModel(BaseModelRegular): + + active: Optional[bool] = None + deleted: Optional[bool] = None + expiry_starts: Optional[str] = None + # expiry_ends: Optional[str] = None + is_confirmed: Optional[bool] = None diff --git a/Services/Postgres/__init__.py b/ApiValidations/__init__.py similarity index 100% rename from Services/Postgres/__init__.py rename to ApiValidations/__init__.py diff --git a/ApiValidations/handler.py b/ApiValidations/handler.py new file mode 100644 index 0000000..e5f0214 --- /dev/null +++ b/ApiValidations/handler.py @@ -0,0 +1,22 @@ +from pydantic import BaseModel + + +def rewrite_input_data(data): + + return { + item[0]: item[1] + for item in data.items() + if not item[1] == "" and item[1] is not None + } + + +class BaseModelRegular(BaseModel): + + def __init__(self, **kwargs): + super().__init__(**rewrite_input_data(kwargs)) + + def excluded_dump(self): + return self.model_dump(exclude_unset=True, exclude_none=True) + + def dump(self): + return self.model_dump() diff --git a/ErrorHandlers/ApiErrorHandlers/__init__.py b/ErrorHandlers/ApiErrorHandlers/__init__.py new file mode 100644 index 0000000..8b3aa1e --- /dev/null +++ b/ErrorHandlers/ApiErrorHandlers/__init__.py @@ -0,0 +1,10 @@ +from ErrorHandlers.ApiErrorHandlers.api_exc_handler import ( + HTTPExceptionApiHandler, + HTTPExceptionApi, +) + + +__all__ = [ + "HTTPExceptionApiHandler", + "HTTPExceptionApi", +] diff --git a/ErrorHandlers/ApiErrorHandlers/api_exc_handler.py b/ErrorHandlers/ApiErrorHandlers/api_exc_handler.py new file mode 100644 index 0000000..6d65cf5 --- /dev/null +++ b/ErrorHandlers/ApiErrorHandlers/api_exc_handler.py @@ -0,0 +1,45 @@ +from typing import Any, Dict, List, Optional + + +class HTTPExceptionApi(Exception): + + def __init__(self, error_code: str, lang: str): + self.error_code = error_code + self.lang = lang + + +class HTTPExceptionApiHandler: + + def __init__( + self, + **kwargs, + ): + self.EXCEPTIONS = kwargs.get( + "exceptions" + ) # from fastapi.exceptions import HTTPException + self.STATUSES = kwargs.get("statuses") # from fastapi import status + self.EXCEPTION_DICTS: Dict = kwargs.get("exceptions_dict") + self.ERRORS_DICT: Dict = kwargs.get("errors_dict") + self.ERRORS_LANG: Dict = kwargs.get("error_language_dict") + self.RESPONSE_MODEL: Any = kwargs.get("response_model") + + def retrieve_error_status_code(self, exc: HTTPExceptionApi): + grab_status = self.ERRORS_DICT.get(str(exc.error_code).upper(), "") + grab_status_code = self.EXCEPTION_DICTS.get(str(grab_status).upper(), "500") + return getattr( + self.STATUSES, + str(grab_status_code), + getattr(self.STATUSES, "HTTP_500_INTERNAL_SERVER_ERROR"), + ) + + def retrieve_error_message(self, exc: HTTPExceptionApi): + message_by_lang = self.ERRORS_LANG.get(str(exc.lang).lower(), {}) + return message_by_lang.get(str(exc.error_code).upper(), "Unknown error") + + def handle_exception(self, request, exc: HTTPExceptionApi): + status_code = self.retrieve_error_status_code(exc) + error_message = self.retrieve_error_message(exc) + return self.RESPONSE_MODEL( + status_code=int(status_code), + content={"message": error_message, "lang": exc.lang, "request": request}, + ) diff --git a/ErrorHandlers/LanguageModels/__init__.py b/ErrorHandlers/LanguageModels/__init__.py new file mode 100644 index 0000000..a7963a9 --- /dev/null +++ b/ErrorHandlers/LanguageModels/__init__.py @@ -0,0 +1,4 @@ +from ErrorHandlers.LanguageModels.base_languages import BaseErrorLanguageModels + + +__all__ = ["BaseErrorLanguageModels"] diff --git a/ErrorHandlers/LanguageModels/base_languages.py b/ErrorHandlers/LanguageModels/base_languages.py new file mode 100644 index 0000000..791717a --- /dev/null +++ b/ErrorHandlers/LanguageModels/base_languages.py @@ -0,0 +1,38 @@ +from ErrorHandlers.bases import ErrorLanguageModelClass, LanguageModelClass + + +class BaseErrorLanguageModelTurkish(ErrorLanguageModelClass): + + NOT_CREATED: str = "Kayıt oluşturulamadı." + NOT_DELETED: str = "Kayıt silinemedi." + NOT_UPDATED: str = "Kayıt güncellenemedi." + NOT_LISTED: str = "Kayıt listelenemedi." + NOT_FOUND: str = "Kayıt bulunamadı." + ALREADY_EXISTS: str = "Kayıt zaten mevcut." + IS_NOT_CONFIRMED: str = "Kayıt onaylanmadı." + NOT_AUTHORIZED: str = "Yetkisiz kullanıcı." + NOT_VALID: str = "Gecersiz veri." + NOT_ACCEPTABLE: str = "Gecersiz veri." + INVALID_DATA: str = "Gecersiz veri." + UNKNOWN_ERROR: str = "Bilinmeyen bir hata oluştu." + + +class BaseErrorLanguageModelEnglish(ErrorLanguageModelClass): + + NOT_CREATED: str = "Not Created." + NOT_DELETED: str = "Not Deleted." + NOT_UPDATED: str = "Not Updated." + NOT_LISTED: str = "Not Listed." + NOT_FOUND: str = "Not Found." + ALREADY_EXISTS: str = "Already Exists." + IS_NOT_CONFIRMED: str = "Not Confirmed." + NOT_AUTHORIZED: str = "Not Authorized." + NOT_VALID: str = "Not Valid." + NOT_ACCEPTABLE: str = "Not Acceptable." + INVALID_DATA: str = "Invalid Data." + UNKNOWN_ERROR: str = "Unknown Error occured." + + +class BaseErrorLanguageModels(LanguageModelClass): + tr: BaseErrorLanguageModelTurkish = BaseErrorLanguageModelTurkish + en: BaseErrorLanguageModelEnglish = BaseErrorLanguageModelEnglish diff --git a/ErrorHandlers/base.py b/ErrorHandlers/base.py new file mode 100644 index 0000000..fdc591a --- /dev/null +++ b/ErrorHandlers/base.py @@ -0,0 +1,16 @@ +from ErrorHandlers.bases import BaseErrorModelClass + + +class BaseError(BaseErrorModelClass): + NOT_CREATED: int = 405 + NOT_DELETED: int = 405 + NOT_UPDATED: int = 405 + NOT_LISTED: int = 404 + NOT_FOUND: int = 404 + ALREADY_EXISTS: int = 400 + IS_NOT_CONFIRMED: int = 405 + NOT_AUTHORIZED: int = 401 + NOT_VALID: int = 406 + NOT_ACCEPTABLE: int = 406 + INVALID_DATA: int = 422 + UNKNOWN_ERROR: int = 502 diff --git a/ErrorHandlers/bases.py b/ErrorHandlers/bases.py new file mode 100644 index 0000000..dfa07e6 --- /dev/null +++ b/ErrorHandlers/bases.py @@ -0,0 +1,30 @@ +from typing import Optional + + +class BaseErrorModelClass: + + @classmethod + def retrieve_error_by_code(cls, error_code: str): + return getattr(cls, error_code, 502) + + +class StatusesModelClass: + + @classmethod + def retrieve_error_by_code(cls, error_code: str): + return getattr(cls, error_code, 502) + + +class ErrorLanguageModelClass: + + @classmethod + def retrieve_error_header(cls, error_code: str): + return getattr(cls, error_code, "Unknown Error occured.") + + +class LanguageModelClass: + + @classmethod + def retrieve_error_by_code(cls, error_code: str, language: Optional[str] = "tr"): + language_model: ErrorLanguageModelClass = getattr(cls, language, "tr") + return language_model.retrieve_error_header(error_code) diff --git a/ErrorHandlers/statuses.py b/ErrorHandlers/statuses.py new file mode 100644 index 0000000..a815e0d --- /dev/null +++ b/ErrorHandlers/statuses.py @@ -0,0 +1,61 @@ +from ErrorHandlers.bases import StatusesModelClass + + +class Statuses(StatusesModelClass): + HTTP_100_CONTINUE = 100 + HTTP_101_SWITCHING_PROTOCOLS = 101 + HTTP_102_PROCESSING = 102 + HTTP_103_EARLY_HINTS = 103 + HTTP_200_OK = 200 + HTTP_201_CREATED = 201 + HTTP_202_ACCEPTED = 202 + HTTP_203_NON_AUTHORITATIVE_INFORMATION = 203 + HTTP_204_NO_CONTENT = 204 + HTTP_205_RESET_CONTENT = 205 + HTTP_206_PARTIAL_CONTENT = 206 + HTTP_207_MULTI_STATUS = 207 + HTTP_208_ALREADY_REPORTED = 208 + HTTP_226_IM_USED = 226 + HTTP_300_MULTIPLE_CHOICES = 300 + HTTP_301_MOVED_PERMANENTLY = 301 + HTTP_302_FOUND = 302 + HTTP_303_SEE_OTHER = 303 + HTTP_304_NOT_MODIFIED = 304 + HTTP_305_USE_PROXY = 305 + HTTP_306_RESERVED = 306 + HTTP_307_TEMPORARY_REDIRECT = 307 + HTTP_308_PERMANENT_REDIRECT = 308 + HTTP_400_BAD_REQUEST = 400 + HTTP_401_UNAUTHORIZED = 401 + HTTP_402_PAYMENT_REQUIRED = 402 + HTTP_403_FORBIDDEN = 403 + HTTP_404_NOT_FOUND = 404 + HTTP_405_METHOD_NOT_ALLOWED = 405 + HTTP_406_NOT_ACCEPTABLE = 406 + HTTP_407_PROXY_AUTHENTICATION_REQUIRED = 407 + HTTP_408_REQUEST_TIMEOUT = 408 + HTTP_409_CONFLICT = 409 + HTTP_410_GONE = 410 + HTTP_411_LENGTH_REQUIRED = 411 + HTTP_412_PRECONDITION_FAILED = 412 + HTTP_413_REQUEST_ENTITY_TOO_LARGE = 413 + HTTP_414_REQUEST_URI_TOO_LONG = 414 + HTTP_415_UNSUPPORTED_MEDIA_TYPE = 415 + HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE = 416 + HTTP_417_EXPECTATION_FAILED = 417 + HTTP_418_IM_A_TEAPOT = 418 + HTTP_421_MISDIRECTED_REQUEST = 421 + HTTP_422_UNPROCESSABLE_ENTITY = 422 + HTTP_423_LOCKED = 423 + HTTP_424_FAILED_DEPENDENCY = 424 + HTTP_426_UPGRADE_REQUIRED = 426 + HTTP_428_PRECONDITION_REQUIRED = 428 + HTTP_429_TOO_MANY_REQUESTS = 429 + HTTP_431_REQUEST_HEADER_FIELDS_TOO_LARGE = 431 + HTTP_451_UNAVAILABLE_FOR_LEGAL_REASONS = 451 + HTTP_500_INTERNAL_SERVER_ERROR = 500 + HTTP_502_BAD_GATEWAY = 502 + + @classmethod + def retrieve_error_by_code(cls, error_code: str): + return getattr(cls, error_code, 502) diff --git a/Services/MongoDb/__init__.py b/Services/MongoDb/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Services/Postgres/Models/base.py b/Services/Postgres/Models/base.py deleted file mode 100644 index 174b32e..0000000 --- a/Services/Postgres/Models/base.py +++ /dev/null @@ -1,68 +0,0 @@ -from datetime import datetime -from typing import Any, Dict, List, Optional, TypeVar, Union - -from sqlalchemy import Column, DateTime, Integer, inspect -from sqlalchemy.ext.declarative import declared_attr -from sqlalchemy.orm import Session - -from Services.Postgres.database import Base, get_db - -T = TypeVar('T') - -class PostgresBase(Base): - """Base class for all PostgreSQL models.""" - - __abstract__ = True - - id = Column(Integer, primary_key=True, index=True) - created_at = Column(DateTime, default=datetime.utcnow) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) - deleted_at = Column(DateTime, nullable=True) - - @declared_attr - def __tablename__(cls) -> str: - """Generate table name automatically from class name.""" - return cls.__name__.lower() - - def as_dict(self) -> Dict[str, Any]: - """Convert model instance to dictionary.""" - return {c.key: getattr(self, c.key) for c in inspect(self).mapper.column_attrs} - - @classmethod - def filter_non_deleted(cls, db: Session): - """Filter out soft-deleted records.""" - return db.query(cls).filter(cls.deleted_at.is_(None)) - - @classmethod - def get_by_id(cls, db: Session, id: int) -> Optional['PostgresBase']: - """Get record by ID if not deleted.""" - return cls.filter_non_deleted(db).filter(cls.id == id).first() - - def soft_delete(self) -> None: - """Soft delete the record.""" - self.deleted_at = datetime.utcnow() - with get_db() as db: - self.save(db) - - def save(self, db: Session) -> None: - """Save the current record.""" - if not self.id: - db.add(self) - db.commit() - db.refresh(self) - - def update(self, **kwargs: Dict[str, Any]) -> None: - """Update record with given values.""" - for key, value in kwargs.items(): - if hasattr(self, key): - setattr(self, key, value) - with get_db() as db: - self.save(db) - - @classmethod - def create(cls, **kwargs: Dict[str, Any]) -> 'PostgresBase': - """Create a new record.""" - instance = cls(**kwargs) - with get_db() as db: - instance.save(db) - return instance diff --git a/Services/Postgres/Models/response.py b/Services/Postgres/Models/response.py deleted file mode 100644 index a6e6e4c..0000000 --- a/Services/Postgres/Models/response.py +++ /dev/null @@ -1,49 +0,0 @@ -from typing import Any, Dict, List, Optional, TypeVar, Generic - -T = TypeVar('T') - -class PostgresResponse(Generic[T]): - """Base class for Postgres response handling.""" - - def __init__( - self, - status: bool, - message: str, - data: Optional[T] = None, - error: Optional[str] = None, - ): - self.status = status - self.message = message - self.data = data - self.error = error - - if isinstance(data, dict): - self.data_type = "dict" - elif isinstance(data, list): - self.data_type = "list" - else: - self.data_type = None - - def as_dict(self) -> Dict[str, Any]: - """Convert response to dictionary format.""" - return { - "status": self.status, - "message": self.message, - "data": self.data, - "dataType": self.data_type, - "error": self.error, - } - - @property - def all(self) -> Optional[List[T]]: - """Get all data items if data is a list.""" - if isinstance(self.data, list): - return self.data - return None - - @property - def first(self) -> Optional[T]: - """Get first data item if data is a list.""" - if isinstance(self.data, list) and self.data: - return self.data[0] - return None diff --git a/Services/PostgresDb/Models/alchemy_response.py b/Services/PostgresDb/Models/alchemy_response.py new file mode 100644 index 0000000..54b767c --- /dev/null +++ b/Services/PostgresDb/Models/alchemy_response.py @@ -0,0 +1,379 @@ +""" +Response handlers for SQLAlchemy query results with pagination support. + +This module provides a set of response classes for handling different types of data: +- Single PostgreSQL records +- Multiple SQLAlchemy records +- List data +- Dictionary data + +Each response includes pagination information and supports data transformation +through response models. +""" + +from __future__ import annotations +from typing import Any, Dict, List, Optional, Type, Union, TypeVar, Protocol +from dataclasses import dataclass + +from fastapi import status +from fastapi.responses import JSONResponse +from sqlalchemy.orm import Query + +from ApiValidations.Request.base_validations import PydanticBaseModel +from ApiValidations.handler import BaseModelRegular +from Services.Postgres.Models.response import PostgresResponse +from ErrorHandlers.ApiErrorHandlers.api_exc_handler import HTTPExceptionApi + +T = TypeVar("T") + + +class DataValidator(Protocol): + """Protocol for data validation methods.""" + + @staticmethod + def validate_data(data: Any, cls_object: Any) -> None: + """Validate data and raise HTTPExceptionApi if invalid.""" + ... + + +@dataclass +class PaginationConfig: + """ + Configuration for pagination settings. + + Attributes: + page: Current page number (default: 1) + size: Items per page (default: 10) + order_field: Field to order by (default: "id") + order_type: Order direction (default: "asc") + """ + + page: int = 1 + size: int = 10 + order_field: str = "id" + order_type: str = "asc" + + +class Pagination: + """ + Handles pagination logic for query results. + + Manages page size, current page, ordering, and calculates total pages + and items based on the data source. + + Attributes: + DEFAULT_SIZE: Default number of items per page (10) + MIN_SIZE: Minimum allowed page size (10) + MAX_SIZE: Maximum allowed page size (40) + """ + + DEFAULT_SIZE = 10 + MIN_SIZE = 10 + MAX_SIZE = 40 + + def __init__(self): + self.size: int = self.DEFAULT_SIZE + self.page: int = 1 + self.order_field: str = "id" + self.order_type: str = "asc" + self.page_count: int = 1 + self.total_count: int = 0 + self.total_pages: int = 1 + + def change(self, config: PaginationConfig) -> None: + """Update pagination settings from config.""" + self.size = ( + config.size + if self.MIN_SIZE <= config.size <= self.MAX_SIZE + else self.DEFAULT_SIZE + ) + self.page = config.page + self.order_field = config.order_field + self.order_type = config.order_type + self._update_page_counts() + + def feed(self, data: Union[List[Any], PostgresResponse, Query]) -> None: + """Calculate pagination based on data source.""" + self.total_count = ( + len(data) + if isinstance(data, list) + else data.count if isinstance(data, PostgresResponse) else data.count() + ) + self._update_page_counts() + + def _update_page_counts(self) -> None: + """Update page counts and validate current page.""" + self.total_pages = max(1, (self.total_count + self.size - 1) // self.size) + self.page = max(1, min(self.page, self.total_pages)) + self.page_count = ( + self.total_count % self.size + if self.page == self.total_pages and self.total_count % self.size + else self.size + ) + + def as_dict(self) -> Dict[str, Any]: + """Convert pagination state to dictionary format.""" + return { + "size": self.size, + "page": self.page, + "totalCount": self.total_count, + "totalPages": self.total_pages, + "pageCount": self.page_count, + "orderField": self.order_field, + "orderType": self.order_type, + } + + +@dataclass +class ResponseConfig: + """ + Configuration for response formatting. + + Attributes: + status_code: HTTP status code (default: "HTTP_200_OK") + message: Response message + completed: Operation completion status + cls_object: Class object for error handling + """ + + status_code: str = "HTTP_200_OK" + message: str = "" + completed: bool = True + cls_object: Optional[Any] = None + + +class BaseJsonResponse: + """ + Base class for JSON response handling. + + Provides common functionality for all response types: + - Response formatting + - Pagination handling + - Data transformation + """ + + def __init__( + self, + config: ResponseConfig, + response_model: Optional[Type[T]] = None, + filter_attributes: Optional[Any] = None, + ): + self.status_code = getattr(status, config.status_code, status.HTTP_200_OK) + self.message = config.message + self.completed = config.completed + self.filter_attributes = filter_attributes + self.response_model = response_model + self.cls_object = config.cls_object + + def _create_pagination(self) -> Pagination: + """Create and configure pagination instance.""" + pagination = Pagination() + if self.filter_attributes: + pagination.change( + PaginationConfig( + page=self.filter_attributes.page, + size=self.filter_attributes.size, + order_field=self.filter_attributes.order_field, + order_type=self.filter_attributes.order_type, + ) + ) + return pagination + + def _format_response(self, pagination: Pagination, data: Any) -> JSONResponse: + """Format final JSON response with pagination.""" + return JSONResponse( + status_code=self.status_code, + content={ + "pagination": pagination.as_dict(), + "completed": self.completed, + "message": self.message, + "data": data, + }, + ) + + @staticmethod + def _validate_data(data: Any, expected_type: Type, cls_object: Any) -> None: + """Validate data type and raise exception if invalid.""" + if not isinstance(data, expected_type): + raise HTTPExceptionApi( + lang=cls_object.lang, + error_code="HTTP_400_BAD_REQUEST", + ) + + +class SinglePostgresResponse(BaseJsonResponse): + """ + Handles single record responses from PostgreSQL queries. + + Used when expecting a single record from a database query. + Validates that the result is a PostgresResponse and contains exactly one record. + """ + + def __new__( + cls, + message: str, + result: PostgresResponse, + response_model: Optional[Type[T]] = None, + status_code: str = "HTTP_200_OK", + completed: bool = True, + cls_object: Optional[Any] = None, + ) -> JSONResponse: + cls._validate_data(result, PostgresResponse, cls_object) + + if not result.first: + raise HTTPExceptionApi( + lang=cls_object.lang, + error_code="HTTP_400_BAD_REQUEST", + ) + + instance = cls() + instance.__init__( + ResponseConfig( + status_code=status_code, + message=message, + completed=completed, + cls_object=cls_object, + ), + response_model=response_model, + ) + + pagination = instance._create_pagination() + data = result.data.get_dict() + if response_model: + data = response_model(**data).dump() + + return instance._format_response(pagination, data) + + +class AlchemyJsonResponse(BaseJsonResponse): + """ + Handles multiple record responses from SQLAlchemy queries. + + Used for database queries returning multiple records. + Validates that the result is a PostgresResponse and contains multiple records. + Supports pagination and data transformation through response models. + """ + + def __new__( + cls, + message: str, + result: PostgresResponse, + response_model: Optional[Type[T]] = None, + status_code: str = "HTTP_200_OK", + completed: bool = True, + cls_object: Optional[Any] = None, + filter_attributes: Optional[Any] = None, + ) -> JSONResponse: + cls._validate_data(result, PostgresResponse, cls_object) + + if result.first: + raise HTTPExceptionApi( + lang=cls_object.lang, + error_code="HTTP_400_BAD_REQUEST", + ) + + instance = cls() + instance.__init__( + ResponseConfig( + status_code=status_code, + message=message, + completed=completed, + cls_object=cls_object, + ), + response_model=response_model, + filter_attributes=filter_attributes, + ) + + pagination = instance._create_pagination() + data = [ + ( + response_model(**item.get_dict()).dump() + if response_model + else item.get_dict() + ) + for item in result.data + ] + pagination.feed(data) + + return instance._format_response(pagination, data) + + +class ListJsonResponse(BaseJsonResponse): + """ + Handles responses for list data. + + Used when working with Python lists that need to be paginated + and optionally transformed through a response model. + Validates that the input is a list. + """ + + def __new__( + cls, + message: str, + result: List[Any], + response_model: Optional[Type[T]] = None, + status_code: str = "HTTP_200_OK", + completed: bool = True, + cls_object: Optional[Any] = None, + filter_attributes: Optional[Any] = None, + ) -> JSONResponse: + cls._validate_data(result, list, cls_object) + + instance = cls() + instance.__init__( + ResponseConfig( + status_code=status_code, + message=message, + completed=completed, + cls_object=cls_object, + ), + response_model=response_model, + filter_attributes=filter_attributes, + ) + + pagination = instance._create_pagination() + data = [ + response_model(**item).dump() if response_model else item for item in result + ] + pagination.feed(data) + + return instance._format_response(pagination, data) + + +class DictJsonResponse(BaseJsonResponse): + """ + Handles responses for dictionary data. + + Used when working with single dictionary objects that need to be + transformed through a response model. Validates that the input + is a dictionary. + """ + + def __new__( + cls, + message: str, + result: Dict[str, Any], + response_model: Optional[Type[T]] = None, + status_code: str = "HTTP_200_OK", + completed: bool = True, + cls_object: Optional[Any] = None, + filter_attributes: Optional[Any] = None, + ) -> JSONResponse: + cls._validate_data(result, dict, cls_object) + + instance = cls() + instance.__init__( + ResponseConfig( + status_code=status_code, + message=message, + completed=completed, + cls_object=cls_object, + ), + response_model=response_model, + filter_attributes=filter_attributes, + ) + + pagination = instance._create_pagination() + data = response_model(**result).dump() if response_model else result + + return instance._format_response(pagination, data) diff --git a/Services/Postgres/Models/base_model.py b/Services/PostgresDb/Models/base_model.py similarity index 99% rename from Services/Postgres/Models/base_model.py rename to Services/PostgresDb/Models/base_model.py index 6da89d7..6d5abf1 100644 --- a/Services/Postgres/Models/base_model.py +++ b/Services/PostgresDb/Models/base_model.py @@ -117,4 +117,4 @@ class BaseModel: # db: Session = Depends(get_db_session) # ): # with User.create_with_session(**user_data) as new_user: -# return new_user \ No newline at end of file +# return new_user diff --git a/Services/PostgresDb/Models/filter_functions.py b/Services/PostgresDb/Models/filter_functions.py new file mode 100644 index 0000000..57d78ca --- /dev/null +++ b/Services/PostgresDb/Models/filter_functions.py @@ -0,0 +1,526 @@ +""" +Advanced filtering functionality for SQLAlchemy models. + +This module provides a comprehensive set of filtering capabilities for SQLAlchemy models, +including pagination, ordering, and complex query building. +""" + +from __future__ import annotations +from typing import Any, Dict, List, Optional, Type, TypeVar, Union, Tuple, Protocol +from dataclasses import dataclass +from json import dumps + +from sqlalchemy import BinaryExpression, desc, asc +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Query, Session +from sqlalchemy.sql.elements import BinaryExpression + +from ApiLibrary import system_arrow +from ErrorHandlers.ApiErrorHandlers import HTTPExceptionApi +from Services.PostgresDb.Models.response import PostgresResponse + +# Type variable for class methods returning self +T = TypeVar("T", bound="FilterAttributes") + + +class HTTPException(Exception): + """Base exception for HTTP errors.""" + + def __init__(self, status_code: str, detail: str): + self.status_code = status_code + self.detail = detail + super().__init__(detail) + + +class HTTPStatus(Protocol): + """Protocol defining required HTTP status codes.""" + + HTTP_400_BAD_REQUEST: str + HTTP_404_NOT_FOUND: str + HTTP_304_NOT_MODIFIED: str + + +@dataclass +class FilterConfig: + """Configuration for filtering and pagination.""" + + page: int = 1 + size: int = 10 + order_field: str = "id" + order_type: str = "asc" + include_joins: List[str] = None + query: Dict[str, Any] = None + + def __post_init__(self): + """Initialize default values for None fields.""" + self.include_joins = self.include_joins or [] + self.query = self.query or {} + + +class QueryConfig: + """Configuration for query building and execution.""" + + def __init__( + self, + pre_query: Optional[Query] = None, + filter_config: Optional[FilterConfig] = None, + http_exception: Optional[Type[HTTPException]] = HTTPException, + status: Optional[Type[HTTPStatus]] = None, + ): + self.pre_query = pre_query + self.filter_config = filter_config or FilterConfig() + self.http_exception = http_exception + self.status = status + self.total_count: Optional[int] = None + + def update_filter_config(self, **kwargs) -> None: + """Update filter configuration parameters.""" + for key, value in kwargs.items(): + if hasattr(self.filter_config, key): + setattr(self.filter_config, key, value) + + def set_total_count(self, count: int) -> None: + """Set the total count of records.""" + self.total_count = count + + +class FilterAttributes: + """ + Advanced filtering capabilities for SQLAlchemy models. + + Features: + - Pagination and ordering + - Complex query building + - Active/deleted/confirmed status filtering + - Expiry date handling + - Transaction management + + Usage: + # Initialize configuration + config = QueryConfig(filter_config=FilterConfig(page=1, size=10)) + + # Create model with configuration + class User(FilterAttributes): + query_config = config + + # Filter multiple records + users = User.filter_by_all(db, name="John").data + + # Update configuration + User.query_config.update_filter_config(page=2, size=20) + next_users = User.filter_all(db).data + """ + + __abstract__ = True + + # Class-level configuration + query_config: QueryConfig = QueryConfig() + + @classmethod + def flush(cls: Type[T], db: Session) -> T: + """ + Flush the current session to the database. + + Args: + db: Database session + + Returns: + Self instance + + Raises: + HTTPException: If database operation fails + """ + try: + db.flush() + return cls + except SQLAlchemyError as e: + raise HTTPExceptionApi( + error_code="HTTP_304_NOT_MODIFIED", + lang=cls.lang or "tr", + ) + + @classmethod + def destroy(cls: Type[T], db: Session) -> None: + """ + Delete the record from the database. + + Args: + db: Database session + """ + db.delete(cls) + db.commit() + + @classmethod + def save_via_metadata(cls: Type[T], db: Session) -> None: + """ + Save or rollback based on metadata. + + Args: + db: Database session + + Raises: + HTTPException: If save operation fails + """ + try: + meta_data = getattr(cls, "meta_data", {}) + if meta_data.get("created", False): + db.commit() + db.rollback() + except SQLAlchemyError as e: + raise HTTPExceptionApi( + error_code="HTTP_304_NOT_MODIFIED", + lang=cls.lang or "tr", + ) + + @classmethod + def save(cls: Type[T], db: Session) -> None: + """ + Commit changes to database. + + Args: + db: Database session + + Raises: + HTTPException: If commit fails + """ + try: + db.commit() + except SQLAlchemyError as e: + raise HTTPExceptionApi( + error_code="HTTP_304_NOT_MODIFIED", + lang=cls.lang or "tr", + ) + + @classmethod + def rollback(cls: Type[T], db: Session) -> None: + """ + Rollback current transaction. + + Args: + db: Database session + """ + db.rollback() + + @classmethod + def save_and_confirm(cls: Type[T], db: Session) -> None: + """ + Save changes and mark record as confirmed. + + Args: + db: Database session + + Raises: + HTTPException: If operation fails + """ + try: + cls.save(db) + cls.update(db, is_confirmed=True) + cls.save(db) + except SQLAlchemyError as e: + raise HTTPExceptionApi( + error_code="HTTP_304_NOT_MODIFIED", + lang=cls.lang or "tr", + ) + + @classmethod + def _query(cls: Type[T], db: Session) -> Query: + """ + Get base query for model. + + Args: + db: Database session + + Returns: + SQLAlchemy Query object + """ + return ( + cls.query_config.pre_query if cls.query_config.pre_query else db.query(cls) + ) + + @classmethod + def add_query_to_filter( + cls: Type[T], query: Query, filter_list: Dict[str, Any] + ) -> Query: + """ + Add pagination and ordering to query. + + Args: + query: Base query + filter_list: Dictionary containing pagination and ordering parameters + + Returns: + Modified query with pagination and ordering + """ + order_field = getattr(cls, filter_list.get("order_field")) + order_func = desc if str(filter_list.get("order_type"))[0] == "d" else asc + + return ( + query.order_by(order_func(order_field)) + .limit(filter_list.get("size")) + .offset((filter_list.get("page") - 1) * filter_list.get("size")) + .populate_existing() + ) + + @classmethod + def get_filter_attributes(cls) -> Dict[str, Any]: + """ + Get filter configuration from attributes. + + Returns: + Dictionary containing pagination and filtering parameters + """ + return { + "page": getattr(cls.query_config.filter_config, "page", 1), + "size": getattr(cls.query_config.filter_config, "size", 10), + "order_field": getattr(cls.query_config.filter_config, "order_field", "id"), + "order_type": getattr(cls.query_config.filter_config, "order_type", "asc"), + "include_joins": getattr( + cls.query_config.filter_config, "include_joins", [] + ), + "query": getattr(cls.query_config.filter_config, "query", {}), + } + + @classmethod + def add_new_arg_to_args( + cls, + args_list: Tuple[BinaryExpression, ...], + argument: str, + value: BinaryExpression, + ) -> Tuple[BinaryExpression, ...]: + """ + Add new argument to filter arguments if not exists. + + Args: + args_list: Current filter arguments + argument: Argument name to check + value: New argument to add + + Returns: + Updated argument tuple + """ + new_args = [arg for arg in args_list if isinstance(arg, BinaryExpression)] + arg_left = lambda arg_obj: getattr(getattr(arg_obj, "left", None), "key", None) + + if not any(arg_left(arg) == argument for arg in new_args): + new_args.append(value) + + return tuple(new_args) + + @classmethod + def get_not_expired_query_arg( + cls, args: Tuple[BinaryExpression, ...] + ) -> Tuple[BinaryExpression, ...]: + """ + Add expiry date conditions to query. + + Args: + args: Current query arguments + + Returns: + Updated arguments with expiry conditions + """ + current_time = str(system_arrow.now()) + args = cls.add_new_arg_to_args( + args, "expiry_ends", cls.expiry_ends > current_time + ) + args = cls.add_new_arg_to_args( + args, "expiry_starts", cls.expiry_starts <= current_time + ) + return args + + @classmethod + def get_active_and_confirmed_query_arg( + cls, args: Tuple[BinaryExpression, ...] + ) -> Tuple[BinaryExpression, ...]: + """ + Add status conditions to query. + + Args: + args: Current query arguments + + Returns: + Updated arguments with status conditions + """ + args = cls.add_new_arg_to_args(args, "is_confirmed", cls.is_confirmed == True) + args = cls.add_new_arg_to_args(args, "active", cls.active == True) + args = cls.add_new_arg_to_args(args, "deleted", cls.deleted == False) + return args + + @classmethod + def select_only( + cls: Type[T], + db: Session, + *args: BinaryExpression, + select_args: List[Any], + order_by: Optional[Any] = None, + limit: Optional[int] = None, + system: bool = False, + ) -> PostgresResponse: + """ + Select specific columns from filtered query. + + Args: + db: Database session + args: Filter conditions + select_args: Columns to select + order_by: Optional ordering + limit: Optional result limit + system: If True, skip status filtering + + Returns: + Query response with selected columns + """ + if not system: + args = cls.get_active_and_confirmed_query_arg(args) + args = cls.get_not_expired_query_arg(args) + + query = cls._query(db).filter(*args).with_entities(*select_args) + cls.query_config.set_total_count(query.count()) + + if order_by is not None: + query = query.order_by(order_by) + if limit: + query = query.limit(limit) + + return PostgresResponse(query=query, first=False) + + @classmethod + def filter_by_all( + cls: Type[T], db: Session, system: bool = False, **kwargs + ) -> PostgresResponse: + """ + Filter multiple records by keyword arguments. + + Args: + db: Database session + system: If True, skip status filtering + **kwargs: Filter criteria + + Returns: + Query response with matching records + """ + if "is_confirmed" not in kwargs and not system: + kwargs["is_confirmed"] = True + kwargs.pop("system", None) + + query = cls._query(db).filter_by(**kwargs) + cls.query_config.set_total_count(query.count()) + + if cls.query_config.filter_config: + filter_list = cls.get_filter_attributes() + query = cls.add_query_to_filter(query, filter_list) + + return PostgresResponse(query=query, first=False) + + @classmethod + def filter_by_one( + cls: Type[T], db: Session, system: bool = False, **kwargs + ) -> PostgresResponse: + """ + Filter single record by keyword arguments. + + Args: + db: Database session + system: If True, skip status filtering + **kwargs: Filter criteria + + Returns: + Query response with single record + """ + if "is_confirmed" not in kwargs and not system: + kwargs["is_confirmed"] = True + kwargs.pop("system", None) + + query = cls._query(db).filter_by(**kwargs) + cls.query_config.set_total_count(1) + + return PostgresResponse(query=query, first=True) + + @classmethod + def filter_all( + cls: Type[T], db: Session, *args: BinaryExpression, system: bool = False + ) -> PostgresResponse: + """ + Filter multiple records by expressions. + + Args: + db: Database session + args: Filter expressions + system: If True, skip status filtering + + Returns: + Query response with matching records + """ + if not system: + args = cls.get_active_and_confirmed_query_arg(args) + args = cls.get_not_expired_query_arg(args) + + filter_list = cls.get_filter_attributes() + if filter_list.get("query"): + for smart_iter in cls.filter_expr(**filter_list["query"]): + if key := getattr(getattr(smart_iter, "left", None), "key", None): + args = cls.add_new_arg_to_args(args, key, smart_iter) + + query = cls._query(db) + cls.query_config.set_total_count(query.count()) + query = query.filter(*args) + + if cls.query_config.filter_config: + query = cls.add_query_to_filter(query, filter_list) + + return PostgresResponse(query=query, first=False) + + @classmethod + def filter_one( + cls: Type[T], + db: Session, + *args: BinaryExpression, + system: bool = False, + expired: bool = False, + ) -> PostgresResponse: + """ + Filter single record by expressions. + + Args: + db: Database session + args: Filter expressions + system: If True, skip status filtering + expired: If True, include expired records + + Returns: + Query response with single record + """ + if not system: + args = cls.get_active_and_confirmed_query_arg(args) + if not expired: + args = cls.get_not_expired_query_arg(args) + + query = cls._query(db).filter(*args) + cls.query_config.set_total_count(1) + + return PostgresResponse(query=query, first=True) + + @classmethod + def raise_http_exception( + cls, + status_code: str, + error_case: str, + data: Dict[str, Any], + message: str, + ) -> None: + """ + Raise HTTP exception with formatted error details. + + Args: + status_code: HTTP status code string + error_case: Error type + data: Additional error data + message: Error message + + Raises: + HTTPException: With formatted error details + """ + raise HTTPExceptionApi( + error_code="HTTP_304_NOT_MODIFIED", + lang=cls.lang or "tr", + ) diff --git a/Services/Postgres/Models/how_to.py b/Services/PostgresDb/Models/how_to.py similarity index 77% rename from Services/Postgres/Models/how_to.py rename to Services/PostgresDb/Models/how_to.py index b33c548..fa8287a 100644 --- a/Services/Postgres/Models/how_to.py +++ b/Services/PostgresDb/Models/how_to.py @@ -17,9 +17,9 @@ from Services.Postgres.database import get_db # Example Model Definition class User(CrudCollection): """Example user model demonstrating CrudCollection usage.""" - + __tablename__ = "users" - + # Additional fields (id and other common fields come from CrudCollection) username: Mapped[str] = mapped_column(String(50), unique=True, index=True) email: Mapped[str] = mapped_column(String(100), unique=True) @@ -28,15 +28,13 @@ class User(CrudCollection): # Example Usage + def example_create(): """Example of creating a new record.""" with get_db() as db: # Create a new user user = User.find_or_create( - db, - username="john_doe", - email="john@example.com", - age=30 + db, username="john_doe", email="john@example.com", age=30 ) db.commit() return user @@ -50,12 +48,10 @@ def example_batch_create(): users = [] for i in range(3): user = User.find_or_create( - db, - username=f"user_{i}", - email=f"user_{i}@example.com" + db, username=f"user_{i}", email=f"user_{i}@example.com" ) users.append(user) - + db.commit() return users except Exception: @@ -69,11 +65,7 @@ def example_update(): # Find user and update user = db.query(User).filter(User.username == "john_doe").first() if user: - user.update( - db, - email="john.doe@newdomain.com", - age=31 - ) + user.update(db, email="john.doe@newdomain.com", age=31) db.commit() return user @@ -94,11 +86,7 @@ def example_query(): # Get active (non-deleted) users active_users = ( db.query(User) - .filter( - User.active == True, - User.deleted == False, - User.age >= 18 - ) + .filter(User.active == True, User.deleted == False, User.age >= 18) .order_by(User.created_at.desc()) .all() ) @@ -110,25 +98,21 @@ def example_complex_transaction(): with get_db() as db: try: # Multiple operations in single transaction - user = User.find_or_create( - db, - username="new_user", - email="new@example.com" - ) - + user = User.find_or_create(db, username="new_user", email="new@example.com") + # Update existing user other_user = db.query(User).filter(User.username == "old_user").first() if other_user: other_user.update(db, email="updated@example.com") - + # Soft delete another user deleted_user = db.query(User).filter(User.username == "to_delete").first() if deleted_user: deleted_user.update(db, deleted=True) - + # Commit all changes at once db.commit() - + except Exception: # Rollback all changes if any operation fails db.rollback() @@ -141,19 +125,12 @@ def example_serialization(): user = db.query(User).first() if user: # Get all fields except specified ones - dict_with_excludes = user.get_dict( - exclude=["created_at", "updated_at"] - ) - + dict_with_excludes = user.get_dict(exclude=["created_at", "updated_at"]) + # Get only specified fields - dict_with_includes = user.get_dict( - include=["id", "username", "email"] - ) - - return { - "excluded": dict_with_excludes, - "included": dict_with_includes - } + dict_with_includes = user.get_dict(include=["id", "username", "email"]) + + return {"excluded": dict_with_excludes, "included": dict_with_includes} def example_confirmation(): @@ -176,17 +153,17 @@ def example_error_handling(): user = User.find_or_create( db, username="existing_user", # This might cause unique constraint violation - email="exists@example.com" + email="exists@example.com", ) db.commit() return {"status": "success", "user": user.get_dict()} - + except Exception as e: db.rollback() return { "status": "error", "message": str(e), - "error_type": e.__class__.__name__ + "error_type": e.__class__.__name__, } @@ -199,18 +176,15 @@ def example_date_handling(): db.query(User) .filter( User.expiry_starts <= datetime.utcnow(), - User.expiry_ends > datetime.utcnow() + User.expiry_ends > datetime.utcnow(), ) .all() ) - + # Set expiry for a user user = db.query(User).first() if user: - user.update( - db, - expiry_ends=datetime(2024, 12, 31, 23, 59, 59) - ) + user.update(db, expiry_ends=datetime(2024, 12, 31, 23, 59, 59)) db.commit() - + return current_users diff --git a/Services/Postgres/Models/my_base_model.py b/Services/PostgresDb/Models/mixins.py similarity index 77% rename from Services/Postgres/Models/my_base_model.py rename to Services/PostgresDb/Models/mixins.py index a48767d..d206dbc 100644 --- a/Services/Postgres/Models/my_base_model.py +++ b/Services/PostgresDb/Models/mixins.py @@ -25,25 +25,23 @@ from sqlalchemy import ( Boolean, SmallInteger, ) -from sqlalchemy.orm import ( - Mapped, - mapped_column, - Session -) +from sqlalchemy.orm import Mapped, mapped_column, Session from sqlalchemy_mixins.serialize import SerializeMixin from sqlalchemy_mixins.repr import ReprMixin from sqlalchemy_mixins.smartquery import SmartQueryMixin -from api_library import DateTimeLocal, system_arrow -from Services.Postgres.database import Base, get_db +from ApiLibrary import DateTimeLocal, system_arrow +from Services.Postgres.database import Base +from Services.PostgresDb.Models.filter_functions import FilterAttributes # Type variable for class methods returning self -T = TypeVar('T', bound='CrudMixin') +T = TypeVar("T", bound="CrudMixin") + class CrudMixin(Base, SmartQueryMixin, SerializeMixin): """ Base mixin providing CRUD operations and common fields for PostgreSQL models. - + Features: - Automatic timestamps (created_at, updated_at) - Soft delete capability @@ -51,30 +49,54 @@ class CrudMixin(Base, SmartQueryMixin, SerializeMixin): - Data serialization - Multi-language support """ - + __abstract__ = True # System fields that should be handled automatically during creation __system__fields__create__ = ( - "created_at", "updated_at", "cryp_uu_id", - "created_by", "created_by_id", "updated_by", "updated_by_id", - "replication_id", "confirmed_by", "confirmed_by_id", - "is_confirmed", "deleted", "active", - "is_notification_send", "is_email_send", + "created_at", + "updated_at", + "cryp_uu_id", + "created_by", + "created_by_id", + "updated_by", + "updated_by_id", + "replication_id", + "confirmed_by", + "confirmed_by_id", + "is_confirmed", + "deleted", + "active", + "is_notification_send", + "is_email_send", ) # System fields that should be handled automatically during updates __system__fields__update__ = ( - "cryp_uu_id", "created_at", "updated_at", - "created_by", "created_by_id", "confirmed_by", "confirmed_by_id", - "updated_by", "updated_by_id", "replication_id", + "cryp_uu_id", + "created_at", + "updated_at", + "created_by", + "created_by_id", + "confirmed_by", + "confirmed_by_id", + "updated_by", + "updated_by_id", + "replication_id", ) # Default fields to exclude from serialization __system_default_model__ = [ - "cryp_uu_id", "is_confirmed", "deleted", - "is_notification_send", "replication_id", "is_email_send", - "confirmed_by_id", "confirmed_by", "updated_by_id", "created_by_id", + "cryp_uu_id", + "is_confirmed", + "deleted", + "is_notification_send", + "replication_id", + "is_email_send", + "confirmed_by_id", + "confirmed_by", + "updated_by_id", + "created_by_id", ] # User credentials and preferences @@ -89,20 +111,20 @@ class CrudMixin(Base, SmartQueryMixin, SerializeMixin): TIMESTAMP(timezone=True), server_default=func.now(), nullable=False, - comment="Record validity start timestamp" + comment="Record validity start timestamp", ) expiry_ends: Mapped[TIMESTAMP] = mapped_column( TIMESTAMP(timezone=True), default="2099-12-31", server_default="2099-12-31", - comment="Record validity end timestamp" + comment="Record validity end timestamp", ) @classmethod def set_user_define_properties(cls, token: Any) -> None: """ Set user-specific properties from the authentication token. - + Args: token: Authentication token containing user preferences """ @@ -114,10 +136,10 @@ class CrudMixin(Base, SmartQueryMixin, SerializeMixin): def remove_non_related_inputs(cls, kwargs: Dict[str, Any]) -> Dict[str, Any]: """ Filter out inputs that don't correspond to model fields. - + Args: kwargs: Dictionary of field names and values - + Returns: Dictionary containing only valid model fields """ @@ -128,21 +150,22 @@ class CrudMixin(Base, SmartQueryMixin, SerializeMixin): } @classmethod - def extract_system_fields(cls, filter_kwargs: dict, create: bool = True) -> Dict[str, Any]: + def extract_system_fields( + cls, filter_kwargs: dict, create: bool = True + ) -> Dict[str, Any]: """ Remove system-managed fields from input dictionary. - + Args: filter_kwargs: Input dictionary of fields create: If True, use creation field list, else use update field list - + Returns: Dictionary with system fields removed """ system_fields = filter_kwargs.copy() extract_fields = ( - cls.__system__fields__create__ if create - else cls.__system__fields__update__ + cls.__system__fields__create__ if create else cls.__system__fields__update__ ) for field in extract_fields: system_fields.pop(field, None) @@ -152,30 +175,30 @@ class CrudMixin(Base, SmartQueryMixin, SerializeMixin): def iterate_over_variables(cls, val: Any, key: str) -> tuple[bool, Optional[Any]]: """ Process a field value based on its type and convert it to the appropriate format. - + Args: val: Field value key: Field name - + Returns: Tuple of (should_include, processed_value) """ key_ = cls.__annotations__.get(key, None) is_primary = key in cls.primary_keys row_attr = bool(getattr(getattr(cls, key), "foreign_keys", None)) - + # Skip primary keys and foreign keys if is_primary or row_attr: return False, None - + # Handle None values if val is None: return True, None - + # Special handling for UUID fields if str(key[-5:]).lower() == "uu_id": return True, str(val) - + # Handle typed fields if key_: if key_ == Mapped[int]: @@ -190,7 +213,7 @@ class CrudMixin(Base, SmartQueryMixin, SerializeMixin): ) elif key_ == Mapped[str]: return True, str(val) - + # Handle based on Python types else: if isinstance(val, datetime.datetime): @@ -207,35 +230,35 @@ class CrudMixin(Base, SmartQueryMixin, SerializeMixin): return True, str(val) elif val is None: return True, None - + return False, None @classmethod def find_or_create(cls: Type[T], db: Session, **kwargs) -> T: """ Find an existing record matching the criteria or create a new one. - + Args: db: Database session **kwargs: Search/creation criteria - + Returns: Existing or newly created record """ check_kwargs = cls.extract_system_fields(kwargs) - + # Search for existing record query = db.query(cls).filter( cls.expiry_ends > str(system_arrow.now()), cls.expiry_starts <= str(system_arrow.now()), ) - + for key, value in check_kwargs.items(): if hasattr(cls, key): query = query.filter(getattr(cls, key) == value) - + already_record = query.first() - + # Handle existing record if already_record: if already_record.deleted: @@ -252,7 +275,7 @@ class CrudMixin(Base, SmartQueryMixin, SerializeMixin): "message": "", } return already_record - + already_record.meta_data = { "created": False, "error_case": "AlreadyExists", @@ -263,75 +286,81 @@ class CrudMixin(Base, SmartQueryMixin, SerializeMixin): # Create new record check_kwargs = cls.remove_non_related_inputs(check_kwargs) created_record = cls() - + for key, value in check_kwargs.items(): setattr(created_record, key, value) - - if getattr(cls.creds, "person_id", None) and getattr(cls.creds, "person_name", None): + + if getattr(cls.creds, "person_id", None) and getattr( + cls.creds, "person_name", None + ): created_record.created_by_id = cls.creds.person_id created_record.created_by = cls.creds.person_name - + db.add(created_record) db.flush() - + created_record.meta_data = {"created": True, "error_case": None, "message": ""} return created_record - def update(self, db: Session, **kwargs) -> 'CrudMixin': + def update(self, db: Session, **kwargs) -> "CrudMixin": """ Update the record with new values. - + Args: db: Database session **kwargs: Fields to update - + Returns: Updated record - + Raises: ValueError: If attempting to update is_confirmed with other fields """ check_kwargs = self.remove_non_related_inputs(kwargs) is_confirmed_argument = kwargs.get("is_confirmed", None) - + if is_confirmed_argument and not len(kwargs) == 1: raise ValueError("Confirm field cannot be updated with other fields") - + check_kwargs = self.extract_system_fields(check_kwargs, create=False) - + for key, value in check_kwargs.items(): setattr(self, key, value) # Update confirmation or modification tracking if is_confirmed_argument: - if getattr(self.creds, "person_id", None) and getattr(self.creds, "person_name", None): + if getattr(self.creds, "person_id", None) and getattr( + self.creds, "person_name", None + ): self.confirmed_by_id = self.creds.person_id self.confirmed_by = self.creds.person_name else: - if getattr(self.creds, "person_id", None) and getattr(self.creds, "person_name", None): + if getattr(self.creds, "person_id", None) and getattr( + self.creds, "person_name", None + ): self.updated_by_id = self.creds.person_id self.updated_by = self.creds.person_name - + db.flush() return self def get_dict( - self, + self, exclude: Optional[List[str]] = None, include: Optional[List[str]] = None, ) -> Dict[str, Any]: """ Convert model instance to dictionary with customizable fields. - + Args: exclude: List of fields to exclude include: List of fields to include (takes precedence over exclude) - + Returns: Dictionary representation of the model """ return_dict: Dict[str, Any] = {} - + if include: # Handle explicitly included fields exclude_list = [ @@ -341,22 +370,30 @@ class CrudMixin(Base, SmartQueryMixin, SerializeMixin): ] columns_include_list = list(set(include).difference(set(exclude_list))) columns_include_list.extend(["uu_id"]) - + for key in columns_include_list: val = getattr(self, key) correct, value_of_database = self.iterate_over_variables(val, key) if correct: return_dict[key] = value_of_database - + elif exclude: # Handle explicitly excluded fields - exclude.extend(list(set(getattr(self, '__exclude__fields__', []) or []).difference(exclude))) - exclude.extend([ - element - for element in self.__system_default_model__ - if str(element)[-2:] == "id" - ]) - + exclude.extend( + list( + set(getattr(self, "__exclude__fields__", []) or []).difference( + exclude + ) + ) + ) + exclude.extend( + [ + element + for element in self.__system_default_model__ + if str(element)[-2:] == "id" + ] + ) + columns_excluded_list = list(set(self.columns).difference(set(exclude))) columns_excluded_list.extend(["uu_id", "active"]) @@ -367,37 +404,41 @@ class CrudMixin(Base, SmartQueryMixin, SerializeMixin): return_dict[key] = value_of_database else: # Handle default field selection - exclude_list = (getattr(self, '__exclude__fields__', []) or []) + self.__system_default_model__ + exclude_list = ( + getattr(self, "__exclude__fields__", []) or [] + ) + self.__system_default_model__ columns_list = list(set(self.columns).difference(set(exclude_list))) columns_list = [col for col in columns_list if str(col)[-2:] != "id"] - columns_list.extend([col for col in self.columns if str(col)[-5:].lower() == "uu_id"]) - + columns_list.extend( + [col for col in self.columns if str(col)[-5:].lower() == "uu_id"] + ) + for remove_field in self.__system_default_model__: if remove_field in columns_list: columns_list.remove(remove_field) - + for key in columns_list: val = getattr(self, key) correct, value_of_database = self.iterate_over_variables(val, key) if correct: return_dict[key] = value_of_database - + return return_dict class BaseCollection(CrudMixin, ReprMixin): """Base model class with minimal fields.""" - + __abstract__ = True __repr__ = ReprMixin.__repr__ id: Mapped[int] = mapped_column(primary_key=True) -class CrudCollection(CrudMixin, SmartQueryMixin): +class CrudCollection(CrudMixin, SmartQueryMixin, FilterAttributes): """ Full-featured model class with all common fields. - + Includes: - UUID and reference ID - Timestamps @@ -406,7 +447,7 @@ class CrudCollection(CrudMixin, SmartQueryMixin): - Soft delete - Notification flags """ - + __abstract__ = True __repr__ = ReprMixin.__repr__ @@ -417,22 +458,19 @@ class CrudCollection(CrudMixin, SmartQueryMixin): server_default=text("gen_random_uuid()"), index=True, unique=True, - comment="Unique identifier UUID" + comment="Unique identifier UUID", ) ref_id: Mapped[str] = mapped_column( - String(100), - nullable=True, - index=True, - comment="External reference ID" + String(100), nullable=True, index=True, comment="External reference ID" ) - + # Timestamps created_at: Mapped[TIMESTAMP] = mapped_column( TIMESTAMP(timezone=True), server_default=func.now(), nullable=False, index=True, - comment="Record creation timestamp" + comment="Record creation timestamp", ) updated_at: Mapped[TIMESTAMP] = mapped_column( TIMESTAMP(timezone=True), @@ -440,95 +478,70 @@ class CrudCollection(CrudMixin, SmartQueryMixin): onupdate=func.now(), nullable=False, index=True, - comment="Last update timestamp" + comment="Last update timestamp", ) - + # Cryptographic and user tracking cryp_uu_id: Mapped[str] = mapped_column( - String, - nullable=True, - index=True, - comment="Cryptographic UUID" + String, nullable=True, index=True, comment="Cryptographic UUID" ) created_by: Mapped[str] = mapped_column( - String, - nullable=True, - comment="Creator name" + String, nullable=True, comment="Creator name" ) created_by_id: Mapped[int] = mapped_column( - Integer, - nullable=True, - comment="Creator ID" + Integer, nullable=True, comment="Creator ID" ) updated_by: Mapped[str] = mapped_column( - String, - nullable=True, - comment="Last modifier name" + String, nullable=True, comment="Last modifier name" ) updated_by_id: Mapped[int] = mapped_column( - Integer, - nullable=True, - comment="Last modifier ID" + Integer, nullable=True, comment="Last modifier ID" ) confirmed_by: Mapped[str] = mapped_column( - String, - nullable=True, - comment="Confirmer name" + String, nullable=True, comment="Confirmer name" ) confirmed_by_id: Mapped[int] = mapped_column( - Integer, - nullable=True, - comment="Confirmer ID" + Integer, nullable=True, comment="Confirmer ID" ) - + # Status flags is_confirmed: Mapped[bool] = mapped_column( - Boolean, - server_default="0", - comment="Record confirmation status" + Boolean, server_default="0", comment="Record confirmation status" ) replication_id: Mapped[int] = mapped_column( - SmallInteger, - server_default="0", - comment="Replication identifier" + SmallInteger, server_default="0", comment="Replication identifier" ) deleted: Mapped[bool] = mapped_column( - Boolean, - server_default="0", - comment="Soft delete flag" + Boolean, server_default="0", comment="Soft delete flag" ) active: Mapped[bool] = mapped_column( - Boolean, - server_default="1", - comment="Record active status" + Boolean, server_default="1", comment="Record active status" ) is_notification_send: Mapped[bool] = mapped_column( - Boolean, - server_default="0", - comment="Notification sent flag" + Boolean, server_default="0", comment="Notification sent flag" ) is_email_send: Mapped[bool] = mapped_column( - Boolean, - server_default="0", - comment="Email sent flag" + Boolean, server_default="0", comment="Email sent flag" ) @classmethod def retrieve_language_model(cls, lang: str, response_model: Any) -> Dict[str, str]: """ Retrieve language-specific model headers and validation messages. - + Args: lang: Language code response_model: Model containing language annotations - + Returns: Dictionary of field names to localized headers """ headers_and_validation = {} __language_model__ = getattr(cls.__language_model__, lang, "tr") - + for field in response_model.__annotations__.keys(): - headers_and_validation[field] = getattr(__language_model__, field, "Lang Not found") - + headers_and_validation[field] = getattr( + __language_model__, field, "Lang Not found" + ) + return headers_and_validation diff --git a/Services/Postgres/Models/query.py b/Services/PostgresDb/Models/query.py similarity index 98% rename from Services/Postgres/Models/query.py rename to Services/PostgresDb/Models/query.py index d971538..d2d40e0 100644 --- a/Services/Postgres/Models/query.py +++ b/Services/PostgresDb/Models/query.py @@ -3,7 +3,8 @@ from typing import Any, List, Optional, TypeVar, Union from sqlalchemy.orm import Query from sqlalchemy.orm.session import Session -T = TypeVar('T') +T = TypeVar("T") + class QueryResponse: """Handler for SQLAlchemy query results with error handling.""" diff --git a/Services/PostgresDb/Models/response.py b/Services/PostgresDb/Models/response.py new file mode 100644 index 0000000..a1a5fd0 --- /dev/null +++ b/Services/PostgresDb/Models/response.py @@ -0,0 +1,81 @@ +""" +Response handler for PostgreSQL query results. + +This module provides a wrapper class for SQLAlchemy query results, +adding convenience methods for accessing data and managing query state. +""" + +from typing import Any, Dict, List, Optional, TypeVar, Generic, Union +from sqlalchemy.orm import Query + +T = TypeVar("T") + + +class PostgresResponse(Generic[T]): + """ + Wrapper for PostgreSQL/SQLAlchemy query results. + + Attributes: + query: SQLAlchemy query object + first: Whether to return first result only + data: Query results (lazy loaded) + count: Total count of results + + Properties: + all: All results as list + first_item: First result only + """ + + def __init__( + self, + query: Query, + first: bool = False, + status: bool = True, + message: str = "", + error: Optional[str] = None, + ): + self._query = query + self._first = first + self.status = status + self.message = message + self.error = error + self._data: Optional[Union[List[T], T]] = None + self._count: Optional[int] = None + + @property + def data(self) -> Union[List[T], T, None]: + """ + Lazy load and return query results. + Returns first item if first=True, otherwise returns all results. + """ + if self._data is None: + results = self._query.all() + self._data = results[0] if self._first and results else results + return self._data + + @property + def count(self) -> int: + """Lazy load and return total count of results.""" + if self._count is None: + self._count = self._query.count() + return self._count + + @property + def all(self) -> List[T]: + """Get all results as list.""" + return self.data if isinstance(self.data, list) else [self.data] if self.data else [] + + @property + def first_item(self) -> Optional[T]: + """Get first result only.""" + return self.data if self._first else (self.data[0] if self.data else None) + + def as_dict(self) -> Dict[str, Any]: + """Convert response to dictionary format.""" + return { + "status": self.status, + "message": self.message, + "data": self.data, + "count": self.count, + "error": self.error, + } diff --git a/Services/PostgresDb/__init__.py b/Services/PostgresDb/__init__.py new file mode 100644 index 0000000..390e444 --- /dev/null +++ b/Services/PostgresDb/__init__.py @@ -0,0 +1,6 @@ +from Services.PostgresDb.Models.mixins import CrudCollection, BaseCollection + +__all__ = [ + "CrudCollection", + "BaseCollection", +] \ No newline at end of file diff --git a/Services/Postgres/database.py b/Services/PostgresDb/database.py similarity index 99% rename from Services/Postgres/database.py rename to Services/PostgresDb/database.py index 0af7885..8da967a 100644 --- a/Services/Postgres/database.py +++ b/Services/PostgresDb/database.py @@ -22,6 +22,7 @@ SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) Base = declarative_base() + @contextmanager def get_db() -> Generator[Session, None, None]: """Get database session with context management."""