bb-integrations-library 3.0.11__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bb_integrations_lib/__init__.py +0 -0
- bb_integrations_lib/converters/__init__.py +0 -0
- bb_integrations_lib/gravitate/__init__.py +0 -0
- bb_integrations_lib/gravitate/base_api.py +20 -0
- bb_integrations_lib/gravitate/model.py +29 -0
- bb_integrations_lib/gravitate/pe_api.py +122 -0
- bb_integrations_lib/gravitate/rita_api.py +552 -0
- bb_integrations_lib/gravitate/sd_api.py +572 -0
- bb_integrations_lib/gravitate/testing/TTE/sd/models.py +1398 -0
- bb_integrations_lib/gravitate/testing/TTE/sd/tests/test_models.py +2987 -0
- bb_integrations_lib/gravitate/testing/__init__.py +0 -0
- bb_integrations_lib/gravitate/testing/builder.py +55 -0
- bb_integrations_lib/gravitate/testing/openapi.py +70 -0
- bb_integrations_lib/gravitate/testing/util.py +274 -0
- bb_integrations_lib/mappers/__init__.py +0 -0
- bb_integrations_lib/mappers/prices/__init__.py +0 -0
- bb_integrations_lib/mappers/prices/model.py +106 -0
- bb_integrations_lib/mappers/prices/price_mapper.py +127 -0
- bb_integrations_lib/mappers/prices/protocol.py +20 -0
- bb_integrations_lib/mappers/prices/util.py +61 -0
- bb_integrations_lib/mappers/rita_mapper.py +523 -0
- bb_integrations_lib/models/__init__.py +0 -0
- bb_integrations_lib/models/dtn_supplier_invoice.py +487 -0
- bb_integrations_lib/models/enums.py +28 -0
- bb_integrations_lib/models/pipeline_structs.py +76 -0
- bb_integrations_lib/models/probe/probe_event.py +20 -0
- bb_integrations_lib/models/probe/request_data.py +431 -0
- bb_integrations_lib/models/probe/resume_token.py +7 -0
- bb_integrations_lib/models/rita/audit.py +113 -0
- bb_integrations_lib/models/rita/auth.py +30 -0
- bb_integrations_lib/models/rita/bucket.py +17 -0
- bb_integrations_lib/models/rita/config.py +188 -0
- bb_integrations_lib/models/rita/constants.py +19 -0
- bb_integrations_lib/models/rita/crossroads_entities.py +293 -0
- bb_integrations_lib/models/rita/crossroads_mapping.py +428 -0
- bb_integrations_lib/models/rita/crossroads_monitoring.py +78 -0
- bb_integrations_lib/models/rita/crossroads_network.py +41 -0
- bb_integrations_lib/models/rita/crossroads_rules.py +80 -0
- bb_integrations_lib/models/rita/email.py +39 -0
- bb_integrations_lib/models/rita/issue.py +63 -0
- bb_integrations_lib/models/rita/mapping.py +227 -0
- bb_integrations_lib/models/rita/probe.py +58 -0
- bb_integrations_lib/models/rita/reference_data.py +110 -0
- bb_integrations_lib/models/rita/source_system.py +9 -0
- bb_integrations_lib/models/rita/workers.py +76 -0
- bb_integrations_lib/models/sd/bols_and_drops.py +241 -0
- bb_integrations_lib/models/sd/get_order.py +301 -0
- bb_integrations_lib/models/sd/orders.py +18 -0
- bb_integrations_lib/models/sd_api.py +115 -0
- bb_integrations_lib/pipelines/__init__.py +0 -0
- bb_integrations_lib/pipelines/parsers/__init__.py +0 -0
- bb_integrations_lib/pipelines/parsers/distribution_report/__init__.py +0 -0
- bb_integrations_lib/pipelines/parsers/distribution_report/order_by_site_product_parser.py +50 -0
- bb_integrations_lib/pipelines/parsers/distribution_report/tank_configs_parser.py +47 -0
- bb_integrations_lib/pipelines/parsers/dtn/__init__.py +0 -0
- bb_integrations_lib/pipelines/parsers/dtn/dtn_price_parser.py +102 -0
- bb_integrations_lib/pipelines/parsers/dtn/model.py +79 -0
- bb_integrations_lib/pipelines/parsers/price_engine/__init__.py +0 -0
- bb_integrations_lib/pipelines/parsers/price_engine/parse_accessorials_prices_parser.py +67 -0
- bb_integrations_lib/pipelines/parsers/price_engine/price_file_upload/__init__.py +0 -0
- bb_integrations_lib/pipelines/parsers/price_engine/price_file_upload/price_merge_parser.py +111 -0
- bb_integrations_lib/pipelines/parsers/price_engine/price_file_upload/price_sync_parser.py +107 -0
- bb_integrations_lib/pipelines/parsers/price_engine/price_file_upload/shared.py +81 -0
- bb_integrations_lib/pipelines/parsers/tank_reading_parser.py +155 -0
- bb_integrations_lib/pipelines/parsers/tank_sales_parser.py +144 -0
- bb_integrations_lib/pipelines/shared/__init__.py +0 -0
- bb_integrations_lib/pipelines/shared/allocation_matching.py +227 -0
- bb_integrations_lib/pipelines/shared/bol_allocation.py +2793 -0
- bb_integrations_lib/pipelines/steps/__init__.py +0 -0
- bb_integrations_lib/pipelines/steps/create_accessorials_step.py +80 -0
- bb_integrations_lib/pipelines/steps/distribution_report/__init__.py +0 -0
- bb_integrations_lib/pipelines/steps/distribution_report/distribution_report_datafram_to_raw_data.py +33 -0
- bb_integrations_lib/pipelines/steps/distribution_report/get_model_history_step.py +50 -0
- bb_integrations_lib/pipelines/steps/distribution_report/get_order_by_site_product_step.py +62 -0
- bb_integrations_lib/pipelines/steps/distribution_report/get_tank_configs_step.py +40 -0
- bb_integrations_lib/pipelines/steps/distribution_report/join_distribution_order_dos_step.py +85 -0
- bb_integrations_lib/pipelines/steps/distribution_report/upload_distribution_report_datafram_to_big_query.py +47 -0
- bb_integrations_lib/pipelines/steps/echo_step.py +14 -0
- bb_integrations_lib/pipelines/steps/export_dataframe_to_rawdata_step.py +28 -0
- bb_integrations_lib/pipelines/steps/exporting/__init__.py +0 -0
- bb_integrations_lib/pipelines/steps/exporting/bbd_export_payroll_file_step.py +107 -0
- bb_integrations_lib/pipelines/steps/exporting/bbd_export_readings_step.py +236 -0
- bb_integrations_lib/pipelines/steps/exporting/cargas_wholesale_bundle_upload_step.py +33 -0
- bb_integrations_lib/pipelines/steps/exporting/dataframe_flat_file_export.py +29 -0
- bb_integrations_lib/pipelines/steps/exporting/gcs_bucket_export_file_step.py +34 -0
- bb_integrations_lib/pipelines/steps/exporting/keyvu_export_step.py +356 -0
- bb_integrations_lib/pipelines/steps/exporting/pe_price_export_step.py +238 -0
- bb_integrations_lib/pipelines/steps/exporting/platform_science_order_sync_step.py +500 -0
- bb_integrations_lib/pipelines/steps/exporting/save_rawdata_to_disk.py +15 -0
- bb_integrations_lib/pipelines/steps/exporting/sftp_export_file_step.py +60 -0
- bb_integrations_lib/pipelines/steps/exporting/sftp_export_many_files_step.py +23 -0
- bb_integrations_lib/pipelines/steps/exporting/update_exported_orders_table_step.py +64 -0
- bb_integrations_lib/pipelines/steps/filter_step.py +22 -0
- bb_integrations_lib/pipelines/steps/get_latest_sync_date.py +34 -0
- bb_integrations_lib/pipelines/steps/importing/bbd_import_payroll_step.py +30 -0
- bb_integrations_lib/pipelines/steps/importing/get_order_numbers_to_export_step.py +138 -0
- bb_integrations_lib/pipelines/steps/importing/load_file_to_dataframe_step.py +46 -0
- bb_integrations_lib/pipelines/steps/importing/load_imap_attachment_step.py +172 -0
- bb_integrations_lib/pipelines/steps/importing/pe_bulk_sync_price_structure_step.py +68 -0
- bb_integrations_lib/pipelines/steps/importing/pe_price_merge_step.py +86 -0
- bb_integrations_lib/pipelines/steps/importing/sftp_file_config_step.py +124 -0
- bb_integrations_lib/pipelines/steps/importing/test_exact_file_match.py +57 -0
- bb_integrations_lib/pipelines/steps/null_step.py +15 -0
- bb_integrations_lib/pipelines/steps/pe_integration_job_step.py +32 -0
- bb_integrations_lib/pipelines/steps/processing/__init__.py +0 -0
- bb_integrations_lib/pipelines/steps/processing/archive_gcs_step.py +76 -0
- bb_integrations_lib/pipelines/steps/processing/archive_sftp_step.py +48 -0
- bb_integrations_lib/pipelines/steps/processing/bbd_format_tank_readings_step.py +492 -0
- bb_integrations_lib/pipelines/steps/processing/bbd_upload_prices_step.py +54 -0
- bb_integrations_lib/pipelines/steps/processing/bbd_upload_tank_sales_step.py +124 -0
- bb_integrations_lib/pipelines/steps/processing/bbd_upload_tankreading_step.py +80 -0
- bb_integrations_lib/pipelines/steps/processing/convert_bbd_order_to_cargas_step.py +226 -0
- bb_integrations_lib/pipelines/steps/processing/delete_sftp_step.py +33 -0
- bb_integrations_lib/pipelines/steps/processing/dtn/__init__.py +2 -0
- bb_integrations_lib/pipelines/steps/processing/dtn/convert_dtn_invoice_to_sd_model.py +145 -0
- bb_integrations_lib/pipelines/steps/processing/dtn/parse_dtn_invoice_step.py +38 -0
- bb_integrations_lib/pipelines/steps/processing/file_config_parser_step.py +720 -0
- bb_integrations_lib/pipelines/steps/processing/file_config_parser_step_v2.py +418 -0
- bb_integrations_lib/pipelines/steps/processing/get_sd_price_price_request.py +105 -0
- bb_integrations_lib/pipelines/steps/processing/keyvu_upload_deliveryplan_step.py +39 -0
- bb_integrations_lib/pipelines/steps/processing/mark_orders_exported_in_bbd_step.py +185 -0
- bb_integrations_lib/pipelines/steps/processing/pe_price_rows_processing_step.py +174 -0
- bb_integrations_lib/pipelines/steps/processing/send_process_report_step.py +47 -0
- bb_integrations_lib/pipelines/steps/processing/sftp_renamer_step.py +61 -0
- bb_integrations_lib/pipelines/steps/processing/tank_reading_touchup_steps.py +75 -0
- bb_integrations_lib/pipelines/steps/processing/upload_supplier_invoice_step.py +16 -0
- bb_integrations_lib/pipelines/steps/send_attached_in_rita_email_step.py +44 -0
- bb_integrations_lib/pipelines/steps/send_rita_email_step.py +34 -0
- bb_integrations_lib/pipelines/steps/sleep_step.py +24 -0
- bb_integrations_lib/pipelines/wrappers/__init__.py +0 -0
- bb_integrations_lib/pipelines/wrappers/accessorials_transformation.py +104 -0
- bb_integrations_lib/pipelines/wrappers/distribution_report.py +191 -0
- bb_integrations_lib/pipelines/wrappers/export_tank_readings.py +237 -0
- bb_integrations_lib/pipelines/wrappers/import_tank_readings.py +192 -0
- bb_integrations_lib/pipelines/wrappers/wrapper.py +81 -0
- bb_integrations_lib/protocols/__init__.py +0 -0
- bb_integrations_lib/protocols/flat_file.py +210 -0
- bb_integrations_lib/protocols/gravitate_client.py +104 -0
- bb_integrations_lib/protocols/pipelines.py +697 -0
- bb_integrations_lib/provider/__init__.py +0 -0
- bb_integrations_lib/provider/api/__init__.py +0 -0
- bb_integrations_lib/provider/api/cargas/__init__.py +0 -0
- bb_integrations_lib/provider/api/cargas/client.py +43 -0
- bb_integrations_lib/provider/api/cargas/model.py +49 -0
- bb_integrations_lib/provider/api/cargas/protocol.py +23 -0
- bb_integrations_lib/provider/api/dtn/__init__.py +0 -0
- bb_integrations_lib/provider/api/dtn/client.py +128 -0
- bb_integrations_lib/provider/api/dtn/protocol.py +9 -0
- bb_integrations_lib/provider/api/keyvu/__init__.py +0 -0
- bb_integrations_lib/provider/api/keyvu/client.py +30 -0
- bb_integrations_lib/provider/api/keyvu/model.py +149 -0
- bb_integrations_lib/provider/api/macropoint/__init__.py +0 -0
- bb_integrations_lib/provider/api/macropoint/client.py +28 -0
- bb_integrations_lib/provider/api/macropoint/model.py +40 -0
- bb_integrations_lib/provider/api/pc_miler/__init__.py +0 -0
- bb_integrations_lib/provider/api/pc_miler/client.py +130 -0
- bb_integrations_lib/provider/api/pc_miler/model.py +6 -0
- bb_integrations_lib/provider/api/pc_miler/web_services_apis.py +131 -0
- bb_integrations_lib/provider/api/platform_science/__init__.py +0 -0
- bb_integrations_lib/provider/api/platform_science/client.py +147 -0
- bb_integrations_lib/provider/api/platform_science/model.py +82 -0
- bb_integrations_lib/provider/api/quicktrip/__init__.py +0 -0
- bb_integrations_lib/provider/api/quicktrip/client.py +52 -0
- bb_integrations_lib/provider/api/telapoint/__init__.py +0 -0
- bb_integrations_lib/provider/api/telapoint/client.py +68 -0
- bb_integrations_lib/provider/api/telapoint/model.py +178 -0
- bb_integrations_lib/provider/api/warren_rogers/__init__.py +0 -0
- bb_integrations_lib/provider/api/warren_rogers/client.py +207 -0
- bb_integrations_lib/provider/aws/__init__.py +0 -0
- bb_integrations_lib/provider/aws/s3/__init__.py +0 -0
- bb_integrations_lib/provider/aws/s3/client.py +126 -0
- bb_integrations_lib/provider/ftp/__init__.py +0 -0
- bb_integrations_lib/provider/ftp/client.py +140 -0
- bb_integrations_lib/provider/ftp/interface.py +273 -0
- bb_integrations_lib/provider/ftp/model.py +76 -0
- bb_integrations_lib/provider/imap/__init__.py +0 -0
- bb_integrations_lib/provider/imap/client.py +228 -0
- bb_integrations_lib/provider/imap/model.py +3 -0
- bb_integrations_lib/provider/sqlserver/__init__.py +0 -0
- bb_integrations_lib/provider/sqlserver/client.py +106 -0
- bb_integrations_lib/secrets/__init__.py +4 -0
- bb_integrations_lib/secrets/adapters.py +98 -0
- bb_integrations_lib/secrets/credential_models.py +222 -0
- bb_integrations_lib/secrets/factory.py +85 -0
- bb_integrations_lib/secrets/providers.py +160 -0
- bb_integrations_lib/shared/__init__.py +0 -0
- bb_integrations_lib/shared/exceptions.py +25 -0
- bb_integrations_lib/shared/model.py +1039 -0
- bb_integrations_lib/shared/shared_enums.py +510 -0
- bb_integrations_lib/storage/README.md +236 -0
- bb_integrations_lib/storage/__init__.py +0 -0
- bb_integrations_lib/storage/aws/__init__.py +0 -0
- bb_integrations_lib/storage/aws/s3.py +8 -0
- bb_integrations_lib/storage/defaults.py +72 -0
- bb_integrations_lib/storage/gcs/__init__.py +0 -0
- bb_integrations_lib/storage/gcs/client.py +8 -0
- bb_integrations_lib/storage/gcsmanager/__init__.py +0 -0
- bb_integrations_lib/storage/gcsmanager/client.py +8 -0
- bb_integrations_lib/storage/setup.py +29 -0
- bb_integrations_lib/util/__init__.py +0 -0
- bb_integrations_lib/util/cache/__init__.py +0 -0
- bb_integrations_lib/util/cache/custom_ttl_cache.py +75 -0
- bb_integrations_lib/util/cache/protocol.py +9 -0
- bb_integrations_lib/util/config/__init__.py +0 -0
- bb_integrations_lib/util/config/manager.py +391 -0
- bb_integrations_lib/util/config/model.py +41 -0
- bb_integrations_lib/util/exception_logger/__init__.py +0 -0
- bb_integrations_lib/util/exception_logger/exception_logger.py +146 -0
- bb_integrations_lib/util/exception_logger/test.py +114 -0
- bb_integrations_lib/util/utils.py +364 -0
- bb_integrations_lib/workers/__init__.py +0 -0
- bb_integrations_lib/workers/groups.py +13 -0
- bb_integrations_lib/workers/rpc_worker.py +50 -0
- bb_integrations_lib/workers/topics.py +20 -0
- bb_integrations_library-3.0.11.dist-info/METADATA +59 -0
- bb_integrations_library-3.0.11.dist-info/RECORD +217 -0
- bb_integrations_library-3.0.11.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
from datetime import datetime, UTC
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from typing import Optional, Union, Dict, Any, List
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ValidationError, model_validator, Field, ConfigDict
|
|
6
|
+
|
|
7
|
+
from bb_integrations_lib.models.rita.probe import ProbeConfig
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MaxSync(BaseModel):
|
|
11
|
+
"""
|
|
12
|
+
This class tracks the most recent synchronization timestamp for a job config
|
|
13
|
+
along with additional contextual information needed for resuming sync operations.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
max_sync_date (datetime): The timestamp of the most recent successful
|
|
17
|
+
synchronization. Defaults to the current UTC time. Serves as a checkpoint
|
|
18
|
+
to determine where to resume data synchronization from in later
|
|
19
|
+
sync operations.
|
|
20
|
+
context (dict): Key-value pairs storing additional synchronization context.
|
|
21
|
+
Can contain resume tokens, sync IDs, cursors, batch sizes, source versions,
|
|
22
|
+
retry counts, and other custom metadata specific to the job run.
|
|
23
|
+
|
|
24
|
+
Example:
|
|
25
|
+
>>> sync_info = MaxSync(
|
|
26
|
+
... max_sync_date=datetime(2024, 1, 15, 10, 30, 0, tzinfo=UTC),
|
|
27
|
+
... context={
|
|
28
|
+
... "resume_token": "abc123xyz",
|
|
29
|
+
... "sync_id": "sync_2024_001",
|
|
30
|
+
... "batch_size": 1000
|
|
31
|
+
... }
|
|
32
|
+
... )
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
max_sync_date: datetime = datetime.now(UTC)
|
|
36
|
+
context: Optional[Dict] = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ConfigType(str, Enum):
|
|
40
|
+
template = "template"
|
|
41
|
+
process = "process"
|
|
42
|
+
generic = "generic"
|
|
43
|
+
fileconfig = "fileconfig"
|
|
44
|
+
probeconfig = "probeconfig"
|
|
45
|
+
DEPRECATED_crossroadsconfig = "crossroadsconfig"
|
|
46
|
+
DEPRECATED_connectorconfig = "connectorconfig"
|
|
47
|
+
scheduler = "scheduler"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SchedulerJob(BaseModel):
|
|
51
|
+
enabled: bool = False
|
|
52
|
+
name: str
|
|
53
|
+
job_func: str = Field(description="The function from the scheduler module to run")
|
|
54
|
+
trigger: str = Field(description="The name of the APScheduler trigger to use for this job")
|
|
55
|
+
scheduler_kwargs: Optional[dict[str, Any]] = Field({},
|
|
56
|
+
description="To pass to the APScheduler job creation function")
|
|
57
|
+
job_kwargs: Optional[dict] = Field({}, description="To pass to the scheduled function")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class SchedulerConfig(BaseModel):
|
|
61
|
+
enabled: bool = False
|
|
62
|
+
jobs: list[SchedulerJob] = []
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Alert(BaseModel):
|
|
66
|
+
enabled: Optional[bool] = False
|
|
67
|
+
tolerance: Optional[int] = None
|
|
68
|
+
notification: Optional[bool] = False
|
|
69
|
+
distribution_list: Optional[list[str]] = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ConfigAction(str, Enum):
|
|
73
|
+
concat = "concat"
|
|
74
|
+
parse_date = "parse_date"
|
|
75
|
+
concat_date = "concat_date"
|
|
76
|
+
add = "add"
|
|
77
|
+
copy = "copy"
|
|
78
|
+
remove_leading_zeros = "remove_leading_zeros"
|
|
79
|
+
remove_trailing_zeros = "remove_trailing_zeros"
|
|
80
|
+
wesroc_volume_formula = "wesroc_volume_formula"
|
|
81
|
+
blank = ""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class FileConfigColumn(BaseModel):
|
|
85
|
+
column_name: str
|
|
86
|
+
file_columns: list[str]
|
|
87
|
+
action: ConfigAction | None = None # "None" is implicitly a copy action.
|
|
88
|
+
format: str | None = None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class FileConfig(BaseModel):
|
|
92
|
+
"""Configuration information that details how a file should be processed."""
|
|
93
|
+
client_name: str = ""
|
|
94
|
+
file_name: str = ""
|
|
95
|
+
file_extension: str = 'csv'
|
|
96
|
+
separator: str = ''
|
|
97
|
+
cols: list[FileConfigColumn] = []
|
|
98
|
+
source_system: str = ""
|
|
99
|
+
inbound_directory: str = ""
|
|
100
|
+
archive_directory: str = ""
|
|
101
|
+
date_format: str = ""
|
|
102
|
+
config_id: Optional[str] = Field(default=None,
|
|
103
|
+
exclude=True) # Placeholder to stuff the parent config ID when needed.
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class Config(BaseModel):
|
|
107
|
+
id: str = Field(..., alias="_id")
|
|
108
|
+
name: str
|
|
109
|
+
created_by: str
|
|
110
|
+
created_on: datetime = datetime.now(UTC)
|
|
111
|
+
updated_on: datetime = datetime.now(UTC)
|
|
112
|
+
updated_by: Optional[str] = None
|
|
113
|
+
type: ConfigType
|
|
114
|
+
owning_bucket_id: Optional[str] = None
|
|
115
|
+
password_fields: Optional[list[str]] = None
|
|
116
|
+
config: Union[Dict[str, Any], List[Any]]
|
|
117
|
+
alert: Optional[Alert] = Alert()
|
|
118
|
+
max_sync: Optional[MaxSync] = None
|
|
119
|
+
|
|
120
|
+
# I hate this. A config is not necessarily an integration. An Integration can have a config.
|
|
121
|
+
# We should instead model the Integration/Connection to contain max_sync or something like it.
|
|
122
|
+
# TODO: speak with Ben/Nick
|
|
123
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@model_validator(mode='before')
|
|
127
|
+
@classmethod
|
|
128
|
+
def ensure_alert_is_not_none(self, values):
|
|
129
|
+
if isinstance(values, dict):
|
|
130
|
+
if values.get("alert") is None:
|
|
131
|
+
values["alert"] = Alert()
|
|
132
|
+
return values
|
|
133
|
+
|
|
134
|
+
def validate_type(self) -> bool:
|
|
135
|
+
if self.type == "generic":
|
|
136
|
+
return True
|
|
137
|
+
|
|
138
|
+
if self.type == "fileconfig":
|
|
139
|
+
try:
|
|
140
|
+
FileConfig(**self.config)
|
|
141
|
+
return True
|
|
142
|
+
except ValidationError as e:
|
|
143
|
+
print(e)
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
if self.type == "probeconfig":
|
|
147
|
+
try:
|
|
148
|
+
ProbeConfig(**self.config)
|
|
149
|
+
return True
|
|
150
|
+
except ValidationError as e:
|
|
151
|
+
print(e)
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
if self.type == "scheduler":
|
|
155
|
+
try:
|
|
156
|
+
SchedulerConfig(**self.config)
|
|
157
|
+
return True
|
|
158
|
+
except ValidationError as e:
|
|
159
|
+
print(e)
|
|
160
|
+
return False
|
|
161
|
+
return True
|
|
162
|
+
|
|
163
|
+
def get_config_value(self):
|
|
164
|
+
if self.type == "generic":
|
|
165
|
+
return self.config
|
|
166
|
+
|
|
167
|
+
if self.type == "fileconfig":
|
|
168
|
+
return FileConfig(**self.config)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class GenericConfig(BaseModel):
|
|
172
|
+
config_id: str
|
|
173
|
+
config: Any
|
|
174
|
+
|
|
175
|
+
class Config:
|
|
176
|
+
arbitrary_types_allowed = True
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
if __name__ == "__main__":
|
|
180
|
+
doc = {
|
|
181
|
+
"name": "Test Config",
|
|
182
|
+
"created_by": "user123",
|
|
183
|
+
"type": "fileconfig",
|
|
184
|
+
"config": {}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
config = Config(**doc)
|
|
188
|
+
print(config.alert)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
parent_keys = [{'field': 'id', 'hide': True},
|
|
2
|
+
{'field': 'source_id', 'filter': True, 'editable': True, 'headerName': 'Source Id'},
|
|
3
|
+
{'field': 'gravitate_id', 'filter': True, 'editable': True, 'headerName': 'Gravitate Id'},
|
|
4
|
+
{'field': 'updated_by', 'filter': True, 'headerName': 'Updated By'},
|
|
5
|
+
{'field': 'updated_on', 'filter': True, 'headerName': 'Updated On', 'type': 'datetime'},
|
|
6
|
+
{'field': 'type', 'filter': True, 'headerName': 'Type'},
|
|
7
|
+
{'field': 'source_system', 'filter': True, 'headerName': 'Source System'},
|
|
8
|
+
|
|
9
|
+
]
|
|
10
|
+
children_keys = [{'field': 'id', 'hide': True},
|
|
11
|
+
{'field': 'parent_id', 'hide': True},
|
|
12
|
+
{'field': 'source_id', 'filter': True, 'editable': True, 'headerName': 'Source Id'},
|
|
13
|
+
{'field': 'gravitate_id', 'filter': True, 'editable': True, 'headerName': 'Gravitate Id'},
|
|
14
|
+
{'field': 'parent_source_id', 'filter': True, 'headerName': 'Parent Source ID'},
|
|
15
|
+
{'field': 'parent_gravitate_id', 'filter': True, 'headerName': 'Parent Gravitate Id'},
|
|
16
|
+
{'field': 'parent_type', 'filter': True, 'headerName': 'Parent Type'},
|
|
17
|
+
{'field': 'updated_by', 'filter': True, 'headerName': 'Updated By'},
|
|
18
|
+
{'field': 'updated_on', 'filter': True, 'headerName': 'Updated On'},
|
|
19
|
+
]
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from typing import Optional, Literal, Self, List
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, computed_field, model_validator, Field
|
|
6
|
+
|
|
7
|
+
from bb_integrations_lib.shared.model import AgGridBaseModel
|
|
8
|
+
from bb_integrations_lib.util.utils import is_valid_goid, is_uuid
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Point(BaseModel):
|
|
12
|
+
lat: float
|
|
13
|
+
lon: float
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CrossroadsEntityType(str, Enum):
|
|
17
|
+
"""Describes the type of entity used for crossroads. These are independent of Supply & Dispatch's model. At present
|
|
18
|
+
it's a pretty close match but they will diverge more as more crossroads features are added."""
|
|
19
|
+
site = "site",
|
|
20
|
+
tank = "tank",
|
|
21
|
+
terminal = "terminal",
|
|
22
|
+
product = "product",
|
|
23
|
+
company = "company"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class BaseCrossroadsEntity(AgGridBaseModel):
|
|
27
|
+
"""Base Crossroads Entity. All entities have an is_active switch for soft deletion and a GOID for unique identification.
|
|
28
|
+
All entities need a grid. They all need to extend AgGridBaseModel.
|
|
29
|
+
"""
|
|
30
|
+
goid: str = Field(..., description="Gravitate Object ID of the record. This field is autogenerated, unique, and immutable.", frozen=True)
|
|
31
|
+
is_active: bool = Field(True)
|
|
32
|
+
record_owner: Optional[str] = Field(None, description="GOID of the company that owns this record. May be null.")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CrossroadsCompany(BaseCrossroadsEntity):
|
|
36
|
+
"""A Compnay is any business entity. It forms the backbone of the relationship network and the integration network and is a component of the entity network."""
|
|
37
|
+
name: str = Field(..., description="Name of the company")
|
|
38
|
+
description: Optional[str] = Field(None, description="Details")
|
|
39
|
+
is_gravitate_customer: bool = Field(False, description="Does this company use at least one Gravitate product?")
|
|
40
|
+
rita_tenant: Optional[str] = Field(None, description="The tenant this company is associated with, if any. Typically only exists for Gravitate customers.")
|
|
41
|
+
ein: Optional[str] = Field(None, description="The Federal Employer Identification Number of the company.")
|
|
42
|
+
is_retailer: bool = Field(False, description="True if the company is ever a retailer.")
|
|
43
|
+
is_wholesaler: bool = Field(False, description="True if the company is ever a wholesaler.")
|
|
44
|
+
is_carrier: bool = Field(False, description="True if the company is ever a carrier.")
|
|
45
|
+
is_supplier: bool = Field(False, description="True if the company is ever a supplier.")
|
|
46
|
+
is_dealer: bool = Field(False, description="True if the company is ever a dealer.")
|
|
47
|
+
|
|
48
|
+
def flatten(self) -> dict:
|
|
49
|
+
obj = self.model_dump(mode="json", exclude={"is_active"})
|
|
50
|
+
return obj
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def unflatten(cls, flattened: dict) -> Self:
|
|
54
|
+
goid = flattened.get("goid") or str(uuid.uuid4())
|
|
55
|
+
return cls.model_validate(flattened | {"goid": goid})
|
|
56
|
+
|
|
57
|
+
@model_validator(mode="after")
|
|
58
|
+
def validate_goid(self):
|
|
59
|
+
if not is_valid_goid(self.goid, "company") and not is_uuid(self.goid):
|
|
60
|
+
raise ValueError("GOID must be in the form `company:<number>`")
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ProductType(str, Enum):
|
|
65
|
+
"""Top-level type for products"""
|
|
66
|
+
gas = "gas",
|
|
67
|
+
diesel = "diesel",
|
|
68
|
+
jet = "jet",
|
|
69
|
+
ethanol = "ethanol",
|
|
70
|
+
bio = "bio"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ProductSubtype(str, Enum):
|
|
74
|
+
"""Subtype for products. Some of these are only valid for certain product types. This is enforced by a validator on the Product entity."""
|
|
75
|
+
rbob = "RBOB",
|
|
76
|
+
cbob = "CBOB",
|
|
77
|
+
carb = "CARBOB",
|
|
78
|
+
ulsd = "ULSD"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
gas_subtypes = [ProductSubtype.rbob, ProductSubtype.cbob, ProductSubtype.carb]
|
|
82
|
+
diesel_subtypes = [ProductSubtype.ulsd]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class CrossroadsProduct(BaseCrossroadsEntity):
|
|
86
|
+
"""A fully-described product in the crossroads network."""
|
|
87
|
+
type: ProductType = Field(..., description="Type of product")
|
|
88
|
+
subtype: ProductSubtype = Field(..., description="Subtype of product")
|
|
89
|
+
name: str = Field(..., description="Name of the product")
|
|
90
|
+
gas_octane: Optional[int] = Field(None, description="Octane rating. type == gas only.")
|
|
91
|
+
gas_ethanol: Optional[int] = Field(None, description="Ethanol component as a percentage. type == gas only.")
|
|
92
|
+
gas_rvp: Optional[float] = Field(None, description="Reid Vapor Pressure. type == gas only.")
|
|
93
|
+
gas_formulation: Optional[Literal["Conv", "RFG"]] = Field(None, description="Formulation of the gas. type == gas only.")
|
|
94
|
+
diesel_no: Optional[int] = Field(None, description="Diesel formulation number. type == diesel only.")
|
|
95
|
+
diesel_bio: Optional[int] = Field(None, description="Diesel bio component as a percentage. type == diesel only.")
|
|
96
|
+
diesel_winter: Optional[bool] = Field(None, description="Whether this is a winter diesel blend. type == diesel only.")
|
|
97
|
+
diesel_dyed: Optional[bool] = Field(None, description="Whether this is a dyed diesel product. type == diesel only.")
|
|
98
|
+
diesel_additive: Optional[bool] = Field(None, description="Whether this product has additives. type == diesel only.")
|
|
99
|
+
# More properties planned for other product types
|
|
100
|
+
|
|
101
|
+
@computed_field
|
|
102
|
+
@property
|
|
103
|
+
def description(self) -> str:
|
|
104
|
+
if self.type == "gas":
|
|
105
|
+
return f"Gasoline {self.subtype.value} {self.gas_octane} E{self.gas_ethanol} {self.gas_rvp} RVP {self.gas_formulation}"
|
|
106
|
+
if self.type == "diesel":
|
|
107
|
+
return f"Diesel {self.subtype.value} #{self.diesel_no} B{self.diesel_bio} {"winter blend" if self.diesel_winter else "summer blend"} {"dyed" if self.diesel_dyed else "clear"} {"w/ additive" if self.diesel_additive else ""}"
|
|
108
|
+
return ""
|
|
109
|
+
|
|
110
|
+
def flatten(self) -> dict:
|
|
111
|
+
obj = self.model_dump(mode="json", exclude={"is_active"})
|
|
112
|
+
return obj
|
|
113
|
+
|
|
114
|
+
@classmethod
|
|
115
|
+
def unflatten(cls, flattened: dict) -> Self:
|
|
116
|
+
goid = flattened.get("goid") or str(uuid.uuid4())
|
|
117
|
+
return cls.model_validate(flattened | {"goid": goid})
|
|
118
|
+
|
|
119
|
+
@model_validator(mode="after")
|
|
120
|
+
def validate_fields(self):
|
|
121
|
+
if self.type == "gas":
|
|
122
|
+
if self.gas_octane is None or self.gas_ethanol is None or self.gas_rvp is None or self.gas_formulation is None:
|
|
123
|
+
raise ValueError("Gas product must have gas_octane, gas_ethanol, gas_rvp, and gas_formulation fields")
|
|
124
|
+
if self.diesel_no is not None or self.diesel_bio is not None or self.diesel_winter is not None or self.diesel_dyed is not None or self.diesel_additive is not None:
|
|
125
|
+
raise ValueError(
|
|
126
|
+
"Gas product cannot have diesel_no, diesel_bio, diesel_winter, diesel_dyed, or diesel_additive fields")
|
|
127
|
+
if self.subtype not in gas_subtypes:
|
|
128
|
+
raise ValueError(f"Gas product must have one of the following subtypes: {gas_subtypes}")
|
|
129
|
+
if self.type == "diesel":
|
|
130
|
+
if self.diesel_no is None or self.diesel_bio is None or self.diesel_winter is None or self.diesel_dyed is None or self.diesel_additive is None:
|
|
131
|
+
raise ValueError(
|
|
132
|
+
"Diesel product must have diesel_no, diesel_bio, diesel_winter, diesel_dyed, and diesel_additive fields")
|
|
133
|
+
if self.gas_octane is not None or self.gas_ethanol is not None or self.gas_rvp is not None or self.gas_formulation is not None:
|
|
134
|
+
raise ValueError("Diesel product cannot have gas_octane, gas_ethanol, gas_rvp, or gas_formulation fields")
|
|
135
|
+
if self.subtype not in diesel_subtypes:
|
|
136
|
+
raise ValueError(f"Diesel product must have one of the following subtypes: {diesel_subtypes}")
|
|
137
|
+
return self
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class SiteStatus(str, Enum):
|
|
141
|
+
open = "open",
|
|
142
|
+
closed = "closed"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class CrossroadsSite(BaseCrossroadsEntity):
|
|
146
|
+
"""Describes a site in the crossroads network."""
|
|
147
|
+
federal_site_id: Optional[str] = Field(None, description="Facility ID from the US EPA, if available.")
|
|
148
|
+
name: str = Field(..., description="Name of the site")
|
|
149
|
+
address: str = Field(..., description="Street address of the site")
|
|
150
|
+
city: str = Field(..., description="City")
|
|
151
|
+
state: str = Field(..., description="State (not abbreviated)")
|
|
152
|
+
country: str = Field(..., description="Country (not abbreviated)")
|
|
153
|
+
postal_code: str = Field(..., description="Postal code")
|
|
154
|
+
location: Point = Field(..., description="Geographic location of the site")
|
|
155
|
+
geofence: Optional[List[Point]] = Field(None, description="A list of points that form a polygon defining the geofence for this site. Points should be in order, with the first and last points being the same.", )
|
|
156
|
+
status: SiteStatus = Field("open", description="Whether the site is open or closed")
|
|
157
|
+
|
|
158
|
+
def flatten(self) -> dict:
|
|
159
|
+
obj = self.model_dump(mode="json", exclude={"is_active", "location", "geofence"})
|
|
160
|
+
obj["lat"] = self.location.lat
|
|
161
|
+
obj["lon"] = self.location.lon
|
|
162
|
+
return obj
|
|
163
|
+
|
|
164
|
+
@classmethod
|
|
165
|
+
def unflatten(cls, flattened: dict) -> Self:
|
|
166
|
+
location = {"lat": flattened.get("lat", 0), "lon": flattened.get("lon", 0)}
|
|
167
|
+
goid = flattened.get("goid") or str(uuid.uuid4())
|
|
168
|
+
return cls.model_validate(flattened | {"location": location, "goid": goid})
|
|
169
|
+
|
|
170
|
+
@model_validator(mode="after")
|
|
171
|
+
def validate_goid(self):
|
|
172
|
+
if not is_valid_goid(self.goid, "site") and not is_uuid(self.goid):
|
|
173
|
+
raise ValueError("goid must be in the form `site:<number>`")
|
|
174
|
+
if self.record_owner is not None and not is_valid_goid(self.record_owner, "company"):
|
|
175
|
+
raise ValueError("record_owner must be a GOID in the form `company:<number>`")
|
|
176
|
+
return self
|
|
177
|
+
|
|
178
|
+
@model_validator(mode="after")
|
|
179
|
+
def validate_geofence(self):
|
|
180
|
+
if self.geofence is None:
|
|
181
|
+
return self
|
|
182
|
+
if len(self.geofence) < 3:
|
|
183
|
+
raise ValueError("Geofence must contain at least 3 points")
|
|
184
|
+
if self.geofence[0] != self.geofence[-1]:
|
|
185
|
+
raise ValueError("First and last points of geofence must be the same")
|
|
186
|
+
return self
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class TankStatus(str, Enum):
|
|
190
|
+
open = "open",
|
|
191
|
+
closed = "closed",
|
|
192
|
+
transitioning = "transitioning"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class CrossroadsTank(BaseCrossroadsEntity):
|
|
196
|
+
"""Describes a tank in the crossroads network."""
|
|
197
|
+
tank_id: str = Field(..., description="ID of the tank. Must be unique within the site.")
|
|
198
|
+
federal_tank_id: Optional[str] = Field(None, description="Federal Tank ID, if available.")
|
|
199
|
+
inventory_manager: Optional[str] = Field(None, description="GOID of the company that manages the inventory for this tank. If it is null, the inventory manager is unknown.")
|
|
200
|
+
site: str = Field(..., description="The site that this tank belongs to.")
|
|
201
|
+
product: str = Field(..., description="ID of the product that this tank is for.")
|
|
202
|
+
brand: Literal["Branded", "Unbranded"] = Field(..., description="Whether this tank has a branded or unbranded product.")
|
|
203
|
+
tank_size: int = Field(..., description="Volume of the tank in gallons.")
|
|
204
|
+
status: TankStatus = Field("open", description="Status of the tank. If 'transitioning', see the transitioning fields for details.")
|
|
205
|
+
transitioning_to: Optional[str] = Field(None, description="GOID of the product that this tank is transitioning to. If null, this tank is not transitioning.")
|
|
206
|
+
transitioning_from: Optional[str] = Field(None, description="GOID of the product that this tank is transitioning from. If null, this tank is not transitioning.")
|
|
207
|
+
storage_max: Optional[int] = Field(..., description="Maximum storage capacity of the tank in gallons.")
|
|
208
|
+
fuel_bottom: Optional[int] = Field(..., description="Fuel bottom of the tank in gallons.")
|
|
209
|
+
|
|
210
|
+
@computed_field
|
|
211
|
+
@property
|
|
212
|
+
def name(self) -> str:
|
|
213
|
+
return f"Tank {self.tank_id} - {self.product}"
|
|
214
|
+
|
|
215
|
+
def flatten(self) -> dict:
|
|
216
|
+
obj = self.model_dump(mode="json", exclude={"is_active"})
|
|
217
|
+
return obj
|
|
218
|
+
|
|
219
|
+
@classmethod
|
|
220
|
+
def unflatten(cls, flattened: dict) -> Self:
|
|
221
|
+
goid = flattened.get("goid") or str(uuid.uuid4())
|
|
222
|
+
return cls.model_validate(flattened | {"goid": goid})
|
|
223
|
+
|
|
224
|
+
@model_validator(mode="after")
|
|
225
|
+
def validate(self):
|
|
226
|
+
if not is_valid_goid(self.goid, "tank") and not is_uuid(self.goid):
|
|
227
|
+
raise ValueError("goid must be a GOID in the form `tank:<number>`")
|
|
228
|
+
if self.record_owner is not None and not is_valid_goid(self.record_owner, "company"):
|
|
229
|
+
raise ValueError("record_owner must be a GOID in the form `company:<number>`")
|
|
230
|
+
if self.inventory_manager is not None and not is_valid_goid(self.inventory_manager, "company"):
|
|
231
|
+
raise ValueError("inventory_manager must be a GOID in the form `company:<number>`")
|
|
232
|
+
if not is_valid_goid(self.site, "site"):
|
|
233
|
+
raise ValueError("site must be a GOID in the form `site:<number>`")
|
|
234
|
+
if not is_valid_goid(self.product, "product"):
|
|
235
|
+
raise ValueError("product must be a GOID in the form `product:<number>`")
|
|
236
|
+
if self.status == "transitioning" and (self.transitioning_to is None and self.transitioning_from is None):
|
|
237
|
+
raise ValueError("If status is transitioning, transitioning_to_product_id and transitioning_from_product_id must be set")
|
|
238
|
+
if self.transitioning_to is not None and not is_valid_goid(self.transitioning_to, "product"):
|
|
239
|
+
raise ValueError("transitioning_to must be a GOID in the form `product:<number>`")
|
|
240
|
+
if self.transitioning_from is not None and not is_valid_goid(self.transitioning_from, "product"):
|
|
241
|
+
raise ValueError("transitioning_from must be a GOID in the form `product:<number>`")
|
|
242
|
+
return self
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class CrossroadsTerminal(BaseCrossroadsEntity):
|
|
246
|
+
"""Describes a terminal in the Crossroads Network"""
|
|
247
|
+
tcn: Optional[str] = Field(None, description="Federal Terminal ID. This should be set for most terminals.")
|
|
248
|
+
alternate_id: Optional[str] = Field(None, description="Alternate ID for this terminal. This must be set if tcn is not set.")
|
|
249
|
+
name: str = Field(..., description="Name of the terminal")
|
|
250
|
+
address: str = Field(..., description="Street address of the terminal")
|
|
251
|
+
city: str = Field(..., description="City")
|
|
252
|
+
state: str = Field(..., description="State (not abbreviated)")
|
|
253
|
+
country: str = Field(..., description="Country (not abbreviated)")
|
|
254
|
+
postal_code: str = Field(..., description="Postal code")
|
|
255
|
+
location: Point = Field(..., description="Geographic location of the terminal")
|
|
256
|
+
geofence: Optional[List[Point]] = Field(None, description="A list of points that form a polygon defining the geofence for this terminal. Points should be in order, with the first and last points being the same.", )
|
|
257
|
+
terminal_owner: Optional[str] = Field(None, description="GOID of the company that owns this terminal. If it is null, no link has been made between this record and a Company record.")
|
|
258
|
+
products: List[str] = Field([], description="List of product GOIDs that this terminal can supply.")
|
|
259
|
+
suppliers: List[str] = Field([], description="List of company GOIDs for suppliers that are available at this terminal.")
|
|
260
|
+
|
|
261
|
+
def flatten(self) -> dict:
|
|
262
|
+
obj = self.model_dump(mode="json", exclude={"is_active", "location", "geofence", "products", "suppliers"})
|
|
263
|
+
obj["lat"] = self.location.lat
|
|
264
|
+
obj["lon"] = self.location.lon
|
|
265
|
+
return obj
|
|
266
|
+
|
|
267
|
+
@classmethod
|
|
268
|
+
def unflatten(cls, flattened: dict) -> Self:
|
|
269
|
+
location = {"lat": flattened.get("lat", 0), "lon": flattened.get("lon", 0)}
|
|
270
|
+
goid = flattened.get("goid") or str(uuid.uuid4())
|
|
271
|
+
return cls.model_validate(flattened | {"location": location, "goid": goid})
|
|
272
|
+
|
|
273
|
+
@model_validator(mode="after")
|
|
274
|
+
def validate(self):
|
|
275
|
+
if (self.tcn is None and self.alternate_id is None) or (self.tcn is not None and self.alternate_id is not None):
|
|
276
|
+
raise ValueError("Terminal must have either a tcn or an alternate_id, but not both.")
|
|
277
|
+
if not is_valid_goid(self.goid, "terminal") and not is_uuid(self.goid):
|
|
278
|
+
raise ValueError("goid must be in the form `terminal:<number>`")
|
|
279
|
+
if self.record_owner is not None and not is_valid_goid(self.record_owner, "company"):
|
|
280
|
+
raise ValueError("reco:rd_owner must be a GOID in the form `company:<number>`")
|
|
281
|
+
if self.terminal_owner is not None and not is_valid_goid(self.terminal_owner, "company"):
|
|
282
|
+
raise ValueError("terminal_owner must be a GOID in the form `company:<number>`")
|
|
283
|
+
return self
|
|
284
|
+
|
|
285
|
+
@model_validator(mode="after")
|
|
286
|
+
def validate_geofence(self):
|
|
287
|
+
if self.geofence is None:
|
|
288
|
+
return self
|
|
289
|
+
if len(self.geofence) < 3:
|
|
290
|
+
raise ValueError("Geofence must contain at least 3 points")
|
|
291
|
+
if self.geofence[0] != self.geofence[-1]:
|
|
292
|
+
raise ValueError("First and last points of geofence must be the same")
|
|
293
|
+
return self
|