Infrastructure for payments overhaul. With state storage for billing, type checking, enums, and serialization. Also plan sql further refined
This commit is contained in:
parent
90dd9b309f
commit
57774ae697
7 changed files with 268 additions and 29 deletions
|
|
@ -1,8 +1,9 @@
|
||||||
|
from core.models.orm_models.Plan import Plan
|
||||||
|
from core.models.orm_calls.model_neutral_tools import get_all_for_model
|
||||||
from core.models.orm_calls.location_calls import get_profile_location_data
|
from core.models.orm_calls.location_calls import get_profile_location_data
|
||||||
from core.models.enum_models import ProtocolChoice, get_choice_list
|
from core.models.enum_models import ProtocolChoice
|
||||||
from core.models.orm_models.Location import Location
|
from core.models.orm_models.Location import Location
|
||||||
from core.errors.logger import logger
|
from core.errors.logger import logger
|
||||||
|
|
||||||
from core.models.Result import Result, ResultError
|
from core.models.Result import Result, ResultError
|
||||||
|
|
||||||
ALL_PROTOCOLS = [e.value for e in ProtocolChoice]
|
ALL_PROTOCOLS = [e.value for e in ProtocolChoice]
|
||||||
|
|
@ -16,15 +17,13 @@ Inputs:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
# if "_" not in location_codes:
|
|
||||||
# error_msg = f"This is a developer error. No location data found for {location_codes}"
|
|
||||||
# logger.error(error_msg)
|
|
||||||
# return Result(valid=False, error_type=ResultError.INVALID_INPUT, message=error_msg)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def get_supported_protocols_for_location(location_codes: str, profile_type: str = None) -> Result:
|
def get_supported_protocols_for_location(location_codes: str, profile_type: str = None) -> Result:
|
||||||
|
"""Get a list of string values for the protocols supported by a given location."""
|
||||||
|
|
||||||
|
if "_" not in location_codes:
|
||||||
|
error_msg = f"This is a developer error. No location data found for {location_codes}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
return Result(valid=False, error_type=ResultError.INVALID_INPUT, message=error_msg)
|
||||||
|
|
||||||
country, city = location_codes.split("_")
|
country, city = location_codes.split("_")
|
||||||
|
|
||||||
|
|
@ -56,3 +55,31 @@ def _get_supported_protocols_for_location(location: Location, as_strings: bool =
|
||||||
SUPPORTED_PROTOCOLS.append(each_protocol)
|
SUPPORTED_PROTOCOLS.append(each_protocol)
|
||||||
|
|
||||||
return SUPPORTED_PROTOCOLS
|
return SUPPORTED_PROTOCOLS
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_plans(chosen_protocol: ProtocolChoice) -> Result:
|
||||||
|
"""Get a formatted list of all Plans for a given protocol."""
|
||||||
|
|
||||||
|
all_plans = get_all_for_model(model_type=Plan)
|
||||||
|
|
||||||
|
if not all_plans:
|
||||||
|
error_msg = "There are no plans in your local SQL database. Please sync or make sure there's no SQL errors."
|
||||||
|
return Result(valid=False, error_type=ResultError.SYNC, message=error_msg)
|
||||||
|
|
||||||
|
PLANS_TO_RETURN = []
|
||||||
|
|
||||||
|
for each_plan in all_plans:
|
||||||
|
|
||||||
|
# filter by protocol:
|
||||||
|
if chosen_protocol not in each_plan.supported_protocols:
|
||||||
|
continue
|
||||||
|
|
||||||
|
each_json = {
|
||||||
|
'id': each_plan.id,
|
||||||
|
'cost': each_plan.cost,
|
||||||
|
'months': each_plan.months
|
||||||
|
}
|
||||||
|
PLANS_TO_RETURN.append(each_json)
|
||||||
|
|
||||||
|
|
||||||
|
return Result(valid=True, data=PLANS_TO_RETURN)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
from core.errors.logger import logger
|
from core.errors.logger import logger
|
||||||
|
|
||||||
from dataclasses import dataclass, fields
|
from dataclasses import dataclass, fields
|
||||||
from dataclasses_json import dataclass_json
|
from dataclasses_json import dataclass_json
|
||||||
from typing import Any, Type, TypeVar
|
from typing import Any, Type, TypeVar
|
||||||
|
|
@ -53,3 +54,17 @@ def validate_model(model_type: Type[T], data: dict|str) -> tuple:
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
return False, error_msg
|
return False, error_msg
|
||||||
|
|
||||||
|
|
||||||
|
class ValidatedDataclass:
|
||||||
|
"""Mixin that provides validate_payload validation for dataclasses."""
|
||||||
|
|
||||||
|
def validate_payload(self) -> bool:
|
||||||
|
for field in fields(self):
|
||||||
|
value = getattr(self, field.name)
|
||||||
|
try:
|
||||||
|
validate_field_type(field.name, value, field.type)
|
||||||
|
except TypeError as e:
|
||||||
|
logger.error(f"Failed to validate type {field.name} with value {value}")
|
||||||
|
return False
|
||||||
|
logger.info("Entire payload looks fine.")
|
||||||
|
return True
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
from core.models.orm_models.Location import Location
|
from core.models.orm_models.Location import Location
|
||||||
from core.models.orm_models.Operator import Operator
|
from core.models.orm_models.Operator import Operator
|
||||||
from core.models.enum_models import ProtocolChoice, get_choice_list
|
|
||||||
from core.models.manage.wrapper import safe_db_operation, WrapperRollback
|
from core.models.manage.wrapper import safe_db_operation, WrapperRollback
|
||||||
from core.models.DatabaseOperation import DatabaseOperation, DBErrorType
|
from core.models.DatabaseOperation import DatabaseOperation, DBErrorType
|
||||||
from core.errors.logger import logger
|
from core.errors.logger import logger
|
||||||
|
|
@ -12,8 +11,6 @@ from sqlalchemy.orm import Session
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import random
|
import random
|
||||||
|
|
||||||
ALL_PROTOCOLS = get_choice_list(ProtocolChoice)
|
|
||||||
|
|
||||||
@safe_db_operation
|
@safe_db_operation
|
||||||
def execute_location_sql(country_code: str, city_code: str, session: Session) -> DatabaseOperation:
|
def execute_location_sql(country_code: str, city_code: str, session: Session) -> DatabaseOperation:
|
||||||
location_object = session.execute(
|
location_object = session.execute(
|
||||||
|
|
@ -62,17 +59,3 @@ def get_random_location(filter_out_list: Optional[list] = None) -> Location:
|
||||||
if random_pick not in filter_out_list:
|
if random_pick not in filter_out_list:
|
||||||
return random_pick
|
return random_pick
|
||||||
|
|
||||||
|
|
||||||
def get_supported_protocols_for_location(location: Location, as_strings: bool = False) -> list:
|
|
||||||
"""Doesn't do SQL to get a list of supported locations"""
|
|
||||||
|
|
||||||
SUPPORTED_PROTOCOLS = []
|
|
||||||
|
|
||||||
for each_protocol in ALL_PROTOCOLS:
|
|
||||||
if location.supports_protocol(protocol):
|
|
||||||
if as_strings:
|
|
||||||
SUPPORTED_PROTOCOLS.append(each_protocol.value)
|
|
||||||
else:
|
|
||||||
SUPPORTED_PROTOCOLS.append(each_protocol)
|
|
||||||
|
|
||||||
return SUPPORTED_PROTOCOLS
|
|
||||||
28
core/models/orm_calls/model_neutral_tools.py
Normal file
28
core/models/orm_calls/model_neutral_tools.py
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
from core.models.manage.wrapper import safe_db_operation, WrapperRollback
|
||||||
|
from core.models.DatabaseOperation import DatabaseOperation, DBErrorType
|
||||||
|
from core.errors.logger import logger
|
||||||
|
|
||||||
|
# generic
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import joinedload
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import Optional, Type, TypeVar
|
||||||
|
|
||||||
|
T = TypeVar('T')
|
||||||
|
|
||||||
|
|
||||||
|
@safe_db_operation
|
||||||
|
def execute_get_all(model_type: Type[T], session: Session) -> DatabaseOperation:
|
||||||
|
return session.query(model_type).all()
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_for_model(model_type: Type[T], filter_out_list: Optional[list] = None) -> Type[T]:
|
||||||
|
"""Return all entries for a given model, with potential filter."""
|
||||||
|
sql_object = execute_get_all(model_type)
|
||||||
|
|
||||||
|
if not sql_object.valid:
|
||||||
|
critical_error = f"Got invalid SQL Query which could not be solved by the wrapper, with error message {sql_object.message} and type {sql_object.error_type}"
|
||||||
|
logger.error(critical_error)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return sql_object.data
|
||||||
1
core/models/orm_calls/plan_calls.py
Normal file
1
core/models/orm_calls/plan_calls.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
from core.errors.logger import logger
|
||||||
|
|
||||||
from dataclasses import dataclass, fields
|
from dataclasses import dataclass, fields
|
||||||
from dataclasses_json import dataclass_json
|
from dataclasses_json import dataclass_json
|
||||||
from typing import Any, Type, TypeVar
|
from typing import Any, Type, TypeVar
|
||||||
|
|
@ -39,12 +41,12 @@ def validate_model(model_type: Type[T], data: dict|str) -> T | None:
|
||||||
elif isinstance(data, str):
|
elif isinstance(data, str):
|
||||||
final_result = model_type.from_json(data)
|
final_result = model_type.from_json(data)
|
||||||
else:
|
else:
|
||||||
print(f"Invalid data format of {type(data)}")
|
logger.error(f"Invalid data format of {type(data)}")
|
||||||
final_result = None
|
final_result = None
|
||||||
return final_result
|
return final_result
|
||||||
except (TypeError, ValueError) as e:
|
except (TypeError, ValueError) as e:
|
||||||
# TypeError comes from the @validate_types decorator
|
# TypeError comes from the @validate_types decorator
|
||||||
# ValueError can come from dataclasses-json if the JSON is malformed
|
# ValueError can come from dataclasses-json if the JSON is malformed
|
||||||
print(f"Validation Error: {e}")
|
logger.error(f"Validation Error: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
|
||||||
183
core/services/payment_phase/payment_state.py
Normal file
183
core/services/payment_phase/payment_state.py
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
from core.models.manage.validate_dataclass import ValidatedDataclass
|
||||||
|
from core.Constants import Constants
|
||||||
|
from core.models.enum_models import Currency, ProtocolChoice, PaymentType
|
||||||
|
from core.errors.logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
from dataclasses import dataclass, fields
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from functools import wraps
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
def singleton(cls):
|
||||||
|
instances = {}
|
||||||
|
|
||||||
|
@wraps(cls)
|
||||||
|
def get_instance(*args, **kwargs):
|
||||||
|
instance_id = kwargs.get('instance_id')
|
||||||
|
|
||||||
|
# If instance_id provided, use localized mode (one instance per ID)
|
||||||
|
# Otherwise, use global mode (single instance)
|
||||||
|
key = (cls, instance_id) if instance_id else cls
|
||||||
|
|
||||||
|
if key not in instances:
|
||||||
|
print(f"Creating new instance with key: {key}")
|
||||||
|
instances[key] = cls(*args, **kwargs)
|
||||||
|
else:
|
||||||
|
print(f"Reusing existing instance with key: {key}")
|
||||||
|
|
||||||
|
# either way:
|
||||||
|
return instances[key]
|
||||||
|
|
||||||
|
return get_instance
|
||||||
|
|
||||||
|
|
||||||
|
# for saving.
|
||||||
|
EXCLUDED_FIELDS = {"parent_folder", "folder_path", "_data_file"}
|
||||||
|
|
||||||
|
class StatePersistenceMixin(ValidatedDataclass):
|
||||||
|
def _serialize_value(self, value):
|
||||||
|
"""Helper method to ensure values are JSON-serializable"""
|
||||||
|
if isinstance(value, Enum):
|
||||||
|
return value.value
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def prep_payload(self) -> dict|None:
|
||||||
|
"""Orchestrate Save, Validation, and Payload preparation of relevant fields"""
|
||||||
|
|
||||||
|
if not self.save():
|
||||||
|
logger.error("Failed to Save! Not returning a payload")
|
||||||
|
return None
|
||||||
|
|
||||||
|
payload = {}
|
||||||
|
|
||||||
|
for f in fields(self):
|
||||||
|
# Only add the field if it's in our allowed list
|
||||||
|
if f.name in self.PAYLOAD_FIELDS:
|
||||||
|
# Get the raw value
|
||||||
|
raw_value = getattr(self, f.name)
|
||||||
|
# Clean it (handle Enums)
|
||||||
|
clean_value = self._serialize_value(raw_value)
|
||||||
|
# Add it to the dictionary
|
||||||
|
payload[f.name] = clean_value
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def save(self) -> bool:
|
||||||
|
"""Save state to file"""
|
||||||
|
|
||||||
|
if not self.validate_payload():
|
||||||
|
return False
|
||||||
|
|
||||||
|
os.makedirs(self.folder_path, exist_ok=True)
|
||||||
|
|
||||||
|
# 1. Start with an empty dictionary
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
# 2. Loop through the fields
|
||||||
|
for f in fields(self):
|
||||||
|
# Only add the field if it's NOT in the excluded list
|
||||||
|
if f.name not in EXCLUDED_FIELDS:
|
||||||
|
# Get the raw value
|
||||||
|
raw_value = getattr(self, f.name)
|
||||||
|
# Clean it (handle Enums)
|
||||||
|
clean_value = self._serialize_value(raw_value)
|
||||||
|
# Add it to the dictionary
|
||||||
|
data[f.name] = clean_value
|
||||||
|
|
||||||
|
with open(self._data_file, "w") as f:
|
||||||
|
json.dump(data, f, indent=4, sort_keys=True)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def load(self):
|
||||||
|
"""Load state from file, if each item passes a type check. With Enum translation"""
|
||||||
|
|
||||||
|
if not os.path.exists(self._data_file):
|
||||||
|
return
|
||||||
|
|
||||||
|
with open(self._data_file, "r") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
for field in fields(self):
|
||||||
|
value = data.get(field.name)
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Translation: If the expected type is an Enum, convert the raw value back into an Enum member
|
||||||
|
if isinstance(field.type, type) and issubclass(field.type, Enum):
|
||||||
|
try:
|
||||||
|
value = field.type(value)
|
||||||
|
except ValueError:
|
||||||
|
logger.error(f"Invalid enum value {value} for {field.name}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Now the type check works because 'value' is now an actual Enum member
|
||||||
|
if isinstance(value, field.type):
|
||||||
|
setattr(self, field.name, value)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@singleton
|
||||||
|
@dataclass
|
||||||
|
class TicketPaymentData(StatePersistenceMixin):
|
||||||
|
PAYLOAD_FIELDS = {"billing_code", "currency", "which_key", "how_many_profiles", "payment_type"}
|
||||||
|
|
||||||
|
# metadata on it's own GLOBAL instance:
|
||||||
|
parent_folder = None
|
||||||
|
folder_path: str = None
|
||||||
|
_data_file: str = None
|
||||||
|
|
||||||
|
# actual payload data:
|
||||||
|
billing_code: str = None
|
||||||
|
currency: Currency = None
|
||||||
|
payment_type: PaymentType = PaymentType.CRYPTO
|
||||||
|
|
||||||
|
# for only tickets:
|
||||||
|
which_key: str = None
|
||||||
|
how_many_profiles: int = None
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
# Global mode, assuming tickets here:
|
||||||
|
self.folder_path = Constants.HV_TICKETING_CONFIG_HOME
|
||||||
|
self._data_file = "billing.json"
|
||||||
|
|
||||||
|
self.load()
|
||||||
|
|
||||||
|
|
||||||
|
@singleton
|
||||||
|
@dataclass
|
||||||
|
class PaymentData(StatePersistenceMixin):
|
||||||
|
PAYLOAD_FIELDS = {"billing_code", "currency", "plan_id", "location_id", "protocol_choice", "payment_type"}
|
||||||
|
|
||||||
|
# metadata on it's own instance:
|
||||||
|
parent_folder = None
|
||||||
|
instance_id: int = None
|
||||||
|
folder_path: str = None
|
||||||
|
_data_file: str = None
|
||||||
|
|
||||||
|
# actual payload data:
|
||||||
|
billing_code: str = None
|
||||||
|
currency: Currency = None
|
||||||
|
payment_type: PaymentType = PaymentType.CRYPTO
|
||||||
|
|
||||||
|
# for traditional plans:
|
||||||
|
plan_id: int = None
|
||||||
|
location_id: int = None
|
||||||
|
protocol_choice: ProtocolChoice = None
|
||||||
|
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
# Localized mode: each ID gets its own folder
|
||||||
|
self.parent_folder = Constants.HV_PROFILE_CONFIG_HOME
|
||||||
|
self.folder_path = os.path.join(self.parent_folder, str(self.instance_id))
|
||||||
|
os.makedirs(self.folder_path, exist_ok=True)
|
||||||
|
self._data_file = os.path.join(self.folder_path, "billing.json")
|
||||||
|
|
||||||
|
self.load()
|
||||||
|
|
||||||
Loading…
Reference in a new issue