diff --git a/core/controllers/profile_state/data_filters.py b/core/controllers/profile_state/data_filters.py index c5d01fc..8047f54 100644 --- a/core/controllers/profile_state/data_filters.py +++ b/core/controllers/profile_state/data_filters.py @@ -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) diff --git a/core/models/manage/validate_dataclass.py b/core/models/manage/validate_dataclass.py index 88ef38c..2fd89c8 100644 --- a/core/models/manage/validate_dataclass.py +++ b/core/models/manage/validate_dataclass.py @@ -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 diff --git a/core/models/orm_calls/location_calls.py b/core/models/orm_calls/location_calls.py index c69b189..7f45dac 100644 --- a/core/models/orm_calls/location_calls.py +++ b/core/models/orm_calls/location_calls.py @@ -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 \ No newline at end of file diff --git a/core/models/orm_calls/model_neutral_tools.py b/core/models/orm_calls/model_neutral_tools.py new file mode 100644 index 0000000..88fa519 --- /dev/null +++ b/core/models/orm_calls/model_neutral_tools.py @@ -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 \ No newline at end of file diff --git a/core/models/orm_calls/plan_calls.py b/core/models/orm_calls/plan_calls.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/core/models/orm_calls/plan_calls.py @@ -0,0 +1 @@ + diff --git a/core/models/pydantic_models/type_checker.py b/core/models/pydantic_models/type_checker.py index e449983..4799505 100644 --- a/core/models/pydantic_models/type_checker.py +++ b/core/models/pydantic_models/type_checker.py @@ -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 diff --git a/core/services/payment_phase/payment_state.py b/core/services/payment_phase/payment_state.py new file mode 100644 index 0000000..dcd3a93 --- /dev/null +++ b/core/services/payment_phase/payment_state.py @@ -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() +