mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
working on integration test
This commit is contained in:
parent
3e0444b3a7
commit
bf3d6f4d51
22 changed files with 602 additions and 120 deletions
3
.idea/Model.iml
generated
3
.idea/Model.iml
generated
|
|
@ -10,4 +10,7 @@
|
||||||
<orderEntry type="jdk" jdkName="Fastapi-backend" jdkType="Python SDK" />
|
<orderEntry type="jdk" jdkName="Fastapi-backend" jdkType="Python SDK" />
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
</component>
|
</component>
|
||||||
|
<component name="TestRunnerService">
|
||||||
|
<option name="PROJECT_TEST_RUNNER" value="py.test" />
|
||||||
|
</component>
|
||||||
</module>
|
</module>
|
||||||
3
backend/app/db/base.py
Normal file
3
backend/app/db/base.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from sqlalchemy.orm import declarative_base
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
@ -7,9 +7,7 @@ from sqlalchemy import (
|
||||||
func,
|
func,
|
||||||
UniqueConstraint,
|
UniqueConstraint,
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import declarative_base
|
from backend.app.db.base import Base
|
||||||
|
|
||||||
Base = declarative_base()
|
|
||||||
|
|
||||||
|
|
||||||
class PostcodeSearch(Base):
|
class PostcodeSearch(Base):
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,12 @@ from sqlalchemy import (
|
||||||
String,
|
String,
|
||||||
Enum as SqlEnum,
|
Enum as SqlEnum,
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import declarative_base, relationship
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
from backend.condition.domain.aspect_type import AspectType
|
from backend.condition.domain.aspect_type import AspectType
|
||||||
from backend.condition.domain.element_type import ElementType
|
from backend.condition.domain.element_type import ElementType
|
||||||
|
|
||||||
Base = declarative_base()
|
from backend.app.db.base import Base
|
||||||
|
|
||||||
ElementTypeDb = SqlEnum(
|
ElementTypeDb = SqlEnum(
|
||||||
ElementType,
|
ElementType,
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
from sqlalchemy import Column, Integer, BigInteger, Text, Float, DateTime, Boolean, Date, ForeignKey
|
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
|
||||||
from sqlalchemy.dialects.postgresql import ENUM as PgEnum
|
|
||||||
import enum
|
import enum
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from backend.app.db.base import Base
|
||||||
Base = declarative_base()
|
from sqlalchemy import Column, Integer, BigInteger, Text, Float, DateTime, Boolean, Date, ForeignKey
|
||||||
|
from sqlalchemy.dialects.postgresql import ENUM as PgEnum
|
||||||
|
|
||||||
|
|
||||||
class EnergyAssessment(Base):
|
class EnergyAssessment(Base):
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,8 @@ from sqlalchemy import (
|
||||||
String,
|
String,
|
||||||
JSON,
|
JSON,
|
||||||
TIMESTAMP,
|
TIMESTAMP,
|
||||||
UniqueConstraint,
|
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import declarative_base
|
from backend.app.db.base import Base
|
||||||
|
|
||||||
Base = declarative_base()
|
|
||||||
|
|
||||||
|
|
||||||
class EpcStore(Base):
|
class EpcStore(Base):
|
||||||
|
|
|
||||||
|
|
@ -3,20 +3,17 @@ import enum
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
Column,
|
Column,
|
||||||
Integer,
|
Integer,
|
||||||
String,
|
|
||||||
Float,
|
Float,
|
||||||
Enum,
|
Enum,
|
||||||
TIMESTAMP,
|
TIMESTAMP,
|
||||||
BigInteger,
|
BigInteger,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import declarative_base
|
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
|
from backend.app.db.base import Base
|
||||||
from backend.app.db.models.recommendations import PlanModel
|
from backend.app.db.models.recommendations import PlanModel
|
||||||
from backend.app.db.models.materials import MaterialType, Material
|
from backend.app.db.models.materials import MaterialType, Material
|
||||||
|
|
||||||
Base = declarative_base()
|
|
||||||
|
|
||||||
|
|
||||||
class SchemeEnum(enum.Enum):
|
class SchemeEnum(enum.Enum):
|
||||||
eco4 = "eco4"
|
eco4 = "eco4"
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,9 @@ from sqlalchemy import (
|
||||||
Enum,
|
Enum,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
)
|
)
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from backend.app.db.base import Base
|
||||||
from backend.app.db.models.portfolio import PropertyModel
|
from backend.app.db.models.portfolio import PropertyModel
|
||||||
|
|
||||||
Base = declarative_base()
|
|
||||||
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------
|
# -------------------------------------------------------------------
|
||||||
# ENUM DEFINITIONS (equivalent to drizzle pgEnum calls)
|
# ENUM DEFINITIONS (equivalent to drizzle pgEnum calls)
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import enum
|
import enum
|
||||||
|
|
||||||
from sqlalchemy import Column, Integer, String, Float, Enum, TIMESTAMP, Boolean
|
from sqlalchemy import Column, Integer, String, Float, Enum, TIMESTAMP, Boolean
|
||||||
from sqlalchemy.orm import declarative_base
|
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
Base = declarative_base()
|
from backend.app.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
class MaterialType(enum.Enum):
|
class MaterialType(enum.Enum):
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
from sqlalchemy import Column, BigInteger, String, TIMESTAMP, ForeignKey, Integer
|
from sqlalchemy import Column, BigInteger, String, TIMESTAMP, ForeignKey, Integer
|
||||||
from sqlalchemy.orm import declarative_base
|
from backend.app.db.base import Base
|
||||||
|
|
||||||
Base = declarative_base()
|
|
||||||
|
|
||||||
|
|
||||||
class NonIntrusiveSurvey(Base):
|
class NonIntrusiveSurvey(Base):
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import datetime
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
Column,
|
Column,
|
||||||
Integer,
|
Integer,
|
||||||
|
BigInteger,
|
||||||
Text,
|
Text,
|
||||||
Boolean,
|
Boolean,
|
||||||
Float,
|
Float,
|
||||||
|
|
@ -12,12 +13,10 @@ from sqlalchemy import (
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
CheckConstraint,
|
CheckConstraint,
|
||||||
)
|
)
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from backend.app.db.base import Base
|
||||||
from backend.app.db.models.users import UserModel # noqa
|
from backend.app.db.models.users import UserModel # noqa
|
||||||
from backend.app.db.models.materials import MaterialType
|
from backend.app.db.models.materials import MaterialType
|
||||||
|
|
||||||
Base = declarative_base()
|
|
||||||
|
|
||||||
|
|
||||||
class PortfolioStatus(enum.Enum):
|
class PortfolioStatus(enum.Enum):
|
||||||
SCOPING = "scoping"
|
SCOPING = "scoping"
|
||||||
|
|
@ -32,7 +31,7 @@ class PortfolioStatus(enum.Enum):
|
||||||
NEEDS_REVIEW = "needs review"
|
NEEDS_REVIEW = "needs review"
|
||||||
|
|
||||||
|
|
||||||
class PortfolioGoal(enum.Enum): # TODO: Move to domain?
|
class PortfolioGoal(enum.Enum): # TODO: Move to domain?
|
||||||
VALUATION_IMPROVEMENT = "Valuation Improvement"
|
VALUATION_IMPROVEMENT = "Valuation Improvement"
|
||||||
INCREASING_EPC = "Increasing EPC"
|
INCREASING_EPC = "Increasing EPC"
|
||||||
REDUCING_CO2_EMISSIONS = "Reducing CO2 emissions"
|
REDUCING_CO2_EMISSIONS = "Reducing CO2 emissions"
|
||||||
|
|
@ -116,9 +115,9 @@ class PropertyModel(Base):
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
portfolio_id = Column(Integer, ForeignKey("portfolio.id"), nullable=False)
|
portfolio_id = Column(Integer, ForeignKey("portfolio.id"), nullable=False)
|
||||||
creation_status = Column(Enum(PropertyCreationStatus), nullable=False)
|
creation_status = Column(Enum(PropertyCreationStatus), nullable=False)
|
||||||
uprn = Column(Integer)
|
uprn = Column(BigInteger)
|
||||||
landlord_property_id = Column(Text)
|
landlord_property_id = Column(Text)
|
||||||
building_reference_number = Column(Integer)
|
building_reference_number = Column(BigInteger)
|
||||||
status = Column(
|
status = Column(
|
||||||
Enum(PortfolioStatus, values_callable=lambda x: [e.value for e in x]),
|
Enum(PortfolioStatus, values_callable=lambda x: [e.value for e in x]),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import enum
|
||||||
from typing import Iterable, List, NamedTuple, Optional, Type
|
from typing import Iterable, List, NamedTuple, Optional, Type
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
Column,
|
Column,
|
||||||
|
|
@ -9,17 +10,15 @@ from sqlalchemy import (
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Enum,
|
Enum,
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import declarative_base, Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
from backend.app.db.base import Base
|
||||||
from backend.app.db.models.portfolio import Portfolio, PortfolioGoal, PropertyModel
|
from backend.app.db.models.portfolio import Portfolio, PortfolioGoal, PropertyModel
|
||||||
from backend.app.db.models.materials import Material
|
from backend.app.db.models.materials import Material
|
||||||
from backend.app.db.models.portfolio import Epc
|
from backend.app.db.models.portfolio import Epc
|
||||||
from datatypes.enums import QuantityUnits
|
from datatypes.enums import QuantityUnits
|
||||||
import enum
|
|
||||||
|
|
||||||
Base = declarative_base()
|
|
||||||
|
|
||||||
|
|
||||||
def portfolio_goal_values(enum_cls: Type[PortfolioGoal]) -> List[str]:
|
def portfolio_goal_values(enum_cls: Type[PortfolioGoal]) -> List[str]:
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,7 @@ import datetime
|
||||||
import pytz
|
import pytz
|
||||||
from enum import Enum as PyEnum
|
from enum import Enum as PyEnum
|
||||||
from sqlalchemy import Column, Integer, Float, DateTime, JSON, BigInteger, ForeignKey, Enum, Boolean
|
from sqlalchemy import Column, Integer, Float, DateTime, JSON, BigInteger, ForeignKey, Enum, Boolean
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from backend.app.db.base import Base
|
||||||
|
|
||||||
Base = declarative_base()
|
|
||||||
|
|
||||||
|
|
||||||
class Solar(Base):
|
class Solar(Base):
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
from sqlalchemy import Column, Integer, String, DateTime
|
from sqlalchemy import Column, Integer, String, DateTime
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
|
from backend.app.db.base import Base
|
||||||
Base = declarative_base()
|
|
||||||
|
|
||||||
|
|
||||||
class UserModel(Base):
|
class UserModel(Base):
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import uuid
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from sqlmodel import SQLModel, Field
|
from sqlmodel import SQLModel, Field
|
||||||
|
|
||||||
|
|
@ -12,4 +11,4 @@ class Whlg(SQLModel, table=True):
|
||||||
index=True,
|
index=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
postcode: str = Field(nullable=False)
|
postcode: str = Field(nullable=False)
|
||||||
|
|
|
||||||
155
backend/export/README.md
Normal file
155
backend/export/README.md
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
# 🧪 Running Tests in PyCharm (macOS + pytest-postgresql)
|
||||||
|
|
||||||
|
Our test suite uses `pytest` and `pytest-postgresql`, which
|
||||||
|
automatically spins up a temporary PostgreSQL instance.
|
||||||
|
|
||||||
|
On Linux (including GitHub Actions), PostgreSQL binaries are installed
|
||||||
|
in standard system locations.\
|
||||||
|
On macOS (Homebrew), they are not --- so PyCharm needs a small
|
||||||
|
configuration tweak to locate `pg_ctl`.
|
||||||
|
|
||||||
|
This guide explains how to run and debug tests locally in PyCharm
|
||||||
|
without modifying test code.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## ✅ Prerequisites
|
||||||
|
|
||||||
|
1. Install PostgreSQL via Homebrew:
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
brew install postgresql
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Confirm `pg_ctl` exists:
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
which pg_ctl
|
||||||
|
```
|
||||||
|
|
||||||
|
Typical output:
|
||||||
|
|
||||||
|
/opt/homebrew/bin/pg_ctl
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# 🚀 Running Tests in PyCharm
|
||||||
|
|
||||||
|
## Step 1 --- Create a PyCharm pytest Run Configuration
|
||||||
|
|
||||||
|
1. Open the test file.
|
||||||
|
2. Click the green ▶ next to the test.
|
||||||
|
3. Choose **"Edit Run Configuration..."**
|
||||||
|
|
||||||
|
You should see something like:
|
||||||
|
|
||||||
|
- **Target:** `backend/export/tests/test_export.py`
|
||||||
|
- **Working directory:** Project root (e.g.`Model/`)
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Step 2 --- Add Required Override (macOS Only)
|
||||||
|
|
||||||
|
In the Run Configuration:
|
||||||
|
|
||||||
|
### ➜ "Additional Arguments"
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
--override-ini=postgresql_exec=/opt/homebrew/bin/pg_ctl
|
||||||
|
|
||||||
|
This tells `pytest-postgresql` where `pg_ctl` lives on macOS.
|
||||||
|
|
||||||
|
Without this, PyCharm may fail with:
|
||||||
|
|
||||||
|
ExecutableMissingException: Could not found pg_config executable
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Step 3 --- Run or Debug
|
||||||
|
|
||||||
|
You can now:
|
||||||
|
|
||||||
|
- Click ▶ Run\
|
||||||
|
- Click 🐞 Debug\
|
||||||
|
- Set breakpoints normally
|
||||||
|
|
||||||
|
The temporary PostgreSQL instance will start automatically.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# 🔍 Why This Is Needed
|
||||||
|
|
||||||
|
`pytest-postgresql` defaults to a Linux-style path:
|
||||||
|
|
||||||
|
/usr/lib/postgresql/<version>/bin/pg_ctl
|
||||||
|
|
||||||
|
That path exists on Ubuntu (CI), but not on macOS.
|
||||||
|
|
||||||
|
On macOS, Homebrew installs PostgreSQL in:
|
||||||
|
|
||||||
|
/opt/homebrew/bin/
|
||||||
|
|
||||||
|
The `--override-ini` flag safely overrides the executable path
|
||||||
|
**locally**, without modifying:
|
||||||
|
|
||||||
|
- test files\
|
||||||
|
- `conftest.py`\
|
||||||
|
- `pytest.ini`\
|
||||||
|
- CI configuration
|
||||||
|
|
||||||
|
This ensures:
|
||||||
|
|
||||||
|
- ✅ Tests still work in GitHub Actions\
|
||||||
|
- ✅ Tests still work for Linux users\
|
||||||
|
- ✅ macOS developers can debug in PyCharm\
|
||||||
|
- ✅ No repository-specific hacks are required
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# 🛠 Optional: Using a Local `.env` File
|
||||||
|
|
||||||
|
If you prefer not to hardcode the override in the run configuration:
|
||||||
|
|
||||||
|
1. Create a local file:
|
||||||
|
|
||||||
|
```{=html}
|
||||||
|
<!-- -->
|
||||||
|
```
|
||||||
|
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
2. Add:
|
||||||
|
|
||||||
|
```{=html}
|
||||||
|
<!-- -->
|
||||||
|
```
|
||||||
|
|
||||||
|
PYTEST_ADDOPTS=--override-ini=postgresql_exec=/opt/homebrew/bin/pg_ctl
|
||||||
|
|
||||||
|
3. In PyCharm:
|
||||||
|
- Open the Run Configuration
|
||||||
|
- Add `.env.local` under **"Paths to .env files"**
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# 🧪 Running Tests via Terminal (Recommended for CI Parity)
|
||||||
|
|
||||||
|
For normal execution outside PyCharm:
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
make test
|
||||||
|
```
|
||||||
|
|
||||||
|
These already work without additional configuration.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# 🧠 Summary
|
||||||
|
|
||||||
|
Environment Works Without Override? Needs `--override-ini`?
|
||||||
|
------------------------ ------------------------- -------------------------
|
||||||
|
GitHub Actions (Linux) ✅ Yes ❌ No
|
||||||
|
Linux local ✅ Yes ❌ No
|
||||||
|
macOS terminal (tox) ✅ Yes ❌ No
|
||||||
|
macOS PyCharm debugger ❌ No ✅ Yes
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
from typing import List, Any, Dict, Optional
|
from typing import List, Any, Dict, Optional
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from sqlalchemy import func
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
|
|
@ -95,7 +94,10 @@ class DbMethods:
|
||||||
|
|
||||||
plans_query = (
|
plans_query = (
|
||||||
self.session.query(PlanModel)
|
self.session.query(PlanModel)
|
||||||
.filter(PlanModel.is_default.is_(True))
|
.filter(
|
||||||
|
PlanModel.portfolio_id == portfolio_id,
|
||||||
|
PlanModel.is_default.is_(True)
|
||||||
|
)
|
||||||
.distinct(PlanModel.property_id)
|
.distinct(PlanModel.property_id)
|
||||||
.order_by(
|
.order_by(
|
||||||
PlanModel.property_id,
|
PlanModel.property_id,
|
||||||
|
|
@ -110,7 +112,10 @@ class DbMethods:
|
||||||
|
|
||||||
plans_query = (
|
plans_query = (
|
||||||
self.session.query(PlanModel)
|
self.session.query(PlanModel)
|
||||||
.filter(PlanModel.scenario_id.in_(scenario_ids))
|
.filter(
|
||||||
|
PlanModel.portfolio_id == portfolio_id,
|
||||||
|
PlanModel.scenario_id.in_(scenario_ids)
|
||||||
|
)
|
||||||
.distinct(
|
.distinct(
|
||||||
PlanModel.scenario_id,
|
PlanModel.scenario_id,
|
||||||
PlanModel.property_id,
|
PlanModel.property_id,
|
||||||
|
|
@ -138,6 +143,7 @@ class DbMethods:
|
||||||
def get_recommendations(self, plan_ids: List[int]) -> pd.DataFrame:
|
def get_recommendations(self, plan_ids: List[int]) -> pd.DataFrame:
|
||||||
|
|
||||||
if not plan_ids:
|
if not plan_ids:
|
||||||
|
logger.info("No plan ids provided")
|
||||||
return pd.DataFrame()
|
return pd.DataFrame()
|
||||||
|
|
||||||
recs_query = (
|
recs_query = (
|
||||||
|
|
|
||||||
|
|
@ -1,96 +1,98 @@
|
||||||
import json
|
import json
|
||||||
from typing import List, Optional, Any, Mapping
|
from typing import Optional, Any, Mapping, Dict, Union
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from backend.export.property_scenarios.input_schema import ExportRequest
|
from backend.export.property_scenarios.input_schema import ExportRequest
|
||||||
from backend.export.property_scenarios.db_functions import DbMethods
|
from backend.export.property_scenarios.db_functions import DbMethods
|
||||||
from backend.app.db.connection import db_engine
|
from backend.app.db.connection import db_read_session
|
||||||
from backend.app.utils import sap_to_epc
|
from backend.app.utils import sap_to_epc
|
||||||
from utils.logger import setup_logger
|
from utils.logger import setup_logger
|
||||||
|
|
||||||
logger = setup_logger()
|
logger = setup_logger()
|
||||||
|
|
||||||
|
|
||||||
def process_export(config: ExportRequest) -> List[str]:
|
def process_export(payload: ExportRequest, session: Session) -> Dict[Union[str, int], pd.DataFrame]:
|
||||||
exported_files: List[str] = []
|
export_files: Dict[Union[str, int], pd.DataFrame] = {}
|
||||||
|
|
||||||
with Session(bind=db_engine) as session:
|
db_methods = DbMethods(session)
|
||||||
|
|
||||||
db_methods = DbMethods(session)
|
properties_df = db_methods.get_properties(payload.portfolio_id)
|
||||||
|
|
||||||
properties_df = db_methods.get_properties(config.portfolio_id)
|
logger.info("Retrieved %s properties for export", len(properties_df))
|
||||||
|
|
||||||
plans_df = db_methods.get_latest_plans(
|
plans_df = db_methods.get_latest_plans(
|
||||||
portfolio_id=config.portfolio_id,
|
portfolio_id=payload.portfolio_id,
|
||||||
scenario_ids=config.scenario_ids,
|
scenario_ids=payload.scenario_ids,
|
||||||
default_only=config.default_plans_only,
|
default_only=payload.default_plans_only,
|
||||||
)
|
)
|
||||||
|
|
||||||
if plans_df.empty:
|
logger.info("Retrieved %s plans for export", len(plans_df))
|
||||||
return exported_files
|
|
||||||
|
|
||||||
recommendations_df = db_methods.get_recommendations(
|
if plans_df.empty:
|
||||||
plans_df["id"].tolist()
|
return export_files
|
||||||
)
|
|
||||||
|
|
||||||
recommendations_df = db_methods.attach_materials(recommendations_df)
|
recommendations_df = db_methods.get_recommendations(
|
||||||
|
plans_df["id"].tolist()
|
||||||
|
)
|
||||||
|
|
||||||
for scenario_id in config.scenario_ids:
|
recommendations_df = db_methods.attach_materials(recommendations_df)
|
||||||
|
|
||||||
|
if payload.default_plans_only:
|
||||||
|
group_keys = [None] # Single export, no scenario grouping
|
||||||
|
else:
|
||||||
|
group_keys = payload.scenario_ids
|
||||||
|
|
||||||
|
for group_key in group_keys:
|
||||||
|
|
||||||
|
if payload.default_plans_only:
|
||||||
|
scenario_recs = recommendations_df
|
||||||
|
export_label = "default_plans"
|
||||||
|
else:
|
||||||
scenario_recs = recommendations_df[
|
scenario_recs = recommendations_df[
|
||||||
recommendations_df["scenario_id"] == scenario_id
|
recommendations_df["scenario_id"] == group_key
|
||||||
]
|
]
|
||||||
|
export_label = group_key
|
||||||
|
|
||||||
if scenario_recs.empty:
|
if scenario_recs.empty:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
measures_df = scenario_recs[
|
measures_df: pd.DataFrame = scenario_recs[
|
||||||
["property_id", "measure_type", "estimated_cost"]
|
["property_id", "measure_type", "estimated_cost"]
|
||||||
].drop_duplicates()
|
].drop_duplicates()
|
||||||
|
|
||||||
pivot = measures_df.pivot(
|
pivot = measures_df.pivot(
|
||||||
index="property_id",
|
index="property_id",
|
||||||
columns="measure_type",
|
columns="measure_type",
|
||||||
values="estimated_cost",
|
values="estimated_cost",
|
||||||
).reset_index()
|
).reset_index()
|
||||||
|
|
||||||
pivot["total_retrofit_cost"] = (
|
pivot["total_retrofit_cost"] = (
|
||||||
pivot.drop(columns=["property_id"]).sum(axis=1)
|
pivot.drop(columns=["property_id"]).sum(axis=1)
|
||||||
)
|
)
|
||||||
|
|
||||||
post_sap = (
|
post_sap = (
|
||||||
scenario_recs.groupby("property_id")[["sap_points"]]
|
scenario_recs.groupby("property_id")[["sap_points"]]
|
||||||
.sum()
|
.sum()
|
||||||
.reset_index()
|
.reset_index()
|
||||||
)
|
)
|
||||||
|
|
||||||
df = (
|
df = (
|
||||||
properties_df
|
properties_df.rename(columns={"solar_pv": "existing_solar_pv"})
|
||||||
.merge(pivot, how="left", on="property_id")
|
.merge(pivot, how="left", on="property_id")
|
||||||
.merge(post_sap, how="left", on="property_id")
|
.merge(post_sap, how="left", on="property_id")
|
||||||
)
|
)
|
||||||
|
|
||||||
df["sap_points"] = df["sap_points"].fillna(0)
|
df["sap_points"] = df["sap_points"].fillna(0)
|
||||||
df["predicted_post_works_sap"] = (
|
df["predicted_post_works_sap"] = df["current_sap_points"] + df["sap_points"]
|
||||||
df["current_sap_points"] + df["sap_points"]
|
df["predicted_post_works_epc"] = df[
|
||||||
)
|
"predicted_post_works_sap"
|
||||||
df["predicted_post_works_epc"] = df[
|
].apply(sap_to_epc)
|
||||||
"predicted_post_works_sap"
|
|
||||||
].apply(sap_to_epc)
|
|
||||||
|
|
||||||
filename = (
|
export_files[export_label] = df
|
||||||
f"/tmp/{config.scenario_names[scenario_id]} - "
|
|
||||||
f"{config.project_name}.xlsx"
|
|
||||||
)
|
|
||||||
|
|
||||||
with pd.ExcelWriter(filename) as writer:
|
return export_files
|
||||||
df.to_excel(writer, sheet_name="properties", index=False)
|
|
||||||
|
|
||||||
exported_files.append(filename)
|
|
||||||
|
|
||||||
return exported_files
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
@ -106,22 +108,23 @@ def handler(event: dict, context: Optional[Any]) -> Mapping[str, int | str]:
|
||||||
3) scenario ids - list of scenario ids to export
|
3) scenario ids - list of scenario ids to export
|
||||||
4) default_plans_only - flag indicating if we should only consider default plans for export (optional,
|
4) default_plans_only - flag indicating if we should only consider default plans for export (optional,
|
||||||
defaults to False)
|
defaults to False)
|
||||||
:param event:
|
|
||||||
:param context:
|
Exxample event:
|
||||||
:return:
|
body_dict = {
|
||||||
|
"task_id": "test",
|
||||||
|
"subtask_id": "test",
|
||||||
|
"portfolio_id": 569,
|
||||||
|
"scenario_ids": [],
|
||||||
|
"default_plans_only": True,
|
||||||
|
}
|
||||||
|
:param event: Lambda event containing export request details
|
||||||
|
:param context: Lambda context (not used in this handler but included for completeness)
|
||||||
|
:return: HTTP response indicating success or failure of the export operation
|
||||||
"""
|
"""
|
||||||
for record in event.get("Records", []):
|
for record in event.get("Records", []):
|
||||||
try:
|
try:
|
||||||
body_dict = json.loads(record["body"])
|
body_dict = json.loads(record["body"])
|
||||||
|
|
||||||
# body_dict = {
|
|
||||||
# "task_id": "test",
|
|
||||||
# "subtask_id": "test",
|
|
||||||
# "portfolio_id": 569,
|
|
||||||
# "scenario_ids": [],
|
|
||||||
# "default_plans_only": True,
|
|
||||||
# }
|
|
||||||
|
|
||||||
logger.debug("Validating request body")
|
logger.debug("Validating request body")
|
||||||
payload = ExportRequest.model_validate(body_dict)
|
payload = ExportRequest.model_validate(body_dict)
|
||||||
|
|
||||||
|
|
@ -132,7 +135,8 @@ def handler(event: dict, context: Optional[Any]) -> Mapping[str, int | str]:
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.debug("Successfully validated request body")
|
logger.debug("Successfully validated request body")
|
||||||
process_export(payload)
|
with db_read_session() as session:
|
||||||
|
exported_files = process_export(payload, session)
|
||||||
|
|
||||||
# TODO: Need to handle the exported files - e.g. upload to s3 and email a presigned url
|
# TODO: Need to handle the exported files - e.g. upload to s3 and email a presigned url
|
||||||
|
|
||||||
|
|
|
||||||
55
backend/export/tests/conftest.py
Normal file
55
backend/export/tests/conftest.py
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from backend.app.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="function")
|
||||||
|
def engine(postgresql):
|
||||||
|
"""
|
||||||
|
Create a SQLAlchemy engine bound to the ephemeral
|
||||||
|
pytest-postgresql database.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Build SQLAlchemy URL from psycopg connection info
|
||||||
|
connection_string = (
|
||||||
|
f"postgresql+psycopg://"
|
||||||
|
f"{postgresql.info.user}:"
|
||||||
|
f"{postgresql.info.password}@"
|
||||||
|
f"{postgresql.info.host}:"
|
||||||
|
f"{postgresql.info.port}/"
|
||||||
|
f"{postgresql.info.dbname}"
|
||||||
|
)
|
||||||
|
|
||||||
|
engine = create_engine(connection_string)
|
||||||
|
|
||||||
|
# Create tables once per test session
|
||||||
|
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
|
||||||
|
yield engine
|
||||||
|
|
||||||
|
# Clean-up after entire test session
|
||||||
|
Base.metadata.drop_all(engine)
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="function")
|
||||||
|
def db_session(engine):
|
||||||
|
"""
|
||||||
|
Provides a clean transactional session per test.
|
||||||
|
|
||||||
|
Rolls back after each test to keep isolation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
connection = engine.connect()
|
||||||
|
transaction = connection.begin()
|
||||||
|
|
||||||
|
session = sessionmaker(bind=connection)()
|
||||||
|
|
||||||
|
yield session
|
||||||
|
|
||||||
|
session.close()
|
||||||
|
transaction.rollback()
|
||||||
|
connection.close()
|
||||||
274
backend/export/tests/test_export.py
Normal file
274
backend/export/tests/test_export.py
Normal file
|
|
@ -0,0 +1,274 @@
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
from pathlib import Path
|
||||||
|
import time
|
||||||
|
|
||||||
|
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
|
||||||
|
from utils.logger import setup_logger
|
||||||
|
|
||||||
|
FIXTURE_PATH = Path("backend/export/tests/fixtures")
|
||||||
|
logger = setup_logger()
|
||||||
|
|
||||||
|
|
||||||
|
def load_csv(name: str) -> pd.DataFrame:
|
||||||
|
df = pd.read_csv(FIXTURE_PATH / name)
|
||||||
|
df = df.replace({np.nan: None})
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_export_integration(db_session):
|
||||||
|
# ----------------------------------------
|
||||||
|
# 1) Load csvs
|
||||||
|
# ----------------------------------------
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
portfolio_df = load_csv("portfolio_569.csv")
|
||||||
|
properties_df = load_csv("properties_569.csv")
|
||||||
|
property_details_epc_df = load_csv("property_details_epc_569.csv")
|
||||||
|
plans_df = load_csv("plans_569.csv")
|
||||||
|
plan_recs_df = load_csv("plan_recs_569.csv")
|
||||||
|
recommendations_df = load_csv("recommendations_569.csv")
|
||||||
|
|
||||||
|
# Shrink down recommendations_df to speed up the data load. For this test, we only need
|
||||||
|
# default recommendations so let's focus on those. We filter on where default is true
|
||||||
|
recommendations_df = recommendations_df[
|
||||||
|
recommendations_df["default"]
|
||||||
|
]
|
||||||
|
valid_rec_ids = recommendations_df["id"].unique()
|
||||||
|
|
||||||
|
plan_recs_df = plan_recs_df[
|
||||||
|
plan_recs_df["recommendation_id"].isin(valid_rec_ids)
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Loaded CSVs in %.2f seconds | properties=%s plans=%s recs=%s",
|
||||||
|
time.perf_counter() - t0,
|
||||||
|
len(properties_df),
|
||||||
|
len(plans_df),
|
||||||
|
len(recommendations_df),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Starting database load")
|
||||||
|
db_load_t0 = time.perf_counter()
|
||||||
|
|
||||||
|
# ----------------------------------------
|
||||||
|
# 2) Insert test portfolio
|
||||||
|
# ----------------------------------------
|
||||||
|
|
||||||
|
portfolios = []
|
||||||
|
for row in portfolio_df.itertuples(index=False):
|
||||||
|
portfolios.append(
|
||||||
|
Portfolio(
|
||||||
|
id=row.id,
|
||||||
|
name=row.name,
|
||||||
|
status=PortfolioStatus[row.status.split(".")[-1]],
|
||||||
|
goal=PortfolioGoal[row.goal.split(".")[-1]] if row.goal else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
db_session.bulk_save_objects(portfolios)
|
||||||
|
db_session.flush()
|
||||||
|
# ----------------------------------------
|
||||||
|
# 3) Insert test property
|
||||||
|
# ----------------------------------------
|
||||||
|
|
||||||
|
properties = []
|
||||||
|
|
||||||
|
for row in properties_df.itertuples(index=False):
|
||||||
|
row_dict = row._asdict()
|
||||||
|
|
||||||
|
row_dict["uprn"] = int(row_dict["uprn"]) if row_dict.get("uprn") else None
|
||||||
|
row_dict["building_reference_number"] = (
|
||||||
|
int(row_dict["building_reference_number"])
|
||||||
|
if row_dict.get("building_reference_number")
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
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]
|
||||||
|
]
|
||||||
|
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]
|
||||||
|
]
|
||||||
|
|
||||||
|
properties.append(prop)
|
||||||
|
|
||||||
|
db_session.bulk_save_objects(properties)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
# ----------------------------------------
|
||||||
|
# 4) Insert property details - EPC
|
||||||
|
# ----------------------------------------
|
||||||
|
|
||||||
|
property_lookup = {
|
||||||
|
prop.uprn: prop
|
||||||
|
for prop in db_session.query(PropertyModel).all()
|
||||||
|
}
|
||||||
|
|
||||||
|
epc_rows = []
|
||||||
|
|
||||||
|
for row in property_details_epc_df.itertuples(index=False):
|
||||||
|
row_dict = row._asdict()
|
||||||
|
|
||||||
|
uprn = int(row_dict["uprn"]) if row_dict.get("uprn") else None
|
||||||
|
property_obj = property_lookup.get(uprn)
|
||||||
|
|
||||||
|
if not property_obj:
|
||||||
|
continue # skip if property not found
|
||||||
|
|
||||||
|
# Build only fields that exist on the model
|
||||||
|
epc_data = {
|
||||||
|
col.name: row_dict[col.name]
|
||||||
|
for col in PropertyDetailsEpcModel.__table__.columns
|
||||||
|
if col.name in row_dict and col.name not in ["id", "property_id", "portfolio_id"]
|
||||||
|
}
|
||||||
|
|
||||||
|
epc = PropertyDetailsEpcModel(
|
||||||
|
property_id=property_obj.id,
|
||||||
|
portfolio_id=property_obj.portfolio_id,
|
||||||
|
**epc_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
epc_rows.append(epc)
|
||||||
|
|
||||||
|
db_session.bulk_save_objects(epc_rows)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
# ----------------------------------------
|
||||||
|
# 4) Insert default plan
|
||||||
|
# ----------------------------------------
|
||||||
|
|
||||||
|
plans = []
|
||||||
|
|
||||||
|
for row in plans_df.itertuples(index=False):
|
||||||
|
row_dict = row._asdict()
|
||||||
|
|
||||||
|
if row_dict.get("post_epc_rating"):
|
||||||
|
row_dict["post_epc_rating"] = Epc[
|
||||||
|
row_dict["post_epc_rating"].split(".")[-1]
|
||||||
|
]
|
||||||
|
|
||||||
|
row_dict["scenario_id"] = None
|
||||||
|
|
||||||
|
plan = PlanModel(**{
|
||||||
|
col: row_dict[col]
|
||||||
|
for col in PlanModel.__table__.columns.keys()
|
||||||
|
if col in row_dict
|
||||||
|
})
|
||||||
|
|
||||||
|
plans.append(plan)
|
||||||
|
|
||||||
|
db_session.bulk_save_objects(plans)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
# ----------------------------------------
|
||||||
|
# 5) Insert recommendation
|
||||||
|
# ----------------------------------------
|
||||||
|
|
||||||
|
recs = [
|
||||||
|
Recommendation(**{
|
||||||
|
col: row[col]
|
||||||
|
for col in Recommendation.__table__.columns.keys()
|
||||||
|
if col in row
|
||||||
|
})
|
||||||
|
for _, row in recommendations_df.iterrows()
|
||||||
|
]
|
||||||
|
|
||||||
|
db_session.bulk_save_objects(recs)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
# ----------------------------------------
|
||||||
|
# 6) Insert PlanRecommendations
|
||||||
|
# ----------------------------------------
|
||||||
|
links = [
|
||||||
|
PlanRecommendations(
|
||||||
|
plan_id=row.plan_id,
|
||||||
|
recommendation_id=row.recommendation_id,
|
||||||
|
)
|
||||||
|
for row in plan_recs_df.itertuples(index=False)
|
||||||
|
]
|
||||||
|
|
||||||
|
db_session.bulk_save_objects(links)
|
||||||
|
db_session.commit()
|
||||||
|
logger.info("Inserted all data in %.2f seconds", time.perf_counter() - db_load_t0)
|
||||||
|
|
||||||
|
# ----------------------------------------
|
||||||
|
# 6) Build payload
|
||||||
|
# ----------------------------------------
|
||||||
|
|
||||||
|
body_dict = {
|
||||||
|
"task_id": "test",
|
||||||
|
"subtask_id": "test",
|
||||||
|
"portfolio_id": 569,
|
||||||
|
"scenario_ids": [],
|
||||||
|
"default_plans_only": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = ExportRequest.model_validate(body_dict)
|
||||||
|
|
||||||
|
# ----------------------------------------
|
||||||
|
# 7) Call process_export
|
||||||
|
# ----------------------------------------
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Recommendation count in DB: %s",
|
||||||
|
db_session.query(Recommendation).count()
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Default + not installed count: %s",
|
||||||
|
db_session.query(Recommendation)
|
||||||
|
.filter(
|
||||||
|
Recommendation.default.is_(True),
|
||||||
|
Recommendation.already_installed.is_(False)
|
||||||
|
)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Starting process_export")
|
||||||
|
process_t0 = time.perf_counter()
|
||||||
|
|
||||||
|
result = process_export(payload, session=db_session)
|
||||||
|
|
||||||
|
logger.info("process_export finished in %.2f seconds", time.perf_counter() - process_t0)
|
||||||
|
|
||||||
|
# ----------------------------------------
|
||||||
|
# 8) Assertions
|
||||||
|
# ----------------------------------------
|
||||||
|
|
||||||
|
assert "default_plans" in result
|
||||||
|
|
||||||
|
df = result["default_plans"]
|
||||||
|
|
||||||
|
assert not df.empty
|
||||||
|
|
||||||
|
# This test was generated on a real portfolio and so we check the things we expect to do
|
||||||
|
|
||||||
|
# 1) All packages are "compliant", where in this case, the properties should get to EPC C
|
||||||
|
|
||||||
|
failed = df[df["predicted_post_works_sap"] < 69]
|
||||||
|
failed_property_types = failed["property_type"].value_counts().to_dict()
|
||||||
|
assert failed_property_types["Flat"] == 113
|
||||||
|
assert failed_property_types["House"] == 8
|
||||||
|
assert failed_property_types["Bungalow"] == 4
|
||||||
|
assert failed_property_types["Maisonette"] == 1
|
||||||
|
# Check the houses
|
||||||
|
|
||||||
|
assert failed.shape[0]
|
||||||
|
|
||||||
|
# Errors for me:
|
||||||
|
# - should get to EPC C: https://ara.domna.homes/portfolio/569/building-passport/661051/plans
|
||||||
|
# - Why doesn't this get to a C, under the plan?:
|
||||||
|
# https://ara.domna.homes/portfolio/569/building-passport/660447/plans/1603913
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
[pytest]
|
[pytest]
|
||||||
pythonpath = .
|
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
|
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
|
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
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,6 @@ pytest
|
||||||
mock
|
mock
|
||||||
pytest-cov
|
pytest-cov
|
||||||
pytest-mock
|
pytest-mock
|
||||||
dotenv
|
dotenv
|
||||||
|
psycopg[binary]
|
||||||
|
pytest-postgresql
|
||||||
Loading…
Add table
Reference in a new issue