From d33ec39aea5229088fb68820fc1968a8e97f8a95 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 5 Mar 2026 18:36:37 +0000 Subject: [PATCH 1/8] get the most recent task for a given service, source, and source ID --- backend/app/tasks/router.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/backend/app/tasks/router.py b/backend/app/tasks/router.py index 1c266f2c..e8ec2686 100644 --- a/backend/app/tasks/router.py +++ b/backend/app/tasks/router.py @@ -16,7 +16,7 @@ from backend.app.tasks.schema import ( from backend.app.db.functions.tasks.Tasks import TasksInterface, SubTaskInterface from backend.app.db.connection import get_db_session -from backend.app.db.models.tasks import Task, SubTask +from backend.app.db.models.tasks import SourceEnum, Task, SubTask from sqlmodel import select @@ -70,6 +70,29 @@ async def get_task(task_id: UUID): } +@router.get( + "/by-source/{source}/{source_id}/{service}", + summary="Get the most recent task by source, source_id, and service", +) +async def get_task_by_source(source: SourceEnum, source_id: str, service: str): + with get_db_session() as session: + task = session.exec( + select(Task) + .where( + Task.source == source, + Task.source_id == source_id, + Task.service == service, + ) + .order_by(Task.job_started.desc()) + .limit(1) + ).first() + + if not task: + raise HTTPException(status_code=404, detail="Task not found") + + return {"task": task} + + # ============================================================ # Update Task Status # ============================================================ From af01f7e5b0b4c11d53eb66d15efcbf6f92fc82ce Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 6 Mar 2026 10:55:40 +0000 Subject: [PATCH 2/8] start writing test --- backend/app/tests/tasks/test_get_task.py | 61 ++++ backend/{export/tests => }/conftest.py | 0 backend/export/tests/test_export.py | 439 ++++++++++++++++------- 3 files changed, 362 insertions(+), 138 deletions(-) create mode 100644 backend/app/tests/tasks/test_get_task.py rename backend/{export/tests => }/conftest.py (100%) diff --git a/backend/app/tests/tasks/test_get_task.py b/backend/app/tests/tasks/test_get_task.py new file mode 100644 index 00000000..751ecd7d --- /dev/null +++ b/backend/app/tests/tasks/test_get_task.py @@ -0,0 +1,61 @@ +from datetime import datetime +import uuid + +from sqlmodel import Session + +from backend.app.db.models.tasks import SourceEnum, Task + + +def test_get_task_by_source(db_session: Session): + # arrange + current_categorisation_task = Task( + id=uuid.uuid4(), + task_source="", + job_started=datetime(2026, 1, 1, 9, 0, 0), + job_completed=None, + status="in progress", + service="plan_categorisation", + updated_at=datetime(2026, 1, 1, 9, 0, 0), + source=SourceEnum.PORTFOLIO, + source_id="100", + ) + + previous_categorisation_task = Task( + id=uuid.uuid4(), + task_source="", + job_started=datetime(2025, 12, 31, 9, 0, 0), + job_completed=datetime(2025, 12, 31, 9, 10, 0), + status="complete", + service="plan_categorisation", + updated_at=datetime(2025, 12, 31, 9, 10, 0), + source=SourceEnum.PORTFOLIO, + source_id="100", + ) + + other_portfolio_categorisation_task = Task( + id=uuid.uuid4(), + task_source="", + job_started=datetime(2026, 1, 1, 9, 0, 0), + job_completed=None, + status="in progress", + service="plan_categorisation", + updated_at=datetime(2026, 1, 1, 9, 0, 0), + source=SourceEnum.PORTFOLIO, + source_id="101", + ) + + engine_task = Task( + id=uuid.uuid4(), + task_source="", + job_started=datetime(2026, 1, 1, 9, 0, 0), + job_completed=None, + status="in progress", + service="plan_engine", + updated_at=datetime(2026, 1, 1, 9, 0, 0), + source=SourceEnum.PORTFOLIO, + source_id="100", + ) + + # act + + # assert diff --git a/backend/export/tests/conftest.py b/backend/conftest.py similarity index 100% rename from backend/export/tests/conftest.py rename to backend/conftest.py diff --git a/backend/export/tests/test_export.py b/backend/export/tests/test_export.py index 823882b5..76be6955 100644 --- a/backend/export/tests/test_export.py +++ b/backend/export/tests/test_export.py @@ -3,12 +3,25 @@ import numpy as np from pathlib import Path import time +from sqlmodel import Session + from backend.export.property_scenarios.main import process_export from backend.export.property_scenarios.input_schema import ExportRequest -from backend.app.db.models.portfolio import PropertyModel, Epc, Portfolio, PortfolioStatus, PortfolioGoal, \ - PropertyCreationStatus, PropertyDetailsEpcModel -from backend.app.db.models.recommendations import PlanModel, Recommendation, PlanRecommendations, \ - RecommendationMaterials +from backend.app.db.models.portfolio import ( + PropertyModel, + Epc, + Portfolio, + PortfolioStatus, + PortfolioGoal, + PropertyCreationStatus, + PropertyDetailsEpcModel, +) +from backend.app.db.models.recommendations import ( + PlanModel, + Recommendation, + PlanRecommendations, + RecommendationMaterials, +) from backend.app.db.models.materials import Material from utils.logger import setup_logger @@ -22,7 +35,7 @@ def load_csv(name: str) -> pd.DataFrame: return df -def test_default_export_integration(db_session): +def test_default_export_integration(db_session: Session): # ---------------------------------------- # 1) Load csvs # ---------------------------------------- @@ -78,11 +91,13 @@ def test_default_export_integration(db_session): else None ) - prop = PropertyModel(**{ - col: row_dict[col] - for col in PropertyModel.__table__.columns.keys() - if col in row_dict - }) + prop = PropertyModel( + **{ + col: row_dict[col] + for col in PropertyModel.__table__.columns.keys() + if col in row_dict + } + ) prop.creation_status = PropertyCreationStatus[ row_dict["creation_status"].split(".")[-1] @@ -90,9 +105,7 @@ def test_default_export_integration(db_session): prop.status = PortfolioStatus[row_dict["status"].split(".")[-1]] if row_dict.get("current_epc_rating"): - prop.current_epc_rating = Epc[ - row_dict["current_epc_rating"].split(".")[-1] - ] + prop.current_epc_rating = Epc[row_dict["current_epc_rating"].split(".")[-1]] properties.append(prop) @@ -112,7 +125,8 @@ def test_default_export_integration(db_session): epc_data = { col.name: row_dict[col.name] for col in PropertyDetailsEpcModel.__table__.columns.values() - if col.name in row_dict and col.name not in ["id", "property_id", "portfolio_id"] + if col.name in row_dict + and col.name not in ["id", "property_id", "portfolio_id"] } epc = PropertyDetailsEpcModel( @@ -142,11 +156,13 @@ def test_default_export_integration(db_session): row_dict["scenario_id"] = None - plan = PlanModel(**{ - col: row_dict[col] - for col in PlanModel.__table__.columns.keys() - if col in row_dict - }) + plan = PlanModel( + **{ + col: row_dict[col] + for col in PlanModel.__table__.columns.keys() + if col in row_dict + } + ) plans.append(plan) @@ -158,11 +174,13 @@ def test_default_export_integration(db_session): # ---------------------------------------- recs = [ - Recommendation(**{ - col: row[col] - for col in Recommendation.__table__.columns.keys() - if col in row - }) + Recommendation( + **{ + col: row[col] + for col in Recommendation.__table__.columns.keys() + if col in row + } + ) for _, row in recommendations_df.iterrows() ] @@ -203,28 +221,19 @@ def test_default_export_integration(db_session): # ---------------------------------------- logger.info( - "Recommendation count in DB: %s", - db_session.query(Recommendation).count() + "Recommendation count in DB: %s", db_session.query(Recommendation).count() ) - logger.info( - "Property count in DB: %s", - db_session.query(PropertyModel).count() - ) + logger.info("Property count in DB: %s", db_session.query(PropertyModel).count()) logger.info( - "Property EPC in DB: %s", - db_session.query(PropertyDetailsEpcModel).count() + "Property EPC in DB: %s", db_session.query(PropertyDetailsEpcModel).count() ) - logger.info( - "Plan count in DB: %s", - db_session.query(PlanModel).count() - ) + logger.info("Plan count in DB: %s", db_session.query(PlanModel).count()) logger.info( - "PlanRecommendatons count in DB: %s", - db_session.query(PlanModel).count() + "PlanRecommendatons count in DB: %s", db_session.query(PlanModel).count() ) logger.info("Starting process_export") @@ -232,17 +241,23 @@ def test_default_export_integration(db_session): result = process_export(payload, session=db_session) - logger.info("process_export finished in %.2f seconds", time.perf_counter() - process_t0) + logger.info( + "process_export finished in %.2f seconds", time.perf_counter() - process_t0 + ) # ---------------------------------------- # 8) Assertions # ---------------------------------------- - assert "default_plans" in result, "Expected 'default_plans' in export result, got {}".format(result.keys()) + assert ( + "default_plans" in result + ), "Expected 'default_plans' in export result, got {}".format(result.keys()) df = result["default_plans"] - assert df.shape[0] == 10, "Expected 10 properties in the export, got {}".format(df.shape[0]) + assert df.shape[0] == 10, "Expected 10 properties in the export, got {}".format( + df.shape[0] + ) failed = df[df["predicted_post_works_sap"] < 69] failed_property_types = failed["property_type"].value_counts().to_dict() @@ -251,19 +266,28 @@ def test_default_export_integration(db_session): assert failed.shape[0] - assert df["total_retrofit_cost"].sum() == 41706.585999999996, ( - "Expected total retrofit cost to be 10000, got {}".format(df["total_retrofit_cost"].sum()) + assert ( + df["total_retrofit_cost"].sum() == 41706.585999999996 + ), "Expected total retrofit cost to be 10000, got {}".format( + df["total_retrofit_cost"].sum() ) - assert df["predicted_post_works_sap"].sum() == 698.1, ( - "Expected total predicted post works SAP to be 698.1, got {}".format(df["predicted_post_works_sap"].sum()) + assert ( + df["predicted_post_works_sap"].sum() == 698.1 + ), "Expected total predicted post works SAP to be 698.1, got {}".format( + df["predicted_post_works_sap"].sum() ) - assert df["sap_points"].sum() == 100.10000000000001, ( - "Expected total SAP points increase to be 100.10000000000001, got {}".format(df["sap_points"].sum()) + assert ( + df["sap_points"].sum() == 100.10000000000001 + ), "Expected total SAP points increase to be 100.10000000000001, got {}".format( + df["sap_points"].sum() ) - assert df.shape == (10, 95), "Expected dataframe shape to be (10, 11), got {}".format(df.shape) + assert df.shape == ( + 10, + 95, + ), "Expected dataframe shape to be (10, 11), got {}".format(df.shape) def test_solar_with_battery_example(db_session): @@ -271,116 +295,251 @@ def test_solar_with_battery_example(db_session): test_property_id = 1 portfolio_df = pd.DataFrame( - [{'id': test_portfolio_id, 'name': 'Example', 'budget': None, - 'status': 'PortfolioStatus.SCOPING', 'goal': 'PortfolioGoal.NONE', 'cost': None, 'number_of_properties': None, - 'co2_equivalent_savings': None, 'energy_savings': None, 'energy_cost_savings': None, - 'property_valuation_increase': None, 'rental_yield_increase': None, 'total_work_hours': None, - 'labour_days': None, 'created_at': '2026-02-12 21:23:37.862000+00:00', - 'updated_at': '2026-02-12 21:23:37.862000+00:00', 'epc_breakdown_pre_retrofit': None, - 'epc_breakdown_post_retrofit': None, 'n_units_to_retrofit': None, 'co2_per_unit_pre_retrofit': None, - 'co2_per_unit_post_retrofit': None, 'energy_bill_per_unit_pre_retrofit': None, - 'energy_bill_per_unit_post_retrofit': None, 'energy_consumption_per_unit_pre_retrofit': None, - 'energy_consumption_per_unit_post_retrofit': None, 'valuation_improvement_per_unit': None, - 'cost_per_unit': None, 'cost_per_co2_saved': None, 'cost_per_sap_point': None, - 'valuation_return_on_investment': None}] + [ + { + "id": test_portfolio_id, + "name": "Example", + "budget": None, + "status": "PortfolioStatus.SCOPING", + "goal": "PortfolioGoal.NONE", + "cost": None, + "number_of_properties": None, + "co2_equivalent_savings": None, + "energy_savings": None, + "energy_cost_savings": None, + "property_valuation_increase": None, + "rental_yield_increase": None, + "total_work_hours": None, + "labour_days": None, + "created_at": "2026-02-12 21:23:37.862000+00:00", + "updated_at": "2026-02-12 21:23:37.862000+00:00", + "epc_breakdown_pre_retrofit": None, + "epc_breakdown_post_retrofit": None, + "n_units_to_retrofit": None, + "co2_per_unit_pre_retrofit": None, + "co2_per_unit_post_retrofit": None, + "energy_bill_per_unit_pre_retrofit": None, + "energy_bill_per_unit_post_retrofit": None, + "energy_consumption_per_unit_pre_retrofit": None, + "energy_consumption_per_unit_post_retrofit": None, + "valuation_improvement_per_unit": None, + "cost_per_unit": None, + "cost_per_co2_saved": None, + "cost_per_sap_point": None, + "valuation_return_on_investment": None, + } + ] ) properties_df = pd.DataFrame( - [{'id': test_property_id, 'portfolio_id': test_portfolio_id, 'creation_status': 'PropertyCreationStatus.READY', - 'uprn': 100090438731, 'landlord_property_id': 'BARR052', 'building_reference_number': 3460742868.0, - 'status': 'PortfolioStatus.ASSESSMENT', 'address': '52, Barrack Street', 'postcode': 'CO1 2LR', - 'has_pre_condition_report': True, 'has_recommendations': True, 'created_at': '2026-02-12 21:59:02.744427', - 'updated_at': '2026-02-19 16:18:57.941443', 'property_type': 'House', 'built_form': 'End-Terrace', - 'local_authority': 'Colchester', 'constituency': 'Colchester', 'number_of_rooms': 4.0, 'year_built': 1900.0, - 'tenure': 'rental (private)', 'current_epc_rating': 'Epc.E', 'current_sap_points': 53.0, - 'current_valuation': 0.0, 'installed_measures_sap_point_adjustment': 0.0, - 'is_sap_points_adjusted_for_installed_measures': False, 'original_sap_points': 53.0}] + [ + { + "id": test_property_id, + "portfolio_id": test_portfolio_id, + "creation_status": "PropertyCreationStatus.READY", + "uprn": 100090438731, + "landlord_property_id": "BARR052", + "building_reference_number": 3460742868.0, + "status": "PortfolioStatus.ASSESSMENT", + "address": "52, Barrack Street", + "postcode": "CO1 2LR", + "has_pre_condition_report": True, + "has_recommendations": True, + "created_at": "2026-02-12 21:59:02.744427", + "updated_at": "2026-02-19 16:18:57.941443", + "property_type": "House", + "built_form": "End-Terrace", + "local_authority": "Colchester", + "constituency": "Colchester", + "number_of_rooms": 4.0, + "year_built": 1900.0, + "tenure": "rental (private)", + "current_epc_rating": "Epc.E", + "current_sap_points": 53.0, + "current_valuation": 0.0, + "installed_measures_sap_point_adjustment": 0.0, + "is_sap_points_adjusted_for_installed_measures": False, + "original_sap_points": 53.0, + } + ] ) property_details_epc_df = pd.DataFrame( [ - {'id': 1534934, 'property_id': test_property_id, 'portfolio_id': test_portfolio_id, - 'full_address': '48, Medcalf Road', 'lodgement_date': '2018-09-05', 'is_expired': False, - 'total_floor_area': 68.0, 'walls': 'Solid brick, as built, no insulation', 'walls_rating': 1, - 'roof': 'Pitched, no insulation', 'roof_rating': 1.0, 'floor': 'Solid, no insulation', - 'floor_rating': None, - 'windows': 'Fully double glazed', 'windows_rating': 4, 'heating': 'Boiler and radiators, mains gas', - 'heating_rating': 4, 'heating_controls': 'Programmer, room thermostat and trvs', - 'heating_controls_rating': 4, - 'hot_water': 'From main system', 'hot_water_rating': 4, - 'lighting': 'Low energy lighting in all fixed outlets', 'lighting_rating': 5, - 'mainfuel': 'Mains gas not community', 'ventilation': 'natural', 'solar_pv': 0.0, 'solar_hot_water': False, - 'wind_turbine': 0.0, 'floor_height': 2.55, 'number_heated_rooms': None, 'heat_loss_corridor': False, - 'unheated_corridor_length': None, 'number_of_open_fireplaces': 0, 'number_of_extensions': 0, - 'number_of_storeys': None, 'mains_gas': True, 'energy_tariff': 'Single', - 'primary_energy_consumption': 278.0, - 'co2_emissions': 3.81, 'current_energy_demand': 14643.366, - 'current_energy_demand_heating_hotwater': 12185.6, - 'estimated': False, 'sap_05_overwritten': False, 'sap_05_score': None, 'sap_05_epc_rating': None, - 'heating_cost_current': 711.0628, 'hot_water_cost_current': 139.06198, 'lighting_cost_current': 70.770935, - 'appliances_cost_current': 609.7844, 'gas_standing_charge': 128.0785, - 'electricity_standing_charge': 199.8375, - 'original_co2_emissions': 3.81, 'original_primary_energy_consumption': 278.0, - 'original_current_energy_demand': 14643.366, 'original_current_energy_demand_heating_hotwater': 12185.6, - 'installed_measures_co2_adjustment': 0.0, 'installed_measures_energy_demand_adjustment': 0.0, - 'installed_measures_total_energy_bill_adjustment': 0.0, 'installed_measures_heat_demand_adjustment': 0.0, - 'is_epc_adjusted_for_installed_measures': False} + { + "id": 1534934, + "property_id": test_property_id, + "portfolio_id": test_portfolio_id, + "full_address": "48, Medcalf Road", + "lodgement_date": "2018-09-05", + "is_expired": False, + "total_floor_area": 68.0, + "walls": "Solid brick, as built, no insulation", + "walls_rating": 1, + "roof": "Pitched, no insulation", + "roof_rating": 1.0, + "floor": "Solid, no insulation", + "floor_rating": None, + "windows": "Fully double glazed", + "windows_rating": 4, + "heating": "Boiler and radiators, mains gas", + "heating_rating": 4, + "heating_controls": "Programmer, room thermostat and trvs", + "heating_controls_rating": 4, + "hot_water": "From main system", + "hot_water_rating": 4, + "lighting": "Low energy lighting in all fixed outlets", + "lighting_rating": 5, + "mainfuel": "Mains gas not community", + "ventilation": "natural", + "solar_pv": 0.0, + "solar_hot_water": False, + "wind_turbine": 0.0, + "floor_height": 2.55, + "number_heated_rooms": None, + "heat_loss_corridor": False, + "unheated_corridor_length": None, + "number_of_open_fireplaces": 0, + "number_of_extensions": 0, + "number_of_storeys": None, + "mains_gas": True, + "energy_tariff": "Single", + "primary_energy_consumption": 278.0, + "co2_emissions": 3.81, + "current_energy_demand": 14643.366, + "current_energy_demand_heating_hotwater": 12185.6, + "estimated": False, + "sap_05_overwritten": False, + "sap_05_score": None, + "sap_05_epc_rating": None, + "heating_cost_current": 711.0628, + "hot_water_cost_current": 139.06198, + "lighting_cost_current": 70.770935, + "appliances_cost_current": 609.7844, + "gas_standing_charge": 128.0785, + "electricity_standing_charge": 199.8375, + "original_co2_emissions": 3.81, + "original_primary_energy_consumption": 278.0, + "original_current_energy_demand": 14643.366, + "original_current_energy_demand_heating_hotwater": 12185.6, + "installed_measures_co2_adjustment": 0.0, + "installed_measures_energy_demand_adjustment": 0.0, + "installed_measures_total_energy_bill_adjustment": 0.0, + "installed_measures_heat_demand_adjustment": 0.0, + "is_epc_adjusted_for_installed_measures": False, + } ] ) plans_df = pd.DataFrame( [ - {'id': 0, 'name': None, 'portfolio_id': test_portfolio_id, 'property_id': test_property_id, - 'scenario_id': 1060, 'created_at': '2026-02-19 16:14:45.560816', 'is_default': True, - 'valuation_increase_lower_bound': 0.0302, - 'valuation_increase_upper_bound': 0.07, 'valuation_increase_average': 0.048226666, 'plan_type': None, - 'post_sap_points': 71.5, 'post_epc_rating': 'Epc.C', 'post_co2_emissions': 4.1813498, - 'co2_savings': 0.71865046, 'post_energy_bill': 1447.5204, 'energy_bill_savings': 691.6662, - 'post_energy_consumption': 15303.688, 'energy_consumption_savings': 3276.7622, - 'valuation_post_retrofit': None, 'valuation_increase': None, 'cost_of_works': 6984.568, - 'contingency_cost': 1003.9568} + { + "id": 0, + "name": None, + "portfolio_id": test_portfolio_id, + "property_id": test_property_id, + "scenario_id": 1060, + "created_at": "2026-02-19 16:14:45.560816", + "is_default": True, + "valuation_increase_lower_bound": 0.0302, + "valuation_increase_upper_bound": 0.07, + "valuation_increase_average": 0.048226666, + "plan_type": None, + "post_sap_points": 71.5, + "post_epc_rating": "Epc.C", + "post_co2_emissions": 4.1813498, + "co2_savings": 0.71865046, + "post_energy_bill": 1447.5204, + "energy_bill_savings": 691.6662, + "post_energy_consumption": 15303.688, + "energy_consumption_savings": 3276.7622, + "valuation_post_retrofit": None, + "valuation_increase": None, + "cost_of_works": 6984.568, + "contingency_cost": 1003.9568, + } ] ) - plan_recs_df = pd.DataFrame( - [{'id': 0, 'plan_id': 0, 'recommendation_id': 0}] - ) + plan_recs_df = pd.DataFrame([{"id": 0, "plan_id": 0, "recommendation_id": 0}]) recommendations_df = pd.DataFrame( - [{'id': 0, 'property_id': test_property_id, 'created_at': '2026-02-19 16:14:45.560816', - 'type': 'solar_pv', 'measure_type': 'solar_pv', - 'description': 'Fit solar', - 'estimated_cost': 10000, 'default': True, 'starting_u_value': None, 'new_u_value': None, 'sap_points': 1.5, - 'heat_demand': 14.9, 'kwh_savings': 1041.2, 'co2_equivalent_savings': 0.2, 'energy_savings': 14.9, - 'energy_cost_savings': 72.639015, 'property_valuation_increase': None, 'rental_yield_increase': None, - 'total_work_hours': 4.16, 'labour_days': 1.0, 'already_installed': False, 'plan_name': 'whatever'} - ] + [ + { + "id": 0, + "property_id": test_property_id, + "created_at": "2026-02-19 16:14:45.560816", + "type": "solar_pv", + "measure_type": "solar_pv", + "description": "Fit solar", + "estimated_cost": 10000, + "default": True, + "starting_u_value": None, + "new_u_value": None, + "sap_points": 1.5, + "heat_demand": 14.9, + "kwh_savings": 1041.2, + "co2_equivalent_savings": 0.2, + "energy_savings": 14.9, + "energy_cost_savings": 72.639015, + "property_valuation_increase": None, + "rental_yield_increase": None, + "total_work_hours": 4.16, + "labour_days": 1.0, + "already_installed": False, + "plan_name": "whatever", + } + ] ) recommendations_materials_df = pd.DataFrame( [ { - "id": 0, "recommendation_id": 0, "material_id": 0, "depth": None, "quantity": 1.0, + "id": 0, + "recommendation_id": 0, + "material_id": 0, + "depth": None, + "quantity": 1.0, "quantity_unit": "part", - "estimated_cost": 10000, "created_at": '2026-02-19 16:14:45.560816', - "updated_at": '2026-02-19 16:14:45.560816', + "estimated_cost": 10000, + "created_at": "2026-02-19 16:14:45.560816", + "updated_at": "2026-02-19 16:14:45.560816", } ] ) materials_df = pd.DataFrame( [ - {'id': 0, 'type': 'solar_pv', 'description': 'Some solar product', - 'depth': 75.0, - 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031, - 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'Test', - 'created_at': "'2026-02-19 16:14:45.560816", 'is_active': True, - 'prime_material_cost': None, - 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, - 'total_cost': 10000, - 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': None, 'size_unit': None, - 'includes_scaffolding': True, 'includes_battery': True, 'battery_size': 5.8} + { + "id": 0, + "type": "solar_pv", + "description": "Some solar product", + "depth": 75.0, + "depth_unit": "mm", + "cost": None, + "cost_unit": "gbp_per_m2", + "r_value_per_mm": 0.030303031, + "r_value_unit": "square_meter_kelvin_per_watt", + "thermal_conductivity": 0.033, + "thermal_conductivity_unit": "watt_per_meter_kelvin", + "link": "Test", + "created_at": "'2026-02-19 16:14:45.560816", + "is_active": True, + "prime_material_cost": None, + "material_cost": 0.0, + "labour_cost": 0.0, + "labour_hours_per_unit": 0.0, + "plant_cost": 0.0, + "total_cost": 10000, + "notes": None, + "is_installer_quote": True, + "innovation_rate": 0.25, + "size": None, + "size_unit": None, + "includes_scaffolding": True, + "includes_battery": True, + "battery_size": 5.8, + } ] ) @@ -463,7 +622,7 @@ def test_solar_with_battery_example(db_session): already_installed=row.already_installed, sap_points=row.sap_points, type=row.type, - description=row.description + description=row.description, ) db_session.add(rec) db_session.flush() @@ -515,13 +674,15 @@ def test_solar_with_battery_example(db_session): db_session.commit() - payload = ExportRequest.model_validate({ - "task_id": "test", - "subtask_id": "test", - "portfolio_id": test_portfolio_id, - "scenario_ids": [], - "default_plans_only": True, - }) + payload = ExportRequest.model_validate( + { + "task_id": "test", + "subtask_id": "test", + "portfolio_id": test_portfolio_id, + "scenario_ids": [], + "default_plans_only": True, + } + ) result = process_export(payload, session=db_session) @@ -534,7 +695,9 @@ def test_solar_with_battery_example(db_session): # solar_pv should NOT exist assert "solar_pv" not in df.columns - assert df.shape[0] == 1, "Expected 1 property in the export, got {}".format(df.shape[0]) + assert df.shape[0] == 1, "Expected 1 property in the export, got {}".format( + df.shape[0] + ) # Cost should land in correct column assert df["solar_pv_with_battery"].iloc[0] == 10000 From 6e7a5ab8631ecf0c691d08106d35d4131afecf2a Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 6 Mar 2026 16:15:11 +0000 Subject: [PATCH 3/8] almost working tests --- .devcontainer/backend/requirements.txt | 1 + backend/app/db/connection.py | 7 +- backend/app/db/models/tasks.py | 153 ++++++++++++++++------- backend/app/tasks/router.py | 41 +++--- backend/app/tests/tasks/test_get_task.py | 43 ++++++- pytest.ini | 23 +++- test.requirements.txt | 3 +- 7 files changed, 202 insertions(+), 69 deletions(-) diff --git a/.devcontainer/backend/requirements.txt b/.devcontainer/backend/requirements.txt index 5cd40ced..e7d1b099 100644 --- a/.devcontainer/backend/requirements.txt +++ b/.devcontainer/backend/requirements.txt @@ -21,6 +21,7 @@ ipykernel>=6.25,<7 dotenv psycopg[binary] pytest-postgresql +httpx # Formatting black==26.1.0 boto3-stubs \ No newline at end of file diff --git a/backend/app/db/connection.py b/backend/app/db/connection.py index f0649c71..c0656374 100644 --- a/backend/app/db/connection.py +++ b/backend/app/db/connection.py @@ -1,7 +1,7 @@ from sqlalchemy import create_engine from contextlib import contextmanager from backend.app.config import get_settings -from sqlmodel import Session +from sqlalchemy.orm import Session connection_string = ( "postgresql+{drivername}://{username}:{password}@{server}:{port}/{dbname}" @@ -56,3 +56,8 @@ def db_read_session(): yield session finally: session.close() + + +def get_session(): + with db_session() as session: + yield session diff --git a/backend/app/db/models/tasks.py b/backend/app/db/models/tasks.py index e97a939f..1eeeafaa 100644 --- a/backend/app/db/models/tasks.py +++ b/backend/app/db/models/tasks.py @@ -1,65 +1,130 @@ +# import enum +# from typing import Optional +# from datetime import datetime +# from uuid import UUID, uuid4 + +# from sqlalchemy import Column, Enum +# from sqlmodel import SQLModel, Field, Relationship + + +# class SourceEnum(enum.Enum): # TODO: move to domain? +# PORTFOLIO = "portfolio_id" + + +# class Task(SQLModel, table=True): +# __tablename__ = "tasks" + +# id: UUID = Field( +# default_factory=uuid4, +# primary_key=True, +# index=True, +# ) +# task_source: str +# job_started: Optional[datetime] = None +# job_completed: Optional[datetime] = None +# status: str = Field(default="In Progress") +# service: Optional[str] = None +# updated_at: datetime = Field(default_factory=datetime.utcnow) + +# # source: Mapped[Optional[SourceEnum]] = mapped_column(Enum(SourceEnum)) <- SQLAlchemy not SQLModel + +# source: Optional[SourceEnum] = Field( +# default=None, +# sa_column=Column( +# Enum( +# SourceEnum, +# name="source", +# values_callable=lambda e: [m.value for m in e], +# ), +# nullable=True, +# ), +# ) +# source_id: Optional[str] = None + +# sub_tasks: list["SubTask"] = Relationship(back_populates="task") + + +# class SubTask(SQLModel, table=True): +# __tablename__ = "sub_task" + +# id: UUID = Field( +# default_factory=uuid4, +# primary_key=True, +# index=True, +# ) + +# task_id: UUID = Field(foreign_key="tasks.id") +# job_started: Optional[datetime] = None +# job_completed: Optional[datetime] = None +# status: str = Field(default="In Progress") +# inputs: Optional[str] = None +# outputs: Optional[str] = None +# cloud_logs_url: Optional[str] = None +# updated_at: datetime = Field(default_factory=datetime.utcnow) + +# task: Optional["Task"] = Relationship(back_populates="sub_tasks") + + import enum -from typing import Optional +from typing import Optional, List from datetime import datetime from uuid import UUID, uuid4 -from sqlalchemy import Column, Enum -from sqlmodel import SQLModel, Field, Relationship +from sqlalchemy import Enum, String, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import UUID as PG_UUID, TIMESTAMP + +from backend.app.db.base import Base -class SourceEnum(enum.Enum): # TODO: move to domain? +class SourceEnum(enum.Enum): PORTFOLIO = "portfolio_id" -class Task(SQLModel, table=True): +class Task(Base): __tablename__ = "tasks" - id: UUID = Field( - default_factory=uuid4, - primary_key=True, - index=True, + id: Mapped[UUID] = mapped_column( + PG_UUID(as_uuid=True), primary_key=True, default=uuid4, index=True ) - task_source: str - job_started: Optional[datetime] = None - job_completed: Optional[datetime] = None - status: str = Field(default="In Progress") - service: Optional[str] = None - updated_at: datetime = Field(default_factory=datetime.utcnow) - - # source: Mapped[Optional[SourceEnum]] = mapped_column(Enum(SourceEnum)) <- SQLAlchemy not SQLModel - - source: Optional[SourceEnum] = Field( - default=None, - sa_column=Column( - Enum( - SourceEnum, - name="source", - values_callable=lambda e: [m.value for m in e], - ), - nullable=True, + task_source: Mapped[str] = mapped_column(String, nullable=False) + job_started: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True) + job_completed: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True) + status: Mapped[str] = mapped_column(String, nullable=False, default="In Progress") + service: Mapped[Optional[str]] = mapped_column(String, nullable=True) + updated_at: Mapped[datetime] = mapped_column( + TIMESTAMP, nullable=False, default=datetime.utcnow + ) + source: Mapped[Optional[SourceEnum]] = mapped_column( + Enum( + SourceEnum, + name="source", + values_callable=lambda e: [m.value for m in e], ), + nullable=True, ) - source_id: Optional[str] = None + source_id: Mapped[Optional[str]] = mapped_column(String, nullable=True) - sub_tasks: list["SubTask"] = Relationship(back_populates="task") + sub_tasks: Mapped[List["SubTask"]] = relationship("SubTask", back_populates="task") -class SubTask(SQLModel, table=True): +class SubTask(Base): __tablename__ = "sub_task" - id: UUID = Field( - default_factory=uuid4, - primary_key=True, - index=True, + id: Mapped[UUID] = mapped_column( + PG_UUID(as_uuid=True), primary_key=True, default=uuid4, index=True + ) + task_id: Mapped[UUID] = mapped_column( + PG_UUID(as_uuid=True), ForeignKey("tasks.id"), nullable=False + ) + job_started: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True) + job_completed: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True) + status: Mapped[str] = mapped_column(String, nullable=False, default="In Progress") + inputs: Mapped[Optional[str]] = mapped_column(String, nullable=True) + outputs: Mapped[Optional[str]] = mapped_column(String, nullable=True) + cloud_logs_url: Mapped[Optional[str]] = mapped_column(String, nullable=True) + updated_at: Mapped[datetime] = mapped_column( + TIMESTAMP, nullable=False, default=datetime.utcnow ) - task_id: UUID = Field(foreign_key="tasks.id") - job_started: Optional[datetime] = None - job_completed: Optional[datetime] = None - status: str = Field(default="In Progress") - inputs: Optional[str] = None - outputs: Optional[str] = None - cloud_logs_url: Optional[str] = None - updated_at: datetime = Field(default_factory=datetime.utcnow) - - task: Optional["Task"] = Relationship(back_populates="sub_tasks") + task: Mapped[Optional["Task"]] = relationship("Task", back_populates="sub_tasks") diff --git a/backend/app/tasks/router.py b/backend/app/tasks/router.py index e8ec2686..88f68762 100644 --- a/backend/app/tasks/router.py +++ b/backend/app/tasks/router.py @@ -15,9 +15,12 @@ from backend.app.tasks.schema import ( # Correct location of interfaces from backend.app.db.functions.tasks.Tasks import TasksInterface, SubTaskInterface -from backend.app.db.connection import get_db_session +from backend.app.db.connection import get_db_session, get_session from backend.app.db.models.tasks import SourceEnum, Task, SubTask -from sqlmodel import select +from sqlalchemy.orm import Session +from sqlalchemy import select + +# from sqlmodel import Session, select router = APIRouter( @@ -74,23 +77,27 @@ async def get_task(task_id: UUID): "/by-source/{source}/{source_id}/{service}", summary="Get the most recent task by source, source_id, and service", ) -async def get_task_by_source(source: SourceEnum, source_id: str, service: str): - with get_db_session() as session: - task = session.exec( - select(Task) - .where( - Task.source == source, - Task.source_id == source_id, - Task.service == service, - ) - .order_by(Task.job_started.desc()) - .limit(1) - ).first() +async def get_task_by_source( + source: SourceEnum, + source_id: str, + service: str, + session: Session = Depends(get_session), +): + task = session.execute( + select(Task) + .where( + Task.source == source, + Task.source_id == source_id, + Task.service == service, + ) + .order_by(Task.job_started.desc()) + .limit(1) + ).first() - if not task: - raise HTTPException(status_code=404, detail="Task not found") + if not task: + raise HTTPException(status_code=404, detail="Task not found") - return {"task": task} + return {"task": task} # ============================================================ diff --git a/backend/app/tests/tasks/test_get_task.py b/backend/app/tests/tasks/test_get_task.py index 751ecd7d..b8e36be8 100644 --- a/backend/app/tests/tasks/test_get_task.py +++ b/backend/app/tests/tasks/test_get_task.py @@ -1,12 +1,17 @@ -from datetime import datetime import uuid +from datetime import datetime +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy import select +from sqlalchemy.orm import Session -from sqlmodel import Session - +from backend.app.db.connection import get_session, db_session as db_session_dependency +from backend.app.dependencies import validate_token from backend.app.db.models.tasks import SourceEnum, Task +from backend.app.tasks.router import router -def test_get_task_by_source(db_session: Session): +def test_get_task_by_source(db_session: Session) -> None: # arrange current_categorisation_task = Task( id=uuid.uuid4(), @@ -56,6 +61,36 @@ def test_get_task_by_source(db_session: Session): source_id="100", ) + db_session.add_all( + [ + current_categorisation_task, + previous_categorisation_task, + other_portfolio_categorisation_task, + engine_task, + ] + ) + db_session.commit() + # db_session.flush() + + # debug: confirm data is visible in this session + all_tasks = db_session.execute(select(Task)).scalars().all() + print(f"Tasks in db: {[(t.service, t.source_id, t.source) for t in all_tasks]}") + # act + test_app = FastAPI() + test_app.include_router(router) + + def override_get_session(): + yield db_session + + test_app.dependency_overrides[get_session] = override_get_session + test_app.dependency_overrides[validate_token] = lambda: None + + client = TestClient(test_app) + response = client.get("/tasks/by-source/portfolio_id/100/plan_categorisation") + + test_app.dependency_overrides.clear() # assert + assert response.status_code == 200 + assert response.json()["task"]["id"] == str(current_categorisation_task.id) diff --git a/pytest.ini b/pytest.ini index 608d5e0c..06eee3ae 100644 --- a/pytest.ini +++ b/pytest.ini @@ -2,5 +2,24 @@ pythonpath = . log_cli = true log_cli_level = INFO -addopts = --cov-report term-missing --cov=etl/epc --cov=recommendations --cov=backend --cov=etl/epc_clean --cov=etl/spatial -testpaths = recommendations/tests backend/tests etl/epc/tests etl/epc_clean/tests etl/spatial/tests backend/condition/tests backend/address2UPRN/tests backend/onboarders/tests backend/categorisation/tests backend/export/tests + +addopts = + --cov-report term-missing + --cov=etl/epc + --cov=etl/epc_clean + --cov=etl/spatial + --cov=recommendations + --cov=backend + +testpaths = + backend/tests + backend/address2UPRN/tests + backend/categorisation/tests + backend/condition/tests + backend/export/tests + backend/onboarders/tests + backend/app/tests + etl/epc/tests + etl/epc_clean/tests + etl/spatial/tests + recommendations/tests \ No newline at end of file diff --git a/test.requirements.txt b/test.requirements.txt index d8b8b777..8fa139d3 100644 --- a/test.requirements.txt +++ b/test.requirements.txt @@ -4,4 +4,5 @@ pytest-cov pytest-mock dotenv psycopg[binary] -pytest-postgresql \ No newline at end of file +pytest-postgresql +httpx \ No newline at end of file From 36f5ad6532201e16f9b9b0eb54cbadacd09c22f3 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Mon, 9 Mar 2026 13:31:08 +0000 Subject: [PATCH 4/8] switch Task back to use SQLModel because SQLAlchemy doesn't auto parse and validate response using pydantic --- backend/app/db/models/tasks.py | 218 ++++++++++++++++----------------- 1 file changed, 109 insertions(+), 109 deletions(-) diff --git a/backend/app/db/models/tasks.py b/backend/app/db/models/tasks.py index 1eeeafaa..058a15c5 100644 --- a/backend/app/db/models/tasks.py +++ b/backend/app/db/models/tasks.py @@ -1,130 +1,130 @@ -# import enum -# from typing import Optional -# from datetime import datetime -# from uuid import UUID, uuid4 - -# from sqlalchemy import Column, Enum -# from sqlmodel import SQLModel, Field, Relationship - - -# class SourceEnum(enum.Enum): # TODO: move to domain? -# PORTFOLIO = "portfolio_id" - - -# class Task(SQLModel, table=True): -# __tablename__ = "tasks" - -# id: UUID = Field( -# default_factory=uuid4, -# primary_key=True, -# index=True, -# ) -# task_source: str -# job_started: Optional[datetime] = None -# job_completed: Optional[datetime] = None -# status: str = Field(default="In Progress") -# service: Optional[str] = None -# updated_at: datetime = Field(default_factory=datetime.utcnow) - -# # source: Mapped[Optional[SourceEnum]] = mapped_column(Enum(SourceEnum)) <- SQLAlchemy not SQLModel - -# source: Optional[SourceEnum] = Field( -# default=None, -# sa_column=Column( -# Enum( -# SourceEnum, -# name="source", -# values_callable=lambda e: [m.value for m in e], -# ), -# nullable=True, -# ), -# ) -# source_id: Optional[str] = None - -# sub_tasks: list["SubTask"] = Relationship(back_populates="task") - - -# class SubTask(SQLModel, table=True): -# __tablename__ = "sub_task" - -# id: UUID = Field( -# default_factory=uuid4, -# primary_key=True, -# index=True, -# ) - -# task_id: UUID = Field(foreign_key="tasks.id") -# job_started: Optional[datetime] = None -# job_completed: Optional[datetime] = None -# status: str = Field(default="In Progress") -# inputs: Optional[str] = None -# outputs: Optional[str] = None -# cloud_logs_url: Optional[str] = None -# updated_at: datetime = Field(default_factory=datetime.utcnow) - -# task: Optional["Task"] = Relationship(back_populates="sub_tasks") - - import enum -from typing import Optional, List +from typing import Optional from datetime import datetime from uuid import UUID, uuid4 -from sqlalchemy import Enum, String, ForeignKey -from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.dialects.postgresql import UUID as PG_UUID, TIMESTAMP - -from backend.app.db.base import Base +from sqlalchemy import Column, Enum +from sqlmodel import SQLModel, Field, Relationship -class SourceEnum(enum.Enum): +class SourceEnum(enum.Enum): # TODO: move to domain? PORTFOLIO = "portfolio_id" -class Task(Base): +class Task(SQLModel, table=True): __tablename__ = "tasks" - id: Mapped[UUID] = mapped_column( - PG_UUID(as_uuid=True), primary_key=True, default=uuid4, index=True + id: UUID = Field( + default_factory=uuid4, + primary_key=True, + index=True, ) - task_source: Mapped[str] = mapped_column(String, nullable=False) - job_started: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True) - job_completed: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True) - status: Mapped[str] = mapped_column(String, nullable=False, default="In Progress") - service: Mapped[Optional[str]] = mapped_column(String, nullable=True) - updated_at: Mapped[datetime] = mapped_column( - TIMESTAMP, nullable=False, default=datetime.utcnow - ) - source: Mapped[Optional[SourceEnum]] = mapped_column( - Enum( - SourceEnum, - name="source", - values_callable=lambda e: [m.value for m in e], + task_source: str + job_started: Optional[datetime] = None + job_completed: Optional[datetime] = None + status: str = Field(default="In Progress") + service: Optional[str] = None + updated_at: datetime = Field(default_factory=datetime.utcnow) + + # source: Mapped[Optional[SourceEnum]] = mapped_column(Enum(SourceEnum)) <- SQLAlchemy not SQLModel + + source: Optional[SourceEnum] = Field( + default=None, + sa_column=Column( + Enum( + SourceEnum, + name="source", + values_callable=lambda e: [m.value for m in e], + ), + nullable=True, ), - nullable=True, ) - source_id: Mapped[Optional[str]] = mapped_column(String, nullable=True) + source_id: Optional[str] = None - sub_tasks: Mapped[List["SubTask"]] = relationship("SubTask", back_populates="task") + sub_tasks: list["SubTask"] = Relationship(back_populates="task") -class SubTask(Base): +class SubTask(SQLModel, table=True): __tablename__ = "sub_task" - id: Mapped[UUID] = mapped_column( - PG_UUID(as_uuid=True), primary_key=True, default=uuid4, index=True - ) - task_id: Mapped[UUID] = mapped_column( - PG_UUID(as_uuid=True), ForeignKey("tasks.id"), nullable=False - ) - job_started: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True) - job_completed: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True) - status: Mapped[str] = mapped_column(String, nullable=False, default="In Progress") - inputs: Mapped[Optional[str]] = mapped_column(String, nullable=True) - outputs: Mapped[Optional[str]] = mapped_column(String, nullable=True) - cloud_logs_url: Mapped[Optional[str]] = mapped_column(String, nullable=True) - updated_at: Mapped[datetime] = mapped_column( - TIMESTAMP, nullable=False, default=datetime.utcnow + id: UUID = Field( + default_factory=uuid4, + primary_key=True, + index=True, ) - task: Mapped[Optional["Task"]] = relationship("Task", back_populates="sub_tasks") + task_id: UUID = Field(foreign_key="tasks.id") + job_started: Optional[datetime] = None + job_completed: Optional[datetime] = None + status: str = Field(default="In Progress") + inputs: Optional[str] = None + outputs: Optional[str] = None + cloud_logs_url: Optional[str] = None + updated_at: datetime = Field(default_factory=datetime.utcnow) + + task: Optional["Task"] = Relationship(back_populates="sub_tasks") + + +# import enum +# from typing import Optional, List +# from datetime import datetime +# from uuid import UUID, uuid4 + +# from sqlalchemy import Enum, String, ForeignKey +# from sqlalchemy.orm import Mapped, mapped_column, relationship +# from sqlalchemy.dialects.postgresql import UUID as PG_UUID, TIMESTAMP + +# from backend.app.db.base import Base + + +# class SourceEnum(enum.Enum): +# PORTFOLIO = "portfolio_id" + + +# class Task(Base): +# __tablename__ = "tasks" + +# id: Mapped[UUID] = mapped_column( +# PG_UUID(as_uuid=True), primary_key=True, default=uuid4, index=True +# ) +# task_source: Mapped[str] = mapped_column(String, nullable=False) +# job_started: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True) +# job_completed: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True) +# status: Mapped[str] = mapped_column(String, nullable=False, default="In Progress") +# service: Mapped[Optional[str]] = mapped_column(String, nullable=True) +# updated_at: Mapped[datetime] = mapped_column( +# TIMESTAMP, nullable=False, default=datetime.utcnow +# ) +# source: Mapped[Optional[SourceEnum]] = mapped_column( +# Enum( +# SourceEnum, +# name="source", +# values_callable=lambda e: [m.value for m in e], +# ), +# nullable=True, +# ) +# source_id: Mapped[Optional[str]] = mapped_column(String, nullable=True) + +# sub_tasks: Mapped[List["SubTask"]] = relationship("SubTask", back_populates="task") + + +# class SubTask(Base): +# __tablename__ = "sub_task" + +# id: Mapped[UUID] = mapped_column( +# PG_UUID(as_uuid=True), primary_key=True, default=uuid4, index=True +# ) +# task_id: Mapped[UUID] = mapped_column( +# PG_UUID(as_uuid=True), ForeignKey("tasks.id"), nullable=False +# ) +# job_started: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True) +# job_completed: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True) +# status: Mapped[str] = mapped_column(String, nullable=False, default="In Progress") +# inputs: Mapped[Optional[str]] = mapped_column(String, nullable=True) +# outputs: Mapped[Optional[str]] = mapped_column(String, nullable=True) +# cloud_logs_url: Mapped[Optional[str]] = mapped_column(String, nullable=True) +# updated_at: Mapped[datetime] = mapped_column( +# TIMESTAMP, nullable=False, default=datetime.utcnow +# ) + +# task: Mapped[Optional["Task"]] = relationship("Task", back_populates="sub_tasks") From b20d8145f33b14c9d8a22ad0f05d058d393b03ec Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 18 Mar 2026 16:15:03 +0000 Subject: [PATCH 5/8] =?UTF-8?q?test=20get=20tast=20by=20source=20?= =?UTF-8?q?=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/tasks/router.py | 26 ++++++++++++++++---------- backend/conftest.py | 6 +++++- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/backend/app/tasks/router.py b/backend/app/tasks/router.py index 88f68762..3fca8df6 100644 --- a/backend/app/tasks/router.py +++ b/backend/app/tasks/router.py @@ -1,3 +1,5 @@ +from typing import Dict + from fastapi import APIRouter, Depends, HTTPException from uuid import UUID import json # ← REQUIRED for json.loads @@ -82,17 +84,21 @@ async def get_task_by_source( source_id: str, service: str, session: Session = Depends(get_session), -): - task = session.execute( - select(Task) - .where( - Task.source == source, - Task.source_id == source_id, - Task.service == service, +) -> Dict[str, Task]: + task = ( + session.execute( + select(Task) + .where( + Task.source == source, + Task.source_id == source_id, + Task.service == service, + ) + .order_by(Task.job_started.desc()) + .limit(1) ) - .order_by(Task.job_started.desc()) - .limit(1) - ).first() + .scalars() + .first() + ) if not task: raise HTTPException(status_code=404, detail="Task not found") diff --git a/backend/conftest.py b/backend/conftest.py index 10bfa971..5eba00ea 100644 --- a/backend/conftest.py +++ b/backend/conftest.py @@ -1,6 +1,7 @@ import pytest from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker +from sqlmodel import SQLModel from backend.app.db.base import Base @@ -24,7 +25,7 @@ def engine(postgresql): engine = create_engine(connection_string) # Create tables once per test session - Base.metadata.create_all(engine) + # Base.metadata.create_all(engine) # Yeild will split this function into two phase. 1) setup and 2) teardown, the latter of which will run after all # tests have completed @@ -46,6 +47,9 @@ def db_session(engine): connection = engine.connect() transaction = connection.begin() + Base.metadata.create_all(bind=connection) + SQLModel.metadata.create_all(bind=connection) + session = sessionmaker(bind=connection)() yield session From dd255a0de671fb53e951f1b8d99b3c3311cf1f5b Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 18 Mar 2026 16:17:06 +0000 Subject: [PATCH 6/8] typing corrections in test file --- backend/app/tests/tasks/test_get_task.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/tests/tasks/test_get_task.py b/backend/app/tests/tasks/test_get_task.py index b8e36be8..530de7a8 100644 --- a/backend/app/tests/tasks/test_get_task.py +++ b/backend/app/tests/tasks/test_get_task.py @@ -5,7 +5,7 @@ from fastapi.testclient import TestClient from sqlalchemy import select from sqlalchemy.orm import Session -from backend.app.db.connection import get_session, db_session as db_session_dependency +from backend.app.db.connection import get_session from backend.app.dependencies import validate_token from backend.app.db.models.tasks import SourceEnum, Task from backend.app.tasks.router import router From a1dba64e517cf38d6307fd43689d01c21dbed3ee Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 18 Mar 2026 16:22:36 +0000 Subject: [PATCH 7/8] delete commented out code --- backend/app/db/models/tasks.py | 65 ---------------------------------- 1 file changed, 65 deletions(-) diff --git a/backend/app/db/models/tasks.py b/backend/app/db/models/tasks.py index 058a15c5..e97a939f 100644 --- a/backend/app/db/models/tasks.py +++ b/backend/app/db/models/tasks.py @@ -63,68 +63,3 @@ class SubTask(SQLModel, table=True): updated_at: datetime = Field(default_factory=datetime.utcnow) task: Optional["Task"] = Relationship(back_populates="sub_tasks") - - -# import enum -# from typing import Optional, List -# from datetime import datetime -# from uuid import UUID, uuid4 - -# from sqlalchemy import Enum, String, ForeignKey -# from sqlalchemy.orm import Mapped, mapped_column, relationship -# from sqlalchemy.dialects.postgresql import UUID as PG_UUID, TIMESTAMP - -# from backend.app.db.base import Base - - -# class SourceEnum(enum.Enum): -# PORTFOLIO = "portfolio_id" - - -# class Task(Base): -# __tablename__ = "tasks" - -# id: Mapped[UUID] = mapped_column( -# PG_UUID(as_uuid=True), primary_key=True, default=uuid4, index=True -# ) -# task_source: Mapped[str] = mapped_column(String, nullable=False) -# job_started: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True) -# job_completed: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True) -# status: Mapped[str] = mapped_column(String, nullable=False, default="In Progress") -# service: Mapped[Optional[str]] = mapped_column(String, nullable=True) -# updated_at: Mapped[datetime] = mapped_column( -# TIMESTAMP, nullable=False, default=datetime.utcnow -# ) -# source: Mapped[Optional[SourceEnum]] = mapped_column( -# Enum( -# SourceEnum, -# name="source", -# values_callable=lambda e: [m.value for m in e], -# ), -# nullable=True, -# ) -# source_id: Mapped[Optional[str]] = mapped_column(String, nullable=True) - -# sub_tasks: Mapped[List["SubTask"]] = relationship("SubTask", back_populates="task") - - -# class SubTask(Base): -# __tablename__ = "sub_task" - -# id: Mapped[UUID] = mapped_column( -# PG_UUID(as_uuid=True), primary_key=True, default=uuid4, index=True -# ) -# task_id: Mapped[UUID] = mapped_column( -# PG_UUID(as_uuid=True), ForeignKey("tasks.id"), nullable=False -# ) -# job_started: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True) -# job_completed: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True) -# status: Mapped[str] = mapped_column(String, nullable=False, default="In Progress") -# inputs: Mapped[Optional[str]] = mapped_column(String, nullable=True) -# outputs: Mapped[Optional[str]] = mapped_column(String, nullable=True) -# cloud_logs_url: Mapped[Optional[str]] = mapped_column(String, nullable=True) -# updated_at: Mapped[datetime] = mapped_column( -# TIMESTAMP, nullable=False, default=datetime.utcnow -# ) - -# task: Mapped[Optional["Task"]] = relationship("Task", back_populates="sub_tasks") From 33a237104c3aa5d8f36ed8ffd608c3dfc0bdba60 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 18 Mar 2026 16:30:20 +0000 Subject: [PATCH 8/8] pre-review tidyup --- backend/app/tasks/router.py | 2 -- backend/conftest.py | 6 ++---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/backend/app/tasks/router.py b/backend/app/tasks/router.py index 3fca8df6..8a75c476 100644 --- a/backend/app/tasks/router.py +++ b/backend/app/tasks/router.py @@ -22,8 +22,6 @@ from backend.app.db.models.tasks import SourceEnum, Task, SubTask from sqlalchemy.orm import Session from sqlalchemy import select -# from sqlmodel import Session, select - router = APIRouter( prefix="/tasks", diff --git a/backend/conftest.py b/backend/conftest.py index 5eba00ea..df7d9cd6 100644 --- a/backend/conftest.py +++ b/backend/conftest.py @@ -25,7 +25,8 @@ def engine(postgresql): engine = create_engine(connection_string) # Create tables once per test session - # Base.metadata.create_all(engine) + Base.metadata.create_all(engine) + SQLModel.metadata.create_all(engine) # Yeild will split this function into two phase. 1) setup and 2) teardown, the latter of which will run after all # tests have completed @@ -47,9 +48,6 @@ def db_session(engine): connection = engine.connect() transaction = connection.begin() - Base.metadata.create_all(bind=connection) - SQLModel.metadata.create_all(bind=connection) - session = sessionmaker(bind=connection)() yield session