Compare commits
34 Commits
feature/j_
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| f68e9ee218 | |||
| 18f6d53998 | |||
| 4b8e878735 | |||
| 3f7da82ea6 | |||
| c1e3e8939a | |||
| dc98b1d500 | |||
| a50cd9cc9a | |||
| d06669e943 | |||
| 6362f47d43 | |||
| c6954fb222 | |||
| 6d8b86280c | |||
| 2a1570d9f5 | |||
| d180dac600 | |||
| 8b4131332b | |||
| 27b9f46f30 | |||
| c8550431e0 | |||
| a1b807824e | |||
| 189626d61c | |||
| 3d76acb2f0 | |||
| 98c05aed3b | |||
| 98696aee93 | |||
| 7f706dfc51 | |||
| ab12e28d3d | |||
| e9a7e03ebf | |||
| f1c5bd3cd8 | |||
| bb13d74849 | |||
| 55cf17d169 | |||
| ea634a3af2 | |||
| 21471d4d41 | |||
| fce897fae4 | |||
| 64c6607076 | |||
| 6dedc04957 | |||
| 213f7cf58c | |||
| 49a8498bbe |
@ -5008,7 +5008,7 @@ namespace BreCalClient.misc.Client
|
||||
{
|
||||
Proxy = null;
|
||||
UserAgent = WebUtility.UrlEncode("OpenAPI-Generator/1.0.0/csharp");
|
||||
BasePath = "https://brecaldevel.bsmd-emswe.eu";
|
||||
BasePath = "https://brecaltest.bsmd-emswe.eu";
|
||||
DefaultHeaders = new ConcurrentDictionary<string, string>();
|
||||
ApiKey = new ConcurrentDictionary<string, string>();
|
||||
ApiKeyPrefix = new ConcurrentDictionary<string, string>();
|
||||
@ -5016,7 +5016,7 @@ namespace BreCalClient.misc.Client
|
||||
{
|
||||
{
|
||||
new Dictionary<string, object> {
|
||||
{"url", "https://brecaldevel.bsmd-emswe.eu"},
|
||||
{"url", "https://brecaltest.bsmd-emswe.eu"},
|
||||
{"description", "Development server hosted on vcup"},
|
||||
}
|
||||
}
|
||||
@ -5035,7 +5035,7 @@ namespace BreCalClient.misc.Client
|
||||
IDictionary<string, string> defaultHeaders,
|
||||
IDictionary<string, string> apiKey,
|
||||
IDictionary<string, string> apiKeyPrefix,
|
||||
string basePath = "https://brecaldevel.bsmd-emswe.eu") : this()
|
||||
string basePath = "https://brecaltest.bsmd-emswe.eu") : this()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(basePath))
|
||||
throw new ArgumentException("The provided basePath is invalid.", "basePath");
|
||||
|
||||
@ -14,7 +14,7 @@ info:
|
||||
name: Use at your own risk
|
||||
url: 'https://www.bsmd.de/license'
|
||||
servers:
|
||||
- url: 'https://brecaldevel.bsmd-emswe.eu'
|
||||
- url: 'https://brecaltest.bsmd-emswe.eu'
|
||||
description: Development server hosted on vcup
|
||||
tags:
|
||||
- name: user
|
||||
@ -658,12 +658,12 @@ paths:
|
||||
- notification
|
||||
operationId: notificationsGet
|
||||
parameters:
|
||||
- name: shipcall_id
|
||||
- name: participant_id
|
||||
in: query
|
||||
required: false
|
||||
description: '**Id of ship call**. *Example: 52*. Id given in ship call list'
|
||||
description: '**Id of participant**. *Example: 7*. Id of logged in participant.'
|
||||
schema:
|
||||
$ref: '#/components/schemas/shipcallId'
|
||||
$ref: '#/components/schemas/participant_id'
|
||||
responses:
|
||||
'200':
|
||||
description: notification list
|
||||
|
||||
153
misc/Readme.md
153
misc/Readme.md
@ -48,3 +48,156 @@ DROP TABLE IF EXISTS `shipcall`;
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
```
|
||||
|
||||
## Schema
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
participant {
|
||||
INT id PK
|
||||
VARCHAR name
|
||||
VARCHAR street
|
||||
VARCHAR postal_code
|
||||
VARCHAR city
|
||||
INT type
|
||||
INT flags
|
||||
}
|
||||
port {
|
||||
INT id PK
|
||||
VARCHAR name
|
||||
CHAR locode
|
||||
}
|
||||
berth {
|
||||
INT id PK
|
||||
VARCHAR name
|
||||
BIT lock
|
||||
INT owner_id FK
|
||||
INT authority_id FK
|
||||
INT port_id FK
|
||||
BIT deleted
|
||||
}
|
||||
ship {
|
||||
INT id PK
|
||||
VARCHAR name
|
||||
INT imo
|
||||
VARCHAR callsign
|
||||
INT participant_id FK
|
||||
FLOAT length
|
||||
FLOAT width
|
||||
BIT is_tug
|
||||
INT bollard_pull
|
||||
INT eni
|
||||
BIT deleted
|
||||
}
|
||||
shipcall {
|
||||
INT id PK
|
||||
INT ship_id FK
|
||||
TINYINT type
|
||||
DATETIME eta
|
||||
DATETIME etd
|
||||
INT arrival_berth_id FK
|
||||
INT departure_berth_id FK
|
||||
INT port_id FK
|
||||
INT flags
|
||||
BIT tug_required
|
||||
BIT pilot_required
|
||||
}
|
||||
times {
|
||||
INT id PK
|
||||
INT shipcall_id FK
|
||||
INT participant_id FK
|
||||
INT berth_id FK
|
||||
INT participant_type
|
||||
DATETIME eta_berth
|
||||
DATETIME etd_berth
|
||||
DATETIME lock_time
|
||||
DATETIME zone_entry
|
||||
}
|
||||
notification {
|
||||
INT id PK
|
||||
INT shipcall_id FK
|
||||
INT participant_id FK
|
||||
TINYINT level
|
||||
TINYINT type
|
||||
}
|
||||
history {
|
||||
INT id PK
|
||||
INT participant_id FK
|
||||
INT user_id FK
|
||||
INT shipcall_id FK
|
||||
DATETIME timestamp
|
||||
DATETIME eta
|
||||
INT type
|
||||
INT operation
|
||||
}
|
||||
shipcall_participant_map {
|
||||
INT id PK
|
||||
INT shipcall_id FK
|
||||
INT participant_id FK
|
||||
INT type
|
||||
}
|
||||
shipcall_tug_map {
|
||||
INT id PK
|
||||
INT shipcall_id FK
|
||||
INT ship_id FK
|
||||
}
|
||||
participant_port_map {
|
||||
INT id PK
|
||||
INT participant_id FK
|
||||
INT port_id FK
|
||||
}
|
||||
user {
|
||||
INT id PK
|
||||
INT participant_id FK
|
||||
VARCHAR first_name
|
||||
VARCHAR last_name
|
||||
VARCHAR user_name
|
||||
VARCHAR user_email
|
||||
}
|
||||
role {
|
||||
INT id PK
|
||||
VARCHAR name
|
||||
VARCHAR description
|
||||
}
|
||||
securable {
|
||||
INT id PK
|
||||
VARCHAR name
|
||||
}
|
||||
role_securable_map {
|
||||
INT id PK
|
||||
INT role_id FK
|
||||
INT securable_id FK
|
||||
}
|
||||
user_role_map {
|
||||
INT id PK
|
||||
INT user_id FK
|
||||
INT role_id FK
|
||||
}
|
||||
|
||||
participant ||--o{ berth : owner_id
|
||||
participant ||--o{ berth : authority_id
|
||||
port ||--o{ berth : port_id
|
||||
participant ||--o{ ship : participant_id
|
||||
ship ||--o{ shipcall : ship_id
|
||||
berth ||--o{ shipcall : arrival_berth_id
|
||||
berth ||--o{ shipcall : departure_berth_id
|
||||
port ||--o{ shipcall : port_id
|
||||
shipcall ||--|| times : shipcall_id
|
||||
participant ||--|| times : participant_id
|
||||
berth ||--o{ times : berth_id
|
||||
shipcall ||--o{ notification : shipcall_id
|
||||
participant ||--o{ notification : participant_id
|
||||
participant ||--o{ history : participant_id
|
||||
user ||--o{ history : user_id
|
||||
shipcall ||--o{ history : shipcall_id
|
||||
shipcall ||--o{ shipcall_participant_map : shipcall_id
|
||||
participant ||--o{ shipcall_participant_map : participant_id
|
||||
shipcall ||--o{ shipcall_tug_map : shipcall_id
|
||||
ship ||--o{ shipcall_tug_map : ship_id
|
||||
participant ||--o{ participant_port_map : participant_id
|
||||
port ||--o{ participant_port_map : port_id
|
||||
participant ||--o{ user : participant_id
|
||||
user ||--o{ user_role_map : user_id
|
||||
role ||--o{ user_role_map : role_id
|
||||
role ||--o{ role_securable_map : role_id
|
||||
securable ||--o{ role_securable_map : securable_id
|
||||
```
|
||||
|
||||
46
misc/check_clear_double_times_entries.sql
Normal file
46
misc/check_clear_double_times_entries.sql
Normal file
@ -0,0 +1,46 @@
|
||||
-- Inspect duplicates first
|
||||
WITH duplicate_participants AS (
|
||||
SELECT
|
||||
shipcall_id,
|
||||
participant_type,
|
||||
COUNT(*) AS cnt
|
||||
FROM times
|
||||
GROUP BY shipcall_id, participant_type
|
||||
HAVING COUNT(*) > 1
|
||||
)
|
||||
SELECT
|
||||
t.*
|
||||
FROM times AS t
|
||||
JOIN duplicate_participants AS d
|
||||
ON d.shipcall_id = t.shipcall_id
|
||||
AND (d.participant_type <=> t.participant_type)
|
||||
ORDER BY t.shipcall_id, t.participant_type, t.id;
|
||||
|
||||
-- Delete all but the highest-id entry per (shipcall_id, participant_type)
|
||||
WITH ordered_times AS (
|
||||
SELECT
|
||||
id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY shipcall_id, participant_type
|
||||
ORDER BY id DESC
|
||||
) AS rn
|
||||
FROM times
|
||||
)
|
||||
DELETE FROM times
|
||||
WHERE id IN (
|
||||
SELECT id
|
||||
FROM ordered_times
|
||||
WHERE rn > 1
|
||||
);
|
||||
|
||||
-- Optional: re-check that no duplicates remain
|
||||
WITH duplicate_participants AS (
|
||||
SELECT
|
||||
shipcall_id,
|
||||
participant_type,
|
||||
COUNT(*) AS cnt
|
||||
FROM times
|
||||
GROUP BY shipcall_id, participant_type
|
||||
HAVING COUNT(*) > 1
|
||||
)
|
||||
SELECT COUNT(*) AS remaining_duplicates FROM duplicate_participants;
|
||||
@ -1,8 +1,8 @@
|
||||
-- MySQL dump 10.13 Distrib 8.0.33, for Win64 (x86_64)
|
||||
-- MySQL dump 10.13 Distrib 8.0.43, for Win64 (x86_64)
|
||||
--
|
||||
-- Host: localhost Database: bremen_calling_test
|
||||
-- ------------------------------------------------------
|
||||
-- Server version 8.0.34-0ubuntu0.22.04.1
|
||||
-- Server version 8.0.42-0ubuntu0.24.10.1
|
||||
|
||||
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
|
||||
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
|
||||
@ -28,17 +28,65 @@ CREATE TABLE `berth` (
|
||||
`lock` bit(1) DEFAULT NULL COMMENT 'The lock must be used',
|
||||
`owner_id` int unsigned DEFAULT NULL,
|
||||
`authority_id` int unsigned DEFAULT NULL,
|
||||
`port_id` int unsigned DEFAULT NULL,
|
||||
`created` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`modified` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
`deleted` bit(1) DEFAULT b'0',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `FK_OWNER_PART_idx` (`owner_id`),
|
||||
KEY `FK_AUTHORITY_PART_idx` (`authority_id`),
|
||||
KEY `FK_AUTHORITY_PART_idx` (`authority_id`) /*!80000 INVISIBLE */,
|
||||
KEY `FK_PORT_PART_idx` (`port_id`),
|
||||
CONSTRAINT `FK_AUTHORITY_PART` FOREIGN KEY (`authority_id`) REFERENCES `participant` (`id`),
|
||||
CONSTRAINT `FK_OWNER_PART` FOREIGN KEY (`owner_id`) REFERENCES `participant` (`id`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=195 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Berth of ship for a ship call';
|
||||
CONSTRAINT `FK_OWNER_PART` FOREIGN KEY (`owner_id`) REFERENCES `participant` (`id`),
|
||||
CONSTRAINT `FK_PORT` FOREIGN KEY (`port_id`) REFERENCES `port` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=205 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Berth of ship for a ship call';
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `history`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `history`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!50503 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `history` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`participant_id` int unsigned NOT NULL,
|
||||
`user_id` int unsigned DEFAULT NULL,
|
||||
`shipcall_id` int unsigned NOT NULL,
|
||||
`timestamp` datetime NOT NULL COMMENT 'Time of saving',
|
||||
`eta` datetime DEFAULT NULL COMMENT 'Current ETA / ETD value (depends if shipcall or times were saved)',
|
||||
`type` int NOT NULL COMMENT 'shipcall or times',
|
||||
`operation` int NOT NULL COMMENT 'insert, update or delete',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `FK_HISTORY_PARTICIPANT_idx` (`participant_id`),
|
||||
KEY `FK_HISTORY_SHIPCALL_idx` (`shipcall_id`),
|
||||
KEY `FK_HISTORY_USER` (`user_id`),
|
||||
CONSTRAINT `FK_HISTORY_PARTICIPANT` FOREIGN KEY (`participant_id`) REFERENCES `participant` (`id`),
|
||||
CONSTRAINT `FK_HISTORY_SHIPCALL` FOREIGN KEY (`shipcall_id`) REFERENCES `shipcall` (`id`),
|
||||
CONSTRAINT `FK_HISTORY_USER` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=23537 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='This table stores a history of changes made to shipcalls so that everyone can see who changed what and when';
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
CREATE TABLE `history` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`participant_id` int unsigned NOT NULL,
|
||||
`user_id` int unsigned DEFAULT NULL,
|
||||
`shipcall_id` int unsigned NOT NULL,
|
||||
`timestamp` datetime NOT NULL COMMENT 'Time of saving',
|
||||
`eta` datetime DEFAULT NULL COMMENT 'Current ETA / ETD value (depends if shipcall or times were saved)',
|
||||
`type` int NOT NULL COMMENT 'shipcall or times',
|
||||
`operation` int NOT NULL COMMENT 'insert, update or delete',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `FK_HISTORY_PARTICIPANT_idx` (`participant_id`),
|
||||
KEY `FK_HISTORY_SHIPCALL_idx` (`shipcall_id`),
|
||||
KEY `FK_HISTORY_USER` (`user_id`),
|
||||
CONSTRAINT `FK_HISTORY_PARTICIPANT` FOREIGN KEY (`participant_id`) REFERENCES `participant` (`id`),
|
||||
CONSTRAINT `FK_HISTORY_SHIPCALL` FOREIGN KEY (`shipcall_id`) REFERENCES `shipcall` (`id`),
|
||||
CONSTRAINT `FK_HISTORY_USER` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=29292 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='This table stores a history of changes made to shipcalls so that everyone can see who changed what and when';
|
||||
|
||||
|
||||
--
|
||||
-- Table structure for table `notification`
|
||||
--
|
||||
@ -48,20 +96,19 @@ DROP TABLE IF EXISTS `notification`;
|
||||
/*!50503 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `notification` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`times_id` int unsigned NOT NULL COMMENT 'times record that caused the notification',
|
||||
`participant_id` int unsigned NOT NULL COMMENT 'participant ref',
|
||||
`acknowledged` bit(1) DEFAULT b'0' COMMENT 'true if UI acknowledged',
|
||||
`shipcall_id` int unsigned DEFAULT NULL,
|
||||
`participant_id` int unsigned DEFAULT NULL,
|
||||
`level` tinyint DEFAULT NULL COMMENT 'severity of the notification',
|
||||
`type` tinyint DEFAULT NULL COMMENT 'Email/UI/Other',
|
||||
`message` varchar(256) DEFAULT NULL COMMENT 'individual message',
|
||||
`message` varchar(512) DEFAULT NULL COMMENT 'individual message',
|
||||
`created` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`modified` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `FK_NOT_TIMES` (`times_id`),
|
||||
KEY `FK_NOT_PART` (`participant_id`),
|
||||
CONSTRAINT `FK_NOT_PART` FOREIGN KEY (`participant_id`) REFERENCES `participant` (`id`),
|
||||
CONSTRAINT `FK_NOT_TIMES` FOREIGN KEY (`times_id`) REFERENCES `times` (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='An entry corresponds to an alarm given by a violated rule during times update';
|
||||
KEY `FK_NOTIFICATION_SHIPCALL_idx` (`shipcall_id`),
|
||||
KEY `FK_NOTIFICATION_PARTICIPANT_idx` (`participant_id`),
|
||||
CONSTRAINT `FK_NOTIFICATION_PARTICIPANT` FOREIGN KEY (`participant_id`) REFERENCES `participant` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT,
|
||||
CONSTRAINT `FK_NOTIFICATION_SHIPCALL` FOREIGN KEY (`shipcall_id`) REFERENCES `shipcall` (`id`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=10398 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='An entry corresponds to an alarm given by a violated rule during times update';
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
@ -83,7 +130,46 @@ CREATE TABLE `participant` (
|
||||
`modified` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
`deleted` bit(1) DEFAULT b'0',
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=137 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='An organization taking part';
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=160 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='An organization taking part';
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `participant_port_map`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `participant_port_map`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!50503 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `participant_port_map` (
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`participant_id` int unsigned NOT NULL COMMENT 'Ref to participant',
|
||||
`port_id` int unsigned NOT NULL COMMENT 'Ref to port',
|
||||
`created` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`modified` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `FK_PP_PARTICIPANT` (`participant_id`),
|
||||
KEY `FK_PP_PORT` (`port_id`),
|
||||
CONSTRAINT `FK_PP_PARTICIPANT` FOREIGN KEY (`participant_id`) REFERENCES `participant` (`id`),
|
||||
CONSTRAINT `FK_PP_PORT` FOREIGN KEY (`port_id`) REFERENCES `port` (`id`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=86 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Mapping table that assigns participants to a port';
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `port`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `port`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!50503 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `port` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`name` varchar(128) NOT NULL COMMENT 'Name of port',
|
||||
`locode` char(5) DEFAULT NULL COMMENT 'UNECE locode',
|
||||
`created` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`modified` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
`deleted` bit(1) DEFAULT b'0',
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Port as reference for shipcalls and berths';
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
@ -166,7 +252,7 @@ CREATE TABLE `ship` (
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `FK_SHIP_PARTICIPANT` (`participant_id`),
|
||||
CONSTRAINT `FK_SHIP_PARTICIPANT` FOREIGN KEY (`participant_id`) REFERENCES `participant` (`id`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=14 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=485 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
@ -202,16 +288,25 @@ CREATE TABLE `shipcall` (
|
||||
`canceled` bit(1) DEFAULT NULL,
|
||||
`evaluation` int unsigned DEFAULT NULL,
|
||||
`evaluation_message` varchar(512) DEFAULT NULL,
|
||||
`evaluation_time` datetime DEFAULT NULL,
|
||||
`evaluation_notifications_sent` bit(1) DEFAULT NULL,
|
||||
`port_id` int unsigned NOT NULL DEFAULT '1' COMMENT 'Selected port for this shipcall',
|
||||
`time_ref_point` int DEFAULT '0' COMMENT 'Index of a location which is the reference point for all time value entries, e.g. berth or Geeste',
|
||||
`created` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`modified` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `FK_SHIPCALL_SHIP` (`ship_id`),
|
||||
KEY `FK_SHIPCALL_BERTH_ARRIVAL` (`arrival_berth_id`),
|
||||
KEY `FK_SHIPCALL_BERTH_DEPARTURE` (`departure_berth_id`),
|
||||
KEY `idx_shipcall_type` (`type`),
|
||||
KEY `idx_shipcall_eta` (`eta`),
|
||||
KEY `idx_shipcall_etd` (`etd`),
|
||||
KEY `FK_SHIPCALL_PORT_idx` (`port_id`),
|
||||
CONSTRAINT `FK_SHIPCALL_BERTH_ARRIVAL` FOREIGN KEY (`arrival_berth_id`) REFERENCES `berth` (`id`),
|
||||
CONSTRAINT `FK_SHIPCALL_BERTH_DEPARTURE` FOREIGN KEY (`departure_berth_id`) REFERENCES `berth` (`id`),
|
||||
CONSTRAINT `FK_SHIPCALL_PORT` FOREIGN KEY (`port_id`) REFERENCES `port` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT,
|
||||
CONSTRAINT `FK_SHIPCALL_SHIP` FOREIGN KEY (`ship_id`) REFERENCES `ship` (`id`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=23 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Incoming, outgoing or moving to another berth';
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=2789 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Incoming, outgoing or moving to another berth';
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
@ -225,15 +320,15 @@ CREATE TABLE `shipcall_participant_map` (
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`shipcall_id` int unsigned DEFAULT NULL,
|
||||
`participant_id` int unsigned DEFAULT NULL,
|
||||
`type` int unsigned DEFAULT NULL COMMENT 'Type of participant role',
|
||||
`type` int unsigned DEFAULT NULL,
|
||||
`created` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`modified` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `FK_MAP_PARTICIPANT_SHIPCALL` (`shipcall_id`),
|
||||
KEY `FK_MAP_SHIPCALL_PARTICIPANT` (`participant_id`),
|
||||
CONSTRAINT `FK_MAP_PARTICIPANT_SHIPCALL` FOREIGN KEY (`shipcall_id`) REFERENCES `shipcall` (`id`),
|
||||
CONSTRAINT `FK_MAP_PARTICIPANT_SHIPCALL` FOREIGN KEY (`shipcall_id`) REFERENCES `shipcall` (`id`) ON DELETE SET NULL,
|
||||
CONSTRAINT `FK_MAP_SHIPCALL_PARTICIPANT` FOREIGN KEY (`participant_id`) REFERENCES `participant` (`id`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=128 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Associates a participant with a shipcall';
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=8933 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Associates a participant with a shipcall';
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
@ -285,13 +380,20 @@ CREATE TABLE `times` (
|
||||
`berth_info` varchar(512) DEFAULT NULL,
|
||||
`pier_side` bit(1) DEFAULT NULL,
|
||||
`participant_type` int unsigned DEFAULT NULL,
|
||||
`ata` datetime DEFAULT NULL COMMENT 'Relevant only for mooring, this field can be used to record actual ATA',
|
||||
`atd` datetime DEFAULT NULL COMMENT 'Relevant only for mooring, this field can be used to record actual ATD',
|
||||
`eta_interval_end` datetime DEFAULT NULL COMMENT 'If this value is set the times are given as interval instead of a single point in time. The start time value depends on the participant type.',
|
||||
`etd_interval_end` datetime DEFAULT NULL COMMENT 'If this value is set the times are given as interval instead of a single point in time. The start time value depends on the participant type.',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_shipcall_participant` (`shipcall_id`,`participant_type`),
|
||||
KEY `FK_TIME_SHIPCALL` (`shipcall_id`),
|
||||
KEY `FK_TIME_PART` (`participant_id`) /*!80000 INVISIBLE */,
|
||||
KEY `FK_TIME_BERTH` (`berth_id`) /*!80000 INVISIBLE */,
|
||||
KEY `idx_times_eta_berth` (`eta_berth`),
|
||||
KEY `idx_times_etd_berth` (`etd_berth`),
|
||||
CONSTRAINT `FK_TIME_BERTH` FOREIGN KEY (`berth_id`) REFERENCES `berth` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT,
|
||||
CONSTRAINT `FK_TIME_PART` FOREIGN KEY (`participant_id`) REFERENCES `participant` (`id`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=44 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='the planned time for the participants work';
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=7863 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='the planned time for the participants work';
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
@ -303,7 +405,7 @@ DROP TABLE IF EXISTS `user`;
|
||||
/*!50503 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `user` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`participant_id` int unsigned DEFAULT NULL,
|
||||
`participant_id` int unsigned NOT NULL,
|
||||
`first_name` varchar(45) DEFAULT NULL,
|
||||
`last_name` varchar(45) DEFAULT NULL,
|
||||
`user_name` varchar(45) DEFAULT NULL,
|
||||
@ -311,12 +413,17 @@ CREATE TABLE `user` (
|
||||
`user_phone` varchar(128) DEFAULT NULL,
|
||||
`password_hash` varchar(128) DEFAULT NULL,
|
||||
`api_key` varchar(256) DEFAULT NULL,
|
||||
`notify_email` bit(1) DEFAULT NULL,
|
||||
`notify_whatsapp` bit(1) DEFAULT NULL,
|
||||
`notify_signal` bit(1) DEFAULT NULL,
|
||||
`notify_popup` bit(1) DEFAULT NULL,
|
||||
`notify_event` int DEFAULT NULL COMMENT 'Bitflag of selected notification event types that the user wants to be notified of',
|
||||
`created` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`modified` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `FK_USER_PART` (`participant_id`),
|
||||
CONSTRAINT `FK_USER_PART` FOREIGN KEY (`participant_id`) REFERENCES `participant` (`id`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=30 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='member of a participant';
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=55 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='member of a participant';
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
@ -339,6 +446,57 @@ CREATE TABLE `user_role_map` (
|
||||
CONSTRAINT `FK_USER_ROLE` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Assigns a user to a role';
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Dumping routines for database 'bremen_calling_test'
|
||||
--
|
||||
/*!50003 DROP PROCEDURE IF EXISTS `delete_data` */;
|
||||
/*!50003 SET @saved_cs_client = @@character_set_client */ ;
|
||||
/*!50003 SET @saved_cs_results = @@character_set_results */ ;
|
||||
/*!50003 SET @saved_col_connection = @@collation_connection */ ;
|
||||
/*!50003 SET character_set_client = utf8mb4 */ ;
|
||||
/*!50003 SET character_set_results = utf8mb4 */ ;
|
||||
/*!50003 SET collation_connection = utf8mb4_0900_ai_ci */ ;
|
||||
/*!50003 SET @saved_sql_mode = @@sql_mode */ ;
|
||||
/*!50003 SET sql_mode = 'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION' */ ;
|
||||
DELIMITER ;;
|
||||
CREATE DEFINER=`ds`@`localhost` PROCEDURE `delete_data`()
|
||||
BEGIN
|
||||
DECLARE shipcall_id_var int;
|
||||
DECLARE done INT DEFAULT FALSE;
|
||||
|
||||
DECLARE shipcall_iter CURSOR FOR
|
||||
SELECT shipcall.id FROM shipcall
|
||||
LEFT JOIN times ON
|
||||
times.shipcall_id = shipcall.id AND times.participant_type = 8
|
||||
WHERE
|
||||
-- ARRIVAL
|
||||
(type = 1 AND GREATEST(shipcall.eta, COALESCE(times.eta_berth, 0)) <= CURRENT_DATE() - INTERVAL 1 MONTH) OR
|
||||
-- DEPARTURE / SHIFTING
|
||||
(type != 1 AND GREATEST(shipcall.etd, COALESCE(times.etd_berth, 0)) <= CURRENT_DATE() - INTERVAL 1 MONTH);
|
||||
|
||||
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;
|
||||
|
||||
OPEN shipcall_iter;
|
||||
|
||||
delete_loop: LOOP
|
||||
FETCH shipcall_iter INTO shipcall_id_var;
|
||||
IF done THEN
|
||||
LEAVE delete_loop;
|
||||
END IF;
|
||||
DELETE FROM shipcall_participant_map WHERE shipcall_id = shipcall_id_var;
|
||||
DELETE FROM shipcall_tug_map WHERE shipcall_id = shipcall_id_var;
|
||||
DELETE FROM times WHERE shipcall_id = shipcall_id_var;
|
||||
DELETE FROM history WHERE shipcall_id = shipcall_id_var;
|
||||
DELETE FROM shipcall WHERE id = shipcall_id_var;
|
||||
END LOOP;
|
||||
CLOSE shipcall_iter;
|
||||
END ;;
|
||||
DELIMITER ;
|
||||
/*!50003 SET sql_mode = @saved_sql_mode */ ;
|
||||
/*!50003 SET character_set_client = @saved_cs_client */ ;
|
||||
/*!50003 SET character_set_results = @saved_cs_results */ ;
|
||||
/*!50003 SET collation_connection = @saved_col_connection */ ;
|
||||
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
|
||||
|
||||
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
|
||||
@ -349,4 +507,4 @@ CREATE TABLE `user_role_map` (
|
||||
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
|
||||
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
|
||||
|
||||
-- Dump completed on 2023-10-06 14:52:04
|
||||
-- Dump completed on 2025-11-17 8:26:36
|
||||
|
||||
@ -1 +1 @@
|
||||
1.8.0.0
|
||||
1.8.0.0
|
||||
|
||||
@ -29,7 +29,7 @@
|
||||
<applicationSettings>
|
||||
<BreCalClient.Properties.Settings>
|
||||
<setting name="BG_COLOR" serializeAs="String">
|
||||
<value>#1D751F</value>
|
||||
<value>#751D1F</value>
|
||||
</setting>
|
||||
<setting name="APP_TITLE" serializeAs="String">
|
||||
<value>!!Bremen calling Entwicklungsversion!!</value>
|
||||
@ -38,7 +38,7 @@
|
||||
<value>https://www.textbausteine.net/</value>
|
||||
</setting>
|
||||
<setting name="API_URL" serializeAs="String">
|
||||
<value>https://brecaldevel.bsmd-emswe.eu</value>
|
||||
<value>https://brecaltest.bsmd-emswe.eu</value>
|
||||
</setting>
|
||||
</BreCalClient.Properties.Settings>
|
||||
</applicationSettings>
|
||||
|
||||
@ -35,7 +35,7 @@ namespace BreCalClient
|
||||
this.ContentWrapper.Background = Brushes.Gray;
|
||||
break;
|
||||
case "MissingData":
|
||||
this.ContentWrapper.Background= Brushes.Yellow;
|
||||
this.ContentWrapper.Background = Brushes.DarkKhaki;
|
||||
break;
|
||||
case "Cancelled":
|
||||
this.ContentWrapper.Background = Brushes.DarkGray;
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
<Title>Bremen calling client</Title>
|
||||
<Description>A Windows WPF client for the Bremen calling API.</Description>
|
||||
<ApplicationIcon>containership.ico</ApplicationIcon>
|
||||
<AssemblyName>BreCalDevelClient</AssemblyName>
|
||||
<AssemblyName>BreCalTestClient</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@ -4,8 +4,8 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
|
||||
-->
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<ApplicationRevision>0</ApplicationRevision>
|
||||
<ApplicationVersion>1.6.0.3</ApplicationVersion>
|
||||
<ApplicationRevision>5</ApplicationRevision>
|
||||
<ApplicationVersion>1.7.0.7</ApplicationVersion>
|
||||
<BootstrapperEnabled>True</BootstrapperEnabled>
|
||||
<Configuration>Debug</Configuration>
|
||||
<CreateDesktopShortcut>True</CreateDesktopShortcut>
|
||||
@ -27,10 +27,10 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
|
||||
<SelfContained>True</SelfContained>
|
||||
<SignatureAlgorithm>(none)</SignatureAlgorithm>
|
||||
<SignManifests>False</SignManifests>
|
||||
<TargetFramework>net6.0-windows</TargetFramework>
|
||||
<TargetFramework>net8.0-windows7.0</TargetFramework>
|
||||
<UpdateEnabled>True</UpdateEnabled>
|
||||
<UpdateMode>Foreground</UpdateMode>
|
||||
<UpdateRequired>False</UpdateRequired>
|
||||
<UpdateRequired>True</UpdateRequired>
|
||||
<WebPageFileName>Publish.html</WebPageFileName>
|
||||
<CreateDesktopShortcut>True</CreateDesktopShortcut>
|
||||
<ErrorReportUrl>https://www.bsmd-emswe.eu/</ErrorReportUrl>
|
||||
@ -38,8 +38,10 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
|
||||
<PublisherName>Informatikbüro Daniel Schick</PublisherName>
|
||||
<SuiteName>Bremen Calling</SuiteName>
|
||||
<SupportUrl>http://www.textbausteine.net/</SupportUrl>
|
||||
<PublishDir>bin\Debug\net6.0-windows\win-x64\app.publish\</PublishDir>
|
||||
<PublishDir>bin\Debug\net8.0-windows7.0\win-x64\app.publish\</PublishDir>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SkipPublishVerification>false</SkipPublishVerification>
|
||||
<MinimumRequiredVersion>1.7.0.7</MinimumRequiredVersion>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PublishFile Include="containership.ico">
|
||||
|
||||
54
src/BreCalClient/Properties/Settings.Designer.cs
generated
54
src/BreCalClient/Properties/Settings.Designer.cs
generated
@ -9,29 +9,29 @@
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
namespace BreCalClient.Properties {
|
||||
|
||||
|
||||
|
||||
|
||||
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
|
||||
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "17.12.0.0")]
|
||||
internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase {
|
||||
|
||||
|
||||
private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings())));
|
||||
|
||||
|
||||
public static Settings Default {
|
||||
get {
|
||||
return defaultInstance;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[global::System.Configuration.ApplicationScopedSettingAttribute()]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Configuration.DefaultSettingValueAttribute("#1D751F")]
|
||||
[global::System.Configuration.DefaultSettingValueAttribute("#751D1F")]
|
||||
public string BG_COLOR {
|
||||
get {
|
||||
return ((string)(this["BG_COLOR"]));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[global::System.Configuration.ApplicationScopedSettingAttribute()]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Configuration.DefaultSettingValueAttribute("!!Bremen calling Entwicklungsversion!!")]
|
||||
@ -40,7 +40,7 @@ namespace BreCalClient.Properties {
|
||||
return ((string)(this["APP_TITLE"]));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[global::System.Configuration.ApplicationScopedSettingAttribute()]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Configuration.DefaultSettingValueAttribute("https://www.textbausteine.net/")]
|
||||
@ -49,7 +49,7 @@ namespace BreCalClient.Properties {
|
||||
return ((string)(this["LOGO_IMAGE_URL"]));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[global::System.Configuration.UserScopedSettingAttribute()]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Configuration.DefaultSettingValueAttribute("")]
|
||||
@ -61,16 +61,16 @@ namespace BreCalClient.Properties {
|
||||
this["FilterCriteria"] = value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[global::System.Configuration.ApplicationScopedSettingAttribute()]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Configuration.DefaultSettingValueAttribute("https://brecaldevel.bsmd-emswe.eu")]
|
||||
[global::System.Configuration.DefaultSettingValueAttribute("https://brecaltest.bsmd-emswe.eu")]
|
||||
public string API_URL {
|
||||
get {
|
||||
return ((string)(this["API_URL"]));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[global::System.Configuration.UserScopedSettingAttribute()]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Configuration.DefaultSettingValueAttribute("800")]
|
||||
@ -82,7 +82,7 @@ namespace BreCalClient.Properties {
|
||||
this["Width"] = value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[global::System.Configuration.UserScopedSettingAttribute()]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Configuration.DefaultSettingValueAttribute("450")]
|
||||
@ -94,7 +94,7 @@ namespace BreCalClient.Properties {
|
||||
this["Height"] = value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[global::System.Configuration.UserScopedSettingAttribute()]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Configuration.DefaultSettingValueAttribute("0")]
|
||||
@ -106,7 +106,7 @@ namespace BreCalClient.Properties {
|
||||
this["Left"] = value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[global::System.Configuration.UserScopedSettingAttribute()]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Configuration.DefaultSettingValueAttribute("0")]
|
||||
@ -118,7 +118,7 @@ namespace BreCalClient.Properties {
|
||||
this["Top"] = value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[global::System.Configuration.UserScopedSettingAttribute()]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Configuration.DefaultSettingValueAttribute("0")]
|
||||
@ -130,7 +130,7 @@ namespace BreCalClient.Properties {
|
||||
this["W1Left"] = value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[global::System.Configuration.UserScopedSettingAttribute()]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Configuration.DefaultSettingValueAttribute("0")]
|
||||
@ -142,7 +142,7 @@ namespace BreCalClient.Properties {
|
||||
this["W1Top"] = value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[global::System.Configuration.UserScopedSettingAttribute()]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Configuration.DefaultSettingValueAttribute("0")]
|
||||
@ -154,7 +154,7 @@ namespace BreCalClient.Properties {
|
||||
this["W2Left"] = value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[global::System.Configuration.UserScopedSettingAttribute()]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Configuration.DefaultSettingValueAttribute("0")]
|
||||
@ -166,7 +166,7 @@ namespace BreCalClient.Properties {
|
||||
this["W2Top"] = value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[global::System.Configuration.UserScopedSettingAttribute()]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Configuration.DefaultSettingValueAttribute("0")]
|
||||
@ -178,7 +178,7 @@ namespace BreCalClient.Properties {
|
||||
this["W3Left"] = value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[global::System.Configuration.UserScopedSettingAttribute()]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Configuration.DefaultSettingValueAttribute("0")]
|
||||
@ -190,7 +190,7 @@ namespace BreCalClient.Properties {
|
||||
this["W3Top"] = value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[global::System.Configuration.UserScopedSettingAttribute()]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Configuration.DefaultSettingValueAttribute("0")]
|
||||
@ -202,7 +202,7 @@ namespace BreCalClient.Properties {
|
||||
this["W4Left"] = value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[global::System.Configuration.UserScopedSettingAttribute()]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Configuration.DefaultSettingValueAttribute("0")]
|
||||
@ -214,7 +214,7 @@ namespace BreCalClient.Properties {
|
||||
this["W4Top"] = value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[global::System.Configuration.UserScopedSettingAttribute()]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Configuration.DefaultSettingValueAttribute("")]
|
||||
@ -226,7 +226,7 @@ namespace BreCalClient.Properties {
|
||||
this["FilterCriteriaMap"] = value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[global::System.Configuration.UserScopedSettingAttribute()]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
public global::System.Collections.Specialized.StringCollection Notifications {
|
||||
@ -237,7 +237,7 @@ namespace BreCalClient.Properties {
|
||||
this["Notifications"] = value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[global::System.Configuration.UserScopedSettingAttribute()]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Configuration.DefaultSettingValueAttribute("0")]
|
||||
@ -249,7 +249,7 @@ namespace BreCalClient.Properties {
|
||||
this["W5Top"] = value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[global::System.Configuration.UserScopedSettingAttribute()]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Configuration.DefaultSettingValueAttribute("0")]
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
<Profiles />
|
||||
<Settings>
|
||||
<Setting Name="BG_COLOR" Type="System.String" Scope="Application">
|
||||
<Value Profile="(Default)">#1D751F</Value>
|
||||
<Value Profile="(Default)">#751D1F</Value>
|
||||
</Setting>
|
||||
<Setting Name="APP_TITLE" Type="System.String" Scope="Application">
|
||||
<Value Profile="(Default)">!!Bremen calling Entwicklungsversion!!</Value>
|
||||
@ -15,7 +15,7 @@
|
||||
<Value Profile="(Default)" />
|
||||
</Setting>
|
||||
<Setting Name="API_URL" Type="System.String" Scope="Application">
|
||||
<Value Profile="(Default)">https://brecaldevel.bsmd-emswe.eu</Value>
|
||||
<Value Profile="(Default)">https://brecaltest.bsmd-emswe.eu</Value>
|
||||
</Setting>
|
||||
<Setting Name="Width" Type="System.Double" Scope="User">
|
||||
<Value Profile="(Default)">800</Value>
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:p = "clr-namespace:BreCalClient.Resources"
|
||||
xmlns:sets="clr-namespace:BreCalClient.Properties"
|
||||
xmlns:db="clr-namespace:BreCalClient;assembly=BreCalDevelClient"
|
||||
xmlns:db="clr-namespace:BreCalClient;assembly=BreCalTestClient"
|
||||
mc:Ignorable="d"
|
||||
d:DesignHeight="135" d:DesignWidth="800">
|
||||
<Border BorderBrush="LightGray" Margin="1" BorderThickness="1">
|
||||
|
||||
@ -215,13 +215,13 @@ namespace BreCalClient
|
||||
switch (this.ShipcallControlModel?.Shipcall?.Type)
|
||||
{
|
||||
case ShipcallType.Arrival: // incoming
|
||||
this.imageShipcallType.Source = new BitmapImage(new Uri("pack://application:,,,/BreCalDevelClient;component/Resources/arrow_down_red.png"));
|
||||
this.imageShipcallType.Source = new BitmapImage(new Uri("pack://application:,,,/BreCalTestClient;component/Resources/arrow_down_red.png"));
|
||||
break;
|
||||
case ShipcallType.Departure: // outgoing
|
||||
this.imageShipcallType.Source = new BitmapImage(new Uri("pack://application:,,,/BreCalDevelClient;component/Resources/arrow_up_blue.png"));
|
||||
this.imageShipcallType.Source = new BitmapImage(new Uri("pack://application:,,,/BreCalTestClient;component/Resources/arrow_up_blue.png"));
|
||||
break;
|
||||
case ShipcallType.Shifting: // shifting
|
||||
this.imageShipcallType.Source = new BitmapImage(new Uri("pack://application:,,,/BreCalDevelClient;component/Resources/arrow_right_green.png"));
|
||||
this.imageShipcallType.Source = new BitmapImage(new Uri("pack://application:,,,/BreCalTestClient;component/Resources/arrow_right_green.png"));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@ -230,13 +230,13 @@ namespace BreCalClient
|
||||
switch(this.ShipcallControlModel?.LightMode)
|
||||
{
|
||||
case EvaluationType.Green:
|
||||
this.imageEvaluation.Source = new BitmapImage(new Uri("pack://application:,,,/BreCalDevelClient;component/Resources/check.png"));
|
||||
this.imageEvaluation.Source = new BitmapImage(new Uri("pack://application:,,,/BreCalTestClient;component/Resources/check.png"));
|
||||
break;
|
||||
case EvaluationType.Yellow:
|
||||
this.imageEvaluation.Source = new BitmapImage(new Uri("pack://application:,,,/BreCalDevelClient;component/Resources/sign_warning.png"));
|
||||
this.imageEvaluation.Source = new BitmapImage(new Uri("pack://application:,,,/BreCalTestClient;component/Resources/sign_warning.png"));
|
||||
break;
|
||||
case EvaluationType.Red:
|
||||
this.imageEvaluation.Source = new BitmapImage(new Uri("pack://application:,,,/BreCalDevelClient;component/Resources/delete2.png"));
|
||||
this.imageEvaluation.Source = new BitmapImage(new Uri("pack://application:,,,/BreCalTestClient;component/Resources/delete2.png"));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
<applicationSettings>
|
||||
<RoleEditor.Properties.Settings>
|
||||
<setting name="ConnectionString" serializeAs="String">
|
||||
<value>Server=localhost;User ID=ds;Password=HalloWach_2323XXL!!;Database=bremen_calling_devel;Port=33306</value>
|
||||
<value>Server=localhost;User ID=ds;Password=HalloWach_2323XXL!!;Database=bremen_calling_test;Port=33307</value>
|
||||
</setting>
|
||||
</RoleEditor.Properties.Settings>
|
||||
</applicationSettings>
|
||||
|
||||
@ -59,7 +59,7 @@
|
||||
<Label Content="Street" Grid.Row="1" Grid.Column="1" HorizontalAlignment="Right"/>
|
||||
<Label Content="Postal code" Grid.Row="2" Grid.Column="1" HorizontalAlignment="Right"/>
|
||||
<Label Content="City" Grid.Row="3" Grid.Column="1" HorizontalAlignment="Right"/>
|
||||
<Label Content="Active" Grid.Row="4" Grid.Column="1" HorizontalAlignment="Right"/>
|
||||
<Label Content="Deleted" Grid.Row="4" Grid.Column="1" HorizontalAlignment="Right"/>
|
||||
<Label Content="Type" Grid.Row="5" Grid.Column="1" HorizontalAlignment="Right"/>
|
||||
<Label Content="Created" Grid.Row="6" Grid.Column="1" HorizontalAlignment="Right"/>
|
||||
<Label Content="Modified" Grid.Row="7" Grid.Column="1" HorizontalAlignment="Right"/>
|
||||
@ -67,7 +67,7 @@
|
||||
<TextBox x:Name="textBoxParticipantStreet" Grid.Row="1" Grid.Column="2" Margin="2" VerticalContentAlignment="Center" />
|
||||
<TextBox x:Name="textBoxParticipantPostalCode" Grid.Row="2" Grid.Column="2" Margin="2" VerticalContentAlignment="Center" />
|
||||
<TextBox x:Name="textBoxParticipantCity" Grid.Row="3" Grid.Column="2" Margin="2" VerticalContentAlignment="Center" />
|
||||
<CheckBox x:Name="checkboxParticipantActive" Grid.Row="4" Grid.Column="2" VerticalAlignment="Center" />
|
||||
<CheckBox x:Name="checkboxParticipantDeleted" Grid.Row="4" Grid.Column="2" VerticalAlignment="Center" IsEnabled="False" />
|
||||
<xctk:CheckComboBox x:Name="comboBoxParticipantType" Grid.Row="5" Grid.Column="2" Margin="2" SelectedValue="Key" DisplayMemberPath="Value" />
|
||||
<TextBox x:Name="textBoxParticipantCreated" Grid.Row="6" IsReadOnly="True" IsEnabled="False" Grid.Column="2" Margin="2" VerticalContentAlignment="Center" />
|
||||
<StackPanel Orientation="Horizontal" Grid.Row="7" Grid.Column="0">
|
||||
@ -167,7 +167,7 @@
|
||||
</Grid.ColumnDefinitions>
|
||||
<ListBox x:Name="listBoxUser" Margin="2" Grid.RowSpan="9" SelectionChanged="listBoxUser_SelectionChanged">
|
||||
<ListBox.ContextMenu>
|
||||
<ContextMenu>
|
||||
<ContextMenu Name="contextMenuUser">
|
||||
<MenuItem x:Name="menuItemNewUser" Header="New.." Click="menuItemNewUser_Click">
|
||||
<MenuItem.Icon>
|
||||
<Image Source="Resources/add.png" />
|
||||
|
||||
@ -61,6 +61,8 @@ namespace RoleEditor
|
||||
|
||||
// load all participants
|
||||
List<Participant> participants = await Participant.LoadAll(_dbManager);
|
||||
participants.Sort((x, y) => string.Compare(x.Name, y.Name));
|
||||
|
||||
foreach (Participant p in participants)
|
||||
{
|
||||
_participants.Add(p);
|
||||
@ -464,9 +466,9 @@ namespace RoleEditor
|
||||
this.textBoxParticipantName.Text = (p != null) ? p.Name : string.Empty;
|
||||
this.textBoxParticipantStreet.Text = (p != null) ? p.Street : string.Empty;
|
||||
this.textBoxParticipantPostalCode.Text = (p != null) ? p.PostalCode : string.Empty;
|
||||
this.textBoxParticipantCity.Text = (p != null) ? p.City : string.Empty;
|
||||
// this.checkboxParticipantActive.Checked = (p != null) ? p.
|
||||
this.textBoxParticipantCity.Text = (p != null) ? p.City : string.Empty;
|
||||
this.textBoxParticipantCreated.Text = (p != null) ? p.Created.ToString() : string.Empty;
|
||||
this.checkboxParticipantDeleted.IsChecked = (p != null) ? p.Deleted : null;
|
||||
this.textBoxParticipantModified.Text = (p != null) ? p.Modified.ToString() : string.Empty;
|
||||
this.checkBoxParticipantAllowBSMD.IsChecked = (p != null) ? p.IsFlagSet(Participant.ParticipantFlags.ALLOW_BSMD) : null;
|
||||
this.comboBoxParticipantType.SelectedItems.Clear();
|
||||
@ -510,6 +512,11 @@ namespace RoleEditor
|
||||
_assignedPorts.Add(pa);
|
||||
}
|
||||
}
|
||||
|
||||
this.contextMenuUser.IsEnabled = !p.Deleted;
|
||||
this.buttonAddPortAssignment.IsEnabled = !p.Deleted;
|
||||
this.buttonRemovePortAssignment.IsEnabled = !p.Deleted;
|
||||
|
||||
}
|
||||
|
||||
private async void listBoxRoles_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
@ -594,7 +601,7 @@ namespace RoleEditor
|
||||
if(this.listBoxParticipant.SelectedItem is Participant p)
|
||||
{
|
||||
await p.Delete(_dbManager);
|
||||
this._participants.Remove(p);
|
||||
p.Deleted = true;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@ -628,6 +635,7 @@ namespace RoleEditor
|
||||
{
|
||||
if (this.listBoxUser.SelectedItem is User u)
|
||||
{
|
||||
await u.ExecuteNonQuery(_dbManager); // extra history delete happens here
|
||||
await u.Delete(_dbManager);
|
||||
this._users.Remove(u);
|
||||
}
|
||||
|
||||
12
src/RoleEditor/Properties/Settings.Designer.cs
generated
12
src/RoleEditor/Properties/Settings.Designer.cs
generated
@ -9,24 +9,24 @@
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
namespace RoleEditor.Properties {
|
||||
|
||||
|
||||
|
||||
|
||||
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
|
||||
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "17.10.0.0")]
|
||||
internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase {
|
||||
|
||||
|
||||
private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings())));
|
||||
|
||||
|
||||
public static Settings Default {
|
||||
get {
|
||||
return defaultInstance;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[global::System.Configuration.ApplicationScopedSettingAttribute()]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Configuration.DefaultSettingValueAttribute("Server=localhost;User ID=ds;Password=HalloWach_2323XXL!!;Database=bremen_calling_" +
|
||||
"devel;Port=33306")]
|
||||
"test;Port=33306")]
|
||||
public string ConnectionString {
|
||||
get {
|
||||
return ((string)(this["ConnectionString"]));
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
<Profiles />
|
||||
<Settings>
|
||||
<Setting Name="ConnectionString" Type="System.String" Scope="Application">
|
||||
<Value Profile="(Default)">Server=localhost;User ID=ds;Password=HalloWach_2323XXL!!;Database=bremen_calling_devel;Port=33306</Value>
|
||||
<Value Profile="(Default)">Server=localhost;User ID=ds;Password=HalloWach_2323XXL!!;Database=bremen_calling_test;Port=33306</Value>
|
||||
</Setting>
|
||||
</Settings>
|
||||
</SettingsFile>
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net6.0-windows</TargetFramework>
|
||||
<TargetFramework>net8.0-windows7.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<UseWPF>true</UseWPF>
|
||||
<ApplicationIcon>Resources\lock_preferences.ico</ApplicationIcon>
|
||||
@ -30,8 +30,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||
<PackageReference Include="ExcelDataReader" Version="3.7.0-develop00385" />
|
||||
<PackageReference Include="Extended.Wpf.Toolkit" Version="4.5.0" />
|
||||
<PackageReference Include="ExcelDataReader" Version="3.8.0" />
|
||||
<PackageReference Include="Extended.Wpf.Toolkit" Version="5.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@ -1,8 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Data;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace brecal.model
|
||||
@ -42,6 +38,11 @@ namespace brecal.model
|
||||
/// <param name="cmd">CMD created by DB manager</param>
|
||||
public abstract void SetDelete(IDbCommand cmd);
|
||||
|
||||
public virtual void SetNonQuery(IDbCommand cmd)
|
||||
{
|
||||
// default: do nothing
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Each database entity must be able to save itself to the database
|
||||
/// </summary>
|
||||
@ -61,9 +62,14 @@ namespace brecal.model
|
||||
/// Each entity must be able to delete itself
|
||||
/// </summary>
|
||||
public async Task Delete(IDBManager manager)
|
||||
{
|
||||
{
|
||||
await manager.ExecuteNonQuery(this.SetDelete);
|
||||
}
|
||||
|
||||
public async Task ExecuteNonQuery(IDBManager manager)
|
||||
{
|
||||
await manager.ExecuteNonQuery(this.SetNonQuery);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -55,6 +55,8 @@ namespace brecal.model
|
||||
|
||||
public uint Flags { get; set; }
|
||||
|
||||
public bool Deleted { get; set; } = false;
|
||||
|
||||
#endregion
|
||||
|
||||
#region public static methods
|
||||
@ -83,6 +85,7 @@ namespace brecal.model
|
||||
if (!reader.IsDBNull(6)) p.Flags = (uint)reader.GetInt32(6);
|
||||
if (!reader.IsDBNull(7)) p.Created = reader.GetDateTime(7);
|
||||
if (!reader.IsDBNull(8)) p.Modified = reader.GetDateTime(8);
|
||||
if (!reader.IsDBNull(9)) p.Deleted = reader.GetBoolean(9);
|
||||
result.Add(p);
|
||||
}
|
||||
return result;
|
||||
@ -90,7 +93,7 @@ namespace brecal.model
|
||||
|
||||
public static void SetLoadQuery(IDbCommand cmd, params object?[] list)
|
||||
{
|
||||
cmd.CommandText = "SELECT id, name, street, postal_code, city, type, flags, created, modified FROM participant";
|
||||
cmd.CommandText = "SELECT id, name, street, postal_code, city, type, flags, created, modified, deleted FROM participant";
|
||||
}
|
||||
|
||||
#endregion
|
||||
@ -111,13 +114,13 @@ namespace brecal.model
|
||||
|
||||
public override void SetDelete(IDbCommand cmd)
|
||||
{
|
||||
cmd.CommandText = "DELETE FROM participant WHERE id = @ID";
|
||||
cmd.CommandText = "UPDATE participant SET deleted = 1 WHERE id = @ID";
|
||||
|
||||
IDataParameter idParam = cmd.CreateParameter();
|
||||
idParam.ParameterName = "ID";
|
||||
idParam.Value = this.Id;
|
||||
cmd.Parameters.Add(idParam);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
@ -101,6 +101,16 @@ namespace brecal.model
|
||||
return this.Username ?? $"{base.Id} - {this.GetType().Name}";
|
||||
}
|
||||
|
||||
public override void SetNonQuery(IDbCommand cmd)
|
||||
{
|
||||
cmd.CommandText = "UPDATE history set user_id = NULL WHERE user_id = @ID";
|
||||
|
||||
IDataParameter idParam = cmd.CreateParameter();
|
||||
idParam.ParameterName = "ID";
|
||||
idParam.Value = this.Id;
|
||||
cmd.Parameters.Add(idParam);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region private methods
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MySqlConnector" Version="2.3.0-beta.1" />
|
||||
<PackageReference Include="MySqlConnector" Version="2.4.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@ -1,51 +0,0 @@
|
||||
# BreCal API Client
|
||||
|
||||
Minimal Python helper for `misc/BreCalApi.yaml`. It focuses on the login, shipcall, and times endpoints needed by CLI tools, but the helper method `BreCalClient.raw_request` makes it straightforward to call any other endpoint defined in the OpenAPI specification.
|
||||
|
||||
Dependencies: only the `requests` package in addition to the standard library.
|
||||
|
||||
## Endpoint selection
|
||||
|
||||
`BreCalClient` reads its default `base_url` from `~/.config/brecal/client.json`. The file lets you define multiple deployments and switch between them without modifying code:
|
||||
|
||||
```json
|
||||
{
|
||||
"environment": "devel",
|
||||
"endpoints": {
|
||||
"local": "http://localhost:5000",
|
||||
"devel": "https://brecaldevel.bsmd-emswe.eu",
|
||||
"test": "https://brecaltest.example.net",
|
||||
"prod": "https://brecal.example.com"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Override the selection at runtime via `BreCalClient(base_url="...")` or the environment variable `BRECAL_BASE_URL`. If no config is present the client falls back to the development server URL.
|
||||
|
||||
## Credentials
|
||||
|
||||
Store credentials in `~/.config/brecal/credentials.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"username": "alfred",
|
||||
"password": "123456"
|
||||
}
|
||||
```
|
||||
|
||||
You can override the location when calling `Credentials.load("/path/to/file.json")` or provide credentials from environment variables via `Credentials.from_env()`.
|
||||
|
||||
## Example
|
||||
|
||||
```python
|
||||
from brecal_api_client import BreCalClient, Credentials
|
||||
|
||||
creds = Credentials.load()
|
||||
with BreCalClient(credentials=creds) as client:
|
||||
# list ship calls from the last week
|
||||
shipcalls = client.get_shipcalls(past_days=7)
|
||||
|
||||
# create/update ship calls or times
|
||||
shipcall_id = client.create_shipcall({...})
|
||||
times = client.get_times(shipcall_id=shipcall_id)
|
||||
```
|
||||
@ -1,25 +0,0 @@
|
||||
"""Simple Python client for the BreCal REST API."""
|
||||
|
||||
from .client import BreCalClient, DEFAULT_BASE_URL
|
||||
from .config import ClientConfig, get_default_base_url
|
||||
from .credentials import Credentials
|
||||
from .exceptions import (
|
||||
AuthenticationError,
|
||||
AuthorizationError,
|
||||
BreCalApiError,
|
||||
ClientConfigurationError,
|
||||
)
|
||||
from .types import LoginResult
|
||||
|
||||
__all__ = [
|
||||
"BreCalClient",
|
||||
"Credentials",
|
||||
"ClientConfig",
|
||||
"get_default_base_url",
|
||||
"LoginResult",
|
||||
"DEFAULT_BASE_URL",
|
||||
"BreCalApiError",
|
||||
"AuthenticationError",
|
||||
"AuthorizationError",
|
||||
"ClientConfigurationError",
|
||||
]
|
||||
@ -1,248 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Iterable, Mapping, Optional, Sequence
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
import time
|
||||
|
||||
from .config import get_default_base_url
|
||||
from .credentials import Credentials
|
||||
from .exceptions import (
|
||||
AuthenticationError,
|
||||
AuthorizationError,
|
||||
BreCalApiError,
|
||||
ClientConfigurationError,
|
||||
)
|
||||
from .types import JsonDict, LoginResult, MutableJsonDict
|
||||
|
||||
DEFAULT_BASE_URL = get_default_base_url()
|
||||
|
||||
|
||||
@dataclass
|
||||
class _RequestContext:
|
||||
method: str
|
||||
path: str
|
||||
expected: Sequence[int]
|
||||
auth: bool
|
||||
|
||||
|
||||
class BreCalClient:
|
||||
"""Thin convenience wrapper around the BreCal REST API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: Optional[str] = None,
|
||||
*,
|
||||
credentials: Optional[Credentials] = None,
|
||||
timeout: float = 30.0,
|
||||
session: Optional[requests.Session] = None,
|
||||
auto_login: bool = True,
|
||||
) -> None:
|
||||
resolved_base_url = base_url or get_default_base_url()
|
||||
if not resolved_base_url:
|
||||
raise ClientConfigurationError("base_url must be provided.")
|
||||
self.base_url = resolved_base_url.rstrip("/")
|
||||
self._timeout = timeout
|
||||
self._session = session or requests.Session()
|
||||
self._credentials = credentials
|
||||
self._login: Optional[LoginResult] = None
|
||||
if auto_login and credentials is not None:
|
||||
self.login(credentials)
|
||||
|
||||
# -----------------------------------------------------
|
||||
# lifecycle helpers
|
||||
# -----------------------------------------------------
|
||||
def close(self) -> None:
|
||||
self._session.close()
|
||||
|
||||
def __enter__(self) -> "BreCalClient":
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None:
|
||||
self.close()
|
||||
|
||||
# -----------------------------------------------------
|
||||
# authentication
|
||||
# -----------------------------------------------------
|
||||
@property
|
||||
def token(self) -> Optional[str]:
|
||||
return self._login.token if self._login else None
|
||||
|
||||
@property
|
||||
def login_info(self) -> Optional[LoginResult]:
|
||||
return self._login
|
||||
|
||||
def ensure_authenticated(self) -> None:
|
||||
if self._login and self._login.expires_at.timestamp() > _epoch_seconds() + 30:
|
||||
return
|
||||
if not self._credentials:
|
||||
raise AuthenticationError(
|
||||
"Client has no stored credentials. Call login() with credentials first."
|
||||
)
|
||||
self.login(self._credentials)
|
||||
|
||||
def login(self, credentials: Credentials) -> LoginResult:
|
||||
payload = {"username": credentials.username, "password": credentials.password}
|
||||
data = self._request_json(
|
||||
_RequestContext("POST", "/login", expected=(200,), auth=False),
|
||||
json=payload,
|
||||
)
|
||||
if not isinstance(data, Mapping):
|
||||
raise AuthenticationError("Login returned unexpected payload.")
|
||||
result = LoginResult.from_api(data)
|
||||
if not result.token:
|
||||
raise AuthenticationError("Login response did not include a token.")
|
||||
self._login = result
|
||||
self._credentials = credentials
|
||||
return result
|
||||
|
||||
# -----------------------------------------------------
|
||||
# shipcalls
|
||||
# -----------------------------------------------------
|
||||
def get_shipcalls(self, *, past_days: Optional[int] = None) -> Sequence[JsonDict]:
|
||||
params: Dict[str, Any] = {}
|
||||
if past_days is not None:
|
||||
params["past_days"] = int(past_days)
|
||||
data = self._request_json(
|
||||
_RequestContext("GET", "/shipcalls", expected=(200,), auth=True),
|
||||
params=params or None,
|
||||
)
|
||||
return _as_sequence_of_dicts(data)
|
||||
|
||||
def create_shipcall(self, shipcall: Mapping[str, Any]) -> int:
|
||||
payload = _copy_without_keys(shipcall, drop_keys=("id",))
|
||||
data = self._request_json(
|
||||
_RequestContext("POST", "/shipcalls", expected=(201,), auth=True),
|
||||
json=payload,
|
||||
)
|
||||
return _extract_id(data)
|
||||
|
||||
def update_shipcall(self, shipcall: Mapping[str, Any]) -> int:
|
||||
if "id" not in shipcall:
|
||||
raise ValueError("Shipcall update requires an 'id' field.")
|
||||
data = self._request_json(
|
||||
_RequestContext("PUT", "/shipcalls", expected=(200,), auth=True),
|
||||
json=dict(shipcall),
|
||||
)
|
||||
return _extract_id(data)
|
||||
|
||||
# -----------------------------------------------------
|
||||
# times
|
||||
# -----------------------------------------------------
|
||||
def get_times(self, *, shipcall_id: Optional[int] = None) -> Sequence[JsonDict]:
|
||||
params = {"shipcall_id": int(shipcall_id)} if shipcall_id is not None else None
|
||||
data = self._request_json(
|
||||
_RequestContext("GET", "/times", expected=(200,), auth=True),
|
||||
params=params,
|
||||
)
|
||||
return _as_sequence_of_dicts(data)
|
||||
|
||||
def create_times(self, entry: Mapping[str, Any]) -> int:
|
||||
payload = _copy_without_keys(entry, drop_keys=("id",))
|
||||
data = self._request_json(
|
||||
_RequestContext("POST", "/times", expected=(201,), auth=True),
|
||||
json=payload,
|
||||
)
|
||||
return _extract_id(data)
|
||||
|
||||
def update_times(self, entry: Mapping[str, Any]) -> int:
|
||||
if "id" not in entry:
|
||||
raise ValueError("Times update requires an 'id' field.")
|
||||
data = self._request_json(
|
||||
_RequestContext("PUT", "/times", expected=(200,), auth=True),
|
||||
json=dict(entry),
|
||||
)
|
||||
return _extract_id(data)
|
||||
|
||||
def delete_times(self, times_id: int) -> int:
|
||||
data = self._request_json(
|
||||
_RequestContext("DELETE", "/times", expected=(200,), auth=True),
|
||||
params={"id": int(times_id)},
|
||||
)
|
||||
return _extract_id(data)
|
||||
|
||||
# -----------------------------------------------------
|
||||
# generic helpers
|
||||
# -----------------------------------------------------
|
||||
def raw_request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
expected: Sequence[int] = (200,),
|
||||
auth: bool = True,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Expose the low-level request helper for endpoints not wrapped yet."""
|
||||
ctx = _RequestContext(method.upper(), path, expected, auth)
|
||||
return self._request_json(ctx, **kwargs)
|
||||
|
||||
def _request_json(self, ctx: _RequestContext, **kwargs: Any) -> Any:
|
||||
url = urljoin(f"{self.base_url}/", ctx.path.lstrip("/"))
|
||||
headers: Dict[str, str] = kwargs.pop("headers", {})
|
||||
headers.setdefault("Accept", "application/json")
|
||||
if "json" in kwargs:
|
||||
headers.setdefault("Content-Type", "application/json")
|
||||
if ctx.auth:
|
||||
self.ensure_authenticated()
|
||||
headers.setdefault("Authorization", f"Bearer {self.token}")
|
||||
|
||||
response = self._session.request(
|
||||
ctx.method,
|
||||
url,
|
||||
timeout=self._timeout,
|
||||
headers=headers,
|
||||
**kwargs,
|
||||
)
|
||||
if response.status_code == 401 or response.status_code == 403:
|
||||
raise AuthorizationError(
|
||||
f"{ctx.method} {ctx.path} returned {response.status_code}",
|
||||
status_code=response.status_code,
|
||||
payload=_safe_json(response),
|
||||
)
|
||||
if response.status_code not in ctx.expected:
|
||||
raise BreCalApiError(
|
||||
f"{ctx.method} {ctx.path} returned {response.status_code}",
|
||||
status_code=response.status_code,
|
||||
payload=_safe_json(response),
|
||||
)
|
||||
if response.content:
|
||||
return _safe_json(response)
|
||||
return None
|
||||
|
||||
|
||||
def _copy_without_keys(
|
||||
data: Mapping[str, Any], *, drop_keys: Iterable[str]
|
||||
) -> MutableJsonDict:
|
||||
payload: MutableJsonDict = dict(data)
|
||||
for key in drop_keys:
|
||||
payload.pop(key, None)
|
||||
return payload
|
||||
|
||||
|
||||
def _extract_id(payload: Any) -> int:
|
||||
if isinstance(payload, Mapping) and "id" in payload:
|
||||
return int(payload["id"])
|
||||
raise BreCalApiError("API response did not include an 'id' field.", payload=payload)
|
||||
|
||||
|
||||
def _as_sequence_of_dicts(data: Any) -> Sequence[JsonDict]:
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
raise BreCalApiError("Expected list response from API.", payload=data)
|
||||
|
||||
|
||||
def _safe_json(response: requests.Response) -> Any:
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
if "application/json" in content_type:
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError:
|
||||
pass
|
||||
return response.text
|
||||
|
||||
|
||||
def _epoch_seconds() -> int:
|
||||
return int(time.time())
|
||||
@ -1,74 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Optional, Union
|
||||
|
||||
from .exceptions import ClientConfigurationError
|
||||
|
||||
ConfigPath = Union[str, Path]
|
||||
|
||||
DEFAULT_BASE_URL_FALLBACK = "https://brecaldevel.bsmd-emswe.eu"
|
||||
CONFIG_FILENAME = "client.json"
|
||||
|
||||
|
||||
def _default_config_path() -> Path:
|
||||
xdg = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
||||
return (xdg / "brecal" / CONFIG_FILENAME).expanduser()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClientConfig:
|
||||
base_url: str
|
||||
environment: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, data: Mapping[str, Any]) -> "ClientConfig":
|
||||
environment = data.get("environment")
|
||||
base_url = data.get("base_url")
|
||||
endpoints = data.get("endpoints")
|
||||
|
||||
if isinstance(endpoints, Mapping):
|
||||
if environment and environment in endpoints:
|
||||
base_url = endpoints[environment]
|
||||
elif not base_url and endpoints:
|
||||
# Pick the first entry as a last resort
|
||||
_, base_url = next(iter(endpoints.items()))
|
||||
|
||||
if not base_url:
|
||||
raise ClientConfigurationError(
|
||||
"Client configuration requires either 'base_url' or an "
|
||||
"'endpoints' mapping."
|
||||
)
|
||||
|
||||
return cls(
|
||||
base_url=str(base_url).rstrip("/"),
|
||||
environment=str(environment) if environment else None,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Optional[ConfigPath] = None) -> "ClientConfig":
|
||||
file_path = Path(path) if path else _default_config_path()
|
||||
data = json.loads(file_path.read_text(encoding="utf-8"))
|
||||
return cls.from_mapping(data)
|
||||
|
||||
|
||||
def get_default_base_url(path: Optional[ConfigPath] = None) -> str:
|
||||
"""Resolve the default base URL using env vars or ~/.config/brecal/client.json."""
|
||||
env_override = os.getenv("BRECAL_BASE_URL")
|
||||
if env_override:
|
||||
return env_override.rstrip("/")
|
||||
|
||||
try:
|
||||
config = ClientConfig.load(path=path)
|
||||
return config.base_url
|
||||
except FileNotFoundError:
|
||||
return DEFAULT_BASE_URL_FALLBACK
|
||||
except ClientConfigurationError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise ClientConfigurationError(
|
||||
f"Failed to load BreCal client configuration: {exc}"
|
||||
) from exc
|
||||
@ -1,68 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Optional, Union
|
||||
|
||||
ConfigPath = Union[str, Path]
|
||||
|
||||
|
||||
def _default_credentials_path() -> Path:
|
||||
"""Return the default path for the credential file."""
|
||||
xdg = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
||||
return (xdg / "brecal" / "credentials.json").expanduser()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Credentials:
|
||||
"""Holds username/password pairs for the BreCal API."""
|
||||
|
||||
username: str
|
||||
password: str
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, data: Mapping[str, Any]) -> "Credentials":
|
||||
"""Create credentials from a mapping (dict, TOML config, etc.)."""
|
||||
username = _coalesce_key(
|
||||
data, ("username", "user_name", "user"), required="username"
|
||||
)
|
||||
password = _coalesce_key(
|
||||
data, ("password", "pass", "secret"), required="password"
|
||||
)
|
||||
if not isinstance(username, str) or not username.strip():
|
||||
raise ValueError("BreCal credentials require a non-empty username.")
|
||||
if not isinstance(password, str) or not password:
|
||||
raise ValueError("BreCal credentials require a non-empty password.")
|
||||
return cls(username=username.strip(), password=password)
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Optional[ConfigPath] = None) -> "Credentials":
|
||||
"""Load credentials from a JSON file."""
|
||||
file_path = Path(path) if path else _default_credentials_path()
|
||||
text = file_path.read_text(encoding="utf-8")
|
||||
data = json.loads(text)
|
||||
return cls.from_mapping(data)
|
||||
|
||||
@classmethod
|
||||
def from_env(
|
||||
cls, username_var: str = "BRECAL_USERNAME", password_var: str = "BRECAL_PASSWORD"
|
||||
) -> "Credentials":
|
||||
"""Load credentials from environment variables."""
|
||||
username = os.getenv(username_var)
|
||||
password = os.getenv(password_var)
|
||||
if not username or not password:
|
||||
raise EnvironmentError(
|
||||
f"Missing credentials in env vars {username_var}/{password_var}"
|
||||
)
|
||||
return cls(username=username, password=password)
|
||||
|
||||
|
||||
def _coalesce_key(
|
||||
data: Mapping[str, Any], keys: tuple[str, ...], *, required: str
|
||||
) -> Any:
|
||||
for key in keys:
|
||||
if key in data:
|
||||
return data[key]
|
||||
raise KeyError(f"Missing '{required}' in credentials mapping.")
|
||||
@ -1,30 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
class BreCalApiError(RuntimeError):
|
||||
"""Base exception for API client failures."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
status_code: Optional[int] = None,
|
||||
payload: Optional[Any] = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.payload = payload
|
||||
|
||||
|
||||
class AuthenticationError(BreCalApiError):
|
||||
"""Raised when login fails."""
|
||||
|
||||
|
||||
class AuthorizationError(BreCalApiError):
|
||||
"""Raised for 401/403 responses after authentication."""
|
||||
|
||||
|
||||
class ClientConfigurationError(ValueError):
|
||||
"""Raised for invalid client configuration or missing dependencies."""
|
||||
@ -1,58 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Mapping, MutableMapping, Optional
|
||||
|
||||
JsonDict = Dict[str, Any]
|
||||
MutableJsonDict = MutableMapping[str, Any]
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoginResult:
|
||||
"""Represents the payload returned by /login."""
|
||||
|
||||
id: int
|
||||
participant_id: Optional[int]
|
||||
first_name: str
|
||||
last_name: str
|
||||
user_name: str
|
||||
user_email: Optional[str]
|
||||
user_phone: Optional[str]
|
||||
token: str
|
||||
exp: int
|
||||
|
||||
@classmethod
|
||||
def from_api(cls, data: Mapping[str, Any]) -> "LoginResult":
|
||||
return cls(
|
||||
id=_coerce_int(data.get("id")),
|
||||
participant_id=_coerce_optional_int(data.get("participant_id")),
|
||||
first_name=str(data.get("first_name") or ""),
|
||||
last_name=str(data.get("last_name") or ""),
|
||||
user_name=str(data.get("user_name") or ""),
|
||||
user_email=_coerce_optional_str(data.get("user_email")),
|
||||
user_phone=_coerce_optional_str(data.get("user_phone")),
|
||||
token=str(data.get("token") or ""),
|
||||
exp=_coerce_int(data.get("exp")),
|
||||
)
|
||||
|
||||
@property
|
||||
def expires_at(self) -> datetime:
|
||||
return datetime.fromtimestamp(self.exp, tz=timezone.utc)
|
||||
|
||||
|
||||
def _coerce_int(value: Any) -> int:
|
||||
if value is None:
|
||||
raise ValueError("Expected integer value, got None")
|
||||
return int(value)
|
||||
|
||||
|
||||
def _coerce_optional_int(value: Any) -> Optional[int]:
|
||||
return None if value is None else int(value)
|
||||
|
||||
|
||||
def _coerce_optional_str(value: Any) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value)
|
||||
return text if text else None
|
||||
@ -1,6 +1,7 @@
|
||||
from flask import Flask
|
||||
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
from . import local_db
|
||||
|
||||
@ -36,7 +37,6 @@ from BreCal.stubs.df_times import get_df_times
|
||||
from BreCal.services.schedule_routines import setup_schedule, run_schedule_permanently_in_background
|
||||
|
||||
def create_app(test_config=None, instance_path=None):
|
||||
|
||||
app = Flask(__name__, instance_relative_config=True)
|
||||
app.config.from_mapping(
|
||||
SECRET_KEY='dev'
|
||||
@ -48,6 +48,8 @@ def create_app(test_config=None, instance_path=None):
|
||||
|
||||
if instance_path is not None:
|
||||
app.instance_path = instance_path
|
||||
elif app.config.get("INSTANCE_PATH"):
|
||||
app.instance_path = app.config["INSTANCE_PATH"]
|
||||
|
||||
try:
|
||||
import os
|
||||
@ -69,13 +71,23 @@ def create_app(test_config=None, instance_path=None):
|
||||
app.register_blueprint(history.bp)
|
||||
app.register_blueprint(ports.bp)
|
||||
|
||||
logging.basicConfig(filename='brecal.log', level=logging.WARNING, format='%(asctime)s | %(name)s | %(levelname)s | %(message)s')
|
||||
local_db.initPool(os.path.dirname(app.instance_path))
|
||||
log_level = getattr(logging, app.config.get("LOG_LEVEL", "DEBUG"))
|
||||
log_kwargs = {"format": "%(asctime)s | %(name)s | %(levelname)s | %(message)s"}
|
||||
if app.config.get("LOG_TO_STDERR"):
|
||||
log_kwargs["stream"] = sys.stderr
|
||||
else:
|
||||
log_kwargs["filename"] = app.config.get("LOG_FILE", "brecaltest.log")
|
||||
logging.basicConfig(level=log_level, **log_kwargs)
|
||||
|
||||
if app.config.get("SECRET_KEY"):
|
||||
os.environ["SECRET_KEY"] = app.config["SECRET_KEY"]
|
||||
|
||||
local_db.initPool(os.path.dirname(app.instance_path), config=app.config)
|
||||
logging.info('App started')
|
||||
|
||||
# Setup Routine jobs (e.g., reevaluation of shipcalls)
|
||||
setup_schedule(update_shipcalls_interval_in_minutes=60)
|
||||
run_schedule_permanently_in_background(latency=30)
|
||||
setup_schedule(update_shipcalls_interval_in_minutes=app.config.get("SCHEDULE_UPDATE_SHIPCALLS_MINUTES", 60))
|
||||
run_schedule_permanently_in_background(latency=app.config.get("SCHEDULE_BACKGROUND_LATENCY_SECONDS", 30))
|
||||
logging.info('Routine Jobs are defined.')
|
||||
|
||||
return app
|
||||
|
||||
@ -14,7 +14,14 @@ def GetNotifications():
|
||||
try:
|
||||
if 'Authorization' in request.headers:
|
||||
token = request.headers.get('Authorization')
|
||||
return impl.notifications.GetNotifications(token)
|
||||
participant_id = None
|
||||
if 'participant_id' in request.args:
|
||||
try:
|
||||
participant_id = int(request.args.get('participant_id'))
|
||||
except (TypeError, ValueError):
|
||||
return create_dynamic_exception_response(ex=None, status_code=400, message="participant_id must be an integer")
|
||||
|
||||
return impl.notifications.GetNotifications(token, participant_id=participant_id)
|
||||
else:
|
||||
return create_dynamic_exception_response(ex=None, status_code=403, message="not authenticated")
|
||||
|
||||
|
||||
@ -7,12 +7,17 @@ from marshmallow import ValidationError
|
||||
from . import verify_if_request_is_json
|
||||
from BreCal.validators.validation_error import create_dynamic_exception_response, create_validation_error_response
|
||||
|
||||
import json
|
||||
import logging
|
||||
import traceback
|
||||
|
||||
bp = Blueprint('user', __name__)
|
||||
|
||||
@bp.route('/user', methods=['put'])
|
||||
@auth_guard() # no restriction by role
|
||||
def PutUser():
|
||||
|
||||
content = None
|
||||
try:
|
||||
verify_if_request_is_json(request)
|
||||
|
||||
@ -21,9 +26,11 @@ def PutUser():
|
||||
return impl.user.PutUser(loadedModel)
|
||||
|
||||
except ValidationError as ex:
|
||||
logging.warning("UserSchema validation failed. Payload=%s", json.dumps(content, default=str))
|
||||
return create_validation_error_response(ex=ex, status_code=400)
|
||||
|
||||
except Exception as ex:
|
||||
logging.error("UserSchema load failed. Payload=%s\n%s", json.dumps(content, default=str), traceback.format_exc())
|
||||
return create_dynamic_exception_response(ex=None, status_code=400, message="bad format")
|
||||
|
||||
|
||||
|
||||
@ -281,10 +281,8 @@ class SQLQuery():
|
||||
|
||||
def get_next24hrs_shipcalls()->str:
|
||||
query = ("SELECT s.id as id, ship.name as name FROM shipcall s INNER JOIN ship ON s.ship_id = ship.id LEFT JOIN times t on t.shipcall_id = s.id AND t.participant_type = 8 " + \
|
||||
"WHERE (type = 1 AND ((t.id IS NOT NULL AND t.eta_berth >= NOW() AND t.eta_berth < (NOW() + INTERVAL 1 DAY))" + \
|
||||
"OR (eta >= NOW() AND eta < (NOW() + INTERVAL 1 DAY)))) OR " + \
|
||||
"((type = 2 OR type = 3) AND ((t.id IS NOT NULL AND t.etd_berth >= NOW() AND " + \
|
||||
"t.etd_berth < (NOW() + INTERVAL 1 DAY)) OR (etd >= NOW() AND etd < (NOW() + INTERVAL 1 DAY))))" + \
|
||||
"WHERE (type = 1 AND (COALESCE(t.eta_berth, eta) >= NOW() AND COALESCE(t.eta_berth, eta) < (NOW() + INTERVAL 1 DAY)))" + \
|
||||
"OR ((type = 2 OR type = 3) AND (COALESCE(t.etd_berth, etd) >= NOW() AND COALESCE(t.etd_berth, etd) < (NOW() + INTERVAL 1 DAY)))"
|
||||
"AND s.canceled = 0")
|
||||
return query
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@ from .. import local_db
|
||||
from ..services import jwt_handler
|
||||
|
||||
|
||||
|
||||
def GetUser(options):
|
||||
|
||||
pooledConnection = None
|
||||
@ -38,7 +39,7 @@ def GetUser(options):
|
||||
"notify_whatsapp": data[0].notify_whatsapp,
|
||||
"notify_signal": data[0].notify_signal,
|
||||
"notify_popup": data[0].notify_popup,
|
||||
"notify_on": model.bitflag_to_list(data[0].notify_event)
|
||||
"notify_on": model.notification_types_to_names(model.bitflag_to_list(data[0].notify_event))
|
||||
}
|
||||
token = jwt_handler.generate_jwt(payload=result, lifetime=120) # generate token valid 60 mins
|
||||
result["token"] = token # add token to user data
|
||||
|
||||
@ -6,17 +6,22 @@ from ..schemas import model
|
||||
from .. import local_db
|
||||
from BreCal.database.sql_queries import SQLQuery
|
||||
|
||||
def GetNotifications(token):
|
||||
def GetNotifications(token, participant_id=None):
|
||||
"""
|
||||
No parameters, gets all entries
|
||||
Optional filtering by participant_id. Returns delivered (level=2) notifications.
|
||||
"""
|
||||
|
||||
pooledConnection = None
|
||||
try:
|
||||
pooledConnection = local_db.getPoolConnection()
|
||||
commands = pydapper.using(pooledConnection)
|
||||
data = commands.query("SELECT id, shipcall_id, participant_id, level, type, message, created, modified FROM notification " +
|
||||
"WHERE level = 2", model=model.Notification.from_query_row)
|
||||
query = "SELECT id, shipcall_id, participant_id, level, type, message, created, modified FROM notification WHERE level = 2"
|
||||
params = {}
|
||||
if participant_id is not None:
|
||||
query += " AND participant_id = ?participant_id?"
|
||||
params["participant_id"] = participant_id
|
||||
|
||||
data = commands.query(query, model=model.Notification.from_query_row, param=params if params else None)
|
||||
|
||||
except Exception as ex:
|
||||
logging.error(ex)
|
||||
|
||||
@ -8,6 +8,7 @@ import sys
|
||||
from BreCal.schemas import defs
|
||||
|
||||
config_path = None
|
||||
secure_dir = None
|
||||
_connection_pool = None
|
||||
|
||||
|
||||
@ -23,17 +24,26 @@ def _build_pool_config(connection_data, pool_name, pool_size):
|
||||
return pool_config
|
||||
|
||||
|
||||
def initPool(instancePath, connection_filename="connection_data_prod.json",
|
||||
pool_name="brecal_pool", pool_size=10):
|
||||
def initPool(instancePath, config=None, connection_filename="connection_data_prod.json",
|
||||
credentials_file="email_credentials_test.json", pool_name="brecal_pool", pool_size=10,
|
||||
secure_directory=None):
|
||||
"""
|
||||
Initialize the MySQL connection pool and load email credentials.
|
||||
"""
|
||||
global config_path, _connection_pool
|
||||
global config_path, secure_dir, _connection_pool
|
||||
try:
|
||||
if config:
|
||||
connection_filename = config.get("DB_CONNECTION_FILE", connection_filename)
|
||||
credentials_file = config.get("EMAIL_CREDENTIALS_FILE", credentials_file)
|
||||
pool_name = config.get("DB_POOL_NAME", pool_name)
|
||||
pool_size = config.get("DB_POOL_SIZE", pool_size)
|
||||
secure_directory = config.get("SECURE_DIR", secure_directory)
|
||||
|
||||
if secure_dir is None:
|
||||
secure_dir = secure_directory if secure_directory else os.path.join(instancePath, '../../../secure')
|
||||
|
||||
if config_path is None:
|
||||
config_path = os.path.join(instancePath, f'../../../secure/{connection_filename}')
|
||||
|
||||
# config_path = 'C:\\temp\\connection_data_test.json'
|
||||
config_path = os.path.join(secure_dir, connection_filename)
|
||||
|
||||
print(config_path)
|
||||
if not os.path.exists(config_path):
|
||||
@ -54,10 +64,7 @@ def initPool(instancePath, connection_filename="connection_data_prod.json",
|
||||
finally:
|
||||
conn_from_pool.close()
|
||||
|
||||
credentials_file = "email_credentials_test.json"
|
||||
credentials_path = os.path.join(instancePath, f'../../../secure/{credentials_file}')
|
||||
|
||||
# credentials_path = 'C:\\temp\\email_credentials_test.json'
|
||||
credentials_path = os.path.join(secure_dir, credentials_file)
|
||||
|
||||
if not os.path.exists(credentials_path):
|
||||
print('cannot find ' + os.path.abspath(credentials_path))
|
||||
|
||||
@ -3,49 +3,42 @@
|
||||
"type" : 1,
|
||||
"color" : "#0867ec",
|
||||
"name" : "assignment",
|
||||
"link" : "https://www.bremen-calling.de/",
|
||||
"msg_text" : "Nominierung"
|
||||
},
|
||||
{
|
||||
"type" : 2,
|
||||
"color" : "#ea5c00",
|
||||
"name" : "next24h",
|
||||
"link" : "https://www.bremen-calling.de/",
|
||||
"msg_text" : "Morgenrunde relevant"
|
||||
},
|
||||
{
|
||||
"type" : 3,
|
||||
"color" : "#f34336",
|
||||
"name" : "time_conflict",
|
||||
"link" : "https://www.bremen-calling.de/",
|
||||
"msg_text" : "Zeitlicher Konflikt"
|
||||
},
|
||||
{
|
||||
"type" : 4,
|
||||
"color" : "#28b532",
|
||||
"name" : "time_conflict_resolved",
|
||||
"link" : "https://www.bremen-calling.de/",
|
||||
"msg_text" : "Zeitlicher Konflikt gelöst"
|
||||
},
|
||||
{
|
||||
"type" : 5,
|
||||
"color" : "#a8a8a8",
|
||||
"name" : "unassigned",
|
||||
"link" : "https://www.bremen-calling.de/",
|
||||
"msg_text" : "Nominierung abgewählt"
|
||||
},
|
||||
{
|
||||
"type" : 6,
|
||||
"color" : "#a8a800",
|
||||
"name" : "missing_data",
|
||||
"link" : "https://www.bremen-calling.de/",
|
||||
"msg_text" : "Fehlende Daten"
|
||||
},
|
||||
{
|
||||
"type" : 7,
|
||||
"color" : "#808070",
|
||||
"name" : "cancelled",
|
||||
"link" : "https://www.bremen-calling.de/",
|
||||
"msg_text" : "Storno"
|
||||
}
|
||||
]
|
||||
@ -5,7 +5,7 @@ from marshmallow_enum import EnumField
|
||||
from enum import IntEnum
|
||||
|
||||
from marshmallow_dataclass import dataclass
|
||||
from typing import List
|
||||
from typing import Iterable, List
|
||||
|
||||
import json
|
||||
import re
|
||||
@ -85,19 +85,31 @@ class NotificationType(IntEnum):
|
||||
def _missing_(cls, value):
|
||||
return cls.undefined
|
||||
|
||||
def bitflag_to_list(bitflag: int) -> list[NotificationType]:
|
||||
def bitflag_to_list(bitflag: int | None) -> list[NotificationType]:
|
||||
"""Converts an integer bitflag to a list of NotificationType enums."""
|
||||
if bitflag is None:
|
||||
return []
|
||||
"""Converts an integer bitflag to a list of NotificationType enums."""
|
||||
return [nt for nt in NotificationType if bitflag & (1 << (nt.value - 1))]
|
||||
|
||||
def list_to_bitflag(notifications: fields.List) -> int:
|
||||
"""Converts a list of NotificationType enums to an integer bitflag."""
|
||||
try:
|
||||
iter(notifications)
|
||||
return sum(1 << (nt.value - 1) for nt in notifications)
|
||||
except TypeError as te:
|
||||
def list_to_bitflag(notifications: Iterable[NotificationType | str | int] | None) -> int:
|
||||
"""Converts a list of NotificationType enums (or their names/values) to an integer bitflag."""
|
||||
if not notifications:
|
||||
return 0
|
||||
bitflag = 0
|
||||
for nt in notifications:
|
||||
enum_val = None
|
||||
if isinstance(nt, NotificationType):
|
||||
enum_val = nt
|
||||
elif isinstance(nt, str):
|
||||
enum_val = NotificationType[nt]
|
||||
else:
|
||||
enum_val = NotificationType(nt)
|
||||
bitflag |= 1 << (enum_val.value - 1)
|
||||
return bitflag
|
||||
|
||||
def notification_types_to_names(notifications: Iterable[NotificationType]) -> list[str]:
|
||||
"""Render NotificationType values as their names for API responses."""
|
||||
return [nt.name for nt in notifications]
|
||||
|
||||
|
||||
class ShipcallType(IntEnum):
|
||||
@ -573,8 +585,7 @@ class User:
|
||||
notify_popup: bool
|
||||
created: datetime
|
||||
modified: datetime
|
||||
ports: List[NotificationType] = field(default_factory=list)
|
||||
notify_event: List[NotificationType] = field(default_factory=list)
|
||||
notify_event: int | None = 0
|
||||
|
||||
def __hash__(self):
|
||||
return hash(id)
|
||||
|
||||
@ -15,13 +15,13 @@ from email.mime.application import MIMEApplication
|
||||
|
||||
class EmailHandler():
|
||||
"""
|
||||
Creates an EmailHandler, which is capable of connecting to a mail server at a respective port,
|
||||
as well as logging into a specific user's mail address.
|
||||
Upon creating messages, these can be sent via this handler.
|
||||
Creates an EmailHandler, which is capable of connecting to a mail server at a respective port,
|
||||
as well as logging into a specific user's mail address.
|
||||
Upon creating messages, these can be sent via this handler.
|
||||
|
||||
Options:
|
||||
mail_server: address of the server, such as 'smtp.gmail.com' or 'w01d5503.kasserver.com
|
||||
mail_port:
|
||||
mail_port:
|
||||
25 - SMTP Port, to send emails
|
||||
110 - POP3 Port, to receive emails
|
||||
143 - IMAP Port, to receive from IMAP
|
||||
@ -38,6 +38,9 @@ class EmailHandler():
|
||||
|
||||
self.server = smtplib.SMTP_SSL(self.mail_server, self.mail_port) # alternatively, SMTP
|
||||
|
||||
# set the following to 0 to avoid log spamming
|
||||
self.server.set_debuglevel(1) # 0: no debug, 1: debug
|
||||
|
||||
def check_state(self):
|
||||
"""check, whether the server login took place and is open."""
|
||||
try:
|
||||
@ -45,7 +48,7 @@ class EmailHandler():
|
||||
return status_code==250 # 250: b'2.0.0 Ok'
|
||||
except smtplib.SMTPServerDisconnected:
|
||||
return False
|
||||
|
||||
|
||||
def check_connection(self):
|
||||
"""check, whether the server object is connected to the server. If not, connect it. """
|
||||
try:
|
||||
@ -53,7 +56,7 @@ class EmailHandler():
|
||||
except smtplib.SMTPServerDisconnected:
|
||||
self.server.connect(self.mail_server, self.mail_port)
|
||||
return
|
||||
|
||||
|
||||
def check_login(self)->bool:
|
||||
"""check, whether the server object is logged in as a user"""
|
||||
user = self.server.__dict__.get("user",None)
|
||||
@ -61,8 +64,8 @@ class EmailHandler():
|
||||
|
||||
def login(self, interactive:bool=True):
|
||||
"""
|
||||
login on the determined mail server's mail address. By default, this function opens an interactive window to
|
||||
type the password without echoing (printing '*******' instead of readable characters).
|
||||
login on the determined mail server's mail address. By default, this function opens an interactive window to
|
||||
type the password without echoing (printing '*******' instead of readable characters).
|
||||
|
||||
returns (status_code, status_msg)
|
||||
"""
|
||||
@ -77,7 +80,7 @@ class EmailHandler():
|
||||
def create_email(self, subject:str, message_body:str)->EmailMessage:
|
||||
"""
|
||||
Create an EmailMessage object, which contains the Email's header ("Subject"), content ("Message Body") and the sender's address ("From").
|
||||
The EmailMessage object does not contain the recipients yet, as these will be defined upon sending the Email.
|
||||
The EmailMessage object does not contain the recipients yet, as these will be defined upon sending the Email.
|
||||
"""
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = subject
|
||||
@ -85,16 +88,16 @@ class EmailHandler():
|
||||
#msg["To"] = email_tgts # will be defined in self.send_email
|
||||
msg.set_content(message_body)
|
||||
return msg
|
||||
|
||||
|
||||
def build_recipients(self, email_tgts:list[str]):
|
||||
"""
|
||||
email formatting does not support lists. Instead, items are joined into a comma-space-separated string.
|
||||
Example:
|
||||
[mail1@mail.com, mail2@mail.com] becomes
|
||||
email formatting does not support lists. Instead, items are joined into a comma-space-separated string.
|
||||
Example:
|
||||
[mail1@mail.com, mail2@mail.com] becomes
|
||||
'mail1@mail.com, mail2@mail.com'
|
||||
"""
|
||||
return ', '.join(email_tgts)
|
||||
|
||||
|
||||
def open_mime_application(self, path:str)->MIMEApplication:
|
||||
"""open a local file, read the bytes into a MIMEApplication object, which is built with the proper subtype (based on the file extension)"""
|
||||
with open(path, 'rb') as file:
|
||||
@ -102,24 +105,24 @@ class EmailHandler():
|
||||
|
||||
attachment.add_header('Content-Disposition','attachment',filename=str(os.path.basename(path)))
|
||||
return attachment
|
||||
|
||||
|
||||
def attach_file(self, path:str, msg:email.mime.multipart.MIMEMultipart)->None:
|
||||
"""
|
||||
attach a file to the message. This function opens the file, reads its bytes, defines the mime type by the
|
||||
path extension. The filename is appended as the header.
|
||||
attach a file to the message. This function opens the file, reads its bytes, defines the mime type by the
|
||||
path extension. The filename is appended as the header.
|
||||
|
||||
mimetypes: # https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types
|
||||
"""
|
||||
attachment = self.open_mime_application(path)
|
||||
msg.attach(attachment)
|
||||
return
|
||||
|
||||
|
||||
def send_email(self, msg:EmailMessage, email_tgts:list[str], cc_tgts:typing.Optional[list[str]]=None, bcc_tgts:typing.Optional[list[str]]=None, debug:bool=False)->typing.Union[dict,EmailMessage]:
|
||||
"""
|
||||
send a prepared email message to recipients (email_tgts), copy (cc_tgts) and blind copy (bcc_tgts).
|
||||
Returns a dictionary of feedback, which is commonly empty and the EmailMessage.
|
||||
|
||||
When failing, this function returns an SMTP error instead of returning the default outputs.
|
||||
send a prepared email message to recipients (email_tgts), copy (cc_tgts) and blind copy (bcc_tgts).
|
||||
Returns a dictionary of feedback, which is commonly empty and the EmailMessage.
|
||||
|
||||
When failing, this function returns an SMTP error instead of returning the default outputs.
|
||||
"""
|
||||
# Set the Recipients
|
||||
msg["To"] = self.build_recipients(email_tgts)
|
||||
@ -130,15 +133,15 @@ class EmailHandler():
|
||||
if bcc_tgts is not None:
|
||||
msg["Bcc"] = self.build_recipients(bcc_tgts)
|
||||
|
||||
# when debugging, do not send the Email, but return the EmailMessage.
|
||||
# when debugging, do not send the Email, but return the EmailMessage.
|
||||
if debug:
|
||||
return {}, msg
|
||||
|
||||
|
||||
assert self.check_login(), f"currently not logged in. Cannot send an Email. Make sure to properly use self.login first. "
|
||||
# send the prepared EmailMessage via the server.
|
||||
feedback = self.server.send_message(msg)
|
||||
return feedback, msg
|
||||
|
||||
|
||||
def translate_mail_to_multipart(self, msg:EmailMessage):
|
||||
"""EmailMessage does not support HTML and attachments. Hence, one can convert an EmailMessage object."""
|
||||
if msg.is_multipart():
|
||||
@ -159,11 +162,11 @@ class EmailHandler():
|
||||
# attach the remainder of the msg, such as the body, to the MIMEMultipart
|
||||
msg_new.attach(msg)
|
||||
return msg_new
|
||||
|
||||
|
||||
def print_email_attachments(self, msg:MIMEMultipart)->list[str]:
|
||||
"""return a list of lines of an Email, which contain 'filename=' as a list. """
|
||||
return [line_ for line_ in msg.as_string().split("\n") if "filename=" in line_]
|
||||
|
||||
|
||||
def close(self):
|
||||
self.server.__dict__.pop("user",None)
|
||||
self.server.__dict__.pop("password",None)
|
||||
|
||||
@ -124,11 +124,11 @@ def SendEmails(email_dict):
|
||||
defs.message_types = json.load(f)
|
||||
f.close()
|
||||
|
||||
for user, notifications in email_dict.items():
|
||||
for user_email, notifications in email_dict.items():
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = '[Bremen calling] Notification'
|
||||
msg["From"] = defs.email_credentials["sender"]
|
||||
msg["To"] = user.user_email
|
||||
msg["To"] = user_email
|
||||
|
||||
with open(os.path.join(current_path,'../msg/notification_template.html'), mode="r", encoding="utf-8") as file:
|
||||
body = file.read()
|
||||
@ -145,7 +145,8 @@ def SendEmails(email_dict):
|
||||
with open(os.path.join(current_path,'../msg/notification_element.html'), mode="r", encoding="utf-8") as file:
|
||||
element = file.read()
|
||||
element = element.replace("[[color]]", message_type["color"])
|
||||
element = element.replace("[[link]]", message_type["link"])
|
||||
linktext = defs.email_credentials["url_template"] + str(notification.shipcall_id)
|
||||
element = element.replace("[[link]]", linktext)
|
||||
|
||||
# We want to show the following information for each notification:
|
||||
# Ship-name, Arr/Dep/Shift, ETA/ETD, berth
|
||||
@ -180,7 +181,7 @@ def SendEmails(email_dict):
|
||||
body = body.replace("[[NOTIFICATION_ELEMENTS]]", replacement)
|
||||
msg.set_content(body, subtype='html', charset='utf-8', cte='8bit')
|
||||
|
||||
conn.sendmail(defs.email_credentials["sender"], user.user_email, msg.as_string())
|
||||
conn.sendmail(defs.email_credentials["sender"], user_email, msg.as_string())
|
||||
|
||||
except Exception as ex:
|
||||
logging.error(ex)
|
||||
@ -210,10 +211,10 @@ def SendNotifications():
|
||||
email_dict = dict()
|
||||
users_dict = dict()
|
||||
user_query = "SELECT * from user"
|
||||
users = commands.query(user_query, model=model.User)
|
||||
users = commands.query(user_query)
|
||||
for participant in participants:
|
||||
for user in users:
|
||||
if user.participant_id == participant.id:
|
||||
if user["participant_id"] == participant.id:
|
||||
if not participant.id in users_dict:
|
||||
users_dict[participant.id] = []
|
||||
users_dict[participant.id].append(user)
|
||||
@ -225,33 +226,39 @@ def SendNotifications():
|
||||
p_query = "SELECT * from shipcall_participant_map where shipcall_id = ?id?"
|
||||
assigned_participants = commands.query(p_query, model=model.ShipcallParticipantMap, param={"id":notification.shipcall_id})
|
||||
for assigned_participant in assigned_participants:
|
||||
if not assigned_participant.participant_id in users_dict:
|
||||
continue
|
||||
users = users_dict[assigned_participant.participant_id]
|
||||
for user in users:
|
||||
# send notification to user
|
||||
if user.notify_email:
|
||||
if user not in email_dict:
|
||||
email_dict[user] = []
|
||||
email_dict[user].append(notification)
|
||||
if user.notify_whatsapp:
|
||||
if user["notify_email"]:
|
||||
if user["user_email"] not in email_dict:
|
||||
email_dict[user["user_email"]] = []
|
||||
if notification not in email_dict[user["user_email"]]:
|
||||
email_dict[user["user_email"]].append(notification)
|
||||
if user["notify_whatsapp"]:
|
||||
# TBD
|
||||
pass
|
||||
if user.notify_signal:
|
||||
if user["notify_signal"]:
|
||||
# TBD
|
||||
pass
|
||||
else:
|
||||
users = users_dict[notification.participant_id]
|
||||
for user in users:
|
||||
# send notification to user
|
||||
if user.notify_email and user.wants_notifications(notification.type):
|
||||
if user not in email_dict:
|
||||
email_dict[user] = []
|
||||
email_dict[user].append(notification)
|
||||
if user.notify_whatsapp and user.wants_notifications(notification.type):
|
||||
# TBD
|
||||
pass
|
||||
if user.notify_signal and user.wants_notifications(notification.type):
|
||||
# TBD
|
||||
pass
|
||||
if notification.participant_id in users_dict:
|
||||
users = users_dict[notification.participant_id]
|
||||
for user in users:
|
||||
user_notifications = model.bitflag_to_list(user["notify_event"])
|
||||
# send notification to user
|
||||
if user["notify_email"] and notification.type in user_notifications:
|
||||
if user["user_email"] not in email_dict:
|
||||
email_dict[user["user_email"]] = []
|
||||
if notification not in email_dict[user["user_email"]]:
|
||||
email_dict[user["user_email"]].append(notification)
|
||||
if user["notify_whatsapp"] and notification.type in user_notifications:
|
||||
# TBD
|
||||
pass
|
||||
if user["notify_signal"] and notification.type in user_notifications:
|
||||
# TBD
|
||||
pass
|
||||
|
||||
# mark as sent
|
||||
commands.execute("UPDATE notification SET level = 2 WHERE id = ?id?", param={"id":notification.id})
|
||||
|
||||
31
src/server/BreCal/validators/input_validation_base.py
Normal file
31
src/server/BreCal/validators/input_validation_base.py
Normal file
@ -0,0 +1,31 @@
|
||||
import typing
|
||||
from marshmallow import ValidationError
|
||||
|
||||
class InputValidationBase:
|
||||
"""
|
||||
Base class for input validators. Common validation methods are grouped here.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def check_required_fields(content:dict, required_fields:list[str]):
|
||||
missing_fields = [field for field in required_fields if content.get(field) is None]
|
||||
if missing_fields:
|
||||
raise ValidationError({"required_fields": f"Missing required fields: {missing_fields}"})
|
||||
|
||||
@staticmethod
|
||||
def check_if_entry_is_already_deleted(entry:dict):
|
||||
"""
|
||||
Checks if the entry has 'deleted' set to True/1.
|
||||
"""
|
||||
if entry.get("deleted") in [True, 1, "1"]:
|
||||
raise ValidationError({"deleted": "The selected entry is already deleted."})
|
||||
|
||||
@staticmethod
|
||||
def check_deleted_flag_on_post(content:dict):
|
||||
"""
|
||||
Checks if 'deleted' is set to 1/True in a POST (Create) request.
|
||||
"""
|
||||
if content.get("deleted") in [True, 1, "1"]:
|
||||
raise ValidationError({"deleted": "Cannot create an entry with 'deleted' set to 1."})
|
||||
|
||||
|
||||
@ -18,10 +18,11 @@ from BreCal.validators.input_validation_utils import check_if_user_is_bsmd_type,
|
||||
from BreCal.database.sql_handler import execute_sql_query_standalone
|
||||
from BreCal.validators.validation_base_utils import check_if_int_is_valid_flag
|
||||
from BreCal.validators.validation_base_utils import check_if_string_has_special_characters
|
||||
from BreCal.validators.input_validation_base import InputValidationBase
|
||||
|
||||
import werkzeug
|
||||
|
||||
class InputValidationShip():
|
||||
class InputValidationShip(InputValidationBase):
|
||||
"""
|
||||
This class combines a complex set of individual input validation functions into a joint object.
|
||||
It uses static methods, so the object does not need to be instantiated, but functions can be called immediately.
|
||||
@ -55,6 +56,13 @@ class InputValidationShip():
|
||||
|
||||
# 3.) Check for reasonable Values (see BreCal.schemas.model.ShipSchema)
|
||||
InputValidationShip.optionally_evaluate_bollard_pull_value(content)
|
||||
|
||||
# 4.) No deleted flag may be set on POST
|
||||
InputValidationShip.check_deleted_flag_on_post(content)
|
||||
|
||||
# 5.) Check if is_tug is null
|
||||
InputValidationShip.check_is_tug_null(content)
|
||||
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
@ -70,6 +78,10 @@ class InputValidationShip():
|
||||
|
||||
# 4.) Check for reasonable Values (see BreCal.schemas.model.ShipSchema)
|
||||
InputValidationShip.optionally_evaluate_bollard_pull_value(content)
|
||||
|
||||
# 5.) Check if tug is null
|
||||
InputValidationShip.check_is_tug_null(content)
|
||||
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
@ -159,5 +171,11 @@ class InputValidationShip():
|
||||
raise ValidationError({"deleted":f"The selected ship entry is already deleted."})
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def check_is_tug_null(content:dict):
|
||||
is_tug = content.get("is_tug", None)
|
||||
if is_tug is None:
|
||||
raise ValidationError({"is_tug":f"The 'is_tug' property must be set to either True or False."})
|
||||
return
|
||||
|
||||
|
||||
|
||||
@ -17,12 +17,13 @@ from BreCal.database.sql_handler import get_assigned_participant_of_type
|
||||
from BreCal.database.sql_handler import execute_sql_query_standalone
|
||||
from BreCal.validators.validation_base_utils import check_if_int_is_valid_flag
|
||||
from BreCal.validators.validation_base_utils import check_if_string_has_special_characters
|
||||
from BreCal.validators.input_validation_base import InputValidationBase
|
||||
from BreCal.database.sql_queries import SQLQuery
|
||||
import werkzeug
|
||||
|
||||
|
||||
|
||||
class InputValidationShipcall():
|
||||
class InputValidationShipcall(InputValidationBase):
|
||||
"""
|
||||
This class combines a complex set of individual input validation functions into a joint object.
|
||||
It uses static methods, so the object does not need to be instantiated, but functions can be called immediately.
|
||||
@ -60,7 +61,11 @@ class InputValidationShipcall():
|
||||
InputValidationShipcall.check_participant_list_not_empty_when_user_is_agency(loadedModel)
|
||||
|
||||
# check for reasonable values in the shipcall fields
|
||||
InputValidationShipcall.check_shipcall_values(loadedModel, content, forbidden_keys=["evaluation", "evaluation_message"]) # "canceled"
|
||||
InputValidationShipcall.check_shipcall_values(loadedModel, content, forbidden_keys=["evaluation", "evaluation_message", "canceled"]) # "canceled"
|
||||
|
||||
# check for deleted flag on POST
|
||||
InputValidationShipcall.check_deleted_flag_on_post(content)
|
||||
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
|
||||
@ -17,6 +17,7 @@ from BreCal.database.sql_queries import SQLQuery
|
||||
from BreCal.database.sql_handler import execute_sql_query_standalone
|
||||
from BreCal.database.sql_handler import get_assigned_participant_of_type
|
||||
from BreCal.database.sql_utils import get_times_data_for_id
|
||||
from BreCal.validators.input_validation_base import InputValidationBase
|
||||
from BreCal.validators.validation_base_utils import check_if_int_is_valid_flag, check_if_string_has_special_characters
|
||||
import werkzeug
|
||||
|
||||
@ -63,7 +64,7 @@ def build_post_data_type_dependent_required_fields_dict()->dict[ShipcallType,dic
|
||||
|
||||
|
||||
|
||||
class InputValidationTimes():
|
||||
class InputValidationTimes(InputValidationBase):
|
||||
"""
|
||||
This class combines a complex set of individual input validation functions into a joint object.
|
||||
It uses static methods, so the object does not need to be instantiated, but functions can be called immediately.
|
||||
@ -92,6 +93,10 @@ class InputValidationTimes():
|
||||
|
||||
# 4.) Value checking
|
||||
InputValidationTimes.check_dataset_values(user_data, loadedModel, content)
|
||||
|
||||
# 5.) Deleted flag may not be set on POST
|
||||
InputValidationTimes.check_deleted_flag_on_post(content)
|
||||
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
|
||||
@ -59,7 +59,7 @@ def create_validation_error_response(ex:ValidationError, status_code:int=400, cr
|
||||
|
||||
if create_log:
|
||||
logging.warning(ex) if ex is not None else logging.warning(message)
|
||||
print(ex) if ex is not None else print(message)
|
||||
# print(ex) if ex is not None else print(message)
|
||||
return (serialized_response, status_code)
|
||||
|
||||
def create_werkzeug_error_response(ex:Forbidden, status_code:int=403, create_log:bool=True)->typing.Tuple[str,int]:
|
||||
@ -71,7 +71,7 @@ def create_werkzeug_error_response(ex:Forbidden, status_code:int=403, create_log
|
||||
|
||||
if create_log:
|
||||
logging.warning(ex) if ex is not None else logging.warning(message)
|
||||
print(ex) if ex is not None else print(message)
|
||||
# print(ex) if ex is not None else print(message)
|
||||
return serialized_response, status_code
|
||||
|
||||
def create_dynamic_exception_response(ex, status_code:int=400, message:typing.Optional[str]=None, create_log:bool=True):
|
||||
@ -83,5 +83,5 @@ def create_dynamic_exception_response(ex, status_code:int=400, message:typing.Op
|
||||
|
||||
if create_log:
|
||||
logging.warning(ex) if ex is not None else logging.warning(message)
|
||||
print(ex) if ex is not None else print(message)
|
||||
# print(ex) if ex is not None else print(message)
|
||||
return (serialized_response, status_code)
|
||||
|
||||
@ -52,7 +52,8 @@ class ValidationRules(ValidationRuleFunctions):
|
||||
# 'translate' all error codes into readable, human-understandable format.
|
||||
evaluation_results = [(state, self.describe_error_message(msg)) for (state, msg) in evaluation_results]
|
||||
|
||||
logging.info(f"Validation results for shipcall {shipcall.id}: {evaluation_results}")
|
||||
if evaluation_results:
|
||||
logging.info(f"Validation results for shipcall {shipcall.id}: {evaluation_results}")
|
||||
|
||||
# check, what the maximum state flag is and return it
|
||||
evaluation_state = np.max(np.array([result[0].value for result in evaluation_results])) if len(evaluation_results)>0 else StatusFlags.GREEN.value
|
||||
|
||||
40
src/server/config.py.sample
Normal file
40
src/server/config.py.sample
Normal file
@ -0,0 +1,40 @@
|
||||
"""
|
||||
Sample configuration for the Flask instance.
|
||||
|
||||
Copy this file to `src/server/instance/config.py` (the instance folder is git-ignored)
|
||||
and adjust the values for each deployment target.
|
||||
"""
|
||||
|
||||
# Flask
|
||||
SECRET_KEY = "change-me"
|
||||
|
||||
# Python path adjustments used by the WSGI entrypoint (flaskapp.wsgi)
|
||||
APP_ROOT = "/var/www/brecal/src/server"
|
||||
SITE_PACKAGES = "/var/www/venv/lib/python3.12/site-packages/"
|
||||
|
||||
# Paths to environment-specific secrets and instance data
|
||||
SECURE_DIR = "/var/www/secure" # directory that holds connection/email JSON files
|
||||
INSTANCE_PATH = "/var/www/brecal/src/server/instance"
|
||||
|
||||
# Logging
|
||||
LOG_FILE = "brecal.log"
|
||||
LOG_LEVEL = "INFO" # e.g. DEBUG, INFO, WARNING
|
||||
LOG_TO_STDERR = False
|
||||
|
||||
# Database pool setup
|
||||
DB_CONNECTION_FILE = "connection_data_prod.json"
|
||||
DB_POOL_NAME = "brecal_pool"
|
||||
DB_POOL_SIZE = 10
|
||||
|
||||
# Email + notifications
|
||||
EMAIL_CREDENTIALS_FILE = "email_credentials_prod.json"
|
||||
EMAIL_URL_TEMPLATE = "https://brecal.example.com/shipcalls/" # base URL for links in emails
|
||||
SMTP_DEBUG_LEVEL = 0 # 0 = quiet, 1 = verbose
|
||||
|
||||
# Scheduler cadence
|
||||
SCHEDULE_UPDATE_SHIPCALLS_MINUTES = 60
|
||||
SCHEDULE_BACKGROUND_LATENCY_SECONDS = 30
|
||||
|
||||
# Notification cleanup / escalation windows
|
||||
NOTIFICATION_COOLDOWN_MINS = 10
|
||||
NOTIFICATION_MAX_AGE_DAYS = 3
|
||||
@ -1,20 +1,29 @@
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
import os
|
||||
import runpy
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, '/var/www/brecal/src/server')
|
||||
sys.path.insert(0, '/var/www/venv/lib/python3.12/site-packages/')
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
INSTANCE_DIR = BASE_DIR / "instance"
|
||||
CONFIG_PATH = INSTANCE_DIR / "config.py"
|
||||
|
||||
import schedule
|
||||
config = {}
|
||||
if CONFIG_PATH.exists():
|
||||
config = runpy.run_path(str(CONFIG_PATH))
|
||||
|
||||
# set the key
|
||||
os.environ['SECRET_KEY'] = 'zdiTz8P3jXOc7jztIQAoelK4zztyuCpJ'
|
||||
app_root = config.get("APP_ROOT", str(BASE_DIR))
|
||||
site_packages = config.get("SITE_PACKAGES")
|
||||
|
||||
# Set up logging
|
||||
logging.basicConfig(stream=sys.stderr, level=logging.DEBUG)
|
||||
sys.path.insert(0, app_root)
|
||||
if site_packages:
|
||||
sys.path.insert(0, site_packages)
|
||||
|
||||
# Set up Scheduled Jobs
|
||||
if config.get("SECRET_KEY"):
|
||||
os.environ["SECRET_KEY"] = config["SECRET_KEY"]
|
||||
|
||||
log_kwargs = {"level": getattr(logging, config.get("LOG_LEVEL", "DEBUG")), "stream": sys.stderr}
|
||||
logging.basicConfig(**log_kwargs)
|
||||
|
||||
# Import and run the Flask app
|
||||
from BreCal import create_app
|
||||
application = create_app()
|
||||
application = create_app(instance_path=config.get("INSTANCE_PATH"))
|
||||
|
||||
@ -1,166 +0,0 @@
|
||||
# parser.py
|
||||
# Utilities to extract text from the PDF and parse rows into records.
|
||||
# Assumes two tables: first is Harbor A, second is Harbor B.
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
from csv import reader
|
||||
import re
|
||||
from typing import List, Dict, Any, Tuple
|
||||
|
||||
|
||||
# Optional: If you want OCR fallback later, wire in pdf2image + pytesseract here.
|
||||
|
||||
|
||||
def extract_text_lines(pdf_path: str) -> List[str]:
|
||||
"""Extract text lines from a PDF using pdfplumber (preferred) with a
|
||||
light fallback to PyPDF2. Returns a list of raw lines."""
|
||||
text = ""
|
||||
|
||||
try:
|
||||
import pdfplumber
|
||||
with pdfplumber.open(pdf_path) as pdf:
|
||||
pages_text = []
|
||||
for p in pdf.pages:
|
||||
t = p.extract_text() or ""
|
||||
pages_text.append(t)
|
||||
text = "\n".join(pages_text)
|
||||
except Exception:
|
||||
|
||||
try:
|
||||
from PyPDF2 import PdfReader
|
||||
reader = PdfReader(pdf_path)
|
||||
pages_text = []
|
||||
for page in reader.pages:
|
||||
pages_text.append(page.extract_text() or "")
|
||||
text = "\n".join(pages_text)
|
||||
except Exception:
|
||||
text = ""
|
||||
|
||||
|
||||
if not text.strip():
|
||||
raise RuntimeError("No text extracted. If the PDF is scanned, add OCR fallback (pytesseract).")
|
||||
|
||||
|
||||
# Normalize to individual lines
|
||||
lines = [ln.strip() for ln in text.splitlines()]
|
||||
return lines
|
||||
|
||||
|
||||
HEADER_PATTERNS = [
|
||||
re.compile(r"\bSchiff\b.*\bETA\b.*\bETS\b", re.IGNORECASE),
|
||||
re.compile(r"Nächster Hafen|Liegeplatz|Ladung|Lotse", re.IGNORECASE),
|
||||
]
|
||||
|
||||
|
||||
DATE_TOKEN = re.compile(r"\b\d{1,2}\.\d{1,2}\.(?:\d{4})?")
|
||||
TIME_FRAGMENT = r"(?:\s*/\s*\d{1,2}\.\d{2}\s*Uhr\s*\*?)?"
|
||||
DT_TOKEN_WITH_TIME = re.compile(r"\d{1,2}\.\d{1,2}\.(?:\d{4})?" + TIME_FRAGMENT)
|
||||
|
||||
|
||||
|
||||
|
||||
def cleanse_lines(lines: List[str]) -> List[str]:
|
||||
"""Remove known header lines and keep data/blank lines."""
|
||||
out: List[str] = []
|
||||
for ln in lines:
|
||||
if not ln:
|
||||
out.append("")
|
||||
continue
|
||||
if any(p.search(ln) for p in HEADER_PATTERNS):
|
||||
continue
|
||||
out.append(ln)
|
||||
return out
|
||||
|
||||
def split_into_tables(lines: List[str]) -> List[List[str]]:
|
||||
"""Find candidate data lines (those containing a date token) and split them
|
||||
into up to two blocks separated by at least one blank line. Returns a list
|
||||
of blocks (1 or 2)."""
|
||||
candidate = [ln for ln in lines if (ln == "" or DATE_TOKEN.search(ln))]
|
||||
|
||||
|
||||
blocks: List[List[str]] = []
|
||||
current: List[str] = []
|
||||
seen_data = False
|
||||
for ln in candidate:
|
||||
if ln == "":
|
||||
if seen_data and current:
|
||||
blocks.append(current)
|
||||
current = []
|
||||
seen_data = False
|
||||
continue
|
||||
current.append(ln)
|
||||
seen_data = True
|
||||
if current:
|
||||
blocks.append(current)
|
||||
|
||||
|
||||
if len(blocks) > 2:
|
||||
# Merge any extra blocks into the second
|
||||
blocks = [blocks[0], sum(blocks[1:], [])]
|
||||
return blocks
|
||||
|
||||
def parse_line_to_record(ln: str) -> Dict[str, Any]:
|
||||
"""Parse a single table line into a minimal record.
|
||||
|
||||
|
||||
Output fields:
|
||||
- ship: text before the first date token
|
||||
- eta_raw: 1st date(+optional time) token as raw string
|
||||
- ets_raw: 2nd date(+optional time) token as raw string (if present)
|
||||
- notes: remainder of the line after the last extracted date token
|
||||
- raw_line: the full original line
|
||||
"""
|
||||
|
||||
# Ship name up to the first date token
|
||||
first = DATE_TOKEN.search(ln)
|
||||
ship = ln[: first.start()].strip() if first else ln.strip()
|
||||
|
||||
|
||||
# Extract up to two date(+time) tokens
|
||||
dt_tokens = DT_TOKEN_WITH_TIME.findall(ln)
|
||||
eta_raw = dt_tokens[0].strip() if len(dt_tokens) >= 1 else None
|
||||
ets_raw = dt_tokens[1].strip() if len(dt_tokens) >= 2 else None
|
||||
|
||||
|
||||
# Notes: everything after the last date token we captured
|
||||
notes = ""
|
||||
if dt_tokens:
|
||||
last_match = None
|
||||
it = DT_TOKEN_WITH_TIME.finditer(ln)
|
||||
for last_match in it:
|
||||
pass
|
||||
if last_match:
|
||||
notes = ln[last_match.end() :].strip()
|
||||
|
||||
|
||||
return {
|
||||
"ship": ship,
|
||||
"eta_raw": eta_raw,
|
||||
"ets_raw": ets_raw,
|
||||
"notes": notes,
|
||||
"raw_line": ln,
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
def parse_pdf_to_records(pdf_path: str) -> List[Dict[str, Any]]:
|
||||
"""High-level: extract lines, cleanse headers, split into 1–2 tables,
|
||||
tag as harbor A/B by order, parse rows → records."""
|
||||
|
||||
lines = extract_text_lines(pdf_path)
|
||||
clean = cleanse_lines(lines)
|
||||
blocks = split_into_tables(clean)
|
||||
|
||||
|
||||
records: List[Dict[str, Any]] = []
|
||||
for i, block in enumerate(blocks):
|
||||
harbor = "A" if i == 0 else "B"
|
||||
for ln in block:
|
||||
if not ln.strip():
|
||||
continue
|
||||
rec = parse_line_to_record(ln)
|
||||
rec["harbor"] = harbor
|
||||
records.append(rec)
|
||||
return records
|
||||
@ -1,172 +0,0 @@
|
||||
# pdf_to_records.py
|
||||
# CLI: parse a PDF and write JSONL (default) or CSV with one record per row.
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
import argparse, json, csv, re
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# PDF text extraction helpers
|
||||
# -----------------------------
|
||||
HEADER_PATTERNS = [
|
||||
re.compile(r"\bSchiff\b.*\bETA\b.*\bETS\b", re.IGNORECASE),
|
||||
re.compile(r"Nächster Hafen|Liegeplatz|Ladung|Lotse", re.IGNORECASE),
|
||||
]
|
||||
DATE_TOKEN = re.compile(r"\b\d{1,2}\.\d{1,2}\.(?:\d{4})?")
|
||||
TIME_FRAGMENT = r"(?:\s*/\s*\d{1,2}\.\d{2}\s*Uhr\s*\*?)?"
|
||||
DT_TOKEN_WITH_TIME = re.compile(r"\d{1,2}\.\d{1,2}\.(?:\d{4})?" + TIME_FRAGMENT)
|
||||
|
||||
|
||||
def extract_text_lines(pdf_path: str) -> List[str]:
|
||||
"""Extract raw text lines from the PDF. Prefers pdfplumber with PyPDF2 fallback."""
|
||||
text = ""
|
||||
try:
|
||||
import pdfplumber
|
||||
|
||||
with pdfplumber.open(pdf_path) as pdf:
|
||||
pages_text = [(p.extract_text() or "") for p in pdf.pages]
|
||||
text = "\n".join(pages_text)
|
||||
except Exception:
|
||||
try:
|
||||
from PyPDF2 import PdfReader
|
||||
|
||||
reader = PdfReader(pdf_path)
|
||||
pages_text = [(page.extract_text() or "") for page in reader.pages]
|
||||
text = "\n".join(pages_text)
|
||||
except Exception:
|
||||
text = ""
|
||||
|
||||
if not text.strip():
|
||||
raise RuntimeError(
|
||||
"No text extracted. If the PDF is scanned, consider adding OCR fallback."
|
||||
)
|
||||
|
||||
return [ln.strip() for ln in text.splitlines()]
|
||||
|
||||
|
||||
def cleanse_lines(lines: List[str]) -> List[str]:
|
||||
"""Remove headers, keep data lines and blanks for table boundaries."""
|
||||
cleaned: List[str] = []
|
||||
for ln in lines:
|
||||
if not ln:
|
||||
cleaned.append("")
|
||||
continue
|
||||
if any(pattern.search(ln) for pattern in HEADER_PATTERNS):
|
||||
continue
|
||||
cleaned.append(ln)
|
||||
return cleaned
|
||||
|
||||
|
||||
def split_into_tables(lines: List[str]) -> List[List[str]]:
|
||||
"""Split lines into up to two tables, separated by blank lines."""
|
||||
candidate = [ln for ln in lines if (ln == "" or DATE_TOKEN.search(ln))]
|
||||
|
||||
blocks: List[List[str]] = []
|
||||
current: List[str] = []
|
||||
seen_data = False
|
||||
for ln in candidate:
|
||||
if ln == "":
|
||||
if seen_data and current:
|
||||
blocks.append(current)
|
||||
current = []
|
||||
seen_data = False
|
||||
continue
|
||||
current.append(ln)
|
||||
seen_data = True
|
||||
if current:
|
||||
blocks.append(current)
|
||||
|
||||
if len(blocks) > 2:
|
||||
blocks = [blocks[0], sum(blocks[1:], [])]
|
||||
return blocks
|
||||
|
||||
|
||||
def parse_line_to_record(ln: str) -> Dict[str, Any]:
|
||||
"""Parse a table line into structured fields."""
|
||||
first = DATE_TOKEN.search(ln)
|
||||
ship = ln[: first.start()].strip() if first else ln.strip()
|
||||
|
||||
dt_tokens = DT_TOKEN_WITH_TIME.findall(ln)
|
||||
eta_raw = dt_tokens[0].strip() if len(dt_tokens) >= 1 else None
|
||||
ets_raw = dt_tokens[1].strip() if len(dt_tokens) >= 2 else None
|
||||
|
||||
notes = ""
|
||||
if dt_tokens:
|
||||
last_match = None
|
||||
for last_match in DT_TOKEN_WITH_TIME.finditer(ln):
|
||||
pass
|
||||
if last_match:
|
||||
notes = ln[last_match.end() :].strip()
|
||||
|
||||
return {
|
||||
"ship": ship,
|
||||
"eta_raw": eta_raw,
|
||||
"ets_raw": ets_raw,
|
||||
"notes": notes,
|
||||
"raw_line": ln,
|
||||
}
|
||||
|
||||
|
||||
def parse_pdf_to_records(pdf_path: str) -> List[Dict[str, Any]]:
|
||||
"""High-level parser: extract text, sanitize, split per harbor, parse rows."""
|
||||
lines = extract_text_lines(pdf_path)
|
||||
clean = cleanse_lines(lines)
|
||||
blocks = split_into_tables(clean)
|
||||
|
||||
records: List[Dict[str, Any]] = []
|
||||
for i, block in enumerate(blocks):
|
||||
harbor = "A" if i == 0 else "B"
|
||||
for ln in block:
|
||||
if not ln.strip():
|
||||
continue
|
||||
rec = parse_line_to_record(ln)
|
||||
rec["harbor"] = harbor
|
||||
records.append(rec)
|
||||
|
||||
return records
|
||||
|
||||
|
||||
def write_jsonl(path: Path, rows: List[Dict[str, Any]]):
|
||||
with path.open("w", encoding="utf-8") as f:
|
||||
for r in rows:
|
||||
f.write(json.dumps(r, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
def write_csv(path: Path, rows: List[Dict[str, Any]]):
|
||||
if not rows:
|
||||
path.write_text("", encoding="utf-8")
|
||||
return
|
||||
fieldnames = ["harbor", "ship", "eta_raw", "ets_raw", "notes"]
|
||||
with path.open("w", newline="", encoding="utf-8") as f:
|
||||
w = csv.DictWriter(f, fieldnames=fieldnames)
|
||||
w.writeheader()
|
||||
for r in rows:
|
||||
w.writerow({k: r.get(k) for k in fieldnames})
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Parse ship tables PDF → records (A/B)")
|
||||
ap.add_argument("pdf", help="Path to partner PDF")
|
||||
ap.add_argument("--out", help="Output file path (default: <pdf>.jsonl)")
|
||||
ap.add_argument("--format", choices=["jsonl", "csv"], default="jsonl")
|
||||
args = ap.parse_args()
|
||||
|
||||
|
||||
rows = parse_pdf_to_records(args.pdf)
|
||||
|
||||
|
||||
out = Path(args.out) if args.out else Path(args.pdf).with_suffix(".jsonl")
|
||||
if args.format == "jsonl":
|
||||
write_jsonl(out, rows)
|
||||
else:
|
||||
write_csv(out, rows)
|
||||
|
||||
print(f"Wrote {len(rows)} records -> {out}")
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Reference in New Issue
Block a user