From 88b55484bd327e030c780b881654768d449871b8 Mon Sep 17 00:00:00 2001 From: Niko Lindroos Date: Tue, 3 Feb 2026 16:37:46 +0200 Subject: [PATCH 1/3] feat(shared): upgrade YTJ client to use v3 endpoint YJDH-804. Notice from PRH: - https://www.prh.fi/en/presentation_and_duties/uutislistaus/ announcements/2025/open-data-interface.html > Start using the new interfaces immediately. You can use the old interfaces until 31 January 2025. After that you can no longer use the service with the old interfaces. Updated the YTJ client to use v3 schema. Added README and ytj_schema.yaml to document the changes. Implemented the new interface with Python data classes to get some structure and types. --- backend/shared/shared/ytj/README.md | 52 ++ backend/shared/shared/ytj/exceptions.py | 10 + .../shared/ytj/tests/test_ytj_client_v3.py | 317 ++++++++ backend/shared/shared/ytj/ytj_client.py | 66 +- backend/shared/shared/ytj/ytj_dataclasses.py | 342 ++++++++ backend/shared/shared/ytj/ytj_schema.yaml | 727 ++++++++++++++++++ 6 files changed, 1473 insertions(+), 41 deletions(-) create mode 100644 backend/shared/shared/ytj/README.md create mode 100644 backend/shared/shared/ytj/exceptions.py create mode 100644 backend/shared/shared/ytj/tests/test_ytj_client_v3.py create mode 100644 backend/shared/shared/ytj/ytj_dataclasses.py create mode 100644 backend/shared/shared/ytj/ytj_schema.yaml diff --git a/backend/shared/shared/ytj/README.md b/backend/shared/shared/ytj/README.md new file mode 100644 index 0000000000..ea82e0e1b4 --- /dev/null +++ b/backend/shared/shared/ytj/README.md @@ -0,0 +1,52 @@ +# YTJ Client (V3) + +This module provides a pythonic interface to the [Finnish Trade Register (YTJ) Open Data API V3](https://avoindata.prh.fi/opendata-ytj-api/v3/). + +## Overview + +The client allows fetching company details using a Business ID (Y-tunnus). It handles the complexity of the V3 API response structure, parsing it into strongly-typed dataclasses and providing helper properties to access preferred values (e.g., prioritizing Finnish names and addresses). + +## Key Components + +- **`YTJClient`** (`ytj_client.py`): The main entry point. Use `get_company_info_with_business_id(business_id)` to get raw data or `get_company_data_from_ytj_data(data)` to extract a simplified dictionary for the application. +- **`YTJCompany`** (`ytj_dataclasses.py`): The root dataclass representing the API response. It contains logic for: + - Language prioritization (FI > SV > EN). + - Address prioritization (Visiting > Postal). + - Data parsing from JSON. +- **`ytj_schema.yaml`**: A local copy of the OpenAPI V3 schema used for reference and validation during development. + +## Usage + +```python +from shared.ytj.ytj_client import YTJClient + +client = YTJClient() + +# 1. Fetch data +# Returns a dict matching the JSON response +data = client.get_company_info_with_business_id("1234567-8") + +# 2. Extract simplified company info +# Returns: +# { +# "name": "Company Oy", +# "business_id": "1234567-8", +# "company_form": "Osakeyhtiö", +# "industry": "Ohjelmistokehitys", +# "street_address": "Katu 1", +# "postcode": "00100", +# "city": "HELSINKI" +# } +company_info = YTJClient.get_company_data_from_ytj_data(data) +``` + +## Configuration + +Required `django.conf.settings`: +- `YTJ_BASE_URL`: Base URL for the V3 API (e.g., `https://avoindata.prh.fi/opendata-ytj-api/v3`). +- `YTJ_TIMEOUT`: Request timeout in seconds. + +## Development + +The dataclasses in `ytj_dataclasses.py` are based on the OpenAPI specification. +See `ytj_schema.yaml` in this directory for the full schema definition. diff --git a/backend/shared/shared/ytj/exceptions.py b/backend/shared/shared/ytj/exceptions.py new file mode 100644 index 0000000000..4b8fd3816a --- /dev/null +++ b/backend/shared/shared/ytj/exceptions.py @@ -0,0 +1,10 @@ +class YTJNotFoundError(ValueError): + """ + Exception raised when no company is found in YTJ. + """ + + +class YTJParseError(ValueError): + """ + Exception raised when YTJ data cannot be parsed (missing required fields). + """ diff --git a/backend/shared/shared/ytj/tests/test_ytj_client_v3.py b/backend/shared/shared/ytj/tests/test_ytj_client_v3.py new file mode 100644 index 0000000000..96aee1d537 --- /dev/null +++ b/backend/shared/shared/ytj/tests/test_ytj_client_v3.py @@ -0,0 +1,317 @@ +from unittest.mock import Mock, patch + +import pytest +from django.conf import settings + +from shared.ytj.exceptions import YTJNotFoundError +from shared.ytj.ytj_client import YTJClient +from shared.ytj.ytj_dataclasses import ( + YTJAddressType, + YTJCompany, + YTJLanguageCode, + YTJNameType, +) + +# Sample Nokia Data (partial, reconstructed/mocked based on investigation) +MOCK_YTJ_RESPONSE = { + "totalResults": 1, + "companies": [ + { + "businessId": { + "value": "0112038-9", + "registrationDate": "1978-03-15", + "source": "3", + }, + "companyForms": [ + { + "type": "OYJ", + "descriptions": [ + {"languageCode": "1", "description": "Julkinen osakeyhtiö"}, + {"languageCode": "3", "description": "Public limited company"}, + ], + } + ], + "names": [ + { + "name": "Nokia Oyj", + "type": "1", + "registrationDate": "1997-09-01", + "version": 1, + "source": "1", + }, + { + "name": "Oy Nokia Ab", + "type": "1", + "registrationDate": "1966-06-10", + "endDate": "1997-08-31", + "version": 2, + "source": "1", + }, + ], + "mainBusinessLine": { + "descriptions": [ + {"languageCode": "3", "description": "Activities of head offices"}, + {"languageCode": "1", "description": "Pääkonttorien toiminta"}, + ] + }, + "addresses": [ + { + "type": 1, + "street": "Karakaari 7", + "postCode": "02610", + "postOffices": [ + {"city": "ESBO", "languageCode": "2"}, + {"city": "ESPOO", "languageCode": "1"}, + ], + } + ], + } + ], +} + + +@pytest.fixture +def ytj_client(): + return YTJClient() + + +@patch("requests.get") +def test_get_company_info_with_business_id(mock_get, ytj_client): + mock_response = Mock() + mock_response.json.return_value = MOCK_YTJ_RESPONSE + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + business_id = "0112038-9" + result = ytj_client.get_company_info_with_business_id(business_id) + + # Check that requests.get was called with correct URL and params + mock_get.assert_called_once() + args, kwargs = mock_get.call_args + assert args[0] == f"{settings.YTJ_BASE_URL}/companies" + assert kwargs["params"] == {"businessId": business_id} + assert kwargs["timeout"] == settings.YTJ_TIMEOUT + + assert result == MOCK_YTJ_RESPONSE + + +@patch("requests.get") +def test_get_company_info_not_found(mock_get, ytj_client): + mock_response = Mock() + mock_response.json.return_value = {"companies": []} + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + business_id = "0000000-0" + with pytest.raises( + YTJNotFoundError, + match=f"No company found in YTJ for business ID: {business_id}", + ): + ytj_client.get_company_info_with_business_id(business_id) + + +def test_get_company_data_from_ytj_data(): + data = YTJClient.get_company_data_from_ytj_data(MOCK_YTJ_RESPONSE) + + assert data["name"] == "Nokia Oyj" + assert data["business_id"] == "0112038-9" + assert data["company_form"] == "Julkinen osakeyhtiö" # FI description + assert data["industry"] == "Pääkonttorien toiminta" # FI description + assert data["street_address"] == "Karakaari 7" + assert data["postcode"] == "02610" + assert data["city"] == "ESPOO" + + +def test_get_company_data_missing_fields(): + # Test minimal data or missing fields handling + minimal_data = { + "companies": [ + { + "businessId": {"value": "1234567-8"}, + "names": [{"name": "Test Company", "type": YTJNameType.MAIN_NAME}], + "addresses": [ + { + "type": YTJAddressType.VISITING_ADDRESS, + "street": "Test street", + "postCode": "00100", + "postOffices": [ + {"city": "Helsinki", "languageCode": YTJLanguageCode.FI} + ], + } + ], + "mainBusinessLine": { + "descriptions": [ + { + "languageCode": YTJLanguageCode.FI, + "description": "Test Industry", + } + ] + }, + } + ] + } + + data = YTJClient.get_company_data_from_ytj_data(minimal_data) + assert data["name"] == "Test Company" + + +def test_get_company_data_no_companies(): + with pytest.raises(YTJNotFoundError, match="No companies found"): + YTJClient.get_company_data_from_ytj_data({}) + + +class TestYTJCompany: + @pytest.fixture + def company_data(self): + return { + "businessId": {"value": "1234567-8"}, + "names": [{"name": "Test Oy", "type": YTJNameType.MAIN_NAME}], + "companyForms": [ + { + "type": "OY", + "descriptions": [ + { + "languageCode": YTJLanguageCode.FI, + "description": "Osakeyhtiö", + }, + { + "languageCode": YTJLanguageCode.SV, + "description": "Aktiebolag", + }, + ], + } + ], + "mainBusinessLine": { + "descriptions": [ + { + "languageCode": YTJLanguageCode.FI, + "description": "Ohjelmistokehitys", + }, + { + "languageCode": YTJLanguageCode.SV, + "description": "Programvaruutveckling", + }, + ] + }, + "addresses": [ + { + "type": YTJAddressType.VISITING_ADDRESS, + "street": "Katu 1", + "postCode": "00100", + "postOffices": [ + {"city": "Helsinki", "languageCode": YTJLanguageCode.FI}, + {"city": "Helsingfors", "languageCode": YTJLanguageCode.SV}, + ], + }, + { + "type": YTJAddressType.POSTAL_ADDRESS, + "street": "Box 123", + "postCode": "00101", + "postOffices": [ + {"city": "Helsinki", "languageCode": YTJLanguageCode.FI} + ], + }, + ], + } + + def test_company_form_language_priority(self, company_data): + # Default (FI) + company = YTJCompany.from_json(company_data) + assert company.company_form == "Osakeyhtiö" + + # SV fallback + company_data["companyForms"][0]["descriptions"] = [ + {"languageCode": YTJLanguageCode.SV, "description": "Aktiebolag"} + ] + company = YTJCompany.from_json(company_data) + assert company.company_form == "Aktiebolag" + + # Type fallback + company_data["companyForms"][0]["descriptions"] = [] + company = YTJCompany.from_json(company_data) + assert company.company_form == "OY" + + def test_industry_language_priority(self, company_data): + # Default (FI) + company = YTJCompany.from_json(company_data) + assert company.industry == "Ohjelmistokehitys" + + # SV fallback + company_data["mainBusinessLine"]["descriptions"] = [ + {"languageCode": YTJLanguageCode.SV, "description": "Programvaruutveckling"} + ] + company = YTJCompany.from_json(company_data) + assert company.industry == "Programvaruutveckling" + + # Fail if no FI/SV + company_data["mainBusinessLine"]["descriptions"] = [ + {"languageCode": YTJLanguageCode.EN, "description": "Software Development"} + ] + company = YTJCompany.from_json(company_data) + with pytest.raises(ValueError, match="Company industry missing"): + _ = company.industry + + def test_address_priority(self, company_data): + # Default (Visiting Address - Type 1) + company = YTJCompany.from_json(company_data) + assert company.address["street_address"] == "Katu 1" + + # Fallback to Postal Address (Type 2) + company_data["addresses"] = [ + d + for d in company_data["addresses"] + if d["type"] == YTJAddressType.POSTAL_ADDRESS + ] + company = YTJCompany.from_json(company_data) + assert company.address["street_address"] == "Box 123" + + # Fail if no valid address + company_data["addresses"] = [] + company = YTJCompany.from_json(company_data) + with pytest.raises(ValueError, match="Company address missing"): + _ = company.address + + def test_city_language_priority(self, company_data): + # Default (FI) + company = YTJCompany.from_json(company_data) + assert company.address["city"] == "Helsinki" + + # SV Fallback (simulate only SV city available) + company_data["addresses"][0]["postOffices"] = [ + {"city": "Helsingfors", "languageCode": YTJLanguageCode.SV} + ] + company = YTJCompany.from_json(company_data) + assert company.address["city"] == "Helsingfors" + + def test_name_priority(self, company_data): + # Default (Main Name - Type 1) + company = YTJCompany.from_json(company_data) + assert company.name == "Test Oy" + + # Fallback to any name if type 1 missing + company_data["names"] = [{"name": "Secondary Name", "type": "2"}] + company = YTJCompany.from_json(company_data) + assert company.name == "Secondary Name" + + # Fail if no names + company_data["names"] = [] + company = YTJCompany.from_json(company_data) + with pytest.raises(ValueError, match="Company name missing"): + _ = company.name + + def test_parsing_with_undocumented_fields(self, company_data): + # Add a field that is NOT in our dataclass + company_data["addresses"][0]["extra_metadata_from_api"] = "some-value" + company_data["mainBusinessLine"]["new_api_feature"] = 123 + + # This should NOT raise a TypeError + company = YTJCompany.from_json(company_data) + assert company.business_id_value == "1234567-8" + + def test_root_fields_mapping(self, company_data): + company_data["status"] = "Registered" + company_data["endDate"] = "2025-01-01" + + company = YTJCompany.from_json(company_data) + assert company.status == "Registered" + assert company.endDate == "2025-01-01" diff --git a/backend/shared/shared/ytj/ytj_client.py b/backend/shared/shared/ytj/ytj_client.py index 0f62d8f8e4..d50866faea 100644 --- a/backend/shared/shared/ytj/ytj_client.py +++ b/backend/shared/shared/ytj/ytj_client.py @@ -1,10 +1,13 @@ import requests from django.conf import settings +from shared.ytj.exceptions import YTJNotFoundError +from shared.ytj.ytj_dataclasses import YTJCompany + class YTJClient: """ - https://avoindata.prh.fi/ + https://avoindata.prh.fi/opendata-ytj-api/v3/ """ def __init__(self): @@ -21,50 +24,31 @@ def get_company_data_from_ytj_data(ytj_data: dict) -> dict: """ Get the required company fields from YTJ data. """ - # Address of type 1 is the visiting address - company_address = next( - (x for x in ytj_data["addresses"] if x["type"] == 1), None - ) - - if not company_address: - # A fallback if address of type 1 does not exist - company_address = next( - (x for x in ytj_data["addresses"] if x["type"] == 2), None - ) - - if not company_address: - raise ValueError("Company address missing from YTJ data") - - # Get the Finnish name of the business line - company_industry = next( - (x for x in ytj_data["businessLines"] if x["language"] == "FI"), None - ) - - if not company_industry: - raise ValueError("Company industry missing from YTJ data") - - company_data = { - "name": ytj_data["name"], - "business_id": ytj_data["businessId"], - "company_form": ytj_data["companyForm"], - "industry": company_industry["name"], - "street_address": company_address["street"], - "postcode": company_address["postCode"], - "city": company_address["city"], + if not ytj_data or "companies" not in ytj_data or not ytj_data["companies"]: + raise YTJNotFoundError("No companies found in YTJ data") + + # Parse first company + company = YTJCompany.from_json(ytj_data["companies"][0]) + + return { + "name": company.name, + "business_id": company.business_id_value, + "company_form": company.company_form, + "industry": company.industry, + **company.address, } - return company_data - def get_company_info_with_business_id(self, business_id: str, **kwargs) -> dict: - company_info_url = f"{settings.YTJ_BASE_URL}/{business_id}" + # V3 uses query parameters + company_info_url = f"{settings.YTJ_BASE_URL}/companies" + params = {"businessId": business_id} + kwargs.update({"params": params}) ytj_data = self._get(company_info_url, **kwargs) - company_result = ytj_data["results"][0] - business_details = self._get(company_result["bisDetailsUri"]) - - company_result["businessLines"] = business_details["results"][0][ - "businessLines" - ] + if not ytj_data.get("companies"): + raise YTJNotFoundError( + f"No company found in YTJ for business ID: {business_id}" + ) - return company_result + return ytj_data diff --git a/backend/shared/shared/ytj/ytj_dataclasses.py b/backend/shared/shared/ytj/ytj_dataclasses.py new file mode 100644 index 0000000000..36cfd3e363 --- /dev/null +++ b/backend/shared/shared/ytj/ytj_dataclasses.py @@ -0,0 +1,342 @@ +""" +Dataclasses representing the company data structure returned by the YTJ V3 API. +Includes logic for parsing the API response and extracting preferred fields +(e.g., Finnish names/addresses). + +Full API documentation: https://avoindata.prh.fi/fi/ytj/swagger-ui +Local Schema Reference: ytj_schema.yaml (OpenAPI 3.0) +""" + +# flake8: noqa: N815 +from dataclasses import dataclass, field +from enum import IntEnum, StrEnum +from typing import List, Optional + +from shared.ytj.exceptions import YTJParseError + + +class YTJLanguageCode(StrEnum): + FI = "1" + SV = "2" + EN = "3" + + +class YTJNameType(StrEnum): + MAIN_NAME = "1" + + +class YTJAddressType(IntEnum): + VISITING_ADDRESS = 1 + POSTAL_ADDRESS = 2 + + +@dataclass +class YTJDescription: + """Description object with language code.""" + + languageCode: str + description: str + + +@dataclass +class YTJName: + """Company name details.""" + + name: str + type: str + registrationDate: Optional[str] = None + version: Optional[int] = None + source: Optional[str] = None + endDate: Optional[str] = None + + +@dataclass +class YTJBusinessId: + """Business ID details.""" + + value: str + registrationDate: Optional[str] = None + source: Optional[str] = None + + +@dataclass +class YTJCompanyForm: + """Company form (legal entity type) details.""" + + type: Optional[str] = None + version: Optional[int] = None + source: Optional[str] = None + registrationDate: Optional[str] = None + endDate: Optional[str] = None + descriptions: List[YTJDescription] = field(default_factory=list) + + +@dataclass +class YTJPostOffice: + """Post office details including city name.""" + + city: str + languageCode: Optional[str] = None + municipalityCode: Optional[str] = None + + +@dataclass +class YTJAddress: + """Address details.""" + + type: int + registrationDate: Optional[str] = None + source: Optional[str] = None + street: Optional[str] = None + postCode: Optional[str] = None + postOffices: List[YTJPostOffice] = field(default_factory=list) + buildingNumber: Optional[str] = None + entrance: Optional[str] = None + apartmentNumber: Optional[str] = None + apartmentIdSuffix: Optional[str] = None + co: Optional[str] = None + postOfficeBox: Optional[str] = None + country: Optional[str] = None + freeAddressLine: Optional[str] = None + + +@dataclass +class YTJBusinessLine: + """Main business line (industry) details.""" + + source: Optional[str] = None + registrationDate: Optional[str] = None + type: Optional[str] = None + descriptions: List[YTJDescription] = field(default_factory=list) + typeCodeSet: Optional[str] = None + + +@dataclass +class YTJRegisteredEntry: + """Registered entry details.""" + + type: str + register: str + authority: str + registrationDate: Optional[str] = None + endDate: Optional[str] = None + descriptions: List[YTJDescription] = field(default_factory=list) + + +def _parse_descriptions(d_list: list) -> List[YTJDescription]: + return [YTJDescription(**d) for d in d_list] if d_list else [] + + +def _parse_company_forms(data: list) -> List[YTJCompanyForm]: + company_forms = [] + for cf in data: + # Create a copy to avoid mutating original dict if reused + cf = cf.copy() + desc = _parse_descriptions(cf.pop("descriptions", [])) + company_forms.append(YTJCompanyForm(descriptions=desc, **cf)) + return company_forms + + +def _parse_addresses(data: list) -> List[YTJAddress]: + addresses = [] + for addr in data: + addr = addr.copy() + po_list = [YTJPostOffice(**po) for po in addr.pop("postOffices", [])] + + # Safe unpacking helper + known_fields = { + "type", + "registrationDate", + "source", + "street", + "postCode", + "buildingNumber", + "entrance", + "apartmentNumber", + "apartmentIdSuffix", + "co", + "postOfficeBox", + "country", + "freeAddressLine", + } + addr_data = {k: v for k, v in addr.items() if k in known_fields} + + addresses.append(YTJAddress(postOffices=po_list, **addr_data)) + return addresses + + +def _parse_registered_entries(data: list) -> List[YTJRegisteredEntry]: + registered_entries = [] + for re in data: + re = re.copy() + desc = _parse_descriptions(re.pop("descriptions", [])) + registered_entries.append(YTJRegisteredEntry(descriptions=desc, **re)) + return registered_entries + + +def _parse_main_business_line(data: dict) -> Optional[YTJBusinessLine]: + if not data: + return None + data = data.copy() + desc = _parse_descriptions(data.pop("descriptions", [])) + + # Whitelist fields for BusinessLine to prevent TypeError from undocumented API + # fields + known_fields = {"source", "registrationDate", "type", "typeCodeSet"} + line_data = {k: v for k, v in data.items() if k in known_fields} + + return YTJBusinessLine(descriptions=desc, **line_data) + + +@dataclass +class YTJCompany: + """ + Root object for a company in the YTJ API response. + Contains methods to parse JSON and properties to extract preferred display values. + """ + + businessId: YTJBusinessId + names: List[YTJName] = field(default_factory=list) + addresses: List[YTJAddress] = field(default_factory=list) + companyForms: List[YTJCompanyForm] = field(default_factory=list) + mainBusinessLine: Optional[YTJBusinessLine] = None + # Add other fields as optional if needed (euId, website, etc) + euId: Optional[dict] = None + website: Optional[dict] = None + companySituations: List[dict] = field(default_factory=list) + registeredEntries: List[YTJRegisteredEntry] = field(default_factory=list) + tradeRegisterStatus: Optional[str] = None + status: Optional[str] = None + registrationDate: Optional[str] = None + endDate: Optional[str] = None + lastModified: Optional[str] = None + + @classmethod + def from_json(cls, data: dict) -> "YTJCompany": + """ + Parse raw JSON data from YTJ V3 API into a YTJCompany object. + """ + processed = { + "businessId": YTJBusinessId(**data.get("businessId", {})), + "names": [YTJName(**n) for n in data.get("names", [])], + "companyForms": _parse_company_forms(data.get("companyForms", [])), + "addresses": _parse_addresses(data.get("addresses", [])), + "mainBusinessLine": _parse_main_business_line(data.get("mainBusinessLine")), + "registeredEntries": _parse_registered_entries( + data.get("registeredEntries", []) + ), + } + return cls(**{**data, **processed}) + + def _get_preferred_translation( + self, + items: list, + attr_name: str, + preferred_langs: Optional[List[YTJLanguageCode]] = None, + ) -> Optional[str]: + """ + Returns the first available translation for the given attribute name + based on the provided language priority. + """ + if preferred_langs is None: + preferred_langs = [YTJLanguageCode.FI, YTJLanguageCode.SV] + + for lang in preferred_langs: + match = next( + (getattr(i, attr_name) for i in items if i.languageCode == lang), + None, + ) + if match: + return match + return None + + @property + def business_id_value(self) -> str: + """Returns the business ID string.""" + if not self.businessId or not self.businessId.value: + raise YTJParseError("Business ID missing from YTJ data") + return self.businessId.value + + @property + def name(self) -> str: + """ + Returns the preferred company name. + Prioritizes the main name (type '1'). Falls back to the first available name. + """ + # Name: prefer type 1 (main name?), fallback to first available + name = next( + (x.name for x in self.names if x.type == YTJNameType.MAIN_NAME), None + ) + if not name and self.names: + name = self.names[0].name + + if not name: + raise YTJParseError("Company name missing from YTJ data") + return name + + @property + def company_form(self) -> str: + """ + Returns the company form as a string description (e.g., "Osakeyhtiö"). + Prioritizes Finnish description. Falls back to type code. + """ + company_form_obj = self.companyForms[0] if self.companyForms else None + company_form = company_form_obj.type if company_form_obj else None + + # Try to find description in preferred languages + if company_form_obj: + translation = self._get_preferred_translation( + company_form_obj.descriptions, "description" + ) + if translation: + company_form = translation + return company_form + + @property + def industry(self) -> str: + """ + Returns the main industry/business line description. + Prioritizes Finnish, then Swedish. + """ + mbl = self.mainBusinessLine + if not mbl: + raise YTJParseError("Company industry missing from YTJ data") + + descriptions = mbl.descriptions + industry = self._get_preferred_translation(descriptions, "description") + + if not industry: + raise YTJParseError("Company industry missing from YTJ data") + return industry + + @property + def address(self) -> dict: + """ + Returns a dict with street_address, postcode, and city. + Prioritizes Visiting Address (type 1), then Postal Address (type 2). + City is selected from postOffices list (prioritizing Finnish). + """ + addresses = self.addresses + # Type 1 is visiting address, Type 2 is postal + company_address = next( + (x for x in addresses if x.type == YTJAddressType.VISITING_ADDRESS), + next( + (x for x in addresses if x.type == YTJAddressType.POSTAL_ADDRESS), None + ), + ) + + if not company_address: + raise YTJParseError("Company address missing from YTJ data") + + # Safely extract city: Prioritize preferred languages, then anything available. + city = None + if company_address.postOffices: + city = self._get_preferred_translation(company_address.postOffices, "city") + if not city: + city = company_address.postOffices[0].city + + return { + "street_address": company_address.street, + "postcode": company_address.postCode, + "city": city, + } diff --git a/backend/shared/shared/ytj/ytj_schema.yaml b/backend/shared/shared/ytj/ytj_schema.yaml new file mode 100644 index 0000000000..2b0349544e --- /dev/null +++ b/backend/shared/shared/ytj/ytj_schema.yaml @@ -0,0 +1,727 @@ +--- +openapi: 3.0.1 +info: + title: Opendata YTJ-Api + version: 2.0 + license: + name: Creative Commons Nimeä 4.0 + url: https://creativecommons.org/licenses/by/4.0/ +servers: + - url: https://avoindata.prh.fi/opendata-ytj-api/v3 +paths: + /companies: + get: + summary: "Hae yrityksiä toiminimen, postitoimipaikan Y-tunnuksen tai yritysmuodon perusteella" + operationId: GetCompanies + parameters: + - in: query + name: name + description: "Toiminimi (haku tehdään nykyiseen ja edelliseen toiminimeen, rinnakkaistoiminimeen ja aputoiminimeen)" + schema: + type: string + - in: query + name: location + description: "Postitoimipaikka" + schema: + type: string + - in: query + name: businessId + description: "Y-tunnus" + schema: + type: string + - in: query + name: companyForm + description: > + Yritysmuoto + schema: + type: string + enum: [ "AOY", "ASH", "ASY", "AY", "AYH", "ETS", "ETY", "SCE", "SCP", "HY", "KOY", "KVJ", "KVY", "KY", "OK", + "OP", "OY", "OYJ", "SE", "SL", "SP", "SÄÄ", "TYH", "VOJ", "VOY", "VY", "VALTLL"] + - in: query + name: mainBusinessLine + description: > + Päätoimiala (Hae joko Tilastokeskuksen toimialakoodilla tai tekstillä) + schema: + type: string + - in: query + name: registrationDateStart + description: "Yrityksen rekisteröintipäivä aikavälinä (kirjoita tähän alkamispäivä muodossa vvvv-kk-pp)" + schema: + type: string + format: date + minLength: 10 + maxLength: 10 + - in: query + name: registrationDateEnd + description: "Yrityksen rekisteröintipäivä aikavälinä (kirjoita tähän päättymispäivä muodossa vvvv-kk-pp)" + schema: + type: string + format: date + minLength: 10 + maxLength: 10 + - in: query + name: postCode + description: "Käynti- tai postiosoitteen postinumero" + schema: + type: string + - in: query + name: businessIdRegistrationStart + description: "Y-tunnuksen antamispäivä aikavälinä (Kirjoita tähän alkamispäivä muodossa vvvv-kk-pp)" + schema: + type: string + format: date + minLength: 10 + maxLength: 10 + - in: query + name: businessIdRegistrationEnd + description: "Y-tunnuksen antamispäivä aikavälinä (kirjoita tähän päättymispäivä muodossa vvvv-kk-pp)" + schema: + type: string + format: date + minLength: 10 + maxLength: 10 + - in: query + name: page + description: "Jos haku palauttaa yli 100 tulosta, tulokset palautetaan sivutettuina. Page -parametrilla voit kertoa haluamasi tulossivun. Jos page -parametria ei ole annettu tai se osoittaa sivulle, jota tuloksista ei löydy, palautetaan ensimmäisen sivun tulokset (esimerkiksi jos haet sivua 5 ja tuloksissa on vain 3 sivua)." + schema: + type: integer + format: int32 + responses: + 200: + description: "OK" + content: + application/json: + schema: + $ref: '#/components/schemas/CompanyResult' + 400: + description: "Virheellinen pyyntö" + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + 429: + description: "Liian monta pyyntöä" + content: + text/plain: + schema: + type: string + 500: + description: "Sisäinen palvelinvirhe" + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + 503: + description: "Palvelin ei voi käsitellä pyyntöä" + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /all_companies: + get: + summary: "Hae kaikki kaupparekisterissä olevat ja vireillä olevat yritykset JSON tiedostona (päivitetään kerran päivässä)" + operationId: GetAllCompanies + responses: + 200: + description: "OK" + content: + application/zip: + schema: + type: string + format: binary + 400: + description: "Virheellinen pyyntö" + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + 429: + description: "Liian monta pyyntöä" + content: + text/plain: + schema: + type: string + 500: + description: "Sisäinen palvelinvirhe" + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + 503: + description: "Palvelin ei voi käsitellä pyyntöä" + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /description: + get: + summary: "Hae koodiston tiedot" + operationId: GetDescription + parameters: + - in: query + name: code + required: true + description: "Koodi" + schema: + type: string + enum: [ "YRMU", "REK_KDI", "TLAJI", "SELTILA", "REK", "VIRANOM", "TLAHDE", "KIELI", "TOIMI", "TOIMI2", "TOIMI3", "TOIMI4", "KONK", "SANE", "STATUS3", "SELTILA,SANE,KONK" ] + - in: query + name: lang + required: true + description: "Kielitunnus" + schema: + type: string + enum: [ "en", "fi", "sv" ] + responses: + 200: + description: "OK" + content: + text/plain: + schema: + type: string + 400: + description: "Virheellinen pyyntö" + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + 429: + description: "Liian monta pyyntöä" + content: + text/plain: + schema: + type: string + 500: + description: "Sisäinen palvelinvirhe" + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + 503: + description: "Palvelin ei voi käsitellä pyyntöä" + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /post_codes: + get: + summary: "Hae postikoodiston tiedot" + operationId: GetPostCodes + parameters: + - in: query + name: lang + required: true + description: "Kielitunnus" + schema: + type: string + enum: [ "en", "fi", "sv" ] + responses: + 200: + description: "OK" + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/PostOfficeEntry' + 400: + description: "Virheellinen pyyntö" + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + 429: + description: "Liian monta pyyntöä" + content: + text/plain: + schema: + type: string + 500: + description: "Sisäinen palvelinvirhe" + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + 503: + description: "Palvelin ei voi käsitellä pyyntöä" + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' +components: + schemas: + CompanyResult: + type: object + description: "Yrityksen tai yhteisön tulokset" + required: ["totalResults", "companies"] + properties: + totalResults: + type: integer + format: int64 + description: "Kokonaistulosten määrä" + companies: + type: array + description: "Yrityksen tai yhteisön tiedot" + items: + $ref: '#/components/schemas/Company' + Company: + type: object + description: "Yrityksen tai yhteisön tiedot" + required: ["businessId", "registeredEntries", "tradeRegisterStatus", "lastModified"] + properties: + businessId: + type: object + required: ["value", "source"] + description: "Y-tunnus, esim 0116297-6" + properties: + value: + type: string + description: "Y-tunnus" + minLength: 9 + maxLength: 9 + registrationDate: + type: string + nullable: true + format: date + description: "Y-tunnuksen antamispäivä" + minLength: 10 + maxLength: 10 + source: + $ref: '#/components/schemas/Source' + euId: + type: object + required: ["value", "source"] + description: "EUID-tunnus, esim FIFPRO.0116297-6" + properties: + value: + type: string + description: "EUID tunnus" + minLength: 16 + maxLength: 16 + source: + $ref: '#/components/schemas/Source' + names: + type: array + description: "Yrityksen nimet; päätoiminimi, rinnakkaistoiminimet ja aputoiminimet" + items: + $ref: '#/components/schemas/RegisterName' + minItems: 0 + mainBusinessLine: + type: object + required: ["type", "source"] + description: "Päätoimiala" + properties: + type: + type: string + description: "Toimialakoodi" + minLength: 2 + maxLength: 5 + descriptions: + type: array + description: "Toimialan kuvaukset" + items: + $ref: '#/components/schemas/DescriptionEntry' + minItems: 0 + typeCodeSet: + type: string + description: "Toimialaluokituksen koodistot TOIMI, TOIMI2, TOIMI3, TOIMI4" + minLength: 5 + maxLength: 6 + registrationDate: + type: string + nullable: true + format: date + description: "Toimialakoodin alkupäivämäärä muodossa vvvv-kk-pp" + minLength: 10 + maxLength: 10 + source: + $ref: '#/components/schemas/Source' + website: + type: object + required: ["url", "source"] + description: "Yrityksen verkkosivut" + properties: + url: + type: string + description: "Verkkosivut" + minLength: 0 + maxLength: 255 + registrationDate: + type: string + nullable: true + format: date + minLength: 10 + maxLength: 10 + source: + $ref: '#/components/schemas/Source' + companyForms: + type: array + description: "Yritysmuoto ja edellinen tieto, jos se on olemassa" + items: + $ref: '#/components/schemas/CompanyForm' + minItems: 0 + maxItems: 2 + companySituations: + type: array + description: "Yrityksen tilanne (mahdollinen saneeraus, selvitystila tai konkurssi)" + items: + $ref: '#/components/schemas/CompanySituation' + minItems: 0 + registeredEntries: + type: array + description: "Yrityksen rekisterimerkinnät" + items: + $ref: '#/components/schemas/RegisteredEntry' + minItems: 0 + addresses: + type: array + description: "Yrityksen käynti- tai postiosoite" + items: + $ref: '#/components/schemas/Address' + minItems: 0 + maxItems: 2 + tradeRegisterStatus: + type: string + description: > + [Yrityksen kaupparekisterin tilatieto](/opendata-ytj-api/v3/description?code=REK_KDI&lang=fi) + status: + type: string + description: > + [Yrityksen y-tunnuksen tila](/opendata-ytj-api/v3/description?code=STATUS3&lang=fi) + registrationDate: + type: string + nullable: true + format: date + description: "Yrityksen rekisteröintipäivämäärä muodossa vvvv-kk-pp" + minLength: 10 + maxLength: 10 + endDate: + type: string + nullable: true + minLength: 10 + maxLength: 10 + format: date + description: "Lakkaamispäivä muodossa vvvv-kk-pp" + lastModified: + type: string + minLength: 19 + maxLength: 19 + format: date-time + x-field-extra-annotation: "@com.fasterxml.jackson.annotation.JsonFormat(shape = com.fasterxml.jackson.annotation.JsonFormat.Shape.STRING, pattern = \"yyyy-MM-ddTHH:mm:ss\")" + description: "Viimeksi muokattu sekuntitasolla ilman aikavyöhykettä muodossa 'vvvv-kk-ppT00:00:00'" + Address: + type: object + description: "Osoitetiedot" + required: ["type", "source"] + properties: + type: + type: integer + format: int32 + description: "Osoitteen laji, käyntiosoite: 1, postiosoite: 2. " + street: + type: string + nullable: true + description: "Katuosoite" + minLength: 0 + maxLength: 50 + postCode: + type: string + nullable: true + description: "Postinumero" + minLength: 0 + maxLength: 5 + postOffices: + type: array + description: "Postitoimipaikka eri kielillä" + items: + $ref: '#/components/schemas/PostOffice' + minItems: 0 + postOfficeBox: + type: string + nullable: true + description: "Postilokero" + minLength: 0 + maxLength: 5 + buildingNumber: + type: string + nullable: true + description: "Talo" + minLength: 0 + maxLength: 13 + entrance: + type: string + nullable: true + description: "Porras" + minLength: 0 + maxLength: 13 + apartmentNumber: + type: string + nullable: true + description: "Huoneisto" + minLength: 0 + maxLength: 4 + apartmentIdSuffix: + type: string + nullable: true + description: "Jakokirjain" + minLength: 0 + maxLength: 1 + co: + type: string + nullable: true + description: "Osoitteen c/o-tieto" + minLength: 0 + maxLength: 34 + country: + type: string + nullable: true + description: "Kaksikirjaiminen maakoodi" + minLength: 0 + maxLength: 2 + freeAddressLine: + type: string + nullable: true + description: "Vapaamuotoinen osoite esim. ulkomaalaiselle osoitteelle. Rivinvaihdot korvattu välilyönnillä, välilyönnit korvattu alaviivalla esim Norgårdsvägen_3 _ SE-451_75 Uddevalla" + minLength: 0 + maxLength: 1000 + registrationDate: + type: string + nullable: true + format : date + description: "Rekisteröintipäivämäärä muodossa vvvv-kk-pp" + minLength: 10 + maxLength: 10 + source: + $ref: '#/components/schemas/Source' + RegisterName: + type: object + description: "Nimitiedot" + required: ["name", "type", "version", "source"] + properties: + name: + type: string + description: "Toiminimi" + minLength: 0 + maxLength: 1000 + type: + type: string + description: > + [Nimen tyyppi](/opendata-ytj-api/v3/description?code=TLAJI&lang=fi) + minLength: 1 + maxLength: 8 + registrationDate: + type: string + nullable: true + format: date + description: "Rekisteröintipäivämäärä muodossa vvvv-kk-pp" + minLength: 10 + maxLength: 10 + endDate: + type: string + nullable: true + format: date + description: "Rekisteröinnin loppupäivämäärä muodossa vvvv-kk-pp" + minLength: 10 + maxLength: 10 + version: + type: integer + format: int32 + description: "Versio, 1 nykyinen versio, muut numerot aiempia versioita" + source: + $ref: '#/components/schemas/Source' + CompanyForm: + type: object + required: ["type", "version", "source"] + description: "Yritysmuoto" + properties: + type: + type: string + description: > + [Yritysmuodon koodi](/opendata-ytj-api/v3/description?code=YRMU&lang=fi) + minLength: 1 + maxLength: 8 + descriptions: + type: array + description: "Yritysmuodon kuvaukset" + items: + $ref: '#/components/schemas/DescriptionEntry' + minItems: 0 + registrationDate: + type: string + nullable: true + format: date + description: "Rekisteröintipäivämäärä muodossa vvvv-kk-pp" + minLength: 10 + maxLength: 10 + endDate: + type: string + nullable: true + format: date + description: "Rekisteröinnin loppupäivämäärä muodossa vvvv-kk-pp" + minLength: 10 + maxLength: 10 + version: + type: integer + format: int32 + description: "Versio, 1 nykyinen versio, muut numerot aiempia versioita" + source: + $ref: '#/components/schemas/Source' + CompanySituation: + type: object + required: ["type", "source"] + description: "Yrityksen tilanne (mahdollinen saneeraus, selvitystila tai konkurssi)" + properties: + type: + type: string + description: > + [Yrityksen tilanteen koodi](/opendata-ytj-api/v3/description?code=SELTILA,SANE,KONK&lang=fi) + enum: ["SANE", "SELTILA", "KONK"] + registrationDate: + type: string + nullable: true + format: date + description: "Rekisteröintipäivämäärä muodossa vvvv-kk-pp" + minLength: 10 + maxLength: 10 + endDate: + type: string + nullable: true + format: date + description: "Rekisteröinnin loppupäivämäärä muodossa vvvv-kk-pp" + minLength: 10 + maxLength: 10 + source: + $ref: '#/components/schemas/Source' + RegisteredEntry: + type: object + required: ["type", "register", "authority"] + description: "Rekisterimerkinnät" + properties: + type: + type: string + description: > + [Yrityksen rekisteröintitilan koodi](/opendata-ytj-api/v3/description?code=REK_KDI&lang=fi). Tulkittava Koodistosta REK_KDI yhdistämällä rekisterimerkinnän koodi rekisterikoodiin erotettuna alaviivalla, esim '1_0' on rekisteröimätön kaupparekisterissä + minLength: 1 + maxLength: 8 + descriptions: + type: array + description: "Rekisteröintitilan kuvaukset" + items: + $ref: '#/components/schemas/DescriptionEntry' + minItems: 0 + registrationDate: + type: string + nullable: true + format: date + description: "Rekisteröintipäivämäärä muodossa vvvv-kk-pp" + minLength: 10 + maxLength: 10 + endDate: + type: string + nullable: true + format: date + description: "Rekisteröinnin loppupäivämäärä muodossa vvvv-kk-pp" + minLength: 10 + maxLength: 10 + register: + type: string + description: > + [Rekisterin koodi](/opendata-ytj-api/v3/description?code=REK&lang=fi) + minLength: 1 + maxLength: 8 + authority: + type: string + description: > + [Viranomaisen koodi](/opendata-ytj-api/v3/description?code=VIRANOM&lang=fi) + minLength: 1 + maxLength: 8 + Source: + type: string + description: > + Tietolähde + DescriptionEntry: + type: object + required: ["languageCode"] + description: "Tiedon kuvaus" + properties: + languageCode: + type: string + description: "Kielikoodi, 1 - Suomi, 2 - Ruotsi, 3 - Englanti" + maxLength: 2 + description: + type: string + description: "Koodiseloste" + nullable: true + maxLength: 255 + PostOffice: + type: object + required: ["city", "languageCode"] + description: "Postiosoitteet" + properties: + city: + type: string + description: "Postitoimipaikka" + maxLength: 50 + languageCode: + type: string + description: > + [Kielikoodi](/opendata-ytj-api/v3/description?code=KIELI&lang=fi) + maxLength: 8 + municipalityCode: + type: string + description: "Kuntakoodi" + nullable: true + minLength: 3 + maxLength: 3 + PostOfficeEntry: + type: object + required: ["postCode", "city", "active", "languageCode"] + description: "Postiosoitteet" + properties: + postCode: + type: string + description: "Postinumero" + minLength: 5 + maxLength: 5 + city: + type: string + description: "Postitoimipaikka" + maxLength: 50 + active: + type: boolean + description: "Onko tieto aktiivinen" + languageCode: + type: string + description: > + [Kielikoodi](/opendata-ytj-api/v3/description?code=KIELI&lang=fi) + maxLength: 8 + municipalityCode: + type: string + description: "Kuntakoodi" + nullable: true + minLength: 3 + maxLength: 3 + ErrorResponse: + type: object + required: [ "timestamp", "code" ] + description: "Virheilmoitus" + properties: + timestamp: + type: string + minLength: 19 + maxLength: 19 + format: date-time + x-field-extra-annotation: "@com.fasterxml.jackson.annotation.JsonFormat(shape = com.fasterxml.jackson.annotation.JsonFormat.Shape.STRING, pattern = \"yyyy-MM-dd HH:mm:ss\")" + description: "Aika sekuntitasolla ilman aikavyöhykettä muodossa 'vvvv-kk-pp 00:00:00'" + message: + type: string + description: "Virheviesti" + maxLength: 1000 + errorcode: + type: integer + format: int32 + description: "Virheen koodi" + +externalDocs: + description: "Lisätiedot Swagger-ohjelmistosta" + url: 'http://swagger.io' \ No newline at end of file From f918a1a3d820b7af0670b487c1754c446396f5b9 Mon Sep 17 00:00:00 2001 From: Niko Lindroos Date: Tue, 3 Feb 2026 16:39:00 +0200 Subject: [PATCH 2/3] feat(kesaseteli): upgrade YTJ client to use v3 endpoint YJDH-804. Update to use YTJ client's PRH API v3. Notice from PRH: - https://www.prh.fi/en/presentation_and_duties/uutislistaus/ announcements/2025/open-data-interface.html > Start using the new interfaces immediately. You can use the old interfaces until 31 January 2025. After that you can no longer use the service with the old interfaces. Also improved logging and error handling. --- backend/kesaseteli/companies/services.py | 40 +- .../companies/tests/data/company_data.py | 735 ++---------------- .../companies/tests/test_company_api.py | 61 +- backend/kesaseteli/kesaseteli/settings.py | 2 +- 4 files changed, 116 insertions(+), 722 deletions(-) diff --git a/backend/kesaseteli/companies/services.py b/backend/kesaseteli/companies/services.py index b718d8607a..c5fe709c75 100644 --- a/backend/kesaseteli/companies/services.py +++ b/backend/kesaseteli/companies/services.py @@ -9,6 +9,7 @@ from companies.models import Company from companies.tests.data.company_data import DUMMY_ORG_ROLES from shared.oidc.utils import get_organization_roles +from shared.ytj.exceptions import YTJNotFoundError, YTJParseError from shared.ytj.ytj_client import YTJClient LOGGER = logging.getLogger(__name__) @@ -21,9 +22,15 @@ def get_or_create_company_using_company_data( Get or create a company instance using a dict of the company data and attach the ytj_data json for the instance. """ - company, _ = Company.objects.get_or_create( + company, created = Company.objects.get_or_create( **company_data, defaults={"ytj_json": ytj_data} ) + if created: + LOGGER.info( + f"Created company {company.name} ({company.business_id}) using YTJ data" + ) + else: + LOGGER.info(f"Found company {company.name} ({company.business_id})") return company @@ -44,9 +51,13 @@ def get_or_create_company_with_name_and_business_id( business_id: str, ) -> Company: """ - Get or create a company instance using a dict of the company data and - attach the ytj_data json for the instance. + Get or create a company instance using the company name and business_id. + This is used as a fallback when YTJ data is not available. """ + LOGGER.info( + f"Creating/getting company {name} ({business_id}) " + "using fallback method (no YTJ data)" + ) company, _ = Company.objects.get_or_create( name=name, business_id=business_id, @@ -78,6 +89,10 @@ def handle_mock_company(request: HttpRequest): if not company: company = create_mock_company_and_store_org_roles_in_session(request) + LOGGER.info( + f"Using mock company data: {company.name} ({company.business_id}). " + f"Organization roles: {org_roles}" + ) return company @@ -105,7 +120,10 @@ def get_or_create_company_using_organization_roles(request: HttpRequest) -> Comp try: organization_roles = get_organization_roles(request) - except RequestException: + except RequestException as e: + LOGGER.error( + f"Unable to fetch organization roles from eauthorizations API: {e}" + ) raise NotFound( detail="Unable to fetch organization roles from eauthorizations API" ) @@ -117,13 +135,15 @@ def get_or_create_company_using_organization_roles(request: HttpRequest) -> Comp if not company: try: company = get_or_create_company_from_ytj_api(business_id) - except ValueError: + except YTJNotFoundError as e: + LOGGER.warning(f"YTJ API error for business_id {business_id}: {str(e)}") raise NotFound(detail="Could not handle the response from YTJ API") - except RequestException: - LOGGER.warning( - "YTJ API is under heavy load or no company found with the given" - f" business id: {business_id}" - ) + except RequestException as e: + LOGGER.error(f"YTJ API connection error for business_id {business_id}: {e}") + name = organization_roles.get("name") + company = get_or_create_company_with_name_and_business_id(name, business_id) + except YTJParseError as e: + LOGGER.error(f"YTJ API parsing error for business_id {business_id}: {e}") name = organization_roles.get("name") company = get_or_create_company_with_name_and_business_id(name, business_id) diff --git a/backend/kesaseteli/companies/tests/data/company_data.py b/backend/kesaseteli/companies/tests/data/company_data.py index 31e63cc134..a2f3478bbc 100644 --- a/backend/kesaseteli/companies/tests/data/company_data.py +++ b/backend/kesaseteli/companies/tests/data/company_data.py @@ -2,7 +2,7 @@ "id": "8c0c7a56-cb98-4c31-87ca-6f1853253986", "name": "I. Haanpää Oy", "business_id": "0877830-0", - "company_form": "OY", + "company_form": "Osakeyhtiö", "industry": "Taloustavaroiden vähittäiskauppa", "street_address": "Vasaratie 4 A 3", "postcode": "65350", @@ -19,714 +19,85 @@ DUMMY_YTJ_RESPONSE = { - "type": "fi.prh.opendata.tr", - "version": "1", - "totalResults": -1, - "resultsFrom": 0, - "previousResultsUri": None, - "nextResultsUri": None, - "exceptionNoticeUri": None, - "results": [ + "companies": [ { - "businessId": "0877830-0", - "name": "I. Haanpää Oy", - "registrationDate": "1992-01-29", - "companyForm": "OY", - "detailsUri": "http://avoindata.prh.fi/opendata/tr/v1/0877830-0", - "bisDetailsUri": "http://avoindata.prh.fi/opendata/bis/v1/0877830-0", - "language": "FI", - "latestRegistrationDate": "2021-01-12", - "checkDate": None, - "names": [ + "businessId": { + "value": "0877830-0", + "registrationDate": "1992-01-29", + "source": "3", + }, + "companyForms": [ { - "order": 0, - "name": "I. Haanpää Oy", + "type": "OY", + "descriptions": [ + {"languageCode": "1", "description": "Osakeyhtiö"}, + {"languageCode": "2", "description": "Aktiebolag"}, + {"languageCode": "3", "description": "Limited company"}, + ], "registrationDate": "1999-03-31", - "endDate": None, - "language": None, - } - ], - "auxiliaryNames": [], - "companyForms": [{"type": "OY", "registrationDate": "1999-03-31"}], - "addresses": [ - { - "street": "Vasaratie 4 A 3", - "postCode": "65350", - "type": 2, - "city": "Vaasa", - "country": "FI", - "website": None, - "phone": "0400 665254", - "fax": None, - "registrationDate": "2020-07-22", - "endDate": None, + "version": 1, + "source": "1", } ], - "publicNotices": [ - { - "recordNumber": "2020/55730X", - "registrationDate": "2021-01-01", - "typeOfRegistration": "TA", - "entryCodes": ["TASE"], - "detailsUri": ( - "http://avoindata.prh.fi/opendata/tr/v1/publicnotices/2020/55730X" - ), - }, - { - "recordNumber": "2020/290401", - "registrationDate": "2020-06-15", - "typeOfRegistration": "M", - "entryCodes": [""], - "detailsUri": ( - "http://avoindata.prh.fi/opendata/tr/v1/publicnotices/2020/290401" - ), - }, - { - "recordNumber": "2019/53986T", - "registrationDate": "2019-12-05", - "typeOfRegistration": "TA", - "entryCodes": ["TASE"], - "detailsUri": ( - "http://avoindata.prh.fi/opendata/tr/v1/publicnotices/2019/53986T" - ), - }, - { - "recordNumber": "2019/250695", - "registrationDate": "2019-07-09", - "typeOfRegistration": "M", - "entryCodes": ["HAL"], - "detailsUri": ( - "http://avoindata.prh.fi/opendata/tr/v1/publicnotices/2019/250695" - ), - }, - { - "recordNumber": "2018/50210V", - "registrationDate": "2018-10-19", - "typeOfRegistration": "TA", - "entryCodes": ["TASE"], - "detailsUri": ( - "http://avoindata.prh.fi/opendata/tr/v1/publicnotices/2018/50210V" - ), - }, - { - "recordNumber": "2017/46683V", - "registrationDate": "2017-10-21", - "typeOfRegistration": "TA", - "entryCodes": ["TASE"], - "detailsUri": ( - "http://avoindata.prh.fi/opendata/tr/v1/publicnotices/2017/46683V" - ), - }, - { - "recordNumber": "2016/86280V", - "registrationDate": "2016-11-03", - "typeOfRegistration": "TA", - "entryCodes": ["TASE"], - "detailsUri": ( - "http://avoindata.prh.fi/opendata/tr/v1/publicnotices/2016/86280V" - ), - }, - { - "recordNumber": "2015/739492", - "registrationDate": "2015-11-04", - "typeOfRegistration": "M", - "entryCodes": ["TILTAR"], - "detailsUri": ( - "http://avoindata.prh.fi/opendata/tr/v1/publicnotices/2015/739492" - ), - }, - { - "recordNumber": "2015/84509U", - "registrationDate": "2015-10-08", - "typeOfRegistration": "TA", - "entryCodes": ["TASE"], - "detailsUri": ( - "http://avoindata.prh.fi/opendata/tr/v1/publicnotices/2015/84509U" - ), - }, - ], - "registeredOffices": [ - { - "registeredOffice": "Vaasa", - "language": "EN", - "registrationDate": "2005-01-25", - "endDate": None, - }, - { - "registeredOffice": "Vasa", - "language": "SE", - "registrationDate": "2005-01-25", - "endDate": None, - }, - { - "registeredOffice": "Vaasa", - "language": "FI", - "registrationDate": "2005-01-25", - "endDate": None, - }, - ], - } - ], -} - - -DUMMY_YTJ_BUSINESS_DETAILS_RESPONSE = { - "type": "fi.prh.opendata.bis", - "version": "1", - "totalResults": -1, - "resultsFrom": 0, - "previousResultsUri": None, - "nextResultsUri": None, - "exceptionNoticeUri": None, - "results": [ - { - "businessId": "0877830-0", - "name": "I. Haanpää Oy", - "registrationDate": "1992-03-03", - "companyForm": "OY", - "detailsUri": None, - "liquidations": [], "names": [ { - "order": 0, - "version": 1, "name": "I. Haanpää Oy", + "type": "1", "registrationDate": "1999-03-31", - "endDate": None, - "source": 1, + "version": 1, + "source": "1", }, { - "order": 0, - "version": 2, "name": "I. Haanpää Ky", + "type": "2", "registrationDate": "1992-01-29", "endDate": "1999-03-30", - "source": 1, + "version": 2, + "source": "1", }, ], - "auxiliaryNames": [], "addresses": [ { - "careOf": None, + "type": 1, "street": "Vasaratie 4 A 3", "postCode": "65350", - "type": 1, - "version": 2, - "city": "VAASA", - "country": None, - "registrationDate": "2013-12-16", - "endDate": "2013-12-18", - "language": "FI", - "source": 0, + "postOffices": [ + {"city": "Vaasa", "languageCode": "1"}, + {"city": "Vasa", "languageCode": "2"}, + ], + "registrationDate": "2020-07-22", + "source": "1", }, { - "careOf": None, + "type": 2, "street": "PL 327", "postCode": "65101", - "type": 2, - "version": 2, - "city": "VAASA", - "country": None, + "postOffices": [ + {"city": "Vaasa", "languageCode": "1"}, + ], "registrationDate": "2013-12-16", "endDate": "2020-07-21", - "language": "FI", - "source": 0, - }, - { - "careOf": None, - "street": "", - "postCode": None, - "type": 1, - "version": 1, - "city": None, - "country": None, - "registrationDate": "2013-12-18", - "endDate": None, - "language": "FI", - "source": 0, - }, - { - "careOf": None, - "street": "Vasaratie 4 A 3", - "postCode": "65350", - "type": 2, - "version": 1, - "city": "VAASA", - "country": None, - "registrationDate": "2020-07-22", - "endDate": None, - "language": "FI", - "source": 0, - }, - ], - "companyForms": [ - { - "version": 1, - "name": "Osakeyhtiö", - "type": "OY", - "registrationDate": "1999-03-31", - "endDate": None, - "language": "FI", - "source": 1, - }, - { - "version": 1, - "name": "Aktiebolag", - "type": "AB", - "registrationDate": "1999-03-31", - "endDate": None, - "language": "SE", - "source": 1, - }, - { - "version": 1, - "name": "Limited company", - "type": None, - "registrationDate": "1999-03-31", - "endDate": None, - "language": "EN", - "source": 1, - }, - ], - "businessLines": [ - { - "order": 0, - "version": 1, - "code": "47594", - "name": "Retail sale of household articles", - "registrationDate": "2007-12-31", - "endDate": None, - "language": "EN", - "source": 2, - }, - { - "order": 0, - "version": 1, - "code": "47594", - "name": "Taloustavaroiden vähittäiskauppa", - "registrationDate": "2007-12-31", - "endDate": None, - "language": "FI", - "source": 2, - }, - { - "order": 0, - "version": 1, - "code": "47594", - "name": "Specialiserad butikshandel med hushållsartiklar", - "registrationDate": "2007-12-31", - "endDate": None, - "language": "SE", - "source": 2, - }, - ], - "languages": [ - { - "version": 1, - "name": "Finnish", - "registrationDate": "1992-03-03", - "endDate": None, - "language": "EN", - "source": 0, - }, - { - "version": 1, - "name": "Suomi", - "registrationDate": "1992-03-03", - "endDate": None, - "language": "FI", - "source": 0, - }, - { - "version": 1, - "name": "Finska", - "registrationDate": "1992-03-03", - "endDate": None, - "language": "SE", - "source": 0, - }, - ], - "registedOffices": [ - { - "order": 0, - "version": 1, - "name": "VAASA", - "registrationDate": "2005-01-25", - "endDate": None, - "language": "EN", - "source": 0, - }, - { - "order": 0, - "version": 1, - "name": "VAASA", - "registrationDate": "2005-01-25", - "endDate": None, - "language": "FI", - "source": 0, - }, - { - "order": 0, - "version": 1, - "name": "VASA", - "registrationDate": "2005-01-25", - "endDate": None, - "language": "SE", - "source": 0, - }, - { - "order": 0, - "version": 2, - "name": "VÄHÄKYRÖ", - "registrationDate": "1999-03-31", - "endDate": "2005-01-24", - "language": "EN", - "source": 0, - }, - { - "order": 0, - "version": 2, - "name": "VÄHÄKYRÖ", - "registrationDate": "1999-03-31", - "endDate": "2005-01-24", - "language": "FI", - "source": 0, - }, - { - "order": 0, - "version": 2, - "name": "LILLKYRO", - "registrationDate": "1999-03-31", - "endDate": "2005-01-24", - "language": "SE", - "source": 0, - }, - ], - "contactDetails": [ - { - "version": 1, - "value": "0400 665254", - "type": "Matkapuhelin", - "registrationDate": "2005-01-11", - "endDate": None, - "language": "FI", - "source": 0, - }, - { - "version": 1, - "value": "0400 665254", - "type": "Mobiltelefon", - "registrationDate": "2005-01-11", - "endDate": None, - "language": "SE", - "source": 0, - }, - { - "version": 1, - "value": "0400 665254", - "type": "Mobile phone", - "registrationDate": "2005-01-11", - "endDate": None, - "language": "EN", - "source": 0, - }, - { - "version": 2, - "value": "06 3129811", - "type": "Puhelin", - "registrationDate": "2005-01-11", - "endDate": "2020-07-21", - "language": "FI", - "source": 0, - }, - { - "version": 2, - "value": "06 3129811", - "type": "Telefon", - "registrationDate": "2005-01-11", - "endDate": "2020-07-21", - "language": "SE", - "source": 0, - }, - { - "version": 2, - "value": "06 3129811", - "type": "Telephone", - "registrationDate": "2005-01-11", - "endDate": "2020-07-21", - "language": "EN", - "source": 0, - }, - { - "version": 2, - "value": "06 9129812", - "type": "Faksi", - "registrationDate": "2005-01-11", - "endDate": "2020-07-21", - "language": "FI", - "source": 0, - }, - { - "version": 2, - "value": "06 9129812", - "type": "Fax", - "registrationDate": "2005-01-11", - "endDate": "2020-07-21", - "language": "SE", - "source": 0, - }, - { - "version": 2, - "value": "06 9129812", - "type": "Fax", - "registrationDate": "2005-01-11", - "endDate": "2020-07-21", - "language": "EN", - "source": 0, - }, - { - "version": 1, - "value": "", - "type": "Puhelin", - "registrationDate": "2020-07-22", - "endDate": None, - "language": "FI", - "source": 0, - }, - { - "version": 1, - "value": "", - "type": "Telefon", - "registrationDate": "2020-07-22", - "endDate": None, - "language": "SE", - "source": 0, - }, - { - "version": 1, - "value": "", - "type": "Telephone", - "registrationDate": "2020-07-22", - "endDate": None, - "language": "EN", - "source": 0, - }, - { - "version": 1, - "value": "", - "type": "Faksi", - "registrationDate": "2020-07-22", - "endDate": None, - "language": "FI", - "source": 0, - }, - { - "version": 1, - "value": "", - "type": "Fax", - "registrationDate": "2020-07-22", - "endDate": None, - "language": "SE", - "source": 0, - }, - { - "version": 1, - "value": "", - "type": "Fax", - "registrationDate": "2020-07-22", - "endDate": None, - "language": "EN", - "source": 0, - }, - ], - "registeredEntries": [ - { - "authority": 1, - "register": 4, - "status": 1, - "registrationDate": "1992-03-03", - "endDate": None, - "statusDate": "2020-06-03", - "language": "FI", - "description": "Rekisterissä", - }, - { - "authority": 1, - "register": 4, - "status": 1, - "registrationDate": "1992-03-03", - "endDate": None, - "statusDate": "2020-06-03", - "language": "SE", - "description": "Registrerad", - }, - { - "authority": 1, - "register": 4, - "status": 1, - "registrationDate": "1992-03-03", - "endDate": None, - "statusDate": "2020-06-03", - "language": "EN", - "description": "Registered", - }, - { - "authority": 1, - "register": 5, - "status": 1, - "registrationDate": "1995-03-01", - "endDate": None, - "statusDate": "2017-03-16", - "language": "FI", - "description": "Rekisterissä", - }, - { - "authority": 1, - "register": 5, - "status": 1, - "registrationDate": "1995-03-01", - "endDate": None, - "statusDate": "2017-03-16", - "language": "SE", - "description": "Registrerad", - }, - { - "authority": 1, - "register": 5, - "status": 1, - "registrationDate": "1995-03-01", - "endDate": None, - "statusDate": "2017-03-16", - "language": "EN", - "description": "Registered", - }, - { - "authority": 1, - "register": 6, - "status": 1, - "registrationDate": "1994-06-01", - "endDate": None, - "statusDate": "2008-07-21", - "language": "FI", - "description": "Liiketoiminnasta arvonlisäverovelvollinen", - }, - { - "authority": 1, - "register": 6, - "status": 1, - "registrationDate": "1994-06-01", - "endDate": None, - "statusDate": "2008-07-21", - "language": "SE", - "description": "Momsskyldig för rörelseverksamhet", - }, - { - "authority": 1, - "register": 6, - "status": 1, - "registrationDate": "1994-06-01", - "endDate": None, - "statusDate": "2008-07-21", - "language": "EN", - "description": "VAT-liable for business activity", - }, - { - "authority": 1, - "register": 6, - "status": 1, - "registrationDate": "2008-08-01", - "endDate": None, - "statusDate": "2008-07-21", - "language": "FI", - "description": "Kiinteistön käyttöoikeuden luovuttamisesta", - }, - { - "authority": 1, - "register": 6, - "status": 1, - "registrationDate": "2008-08-01", - "endDate": None, - "statusDate": "2008-07-21", - "language": "SE", - "description": ( - "För överlåtelse av nyttjanderätten till en fastighet" - ), - }, - { - "authority": 1, - "register": 6, - "status": 1, - "registrationDate": "2008-08-01", - "endDate": None, - "statusDate": "2008-07-21", - "language": "EN", - "description": ( - "VAT-obliged for the transfer of rights to use immovable" - " property" - ), - }, - { - "authority": 1, - "register": 7, - "status": 1, - "registrationDate": "1992-03-01", - "endDate": None, - "statusDate": "2001-03-30", - "language": "FI", - "description": "Rekisterissä", - }, - { - "authority": 1, - "register": 7, - "status": 1, - "registrationDate": "1992-03-01", - "endDate": None, - "statusDate": "2001-03-30", - "language": "SE", - "description": "Registrerad", - }, - { - "authority": 1, - "register": 7, - "status": 1, - "registrationDate": "1992-03-01", - "endDate": None, - "statusDate": "2001-03-30", - "language": "EN", - "description": "Registered", - }, - { - "authority": 2, - "register": 1, - "status": 1, - "registrationDate": "1992-01-29", - "endDate": None, - "statusDate": "1992-01-29", - "language": "FI", - "description": "Rekisterissä", - }, - { - "authority": 2, - "register": 1, - "status": 1, - "registrationDate": "1992-01-29", - "endDate": None, - "statusDate": "1992-01-29", - "language": "SE", - "description": "Registrerad", - }, - { - "authority": 2, - "register": 1, - "status": 1, - "registrationDate": "1992-01-29", - "endDate": None, - "statusDate": "1992-01-29", - "language": "EN", - "description": "Registered", + "source": "1", }, ], - "businessIdChanges": [], + "mainBusinessLine": { + "descriptions": [ + { + "languageCode": "1", + "description": "Taloustavaroiden vähittäiskauppa", + }, + { + "languageCode": "2", + "description": "Specialiserad butikshandel med hushållsartiklar", + }, + { + "languageCode": "3", + "description": "Retail sale of household articles", + }, + ], + "registrationDate": "2007-12-31", + "source": "2", + }, } - ], + ] } diff --git a/backend/kesaseteli/companies/tests/test_company_api.py b/backend/kesaseteli/companies/tests/test_company_api.py index 5d2f410876..c82c0114c0 100644 --- a/backend/kesaseteli/companies/tests/test_company_api.py +++ b/backend/kesaseteli/companies/tests/test_company_api.py @@ -10,7 +10,6 @@ from companies.models import Company from companies.tests.data.company_data import ( DUMMY_COMPANY_DATA, - DUMMY_YTJ_BUSINESS_DETAILS_RESPONSE, DUMMY_YTJ_RESPONSE, ) @@ -19,18 +18,11 @@ def get_company_api_url(): return "/v1/company/" -def set_up_mock_requests( - ytj_response: dict, business_details_response: dict, requests_mock -): +def set_up_mock_requests(ytj_response: dict, requests_mock): """ Set up the mock responses. """ - business_id = ytj_response["results"][0]["businessId"] - ytj_url = f"{settings.YTJ_BASE_URL}/{business_id}" - business_details_url = ytj_response["results"][0]["bisDetailsUri"] - - requests_mock.get(ytj_url, json=ytj_response) - requests_mock.get(business_details_url, json=business_details_response) + requests_mock.get(f"{settings.YTJ_BASE_URL}/companies", json=ytj_response) @pytest.mark.django_db @@ -56,9 +48,10 @@ def test_get_mock_company_not_found_from_ytj(api_client): for field in [ f for f in Company._meta.fields - if f.name not in ["id", "name", "business_id", "organization_type", "ytj_json"] + if f.name + not in ["id", "name", "business_id", "ytj_json", "created_at", "modified_at"] ]: - assert response.data[field.name] == "" + assert response.data.get(field.name, "") == "" @pytest.mark.django_db @@ -68,7 +61,7 @@ def test_get_mock_company_not_found_from_ytj(api_client): EAUTHORIZATIONS_CLIENT_ID="test", EAUTHORIZATIONS_CLIENT_SECRET="test", ) -def test_get_company_organization_roles_error(api_client, requests_mock, user): +def test_get_company_organization_roles_error(api_client, requests_mock, user, caplog): session = api_client.session session.pop("organization_roles") session.save() @@ -83,12 +76,13 @@ def test_get_company_organization_roles_error(api_client, requests_mock, user): response.data["detail"] == "Unable to fetch organization roles from eauthorizations API" ) + assert "Unable to fetch organization roles from eauthorizations API" in caplog.text @pytest.mark.django_db @override_settings( NEXT_PUBLIC_MOCK_FLAG=False, - YTJ_BASE_URL="http://example.com", + YTJ_BASE_URL="http://example.com/v3", ) def test_get_company_from_ytj(api_client, requests_mock): session = api_client.session @@ -97,9 +91,7 @@ def test_get_company_from_ytj(api_client, requests_mock): Company.objects.all().delete() - set_up_mock_requests( - DUMMY_YTJ_RESPONSE, DUMMY_YTJ_BUSINESS_DETAILS_RESPONSE, requests_mock - ) + set_up_mock_requests(DUMMY_YTJ_RESPONSE, requests_mock) org_roles_json = { "name": "Activenakusteri Oy", @@ -120,7 +112,8 @@ def test_get_company_from_ytj(api_client, requests_mock): assert response.data == company_data assert ( - response.data["business_id"] == DUMMY_YTJ_RESPONSE["results"][0]["businessId"] + response.data["business_id"] + == DUMMY_YTJ_RESPONSE["companies"][0]["businessId"]["value"] ) for field in ["company_form", "industry", "street_address", "postcode", "city"]: assert response.data[field] @@ -129,7 +122,7 @@ def test_get_company_from_ytj(api_client, requests_mock): @pytest.mark.django_db @override_settings( NEXT_PUBLIC_MOCK_FLAG=False, - YTJ_BASE_URL="http://example.com", + YTJ_BASE_URL="http://example.com/v3", ) def test_get_company_not_found_from_ytj(api_client, requests_mock, user): matcher = re.compile(re.escape(settings.YTJ_BASE_URL)) @@ -154,23 +147,23 @@ def test_get_company_not_found_from_ytj(api_client, requests_mock, user): for field in [ f for f in Company._meta.fields - if f.name not in ["id", "name", "business_id", "organization_type", "ytj_json"] + if f.name + not in ["id", "name", "business_id", "ytj_json", "created_at", "modified_at"] ]: - assert response.data[field.name] == "" + assert response.data.get(field.name, "") == "" @pytest.mark.django_db @override_settings( NEXT_PUBLIC_MOCK_FLAG=False, - YTJ_BASE_URL="http://example.com", + YTJ_BASE_URL="http://example.com/v3", ) -def test_get_company_from_ytj_invalid_response(api_client, requests_mock, user): +def test_get_company_from_ytj_invalid_response(api_client, requests_mock, user, caplog): ytj_reponse = copy.deepcopy(DUMMY_YTJ_RESPONSE) - ytj_reponse["results"][0]["addresses"] = [] + # Simulate invalid data: empty companies list or no addresses + ytj_reponse["companies"][0]["addresses"] = [] - set_up_mock_requests( - ytj_reponse, DUMMY_YTJ_BUSINESS_DETAILS_RESPONSE, requests_mock - ) + set_up_mock_requests(ytj_reponse, requests_mock) org_roles_json = { "name": "Activenakusteri Oy", @@ -184,5 +177,15 @@ def test_get_company_from_ytj_invalid_response(api_client, requests_mock, user): ): response = api_client.get(get_company_api_url()) - assert response.status_code == 404 - assert response.data["detail"] == "Could not handle the response from YTJ API" + assert response.status_code == 200 + assert response.data["name"] == org_roles_json["name"] + assert response.data["business_id"] == org_roles_json["identifier"] + assert "YTJ API parsing error for business_id" in caplog.text + + for field in [ + f + for f in Company._meta.fields + if f.name + not in ["id", "name", "business_id", "ytj_json", "created_at", "modified_at"] + ]: + assert response.data.get(field.name, "") == "" diff --git a/backend/kesaseteli/kesaseteli/settings.py b/backend/kesaseteli/kesaseteli/settings.py index 90cbe34a73..d059be17b1 100644 --- a/backend/kesaseteli/kesaseteli/settings.py +++ b/backend/kesaseteli/kesaseteli/settings.py @@ -58,7 +58,7 @@ CSRF_COOKIE_DOMAIN=(str, "localhost"), CSRF_TRUSTED_ORIGINS=(list, []), CSRF_COOKIE_NAME=(str, "yjdhcsrftoken"), - YTJ_BASE_URL=(str, "http://avoindata.prh.fi/opendata/tr/v1"), + YTJ_BASE_URL=(str, "https://avoindata.prh.fi/opendata-ytj-api/v3"), YTJ_TIMEOUT=(int, 30), NEXT_PUBLIC_MOCK_FLAG=(bool, False), SESSION_COOKIE_AGE=(int, 60 * 60 * 2), From 5dab8a6d20ada8d5cb9339d180bb722b34237ce6 Mon Sep 17 00:00:00 2001 From: Niko Lindroos Date: Thu, 5 Feb 2026 12:56:41 +0200 Subject: [PATCH 3/3] fix(kesaseteli): add organization_type to organisation serializer YJDH-804. To fix issues in companies API, add a common `organization_type` field to serializer. --- .../applications/tests/test_security_with_non_staff_user.py | 2 ++ backend/kesaseteli/companies/api/v1/serializers.py | 1 + 2 files changed, 3 insertions(+) diff --git a/backend/kesaseteli/applications/tests/test_security_with_non_staff_user.py b/backend/kesaseteli/applications/tests/test_security_with_non_staff_user.py index 20963a00e0..08407ea77d 100644 --- a/backend/kesaseteli/applications/tests/test_security_with_non_staff_user.py +++ b/backend/kesaseteli/applications/tests/test_security_with_non_staff_user.py @@ -862,6 +862,7 @@ def test_company_openly_accessible_to_non_staff_user(user_client): street_address="Test street 1", postcode="00100", city="Test city", + organization_type="company", ) set_company_business_id_to_client(company, user_client) @@ -877,6 +878,7 @@ def test_company_openly_accessible_to_non_staff_user(user_client): "name": "Test company", "postcode": "00100", "street_address": "Test street 1", + "organization_type": "company", } diff --git a/backend/kesaseteli/companies/api/v1/serializers.py b/backend/kesaseteli/companies/api/v1/serializers.py index 8312c18b29..f2f353b031 100644 --- a/backend/kesaseteli/companies/api/v1/serializers.py +++ b/backend/kesaseteli/companies/api/v1/serializers.py @@ -10,6 +10,7 @@ class Meta: "id", "name", "business_id", + "organization_type", "company_form", "industry", "street_address",