Infrastructure for payments overhaul. With state storage for billing, type checking, enums, and serialization. Also plan sql further refined

This commit is contained in:
SimplifiedPrivacy 2026-10-04 20:07:56 -04:00
parent 90dd9b309f
commit 57774ae697
7 changed files with 268 additions and 29 deletions

View file

@ -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.enum_models import ProtocolChoice, get_choice_list
from core.models.enum_models import ProtocolChoice
from core.models.orm_models.Location import Location
from core.errors.logger import logger
from core.models.Result import Result, ResultError
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:
"""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("_")
@ -56,3 +55,31 @@ def _get_supported_protocols_for_location(location: Location, as_strings: bool =
SUPPORTED_PROTOCOLS.append(each_protocol)
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)

View file

@ -1,4 +1,5 @@
from core.errors.logger import logger
from dataclasses import dataclass, fields
from dataclasses_json import dataclass_json
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)
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

View file

@ -1,6 +1,5 @@
from core.models.orm_models.Location import Location
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.DatabaseOperation import DatabaseOperation, DBErrorType
from core.errors.logger import logger
@ -12,8 +11,6 @@ from sqlalchemy.orm import Session
from typing import Optional
import random
ALL_PROTOCOLS = get_choice_list(ProtocolChoice)
@safe_db_operation
def execute_location_sql(country_code: str, city_code: str, session: Session) -> DatabaseOperation:
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:
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

View 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

View file

@ -0,0 +1 @@

View file

@ -1,3 +1,5 @@
from core.errors.logger import logger
from dataclasses import dataclass, fields
from dataclasses_json import dataclass_json
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):
final_result = model_type.from_json(data)
else:
print(f"Invalid data format of {type(data)}")
logger.error(f"Invalid data format of {type(data)}")
final_result = None
return final_result
except (TypeError, ValueError) as e:
# TypeError comes from the @validate_types decorator
# ValueError can come from dataclasses-json if the JSON is malformed
print(f"Validation Error: {e}")
logger.error(f"Validation Error: {e}")
return None

View 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()