Skip to content
Snippets Groups Projects
Verified Commit 35e17e93 authored by Karel van Klink's avatar Karel van Klink :smiley_cat:
Browse files

move types into their respective product blocks and types

parent af7e59b5
No related branches found
No related tags found
No related merge requests found
Pipeline #84225 failed
Showing
with 199 additions and 202 deletions
...@@ -3,6 +3,9 @@ Glossary of terms ...@@ -3,6 +3,9 @@ Glossary of terms
.. glossary:: .. glossary::
API
Application Programming Interface
BGP BGP
Border Gateway Protocol: a path vector routing protocol described in Border Gateway Protocol: a path vector routing protocol described in
`RFC 4271 <https://datatracker.ietf.org/doc/html/rfc4271>`_. `RFC 4271 <https://datatracker.ietf.org/doc/html/rfc4271>`_.
......
``gso.products`` ``gso.products``
================ ================
.. automodule:: gso.products
:members:
:show-inheritance:
Subpackages Subpackages
----------- -----------
......
import ipaddress
from typing import Any from typing import Any
from uuid import UUID from uuid import UUID
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from fastapi.routing import APIRouter from fastapi.routing import APIRouter
from orchestrator.security import opa_security_default from orchestrator.security import opa_security_default
from orchestrator.services import processes, subscriptions from orchestrator.services import processes
from orchestrator.services import subscriptions as wfo_subscriptions
from pydantic import BaseModel, root_validator, validator
from sqlalchemy.exc import MultipleResultsFound from sqlalchemy.exc import MultipleResultsFound
from gso.utils.types.imports import ImportResponseModel, IptrunkImportModel, RouterImportModel, SiteImportModel from gso.products.product_blocks.iptrunk import IptrunkType, PhyPortCapacity
from gso.products.product_blocks.router import RouterRole, RouterVendor
from gso.products.product_blocks.site import SiteTier
from gso.services import subscriptions
from gso.services.crm import CustomerNotFoundError, get_customer_by_name
from gso.workflows.iptrunk.utils import LAGMember
router = APIRouter(prefix="/imports", tags=["Imports"], dependencies=[Depends(opa_security_default)]) router = APIRouter(prefix="/imports", tags=["Imports"], dependencies=[Depends(opa_security_default)])
class ImportResponseModel(BaseModel):
pid: UUID
detail: str
class SiteImportModel(BaseModel):
site_name: str
site_city: str
site_country: str
site_country_code: str
site_latitude: float
site_longitude: float
site_bgp_community_id: int
site_internal_id: int
site_tier: SiteTier
site_ts_address: str
customer: str
class RouterImportModel(BaseModel):
customer: str
router_site: str
hostname: str
ts_port: int
router_vendor: RouterVendor
router_role: RouterRole
is_ias_connected: bool
router_lo_ipv4_address: ipaddress.IPv4Address
router_lo_ipv6_address: ipaddress.IPv6Address
router_lo_iso_address: str
router_si_ipv4_network: ipaddress.IPv4Network | None = None
router_ias_lt_ipv4_network: ipaddress.IPv4Network | None = None
router_ias_lt_ipv6_network: ipaddress.IPv6Network | None = None
class IptrunkImportModel(BaseModel):
customer: str
geant_s_sid: str
iptrunk_type: IptrunkType
iptrunk_description: str
iptrunk_speed: PhyPortCapacity
iptrunk_minimum_links: int
side_a_node_id: str
side_a_ae_iface: str
side_a_ae_geant_a_sid: str
side_a_ae_members: list[LAGMember]
side_b_node_id: str
side_b_ae_iface: str
side_b_ae_geant_a_sid: str
side_b_ae_members: list[LAGMember]
iptrunk_ipv4_network: ipaddress.IPv4Network
iptrunk_ipv6_network: ipaddress.IPv6Network
@classmethod
def _get_active_routers(cls) -> set[str]:
return {
str(router_id) for router_id in subscriptions.get_active_router_subscriptions(fields=["subscription_id"])
}
@validator("customer")
def check_if_customer_exists(cls, value: str) -> str:
try:
get_customer_by_name(value)
except CustomerNotFoundError:
raise ValueError(f"Customer {value} not found")
return value
@validator("side_a_node_id", "side_b_node_id")
def check_if_router_side_is_available(cls, value: str) -> str:
if value not in cls._get_active_routers():
raise ValueError(f"Router {value} not found")
return value
@validator("side_a_ae_members", "side_b_ae_members")
def check_side_uniqueness(cls, value: list[str]) -> list[str]:
if len(value) != len(set(value)):
raise ValueError("Items must be unique")
return value
@root_validator
def check_members(cls, values: dict[str, Any]) -> dict[str, Any]:
min_links = values["iptrunk_minimum_links"]
side_a_members = values.get("side_a_ae_members", [])
side_b_members = values.get("side_b_ae_members", [])
len_a = len(side_a_members)
len_b = len(side_b_members)
if len_a < min_links:
raise ValueError(f"Side A members should be at least {min_links} (iptrunk_minimum_links)")
if len_a != len_b:
raise ValueError("Mismatch between Side A and B members")
return values
def _start_process(process_name: str, data: dict) -> UUID: def _start_process(process_name: str, data: dict) -> UUID:
"""Start a process and handle common exceptions.""" """Start a process and handle common exceptions."""
...@@ -42,7 +151,7 @@ def import_site(site: SiteImportModel) -> dict[str, Any]: ...@@ -42,7 +151,7 @@ def import_site(site: SiteImportModel) -> dict[str, Any]:
:raises HTTPException: If the site already exists or if there's an error in the process. :raises HTTPException: If the site already exists or if there's an error in the process.
""" """
try: try:
subscription = subscriptions.retrieve_subscription_by_subscription_instance_value( subscription = wfo_subscriptions.retrieve_subscription_by_subscription_instance_value(
resource_type="site_name", value=site.site_name, sub_status=("provisioning", "active") resource_type="site_name", value=site.site_name, sub_status=("provisioning", "active")
) )
if subscription: if subscription:
......
"""Module that updates the domain model of :term:`GSO`. Should contain all types of subscriptions.""" """Module that updates the domain model of :term:`GSO`. Should contain all types of subscriptions.
.. warning::
Whenever a new product type is added, this should be reflected in the :py:class:`gso.products.ProductType`
enumerator.
"""
from orchestrator.domain import SUBSCRIPTION_MODEL_REGISTRY from orchestrator.domain import SUBSCRIPTION_MODEL_REGISTRY
from pydantic_forms.types import strEnum from pydantic_forms.types import strEnum
...@@ -15,8 +20,8 @@ class ProductType(strEnum): ...@@ -15,8 +20,8 @@ class ProductType(strEnum):
SUBSCRIPTION_MODEL_REGISTRY.update( SUBSCRIPTION_MODEL_REGISTRY.update(
{ {
ProductType.SITE.value: Site, "Site": Site,
ProductType.ROUTER.value: Router, "Router": Router,
ProductType.IP_TRUNK.value: Iptrunk, "IP trunk": Iptrunk,
} }
) )
...@@ -8,7 +8,18 @@ from orchestrator.forms.validators import UniqueConstrainedList ...@@ -8,7 +8,18 @@ from orchestrator.forms.validators import UniqueConstrainedList
from orchestrator.types import SubscriptionLifecycle, strEnum from orchestrator.types import SubscriptionLifecycle, strEnum
from gso.products.product_blocks.router import RouterBlock, RouterBlockInactive, RouterBlockProvisioning from gso.products.product_blocks.router import RouterBlock, RouterBlockInactive, RouterBlockProvisioning
from gso.utils.types.phy_port import PhyPortCapacity
class PhyPortCapacity(strEnum):
"""Physical port capacity enumerator.
An enumerator that has the different possible capacities of ports that are available to use in subscriptions.
"""
ONE_GIGABIT_PER_SECOND = "1G"
TEN_GIGABIT_PER_SECOND = "10G"
HUNDRED_GIGABIT_PER_SECOND = "100G"
FOUR_HUNDRED_GIGABIT_PER_SECOND = "400G"
class IptrunkType(strEnum): class IptrunkType(strEnum):
......
...@@ -3,9 +3,9 @@ import ipaddress ...@@ -3,9 +3,9 @@ import ipaddress
from orchestrator.domain.base import ProductBlockModel from orchestrator.domain.base import ProductBlockModel
from orchestrator.types import SubscriptionLifecycle, strEnum from orchestrator.types import SubscriptionLifecycle, strEnum
from pydantic import ConstrainedInt
from gso.products.product_blocks.site import SiteBlock, SiteBlockInactive, SiteBlockProvisioning from gso.products.product_blocks.site import SiteBlock, SiteBlockInactive, SiteBlockProvisioning
from gso.utils.types.ip_port import PortNumber
class RouterVendor(strEnum): class RouterVendor(strEnum):
...@@ -23,6 +23,16 @@ class RouterRole(strEnum): ...@@ -23,6 +23,16 @@ class RouterRole(strEnum):
AMT = "amt" AMT = "amt"
class PortNumber(ConstrainedInt):
"""Constrained integer for valid port numbers.
The range from 49152 to 65535 is marked as ephemeral, and can therefore not be selected for permanent allocation.
"""
gt = 0
le = 49151
class RouterBlockInactive( class RouterBlockInactive(
ProductBlockModel, lifecycle=[SubscriptionLifecycle.INITIAL], product_block_name="RouterBlock" ProductBlockModel, lifecycle=[SubscriptionLifecycle.INITIAL], product_block_name="RouterBlock"
): ):
......
"""The product block that describes a site subscription.""" """The product block that describes a site subscription."""
import re
from typing import Union
from orchestrator.domain.base import ProductBlockModel from orchestrator.domain.base import ProductBlockModel
from orchestrator.types import SubscriptionLifecycle, strEnum from orchestrator.types import SubscriptionLifecycle, strEnum
from pydantic import ConstrainedStr
from gso.utils.types.snmp import LatitudeCoordinate, LongitudeCoordinate
class SiteTier(strEnum): class SiteTier(strEnum):
...@@ -18,6 +20,42 @@ class SiteTier(strEnum): ...@@ -18,6 +20,42 @@ class SiteTier(strEnum):
TIER4 = 4 TIER4 = 4
class LatitudeCoordinate(ConstrainedStr):
"""A latitude coordinate, modeled as a constrained string.
The coordinate must match the format conforming to the latitude range of -90 to +90 degrees. It can be a
floating-point number or an integer.
Valid examples: 40.7128, -74.0060, 90, -90, 0
"""
regex = re.compile(r"^-?([1-8]?\d(\.\d+)?|90(\.0+)?)$")
@classmethod
def validate(cls, value: Union[str]) -> Union[str]:
if not cls.regex.match(value):
raise ValueError("Invalid latitude coordinate. Valid examples: '40.7128', '-74.0060', '90', '-90', '0'.")
return value
class LongitudeCoordinate(ConstrainedStr):
"""A longitude coordinate, modeled as a constrained string.
The coordinate must match the format conforming to the longitude
range of -180 to +180 degrees. It can be a floating point number or an integer.
Valid examples: 40.7128, -74.0060, 180, -180, 0
"""
regex = re.compile(r"^-?(180(\.0+)?|((1[0-7]\d)|([1-9]?\d))(\.\d+)?)$")
@classmethod
def validate(cls, value: Union[str]) -> Union[str]:
if not cls.regex.match(value):
raise ValueError("Invalid longitude coordinate. Valid examples: '40.7128', '-74.0060', '180', '-180'")
return value
class SiteBlockInactive(ProductBlockModel, lifecycle=[SubscriptionLifecycle.INITIAL], product_block_name="SiteBlock"): class SiteBlockInactive(ProductBlockModel, lifecycle=[SubscriptionLifecycle.INITIAL], product_block_name="SiteBlock"):
"""A site that's currently inactive, see :class:`SiteBlock`.""" """A site that's currently inactive, see :class:`SiteBlock`."""
......
File moved
import ipaddress
from typing import Any
from uuid import UUID
from pydantic import BaseModel, root_validator, validator
from gso.products.product_blocks.iptrunk import IptrunkType
from gso.products.product_blocks.router import RouterRole, RouterVendor
from gso.products.product_blocks.site import SiteTier
from gso.services import subscriptions
from gso.services.crm import CustomerNotFoundError, get_customer_by_name
from gso.utils.types.phy_port import PhyPortCapacity
from gso.workflows.iptrunk.utils import LAGMember
class ImportResponseModel(BaseModel):
pid: UUID
detail: str
class SiteImportModel(BaseModel):
site_name: str
site_city: str
site_country: str
site_country_code: str
site_latitude: float
site_longitude: float
site_bgp_community_id: int
site_internal_id: int
site_tier: SiteTier
site_ts_address: str
customer: str
class RouterImportModel(BaseModel):
customer: str
router_site: str
hostname: str
ts_port: int
router_vendor: RouterVendor
router_role: RouterRole
is_ias_connected: bool
router_lo_ipv4_address: ipaddress.IPv4Address
router_lo_ipv6_address: ipaddress.IPv6Address
router_lo_iso_address: str
router_si_ipv4_network: ipaddress.IPv4Network | None = None
router_ias_lt_ipv4_network: ipaddress.IPv4Network | None = None
router_ias_lt_ipv6_network: ipaddress.IPv6Network | None = None
class IptrunkImportModel(BaseModel):
customer: str
geant_s_sid: str
iptrunk_type: IptrunkType
iptrunk_description: str
iptrunk_speed: PhyPortCapacity
iptrunk_minimum_links: int
side_a_node_id: str
side_a_ae_iface: str
side_a_ae_geant_a_sid: str
side_a_ae_members: list[LAGMember]
side_b_node_id: str
side_b_ae_iface: str
side_b_ae_geant_a_sid: str
side_b_ae_members: list[LAGMember]
iptrunk_ipv4_network: ipaddress.IPv4Network
iptrunk_ipv6_network: ipaddress.IPv6Network
@classmethod
def _get_active_routers(cls) -> set[str]:
return {
str(router_id) for router_id in subscriptions.get_active_router_subscriptions(fields=["subscription_id"])
}
@validator("customer")
def check_if_customer_exists(cls, value: str) -> str:
try:
get_customer_by_name(value)
except CustomerNotFoundError:
raise ValueError(f"Customer {value} not found")
return value
@validator("side_a_node_id", "side_b_node_id")
def check_if_router_side_is_available(cls, value: str) -> str:
if value not in cls._get_active_routers():
raise ValueError("Router not found")
return value
@validator("side_a_ae_members", "side_b_ae_members")
def check_side_uniqueness(cls, value: list[str]) -> list[str]:
if len(value) != len(set(value)):
raise ValueError("Items must be unique")
return value
@root_validator
def check_members(cls, values: dict[str, Any]) -> dict[str, Any]:
min_links = values["iptrunk_minimum_links"]
side_a_members = values.get("side_a_ae_members", {})
side_b_members = values.get("side_b_ae_members", {})
len_a = len(side_a_members)
len_b = len(side_b_members)
if len_a < min_links:
raise ValueError(f"Side A members should be at least {min_links} (iptrunk_minimum_links)")
if len_a != len_b:
raise ValueError("Mismatch between Side A and B members")
return values
from pydantic import ConstrainedInt
class PortNumber(ConstrainedInt):
"""Constrained integer for valid port numbers.
The range from 49152 to 65535 is marked as ephemeral, and can therefore not be selected for permanent allocation.
"""
gt = 0
le = 49151
from orchestrator.types import strEnum
class PhyPortCapacity(strEnum):
"""Physical port capacity enumerator.
An enumerator that has the different possible capacities of ports that are available to use in subscriptions.
"""
ONE_GIGABIT_PER_SECOND = "1G"
TEN_GIGABIT_PER_SECOND = "10G"
HUNDRED_GIGABIT_PER_SECOND = "100G"
FOUR_HUNDRED_GIGABIT_PER_SECOND = "400G"
import re
from typing import Union
from pydantic import ConstrainedStr
class LatitudeCoordinate(ConstrainedStr):
"""A latitude coordinate, modeled as a constrained string.
The coordinate must match the format conforming to the latitude range of -90 to +90 degrees. It can be a
floating-point number or an integer.
Valid examples: 40.7128, -74.0060, 90, -90, 0
"""
regex = re.compile(r"^-?([1-8]?\d(\.\d+)?|90(\.0+)?)$")
@classmethod
def validate(cls, value: Union[str]) -> Union[str]:
if not cls.regex.match(value):
raise ValueError("Invalid latitude coordinate. Valid examples: '40.7128', '-74.0060', '90', '-90', '0'.")
return value
class LongitudeCoordinate(ConstrainedStr):
"""A longitude coordinate, modeled as a constrained string.
The coordinate must match the format conforming to the longitude
range of -180 to +180 degrees. It can be a floating point number or an integer.
Valid examples: 40.7128, -74.0060, 180, -180, 0
"""
regex = re.compile(r"^-?(180(\.0+)?|((1[0-7]\d)|([1-9]?\d))(\.\d+)?)$")
@classmethod
def validate(cls, value: Union[str]) -> Union[str]:
if not cls.regex.match(value):
raise ValueError("Invalid longitude coordinate. Valid examples: '40.7128', '-74.0060', '180', '-180'")
return value
...@@ -10,7 +10,7 @@ from orchestrator.workflows.utils import wrap_create_initial_input_form ...@@ -10,7 +10,7 @@ from orchestrator.workflows.utils import wrap_create_initial_input_form
from pydantic import validator from pydantic import validator
from pynetbox.models.dcim import Interfaces from pynetbox.models.dcim import Interfaces
from gso.products.product_blocks.iptrunk import IptrunkInterfaceBlockInactive, IptrunkType from gso.products.product_blocks.iptrunk import IptrunkInterfaceBlockInactive, IptrunkType, PhyPortCapacity
from gso.products.product_blocks.router import RouterVendor from gso.products.product_blocks.router import RouterVendor
from gso.products.product_types.iptrunk import IptrunkInactive, IptrunkProvisioning from gso.products.product_types.iptrunk import IptrunkInactive, IptrunkProvisioning
from gso.products.product_types.router import Router from gso.products.product_types.router import Router
......
...@@ -9,11 +9,10 @@ from orchestrator.workflow import StepList, done, init, step, workflow ...@@ -9,11 +9,10 @@ from orchestrator.workflow import StepList, done, init, step, workflow
from orchestrator.workflows.steps import resync, store_process_subscription, unsync from orchestrator.workflows.steps import resync, store_process_subscription, unsync
from orchestrator.workflows.utils import wrap_modify_initial_input_form from orchestrator.workflows.utils import wrap_modify_initial_input_form
from gso.products.product_blocks.iptrunk import IptrunkInterfaceBlock, IptrunkType from gso.products.product_blocks.iptrunk import IptrunkInterfaceBlock, IptrunkType, PhyPortCapacity
from gso.products.product_types.iptrunk import Iptrunk from gso.products.product_types.iptrunk import Iptrunk
from gso.services import provisioning_proxy from gso.services import provisioning_proxy
from gso.services.provisioning_proxy import pp_interaction from gso.services.provisioning_proxy import pp_interaction
from gso.utils.types.phy_port import PhyPortCapacity
from gso.workflows.iptrunk.utils import LAGMember from gso.workflows.iptrunk.utils import LAGMember
......
...@@ -11,15 +11,14 @@ from orchestrator.workflows.steps import resync, set_status, store_process_subsc ...@@ -11,15 +11,14 @@ from orchestrator.workflows.steps import resync, set_status, store_process_subsc
from orchestrator.workflows.utils import wrap_create_initial_input_form from orchestrator.workflows.utils import wrap_create_initial_input_form
from pydantic import validator from pydantic import validator
from gso.products.product_blocks.router import RouterRole, RouterVendor, generate_fqdn from gso.products.product_blocks.router import PortNumber, RouterRole, RouterVendor, generate_fqdn
from gso.products.product_types.router import RouterInactive, RouterProvisioning from gso.products.product_types.router import RouterInactive, RouterProvisioning
from gso.products.product_types.site import Site from gso.products.product_types.site import Site
from gso.services import infoblox, provisioning_proxy, subscriptions from gso.services import infoblox, provisioning_proxy, subscriptions
from gso.services.crm import customer_selector from gso.services.crm import customer_selector
from gso.services.netbox_client import NetboxClient from gso.services.netbox_client import NetboxClient
from gso.services.provisioning_proxy import pp_interaction from gso.services.provisioning_proxy import pp_interaction
from gso.utils.functions import iso_from_ipv4 from gso.utils.helpers import iso_from_ipv4
from gso.utils.types.ip_port import PortNumber
def _site_selector() -> Choice: def _site_selector() -> Choice:
......
...@@ -11,9 +11,9 @@ from orchestrator.workflows.utils import wrap_create_initial_input_form ...@@ -11,9 +11,9 @@ from orchestrator.workflows.utils import wrap_create_initial_input_form
from pydantic import validator from pydantic import validator
from gso.products.product_blocks import site as site_pb from gso.products.product_blocks import site as site_pb
from gso.products.product_blocks.site import LatitudeCoordinate, LongitudeCoordinate
from gso.products.product_types import site from gso.products.product_types import site
from gso.services.crm import customer_selector from gso.services.crm import customer_selector
from gso.utils.types.snmp import LatitudeCoordinate, LongitudeCoordinate
def initial_input_form_generator(product_name: str) -> FormGenerator: # noqa: C901 def initial_input_form_generator(product_name: str) -> FormGenerator: # noqa: C901
......
...@@ -9,11 +9,10 @@ from orchestrator.workflow import StepList, done, init, step ...@@ -9,11 +9,10 @@ from orchestrator.workflow import StepList, done, init, step
from orchestrator.workflows.steps import resync, set_status, store_process_subscription from orchestrator.workflows.steps import resync, set_status, store_process_subscription
from gso.products import ProductType from gso.products import ProductType
from gso.products.product_blocks.iptrunk import IptrunkType from gso.products.product_blocks.iptrunk import IptrunkType, PhyPortCapacity
from gso.products.product_types.iptrunk import IptrunkInactive, IptrunkProvisioning from gso.products.product_types.iptrunk import IptrunkInactive, IptrunkProvisioning
from gso.services import subscriptions from gso.services import subscriptions
from gso.services.crm import get_customer_by_name from gso.services.crm import get_customer_by_name
from gso.utils.types.phy_port import PhyPortCapacity
from gso.workflows.iptrunk.create_iptrunk import initialize_subscription from gso.workflows.iptrunk.create_iptrunk import initialize_subscription
from gso.workflows.iptrunk.utils import LAGMember from gso.workflows.iptrunk.utils import LAGMember
......
...@@ -10,13 +10,12 @@ from orchestrator.workflows.steps import resync, set_status, store_process_subsc ...@@ -10,13 +10,12 @@ from orchestrator.workflows.steps import resync, set_status, store_process_subsc
from gso.products import ProductType from gso.products import ProductType
from gso.products.product_blocks import router as router_pb from gso.products.product_blocks import router as router_pb
from gso.products.product_blocks.router import RouterRole, RouterVendor from gso.products.product_blocks.router import PortNumber, RouterRole, RouterVendor
from gso.products.product_types import router from gso.products.product_types import router
from gso.products.product_types.router import RouterInactive from gso.products.product_types.router import RouterInactive
from gso.products.product_types.site import Site from gso.products.product_types.site import Site
from gso.services import subscriptions from gso.services import subscriptions
from gso.services.crm import get_customer_by_name from gso.services.crm import get_customer_by_name
from gso.utils.types.ip_port import PortNumber
def _get_site_by_name(site_name: str) -> Site: def _get_site_by_name(site_name: str) -> Site:
......
...@@ -6,14 +6,13 @@ from orchestrator.domain import SubscriptionModel ...@@ -6,14 +6,13 @@ from orchestrator.domain import SubscriptionModel
from orchestrator.types import SubscriptionLifecycle, UUIDstr from orchestrator.types import SubscriptionLifecycle, UUIDstr
from gso.products import ProductType from gso.products import ProductType
from gso.products.product_blocks.iptrunk import IptrunkInterfaceBlock, IptrunkSideBlock, IptrunkType from gso.products.product_blocks.iptrunk import IptrunkInterfaceBlock, IptrunkSideBlock, IptrunkType, PhyPortCapacity
from gso.products.product_blocks.router import RouterRole, RouterVendor from gso.products.product_blocks.router import RouterRole, RouterVendor
from gso.products.product_blocks.site import SiteTier from gso.products.product_blocks.site import SiteTier
from gso.products.product_types.iptrunk import IptrunkInactive from gso.products.product_types.iptrunk import IptrunkInactive
from gso.products.product_types.router import Router, RouterInactive from gso.products.product_types.router import Router, RouterInactive
from gso.products.product_types.site import Site, SiteInactive from gso.products.product_types.site import Site, SiteInactive
from gso.services import subscriptions from gso.services import subscriptions
from gso.utils.types.phy_port import PhyPortCapacity
CUSTOMER_ID: UUIDstr = "2f47f65a-0911-e511-80d0-005056956c1a" CUSTOMER_ID: UUIDstr = "2f47f65a-0911-e511-80d0-005056956c1a"
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment