diff --git a/backend/hitas/services/owner.py b/backend/hitas/services/owner.py index 3439e2f4e..bcbf2496e 100644 --- a/backend/hitas/services/owner.py +++ b/backend/hitas/services/owner.py @@ -96,6 +96,27 @@ def obfuscate_owners_without_regulated_apartments() -> list[OwnerT]: return obfuscated_owners +def find_regulated_ownerships() -> list[Ownership]: + return list( + Ownership.objects.select_related( + "owner", + "sale__apartment__building__real_estate__housing_company__postal_code", + "sale__apartment__building__real_estate__housing_company", + ) + .filter( + sale__apartment__building__real_estate__housing_company__regulation_status=RegulationStatus.REGULATED, + ) + .exclude( + sale__apartment__building__real_estate__housing_company__hitas_type=HitasType.HALF_HITAS, + ) + .order_by( + "owner__name", + "sale__apartment__building__real_estate__housing_company__postal_code__value", + "sale__apartment__street_address", + ) + ) + + def find_owners_with_multiple_ownerships() -> list[OwnershipWithApartmentCount]: return list( Ownership.objects.select_related( diff --git a/backend/hitas/services/reports.py b/backend/hitas/services/reports.py index 464c89fb9..39912dbe5 100644 --- a/backend/hitas/services/reports.py +++ b/backend/hitas/services/reports.py @@ -20,7 +20,7 @@ HousingCompanyWithUnregulatedReportAnnotations, RegulationStatus, ) -from hitas.models.ownership import OwnershipWithApartmentCount +from hitas.models.ownership import Ownership, OwnershipWithApartmentCount from hitas.utils import format_sheet, resize_columns T = TypeVar("T") @@ -90,6 +90,16 @@ class SalesInfo(TypedDict): maximum: Decimal +class OwnershipReportColumns(NamedTuple): + owner_name: str + apartment_address: str + postal_code: str + owner_identifier: str + housing_company_name: str + housing_company_completion_date: datetime.date | str + cost_area: int | str + + class MultipleOwnershipReportColumns(NamedTuple): owner_name: str apartment_address: str @@ -713,6 +723,51 @@ def sort_sales_by_cost_area(sales: list[ApartmentSale]) -> SalesByCostArea: ) +def build_regulated_ownerships_report_excel(ownerships: list[Ownership]) -> Workbook: + workbook = Workbook() + worksheet: Worksheet = workbook.active + + column_headers = OwnershipReportColumns( + owner_name="Omistajan nimi", + apartment_address="Asunnon osoite", + postal_code="Postinumero", + owner_identifier="Omistajan henkilö- tai Y-tunnus", + housing_company_name="Yhtiön nimi", + housing_company_completion_date="Yhtiön valmistumispäivä", + cost_area="Kalleusalue", + ) + worksheet.append(column_headers) + + completion_dates_by_housing_company_id = {} + + for ownership in ownerships: + # Cache completion_date as it is the most expensive operation here + # because it queries all apartment completion dates on each iteration + # and there are relatively few housing companies. + completion_date = completion_dates_by_housing_company_id.get( + ownership.apartment.building.real_estate.housing_company.pk, None + ) + if completion_date is None: + completion_date = ownership.apartment.building.real_estate.housing_company.completion_date + completion_dates_by_housing_company_id[ + ownership.apartment.building.real_estate.housing_company.pk + ] = completion_date + worksheet.append( + OwnershipReportColumns( + owner_name=Owner.OBFUSCATED_OWNER_NAME if ownership.owner.non_disclosure else ownership.owner.name, + apartment_address=ownership.apartment.address, + postal_code=ownership.apartment.postal_code.value, + owner_identifier="" if ownership.owner.non_disclosure else ownership.owner.identifier, + housing_company_name=ownership.apartment.building.real_estate.housing_company.display_name, + housing_company_completion_date=completion_date, + cost_area=ownership.apartment.postal_code.cost_area, + ) + ) + + _basic_format_sheet(column_headers, worksheet) + return workbook + + def build_multiple_ownerships_report_excel(ownerships: list[OwnershipWithApartmentCount]) -> Workbook: workbook = Workbook() worksheet: Worksheet = workbook.active diff --git a/backend/hitas/tests/apis/test_api_reports.py b/backend/hitas/tests/apis/test_api_reports.py index 7b687ef1a..5039a492e 100644 --- a/backend/hitas/tests/apis/test_api_reports.py +++ b/backend/hitas/tests/apis/test_api_reports.py @@ -1440,6 +1440,17 @@ def test__api__multiple_ownerships_report__no_owners(api_client: HitasAPIClient) ] +@pytest.mark.django_db +def test__api__regulated_ownerships_report__no_owners(api_client: HitasAPIClient): + url = reverse("hitas:regulated-ownerships-report-list") + response: HttpResponse = api_client.get(url) + + workbook: Workbook = load_workbook(BytesIO(response.content), data_only=False) + worksheet: Worksheet = workbook.worksheets[0] + + assert len(list(worksheet.values)) == 1, "There should be only the header row" + + @pytest.mark.django_db @pytest.mark.parametrize("non_disclosure", [False, True]) def test__api__multiple_ownerships_report__single_owner(api_client: HitasAPIClient, non_disclosure): @@ -1452,7 +1463,7 @@ def test__api__multiple_ownerships_report__single_owner(api_client: HitasAPIClie ownership_2: Ownership = OwnershipFactory.create( owner=owner, sale__apartment__building__real_estate__housing_company__postal_code__value="00002", - sale__apartment__building__real_estate__housing_company__hitas_type=HitasType.NEW_HITAS_II, + sale__apartment__building__real_estate__housing_company__hitas_type=HitasType.NEW_HITAS_I, ) url = reverse("hitas:multiple-ownerships-report-list") @@ -1499,6 +1510,30 @@ def test__api__multiple_ownerships_report__single_owner(api_client: HitasAPIClie ] +@pytest.mark.django_db +@pytest.mark.parametrize("non_disclosure", [False, True]) +def test__api__regulated_ownerships_report__single_owner(api_client: HitasAPIClient, non_disclosure): + owner: Owner = OwnerFactory.create(non_disclosure=non_disclosure) + OwnershipFactory.create( + owner=owner, + sale__apartment__building__real_estate__housing_company__postal_code__value="00001", + sale__apartment__building__real_estate__housing_company__hitas_type=HitasType.NEW_HITAS_I, + ) + OwnershipFactory.create( + owner=owner, + sale__apartment__building__real_estate__housing_company__postal_code__value="00002", + sale__apartment__building__real_estate__housing_company__hitas_type=HitasType.NEW_HITAS_I, + ) + + url = reverse("hitas:regulated-ownerships-report-list") + response: HttpResponse = api_client.get(url) + + workbook: Workbook = load_workbook(BytesIO(response.content), data_only=False) + worksheet: Worksheet = workbook.worksheets[0] + + assert len(list(worksheet.values)) == 3, "There should be 2 ownership rows and 1 header row" + + @pytest.mark.django_db def test__api__multiple_ownerships_report__multiple_owners(api_client: HitasAPIClient): owner_1: Owner = OwnerFactory.create(name="Owner 1") @@ -1610,6 +1645,46 @@ def test__api__multiple_ownerships_report__multiple_owners(api_client: HitasAPIC ] +@pytest.mark.django_db +def test__api__regulated_ownerships_report__multiple_owners(api_client: HitasAPIClient): + owner_1: Owner = OwnerFactory.create(name="Owner 1") + OwnershipFactory.create( + owner=owner_1, + sale__apartment__building__real_estate__housing_company__postal_code__value="00001", + sale__apartment__building__real_estate__housing_company__hitas_type=HitasType.NEW_HITAS_I, + ) + OwnershipFactory.create( + owner=owner_1, + sale__apartment__building__real_estate__housing_company__postal_code__value="00002", + sale__apartment__building__real_estate__housing_company__hitas_type=HitasType.NEW_HITAS_I, + ) + + owner_2: Owner = OwnerFactory.create(name="Owner 2", non_disclosure=True) + OwnershipFactory.create( + owner=owner_2, + sale__apartment__building__real_estate__housing_company__postal_code__value="00001", + sale__apartment__building__real_estate__housing_company__hitas_type=HitasType.NEW_HITAS_I, + ) + OwnershipFactory.create( + owner=owner_2, + sale__apartment__building__real_estate__housing_company__postal_code__value="00002", + sale__apartment__building__real_estate__housing_company__hitas_type=HitasType.NEW_HITAS_I, + ) + OwnershipFactory.create( + owner=owner_2, + sale__apartment__building__real_estate__housing_company__postal_code__value="00003", + sale__apartment__building__real_estate__housing_company__hitas_type=HitasType.NEW_HITAS_I, + ) + + url = reverse("hitas:regulated-ownerships-report-list") + response: HttpResponse = api_client.get(url) + + workbook: Workbook = load_workbook(BytesIO(response.content), data_only=False) + worksheet: Worksheet = workbook.worksheets[0] + + assert len(list(worksheet.values)) == 6, "There should be 5 ownership rows and 1 header row" + + @pytest.mark.django_db def test__api__download_ownerships_by_housing_company(api_client: HitasAPIClient): housing_company = HousingCompanyFactory( diff --git a/backend/hitas/urls.py b/backend/hitas/urls.py index 6108ba2f7..43eb08de0 100644 --- a/backend/hitas/urls.py +++ b/backend/hitas/urls.py @@ -120,6 +120,13 @@ basename="sales-by-postal-code-and-area-report", ) +# /api/v1/reports/download-regulated-ownerships-report +router.register( + r"reports/download-regulated-ownerships-report", + views.RegulatedOwnershipsReportView, + basename="regulated-ownerships-report", +) + # /api/v1/reports/download-multiple-ownerships-report router.register( r"reports/download-multiple-ownerships-report", diff --git a/backend/hitas/views/__init__.py b/backend/hitas/views/__init__.py index d899b436d..6dbc741cd 100644 --- a/backend/hitas/views/__init__.py +++ b/backend/hitas/views/__init__.py @@ -36,6 +36,7 @@ OwnershipsByHousingCompanyReport, RegulatedHalfHitasHousingCompaniesReportView, RegulatedHousingCompaniesReportView, + RegulatedOwnershipsReportView, SalesByPostalCodeAndAreaReportView, SalesReportView, UnregulatedHousingCompaniesReportView, diff --git a/backend/hitas/views/reports.py b/backend/hitas/views/reports.py index b45fc2799..3376d66d0 100644 --- a/backend/hitas/views/reports.py +++ b/backend/hitas/views/reports.py @@ -15,12 +15,17 @@ find_regulated_housing_companies_for_reporting, find_unregulated_housing_companies_for_reporting, ) -from hitas.services.owner import find_apartments_by_housing_company, find_owners_with_multiple_ownerships +from hitas.services.owner import ( + find_apartments_by_housing_company, + find_owners_with_multiple_ownerships, + find_regulated_ownerships, +) from hitas.services.reports import ( build_housing_company_state_report_excel, build_multiple_ownerships_report_excel, build_owners_by_housing_companies_report_excel, build_regulated_housing_companies_report_excel, + build_regulated_ownerships_report_excel, build_sales_by_postal_code_and_area_report_excel, build_sales_report_excel, build_unregulated_housing_companies_report_excel, @@ -131,6 +136,16 @@ def list(self, request: Request, *args, **kwargs) -> HttpResponse: return get_excel_response(filename=filename, excel=workbook) +class RegulatedOwnershipsReportView(ViewSet): + renderer_classes = [HitasJSONRenderer, ExcelRenderer] + + def list(self, request: Request, *args, **kwargs) -> HttpResponse: + ownerships = find_regulated_ownerships() + workbook = build_regulated_ownerships_report_excel(ownerships) + filename = "Sääntelyn piirissä olevien asuntojen omistajat.xlsx" + return get_excel_response(filename=filename, excel=workbook) + + class MultipleOwnershipsReportView(ViewSet): renderer_classes = [HitasJSONRenderer, ExcelRenderer] diff --git a/backend/openapi.yaml b/backend/openapi.yaml index bfa122aa2..22e7d90a8 100644 --- a/backend/openapi.yaml +++ b/backend/openapi.yaml @@ -4130,6 +4130,27 @@ paths: "500": $ref: "#/components/responses/InternalServerError" + /api/v1/reports/download-regulated-ownerships-report: + get: + description: Download an Excel report of owners with ownerships to regulated hitas apartments + operationId: fetch-regulated-ownerships-report-excel + tags: + - Reports + responses: + '200': + description: Successfully downloaded a report of owners with ownerships to regulated hitas apartments + content: + application/vnd.openxmlformats-officedocument.spreadsheetml.sheet: + schema: + type: string + format: binary + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + /api/v1/reports/download-multiple-ownerships-report: get: description: Download an Excel report of owners with multiple ownerships to regulated hitas apartments diff --git a/frontend/src/common/services/hitasApi/reports.ts b/frontend/src/common/services/hitasApi/reports.ts index d4aa650d8..296aa68fc 100644 --- a/frontend/src/common/services/hitasApi/reports.ts +++ b/frontend/src/common/services/hitasApi/reports.ts @@ -68,6 +68,9 @@ export const downloadUnregulatedHousingCompaniesPDF = () => export const downloadHousingCompanyStatesReportPDF = () => fetchAndDownloadPDF("/reports/download-housing-company-states-report"); +export const downloadRegulatedOwnershipsReportExcel = () => + fetchAndDownloadPDF("/reports/download-regulated-ownerships-report"); + export const downloadMultipleOwnershipsReportPDF = () => fetchAndDownloadPDF("/reports/download-multiple-ownerships-report"); diff --git a/frontend/src/features/reports/components/OwnerReports.tsx b/frontend/src/features/reports/components/OwnerReports.tsx index 38c5fc59b..c573c83b8 100644 --- a/frontend/src/features/reports/components/OwnerReports.tsx +++ b/frontend/src/features/reports/components/OwnerReports.tsx @@ -1,22 +1,40 @@ import {DownloadButton, Heading} from "../../../common/components"; -import {downloadMultipleOwnershipsReportPDF} from "../../../common/services"; +import {downloadMultipleOwnershipsReportPDF, downloadRegulatedOwnershipsReportExcel} from "../../../common/services"; const OwnerReports = () => { return ( -