Compare commits

...

111 Commits

Author SHA1 Message Date
f68e9ee218 Notification endpoint now filters for participant_id and fixed parsing of event type enumeration array 2025-12-09 12:21:11 +01:00
18f6d53998 Added logging to PutUser failures 2025-12-08 15:40:44 +01:00
4b8e878735 Added ChatGPT-created Mermaid database diagram to the Readme file 2025-12-08 15:40:32 +01:00
3f7da82ea6 Updated create schema script and added script to clear times doublettes for same participant type 2025-12-08 15:40:18 +01:00
c1e3e8939a Externalize all configuration parameters Pt.I 2025-12-05 18:08:15 +01:00
dc98b1d500 Merge branch 'release/1.7.0' into develop 2025-12-05 16:05:42 +01:00
a50cd9cc9a Merge branch 'bugfix/deactivating_users_roleeditor' into develop 2025-12-05 16:00:45 +01:00
d06669e943 Merge branch 'release/1.7.0' into develop 2025-12-05 15:58:38 +01:00
60baf02299 Applied automatic field check also for PUT on times to avoid accidental overwrite 2025-11-16 19:52:09 +01:00
ae2ce859ad Do not accidentally overwrite shipcall fields when fields are not passed on PUT request 2025-11-16 19:26:15 +01:00
44dd6010d7 Put some extra info in the docs 2025-11-14 15:54:18 +01:00
9116841292 Added a documentation file for the API spec 2025-11-14 11:19:04 +01:00
b5dd7422f4 Initializing pool connection variable with None.
Release pool connection handle und all circumstances especially also when a query fails
before the call is finished. This should avoid connection starvation.

fix prod. link

production fix

Fixed application path
2025-11-12 15:06:54 +01:00
63a3ce2f6f Improved connection pool init 2025-11-12 13:54:26 +01:00
8cc3444626 Added default port to python run flask settings 2025-11-12 13:53:34 +01:00
6362f47d43 Updated project settings, removed participant 'active' and fixed user delete 2025-10-13 17:37:59 +02:00
c6954fb222 Fixed some validation issues that have cropped up over the last months 2025-10-07 12:01:57 +02:00
6d8b86280c changed e-mail formatting to direct url at actual notified shipcall 2025-09-30 14:46:34 +02:00
2a1570d9f5 bugfix enum format
fixed required case

fixed more default occurrances

changed validates signature
2025-09-08 15:02:06 +02:00
14cfb41591 bugfix enum format
fixed required case

fixed more default occurrances

changed validates signature
2025-09-08 14:59:09 +02:00
62bd6304c4 Allow special characters &,-,_ for ship name and callsign 2025-07-25 13:33:18 +02:00
7fea4d27b7 Updated clear data script for the database (for purging all data via SQL) 2025-07-25 13:05:13 +02:00
03b434b801 Zwei Nachkommastellen für den Tiefgang in der Übersicht 2025-05-26 17:14:41 +02:00
dbd7347ac9 Moved draft up and put unit behind the value 2025-05-26 17:14:21 +02:00
ac15a6c2cf Added german satellite assemblies to setup project 2025-05-26 17:14:09 +02:00
c27685df6e Draft instead of callsign in BSMD cell 2025-05-26 17:12:31 +02:00
6610532c90 Some stuff for the website 2025-04-29 10:58:06 +02:00
d180dac600 improved next 24hr schedule check query that takes precedence for times eta value 2025-03-14 15:03:38 +01:00
8b4131332b release pooled SQL connection when sending an email 2025-03-07 10:05:14 +01:00
27b9f46f30 Avoid adding the same notification twice to a sender 2025-03-06 09:51:12 +01:00
c8550431e0 Bugfix for last update 2025-03-05 17:39:39 +01:00
a1b807824e fix for checking notification types when e-mail notifications are evaluated 2025-03-04 17:55:18 +01:00
189626d61c Reduced log-verbosity on the server 2025-02-27 13:48:13 +01:00
7b08eafd84 Version bump to 1.8.0.0 2025-02-10 08:28:43 +01:00
9e1c654826 Fixed error where rows where not collapsed when user is not of type pilot 2025-02-10 08:19:32 +01:00
ec925c1eb6 Fixed flag evaluation for notification selection type 2025-02-10 08:19:20 +01:00
d879d8cc5c Fixed typo 2025-02-10 08:19:08 +01:00
4885c6a0ff Fix bug when checking for assigned pilot in case tidal times are changed 2025-02-10 08:18:58 +01:00
7baa7b0220 Added event type evaluation and storage of selection bitflag. Fixed some details in the UI 2025-02-10 08:18:44 +01:00
a3a8ef3b39 Extended API and added event type selection to about dialog 2025-02-10 08:18:32 +01:00
fd5dbc8b37 Changed Win Target to .NET8, updated YAML for user notification event selection 2025-02-10 08:18:17 +01:00
6cbc8df5f5 Allow pilots to enter tidal times 2025-02-10 08:17:59 +01:00
bc3d5678ed Allow shipcall PUT also by PILOT 2025-02-10 08:17:46 +01:00
545910c9b8 Fix filtering of notifications depending on participant assignment to shipcall in case the notification has no participant id 2025-02-10 08:17:20 +01:00
9dc4673b3b Fix E-Mail validation error reporting 2025-02-10 08:16:57 +01:00
3d76acb2f0 Version bump to 1.7.0.7 2025-02-10 08:14:57 +01:00
98c05aed3b Fixed error where rows where not collapsed when user is not of type pilot 2025-02-09 13:57:03 +01:00
98696aee93 Fixed flag evaluation for notification selection type 2025-02-08 13:41:44 +01:00
7f706dfc51 Fixed typo 2025-02-08 11:16:21 +01:00
ab12e28d3d Fix bug when checking for assigned pilot in case tidal times are changed 2025-02-06 06:54:19 +01:00
e9a7e03ebf Version bump to 1.7.0.6 2025-02-05 20:06:07 +01:00
f1c5bd3cd8 Added event type evaluation and storage of selection bitflag. Fixed some details in the UI 2025-02-05 19:24:07 +01:00
bb13d74849 Extended API and added event type selection to about dialog 2025-02-05 09:27:59 +01:00
55cf17d169 Changed Win Target to .NET8, updated YAML for user notification event selection 2025-02-04 10:12:29 +01:00
ea634a3af2 Allow pilots to enter tidal times 2025-02-03 12:02:24 +01:00
21471d4d41 Allow shipcall PUT also by PILOT 2025-02-03 11:56:44 +01:00
fce897fae4 Fix filtering of notifications depending on participant assignment to shipcall in case the notification has no participant id 2025-02-03 11:14:51 +01:00
64c6607076 Fix E-Mail validation error reporting 2025-02-03 10:35:46 +01:00
6dedc04957 changed bg color for missing data 2025-01-21 15:19:18 +01:00
213f7cf58c fixed path 2025-01-21 14:52:50 +01:00
49a8498bbe Changed settings for test version 2025-01-21 13:47:52 +01:00
e84a73465d Version bump to 1.7.0.5 2025-01-20 08:14:18 +01:00
2d61565c29 fixed stupid serialization error 2025-01-20 08:13:37 +01:00
753d8a4465 fixed stupid init bug 2025-01-13 17:35:35 +01:00
654518e642 Version bump to 1.7.0.4 2025-01-13 17:09:52 +01:00
7840406688 split up red / yellow evaluation errors on separate notification types (time_conflict(red), missing_data(yellow)) 2025-01-13 16:31:32 +01:00
1f860baa2b do not show notifications again on the client 2025-01-13 11:59:12 +01:00
5eb1074a79 Fixed notification event display on client side 2025-01-13 11:45:13 +01:00
ba8778cc3f fixed interval settings 2025-01-13 10:44:37 +01:00
6b173495af Clear notifications from the database that are more than 3 days in the past 2025-01-13 09:45:56 +01:00
cda3f231a7 creating notifications if a shipcall is cancelled 2025-01-10 13:49:00 +01:00
91caf74dca filter out cancelled shipcalls before timer error validation 2025-01-10 13:28:12 +01:00
b36e2c9e05 Added new notifications to basic types 2025-01-10 11:48:11 +01:00
0c6c3a048d Merge branch 'feature/toast_notifications' into develop 2025-01-10 11:17:11 +01:00
1e6e34df77 Version bump to 1.7.0.3 2025-01-08 09:24:11 +01:00
f7a43ca971 Added some separators in about dialog to make it easier to understand 2025-01-08 09:23:53 +01:00
e103743d5e removed erroneous break from add user loop 2025-01-07 07:47:57 +01:00
710e21e567 fixed small de-ref bug 2025-01-07 07:24:42 +01:00
afe31e504a Version bump 1.7.0.2 2024-12-23 18:48:55 +01:00
1fd87edd6e Custom toast control, colored by type 2024-12-23 18:39:24 +01:00
a648cc2e71 Overview window of past notifications 2024-12-23 11:23:37 +01:00
880a8a2a8d Got simple toast notifications going 2024-12-19 12:59:54 +01:00
f7684902aa fixed missing info in notification API 2024-12-19 10:48:36 +01:00
f218e5f96a fixed missing info in notification API 2024-12-18 17:59:40 +01:00
4d5d63dbdd Updated Nuget 2024-12-18 08:53:06 +01:00
622ab6b4a3 fixed some smaller issues 2024-12-17 14:51:04 +01:00
47da3ff475 removed wrong curly braces 2024-12-17 10:48:39 +01:00
7813203790 Reset everything to online devel version 2024-12-17 10:40:40 +01:00
331ffcd10c Notification Mail püpscher 2024-12-16 17:48:32 +01:00
14244e2f48 EMail notifications work in progress 2024-12-16 16:25:52 +01:00
3e2b9f649c moved and updated e-mail msg templates 2024-12-16 08:31:38 +01:00
02947ce6e5 E-Mail template first steps 2024-12-14 18:56:06 +01:00
fc6c6179b8 Added E-Mail send logic (untested yet) 2024-12-13 11:36:21 +01:00
7548de7609 Prepare to send E-mail notifications 2024-12-12 16:06:32 +01:00
e5d9d051ea Added notification generation for next 24hrs shipcalls 2024-12-12 11:10:05 +01:00
50cecc6a9d fixed bug in participant API GET with user_id parameter 2024-12-11 12:10:37 +01:00
ebb2182c4c Create assignment and un-assignment notifications 2024-12-10 10:30:26 +01:00
023f3357f3 Do not allow editing on cancelled shipcalls 2024-12-07 15:17:52 +01:00
dd3f000f84 fixed missing shipcall id in backend result 2024-12-07 15:17:25 +01:00
573ab2d808 Scheduler setup for notification level evaluation 2024-12-06 10:08:24 +01:00
9b69e4f50c Added data delete script 2024-12-06 08:08:28 +01:00
be46e79a67 Cosmetics and bumped version to 1.7.0.0 2024-12-05 18:46:04 +01:00
7d4f202692 Fixed error in validation when not all fields are transmitted. Added UI for Notification flags. 2024-12-05 18:39:28 +01:00
44f5d07ed7 Adjusted yaml spec and fixed user interface for storing notification flags 2024-12-05 17:25:01 +01:00
941b5e70fb Fixed ship add in backend 2024-12-05 14:47:20 +01:00
97a9e0bcf7 Fixed small bug regarding read only of port combobox 2024-12-04 10:29:20 +01:00
4acf8d7c29 fixed init when Port has been preselected but no berth 2024-12-04 10:29:07 +01:00
74b15e4b64 Do not run change port event handler during iinit 2024-12-04 10:28:57 +01:00
e60a623753 Do port dependencies for comboboxes also when loading existing shipcall 2024-12-04 10:28:47 +01:00
ddae95b784 Make pier filter combobox dependent on selected harbour 2024-12-04 10:28:34 +01:00
184d15554b Port may only be changed if the shipcall is created 2024-12-04 10:28:24 +01:00
102 changed files with 4583 additions and 800 deletions

3
.vscode/launch.json vendored
View File

@ -12,7 +12,8 @@
"env": { "env": {
"FLASK_APP": "src/server/BreCal", "FLASK_APP": "src/server/BreCal",
"FLASK_DEBUG": "1", "FLASK_DEBUG": "1",
"SECRET_KEY" : "zdiTz8P3jXOc7jztIQAoelK4zztyuCpJ" // https://randomkeygen.com/ "SECRET_KEY" : "zdiTz8P3jXOc7jztIQAoelK4zztyuCpJ", // https://randomkeygen.com/
"FLASK_RUN_PORT": "5000"
}, },
"args": [ "args": [
"run", "run",

File diff suppressed because it is too large Load Diff

537
misc/BreCalApi.md Normal file
View File

@ -0,0 +1,537 @@
# Bremen calling API
Version: _1.7.0_
Last change: _Nov 14, 2025_
## Introduction
This API allows users to interact with "Bremen calling" without an UI. Apart vom querying data via _GET_ endpoints users may create and update shipcalls, assign participants and update participant times for shipcalls.
Creating and updating times and shipcalls depend on the participant roles a user is assigned to. For example, if a participant has the role "AGENCY" they may change assignments _and_ create and update agency times. A participant with the role "PILOT" on the other hand may not change the assigments and only create/update times for the pilot.
### Authentication
- **ApiKey**: API key in `header` header named `Authorization`. This is a JWT Token that the caller receives upon login.
### Notes on this version
This version refers to _1.7_ whereas the public client currently has version _1.6_. This means that there is some functionality available in the API that cannot be accessed through the UI yet, specifically notifications.
There is no documentation for the structures returned by _GET_ requests but these can easily be determined via a single query.
## Ship Endpoints
### `DELETE /ships`
**Summary:** Delete a ship (logically).
A ship can only be logically deleted, since it is possible to have been used in previous shipcalls. On logical delete, the ship can no longer be selected in a new ship call.
#### Parameters
| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| id | query | integer | Yes | **Id of ship**. *Example: 42*. Id of ship to be deleted. |
#### Responses
- **200**
- **400**
- **401**
- **500**
- **503**
---
### `GET /ships`
**Summary:** gets a list of ships
Gets a list of ships including logically deleted ships to be used with shipcalls
#### Responses
- **200**: list of ships
- **400**
- **401**
- **500**
- **503**
---
### `POST /ships`
**Summary:** create a new ship entry
adds a new non-existing ship to the database. The ships IMO number is the unique identifier.
#### Request Body
Ship details. **Do not** provide id parameter.
**JSON Schema**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| id | integer | No | |
| name | string | No | |
| imo | integer | No | |
| callsign | string | No | |
| participant_id | integer | No | Optional reference to participant (tug role) |
| length | number | No | |
| width | number | No | |
| is_tug | boolean | No | |
| bollard_pull | integer | No | |
| eni | integer | No | BSMD internal use |
| created | string | No | Readonly field set by the database when ship was created |
| modified | string | No | Readonly field set by the database when ship was last modified |
| deleted | boolean | No | marks the ship as logically deleted |
#### Responses
- **201**
- **400**
- **401**
- **500**
- **503**
---
### `PUT /ships`
**Summary:** Update a ship entry
Updating a ship entry. Please do not modify the IMO number. In that case please add a new entry.
#### Request Body
Updated ship entry. The id parameter is **required**.
**JSON Schema**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| id | integer | No | |
| name | string | No | |
| imo | integer | No | |
| callsign | string | No | |
| participant_id | integer | No | Optional reference to participant (tug role) |
| length | number | No | |
| width | number | No | |
| is_tug | boolean | No | |
| bollard_pull | integer | No | |
| eni | integer | No | BSMD internal use |
| created | string | No | Readonly field set by the database when ship was created |
| modified | string | No | Readonly field set by the database when ship was last modified |
| deleted | boolean | No | marks the ship as logically deleted |
#### Responses
- **200**
- **400**
- **401**
- **500**
- **503**
---
## Shipcall Endpoints
### `GET /shipcalls`
**Summary:** Gets a list of ship calls
Get current ship calls
#### Parameters
| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| past_days | query | integer | No | number of days in the past to include in the result. *Example: 7*. |
#### Responses
- **200**: ship call list
- **400**
- **401**
- **500**
- **503**
---
### `POST /shipcalls`
**Summary:** Create a new ship call
A new shipcall is created without times at this point. This is ususally done by the BSMD or a participant with that particular role.
#### Request Body
Creates a new ship call. **Do not** provide id parameter.
**JSON Schema**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| id | integer | No | |
| ship_id | integer | Yes | |
| port_id | integer | No | |
| type | string | Yes | Type of ship call |
| eta | string | No | |
| voyage | string | No | |
| etd | string | No | |
| arrival_berth_id | integer | No | |
| departure_berth_id | integer | No | |
| tug_required | boolean | No | |
| pilot_required | boolean | No | |
| flags | integer | No | |
| pier_side | boolean | No | |
| bunkering | boolean | No | |
| replenishing_terminal | boolean | No | |
| replenishing_lock | boolean | No | |
| draft | number | No | |
| tidal_window_from | string | No | |
| tidal_window_to | string | No | |
| rain_sensitive_cargo | boolean | No | |
| recommended_tugs | integer | No | |
| anchored | boolean | No | |
| moored_lock | boolean | No | |
| canceled | boolean | No | |
| evaluation | string | No | Evaluation of the ship call |
| evaluation_message | string | No | |
| time_ref_point | integer | No | Physical reference point for all times given in shipcall and depending times entries |
| participants | array<object> | No | |
| created | string | No | Readonly field set by the database when shipcall was created |
| modified | string | No | Readonly field set by the database when shipcall was last modified |
#### Responses
- **201**
- **400**
- **401**
- **500**
- **503**
---
### `PUT /shipcalls`
**Summary:** Updates a ship call
Updates a shipcall. Usually done if the participant assignments change.
#### Request Body
Creates a new ship call. The id parameter is **required**.
**JSON Schema**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| id | integer | No | |
| ship_id | integer | Yes | |
| port_id | integer | No | |
| type | string | Yes | Type of ship call |
| eta | string | No | |
| voyage | string | No | |
| etd | string | No | |
| arrival_berth_id | integer | No | |
| departure_berth_id | integer | No | |
| tug_required | boolean | No | |
| pilot_required | boolean | No | |
| flags | integer | No | |
| pier_side | boolean | No | |
| bunkering | boolean | No | |
| replenishing_terminal | boolean | No | |
| replenishing_lock | boolean | No | |
| draft | number | No | |
| tidal_window_from | string | No | |
| tidal_window_to | string | No | |
| rain_sensitive_cargo | boolean | No | |
| recommended_tugs | integer | No | |
| anchored | boolean | No | |
| moored_lock | boolean | No | |
| canceled | boolean | No | |
| evaluation | string | No | Evaluation of the ship call |
| evaluation_message | string | No | |
| time_ref_point | integer | No | Physical reference point for all times given in shipcall and depending times entries |
| participants | array<object> | No | |
| created | string | No | Readonly field set by the database when shipcall was created |
| modified | string | No | Readonly field set by the database when shipcall was last modified |
#### Responses
- **200**
- **400**
- **401**
- **500**
- **503**
---
## Static Endpoints
### `GET /berths`
**Summary:** Gets a list of all berths registered
Returns a list of berths, including berths that are (logically) deleted
#### Responses
- **200**: list of berths
- **400**
- **401**
- **500**
- **503**
---
### `GET /history`
**Summary:** History data
This endpoint returns a list of changes made to the specific shipcall
#### Parameters
| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| shipcall_id | query | integer | Yes | **Id of ship call**. *Example: 3*. Id given in ship call list |
#### Responses
- **200**: list of history entries
- **400**
- **401**
- **500**
- **503**
---
### `GET /notifications`
**Summary:** Gets a list of notifications pursuant to a specified participant and ship call
List of notifications (tbd)
#### Parameters
| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| shipcall_id | query | integer | No | **Id of ship call**. *Example: 52*. Id given in ship call list |
#### Responses
- **200**: notification list
- **400**
- **401**
- **500**
- **503**
---
### `GET /participants`
**Summary:** gets one or all participants
If no parameter is given, all participants are returned. The list can be used to display participant information in the context of ship calls.
#### Parameters
| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| user_id | query | integer | No | **Id of user**. *Example: 2*. User id returned by verify call. |
#### Responses
- **200**: one or all participants as list
- **400**
- **401**
- **404**
- **500**
- **503**
---
### `GET /ports`
**Summary:** Your GET endpoint
Returns a list of ports
#### Responses
- **200**: list of ports
- **401**
- **403**
- **500**
- **503**
---
## Times Endpoints
### `DELETE /times`
**Summary:** Delete a times entry for a ship call.
A times entry is typically deleted if the agent for example changes or removes the participant assignment for a particular role.
#### Parameters
| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| id | query | integer | Yes | **Id of times**. *Example: 42*. Id of times entry to be deleted. |
#### Responses
- **200**
- **400**
- **401**
- **500**
- **503**
---
### `GET /times`
**Summary:** Gets list of times
Get all times assigned to a shipcall. These might not be complete.
#### Parameters
| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| shipcall_id | query | integer | No | **Id**. *Example: 42*. Id of referenced ship call. |
#### Responses
- **200**: list of recorded times
- **400**
- **401**
- **500**
- **503**
---
### `POST /times`
**Summary:** Create a new times entry for a ship call
The times entry for a shipcall is created with reference to a participant. For each participant type there should be only one times data record.
#### Request Body
Times entry that will be added to the ship call. **Do not** provide id parameter.
**JSON Schema**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| id | integer | No | |
| eta_berth | string | No | Arrival time at berth |
| eta_berth_fixed | boolean | No | If true, the eta is fixed and cannot be changed |
| etd_berth | string | No | departure time from berth |
| etd_berth_fixed | boolean | No | If true, the etd is fixed and cannot be changed |
| lock_time | string | No | arrival time at lock |
| lock_time_fixed | boolean | No | If true, the lock time is fixed and cannot be changed |
| zone_entry | string | No | Expected time of entry into the zone |
| zone_entry_fixed | boolean | No | If true, the zone entry time is fixed and cannot be changed |
| operations_start | string | No | Start time for terminal operations |
| operations_end | string | No | End time for terminal operations |
| remarks | string | No | Additional remarks |
| shipcall_id | integer | Yes | Reference to a shipcall id |
| participant_id | integer | Yes | Reference to a participant id |
| berth_id | integer | No | Reference to a berth id |
| berth_info | string | No | Additional info text for berth |
| pier_side | boolean | No | true if ship is rotated, false otherwise |
| participant_type | integer | No | |
| ata | string | No | ata can be set by mooring if actual times are different from planned |
| atd | string | No | atd can be set by mooring if actual times are different from planned |
| eta_interval_end | string | No | Optional end of the interval for the times eta entry |
| etd_interval_end | string | No | Optional end of the interval for the times etd entry |
| created | string | No | Readonly field set by the database when times record was created |
| modified | string | No | Readonly field set by the database when times record was last modified |
#### Responses
- **201**
- **400**
- **401**
- **500**
- **503**
---
### `PUT /times`
**Summary:** Update a times entry for a ship call
Updating a times entry for a ship for a particular participant. The times entries are required for a shipcall to pass the validation rules.
#### Request Body
Times entry that will be added to the ship call. The id parameter is **required**.
**JSON Schema**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| id | integer | No | |
| eta_berth | string | No | Arrival time at berth |
| eta_berth_fixed | boolean | No | If true, the eta is fixed and cannot be changed |
| etd_berth | string | No | departure time from berth |
| etd_berth_fixed | boolean | No | If true, the etd is fixed and cannot be changed |
| lock_time | string | No | arrival time at lock |
| lock_time_fixed | boolean | No | If true, the lock time is fixed and cannot be changed |
| zone_entry | string | No | Expected time of entry into the zone |
| zone_entry_fixed | boolean | No | If true, the zone entry time is fixed and cannot be changed |
| operations_start | string | No | Start time for terminal operations |
| operations_end | string | No | End time for terminal operations |
| remarks | string | No | Additional remarks |
| shipcall_id | integer | Yes | Reference to a shipcall id |
| participant_id | integer | Yes | Reference to a participant id |
| berth_id | integer | No | Reference to a berth id |
| berth_info | string | No | Additional info text for berth |
| pier_side | boolean | No | true if ship is rotated, false otherwise |
| participant_type | integer | No | |
| ata | string | No | ata can be set by mooring if actual times are different from planned |
| atd | string | No | atd can be set by mooring if actual times are different from planned |
| eta_interval_end | string | No | Optional end of the interval for the times eta entry |
| etd_interval_end | string | No | Optional end of the interval for the times etd entry |
| created | string | No | Readonly field set by the database when times record was created |
| modified | string | No | Readonly field set by the database when times record was last modified |
#### Responses
- **200**
- **400**
- **401**
- **500**
- **503**
---
## User Endpoints
### `POST /login`
**Summary:** Returns a JWT session token and user data if successful
Perform login
#### Request Body
Login credentials
**JSON Schema**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| username | string | Yes | |
| password | string | Yes | |
#### Responses
- **200**: Successful response
- **400**
- **403**
- **500**
- **503**
**Response 200 JSON Schema**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| id | integer | No | |
| participant_id | integer | No | |
| first_name | string | No | |
| last_name | string | No | |
| user_name | string | No | |
| user_phone | string | No | |
| user_email | string | No | |
| notify_email | boolean | No | |
| notify_whatsapp | boolean | No | |
| notify_signal | boolean | No | |
| notify_popup | boolean | No | |
| exp | number | No | |
| token | string | No | |
| notify_on | array<string> | No | |
---
### `PUT /user`
**Summary:** Update user details (first/last name, phone, password)
Update user information
#### Request Body
User details
**JSON Schema**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| id | integer | No | |
| old_password | string | No | |
| new_password | string | No | |
| first_name | string | No | |
| last_name | string | No | |
| user_phone | string | No | |
| user_email | string | No | |
| notify_email | boolean | No | |
| notify_popup | boolean | No | |
| notify_whatsapp | boolean | No | |
| notify_signal | boolean | No | |
| notify_on | array<string> | No | |
#### Responses
- **200**
- **400**
- **401**
- **500**
- **503**
---

View File

@ -2,7 +2,7 @@ openapi: 3.0.0
x-stoplight: x-stoplight:
id: mwv4y8vcnopwr id: mwv4y8vcnopwr
info: info:
version: 1.6.0 version: 1.7.0
title: Bremen calling API title: Bremen calling API
description: 'Administer DEBRE ship calls, times and notifications' description: 'Administer DEBRE ship calls, times and notifications'
termsOfService: 'https://www.bsmd.de/' termsOfService: 'https://www.bsmd.de/'
@ -14,7 +14,7 @@ info:
name: Use at your own risk name: Use at your own risk
url: 'https://www.bsmd.de/license' url: 'https://www.bsmd.de/license'
servers: servers:
- url: 'https://brecaldevel.bsmd-emswe.eu' - url: 'https://brecaltest.bsmd-emswe.eu'
description: Development server hosted on vcup description: Development server hosted on vcup
tags: tags:
- name: user - name: user
@ -22,6 +22,8 @@ tags:
- name: times - name: times
- name: static - name: static
- name: ship - name: ship
- name: notification
- name: history
paths: paths:
/login: /login:
post: post:
@ -30,6 +32,7 @@ paths:
tags: tags:
- user - user
operationId: login operationId: login
security: []
requestBody: requestBody:
description: Login credentials description: Login credentials
required: true required: true
@ -256,7 +259,7 @@ paths:
- shipcall - shipcall
operationId: shipcallUpdate operationId: shipcallUpdate
requestBody: requestBody:
description: Creates a new ship call. The id parameter is **required**. description: Updates a ship call. The id parameter is **required**.
required: true required: true
content: content:
application/json: application/json:
@ -443,7 +446,7 @@ paths:
- name: user_id - name: user_id
in: query in: query
required: false required: false
description: '**Id of user**. *Example: 2*. User id returned by verify call.' description: '**Id of user**. *Example: 2*. User id returned by login call.'
schema: schema:
type: integer type: integer
example: 2 example: 2
@ -581,7 +584,7 @@ paths:
- times - times
operationId: timesUpdate operationId: timesUpdate
requestBody: requestBody:
description: Times entry that will be added to the ship call. The id parameter is **required**. description: Times entry that will be updated for the ship call. The id parameter is **required**.
required: true required: true
content: content:
application/json: application/json:
@ -649,18 +652,18 @@ paths:
$ref: '#/components/responses/503' $ref: '#/components/responses/503'
/notifications: /notifications:
get: get:
summary: Gets a list of notifications pursuant to a specified participant and ship call summary: Gets a list of notifications pursuant to a specified ship call
description: List of notifications (tbd) description: List of notifications (tbd)
tags: tags:
- static - notification
operationId: notificationsGet operationId: notificationsGet
parameters: parameters:
- name: shipcall_id - name: participant_id
in: query in: query
required: true 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: schema:
$ref: '#/components/schemas/shipcallId' $ref: '#/components/schemas/participant_id'
responses: responses:
'200': '200':
description: notification list description: notification list
@ -723,7 +726,7 @@ paths:
description: This endpoint returns a list of changes made to the specific shipcall description: This endpoint returns a list of changes made to the specific shipcall
summary: History data summary: History data
tags: tags:
- static - history
operationId: historyGet operationId: historyGet
parameters: parameters:
- name: shipcall_id - name: shipcall_id
@ -1538,6 +1541,7 @@ components:
eta: '2023-08-21T08:23:35Z' eta: '2023-08-21T08:23:35Z'
operation: update operation: update
type: shipcall type: shipcall
notification: notification:
type: object type: object
description: a notification created by the engine if a times entry violates a rule description: a notification created by the engine if a times entry violates a rule
@ -1545,10 +1549,16 @@ components:
id: id:
type: integer type: integer
example: 42 example: 42
nullable: false
shipcall_id: shipcall_id:
type: integer type: integer
example: 5 example: 5
notification_type: nullable: false
participant_id:
type: integer
example: 9
nullable: true
type:
$ref: '#/components/schemas/NotificationType' $ref: '#/components/schemas/NotificationType'
message: message:
type: string type: string
@ -1567,8 +1577,9 @@ components:
example: example:
id: 42 id: 42
shipcall_id: 5 shipcall_id: 5
notification_type: email participant_id: 9
message: Entry XY violates rule Z type: next24h
message: Shipcall may be relevant to you in the next 24 hours
created: '2023-08-21T08:23:35Z' created: '2023-08-21T08:23:35Z'
modified: '2023-08-21T08:23:35Z' modified: '2023-08-21T08:23:35Z'
notification_list: notification_list:
@ -1579,13 +1590,14 @@ components:
example: example:
- id: 42 - id: 42
shipcall_id: 5 shipcall_id: 5
notification_type: email participant_id: 9
type: time_conflict
message: Entry XY violates rule Z message: Entry XY violates rule Z
created: '2023-08-21T08:23:35Z' created: '2023-08-21T08:23:35Z'
modified: '2023-08-21T08:23:35Z' modified: '2023-08-21T08:23:35Z'
- id: 43 - id: 43
shipcall_id: 7 shipcall_id: 7
notification_type: email type: time_conflict
message: Entry AB violates rule C message: Entry AB violates rule C
created: '2023-08-21T08:23:35Z' created: '2023-08-21T08:23:35Z'
modified: '2023-08-21T08:23:35Z' modified: '2023-08-21T08:23:35Z'
@ -1698,10 +1710,28 @@ components:
example: johndoe example: johndoe
user_phone: user_phone:
type: string type: string
nullable: true
example: '1234567890' example: '1234567890'
user_email: user_email:
type: string type: string
nullable: true
example: no@where.com example: no@where.com
notify_email:
type: boolean
nullable: true
example: true
notify_whatsapp:
type: boolean
nullable: true
example: false
notify_signal:
type: boolean
nullable: true
example: false
notify_popup:
type: boolean
nullable: true
example: false
exp: exp:
type: number type: number
format: float format: float
@ -1709,6 +1739,14 @@ components:
token: token:
type: string type: string
example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
notify_on:
type: array
nullable: true
items:
$ref: '#/components/schemas/NotificationType'
example:
- assignment
- next24h
example: example:
id: 42 id: 42
participant_id: 5 participant_id: 5
@ -1721,10 +1759,11 @@ components:
token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
user_details: user_details:
type: object type: object
description: fields that a user may change description: user metadata and editable fields
properties: properties:
id: id:
type: integer type: integer
readOnly: true
example: 42 example: 42
old_password: old_password:
type: string type: string
@ -1756,6 +1795,30 @@ components:
type: string type: string
nullable: true nullable: true
example: no@where.com example: no@where.com
notify_email:
type: boolean
nullable: true
example: true
notify_popup:
type: boolean
nullable: true
example: false
notify_whatsapp:
type: boolean
nullable: true
example: false
notify_signal:
type: boolean
nullable: true
example: false
notify_on:
type: array
nullable: true
items:
$ref: '#/components/schemas/NotificationType'
example:
- assignment
- next24h
example: example:
id: 42 id: 42
old_password: oldpassword old_password: oldpassword
@ -1804,10 +1867,14 @@ components:
type: string type: string
description: Type of notification description: Type of notification
enum: enum:
- undefined - assignment
- email - next24h
- push - time_conflict
example: email - time_conflict_resolved
- unassigned
- missing_data
- cancelled
example: time_conflict
EvaluationType: EvaluationType:
description: Evaluation of the ship call description: Evaluation of the ship call
readOnly: true readOnly: true

View File

@ -48,3 +48,156 @@ DROP TABLE IF EXISTS `shipcall`;
SET FOREIGN_KEY_CHECKS = 1; 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
```

View File

@ -17,3 +17,11 @@ public bool ShouldSerializeEvaluation()
``` ```
Witziger(!)weise funktioniert es für das Property EvaluationMessage korrekt. Witziger(!)weise funktioniert es für das Property EvaluationMessage korrekt.
### Vacuum Yaml Linter
Example Usage:
```bash
vacuum lint -d .\misc\BreCalApi.yaml --fail-severity warn
```

View File

@ -0,0 +1,16 @@
# Versionshistorie
## 1.7
### YAML / API
1. Notifications GET: Der Parameter "shipcall_id" ist jetzt optional für den Abruf von Benachrichtigungen.
2. Notification: Enthält jetzt ein neues Feld "participant_id". Ist dieses gesetzt, richtet sich die Benachrichtigung an diesen Teilnehmer. Ist das Feld nicht vorhanden, richtet sich die Benachrichtigung an alle Beteiligten des shipcall
3. Die Benutzerdaten (login_result) enthalten jetzt die Felder (Flags) der Zuordnung für die verschiedenen Benachrichtigungs-Wege, aktuell implementiert ist notify_email und notify_popup. Diese können auch über user_details analog zu Telefonnummer, Name etc. gesetzt werden.
4. Die Enumeration NotificationType enthält jetzt nicht mehr den Benachrichtigungsweg, sondern den Typ des Ereignisses, das die Benachrichtigung ausgelöst hat. Aktuell werden 7 Ereignisse unterschieden.
5. Die Benutzerdaten enthalten eine Liste NotifyOn vom Typ NotificationType. In dieser Aufzählung sind die Ereignisse enthalten, über die der Benutzer benachrichtigt werden will. Wenn diese Liste leer oder nicht vorhanden ist erhält der Benutzer keine Nachrichten, auch wenn er einen Benachrichtigungsweg ausgewählt hat.

View 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;

37
misc/clear_data.sql Normal file
View File

@ -0,0 +1,37 @@
-- This script clears all data from the database tables related to the port management system.
DELETE FROM notification WHERE id > 0;
DELETE FROM history WHERE id > 0;
DELETE FROM notification WHERE id > 0;
DELETE FROM shipcall_participant_map WHERE id > 0;
DELETE FROM participant_port_map WHERE id > 0;
DELETE FROM shipcall_tug_map WHERE id > 0;
DELETE FROM times WHERE id > 0;
DELETE FROM shipcall WHERE id > 0;
DELETE FROM user_role_map WHERE id > 0;
DELETE FROM role_securable_map WHERE id > 0;
DELETE FROM user_role_map WHERE id > 0;
DELETE FROM securable WHERE id > 0;
DELETE FROM role WHERE id > 0;
DELETE FROM user WHERE id > 0;
delete FROM ship WHERE id > 0;
DELETE FROM berth WHERE id > 0;
DELETE FROM participant WHERE id > 0;
DELETE FROM port WHERE id > 0;

View File

@ -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 -- 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_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; /*!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', `lock` bit(1) DEFAULT NULL COMMENT 'The lock must be used',
`owner_id` int unsigned DEFAULT NULL, `owner_id` int unsigned DEFAULT NULL,
`authority_id` int unsigned DEFAULT NULL, `authority_id` int unsigned DEFAULT NULL,
`port_id` int unsigned DEFAULT NULL,
`created` datetime DEFAULT CURRENT_TIMESTAMP, `created` datetime DEFAULT CURRENT_TIMESTAMP,
`modified` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, `modified` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
`deleted` bit(1) DEFAULT b'0', `deleted` bit(1) DEFAULT b'0',
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
KEY `FK_OWNER_PART_idx` (`owner_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_AUTHORITY_PART` FOREIGN KEY (`authority_id`) REFERENCES `participant` (`id`),
CONSTRAINT `FK_OWNER_PART` FOREIGN KEY (`owner_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_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 */; /*!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` -- Table structure for table `notification`
-- --
@ -48,20 +96,19 @@ DROP TABLE IF EXISTS `notification`;
/*!50503 SET character_set_client = utf8mb4 */; /*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `notification` ( CREATE TABLE `notification` (
`id` int unsigned NOT NULL AUTO_INCREMENT, `id` int unsigned NOT NULL AUTO_INCREMENT,
`times_id` int unsigned NOT NULL COMMENT 'times record that caused the notification', `shipcall_id` int unsigned DEFAULT NULL,
`participant_id` int unsigned NOT NULL COMMENT 'participant ref', `participant_id` int unsigned DEFAULT NULL,
`acknowledged` bit(1) DEFAULT b'0' COMMENT 'true if UI acknowledged',
`level` tinyint DEFAULT NULL COMMENT 'severity of the notification', `level` tinyint DEFAULT NULL COMMENT 'severity of the notification',
`type` tinyint DEFAULT NULL COMMENT 'Email/UI/Other', `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, `created` datetime DEFAULT CURRENT_TIMESTAMP,
`modified` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, `modified` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
KEY `FK_NOT_TIMES` (`times_id`), KEY `FK_NOTIFICATION_SHIPCALL_idx` (`shipcall_id`),
KEY `FK_NOT_PART` (`participant_id`), KEY `FK_NOTIFICATION_PARTICIPANT_idx` (`participant_id`),
CONSTRAINT `FK_NOT_PART` FOREIGN KEY (`participant_id`) REFERENCES `participant` (`id`), CONSTRAINT `FK_NOTIFICATION_PARTICIPANT` FOREIGN KEY (`participant_id`) REFERENCES `participant` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT,
CONSTRAINT `FK_NOT_TIMES` FOREIGN KEY (`times_id`) REFERENCES `times` (`id`) CONSTRAINT `FK_NOTIFICATION_SHIPCALL` FOREIGN KEY (`shipcall_id`) REFERENCES `shipcall` (`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'; ) 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 */; /*!40101 SET character_set_client = @saved_cs_client */;
-- --
@ -83,7 +130,46 @@ CREATE TABLE `participant` (
`modified` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, `modified` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
`deleted` bit(1) DEFAULT b'0', `deleted` bit(1) DEFAULT b'0',
PRIMARY KEY (`id`) 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 */; /*!40101 SET character_set_client = @saved_cs_client */;
-- --
@ -166,7 +252,7 @@ CREATE TABLE `ship` (
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
KEY `FK_SHIP_PARTICIPANT` (`participant_id`), KEY `FK_SHIP_PARTICIPANT` (`participant_id`),
CONSTRAINT `FK_SHIP_PARTICIPANT` FOREIGN KEY (`participant_id`) REFERENCES `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 */; /*!40101 SET character_set_client = @saved_cs_client */;
-- --
@ -202,16 +288,25 @@ CREATE TABLE `shipcall` (
`canceled` bit(1) DEFAULT NULL, `canceled` bit(1) DEFAULT NULL,
`evaluation` int unsigned DEFAULT NULL, `evaluation` int unsigned DEFAULT NULL,
`evaluation_message` varchar(512) 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, `created` datetime DEFAULT CURRENT_TIMESTAMP,
`modified` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, `modified` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
KEY `FK_SHIPCALL_SHIP` (`ship_id`), KEY `FK_SHIPCALL_SHIP` (`ship_id`),
KEY `FK_SHIPCALL_BERTH_ARRIVAL` (`arrival_berth_id`), KEY `FK_SHIPCALL_BERTH_ARRIVAL` (`arrival_berth_id`),
KEY `FK_SHIPCALL_BERTH_DEPARTURE` (`departure_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_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_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`) 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 */; /*!40101 SET character_set_client = @saved_cs_client */;
-- --
@ -225,15 +320,15 @@ CREATE TABLE `shipcall_participant_map` (
`id` int NOT NULL AUTO_INCREMENT, `id` int NOT NULL AUTO_INCREMENT,
`shipcall_id` int unsigned DEFAULT NULL, `shipcall_id` int unsigned DEFAULT NULL,
`participant_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, `created` datetime DEFAULT CURRENT_TIMESTAMP,
`modified` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, `modified` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
KEY `FK_MAP_PARTICIPANT_SHIPCALL` (`shipcall_id`), KEY `FK_MAP_PARTICIPANT_SHIPCALL` (`shipcall_id`),
KEY `FK_MAP_SHIPCALL_PARTICIPANT` (`participant_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`) 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 */; /*!40101 SET character_set_client = @saved_cs_client */;
-- --
@ -285,13 +380,20 @@ CREATE TABLE `times` (
`berth_info` varchar(512) DEFAULT NULL, `berth_info` varchar(512) DEFAULT NULL,
`pier_side` bit(1) DEFAULT NULL, `pier_side` bit(1) DEFAULT NULL,
`participant_type` int unsigned 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`), PRIMARY KEY (`id`),
UNIQUE KEY `uniq_shipcall_participant` (`shipcall_id`,`participant_type`),
KEY `FK_TIME_SHIPCALL` (`shipcall_id`), KEY `FK_TIME_SHIPCALL` (`shipcall_id`),
KEY `FK_TIME_PART` (`participant_id`) /*!80000 INVISIBLE */, KEY `FK_TIME_PART` (`participant_id`) /*!80000 INVISIBLE */,
KEY `FK_TIME_BERTH` (`berth_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_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`) 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 */; /*!40101 SET character_set_client = @saved_cs_client */;
-- --
@ -303,7 +405,7 @@ DROP TABLE IF EXISTS `user`;
/*!50503 SET character_set_client = utf8mb4 */; /*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `user` ( CREATE TABLE `user` (
`id` int unsigned NOT NULL AUTO_INCREMENT, `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, `first_name` varchar(45) DEFAULT NULL,
`last_name` varchar(45) DEFAULT NULL, `last_name` varchar(45) DEFAULT NULL,
`user_name` varchar(45) DEFAULT NULL, `user_name` varchar(45) DEFAULT NULL,
@ -311,12 +413,17 @@ CREATE TABLE `user` (
`user_phone` varchar(128) DEFAULT NULL, `user_phone` varchar(128) DEFAULT NULL,
`password_hash` varchar(128) DEFAULT NULL, `password_hash` varchar(128) DEFAULT NULL,
`api_key` varchar(256) 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, `created` datetime DEFAULT CURRENT_TIMESTAMP,
`modified` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, `modified` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
KEY `FK_USER_PART` (`participant_id`), KEY `FK_USER_PART` (`participant_id`),
CONSTRAINT `FK_USER_PART` FOREIGN KEY (`participant_id`) REFERENCES `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 */; /*!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`) 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'; ) 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 */; /*!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 */; /*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; /*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
@ -349,4 +507,4 @@ CREATE TABLE `user_role_map` (
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; /*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; /*!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

BIN
misc/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

11
misc/index.html Normal file
View File

@ -0,0 +1,11 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<title>Bremen Calling</title>
<link rel="shortcut icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

113
misc/notifications.md Normal file
View File

@ -0,0 +1,113 @@
# Benachrichtigungen
___
## Benachrichtigungs-Typen (Auslöser)
### 1. Teilnehmer wird zugeordnet
Ein Teilnehmer wird über die Anwendung einem Anlauf zugeordnet. Dies ist entweder die Agentur (durch BSMD zugeordnet) oder ein weiterer Teilnehmer außer dem Hafenamt, der durch die Agentur zugeordnet wird. Die Zuordnung des Hafenamts erfolgt automatisch bei Anlage des Anlaufs.
### 2. Morgenrunde ist relevant
Ein Teilnehmer ist einem Anlauf zugeordnet. Dieser Anlauf findet in den nächsten 24 Stunden statt und ist daher für die "Morgenrunde" relevant. Der Teilnehmer erhält dazu eine Benachrichtigung.
### 3. Zeitlicher Konflikt ("Ampel")
Durch unterschiedliche Zeitangaben der Teilnehmer wird die Ampel ausgelöst und stellt eine entsprechende Fehlermeldung dar. Die Benachrichtigung wird ausgelöst bei folgenden Ampel-Wechseln:
* grün -> gelb
* grün -> rot
* gelb -> rot
### 4. Auflösung zeitl. Konflikt
* rot -> gelb
* rot -> grün
* gelb -> grün
### 5. Abwählen eines Teilnehmer
Der Teilnehmer ist nicht mehr länger dem Anlauf zugeordnet.
### 6. Fehlende Daten
Dienstleister, die 16 Stunden vor ETA/ETD und Agenturen, die 20 Stunden vor ETA/ETD keine Angaben gemacht haben.
### 7. Storno
Wird ein Anlauf storniert erhalten alle bis dahin zugeordneten Teilnehmer eine Benachrichtigung.
## API
```yaml
NotificationType:
type: string
description: Type of notification
enum:
- assignment
- next24h
- time_conflict
- time_conflict_resolved
- unassigned
- missing_data
- cancelled
```
## Entfernen von Benachrichtigungen
Unter den folgenden Voraussetzungen werden Benachrichtigungen wieder aus dem System entfernt:
* Die Benachrichtigung ist älter als 3 Tage (n.B.: Zeitraum definieren)
* Ein Teilnehmer wird wieder abgewählt
* Ein zeitlicher Konflikt wird aufgelöst
## Ablauf der Benachrichtigungen
Eine Benachrichtung enthält folgende Informationen:
* Verweis auf den Anlauf (shipcall)
* ein Erstell- und Änderungsdatum
* einen Benachrichtigungs-Typ
* einen Zustand ("level")
Der Zustand steuert den Ablauf, wenn die Prüfungsfunktion die Anläufe durchsucht oder ein Anlauf gespeichert wird.
Wird einer der Zustände 1-3 erkannt wird geprüft, ob bereits eine Benachrichtigung vorhanden ist. Ist dies nicht der Fall, wird eine Benachrichtigung neu erstellt im Zustand "0".
Die Prüfungsfunktion durchläuft alle Benachrichtigungen. Abhängig vom Zustand (0-2) werden folgende Aktionen ausgeführt:
* Ist die Benachrichtigung um Zustand "0" und sind mind. 10 Minuten vergangen, wird die Benachrichtigung in den Zustand "1" versetzt.
* Ist die Benachrichtigung im Zustand "1" wird versucht, allen dafür eingetragenenen Benutzern eine E-Mail zu senden. Ist dies erfolgreich, wechselt die Benachrichtigung in den Zustand "2".
* Ist die Benachrichtigung im Zustand "2" und sind mind. 3 Tage vergangen wird die Benachrichtigung gelöscht.
```mermaid
---
title: Ablauf
---
stateDiagram-v2
state "Level 0" as lvl0
state "Level 1" as lvl1
state "Level 2" as lvl2
[*] --> lvl0: Zustand 1-3 erkannt
lvl0 --> lvl1: +10 min.
lvl1 --> lvl2: E-Mail Versand erfolgt
lvl2 --> [*]: +3 Tage ODER Zustand 1-3 nicht mehr relevant
lvl0 --> [*]: Zustand 1-3 nicht mehr relevant
```
## Bemerkungen
Für die Zukunft sind ggf. auch Benachrichtigungen via Whatsapp/Signal geplant. Diese verhalten sich analog zu den E-Mail Benachrichtigungen und werden nur einmal versendet. Die Anzahl der Zustände wird dabei erhöht bzw. der Wechsel in den Endzustand ensprechend angepasst.
## Benachrichtigungstext
... TBD

View File

@ -0,0 +1,13 @@
ALTER TABLE `notification`
ADD COLUMN `participant_id` INT UNSIGNED NULL DEFAULT NULL AFTER `shipcall_id`,
ADD INDEX `FK_NOTIFICATION_PARTICIPANT_idx` (`participant_id` ASC) VISIBLE;
;
ALTER TABLE `notification`
ADD CONSTRAINT `FK_NOTIFICATION_PARTICIPANT`
FOREIGN KEY (`participant_id`)
REFERENCES `participant` (`id`)
ON DELETE RESTRICT
ON UPDATE RESTRICT;
ALTER TABLE `user`
ADD COLUMN `notify_event` INT NULL COMMENT 'Bitflag of selected notification event types that the user wants to be notified of' AFTER `notify_popup`;

View File

@ -1 +1 @@
1.6.0.4 1.8.0.0

10
misc/weserport.md Normal file
View File

@ -0,0 +1,10 @@
# Schnittstelle Weserport Anforderungen
##
Automatische Zuordnung:
* Hafenamt
* Festmacher
* Lotsen (>120m l 13m br)

View File

@ -7,11 +7,12 @@
xmlns:xctk="http://schemas.xceed.com/wpf/xaml/toolkit" xmlns:xctk="http://schemas.xceed.com/wpf/xaml/toolkit"
xmlns:p = "clr-namespace:BreCalClient.Resources" xmlns:p = "clr-namespace:BreCalClient.Resources"
mc:Ignorable="d" Left="{local:SettingBinding W1Left}" Top="{local:SettingBinding W1Top}" mc:Ignorable="d" Left="{local:SettingBinding W1Left}" Top="{local:SettingBinding W1Top}"
Title="Help" Height="374" Width="500" Loaded="Window_Loaded"> Title="Help" Height="512" Width="800" Loaded="Window_Loaded">
<Grid> <Grid>
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="180" /> <ColumnDefinition Width="180" />
<ColumnDefinition Width="*" /> <ColumnDefinition Width=".5*" />
<ColumnDefinition Width=".5*" />
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="28" /> <RowDefinition Height="28" />
@ -25,6 +26,13 @@
<RowDefinition Height="28" /> <RowDefinition Height="28" />
<RowDefinition Height="28" /> <RowDefinition Height="28" />
<RowDefinition Height="28" /> <RowDefinition Height="28" />
<RowDefinition Height="28" />
<RowDefinition Height="10" />
<RowDefinition Height="28" />
<RowDefinition Height="28" />
<RowDefinition Height="28" />
<RowDefinition Height="28" />
<RowDefinition Height="10" />
<RowDefinition Height="*" /> <RowDefinition Height="*" />
<RowDefinition Height="28" /> <RowDefinition Height="28" />
@ -42,21 +50,33 @@
Informatikbüro Daniel Schick Informatikbüro Daniel Schick
</Hyperlink> </Hyperlink>
</TextBlock> </TextBlock>
<Border BorderThickness="0 0 0 2" Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="3" BorderBrush="Gray" />
<Label FontWeight="DemiBold" Grid.Row="3" Grid.Column="0" Content="{x:Static p:Resources.textChangeContactInfo}" HorizontalContentAlignment="Right"/> <Label FontWeight="DemiBold" Grid.Row="3" Grid.Column="0" Content="{x:Static p:Resources.textChangeContactInfo}" HorizontalContentAlignment="Right"/>
<Label Grid.Row="4" Grid.Column="0" Content="{x:Static p:Resources.textEmail}" HorizontalContentAlignment="Right" /> <Label Grid.Row="4" Grid.Column="0" Content="{x:Static p:Resources.textEmail}" HorizontalContentAlignment="Right" />
<Label Grid.Row="5" Grid.Column="0" Content="{x:Static p:Resources.textPhone}" HorizontalContentAlignment="Right" /> <Label Grid.Row="5" Grid.Column="0" Content="{x:Static p:Resources.textPhone}" HorizontalContentAlignment="Right" />
<TextBox Name="textBoxUserEmail" Grid.Column="1" Grid.Row="4" Margin="2" VerticalContentAlignment="Center" /> <TextBox Name="textBoxUserEmail" Grid.Column="1" Grid.Row="4" Margin="2" VerticalContentAlignment="Center" />
<TextBox Name="textBoxUserPhone" Grid.Column="1" Grid.Row="5" Margin="2" VerticalContentAlignment="Center" /> <TextBox Name="textBoxUserPhone" Grid.Column="1" Grid.Row="5" Margin="2" VerticalContentAlignment="Center" />
<Label FontWeight="DemiBold" Grid.Row="7" Grid.Column="0" Content="{x:Static p:Resources.textChangePassword}" HorizontalContentAlignment="Right"/> <Label FontWeight="DemiBold" Grid.Row="7" Grid.Column="0" Content="{x:Static p:Resources.textNotifications}" HorizontalContentAlignment="Right"/>
<CheckBox HorizontalAlignment="Right" Margin="2" Grid.Column="0" Grid.Row="8" VerticalAlignment="Center" x:Name="checkboxEMailNotify" />
<Label Grid.Row="8" Grid.Column="1" Content="{x:Static p:Resources.textNotifyEmail}" />
<CheckBox HorizontalAlignment="Right" Margin="2" Grid.Column="0" Grid.Row="9" VerticalAlignment="Center" x:Name="checkboxPushNotify" />
<Label Grid.Row="9" Grid.Column="1" Content="{x:Static p:Resources.textNotifyPush}" />
<Label Grid.Row="7" Grid.Column="2" Content="{x:Static p:Resources.textNotifyOn}" />
<xctk:CheckListBox Grid.Column="2" Grid.Row="8" Grid.RowSpan="3" x:Name="checkListBoxEventSelection" Margin="2" />
<Button x:Name="buttonChangeUserFields" Click="buttonChangeUserFields_Click" Grid.Column="2" Grid.Row="11" Margin="2" Content="{x:Static p:Resources.textChange}" Width="80" HorizontalAlignment="Right" IsEnabled="True" />
<xctk:WatermarkPasswordBox Watermark="{x:Static p:Resources.textOldPassword}" Grid.Column="1" Grid.Row="7" Margin="2" x:Name="wpBoxOldPassword" TextChanged="wpBoxOldPassword_TextChanged"/> <Border BorderThickness="0 0 0 2" Grid.Row="12" Grid.Column="0" Grid.ColumnSpan="3" BorderBrush="Gray" />
<xctk:WatermarkPasswordBox Watermark="{x:Static p:Resources.textNewPassword}" Grid.Column="1" Grid.Row="8" Margin="2" x:Name="wpBoxNewPassword" TextChanged="wpBoxOldPassword_TextChanged"/> <Label FontWeight="DemiBold" Grid.Row="13" Grid.Column="0" Content="{x:Static p:Resources.textChangePassword}" HorizontalContentAlignment="Right"/>
<xctk:WatermarkPasswordBox Watermark="{x:Static p:Resources.textRepeatNewPassword}" Grid.Column="1" Grid.Row="9" Margin="2" x:Name="wpBoxNewPasswordRepeat" TextChanged="wpBoxOldPassword_TextChanged"/>
<Button x:Name="buttonChangePassword" Click="buttonChangePassword_Click" Grid.Column="1" Grid.Row="10" Margin="2" Content="{x:Static p:Resources.textChange}" Width="80" HorizontalAlignment="Left" IsEnabled="True" />
<Button x:Name="buttonClose" Click="buttonClose_Click" Content="{x:Static p:Resources.textClose}" Width="80" Margin="2" Grid.Column="1" Grid.Row="12" HorizontalAlignment="Right" /> <xctk:WatermarkPasswordBox Watermark="{x:Static p:Resources.textOldPassword}" Grid.Column="1" Grid.Row="13" Margin="2" x:Name="wpBoxOldPassword" TextChanged="wpBoxOldPassword_TextChanged"/>
<xctk:WatermarkPasswordBox Watermark="{x:Static p:Resources.textNewPassword}" Grid.Column="1" Grid.Row="14" Margin="2" x:Name="wpBoxNewPassword" TextChanged="wpBoxOldPassword_TextChanged"/>
<xctk:WatermarkPasswordBox Watermark="{x:Static p:Resources.textRepeatNewPassword}" Grid.Column="1" Grid.Row="15" Margin="2" x:Name="wpBoxNewPasswordRepeat" TextChanged="wpBoxOldPassword_TextChanged"/>
<Button x:Name="buttonChangePassword" Click="buttonChangePassword_Click" Grid.Column="1" Grid.Row="16" Margin="2" Content="{x:Static p:Resources.textChangePassword}" Width="120" HorizontalAlignment="Right" IsEnabled="False" />
<Border BorderThickness="0 0 0 2" Grid.Row="17" Grid.Column="0" Grid.ColumnSpan="3" BorderBrush="Gray" />
<Button x:Name="buttonClose" Click="buttonClose_Click" Content="{x:Static p:Resources.textClose}" Width="80" Margin="2" Grid.Column="2" Grid.Row="19" HorizontalAlignment="Right" />
</Grid> </Grid>
</Window> </Window>

View File

@ -34,6 +34,7 @@ namespace BreCalClient
#region events #region events
public event Action<string, string>? ChangePasswordRequested; public event Action<string, string>? ChangePasswordRequested;
public event Action? ChangeUserSettingsRequested;
#endregion #endregion
@ -45,14 +46,25 @@ namespace BreCalClient
} }
private void buttonChangePassword_Click(object sender, RoutedEventArgs e) private void buttonChangePassword_Click(object sender, RoutedEventArgs e)
{
this.ChangePasswordRequested?.Invoke(this.wpBoxOldPassword.Password, this.wpBoxNewPassword.Password);
}
private void buttonChangeUserFields_Click(object sender, RoutedEventArgs e)
{ {
if (this.LoginResult != null) if (this.LoginResult != null)
{ {
this.LoginResult.UserPhone = this.textBoxUserPhone.Text.Trim(); this.LoginResult.UserPhone = this.textBoxUserPhone.Text.Trim();
this.LoginResult.UserEmail = this.textBoxUserEmail.Text.Trim(); this.LoginResult.UserEmail = this.textBoxUserEmail.Text.Trim();
this.LoginResult.NotifyEmail = this.checkboxEMailNotify.IsChecked ?? false;
this.LoginResult.NotifyPopup = this.checkboxPushNotify.IsChecked ?? false;
if ((this.checkListBoxEventSelection.SelectedItems.Count > 0) && (this.LoginResult.NotifyOn == null))
this.LoginResult.NotifyOn = new();
this.LoginResult.NotifyOn.Clear();
foreach (NotificationType nt in this.checkListBoxEventSelection.SelectedItems)
this.LoginResult.NotifyOn.Add(nt);
this.ChangeUserSettingsRequested?.Invoke();
} }
this.ChangePasswordRequested?.Invoke(this.wpBoxOldPassword.Password, this.wpBoxNewPassword.Password);
} }
private void Hyperlink_RequestNavigate(object sender, System.Windows.Navigation.RequestNavigateEventArgs e) private void Hyperlink_RequestNavigate(object sender, System.Windows.Navigation.RequestNavigateEventArgs e)
@ -73,13 +85,23 @@ namespace BreCalClient
private void Window_Loaded(object sender, RoutedEventArgs e) private void Window_Loaded(object sender, RoutedEventArgs e)
{ {
this.checkListBoxEventSelection.ItemsSource = Enum.GetValues(typeof(BreCalClient.misc.Model.NotificationType));
if(LoginResult != null) if(LoginResult != null)
{ {
this.textBoxUserEmail.Text = LoginResult.UserEmail; this.textBoxUserEmail.Text = LoginResult.UserEmail;
this.textBoxUserPhone.Text = LoginResult.UserPhone; this.textBoxUserPhone.Text = LoginResult.UserPhone;
this.checkboxEMailNotify.IsChecked = LoginResult.NotifyEmail;
this.checkboxPushNotify.IsChecked = LoginResult.NotifyPopup;
if (LoginResult.NotifyOn != null)
{
foreach (NotificationType nt in LoginResult.NotifyOn)
this.checkListBoxEventSelection.SelectedItems.Add(nt);
}
} }
} }
#endregion #endregion
} }
} }

View File

@ -29,16 +29,16 @@
<applicationSettings> <applicationSettings>
<BreCalClient.Properties.Settings> <BreCalClient.Properties.Settings>
<setting name="BG_COLOR" serializeAs="String"> <setting name="BG_COLOR" serializeAs="String">
<value>#1D751F</value> <value>#751D1F</value>
</setting> </setting>
<setting name="APP_TITLE" serializeAs="String"> <setting name="APP_TITLE" serializeAs="String">
<value>!!Bremen calling Testversion!!</value> <value>!!Bremen calling Entwicklungsversion!!</value>
</setting> </setting>
<setting name="LOGO_IMAGE_URL" serializeAs="String"> <setting name="LOGO_IMAGE_URL" serializeAs="String">
<value>https://www.textbausteine.net/</value> <value>https://www.textbausteine.net/</value>
</setting> </setting>
<setting name="API_URL" serializeAs="String"> <setting name="API_URL" serializeAs="String">
<value>https://brecaldevel.bsmd-emswe.eu</value> <value>https://brecaltest.bsmd-emswe.eu</value>
</setting> </setting>
</BreCalClient.Properties.Settings> </BreCalClient.Properties.Settings>
</applicationSettings> </applicationSettings>
@ -86,6 +86,12 @@
<setting name="FilterCriteriaMap" serializeAs="String"> <setting name="FilterCriteriaMap" serializeAs="String">
<value /> <value />
</setting> </setting>
<setting name="W5Top" serializeAs="String">
<value>0</value>
</setting>
<setting name="W5Left" serializeAs="String">
<value>0</value>
</setting>
</BreCalClient.Properties.Settings> </BreCalClient.Properties.Settings>
</userSettings> </userSettings>
</configuration> </configuration>

View File

@ -3,6 +3,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:BreCalClient" xmlns:local="clr-namespace:BreCalClient"
xmlns:sys="clr-namespace:System;assembly=mscorlib" xmlns:sys="clr-namespace:System;assembly=mscorlib"
xmlns:options="http://schemas.microsoft.com/winfx/2006/xaml/presentation/options"
StartupUri="MainWindow.xaml" Exit="Application_Exit" Startup="Application_Startup" > StartupUri="MainWindow.xaml" Exit="Application_Exit" Startup="Application_Startup" >
<Application.Resources> <Application.Resources>
@ -14,6 +15,95 @@
<sys:Double x:Key="{x:Static SystemParameters.VerticalScrollBarWidthKey}">10</sys:Double> <sys:Double x:Key="{x:Static SystemParameters.VerticalScrollBarWidthKey}">10</sys:Double>
<sys:Double x:Key="{x:Static SystemParameters.HorizontalScrollBarHeightKey}">10</sys:Double> <sys:Double x:Key="{x:Static SystemParameters.HorizontalScrollBarHeightKey}">10</sys:Double>
<Color x:Key="InformationColor">#147ec9</Color>
<SolidColorBrush x:Key="InformationColorBrush" Color="{StaticResource InformationColor}" options:Freeze="True" />
<Color x:Key="SuccessColor">#11ad45</Color>
<SolidColorBrush x:Key="SuccessColorBrush" Color="{StaticResource SuccessColor}" options:Freeze="True" />
<Color x:Key="ErrorColor">#e60914</Color>
<SolidColorBrush x:Key="ErrorColorBrush" Color="{StaticResource ErrorColor}" options:Freeze="True" />
<Color x:Key="WarningColor">#f5a300</Color>
<SolidColorBrush x:Key="WarningColorBrush" Color="{StaticResource WarningColor}" options:Freeze="True" />
<Canvas x:Key="InformationIcon" Width="24" Height="24">
<Path Data="M11,9H13V7H11M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M11,17H13V11H11V17Z" Fill="White" />
</Canvas>
<Canvas x:Key="SuccessIcon" Width="24" Height="24">
<Path Data="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z" Fill="White" />
</Canvas>
<Canvas x:Key="ErrorIcon" Width="24" Height="24">
<Path Data="M11,15H13V17H11V15M11,7H13V13H11V7M12,2C6.47,2 2,6.5 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,20A8,8 0 0,1 4,12A8,8 0 0,1 12,4A8,8 0 0,1 20,12A8,8 0 0,1 12,20Z" Fill="White" />
</Canvas>
<Canvas x:Key="WarningIcon" Width="24" Height="24">
<Path Data="M12,2L1,21H23M12,6L19.53,19H4.47M11,10V14H13V10M11,16V18H13V16" Fill="White" />
</Canvas>
<Canvas x:Key="CloseIcon" Width="76" Height="76" Clip="F1 M 0,0L 76,0L 76,76L 0,76L 0,0">
<Path Width="31.6666" Height="31.6667" Canvas.Left="22.1666" Canvas.Top="22.1667" Stretch="Fill" Fill="#FF000000" Data="F1 M 26.9166,22.1667L 37.9999,33.25L 49.0832,22.1668L 53.8332,26.9168L 42.7499,38L 53.8332,49.0834L 49.0833,53.8334L 37.9999,42.75L 26.9166,53.8334L 22.1666,49.0833L 33.25,38L 22.1667,26.9167L 26.9166,22.1667 Z "/>
</Canvas>
<Style TargetType="Border" x:Key="NotificationBorder">
<Setter Property="Padding" Value="5" />
<Setter Property="Effect">
<Setter.Value>
<DropShadowEffect Opacity="0.5" ShadowDepth="1" BlurRadius="2" />
</Setter.Value>
</Setter>
</Style>
<Style TargetType="Rectangle" x:Key="NotificationIcon">
<Setter Property="Width" Value="24"/>
<Setter Property="Height" Value="24"/>
<Setter Property="Margin" Value="0,5,5,5" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Fill" Value="White"/>
</Style>
<Style TargetType="TextBlock" x:Key="NotificationText">
<Setter Property="Foreground" Value="White" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="TextWrapping" Value="Wrap" />
<Setter Property="Margin" Value="5,0,0,0" />
</Style>
<Style TargetType="{x:Type Button}" x:Key="NotificationCloseButton">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="#FFF" />
<Setter Property="FontSize" Value="15" />
<Setter Property="SnapsToDevicePixels" Value="True" />
<Setter Property="VerticalAlignment" Value="Top" />
<Setter Property="HorizontalAlignment" Value="Right" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type Button}">
<Border Background="{TemplateBinding Background}">
<ContentPresenter Content="{TemplateBinding Content}" HorizontalAlignment="Center" VerticalAlignment="Center" Margin="0,0,0,0" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#33000000" />
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter Property="Background" Value="#77000000" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="Rectangle" x:Key="CloseButtonIcon">
<Setter Property="Width" Value="10"/>
<Setter Property="Height" Value="10"/>
<Setter Property="Fill" Value="{Binding Path=Foreground, RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type Button}}}" />
</Style>
</ResourceDictionary> </ResourceDictionary>
</Application.Resources> </Application.Resources>

View File

@ -0,0 +1,207 @@
// Copyright (c) 2024- schick Informatik
// Description: Helper (static) class to handle polled API notifications
//
using System;
using System.Linq;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using ToastNotifications.Core;
using BreCalClient.misc.Model;
namespace BreCalClient
{
internal class AppNotification(int id)
{
private static readonly Dictionary<int, AppNotification> _notifications = [];
private static readonly ObservableCollection<AppNotification> _notificationsCollection = [];
#region Properties
public int Id { get { return id; } }
public string? NotificationType
{
get; private set;
}
public string? NotificationDisplay
{
get; private set;
}
public string? NotificationDate
{
get; private set;
}
public string? Ship
{
get; private set;
}
public string? ShipcallType
{
get; private set;
}
public string? Berth
{
get; private set;
}
public string? ETA
{
get; private set;
}
public string? Message
{
get; private set;
}
public static ObservableCollection<AppNotification> AppNotifications { get { return _notificationsCollection; } }
#endregion
#region internal statics
internal static void LoadFromSettings()
{
_notifications.Clear();
if (Properties.Settings.Default.Notifications != null)
{
// load notification ids that have been processed
foreach (string? notification_id in Properties.Settings.Default.Notifications)
{
if (Int32.TryParse(notification_id, out int result))
_notifications.Add(result, new AppNotification(result));
}
}
}
internal static void Clear()
{
_notifications.Clear();
SaveNotifications();
}
internal static bool UpdateNotifications(List<Notification> notifications, System.Collections.Concurrent.ConcurrentDictionary<int, ShipcallControlModel> currentShipcalls, ToastViewModel vm, LoginResult loginResult)
{
bool result = false;
foreach (Notification notification in notifications)
{
if (notification.ParticipantId.HasValue && notification.ParticipantId.Value != App.Participant.Id) // not meant for us
continue;
if (!currentShipcalls.ContainsKey(notification.ShipcallId)) // not one of our shipcalls (maybe for another port or filtered)
continue;
// filter out notifications for shipcalls where we are not nomiated/assigned
if (!notification.ParticipantId.HasValue)
{
bool iAmAssigned = false;
foreach (ParticipantAssignment p in currentShipcalls[notification.ShipcallId].AssignedParticipants.Values)
{
if (p.ParticipantId.Equals(App.Participant.Id))
{
iAmAssigned = true; break;
}
}
if (!iAmAssigned) continue;
}
// filter out notifications the user is not interested in
if((notification.Type != null) && !loginResult.NotifyOn.Contains(notification.Type.Value))
continue;
if (!_notificationsCollection.Where(x => x.Id == notification.Id).Any())
{
List<AppNotification> newList = new(_notificationsCollection);
AppNotification ap = new(notification.Id)
{
NotificationType = notification.Type.ToString(),
NotificationDate = notification.Created.ToString(),
Ship = currentShipcalls[notification.ShipcallId]?.Ship?.Name,
ShipcallType = currentShipcalls[notification.ShipcallId]?.Shipcall?.Type.ToString(),
ETA = currentShipcalls[notification.ShipcallId]?.GetETAETD(true)
};
Times? agencyTimes = currentShipcalls[notification.ShipcallId]?.GetTimesForParticipantType(Extensions.ParticipantType.AGENCY);
ap.Berth = currentShipcalls[notification.ShipcallId]?.GetBerthText(agencyTimes);
ap.Message = notification.Message;
System.Diagnostics.Trace.WriteLine($"Notification {notification.Id} Type {notification.Type}");
MessageOptions options = new()
{
FontSize = 14,
ShowCloseButton = true,
Tag = ap
};
newList.Add(ap);
newList.Sort((a, b) => (a.NotificationDate ?? "").CompareTo(b.NotificationDate));
_notificationsCollection.Clear();
foreach(AppNotification newAp in newList)
_notificationsCollection.Add(newAp);
ap.NotificationDisplay = string.Empty;
switch(notification.Type)
{
case misc.Model.NotificationType.Assignment:
ap.NotificationDisplay = Resources.Resources.textAssignment; break;
case misc.Model.NotificationType.Next24h:
ap.NotificationDisplay = Resources.Resources.textNext24h; break;
case misc.Model.NotificationType.TimeConflict:
ap.NotificationDisplay = Resources.Resources.textTimeConflict; break;
case misc.Model.NotificationType.TimeConflictResolved:
ap.NotificationDisplay = Resources.Resources.textTimeConflictResolved; break;
case misc.Model.NotificationType.MissingData:
ap.NotificationDisplay = Resources.Resources.textMissingData; break;
case misc.Model.NotificationType.Cancelled:
ap.NotificationDisplay = Resources.Resources.textCancelled; break;
case misc.Model.NotificationType.Unassigned:
ap.NotificationDisplay = Resources.Resources.textUnassigned; break;
}
string toastText = ap.NotificationDisplay + "\n";
toastText += $"{ap.Ship} ({ap.ShipcallType}) - {ap.ETA} - {ap.Berth}";
if (!string.IsNullOrEmpty(ap.Message))
toastText += $" \n{ap.Message}";
if (_notifications.TryAdd(notification.Id, ap))
{
App.Current.Dispatcher.Invoke(() =>
{
vm.ShowAppNotification(toastText, options);
});
result = true;
}
}
}
if (result)
SaveNotifications(); // store notification ids in config array on change
return result;
}
internal static void SaveNotifications()
{
if (Properties.Settings.Default.Notifications == null)
Properties.Settings.Default.Notifications = [];
else
Properties.Settings.Default.Notifications.Clear();
foreach (int notification_id in _notifications.Keys)
{
Properties.Settings.Default.Notifications.Add(notification_id.ToString());
}
}
#endregion
}
}

View File

@ -0,0 +1,23 @@
// Copyright (c) 2024- schick Informatik
// Description:
//
using ToastNotifications;
using ToastNotifications.Core;
namespace BreCalClient
{
public static class AppNotificationExtension
{
public static void ShowAppNotification(this Notifier notifier, string message)
{
notifier.Notify(() => new AppNotificationMessage(message));
}
public static void ShowAppNotification(this Notifier notifier, string message, MessageOptions displayOptions)
{
notifier.Notify(() => new AppNotificationMessage(message, displayOptions));
}
}
}

View File

@ -0,0 +1,30 @@
using System.Windows;
using ToastNotifications.Core;
using ToastNotifications.Messages.Core;
namespace BreCalClient
{
public class AppNotificationMessage : MessageBase<AppNotificationPart>
{
public AppNotificationMessage(string message) : this(message, new MessageOptions())
{
}
public AppNotificationMessage(string message, MessageOptions options) : base(message, options)
{
}
protected override AppNotificationPart CreateDisplayPart()
{
return new AppNotificationPart(this);
}
protected override void UpdateDisplayOptions(AppNotificationPart displayPart, MessageOptions options)
{
// if (options.FontSize != null)
// displayPart.Text.FontSize = options.FontSize.Value;
// displayPart.CloseButton.Visibility = options.ShowCloseButton ? Visibility.Visible : Visibility.Collapsed;
}
}
}

View File

@ -0,0 +1,31 @@
<core:NotificationDisplayPart x:Class="BreCalClient.AppNotificationPart"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:core="clr-namespace:ToastNotifications.Core;assembly=ToastNotifications"
mc:Ignorable="d" d:DesignWidth="250" >
<Border x:Name="ContentWrapper" Style="{DynamicResource NotificationBorder}" Background="{DynamicResource ErrorColorBrush}">
<Grid x:Name="ContentContainer">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="25" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Rectangle x:Name="Icon" Width="24" Height="24">
<Rectangle.Fill>
<VisualBrush Visual="{StaticResource ErrorIcon}" />
</Rectangle.Fill>
</Rectangle>
<TextBlock x:Name="Text" Text="{Binding Message, Mode=OneTime}" Style="{StaticResource NotificationText}" Grid.Column="1" />
<Button x:Name="CloseButton" Style="{StaticResource NotificationCloseButton}" Padding="1" Grid.Column="2" Click="OnClose" Visibility="Hidden">
<Rectangle Style="{StaticResource CloseButtonIcon}" Margin="2">
<Rectangle.OpacityMask>
<VisualBrush Stretch="Fill" Visual="{StaticResource CloseIcon}" />
</Rectangle.OpacityMask>
</Rectangle>
</Button>
</Grid>
</Border>
</core:NotificationDisplayPart>

View File

@ -0,0 +1,57 @@
using BreCalClient.misc.Model;
using System.Windows;
using System.Windows.Media;
using ToastNotifications.Core;
namespace BreCalClient
{
/// <summary>
/// Interaction logic for NotificationPart.xaml
/// </summary>
public partial class AppNotificationPart : NotificationDisplayPart
{
public AppNotificationPart(AppNotificationMessage appNotification)
{
InitializeComponent();
Bind(appNotification);
if (appNotification.Options.Tag is AppNotification ap)
{
switch (ap.NotificationType)
{
case "TimeConflict":
this.ContentWrapper.Background = Brushes.Red;
break;
case "TimeConflictResolved":
this.ContentWrapper.Background = Brushes.Green;
break;
case "Assignment":
this.ContentWrapper.Background = Brushes.Blue;
break;
case "Next24h":
this.ContentWrapper.Background = Brushes.DarkOrange;
break;
case "Unassigned":
this.ContentWrapper.Background = Brushes.Gray;
break;
case "MissingData":
this.ContentWrapper.Background = Brushes.DarkKhaki;
break;
case "Cancelled":
this.ContentWrapper.Background = Brushes.DarkGray;
break;
default:
break;
}
}
}
private void OnClose(object sender, RoutedEventArgs e)
{
Notification.Close();
}
}
}

View File

@ -2,18 +2,18 @@
<PropertyGroup> <PropertyGroup>
<OutputType>WinExe</OutputType> <OutputType>WinExe</OutputType>
<TargetFramework>net6.0-windows</TargetFramework> <TargetFramework>net8.0-windows7.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<UseWPF>true</UseWPF> <UseWPF>true</UseWPF>
<SignAssembly>True</SignAssembly> <SignAssembly>True</SignAssembly>
<StartupObject>BreCalClient.App</StartupObject> <StartupObject>BreCalClient.App</StartupObject>
<AssemblyOriginatorKeyFile>..\..\misc\brecal.snk</AssemblyOriginatorKeyFile> <AssemblyOriginatorKeyFile>..\..\misc\brecal.snk</AssemblyOriginatorKeyFile>
<AssemblyVersion>1.6.0.4</AssemblyVersion> <AssemblyVersion>1.8.0.0</AssemblyVersion>
<FileVersion>1.6.0.4</FileVersion> <FileVersion>1.8.0.0</FileVersion>
<Title>Bremen calling client</Title> <Title>Bremen calling client</Title>
<Description>A Windows WPF client for the Bremen calling API.</Description> <Description>A Windows WPF client for the Bremen calling API.</Description>
<ApplicationIcon>containership.ico</ApplicationIcon> <ApplicationIcon>containership.ico</ApplicationIcon>
<AssemblyName>BreCalDevelClient</AssemblyName> <AssemblyName>BreCalTestClient</AssemblyName>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@ -26,6 +26,7 @@
<None Remove="Resources\arrow_up_blue.png" /> <None Remove="Resources\arrow_up_blue.png" />
<None Remove="Resources\arrow_up_green.png" /> <None Remove="Resources\arrow_up_green.png" />
<None Remove="Resources\arrow_up_red.png" /> <None Remove="Resources\arrow_up_red.png" />
<None Remove="Resources\bell3.png" />
<None Remove="Resources\check.png" /> <None Remove="Resources\check.png" />
<None Remove="Resources\clipboard.png" /> <None Remove="Resources\clipboard.png" />
<None Remove="Resources\clock.png" /> <None Remove="Resources\clock.png" />
@ -84,6 +85,7 @@
<Resource Include="Resources\arrow_up_blue.png" /> <Resource Include="Resources\arrow_up_blue.png" />
<Resource Include="Resources\arrow_up_green.png" /> <Resource Include="Resources\arrow_up_green.png" />
<Resource Include="Resources\arrow_up_red.png" /> <Resource Include="Resources\arrow_up_red.png" />
<Resource Include="Resources\bell3.png" />
<Resource Include="Resources\check.png" /> <Resource Include="Resources\check.png" />
<Resource Include="Resources\clipboard.png" /> <Resource Include="Resources\clipboard.png" />
<Resource Include="Resources\clock.png" /> <Resource Include="Resources\clock.png" />
@ -118,10 +120,12 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Extended.Wpf.Toolkit" Version="4.6.1" /> <PackageReference Include="Extended.Wpf.Toolkit" Version="4.6.1" />
<PackageReference Include="JsonSubTypes" Version="2.0.1" /> <PackageReference Include="JsonSubTypes" Version="2.0.1" />
<PackageReference Include="log4net" Version="2.0.17" /> <PackageReference Include="log4net" Version="3.0.3" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Polly" Version="8.4.1" /> <PackageReference Include="Polly" Version="8.5.1" />
<PackageReference Include="RestSharp" Version="112.0.0" /> <PackageReference Include="RestSharp" Version="112.0.0" />
<PackageReference Include="ToastNotifications" Version="2.5.1" />
<PackageReference Include="ToastNotifications.Messages" Version="2.5.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -42,13 +42,14 @@ namespace BreCalClient
public ShipApi? ShipApi { get; set; } public ShipApi? ShipApi { get; set; }
public bool IsCreate { get; set; } = false;
#endregion #endregion
#region Event handler #region Event handler
private void Window_Loaded(object sender, RoutedEventArgs e) private void Window_Loaded(object sender, RoutedEventArgs e)
{ {
this.comboBoxAgency.ItemsSource = BreCalLists.Participants_Agent;
this.comboBoxShip.ItemsSource = BreCalLists.Ships; this.comboBoxShip.ItemsSource = BreCalLists.Ships;
Array types = Enum.GetValues(typeof(ShipcallType)); Array types = Enum.GetValues(typeof(ShipcallType));
@ -60,9 +61,6 @@ namespace BreCalClient
else first = false; else first = false;
} }
this.comboBoxArrivalBerth.ItemsSource = BreCalLists.Berths;
this.comboBoxDepartureBerth.ItemsSource = BreCalLists.Berths;
this.comboBoxTimeRef.ItemsSource = BreCalLists.TimeRefs; this.comboBoxTimeRef.ItemsSource = BreCalLists.TimeRefs;
this.comboBoxHarbour.ItemsSource = BreCalLists.Ports.Where(x => App.Participant.Ports.Contains(x.Id)); this.comboBoxHarbour.ItemsSource = BreCalLists.Ports.Where(x => App.Participant.Ports.Contains(x.Id));
@ -190,6 +188,8 @@ namespace BreCalClient
void CheckForCompletion() void CheckForCompletion()
{ {
if (this.ShipcallModel.Shipcall?.Canceled ?? false) return; // Cancelled shipcall never clicks ok
bool isEnabled = true; bool isEnabled = true;
isEnabled &= this.comboBoxShip.SelectedItem != null; isEnabled &= this.comboBoxShip.SelectedItem != null;
@ -325,7 +325,7 @@ namespace BreCalClient
this.comboBoxCategories.SelectedItem = new EnumToStringConverter().Convert(this.ShipcallModel.Shipcall.Type, typeof(ShipcallType), new object(), System.Globalization.CultureInfo.CurrentCulture); this.comboBoxCategories.SelectedItem = new EnumToStringConverter().Convert(this.ShipcallModel.Shipcall.Type, typeof(ShipcallType), new object(), System.Globalization.CultureInfo.CurrentCulture);
if (this.ShipcallModel.Shipcall.Eta != DateTime.MinValue) if (this.ShipcallModel.Shipcall.Eta != DateTime.MinValue)
this.datePickerETA.Value = this.ShipcallModel.Shipcall.Eta; this.datePickerETA.Value = this.ShipcallModel.Shipcall.Eta;
// this.textBoxVoyage.Text = this.ShipcallModel.Shipcall.Voyage;
this.datePickerETD.Value = this.ShipcallModel.Shipcall.Etd; this.datePickerETD.Value = this.ShipcallModel.Shipcall.Etd;
if (BreCalLists.Ships.Find(x => x.Ship.Id == this.ShipcallModel.Shipcall.ShipId) != null) if (BreCalLists.Ships.Find(x => x.Ship.Id == this.ShipcallModel.Shipcall.ShipId) != null)
{ {
@ -336,6 +336,16 @@ namespace BreCalClient
} }
this.checkBoxCancelled.IsChecked = this.ShipcallModel.Shipcall.Canceled ?? false; this.checkBoxCancelled.IsChecked = this.ShipcallModel.Shipcall.Canceled ?? false;
if (BreCalLists.PortLookupDict.ContainsKey(this.ShipcallModel.Shipcall.PortId))
this.comboBoxHarbour.SelectedValue = this.ShipcallModel.Shipcall.PortId;
List<Berth> availableBerths = BreCalLists.GetBerthsByPort(this.ShipcallModel.Shipcall.PortId);
this.comboBoxArrivalBerth.ItemsSource = availableBerths;
this.comboBoxDepartureBerth.ItemsSource = availableBerths;
// Filter agency combobox by port
List<Participant> availableAgencies = BreCalLists.GetParticipants(this.ShipcallModel.Shipcall.PortId, ParticipantType.AGENCY);
this.comboBoxAgency.ItemsSource = availableAgencies;
if (this.ShipcallModel.Shipcall.Type != ShipcallType.Shifting) // incoming, outgoing if (this.ShipcallModel.Shipcall.Type != ShipcallType.Shifting) // incoming, outgoing
{ {
this.comboBoxArrivalBerth.SelectedValue = this.ShipcallModel.Shipcall.ArrivalBerthId; this.comboBoxArrivalBerth.SelectedValue = this.ShipcallModel.Shipcall.ArrivalBerthId;
@ -357,8 +367,6 @@ namespace BreCalClient
} }
} }
if (BreCalLists.PortLookupDict.ContainsKey(this.ShipcallModel.Shipcall.PortId))
this.comboBoxHarbour.SelectedValue = this.ShipcallModel.Shipcall.PortId;
this.comboBoxHarbour.SelectionChanged += this.comboBoxHarbour_SelectionChanged; this.comboBoxHarbour.SelectionChanged += this.comboBoxHarbour_SelectionChanged;
this.comboBoxHarbour.IsEnabled = this.ShipcallModel.AllowPortChange; this.comboBoxHarbour.IsEnabled = this.ShipcallModel.AllowPortChange;
@ -372,6 +380,8 @@ namespace BreCalClient
bool editRightGrantedForBSMD = false; bool editRightGrantedForBSMD = false;
if (this.ShipcallModel.Shipcall?.Canceled ?? false) return; // do not allow edit on canceled shipcall
// Special case: Selected Agency allows BSMD to edit their fields // Special case: Selected Agency allows BSMD to edit their fields
if (this.comboBoxAgency.SelectedIndex >= 0) if (this.comboBoxAgency.SelectedIndex >= 0)
{ {
@ -395,6 +405,7 @@ namespace BreCalClient
this.datePickerETD.IsEnabled = isAgency || isBsmd; this.datePickerETD.IsEnabled = isAgency || isBsmd;
this.labelBSMDGranted.Visibility = editRightGrantedForBSMD ? Visibility.Visible : Visibility.Hidden; this.labelBSMDGranted.Visibility = editRightGrantedForBSMD ? Visibility.Visible : Visibility.Hidden;
this.comboBoxHarbour.IsEnabled = this.IsCreate && this.ShipcallModel.AllowPortChange;
this.comboBoxCategories_SelectionChanged(null, null); this.comboBoxCategories_SelectionChanged(null, null);
} }

View File

@ -366,7 +366,7 @@ namespace BreCalClient
private void CheckOKButton() private void CheckOKButton()
{ {
this.buttonOK.IsEnabled = _editing && RequiredFieldsSet(); this.buttonOK.IsEnabled = _editing && RequiredFieldsSet() && !(this.ShipcallModel.Shipcall?.Canceled ?? false);
} }
#endregion #endregion

View File

@ -356,7 +356,7 @@ namespace BreCalClient
private void CheckOKButton() private void CheckOKButton()
{ {
this.buttonOK.IsEnabled = _editing && RequiredFieldsSet(); this.buttonOK.IsEnabled = _editing && RequiredFieldsSet() && !(this.ShipcallModel.Shipcall?.Canceled ?? false);
} }
#endregion #endregion

View File

@ -394,7 +394,7 @@ namespace BreCalClient
private void CheckOKButton() private void CheckOKButton()
{ {
this.buttonOK.IsEnabled = _editing && RequiredFieldsSet(); this.buttonOK.IsEnabled = _editing && RequiredFieldsSet() && !(this.ShipcallModel.Shipcall?.Canceled ?? false);
} }
#endregion #endregion

View File

@ -7,7 +7,7 @@
xmlns:p = "clr-namespace:BreCalClient.Resources" xmlns:p = "clr-namespace:BreCalClient.Resources"
xmlns:xctk="http://schemas.xceed.com/wpf/xaml/toolkit" xmlns:xctk="http://schemas.xceed.com/wpf/xaml/toolkit"
mc:Ignorable="d" Left="{local:SettingBinding W1Left}" Top="{local:SettingBinding W1Top}" mc:Ignorable="d" Left="{local:SettingBinding W1Left}" Top="{local:SettingBinding W1Top}"
Title="{x:Static p:Resources.textEditTimes}" Height="331" Width="500" Loaded="Window_Loaded" ResizeMode="CanResizeWithGrip" Icon="Resources/containership.ico"> Title="{x:Static p:Resources.textEditTimes}" Height="415" Width="500" Loaded="Window_Loaded" ResizeMode="CanResizeWithGrip" Icon="Resources/containership.ico">
<Grid> <Grid>
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width=".20*" /> <ColumnDefinition Width=".20*" />
@ -22,6 +22,9 @@
<RowDefinition Height="28" /> <RowDefinition Height="28" />
<RowDefinition Height="28" /> <RowDefinition Height="28" />
<RowDefinition Height="28" /> <RowDefinition Height="28" />
<RowDefinition Height="28" x:Name="rowt1" />
<RowDefinition Height="28" x:Name="rowt2" />
<RowDefinition Height="28" x:Name="rowt3" />
<RowDefinition Height="*" /> <RowDefinition Height="*" />
<RowDefinition Height="28" /> <RowDefinition Height="28" />
</Grid.RowDefinitions> </Grid.RowDefinitions>
@ -35,8 +38,10 @@
<Label Grid.Row="4" Grid.Column="0" Content="ATD" HorizontalContentAlignment="Right" x:Name="labelATD" /> <Label Grid.Row="4" Grid.Column="0" Content="ATD" HorizontalContentAlignment="Right" x:Name="labelATD" />
<Label Grid.Row="5" Grid.Column="0" Content="{x:Static p:Resources.textLockTime}" HorizontalContentAlignment="Right" /> <Label Grid.Row="5" Grid.Column="0" Content="{x:Static p:Resources.textLockTime}" HorizontalContentAlignment="Right" />
<Label Grid.Row="6" Grid.Column="0" Content="{x:Static p:Resources.textZoneEntryTime}" HorizontalContentAlignment="Right" /> <Label Grid.Row="6" Grid.Column="0" Content="{x:Static p:Resources.textZoneEntryTime}" HorizontalContentAlignment="Right" />
<Label Grid.Row="7" Grid.Column="0" Content="{x:Static p:Resources.textTidalWindow}" HorizontalContentAlignment="Right" />
<Label Grid.Row="7" Grid.Column="0" Content="{x:Static p:Resources.textRemarks}" HorizontalContentAlignment="Right" /> <Label Grid.Row="8" Grid.Column="0" Content="{x:Static p:Resources.textFrom}" HorizontalContentAlignment="Right" />
<Label Grid.Row="9" Grid.Column="0" Content="{x:Static p:Resources.textTo}" HorizontalContentAlignment="Right" />
<Label Grid.Row="10" Grid.Column="0" Content="{x:Static p:Resources.textRemarks}" HorizontalContentAlignment="Right" />
<Grid Grid.Row="1" Grid.Column="1"> <Grid Grid.Row="1" Grid.Column="1">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
@ -137,7 +142,7 @@
</ContextMenu> </ContextMenu>
</xctk:DateTimePicker.ContextMenu> </xctk:DateTimePicker.ContextMenu>
</local:DateTimePickerExt> </local:DateTimePickerExt>
<!--CheckBox IsEnabled="False" Grid.Row="3" Grid.Column="2" Margin="4,0,0,0" Name="checkBoxLockTimeFixed" VerticalAlignment="Center" /-->
<local:DateTimePickerExt IsEnabled="False" Grid.Row="6" Grid.Column="1" Margin="2" x:Name="datePickerZoneEntry" Format="Custom" FormatString="dd.MM. yyyy HH:mm"> <local:DateTimePickerExt IsEnabled="False" Grid.Row="6" Grid.Column="1" Margin="2" x:Name="datePickerZoneEntry" Format="Custom" FormatString="dd.MM. yyyy HH:mm">
<xctk:DateTimePicker.ContextMenu> <xctk:DateTimePicker.ContextMenu>
<ContextMenu> <ContextMenu>
@ -149,10 +154,12 @@
</ContextMenu> </ContextMenu>
</xctk:DateTimePicker.ContextMenu> </xctk:DateTimePicker.ContextMenu>
</local:DateTimePickerExt> </local:DateTimePickerExt>
<!--CheckBox IsEnabled="False" Grid.Row="4" Grid.Column="2" Margin="4,0,0,0" Name="checkBoxZoneEntryFixed" VerticalAlignment="Center" /-->
<TextBox Grid.Row="7" Grid.Column="1" Margin="2" Name="textBoxRemarks" TextWrapping="Wrap" AcceptsReturn="True" SpellCheck.IsEnabled="True" AcceptsTab="False" IsReadOnly="True" MaxLength="512"/> <xctk:DateTimePicker Name="datePickerTidalWindowFrom" Grid.Column="1" Grid.Row="8" Margin="2" IsEnabled="False" Format="Custom" FormatString="dd.MM. yyyy HH:mm"/>
<StackPanel Grid.Row="8" Grid.Column="1" Grid.ColumnSpan="2" Orientation="Horizontal" HorizontalAlignment="Right"> <xctk:DateTimePicker Name="datePickerTidalWindowTo" Grid.Column="1" Grid.Row="9" Margin="2" IsEnabled="False" Format="Custom" FormatString="dd.MM. yyyy HH:mm"/>
<TextBox Grid.Row="10" Grid.Column="1" Margin="2" Name="textBoxRemarks" TextWrapping="Wrap" AcceptsReturn="True" SpellCheck.IsEnabled="True" AcceptsTab="False" IsReadOnly="True" MaxLength="512"/>
<StackPanel Grid.Row="11" Grid.Column="1" Grid.ColumnSpan="2" Orientation="Horizontal" HorizontalAlignment="Right">
<Button Width= "80" Margin="2" Content="{x:Static p:Resources.textOK}" x:Name="buttonOK" Click="buttonOK_Click" /> <Button Width= "80" Margin="2" Content="{x:Static p:Resources.textOK}" x:Name="buttonOK" Click="buttonOK_Click" />
<Button Width="80" Margin="2" Content="{x:Static p:Resources.textCancel}" x:Name="buttonCancel" Click="buttonCancel_Click"/> <Button Width="80" Margin="2" Content="{x:Static p:Resources.textCancel}" x:Name="buttonCancel" Click="buttonCancel_Click"/>
<Button Width="28" x:Name="buttonClearAll" Click="buttonClearAll_Click" Margin="2" IsEnabled="False"> <Button Width="28" x:Name="buttonClearAll" Click="buttonClearAll_Click" Margin="2" IsEnabled="False">

View File

@ -86,6 +86,8 @@ namespace BreCalClient
this.datePickerLockTime.Value = null; this.datePickerLockTime.Value = null;
this.datePickerZoneEntry.Value = null; this.datePickerZoneEntry.Value = null;
this.textBoxRemarks.Text = null; this.textBoxRemarks.Text = null;
this.datePickerTidalWindowFrom.Value = null;
this.datePickerTidalWindowTo.Value = null;
} }
} }
@ -153,6 +155,30 @@ namespace BreCalClient
return false; return false;
} }
if ((Extensions.ParticipantType)this.Times.ParticipantType == Extensions.ParticipantType.PILOT)
{
if ((this.datePickerTidalWindowFrom.Value != this.ShipcallModel.Shipcall?.TidalWindowFrom) || (this.datePickerTidalWindowTo.Value != this.ShipcallModel.Shipcall?.TidalWindowTo)) // something has changed
{
if (datePickerTidalWindowTo.Value.IsTooOld() || this.datePickerTidalWindowFrom.Value.IsTooOld())
{
message = BreCalClient.Resources.Resources.textTideTimesInThePast;
return false;
}
if (this.datePickerTidalWindowFrom.Value.HasValue && this.datePickerTidalWindowTo.Value.HasValue && this.datePickerTidalWindowFrom.Value > this.datePickerTidalWindowTo.Value)
{
message = BreCalClient.Resources.Resources.textEndValueBeforeStartValue;
return false;
}
if ((this.datePickerTidalWindowFrom.Value.HasValue && !this.datePickerTidalWindowTo.Value.HasValue) || (!this.datePickerTidalWindowFrom.Value.HasValue && this.datePickerTidalWindowTo.Value.HasValue))
{
message = BreCalClient.Resources.Resources.textTidalBothValues;
return false;
}
}
}
return true; return true;
} }
@ -167,6 +193,13 @@ namespace BreCalClient
this.Times.ZoneEntry = this.datePickerZoneEntry.Value; this.Times.ZoneEntry = this.datePickerZoneEntry.Value;
this.Times.Ata = this.datePickerATA.Value; this.Times.Ata = this.datePickerATA.Value;
this.Times.Atd = this.datePickerATD.Value; this.Times.Atd = this.datePickerATD.Value;
Extensions.ParticipantType pType = (Extensions.ParticipantType)this.Times.ParticipantType;
if ((pType == Extensions.ParticipantType.PILOT) && this.ShipcallModel.Shipcall != null)
{
this.ShipcallModel.Shipcall.TidalWindowFrom = this.datePickerTidalWindowFrom.Value;
this.ShipcallModel.Shipcall.TidalWindowTo = this.datePickerTidalWindowTo.Value;
}
} }
private void CopyToControls() private void CopyToControls()
@ -218,6 +251,19 @@ namespace BreCalClient
} }
} }
Extensions.ParticipantType pType = (Extensions.ParticipantType)this.Times.ParticipantType;
if ((pType == Extensions.ParticipantType.PILOT) && this.ShipcallModel.Shipcall != null)
{
this.datePickerTidalWindowFrom.Value = this.ShipcallModel.Shipcall.TidalWindowFrom;
this.datePickerTidalWindowTo.Value = this.ShipcallModel.Shipcall.TidalWindowTo;
}
else
{
this.rowt1.Height = new(0);
this.rowt2.Height = new(0);
this.rowt3.Height = new(0);
}
this.SetLockButton(this.Times.EtaBerthFixed ?? false); this.SetLockButton(this.Times.EtaBerthFixed ?? false);
} }
@ -259,7 +305,7 @@ namespace BreCalClient
// setting en/dis-abled // setting en/dis-abled
if (this.Times.ParticipantId != App.Participant.Id) if ((this.Times.ParticipantId != App.Participant.Id) || (this.ShipcallModel.Shipcall?.Canceled ?? false))
{ {
this.buttonFixedOrder.IsEnabled = false; this.buttonFixedOrder.IsEnabled = false;
this.buttonOK.IsEnabled = false; this.buttonOK.IsEnabled = false;
@ -283,8 +329,12 @@ namespace BreCalClient
this.datePickerLockTime.IsEnabled = true; this.datePickerLockTime.IsEnabled = true;
break; break;
case Extensions.ParticipantType.TUG: case Extensions.ParticipantType.TUG:
this.datePickerZoneEntry.IsEnabled = (ShipcallModel.Shipcall?.Type == ShipcallType.Arrival);
break;
case Extensions.ParticipantType.PILOT: case Extensions.ParticipantType.PILOT:
this.datePickerZoneEntry.IsEnabled = (ShipcallModel.Shipcall?.Type == ShipcallType.Arrival); this.datePickerZoneEntry.IsEnabled = (ShipcallModel.Shipcall?.Type == ShipcallType.Arrival);
this.datePickerTidalWindowFrom.IsEnabled = true;
this.datePickerTidalWindowTo.IsEnabled = true;
break; break;
} }
} }

View File

@ -217,7 +217,7 @@ namespace BreCalClient
private void EnableControls() private void EnableControls()
{ {
if (this.Times.ParticipantId != App.Participant.Id) if ((this.Times.ParticipantId != App.Participant.Id) || (this.ShipcallModel.Shipcall?.Canceled ?? false))
{ {
this.buttonOK.IsEnabled = false; this.buttonOK.IsEnabled = false;
return; return;

View File

@ -1,5 +1,5 @@
// Copyright (c) 2024- schick Informatik // Copyright (c) 2024- schick Informatik
// Description: Window to show (complete) list of current shipcall histories // Description:
// //
using BreCalClient.misc.Api; using BreCalClient.misc.Api;

View File

@ -124,6 +124,8 @@
<ColumnDefinition Width="Auto" /> <ColumnDefinition Width="Auto" />
<ColumnDefinition Width="26" /> <ColumnDefinition Width="26" />
<ColumnDefinition Width="Auto" /> <ColumnDefinition Width="Auto" />
<ColumnDefinition Width="26" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" /> <ColumnDefinition Width="Auto" />
<ColumnDefinition Width="26" /> <ColumnDefinition Width="26" />
@ -157,19 +159,25 @@
</StatusBarItem> </StatusBarItem>
<Separator Grid.Column="9"/> <Separator Grid.Column="9"/>
<StatusBarItem Grid.Column="10"> <StatusBarItem Grid.Column="10">
<Button x:Name="buttonNotifications" Click="buttonNotifications_Click" Width="20" ToolTip="{x:Static p:Resources.textShowNotifications}">
<Image Source="./Resources/bell3.png"/>
</Button>
</StatusBarItem>
<Separator Grid.Column="9"/>
<StatusBarItem Grid.Column="12">
<TextBlock Name="labelStatusBar"></TextBlock> <TextBlock Name="labelStatusBar"></TextBlock>
</StatusBarItem> </StatusBarItem>
<Separator Grid.Column="11"/> <Separator Grid.Column="13"/>
<StatusBarItem Grid.Column="12"> <StatusBarItem Grid.Column="14">
<Button x:Name="buttonManualRefresh" Width="20" Click="buttonManualRefresh_Click" ToolTip="{x:Static p:Resources.textTriggerManualRefresh}"> <Button x:Name="buttonManualRefresh" Width="20" Click="buttonManualRefresh_Click" ToolTip="{x:Static p:Resources.textTriggerManualRefresh}">
<Image Source="./Resources/nav_refresh_green.png"/> <Image Source="./Resources/nav_refresh_green.png"/>
</Button> </Button>
</StatusBarItem> </StatusBarItem>
<StatusBarItem Grid.Column="13"> <StatusBarItem Grid.Column="15">
<TextBlock x:Name="labelLatestUpdate" /> <TextBlock x:Name="labelLatestUpdate" />
</StatusBarItem> </StatusBarItem>
<Separator Grid.Column="14"/> <Separator Grid.Column="16"/>
<StatusBarItem Grid.Column="15"> <StatusBarItem Grid.Column="17">
<ProgressBar Name="generalProgressStatus" Width="90" Height="16" Foreground="LightGray"/> <ProgressBar Name="generalProgressStatus" Width="90" Height="16" Foreground="LightGray"/>
</StatusBarItem> </StatusBarItem>
</StatusBar> </StatusBar>

View File

@ -36,8 +36,11 @@ namespace BreCalClient
public partial class MainWindow : Window public partial class MainWindow : Window
{ {
private readonly ILog _log = LogManager.GetLogger(typeof(MainWindow)); private readonly ILog _log = LogManager.GetLogger(typeof(MainWindow));
private readonly ToastViewModel _vm;
private const int SHIPCALL_UPDATE_INTERVAL_SECONDS = 30; private const int SHIPCALL_UPDATE_INTERVAL_SECONDS = 30;
private const int SHIPS_UPDATE_INTERVAL_SECONDS = 120; private const int SHIPS_UPDATE_INTERVAL_SECONDS = 120;
private const int CHECK_NOTIFICATIONS_INTERVAL_SECONDS = 5;
private const int PROGRESS_STEPS = 50; private const int PROGRESS_STEPS = 50;
#region Fields #region Fields
@ -49,7 +52,7 @@ namespace BreCalClient
private readonly ConcurrentDictionary<int, ShipcallControlModel> _allShipcallsDict = new(); private readonly ConcurrentDictionary<int, ShipcallControlModel> _allShipcallsDict = new();
private readonly ConcurrentDictionary<int, ShipcallControl> _allShipCallsControlDict = new(); private readonly ConcurrentDictionary<int, ShipcallControl> _allShipCallsControlDict = new();
private readonly List<ShipcallControlModel> _visibleControlModels = new(); private readonly List<ShipcallControlModel> _visibleControlModels = [];
private readonly ShipcallApi _shipcallApi; private readonly ShipcallApi _shipcallApi;
private readonly UserApi _userApi; private readonly UserApi _userApi;
@ -68,6 +71,7 @@ namespace BreCalClient
// private bool _filterChanged = false; // private bool _filterChanged = false;
// private bool _sequenceChanged = false; // private bool _sequenceChanged = false;
private HistoryDialog? _historyDialog; private HistoryDialog? _historyDialog;
private NotificationDialog? _notificationDialog;
#endregion #endregion
@ -122,6 +126,13 @@ namespace BreCalClient
RetryConfiguration.AsyncRetryPolicy = retryPolicy; RetryConfiguration.AsyncRetryPolicy = retryPolicy;
this.generalProgressStatus.Maximum = PROGRESS_STEPS; this.generalProgressStatus.Maximum = PROGRESS_STEPS;
_vm = new ToastViewModel();
this.Unloaded += MainWindow_Unloaded;
}
private void MainWindow_Unloaded(object sender, RoutedEventArgs e)
{
_vm.OnUnloaded();
} }
#endregion #endregion
@ -141,6 +152,7 @@ namespace BreCalClient
}; };
this.comboBoxSortOrder.ItemsSource = Enum.GetValues(typeof(Extensions.SortOrder)); this.comboBoxSortOrder.ItemsSource = Enum.GetValues(typeof(Extensions.SortOrder));
this.comboBoxSortOrder.SelectedIndex = (int)_sortOrder; this.comboBoxSortOrder.SelectedIndex = (int)_sortOrder;
AppNotification.LoadFromSettings();
} }
private void Window_Closing(object sender, System.ComponentModel.CancelEventArgs e) private void Window_Closing(object sender, System.ComponentModel.CancelEventArgs e)
@ -183,7 +195,7 @@ namespace BreCalClient
} }
catch (ApiException ex) catch (ApiException ex)
{ {
if ((ex.ErrorContent != null && ((string)ex.ErrorContent).StartsWith("{"))) { if ((ex.ErrorContent != null && ((string)ex.ErrorContent).StartsWith('{'))) {
Error? anError = JsonConvert.DeserializeObject<Error>((string)ex.ErrorContent); Error? anError = JsonConvert.DeserializeObject<Error>((string)ex.ErrorContent);
if ((anError != null) && anError.ErrorField.Equals("invalid credentials")) if ((anError != null) && anError.ErrorField.Equals("invalid credentials"))
this.labelLoginResult.Content = BreCalClient.Resources.Resources.textWrongCredentials; this.labelLoginResult.Content = BreCalClient.Resources.Resources.textWrongCredentials;
@ -247,7 +259,8 @@ namespace BreCalClient
EditShipcallControl esc = new() EditShipcallControl esc = new()
{ {
ShipEditingEnabled = App.Participant.IsTypeFlagSet(Extensions.ParticipantType.BSMD), ShipEditingEnabled = App.Participant.IsTypeFlagSet(Extensions.ParticipantType.BSMD),
ShipApi = _shipApi ShipApi = _shipApi,
IsCreate = true
}; };
if (model != null) if (model != null)
esc.ShipcallModel = model; esc.ShipcallModel = model;
@ -296,7 +309,7 @@ namespace BreCalClient
scmOut.Shipcall.DepartureBerthId = esc.ShipcallModel.Shipcall?.ArrivalBerthId; scmOut.Shipcall.DepartureBerthId = esc.ShipcallModel.Shipcall?.ArrivalBerthId;
if (esc.ShipcallModel.Shipcall != null) if (esc.ShipcallModel.Shipcall != null)
{ {
scmOut.Shipcall.Participants = new(); scmOut.Shipcall.Participants = [];
scmOut.Shipcall.Participants.AddRange(esc.ShipcallModel.Shipcall.Participants); scmOut.Shipcall.Participants.AddRange(esc.ShipcallModel.Shipcall.Participants);
foreach(ParticipantType pType in esc.ShipcallModel.AssignedParticipants.Keys) foreach(ParticipantType pType in esc.ShipcallModel.AssignedParticipants.Keys)
scmOut.AssignedParticipants[pType] = esc.ShipcallModel.AssignedParticipants[pType]; scmOut.AssignedParticipants[pType] = esc.ShipcallModel.AssignedParticipants[pType];
@ -324,10 +337,6 @@ namespace BreCalClient
UserDetails ud = new() UserDetails ud = new()
{ {
Id = _loginResult.Id, Id = _loginResult.Id,
FirstName = _loginResult.FirstName,
LastName = _loginResult.LastName,
UserPhone = _loginResult.UserPhone,
UserEmail = _loginResult.UserEmail,
OldPassword = oldPw, OldPassword = oldPw,
NewPassword = newPw NewPassword = newPw
}; };
@ -345,6 +354,41 @@ namespace BreCalClient
} }
} }
}; };
ad.ChangeUserSettingsRequested += async () =>
{
if (_loginResult != null)
{
UserDetails ud = new()
{
Id = _loginResult.Id,
FirstName = _loginResult.FirstName,
LastName = _loginResult.LastName,
UserPhone = _loginResult.UserPhone,
UserEmail = _loginResult.UserEmail,
NotifyEmail = _loginResult.NotifyEmail,
NotifyPopup = _loginResult.NotifyPopup,
NotifySignal = _loginResult.NotifySignal,
NotifyWhatsapp = _loginResult.NotifyWhatsapp,
};
if (_loginResult.NotifyOn != null)
{
ud.NotifyOn = new(_loginResult.NotifyOn);
}
try
{
await _userApi.UserUpdateAsync(ud);
MessageBox.Show(BreCalClient.Resources.Resources.textInformationUpdated, BreCalClient.Resources.Resources.textConfirmation, MessageBoxButton.OK, MessageBoxImage.Information);
}
catch (Exception ex)
{
this.Dispatcher.Invoke(new Action(() =>
{
ShowErrorDialog(ex.Message, "Error saving user information");
}));
}
}
};
ad.ShowDialog(); ad.ShowDialog();
} }
@ -373,8 +417,18 @@ namespace BreCalClient
private void comboBoxPorts_ItemSelectionChanged(object sender, Xceed.Wpf.Toolkit.Primitives.ItemSelectionChangedEventArgs e) private void comboBoxPorts_ItemSelectionChanged(object sender, Xceed.Wpf.Toolkit.Primitives.ItemSelectionChangedEventArgs e)
{ {
this.searchFilterControl.SearchFilter.Ports.Clear(); this.searchFilterControl.SearchFilter.Ports.Clear();
List<Berth> berths = [];
foreach (Port port in comboBoxPorts.SelectedItems) foreach (Port port in comboBoxPorts.SelectedItems)
{
this.searchFilterControl.SearchFilter.Ports.Add(port.Id); this.searchFilterControl.SearchFilter.Ports.Add(port.Id);
berths.AddRange(BreCalLists.GetBerthsByPort(port.Id));
}
// create list of berths from selected port(s) or return all berths
if (berths.Count == 0)
berths = BreCalLists.AllBerths;
this.searchFilterControl.SetBerths(berths);
this.SearchFilterControl_SearchFilterChanged(); this.SearchFilterControl_SearchFilterChanged();
} }
@ -395,8 +449,8 @@ namespace BreCalClient
_historyDialog.Closed += (sender, e) => { this._historyDialog = null; }; _historyDialog.Closed += (sender, e) => { this._historyDialog = null; };
_historyDialog.HistoryItemSelected += (x) => _historyDialog.HistoryItemSelected += (x) =>
{ {
if(_allShipCallsControlDict.ContainsKey(x)) if(_allShipCallsControlDict.TryGetValue(x, out ShipcallControl? value))
_allShipCallsControlDict[x].BringIntoView(); value.BringIntoView();
}; };
_historyDialog.Show(); _historyDialog.Show();
} }
@ -406,6 +460,21 @@ namespace BreCalClient
} }
} }
private void buttonNotifications_Click(object sender, RoutedEventArgs e)
{
if (_notificationDialog == null)
{
_notificationDialog = new NotificationDialog();
_notificationDialog.AppNotifications = AppNotification.AppNotifications;
_notificationDialog.Closed += (sender, e) => { this._notificationDialog = null; };
_notificationDialog.Show();
}
else
{
_notificationDialog.Activate();
}
}
private void buttonManualRefresh_Click(object sender, RoutedEventArgs e) private void buttonManualRefresh_Click(object sender, RoutedEventArgs e)
{ {
_refreshImmediately = true; // set flag to avoid timer loop termination _refreshImmediately = true; // set flag to avoid timer loop termination
@ -445,14 +514,14 @@ namespace BreCalClient
SearchFilterModel? currentFilter = null; SearchFilterModel? currentFilter = null;
if (SearchFilterModel.filterMap != null) if (SearchFilterModel.filterMap != null)
{ {
if((_loginResult != null) && SearchFilterModel.filterMap.ContainsKey(_loginResult.Id)) if((_loginResult != null) && SearchFilterModel.filterMap.TryGetValue(_loginResult.Id, out SearchFilterModel? value))
{ {
currentFilter = SearchFilterModel.filterMap[_loginResult.Id]; currentFilter = value;
} }
} }
else else
{ {
SearchFilterModel.filterMap = new(); SearchFilterModel.filterMap = [];
} }
if (currentFilter == null) if (currentFilter == null)
{ {
@ -473,6 +542,7 @@ namespace BreCalClient
_ = Task.Run(() => RefreshShipcalls()); _ = Task.Run(() => RefreshShipcalls());
_ = Task.Run(() => RefreshShips()); _ = Task.Run(() => RefreshShips());
_ = Task.Run(() => CheckNotifications());
} }
@ -537,7 +607,7 @@ namespace BreCalClient
// load times for each shipcall // load times for each shipcall
List<Times> currentTimes = await _timesApi.TimesGetAsync(shipcall.Id); List<Times> currentTimes = await _timesApi.TimesGetAsync(shipcall.Id);
if (!_allShipcallsDict.ContainsKey(shipcall.Id)) if (!_allShipcallsDict.TryGetValue(shipcall.Id, out ShipcallControlModel? value))
{ {
// add entry // add entry
ShipcallControlModel scm = new() ShipcallControlModel scm = new()
@ -549,10 +619,9 @@ namespace BreCalClient
} }
else else
{ {
// update entry value.Shipcall = shipcall;
_allShipcallsDict[shipcall.Id].Shipcall = shipcall; value.Times = currentTimes;
_allShipcallsDict[shipcall.Id].Times = currentTimes; UpdateShipcall(value);
UpdateShipcall(_allShipcallsDict[shipcall.Id]);
} }
} }
@ -596,6 +665,19 @@ namespace BreCalClient
} }
} }
public async Task CheckNotifications()
{
while (true)
{
Thread.Sleep(CHECK_NOTIFICATIONS_INTERVAL_SECONDS * 1000);
if (_loginResult?.NotifyPopup ?? false)
{
List<Notification> notifications = await _staticApi.NotificationsGetAsync();
AppNotification.UpdateNotifications(notifications, _allShipcallsDict, _vm, _loginResult);
}
}
}
#endregion #endregion
#region basic operations #region basic operations
@ -606,8 +688,8 @@ namespace BreCalClient
_allShipcallsDict[scm.Shipcall.Id] = scm; _allShipcallsDict[scm.Shipcall.Id] = scm;
Shipcall shipcall = scm.Shipcall; Shipcall shipcall = scm.Shipcall;
if (BreCalLists.ShipLookupDict.ContainsKey(shipcall.ShipId)) if (BreCalLists.ShipLookupDict.TryGetValue(shipcall.ShipId, out ShipModel? value))
scm.Ship = BreCalLists.ShipLookupDict[shipcall.ShipId].Ship; scm.Ship = value.Ship;
if (shipcall.Type == ShipcallType.Arrival) if (shipcall.Type == ShipcallType.Arrival)
{ {
@ -640,8 +722,8 @@ namespace BreCalClient
{ {
if(scm.Shipcall == null) return; if(scm.Shipcall == null) return;
Shipcall shipcall = scm.Shipcall; Shipcall shipcall = scm.Shipcall;
if (BreCalLists.ShipLookupDict.ContainsKey(shipcall.ShipId)) if (BreCalLists.ShipLookupDict.TryGetValue(shipcall.ShipId, out ShipModel? value))
scm.Ship = BreCalLists.ShipLookupDict[shipcall.ShipId].Ship; scm.Ship = value.Ship;
if (shipcall.Type == ShipcallType.Arrival) if (shipcall.Type == ShipcallType.Arrival)
{ {
@ -886,10 +968,10 @@ namespace BreCalClient
foreach (ShipcallControlModel visibleModel in this._visibleControlModels) foreach (ShipcallControlModel visibleModel in this._visibleControlModels)
{ {
if (visibleModel.Shipcall == null) continue; // should not happen if (visibleModel.Shipcall == null) continue; // should not happen
if (this._allShipCallsControlDict.ContainsKey(visibleModel.Shipcall.Id)) if (this._allShipCallsControlDict.TryGetValue(visibleModel.Shipcall.Id, out ShipcallControl? value))
{ {
this._allShipCallsControlDict[visibleModel.Shipcall.Id].RefreshData(); value.RefreshData();
this.stackPanel.Children.Add(this._allShipCallsControlDict[visibleModel.Shipcall.Id]); this.stackPanel.Children.Add(value);
} }
} }
} }
@ -995,6 +1077,13 @@ namespace BreCalClient
etc.Times.Id = apiResultId.VarId; etc.Times.Id = apiResultId.VarId;
obj.ShipcallControlModel?.Times.Add(etc.Times); obj.ShipcallControlModel?.Times.Add(etc.Times);
} }
// a pilot may have changed the tidal window so we update the shipcall too in this case
if(((Extensions.ParticipantType)etc.Times.ParticipantType) == ParticipantType.PILOT)
{
await _shipcallApi.ShipcallUpdateAsync(obj.ShipcallControlModel?.Shipcall);
}
_refreshImmediately = true; _refreshImmediately = true;
_tokenSource.Cancel(); _tokenSource.Cancel();
} }
@ -1032,8 +1121,8 @@ namespace BreCalClient
} }
else else
{ {
if(editControl.ShipcallModel.AssignedParticipants.ContainsKey(ParticipantType.AGENCY)) if(editControl.ShipcallModel.AssignedParticipants.TryGetValue(ParticipantType.AGENCY, out ParticipantAssignment? value))
editControl.Times.ParticipantId = editControl.ShipcallModel.AssignedParticipants[ParticipantType.AGENCY].ParticipantId; editControl.Times.ParticipantId = value.ParticipantId;
} }
editControl.Times.ParticipantType = (int)ParticipantType.AGENCY; editControl.Times.ParticipantType = (int)ParticipantType.AGENCY;
if(editControl.ShowDialog() ?? false) if(editControl.ShowDialog() ?? false)
@ -1049,9 +1138,9 @@ namespace BreCalClient
} }
// always try to be the agent, even if we are BSMD // always try to be the agent, even if we are BSMD
if (editControl.ShipcallModel.AssignedParticipants.ContainsKey(ParticipantType.AGENCY)) if (editControl.ShipcallModel.AssignedParticipants.TryGetValue(ParticipantType.AGENCY, out ParticipantAssignment? value))
{ {
editControl.Times.ParticipantId = editControl.ShipcallModel.AssignedParticipants[ParticipantType.AGENCY].ParticipantId; editControl.Times.ParticipantId = value.ParticipantId;
} }
else else
{ {
@ -1100,7 +1189,7 @@ namespace BreCalClient
// (if the special-flag is enabled). Assigned Agency: ShipcallParticipantMap(id=628, shipcall_id=115, participant_id=10, // (if the special-flag is enabled). Assigned Agency: ShipcallParticipantMap(id=628, shipcall_id=115, participant_id=10,
// type=8, created=datetime.datetime(2024, 8, 28, 15, 13, 14), modified=None) with Flags: 42\"} // type=8, created=datetime.datetime(2024, 8, 28, 15, 13, 14), modified=None) with Flags: 42\"}
Match m = Regex.Match(message, "\\{(.*)\\}"); Match m = ErrorRegex().Match(message);
if ((m != null) && m.Success) if ((m != null) && m.Success)
{ {
try try
@ -1145,6 +1234,9 @@ namespace BreCalClient
e.Handled = true; e.Handled = true;
} }
[GeneratedRegex("\\{(.*)\\}")]
private static partial Regex ErrorRegex();
#endregion #endregion
} }

View File

@ -0,0 +1,68 @@
<Window x:Class="BreCalClient.NotificationDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:BreCalClient"
xmlns:xctk="http://schemas.xceed.com/wpf/xaml/toolkit"
xmlns:p = "clr-namespace:BreCalClient.Resources"
mc:Ignorable="d" Left="{local:SettingBinding W5Left}" Top="{local:SettingBinding W5Top}"
Title="{x:Static p:Resources.textNotifications}" Height="450" Width="800" Loaded="Window_Loaded">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="28" />
</Grid.RowDefinitions>
<local:ENIDataGrid x:Name="dataGridNotifications" Grid.Row="0" SelectionMode="Single" IsReadOnly="True" AutoGenerateColumns="False"
CanUserAddRows="False" HorizontalContentAlignment="Stretch" VerticalContentAlignment="Stretch">
<local:ENIDataGrid.RowStyle>
<Style TargetType="DataGridRow">
<Style.Triggers>
<DataTrigger Binding="{Binding NotificationType}" Value="Assignment">
<Setter Property="Foreground" Value="Blue"/>
</DataTrigger>
<DataTrigger Binding="{Binding NotificationType}" Value="Next24h">
<Setter Property="Foreground" Value="DarkOrange"/>
</DataTrigger>
<DataTrigger Binding="{Binding NotificationType}" Value="TimeConflict">
<Setter Property="Background" Value="Red"/>
</DataTrigger>
<DataTrigger Binding="{Binding NotificationType}" Value="TimeConflictResolved">
<Setter Property="Foreground" Value="Green"/>
</DataTrigger>
<DataTrigger Binding="{Binding NotificationType}" Value="Unassigned">
<Setter Property="Foreground" Value="DarkGray"/>
</DataTrigger>
<DataTrigger Binding="{Binding NotificationType}" Value="MissingData">
<Setter Property="Foreground" Value="Yellow" />
<Setter Property="Background" Value="DarkGray" />
</DataTrigger>
<DataTrigger Binding="{Binding NotificationType}" Value="Cancelled">
<Setter Property="Background" Value="LightGray" />
</DataTrigger>
</Style.Triggers>
</Style>
</local:ENIDataGrid.RowStyle>
<DataGrid.Columns>
<DataGridTextColumn Header="Id" Binding="{Binding Path=Id}" IsReadOnly="True"/>
<DataGridTextColumn Header="{x:Static p:Resources.textType}" Binding="{Binding Path=NotificationDisplay}" IsReadOnly="True"/>
<DataGridTextColumn Header="{x:Static p:Resources.textDate}" Binding="{Binding Path=NotificationDate}" IsReadOnly="True"/>
<DataGridTextColumn Header="{x:Static p:Resources.textShip}" Binding="{Binding Path=Ship}" IsReadOnly="True"/>
<DataGridTextColumn Header="{x:Static p:Resources.textShipcall}" Binding="{Binding Path=ShipcallType}" IsReadOnly="True"/>
<DataGridTextColumn Header="ETA/ETD" Binding="{Binding Path=ETA}" IsReadOnly="True"/>
<DataGridTextColumn Header="{x:Static p:Resources.textBerth}" Binding="{Binding Path=Berth}" IsReadOnly="True"/>
</DataGrid.Columns>
</local:ENIDataGrid>
<Grid Grid.Row="1" Grid.Column="0" >
<Grid.ColumnDefinitions>
<ColumnDefinition Width="22" />
<ColumnDefinition Width="80" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width=".2*" />
</Grid.ColumnDefinitions>
<Button x:Name="buttonClose" Click="buttonClose_Click" Content="{x:Static p:Resources.textClose}" Width="80" Margin="2" Grid.Row="0" Grid.Column="3" HorizontalAlignment="Right" />
</Grid>
</Grid>
</Window>

View File

@ -0,0 +1,44 @@
// Copyright (c) 2024- schick Informatik
// Description:
//
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Shapes;
namespace BreCalClient
{
/// <summary>
/// Interaction logic for NotificationDialog.xaml
/// </summary>
public partial class NotificationDialog : Window
{
public NotificationDialog()
{
InitializeComponent();
}
internal ObservableCollection<AppNotification>? AppNotifications { get; set; }
private void buttonClose_Click(object sender, RoutedEventArgs e)
{
this.Close();
}
private void Window_Loaded(object sender, RoutedEventArgs e)
{
this.dataGridNotifications.ItemsSource = AppNotifications;
}
}
}

View File

@ -4,8 +4,8 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
--> -->
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<ApplicationRevision>2</ApplicationRevision> <ApplicationRevision>0</ApplicationRevision>
<ApplicationVersion>1.6.0.4</ApplicationVersion> <ApplicationVersion>1.8.0.0</ApplicationVersion>
<BootstrapperEnabled>True</BootstrapperEnabled> <BootstrapperEnabled>True</BootstrapperEnabled>
<Configuration>Debug</Configuration> <Configuration>Debug</Configuration>
<CreateDesktopShortcut>True</CreateDesktopShortcut> <CreateDesktopShortcut>True</CreateDesktopShortcut>
@ -21,7 +21,7 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
<OpenBrowserOnPublish>False</OpenBrowserOnPublish> <OpenBrowserOnPublish>False</OpenBrowserOnPublish>
<Platform>Any CPU</Platform> <Platform>Any CPU</Platform>
<ProductName>Bremen calling development client</ProductName> <ProductName>Bremen calling development client</ProductName>
<PublishDir>bin\Debug\net6.0-windows\win-x64\app.publish\</PublishDir> <PublishDir>bin\Debug\net8.0-windows7.0\win-x64\app.publish\</PublishDir>
<PublishUrl>bin\publish.devel\</PublishUrl> <PublishUrl>bin\publish.devel\</PublishUrl>
<PublisherName>Informatikbüro Daniel Schick</PublisherName> <PublisherName>Informatikbüro Daniel Schick</PublisherName>
<PublishProtocol>ClickOnce</PublishProtocol> <PublishProtocol>ClickOnce</PublishProtocol>
@ -33,12 +33,12 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
<SignManifests>False</SignManifests> <SignManifests>False</SignManifests>
<SuiteName>Bremen calling</SuiteName> <SuiteName>Bremen calling</SuiteName>
<SupportUrl>https://www.textbausteine.net/</SupportUrl> <SupportUrl>https://www.textbausteine.net/</SupportUrl>
<TargetFramework>net6.0-windows</TargetFramework> <TargetFramework>net8.0-windows7.0</TargetFramework>
<UpdateEnabled>True</UpdateEnabled> <UpdateEnabled>True</UpdateEnabled>
<UpdateMode>Foreground</UpdateMode> <UpdateMode>Foreground</UpdateMode>
<UpdateRequired>True</UpdateRequired> <UpdateRequired>True</UpdateRequired>
<WebPageFileName>Publish.html</WebPageFileName> <WebPageFileName>Publish.html</WebPageFileName>
<MinimumRequiredVersion>1.6.0.4</MinimumRequiredVersion> <MinimumRequiredVersion>1.8.0.0</MinimumRequiredVersion>
<SkipPublishVerification>false</SkipPublishVerification> <SkipPublishVerification>false</SkipPublishVerification>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View File

@ -4,8 +4,8 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
--> -->
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<ApplicationRevision>0</ApplicationRevision> <ApplicationRevision>5</ApplicationRevision>
<ApplicationVersion>1.6.0.3</ApplicationVersion> <ApplicationVersion>1.7.0.7</ApplicationVersion>
<BootstrapperEnabled>True</BootstrapperEnabled> <BootstrapperEnabled>True</BootstrapperEnabled>
<Configuration>Debug</Configuration> <Configuration>Debug</Configuration>
<CreateDesktopShortcut>True</CreateDesktopShortcut> <CreateDesktopShortcut>True</CreateDesktopShortcut>
@ -27,10 +27,10 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
<SelfContained>True</SelfContained> <SelfContained>True</SelfContained>
<SignatureAlgorithm>(none)</SignatureAlgorithm> <SignatureAlgorithm>(none)</SignatureAlgorithm>
<SignManifests>False</SignManifests> <SignManifests>False</SignManifests>
<TargetFramework>net6.0-windows</TargetFramework> <TargetFramework>net8.0-windows7.0</TargetFramework>
<UpdateEnabled>True</UpdateEnabled> <UpdateEnabled>True</UpdateEnabled>
<UpdateMode>Foreground</UpdateMode> <UpdateMode>Foreground</UpdateMode>
<UpdateRequired>False</UpdateRequired> <UpdateRequired>True</UpdateRequired>
<WebPageFileName>Publish.html</WebPageFileName> <WebPageFileName>Publish.html</WebPageFileName>
<CreateDesktopShortcut>True</CreateDesktopShortcut> <CreateDesktopShortcut>True</CreateDesktopShortcut>
<ErrorReportUrl>https://www.bsmd-emswe.eu/</ErrorReportUrl> <ErrorReportUrl>https://www.bsmd-emswe.eu/</ErrorReportUrl>
@ -38,8 +38,10 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
<PublisherName>Informatikbüro Daniel Schick</PublisherName> <PublisherName>Informatikbüro Daniel Schick</PublisherName>
<SuiteName>Bremen Calling</SuiteName> <SuiteName>Bremen Calling</SuiteName>
<SupportUrl>http://www.textbausteine.net/</SupportUrl> <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> <RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SkipPublishVerification>false</SkipPublishVerification>
<MinimumRequiredVersion>1.7.0.7</MinimumRequiredVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PublishFile Include="containership.ico"> <PublishFile Include="containership.ico">

View File

@ -12,7 +12,7 @@ namespace BreCalClient.Properties {
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "17.10.0.0")] [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "17.12.0.0")]
internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase { internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase {
private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings()))); private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings())));
@ -25,7 +25,7 @@ namespace BreCalClient.Properties {
[global::System.Configuration.ApplicationScopedSettingAttribute()] [global::System.Configuration.ApplicationScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Configuration.DefaultSettingValueAttribute("#1D751F")] [global::System.Configuration.DefaultSettingValueAttribute("#751D1F")]
public string BG_COLOR { public string BG_COLOR {
get { get {
return ((string)(this["BG_COLOR"])); return ((string)(this["BG_COLOR"]));
@ -34,7 +34,7 @@ namespace BreCalClient.Properties {
[global::System.Configuration.ApplicationScopedSettingAttribute()] [global::System.Configuration.ApplicationScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Configuration.DefaultSettingValueAttribute("!!Bremen calling Testversion!!")] [global::System.Configuration.DefaultSettingValueAttribute("!!Bremen calling Entwicklungsversion!!")]
public string APP_TITLE { public string APP_TITLE {
get { get {
return ((string)(this["APP_TITLE"])); return ((string)(this["APP_TITLE"]));
@ -64,7 +64,7 @@ namespace BreCalClient.Properties {
[global::System.Configuration.ApplicationScopedSettingAttribute()] [global::System.Configuration.ApplicationScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [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 { public string API_URL {
get { get {
return ((string)(this["API_URL"])); return ((string)(this["API_URL"]));
@ -226,5 +226,40 @@ namespace BreCalClient.Properties {
this["FilterCriteriaMap"] = value; this["FilterCriteriaMap"] = value;
} }
} }
[global::System.Configuration.UserScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
public global::System.Collections.Specialized.StringCollection Notifications {
get {
return ((global::System.Collections.Specialized.StringCollection)(this["Notifications"]));
}
set {
this["Notifications"] = value;
}
}
[global::System.Configuration.UserScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Configuration.DefaultSettingValueAttribute("0")]
public double W5Top {
get {
return ((double)(this["W5Top"]));
}
set {
this["W5Top"] = value;
}
}
[global::System.Configuration.UserScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Configuration.DefaultSettingValueAttribute("0")]
public double W5Left {
get {
return ((double)(this["W5Left"]));
}
set {
this["W5Left"] = value;
}
}
} }
} }

View File

@ -3,10 +3,10 @@
<Profiles /> <Profiles />
<Settings> <Settings>
<Setting Name="BG_COLOR" Type="System.String" Scope="Application"> <Setting Name="BG_COLOR" Type="System.String" Scope="Application">
<Value Profile="(Default)">#1D751F</Value> <Value Profile="(Default)">#751D1F</Value>
</Setting> </Setting>
<Setting Name="APP_TITLE" Type="System.String" Scope="Application"> <Setting Name="APP_TITLE" Type="System.String" Scope="Application">
<Value Profile="(Default)">!!Bremen calling Testversion!!</Value> <Value Profile="(Default)">!!Bremen calling Entwicklungsversion!!</Value>
</Setting> </Setting>
<Setting Name="LOGO_IMAGE_URL" Type="System.String" Scope="Application"> <Setting Name="LOGO_IMAGE_URL" Type="System.String" Scope="Application">
<Value Profile="(Default)">https://www.textbausteine.net/</Value> <Value Profile="(Default)">https://www.textbausteine.net/</Value>
@ -15,7 +15,7 @@
<Value Profile="(Default)" /> <Value Profile="(Default)" />
</Setting> </Setting>
<Setting Name="API_URL" Type="System.String" Scope="Application"> <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>
<Setting Name="Width" Type="System.Double" Scope="User"> <Setting Name="Width" Type="System.Double" Scope="User">
<Value Profile="(Default)">800</Value> <Value Profile="(Default)">800</Value>
@ -56,5 +56,14 @@
<Setting Name="FilterCriteriaMap" Type="System.String" Scope="User"> <Setting Name="FilterCriteriaMap" Type="System.String" Scope="User">
<Value Profile="(Default)" /> <Value Profile="(Default)" />
</Setting> </Setting>
<Setting Name="Notifications" Type="System.Collections.Specialized.StringCollection" Scope="User">
<Value Profile="(Default)" />
</Setting>
<Setting Name="W5Top" Type="System.Double" Scope="User">
<Value Profile="(Default)">0</Value>
</Setting>
<Setting Name="W5Left" Type="System.Double" Scope="User">
<Value Profile="(Default)">0</Value>
</Setting>
</Settings> </Settings>
</SettingsFile> </SettingsFile>

View File

@ -167,6 +167,16 @@ namespace BreCalClient.Resources {
} }
} }
/// <summary>
/// Looks up a localized resource of type System.Byte[].
/// </summary>
public static byte[] bell3 {
get {
object obj = ResourceManager.GetObject("bell3", resourceCulture);
return ((byte[])(obj));
}
}
/// <summary> /// <summary>
/// Looks up a localized resource of type System.Byte[]. /// Looks up a localized resource of type System.Byte[].
/// </summary> /// </summary>
@ -380,6 +390,15 @@ namespace BreCalClient.Resources {
} }
} }
/// <summary>
/// Looks up a localized string similar to Participant assigned to shipcall.
/// </summary>
public static string textAssignment {
get {
return ResourceManager.GetString("textAssignment", resourceCulture);
}
}
/// <summary> /// <summary>
/// Looks up a localized string similar to Berth. /// Looks up a localized string similar to Berth.
/// </summary> /// </summary>
@ -560,6 +579,15 @@ namespace BreCalClient.Resources {
} }
} }
/// <summary>
/// Looks up a localized string similar to Date.
/// </summary>
public static string textDate {
get {
return ResourceManager.GetString("textDate", resourceCulture);
}
}
/// <summary> /// <summary>
/// Looks up a localized string similar to Delete. /// Looks up a localized string similar to Delete.
/// </summary> /// </summary>
@ -596,6 +624,15 @@ namespace BreCalClient.Resources {
} }
} }
/// <summary>
/// Looks up a localized string similar to Draft.
/// </summary>
public static string textDraftNoUnit {
get {
return ResourceManager.GetString("textDraftNoUnit", resourceCulture);
}
}
/// <summary> /// <summary>
/// Looks up a localized string similar to Edit. /// Looks up a localized string similar to Edit.
/// </summary> /// </summary>
@ -776,6 +813,15 @@ namespace BreCalClient.Resources {
} }
} }
/// <summary>
/// Looks up a localized string similar to Information successfully updated.
/// </summary>
public static string textInformationUpdated {
get {
return ResourceManager.GetString("textInformationUpdated", resourceCulture);
}
}
/// <summary> /// <summary>
/// Looks up a localized string similar to Interval. /// Looks up a localized string similar to Interval.
/// </summary> /// </summary>
@ -839,6 +885,15 @@ namespace BreCalClient.Resources {
} }
} }
/// <summary>
/// Looks up a localized string similar to The participant has not provided any info.
/// </summary>
public static string textMissingData {
get {
return ResourceManager.GetString("textMissingData", resourceCulture);
}
}
/// <summary> /// <summary>
/// Looks up a localized string similar to Moored in lock. /// Looks up a localized string similar to Moored in lock.
/// </summary> /// </summary>
@ -875,6 +930,51 @@ namespace BreCalClient.Resources {
} }
} }
/// <summary>
/// Looks up a localized string similar to Relevant next 24hrs.
/// </summary>
public static string textNext24h {
get {
return ResourceManager.GetString("textNext24h", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Notifications.
/// </summary>
public static string textNotifications {
get {
return ResourceManager.GetString("textNotifications", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Notify by e-mail.
/// </summary>
public static string textNotifyEmail {
get {
return ResourceManager.GetString("textNotifyEmail", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Notify on.
/// </summary>
public static string textNotifyOn {
get {
return ResourceManager.GetString("textNotifyOn", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Notify by push notification in app.
/// </summary>
public static string textNotifyPush {
get {
return ResourceManager.GetString("textNotifyPush", resourceCulture);
}
}
/// <summary> /// <summary>
/// Looks up a localized string similar to Not rotated. /// Looks up a localized string similar to Not rotated.
/// </summary> /// </summary>
@ -1055,6 +1155,15 @@ namespace BreCalClient.Resources {
} }
} }
/// <summary>
/// Looks up a localized string similar to Position.
/// </summary>
public static string textPosition {
get {
return ResourceManager.GetString("textPosition", resourceCulture);
}
}
/// <summary> /// <summary>
/// Looks up a localized string similar to Rain sensitive cargo. /// Looks up a localized string similar to Rain sensitive cargo.
/// </summary> /// </summary>
@ -1172,6 +1281,24 @@ namespace BreCalClient.Resources {
} }
} }
/// <summary>
/// Looks up a localized string similar to Shipcall.
/// </summary>
public static string textShipcall {
get {
return ResourceManager.GetString("textShipcall", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to The shipcall was cancelled.
/// </summary>
public static string textShipcallCancelled {
get {
return ResourceManager.GetString("textShipcallCancelled", resourceCulture);
}
}
/// <summary> /// <summary>
/// Looks up a localized string similar to Ship length. /// Looks up a localized string similar to Ship length.
/// </summary> /// </summary>
@ -1208,6 +1335,15 @@ namespace BreCalClient.Resources {
} }
} }
/// <summary>
/// Looks up a localized string similar to Show notificiations.
/// </summary>
public static string textShowNotifications {
get {
return ResourceManager.GetString("textShowNotifications", resourceCulture);
}
}
/// <summary> /// <summary>
/// Looks up a localized string similar to Sort order. /// Looks up a localized string similar to Sort order.
/// </summary> /// </summary>
@ -1271,6 +1407,24 @@ namespace BreCalClient.Resources {
} }
} }
/// <summary>
/// Looks up a localized string similar to Participants disagree on times.
/// </summary>
public static string textTimeConflict {
get {
return ResourceManager.GetString("textTimeConflict", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Times conflict resolved.
/// </summary>
public static string textTimeConflictResolved {
get {
return ResourceManager.GetString("textTimeConflictResolved", resourceCulture);
}
}
/// <summary> /// <summary>
/// Looks up a localized string similar to Timestamp. /// Looks up a localized string similar to Timestamp.
/// </summary> /// </summary>
@ -1361,6 +1515,15 @@ namespace BreCalClient.Resources {
} }
} }
/// <summary>
/// Looks up a localized string similar to Participant unassigned from shipcall.
/// </summary>
public static string textUnassigned {
get {
return ResourceManager.GetString("textUnassigned", resourceCulture);
}
}
/// <summary> /// <summary>
/// Looks up a localized string similar to User login. /// Looks up a localized string similar to User login.
/// </summary> /// </summary>

View File

@ -235,6 +235,9 @@
<data name="textDraft" xml:space="preserve"> <data name="textDraft" xml:space="preserve">
<value>Tiefgang (m)</value> <value>Tiefgang (m)</value>
</data> </data>
<data name="textDraftNoUnit" xml:space="preserve">
<value>Tiefgang</value>
</data>
<data name="textEdit" xml:space="preserve"> <data name="textEdit" xml:space="preserve">
<value>Bearbeiten</value> <value>Bearbeiten</value>
</data> </data>
@ -286,6 +289,9 @@
<data name="textFrom" xml:space="preserve"> <data name="textFrom" xml:space="preserve">
<value>von</value> <value>von</value>
</data> </data>
<data name="textHarbour" xml:space="preserve">
<value>Hafen</value>
</data>
<data name="textIncoming" xml:space="preserve"> <data name="textIncoming" xml:space="preserve">
<value>Einkommend</value> <value>Einkommend</value>
</data> </data>
@ -547,55 +553,52 @@
<data name="arrow_up_green" type="System.Resources.ResXFileRef, System.Windows.Forms"> <data name="arrow_up_green" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>arrow_up_green.png;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> <value>arrow_up_green.png;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</data> </data>
<data name="textShiftingSequence" xml:space="preserve">
<value>Verhol. Nr.</value>
</data>
<data name="textClearAll" xml:space="preserve">
<value>Alle Eintragungen zurücksetzen?</value>
</data>
<data name="textBothTideTimesNecessary" xml:space="preserve">
<value>Beide Tidenzeiten sollten angegeben werden (von - bis)</value>
</data>
<data name="textEndValueBeforeStartValue" xml:space="preserve">
<value>Endzeit liegt vor Startzeit</value>
</data>
<data name="textError" xml:space="preserve">
<value>Error</value>
</data>
<data name="textETAInThePast" xml:space="preserve">
<value>Zeitpunkt ETA liegt in der Vergangenheit</value>
</data>
<data name="textETDInThePast" xml:space="preserve">
<value>Zeitpunkt ETD liegt in der Vergangenheit</value>
</data>
<data name="textLockTimeInThePast" xml:space="preserve">
<value>Schleusenzeit liegt in der Vergangenheit</value>
</data>
<data name="textOperationEndInThePast" xml:space="preserve">
<value>Operation Endzeit liegt in der Vergangenheit</value>
</data>
<data name="textOperationStartInThePast" xml:space="preserve">
<value>Operation Startzeit liegt in der Vergangenheit</value>
</data>
<data name="textTideTimesInThePast" xml:space="preserve">
<value>Tidenzeit liegt in der Vergangenheit</value>
</data>
<data name="textWarning" xml:space="preserve">
<value>Warnung</value>
</data>
<data name="textZoneEntryInThePast" xml:space="preserve">
<value>Zeit Reviereintritt liegt in der Vergangenheit</value>
</data>
<data name="textTidalBothValues" xml:space="preserve">
<value>Für das Tidenfenster müssen beide Zeiten angegeben werden</value>
</data>
<data name="textTooFarInTheFuture" xml:space="preserve">
<value>Eine Zeiteingabe ist zu weit in der Zukunft</value>
</data>
<data name="textStartTimeMissing" xml:space="preserve"> <data name="textStartTimeMissing" xml:space="preserve">
<value>Wenn eine Ende-Zeit angegeben wird, muss auch eine Start-Zeit angegeben werden</value> <value>Wenn eine Ende-Zeit angegeben wird, muss auch eine Start-Zeit angegeben werden</value>
</data> </data>
<data name="textHarbour" xml:space="preserve"> <data name="textInformationUpdated" xml:space="preserve">
<value>Hafen</value> <value>Einstellungen erfolgreich aktualisiert</value>
</data>
<data name="textNotifications" xml:space="preserve">
<value>Benachrichtigungen</value>
</data>
<data name="textNotifyEmail" xml:space="preserve">
<value>E-Mail Benachrichtigung</value>
</data>
<data name="textNotifyPush" xml:space="preserve">
<value>Banner / Push Benachrichtigung in App</value>
</data>
<data name="textAssignment" xml:space="preserve">
<value>Teilnehmer wurde nominiert</value>
</data>
<data name="textDate" xml:space="preserve">
<value>Datum</value>
</data>
<data name="textNext24h" xml:space="preserve">
<value>Relevant für Morgenrunde (24hrs)</value>
</data>
<data name="textShipcall" xml:space="preserve">
<value>Anlauf</value>
</data>
<data name="textShowNotifications" xml:space="preserve">
<value>Benachrichtigungen anzeigen</value>
</data>
<data name="textTimeConflict" xml:space="preserve">
<value>"Ampel"-Regel(n) wurde verletzt</value>
</data>
<data name="textTimeConflictResolved" xml:space="preserve">
<value>Zeitliche Konflikte aufgelöst</value>
</data>
<data name="textUnassigned" xml:space="preserve">
<value>Nominierung des Teilnehmer entfernt</value>
</data>
<data name="textMissingData" xml:space="preserve">
<value>Der Teilnehmer hat keine Daten eingetragen</value>
</data>
<data name="textShipcallCancelled" xml:space="preserve">
<value>Der Anlauf wurde storniert</value>
</data>
<data name="textNotifyOn" xml:space="preserve">
<value>Benachrichtigung bei</value>
</data> </data>
</root> </root>

View File

@ -601,4 +601,58 @@
<data name="textStartTimeMissing" xml:space="preserve"> <data name="textStartTimeMissing" xml:space="preserve">
<value>If an end time is set, a start time is also required</value> <value>If an end time is set, a start time is also required</value>
</data> </data>
<data name="textInformationUpdated" xml:space="preserve">
<value>Information successfully updated</value>
</data>
<data name="textNotifications" xml:space="preserve">
<value>Notifications</value>
</data>
<data name="textNotifyEmail" xml:space="preserve">
<value>Notify by e-mail</value>
</data>
<data name="textNotifyPush" xml:space="preserve">
<value>Notify by push notification in app</value>
</data>
<data name="bell3" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>bell3.png;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</data>
<data name="textAssignment" xml:space="preserve">
<value>Participant assigned to shipcall</value>
</data>
<data name="textDate" xml:space="preserve">
<value>Date</value>
</data>
<data name="textNext24h" xml:space="preserve">
<value>Relevant next 24hrs</value>
</data>
<data name="textShipcall" xml:space="preserve">
<value>Shipcall</value>
</data>
<data name="textShowNotifications" xml:space="preserve">
<value>Show notificiations</value>
</data>
<data name="textTimeConflict" xml:space="preserve">
<value>Participants disagree on times</value>
</data>
<data name="textTimeConflictResolved" xml:space="preserve">
<value>Times conflict resolved</value>
</data>
<data name="textUnassigned" xml:space="preserve">
<value>Participant unassigned from shipcall</value>
</data>
<data name="textMissingData" xml:space="preserve">
<value>The participant has not provided any info</value>
</data>
<data name="textShipcallCancelled" xml:space="preserve">
<value>The shipcall was cancelled</value>
</data>
<data name="textNotifyOn" xml:space="preserve">
<value>Notify on</value>
</data>
<data name="textPosition" xml:space="preserve">
<value>Position</value>
</data>
<data name="textDraftNoUnit" xml:space="preserve">
<value>Draft</value>
</data>
</root> </root>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -98,7 +98,7 @@ namespace BreCalClient
private async void DataGridShips_CreateRequested() private async void DataGridShips_CreateRequested()
{ {
ShipModel shipModel = new((ShipModel.LastEditShip != null) ? ShipModel.LastEditShip : new Ship()); ShipModel shipModel = new(ShipModel.LastEditShip ?? new Ship());
EditShipDialog esd = new() EditShipDialog esd = new()
{ {

View File

@ -5,7 +5,7 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:p = "clr-namespace:BreCalClient.Resources" xmlns:p = "clr-namespace:BreCalClient.Resources"
xmlns:sets="clr-namespace:BreCalClient.Properties" xmlns:sets="clr-namespace:BreCalClient.Properties"
xmlns:db="clr-namespace:BreCalClient;assembly=BreCalDevelClient" xmlns:db="clr-namespace:BreCalClient;assembly=BreCalTestClient"
mc:Ignorable="d" mc:Ignorable="d"
d:DesignHeight="135" d:DesignWidth="800"> d:DesignHeight="135" d:DesignWidth="800">
<Border BorderBrush="LightGray" Margin="1" BorderThickness="1"> <Border BorderBrush="LightGray" Margin="1" BorderThickness="1">
@ -29,7 +29,6 @@
<RowDefinition Height=".125*"/> <RowDefinition Height=".125*"/>
<RowDefinition Height=".125*"/> <RowDefinition Height=".125*"/>
<RowDefinition Height=".125*"/> <RowDefinition Height=".125*"/>
<RowDefinition Height=".125*"/> <RowDefinition Height=".125*"/>
<RowDefinition Height=".125*"/> <RowDefinition Height=".125*"/>
<RowDefinition Height=".125*"/> <RowDefinition Height=".125*"/>
@ -64,17 +63,17 @@
<TextBlock x:Name="textBlockIMO" Padding="0" FontWeight="DemiBold" /> <TextBlock x:Name="textBlockIMO" Padding="0" FontWeight="DemiBold" />
</Viewbox> </Viewbox>
<Viewbox Grid.Row="2" Grid.Column="0" HorizontalAlignment="Left"> <Viewbox Grid.Row="2" Grid.Column="0" HorizontalAlignment="Left">
<TextBlock Text="{x:Static p:Resources.textCallsign}" />
</Viewbox>
<Viewbox Grid.Row="2" Grid.Column="1" HorizontalAlignment="Left">
<TextBlock x:Name="textBlockCallsign" Padding="0"/>
</Viewbox>
<Viewbox Grid.Row="3" Grid.Column="0" HorizontalAlignment="Left">
<TextBlock Text="{x:Static p:Resources.textLengthWidth}" Padding="0" /> <TextBlock Text="{x:Static p:Resources.textLengthWidth}" Padding="0" />
</Viewbox> </Viewbox>
<Viewbox Grid.Row="3" Grid.Column="1" HorizontalAlignment="Left"> <Viewbox Grid.Row="2" Grid.Column="1" HorizontalAlignment="Left">
<TextBlock x:Name="textBlockLengthWidth" Padding="0"/> <TextBlock x:Name="textBlockLengthWidth" Padding="0"/>
</Viewbox> </Viewbox>
<Viewbox Grid.Row="3" Grid.Column="0" HorizontalAlignment="Left">
<TextBlock Text="{x:Static p:Resources.textDraftNoUnit}" />
</Viewbox>
<Viewbox Grid.Row="3" Grid.Column="1" HorizontalAlignment="Left">
<TextBlock x:Name="textBlockDraft" Padding="0"/>
</Viewbox>
<Viewbox Grid.Row="4" Grid.Column="0" HorizontalAlignment="Left"> <Viewbox Grid.Row="4" Grid.Column="0" HorizontalAlignment="Left">
<TextBlock Text="ETA" x:Name="labelETA"/> <TextBlock Text="ETA" x:Name="labelETA"/>
</Viewbox> </Viewbox>
@ -90,7 +89,6 @@
<Viewbox Grid.Row="6" Grid.Column="0" Grid.ColumnSpan="2" HorizontalAlignment="Left"> <Viewbox Grid.Row="6" Grid.Column="0" Grid.ColumnSpan="2" HorizontalAlignment="Left">
<TextBlock x:Name="textBlockHarbour" Padding="0" FontWeight="DemiBold" /> <TextBlock x:Name="textBlockHarbour" Padding="0" FontWeight="DemiBold" />
</Viewbox> </Viewbox>
</Grid> </Grid>
<Grid Grid.Row="0" Grid.Column="1"> <Grid Grid.Row="0" Grid.Column="1">
<Grid.RowDefinitions> <Grid.RowDefinitions>

View File

@ -215,13 +215,13 @@ namespace BreCalClient
switch (this.ShipcallControlModel?.Shipcall?.Type) switch (this.ShipcallControlModel?.Shipcall?.Type)
{ {
case ShipcallType.Arrival: // incoming 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; break;
case ShipcallType.Departure: // outgoing 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; break;
case ShipcallType.Shifting: // shifting 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; break;
default: default:
break; break;
@ -230,13 +230,13 @@ namespace BreCalClient
switch(this.ShipcallControlModel?.LightMode) switch(this.ShipcallControlModel?.LightMode)
{ {
case EvaluationType.Green: 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; break;
case EvaluationType.Yellow: 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; break;
case EvaluationType.Red: 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; break;
default: default:
break; break;
@ -269,7 +269,7 @@ namespace BreCalClient
this.imageEvaluation.ToolTip = null; this.imageEvaluation.ToolTip = null;
this.textBlockBerth.Text = this.ShipcallControlModel?.GetBerthText(null); this.textBlockBerth.Text = this.ShipcallControlModel?.GetBerthText(null);
this.textBlockCallsign.Text = this.ShipcallControlModel?.Ship?.Callsign; this.textBlockDraft.Text = (this.ShipcallControlModel?.Shipcall?.Draft != null) ? $"{this.ShipcallControlModel?.Shipcall?.Draft.Value.ToString("N2")} m" : "-";
this.textBlockETA.Text = this.ShipcallControlModel?.GetETAETD(true); this.textBlockETA.Text = this.ShipcallControlModel?.GetETAETD(true);
this.textBlockIMO.Text = this.ShipcallControlModel?.Ship?.Imo.ToString(); this.textBlockIMO.Text = this.ShipcallControlModel?.Ship?.Imo.ToString();

View File

@ -0,0 +1,73 @@
// Copyright (c) 2024- schick Informatik
// Description:
//
using System;
using System.ComponentModel;
using System.Windows;
using ToastNotifications;
using ToastNotifications.Core;
using ToastNotifications.Lifetime;
using ToastNotifications.Lifetime.Clear;
using ToastNotifications.Messages;
using ToastNotifications.Position;
namespace BreCalClient
{
internal class ToastViewModel : INotifyPropertyChanged
{
private readonly Notifier _notifier;
public ToastViewModel()
{
_notifier = new Notifier(cfg =>
{
cfg.PositionProvider = new WindowPositionProvider(
parentWindow: Application.Current.MainWindow,
corner: Corner.BottomRight,
offsetX: 25,
offsetY: 100);
cfg.LifetimeSupervisor = new TimeAndCountBasedLifetimeSupervisor(
notificationLifetime: TimeSpan.FromSeconds(30),
maximumNotificationCount: MaximumNotificationCount.FromCount(6));
cfg.Dispatcher = Application.Current.Dispatcher;
cfg.DisplayOptions.TopMost = false;
cfg.DisplayOptions.Width = 250;
});
_notifier.ClearMessages(new ClearAll());
}
public void OnUnloaded()
{
_notifier.Dispose();
}
public void ShowAppNotification(string message, MessageOptions options)
{
_notifier?.ShowAppNotification(message, options);
}
public void ShowAppNotification(string message)
{
_notifier?.ShowAppNotification(message);
}
public event PropertyChangedEventHandler? PropertyChanged;
protected virtual void OnPropertyChanged(string? propertyName = null)
{
var handler = PropertyChanged;
handler?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
public void ClearAll()
{
_notifier.ClearMessages(new ClearAll());
}
}
}

View File

@ -8,7 +8,7 @@
<applicationSettings> <applicationSettings>
<RoleEditor.Properties.Settings> <RoleEditor.Properties.Settings>
<setting name="ConnectionString" serializeAs="String"> <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> </setting>
</RoleEditor.Properties.Settings> </RoleEditor.Properties.Settings>
</applicationSettings> </applicationSettings>

View File

@ -59,7 +59,7 @@
<Label Content="Street" Grid.Row="1" Grid.Column="1" HorizontalAlignment="Right"/> <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="Postal code" Grid.Row="2" Grid.Column="1" HorizontalAlignment="Right"/>
<Label Content="City" Grid.Row="3" 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="Type" Grid.Row="5" Grid.Column="1" HorizontalAlignment="Right"/>
<Label Content="Created" Grid.Row="6" 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"/> <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="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="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" /> <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" /> <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" /> <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"> <StackPanel Orientation="Horizontal" Grid.Row="7" Grid.Column="0">
@ -167,7 +167,7 @@
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<ListBox x:Name="listBoxUser" Margin="2" Grid.RowSpan="9" SelectionChanged="listBoxUser_SelectionChanged"> <ListBox x:Name="listBoxUser" Margin="2" Grid.RowSpan="9" SelectionChanged="listBoxUser_SelectionChanged">
<ListBox.ContextMenu> <ListBox.ContextMenu>
<ContextMenu> <ContextMenu Name="contextMenuUser">
<MenuItem x:Name="menuItemNewUser" Header="New.." Click="menuItemNewUser_Click"> <MenuItem x:Name="menuItemNewUser" Header="New.." Click="menuItemNewUser_Click">
<MenuItem.Icon> <MenuItem.Icon>
<Image Source="Resources/add.png" /> <Image Source="Resources/add.png" />

View File

@ -61,6 +61,8 @@ namespace RoleEditor
// load all participants // load all participants
List<Participant> participants = await Participant.LoadAll(_dbManager); List<Participant> participants = await Participant.LoadAll(_dbManager);
participants.Sort((x, y) => string.Compare(x.Name, y.Name));
foreach (Participant p in participants) foreach (Participant p in participants)
{ {
_participants.Add(p); _participants.Add(p);
@ -465,8 +467,8 @@ namespace RoleEditor
this.textBoxParticipantStreet.Text = (p != null) ? p.Street : string.Empty; this.textBoxParticipantStreet.Text = (p != null) ? p.Street : string.Empty;
this.textBoxParticipantPostalCode.Text = (p != null) ? p.PostalCode : string.Empty; this.textBoxParticipantPostalCode.Text = (p != null) ? p.PostalCode : string.Empty;
this.textBoxParticipantCity.Text = (p != null) ? p.City : string.Empty; this.textBoxParticipantCity.Text = (p != null) ? p.City : string.Empty;
// this.checkboxParticipantActive.Checked = (p != null) ? p.
this.textBoxParticipantCreated.Text = (p != null) ? p.Created.ToString() : 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.textBoxParticipantModified.Text = (p != null) ? p.Modified.ToString() : string.Empty;
this.checkBoxParticipantAllowBSMD.IsChecked = (p != null) ? p.IsFlagSet(Participant.ParticipantFlags.ALLOW_BSMD) : null; this.checkBoxParticipantAllowBSMD.IsChecked = (p != null) ? p.IsFlagSet(Participant.ParticipantFlags.ALLOW_BSMD) : null;
this.comboBoxParticipantType.SelectedItems.Clear(); this.comboBoxParticipantType.SelectedItems.Clear();
@ -510,6 +512,11 @@ namespace RoleEditor
_assignedPorts.Add(pa); _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) private async void listBoxRoles_SelectionChanged(object sender, SelectionChangedEventArgs e)
@ -594,7 +601,7 @@ namespace RoleEditor
if(this.listBoxParticipant.SelectedItem is Participant p) if(this.listBoxParticipant.SelectedItem is Participant p)
{ {
await p.Delete(_dbManager); await p.Delete(_dbManager);
this._participants.Remove(p); p.Deleted = true;
} }
} }
catch (Exception ex) catch (Exception ex)
@ -628,6 +635,7 @@ namespace RoleEditor
{ {
if (this.listBoxUser.SelectedItem is User u) if (this.listBoxUser.SelectedItem is User u)
{ {
await u.ExecuteNonQuery(_dbManager); // extra history delete happens here
await u.Delete(_dbManager); await u.Delete(_dbManager);
this._users.Remove(u); this._users.Remove(u);
} }

View File

@ -26,7 +26,7 @@ namespace RoleEditor.Properties {
[global::System.Configuration.ApplicationScopedSettingAttribute()] [global::System.Configuration.ApplicationScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Configuration.DefaultSettingValueAttribute("Server=localhost;User ID=ds;Password=HalloWach_2323XXL!!;Database=bremen_calling_" + [global::System.Configuration.DefaultSettingValueAttribute("Server=localhost;User ID=ds;Password=HalloWach_2323XXL!!;Database=bremen_calling_" +
"devel;Port=33306")] "test;Port=33306")]
public string ConnectionString { public string ConnectionString {
get { get {
return ((string)(this["ConnectionString"])); return ((string)(this["ConnectionString"]));

View File

@ -3,7 +3,7 @@
<Profiles /> <Profiles />
<Settings> <Settings>
<Setting Name="ConnectionString" Type="System.String" Scope="Application"> <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> </Setting>
</Settings> </Settings>
</SettingsFile> </SettingsFile>

View File

@ -2,12 +2,12 @@
<PropertyGroup> <PropertyGroup>
<OutputType>WinExe</OutputType> <OutputType>WinExe</OutputType>
<TargetFramework>net6.0-windows</TargetFramework> <TargetFramework>net8.0-windows7.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<UseWPF>true</UseWPF> <UseWPF>true</UseWPF>
<ApplicationIcon>Resources\lock_preferences.ico</ApplicationIcon> <ApplicationIcon>Resources\lock_preferences.ico</ApplicationIcon>
<FileVersion>1.6.0.4</FileVersion> <FileVersion>1.8.0.0</FileVersion>
<AssemblyVersion>1.6.0.4</AssemblyVersion> <AssemblyVersion>1.8.0.0</AssemblyVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@ -30,8 +30,8 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" /> <PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="ExcelDataReader" Version="3.7.0-develop00385" /> <PackageReference Include="ExcelDataReader" Version="3.8.0" />
<PackageReference Include="Extended.Wpf.Toolkit" Version="4.5.0" /> <PackageReference Include="Extended.Wpf.Toolkit" Version="5.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -13,6 +13,12 @@
"SccProvider" = "8:" "SccProvider" = "8:"
"Hierarchy" "Hierarchy"
{ {
"Entry"
{
"MsmKey" = "8:_1E7663DCE02A4D848349229A724E961A"
"OwnerKey" = "8:_UNDEFINED"
"MsmSig" = "8:_UNDEFINED"
}
"Entry" "Entry"
{ {
"MsmKey" = "8:_3E48B6E716164CC1826E094025517B3F" "MsmKey" = "8:_3E48B6E716164CC1826E094025517B3F"
@ -25,6 +31,24 @@
"OwnerKey" = "8:_UNDEFINED" "OwnerKey" = "8:_UNDEFINED"
"MsmSig" = "8:_UNDEFINED" "MsmSig" = "8:_UNDEFINED"
} }
"Entry"
{
"MsmKey" = "8:_CD20A468610C42B89F66B4D3367A5A6A"
"OwnerKey" = "8:_UNDEFINED"
"MsmSig" = "8:_UNDEFINED"
}
"Entry"
{
"MsmKey" = "8:_UNDEFINED"
"OwnerKey" = "8:_CD20A468610C42B89F66B4D3367A5A6A"
"MsmSig" = "8:_UNDEFINED"
}
"Entry"
{
"MsmKey" = "8:_UNDEFINED"
"OwnerKey" = "8:_1E7663DCE02A4D848349229A724E961A"
"MsmSig" = "8:_UNDEFINED"
}
} }
"Configurations" "Configurations"
{ {
@ -76,6 +100,14 @@
{ {
"LaunchCondition" "LaunchCondition"
{ {
"{A06ECF26-33A3-4562-8140-9B0E340D4F24}:_3415D375792A4611BF998D78F56CD22C"
{
"Name" = "8:.NET Framework"
"Message" = "8:[VSDNETMSG]"
"FrameworkVersion" = "8:.NETFramework,Version=v4.7.2"
"AllowLaterVersions" = "11:FALSE"
"InstallUrl" = "8:http://go.microsoft.com/fwlink/?LinkId=863262"
}
"{A06ECF26-33A3-4562-8140-9B0E340D4F24}:_7C5ED856EDF94532A041DBACD5D5C09E" "{A06ECF26-33A3-4562-8140-9B0E340D4F24}:_7C5ED856EDF94532A041DBACD5D5C09E"
{ {
"Name" = "8:.NET Core" "Name" = "8:.NET Core"
@ -90,6 +122,37 @@
} }
"File" "File"
{ {
"{9F6F8455-1EF1-4B85-886A-4223BCC8E7F7}:_1E7663DCE02A4D848349229A724E961A"
{
"AssemblyRegister" = "3:1"
"AssemblyIsInGAC" = "11:FALSE"
"AssemblyAsmDisplayName" = "8:Xceed.Wpf.AvalonDock.resources, Version=4.6.0.0, Culture=de, PublicKeyToken=3e4669d2f30244f4, processorArchitecture=MSIL"
"ScatterAssemblies"
{
"_1E7663DCE02A4D848349229A724E961A"
{
"Name" = "8:Xceed.Wpf.AvalonDock.resources.dll"
"Attributes" = "3:512"
}
}
"SourcePath" = "8:..\\BreCalClient\\bin\\Debug\\net6.0-windows\\de\\Xceed.Wpf.AvalonDock.resources.dll"
"TargetName" = "8:"
"Tag" = "8:"
"Folder" = "8:_F64284776BC0480CBF6C33B1FE00C374"
"Condition" = "8:"
"Transitive" = "11:FALSE"
"Vital" = "11:TRUE"
"ReadOnly" = "11:FALSE"
"Hidden" = "11:FALSE"
"System" = "11:FALSE"
"Permanent" = "11:FALSE"
"SharedLegacy" = "11:FALSE"
"PackageAs" = "3:1"
"Register" = "3:1"
"Exclude" = "11:FALSE"
"IsDependency" = "11:FALSE"
"IsolateTo" = "8:"
}
"{1FB2D0AE-D3B9-43D4-B9DD-F88EC61E35DE}:_4EE484EAA4A246CBBB283030A6054BC0" "{1FB2D0AE-D3B9-43D4-B9DD-F88EC61E35DE}:_4EE484EAA4A246CBBB283030A6054BC0"
{ {
"SourcePath" = "8:..\\BreCalClient\\Resources\\containership.ico" "SourcePath" = "8:..\\BreCalClient\\Resources\\containership.ico"
@ -110,6 +173,37 @@
"IsDependency" = "11:FALSE" "IsDependency" = "11:FALSE"
"IsolateTo" = "8:" "IsolateTo" = "8:"
} }
"{9F6F8455-1EF1-4B85-886A-4223BCC8E7F7}:_CD20A468610C42B89F66B4D3367A5A6A"
{
"AssemblyRegister" = "3:1"
"AssemblyIsInGAC" = "11:FALSE"
"AssemblyAsmDisplayName" = "8:BreCalClient.resources, Version=1.6.2.0, Culture=de, PublicKeyToken=9ce7b6b354e08ac9, processorArchitecture=MSIL"
"ScatterAssemblies"
{
"_CD20A468610C42B89F66B4D3367A5A6A"
{
"Name" = "8:BreCalClient.resources.dll"
"Attributes" = "3:512"
}
}
"SourcePath" = "8:..\\BreCalClient\\bin\\Debug\\net6.0-windows\\de\\BreCalClient.resources.dll"
"TargetName" = "8:"
"Tag" = "8:"
"Folder" = "8:_F64284776BC0480CBF6C33B1FE00C374"
"Condition" = "8:"
"Transitive" = "11:FALSE"
"Vital" = "11:TRUE"
"ReadOnly" = "11:FALSE"
"Hidden" = "11:FALSE"
"System" = "11:FALSE"
"Permanent" = "11:FALSE"
"SharedLegacy" = "11:FALSE"
"PackageAs" = "3:1"
"Register" = "3:1"
"Exclude" = "11:FALSE"
"IsDependency" = "11:FALSE"
"IsolateTo" = "8:"
}
} }
"FileType" "FileType"
{ {
@ -137,6 +231,17 @@
"Property" = "8:TARGETDIR" "Property" = "8:TARGETDIR"
"Folders" "Folders"
{ {
"{9EF0B969-E518-4E46-987F-47570745A589}:_F64284776BC0480CBF6C33B1FE00C374"
{
"Name" = "8:de"
"AlwaysCreate" = "11:FALSE"
"Condition" = "8:"
"Transitive" = "11:FALSE"
"Property" = "8:_319F0FD8E72443BFA3AE5E1F3F42523B"
"Folders"
{
}
}
} }
} }
"{1525181F-901A-416C-8A58-119130FE478E}:_8BBC7FE2F38E4B41A71D26CCED7D0BCB" "{1525181F-901A-416C-8A58-119130FE478E}:_8BBC7FE2F38E4B41A71D26CCED7D0BCB"

View File

@ -1,8 +1,4 @@
using System; using System.Data;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace brecal.model namespace brecal.model
@ -42,6 +38,11 @@ namespace brecal.model
/// <param name="cmd">CMD created by DB manager</param> /// <param name="cmd">CMD created by DB manager</param>
public abstract void SetDelete(IDbCommand cmd); public abstract void SetDelete(IDbCommand cmd);
public virtual void SetNonQuery(IDbCommand cmd)
{
// default: do nothing
}
/// <summary> /// <summary>
/// Each database entity must be able to save itself to the database /// Each database entity must be able to save itself to the database
/// </summary> /// </summary>
@ -65,5 +66,10 @@ namespace brecal.model
await manager.ExecuteNonQuery(this.SetDelete); await manager.ExecuteNonQuery(this.SetDelete);
} }
public async Task ExecuteNonQuery(IDBManager manager)
{
await manager.ExecuteNonQuery(this.SetNonQuery);
}
} }
} }

View File

@ -55,6 +55,8 @@ namespace brecal.model
public uint Flags { get; set; } public uint Flags { get; set; }
public bool Deleted { get; set; } = false;
#endregion #endregion
#region public static methods #region public static methods
@ -83,6 +85,7 @@ namespace brecal.model
if (!reader.IsDBNull(6)) p.Flags = (uint)reader.GetInt32(6); if (!reader.IsDBNull(6)) p.Flags = (uint)reader.GetInt32(6);
if (!reader.IsDBNull(7)) p.Created = reader.GetDateTime(7); if (!reader.IsDBNull(7)) p.Created = reader.GetDateTime(7);
if (!reader.IsDBNull(8)) p.Modified = reader.GetDateTime(8); if (!reader.IsDBNull(8)) p.Modified = reader.GetDateTime(8);
if (!reader.IsDBNull(9)) p.Deleted = reader.GetBoolean(9);
result.Add(p); result.Add(p);
} }
return result; return result;
@ -90,7 +93,7 @@ namespace brecal.model
public static void SetLoadQuery(IDbCommand cmd, params object?[] list) 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 #endregion
@ -111,7 +114,7 @@ namespace brecal.model
public override void SetDelete(IDbCommand cmd) 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(); IDataParameter idParam = cmd.CreateParameter();
idParam.ParameterName = "ID"; idParam.ParameterName = "ID";

View File

@ -101,6 +101,16 @@ namespace brecal.model
return this.Username ?? $"{base.Id} - {this.GetType().Name}"; 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 #endregion
#region private methods #region private methods

View File

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net6.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>

View File

@ -1,13 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net6.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="MySqlConnector" Version="2.3.0-beta.1" /> <PackageReference Include="MySqlConnector" Version="2.4.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -1,6 +1,7 @@
from flask import Flask from flask import Flask
import os import os
import sys
import logging import logging
from . import local_db 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 from BreCal.services.schedule_routines import setup_schedule, run_schedule_permanently_in_background
def create_app(test_config=None, instance_path=None): def create_app(test_config=None, instance_path=None):
app = Flask(__name__, instance_relative_config=True) app = Flask(__name__, instance_relative_config=True)
app.config.from_mapping( app.config.from_mapping(
SECRET_KEY='dev' SECRET_KEY='dev'
@ -48,6 +48,8 @@ def create_app(test_config=None, instance_path=None):
if instance_path is not None: if instance_path is not None:
app.instance_path = instance_path app.instance_path = instance_path
elif app.config.get("INSTANCE_PATH"):
app.instance_path = app.config["INSTANCE_PATH"]
try: try:
import os import os
@ -69,13 +71,23 @@ def create_app(test_config=None, instance_path=None):
app.register_blueprint(history.bp) app.register_blueprint(history.bp)
app.register_blueprint(ports.bp) app.register_blueprint(ports.bp)
logging.basicConfig(filename='brecaldevel.log', level=logging.DEBUG, format='%(asctime)s | %(name)s | %(levelname)s | %(message)s') log_level = getattr(logging, app.config.get("LOG_LEVEL", "DEBUG"))
local_db.initPool(os.path.dirname(app.instance_path)) 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') logging.info('App started')
# Setup Routine jobs (e.g., reevaluation of shipcalls) # Setup Routine jobs (e.g., reevaluation of shipcalls)
setup_schedule(update_shipcalls_interval_in_minutes=60) setup_schedule(update_shipcalls_interval_in_minutes=app.config.get("SCHEDULE_UPDATE_SHIPCALLS_MINUTES", 60))
run_schedule_permanently_in_background(latency=30) run_schedule_permanently_in_background(latency=app.config.get("SCHEDULE_BACKGROUND_LATENCY_SECONDS", 30))
logging.info('Routine Jobs are defined.') logging.info('Routine Jobs are defined.')
return app return app

View File

@ -12,12 +12,18 @@ bp = Blueprint('notifications', __name__)
@auth_guard() # no restriction by role @auth_guard() # no restriction by role
def GetNotifications(): def GetNotifications():
try: try:
if 'shipcall_id' in request.args: if 'Authorization' in request.headers:
options = {} token = request.headers.get('Authorization')
options["shipcall_id"] = request.args.get("shipcall_id") participant_id = None
return impl.notifications.GetNotifications(options) 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: else:
return create_dynamic_exception_response(ex=None, status_code=400, message="missing argument: shipcall_id") return create_dynamic_exception_response(ex=None, status_code=403, message="not authenticated")
except Exception as ex: except Exception as ex:
return create_dynamic_exception_response(ex=ex, status_code=400) return create_dynamic_exception_response(ex=ex, status_code=400)

View File

@ -89,7 +89,7 @@ def PutShipcalls():
# validate the PUT shipcall data and the user's authority # validate the PUT shipcall data and the user's authority
InputValidationShipcall.evaluate_put_data(user_data, loadedModel, content) InputValidationShipcall.evaluate_put_data(user_data, loadedModel, content)
return impl.shipcalls.PutShipcalls(loadedModel) return impl.shipcalls.PutShipcalls(loadedModel, content)
except ValidationError as ex: except ValidationError as ex:
return create_validation_error_response(ex=ex, status_code=400) return create_validation_error_response(ex=ex, status_code=400)

View File

@ -72,7 +72,7 @@ def PutTimes():
# validate the request # validate the request
InputValidationTimes.evaluate_put_data(user_data, loadedModel, content) InputValidationTimes.evaluate_put_data(user_data, loadedModel, content)
return impl.times.PutTimes(loadedModel) return impl.times.PutTimes(loadedModel, content)
except ValidationError as ex: except ValidationError as ex:
return create_validation_error_response(ex=ex, status_code=400) return create_validation_error_response(ex=ex, status_code=400)

View File

@ -2,18 +2,22 @@ from flask import Blueprint, request
from ..schemas import model from ..schemas import model
from .. import impl from .. import impl
from ..services.auth_guard import auth_guard from ..services.auth_guard import auth_guard
import json
import logging
from marshmallow import ValidationError from marshmallow import ValidationError
from . import verify_if_request_is_json from . import verify_if_request_is_json
from BreCal.validators.validation_error import create_dynamic_exception_response, create_validation_error_response 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 = Blueprint('user', __name__)
@bp.route('/user', methods=['put']) @bp.route('/user', methods=['put'])
@auth_guard() # no restriction by role @auth_guard() # no restriction by role
def PutUser(): def PutUser():
content = None
try: try:
verify_if_request_is_json(request) verify_if_request_is_json(request)
@ -22,9 +26,11 @@ def PutUser():
return impl.user.PutUser(loadedModel) return impl.user.PutUser(loadedModel)
except ValidationError as ex: 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) return create_validation_error_response(ex=ex, status_code=400)
except Exception as ex: 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") return create_dynamic_exception_response(ex=None, status_code=400, message="bad format")

View File

@ -48,13 +48,6 @@ class PierSide(IntEnum):
PORTSIDE = 0 # Port/Backbord PORTSIDE = 0 # Port/Backbord
STARBOARD_SIDE = 1 # Starboard / Steuerbord STARBOARD_SIDE = 1 # Starboard / Steuerbord
class NotificationType(IntFlag):
"""determines the method by which a notification is distributed to users. Flagging allows selecting multiple notification types."""
UNDEFINED = 0
EMAIL = 1
POPUP = 2
MESSENGER = 4
class ParticipantFlag(IntFlag): class ParticipantFlag(IntFlag):
""" """
| 1 | If this flag is set on a shipcall record with participant type Agency (8), | 1 | If this flag is set on a shipcall record with participant type Agency (8),

View File

@ -237,7 +237,7 @@ class SQLQuery():
@staticmethod @staticmethod
def get_user()->str: def get_user()->str:
query = "SELECT id, participant_id, first_name, last_name, user_name, user_email, user_phone, password_hash, " +\ query = "SELECT id, participant_id, first_name, last_name, user_name, user_email, user_phone, password_hash, " +\
"api_key, notify_email, notify_whatsapp, notify_signal, notify_popup, created, modified FROM user " +\ "api_key, notify_email, notify_whatsapp, notify_signal, notify_popup, notify_event, created, modified FROM user " +\
"WHERE user_name = ?username? OR user_email = ?username?" "WHERE user_name = ?username? OR user_email = ?username?"
return query return query
@ -279,6 +279,13 @@ class SQLQuery():
"ORDER BY eta") "ORDER BY eta")
return query return query
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 (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
@staticmethod @staticmethod
def get_ships()->str: def get_ships()->str:
query = "SELECT id, name, imo, callsign, participant_id, length, width, is_tug, bollard_pull, eni, created, modified, deleted FROM ship ORDER BY name" query = "SELECT id, name, imo, callsign, participant_id, length, width, is_tug, bollard_pull, eni, created, modified, deleted FROM ship ORDER BY name"

View File

@ -1,4 +1,5 @@
from BreCal.database.sql_handler import execute_sql_query_standalone from BreCal.database.sql_handler import execute_sql_query_standalone
import datetime import datetime
def get_user_data_for_id(user_id:int, expiration_time:int=90): def get_user_data_for_id(user_id:int, expiration_time:int=90):
@ -34,3 +35,9 @@ def get_port_ids_for_participant_id(participant_id:int):
query = "SELECT port_id FROM participant_port_map where participant_id = ?participant_id?" query = "SELECT port_id FROM participant_port_map where participant_id = ?participant_id?"
pdata = execute_sql_query_standalone(query=query, param={"participant_id":participant_id}) pdata = execute_sql_query_standalone(query=query, param={"participant_id":participant_id})
return pdata return pdata
def get_notification_for_shipcall_and_type(shipcall_id:int, notification_type:int):
"""helper function to load a notification for a shipcall and a specific type"""
query = "SELECT * FROM notification where shipcall_id = ?shipcall_id? and type = ?type?"
pdata = execute_sql_query_standalone(query=query, param={"shipcall_id":shipcall_id, "type":notification_type}, command_type="query")
return pdata

View File

@ -10,6 +10,7 @@ def GetBerths(options):
No parameters, gets all entries No parameters, gets all entries
""" """
pooledConnection = None
try: try:
pooledConnection = local_db.getPoolConnection() pooledConnection = local_db.getPoolConnection()
commands = pydapper.using(pooledConnection) commands = pydapper.using(pooledConnection)

View File

@ -16,6 +16,8 @@ def GetHistory(options):
options["shipcall_id"]: **Id of shipcall**. options["shipcall_id"]: **Id of shipcall**.
""" """
pooledConnection = None
data = []
try: try:
pooledConnection = local_db.getPoolConnection() pooledConnection = local_db.getPoolConnection()
commands = pydapper.using(pooledConnection) commands = pydapper.using(pooledConnection)
@ -26,10 +28,6 @@ def GetHistory(options):
data = commands.query("SELECT id, participant_id, shipcall_id, timestamp, eta, type, operation FROM history WHERE shipcall_id = ?shipcallid?", data = commands.query("SELECT id, participant_id, shipcall_id, timestamp, eta, type, operation FROM history WHERE shipcall_id = ?shipcallid?",
model=History.from_query_row, model=History.from_query_row,
param={"shipcallid" : options["shipcall_id"]}) param={"shipcallid" : options["shipcall_id"]})
pooledConnection.close()
except Exception as ex: except Exception as ex:
pdb.pm() pdb.pm()
logging.error(ex) logging.error(ex)
@ -37,6 +35,9 @@ def GetHistory(options):
result = {} result = {}
result["error_field"] = "call failed" result["error_field"] = "call failed"
return json.dumps("call failed"), 500 return json.dumps("call failed"), 500
finally:
if pooledConnection is not None:
pooledConnection.close()
return json.dumps(data, default=model.obj_dict), 200, {'Content-Type': 'application/json; charset=utf-8'} return json.dumps(data, default=model.obj_dict), 200, {'Content-Type': 'application/json; charset=utf-8'}

View File

@ -6,19 +6,22 @@ import bcrypt
from ..schemas import model from ..schemas import model
from .. import local_db from .. import local_db
from ..services import jwt_handler from ..services import jwt_handler
from BreCal.database.sql_queries import SQLQuery
def GetUser(options): def GetUser(options):
pooledConnection = None
try: try:
if "password" in options and "username" in options: if "password" in options and "username" in options:
hash = bcrypt.hashpw(options["password"].encode('utf-8'), bcrypt.gensalt( 12 )).decode('utf8')
pooledConnection = local_db.getPoolConnection() pooledConnection = local_db.getPoolConnection()
commands = pydapper.using(pooledConnection) commands = pydapper.using(pooledConnection)
# query = SQLQuery.get_user() # query = SQLQuery.get_user()
# data = commands.query(query, model=model.User, param={"username" : options["username"]}) # data = commands.query(query, model=model.User, param={"username" : options["username"]})
data = commands.query("SELECT id, participant_id, first_name, last_name, user_name, user_email, user_phone, password_hash, " + data = commands.query("SELECT id, participant_id, first_name, last_name, user_name, user_email, user_phone, password_hash, " +
"api_key, notify_email, notify_whatsapp, notify_signal, notify_popup, created, modified FROM user " + "api_key, notify_email, notify_whatsapp, notify_signal, notify_popup, notify_event, created, modified FROM user " +
"WHERE user_name = ?username? OR user_email = ?username?", "WHERE user_name = ?username? OR user_email = ?username?",
model=model.User, param={"username" : options["username"]}) model=model.User, param={"username" : options["username"]})
@ -31,7 +34,12 @@ def GetUser(options):
"last_name": data[0].last_name, "last_name": data[0].last_name,
"user_name": data[0].user_name, "user_name": data[0].user_name,
"user_phone": data[0].user_phone, "user_phone": data[0].user_phone,
"user_email": data[0].user_email "user_email": data[0].user_email,
"notify_email": data[0].notify_email,
"notify_whatsapp": data[0].notify_whatsapp,
"notify_signal": data[0].notify_signal,
"notify_popup": data[0].notify_popup,
"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 token = jwt_handler.generate_jwt(payload=result, lifetime=120) # generate token valid 60 mins
result["token"] = token # add token to user data result["token"] = token # add token to user data
@ -57,7 +65,3 @@ def GetUser(options):
finally: finally:
if pooledConnection is not None: if pooledConnection is not None:
pooledConnection.close() pooledConnection.close()
# $2b$12$uWLE0r32IrtCV30WkMbVwOdltgeibymZyYAf4ZnQb2Bip8hrkGGwG
# $2b$12$.vEapj9xU8z0RK0IpIGeYuRIl0ktdMt4XdJQBhVn.3K2hmvm7qD3y
# $2b$12$yL3PiseU70ciwEuMVM4OtuMwR6tNuIT9vvBiBG/uyMrPxa16E2Zqu

View File

@ -6,22 +6,22 @@ from ..schemas import model
from .. import local_db from .. import local_db
from BreCal.database.sql_queries import SQLQuery from BreCal.database.sql_queries import SQLQuery
def GetNotifications(options): def GetNotifications(token, participant_id=None):
""" """
:param options: A dictionary containing all the paramters for the Operations Optional filtering by participant_id. Returns delivered (level=2) notifications.
options["shipcall_id"]: **Id**. *Example: 42*. Id of referenced ship call.
""" """
pooledConnection = None
try: try:
pooledConnection = local_db.getPoolConnection() pooledConnection = local_db.getPoolConnection()
commands = pydapper.using(pooledConnection) commands = pydapper.using(pooledConnection)
# query = SQLQuery.get_notifications() query = "SELECT id, shipcall_id, participant_id, level, type, message, created, modified FROM notification WHERE level = 2"
# data = commands.query(query, model=model.Notification.from_query_row, param={"scid" : options["shipcall_id"]}) params = {}
data = commands.query("SELECT id, shipcall_id, level, type, message, created, modified FROM notification " + if participant_id is not None:
"WHERE shipcall_id = ?scid?", model=model.Notification.from_query_row, param={"scid" : options["shipcall_id"]}) query += " AND participant_id = ?participant_id?"
pooledConnection.close() 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: except Exception as ex:
logging.error(ex) logging.error(ex)
@ -29,6 +29,9 @@ def GetNotifications(options):
result = {} result = {}
result["error_field"] = "call failed" result["error_field"] = "call failed"
return json.dumps(result), 500, {'Content-Type': 'application/json; charset=utf-8'} return json.dumps(result), 500, {'Content-Type': 'application/json; charset=utf-8'}
finally:
if pooledConnection is not None:
pooledConnection.close()
return json.dumps(data, default=model.obj_dict), 200, {'Content-Type': 'application/json; charset=utf-8'} return json.dumps(data, default=model.obj_dict), 200, {'Content-Type': 'application/json; charset=utf-8'}

View File

@ -12,6 +12,7 @@ def GetParticipant(options):
options["user_id"]: **Id of user**. *Example: 2*. User id returned by login call. options["user_id"]: **Id of user**. *Example: 2*. User id returned by login call.
""" """
pooledConnection = None
try: try:
pooledConnection = local_db.getPoolConnection() pooledConnection = local_db.getPoolConnection()
commands = pydapper.using(pooledConnection) commands = pydapper.using(pooledConnection)
@ -19,8 +20,13 @@ def GetParticipant(options):
# query = SQLQuery.get_participant_by_user_id() # query = SQLQuery.get_participant_by_user_id()
query = ("SELECT p.id as id, p.name as name, p.street as street, p.postal_code as postal_code, p.city as city, p.type as type, p.flags as flags, " + query = ("SELECT p.id as id, p.name as name, p.street as street, p.postal_code as postal_code, p.city as city, p.type as type, p.flags as flags, " +
"p.created as created, p.modified as modified, p.deleted as deleted FROM participant p " + "p.created as created, p.modified as modified, p.deleted as deleted FROM participant p " +
"INNER JOIN user u WHERE u.participant_id = p.id and u.id = %d") % options["user_id"] "INNER JOIN user u WHERE u.participant_id = p.id and u.id = %s") % options["user_id"]
data = commands.query(query, model=model.Participant) data = commands.query(query, model=model.Participant)
for participant in data:
port_query = "SELECT port_id FROM participant_port_map WHERE participant_id=?id?"
for record in commands.query(port_query, model=model.Port_Assignment, param={"id" : participant.id}, buffered=False):
pa = model.Port_Assignment(record.port_id)
participant.ports.append(pa.port_id)
else: else:
# query = SQLQuery.get_participants() # query = SQLQuery.get_participants()
if "participant_id" in options: if "participant_id" in options:
@ -29,7 +35,7 @@ def GetParticipant(options):
"FROM participant p " + "FROM participant p " +
"JOIN participant_port_map ON p.id = participant_port_map.participant_id " + "JOIN participant_port_map ON p.id = participant_port_map.participant_id " +
"WHERE participant_port_map.port_id IN " + "WHERE participant_port_map.port_id IN " +
"(SELECT port_id FROM participant_port_map where participant_id = %d) " + "(SELECT port_id FROM participant_port_map where participant_id = %s) " +
"GROUP BY id " + "GROUP BY id " +
"ORDER BY p.name") % options["participant_id"] "ORDER BY p.name") % options["participant_id"]
else: else:
@ -41,7 +47,7 @@ def GetParticipant(options):
data = commands.query(query, model=model.Participant) data = commands.query(query, model=model.Participant)
for participant in data: for participant in data:
port_query = "SELECT port_id FROM participant_port_map WHERE participant_id=?id?"; port_query = "SELECT port_id FROM participant_port_map WHERE participant_id=?id?"
for record in commands.query(port_query, model=model.Port_Assignment, param={"id" : participant.id}, buffered=False): for record in commands.query(port_query, model=model.Port_Assignment, param={"id" : participant.id}, buffered=False):
pa = model.Port_Assignment(record.port_id) pa = model.Port_Assignment(record.port_id)
participant.ports.append(pa.port_id) participant.ports.append(pa.port_id)

View File

@ -11,6 +11,7 @@ def GetPorts(token):
No parameters, gets all entries No parameters, gets all entries
""" """
pooledConnection = None
try: try:
pooledConnection = local_db.getPoolConnection() pooledConnection = local_db.getPoolConnection()
commands = pydapper.using(pooledConnection) commands = pydapper.using(pooledConnection)

View File

@ -8,7 +8,8 @@ from .. import local_db
from ..services.auth_guard import check_jwt from ..services.auth_guard import check_jwt
from BreCal.database.update_database import evaluate_shipcall_state from BreCal.database.update_database import evaluate_shipcall_state
from BreCal.database.sql_queries import create_sql_query_shipcall_get, create_sql_query_shipcall_post, create_sql_query_shipcall_put, create_sql_query_history_post, create_sql_query_history_put, SQLQuery from BreCal.database.sql_utils import get_notification_for_shipcall_and_type, get_ship_data_for_id
from BreCal.database.sql_queries import create_sql_query_shipcall_get
from marshmallow import Schema, fields, ValidationError from marshmallow import Schema, fields, ValidationError
from BreCal.validators.validation_error import create_validation_error_response from BreCal.validators.validation_error import create_validation_error_response
@ -17,8 +18,8 @@ def GetShipcalls(options):
No parameters, gets all entries No parameters, gets all entries
""" """
pooledConnection = None
try: try:
pooledConnection = local_db.getPoolConnection() pooledConnection = local_db.getPoolConnection()
commands = pydapper.using(pooledConnection) commands = pydapper.using(pooledConnection)
# query = SQLQuery.get_shipcalls(options) # query = SQLQuery.get_shipcalls(options)
@ -69,12 +70,13 @@ def PostShipcalls(schemaModel):
""" """
# This creates a *new* entry # This creates a *new* entry
pooledConnection = None
try: try:
pooledConnection = local_db.getPoolConnection() pooledConnection = local_db.getPoolConnection()
commands = pydapper.using(pooledConnection) commands = pydapper.using(pooledConnection)
# query = SQLQuery.get_shipcall_post(schemaModel) # create_sql_query_shipcall_post(schemaModel) # query = SQLQuery.get_shipcall_post(schemaModel) # create_sql_query_shipcall_post(schemaModel)
query = "INSERT INTO shipcall (" query = "INSERT INTO shipcall ("
isNotFirst = False isNotFirst = False
for key in schemaModel.keys(): for key in schemaModel.keys():
@ -133,12 +135,27 @@ def PostShipcalls(schemaModel):
# new_id = commands.execute_scalar(lquery) # new_id = commands.execute_scalar(lquery)
new_id = commands.execute_scalar("select last_insert_id()") new_id = commands.execute_scalar("select last_insert_id()")
shipdata = get_ship_data_for_id(schemaModel["ship_id"])
message = shipdata['name']
if "type_value" in schemaModel:
match schemaModel["type_value"]:
case 1:
message += " [ARRIVAL]"
case 2:
message += " [DEPARTURE]"
case 3:
message += " [SHIFTING]"
# add participant assignments if we have a list of participants # add participant assignments if we have a list of participants
if 'participants' in schemaModel: if 'participants' in schemaModel:
# pquery = SQLQuery.get_shipcall_post_update_shipcall_participant_map() # pquery = SQLQuery.get_shipcall_post_update_shipcall_participant_map()
pquery = "INSERT INTO shipcall_participant_map (shipcall_id, participant_id, type) VALUES (?shipcall_id?, ?participant_id?, ?type?)" pquery = "INSERT INTO shipcall_participant_map (shipcall_id, participant_id, type) VALUES (?shipcall_id?, ?participant_id?, ?type?)"
nquery = "INSERT INTO notification (shipcall_id, participant_id, level, type, message) VALUES (?shipcall_id?, ?participant_id?, 0, 1, ?message?)" # type = 1 is assignment
for participant_assignment in schemaModel["participants"]: for participant_assignment in schemaModel["participants"]:
commands.execute(pquery, param={"shipcall_id" : new_id, "participant_id" : participant_assignment["participant_id"], "type" : participant_assignment["type"]}) commands.execute(pquery, param={"shipcall_id" : new_id, "participant_id" : participant_assignment["participant_id"], "type" : participant_assignment["type"]})
commands.execute(nquery, param={"shipcall_id" : new_id, "participant_id" : participant_assignment["participant_id"], "message" : message})
# apply 'Traffic Light' evaluation to obtain 'GREEN', 'YELLOW' or 'RED' evaluation state. The function internally updates the mysql database # apply 'Traffic Light' evaluation to obtain 'GREEN', 'YELLOW' or 'RED' evaluation state. The function internally updates the mysql database
# evaluate_shipcall_state(mysql_connector_instance=pooledConnection, shipcall_id=new_id) # new_id (last insert id) refers to the shipcall id # evaluate_shipcall_state(mysql_connector_instance=pooledConnection, shipcall_id=new_id) # new_id (last insert id) refers to the shipcall id
@ -168,15 +185,15 @@ def PostShipcalls(schemaModel):
pooledConnection.close() pooledConnection.close()
def PutShipcalls(schemaModel): def PutShipcalls(schemaModel, original_payload=None):
""" """
:param schemaModel: The deserialized dict of the request :param schemaModel: The deserialized dict of the request
""" """
# This updates an *existing* entry # This updates an *existing* entry
pooledConnection = None
try: try:
pooledConnection = local_db.getPoolConnection() pooledConnection = local_db.getPoolConnection()
commands = pydapper.using(pooledConnection) commands = pydapper.using(pooledConnection)
@ -185,17 +202,19 @@ def PutShipcalls(schemaModel):
# test if object to update is found # test if object to update is found
sentinel = object() sentinel = object()
# query = SQLQuery.get_shipcall_by_id()
# theshipcall = commands.query_single_or_default(query, sentinel, param={"id" : schemaModel["id"]})
theshipcall = commands.query_single_or_default("SELECT * FROM shipcall where id = ?id?", sentinel, param={"id" : schemaModel["id"]}) theshipcall = commands.query_single_or_default("SELECT * FROM shipcall where id = ?id?", sentinel, param={"id" : schemaModel["id"]})
if theshipcall is sentinel: if theshipcall is sentinel:
pooledConnection.close()
return json.dumps("no such record"), 404, {'Content-Type': 'application/json; charset=utf-8'} return json.dumps("no such record"), 404, {'Content-Type': 'application/json; charset=utf-8'}
# query = SQLQuery.get_shipcall_put(schemaModel) was_canceled = theshipcall["canceled"]
query = "UPDATE shipcall SET "
isNotFirst = False provided_keys = set(original_payload.keys()) if isinstance(original_payload, dict) else None
update_clauses = []
for key in schemaModel.keys(): for key in schemaModel.keys():
if provided_keys is not None and key not in provided_keys:
continue
param_key = key param_key = key
if key == "id": if key == "id":
continue continue
@ -217,21 +236,37 @@ def PutShipcalls(schemaModel):
param_key = "evaluation_value" param_key = "evaluation_value"
if key == "evaluation_value": if key == "evaluation_value":
continue continue
if isNotFirst: update_clauses.append(f"{key} = ?{param_key}?")
query += ", "
isNotFirst = True
query += key + " = ?" + param_key + "? "
query += "WHERE id = ?id?" if update_clauses:
query = "UPDATE shipcall SET " + ", ".join(update_clauses) + " WHERE id = ?id?"
commands.execute(query, param=schemaModel)
affected_rows = commands.execute(query, param=schemaModel) ship_id_value = schemaModel.get("ship_id") if (provided_keys is None or "ship_id" in provided_keys) else theshipcall["ship_id"]
shipdata = get_ship_data_for_id(ship_id_value)
message = shipdata['name']
type_value = schemaModel.get("type_value") if (provided_keys is None or "type" in provided_keys) else theshipcall["type"]
if type_value is not None:
match type_value:
case 1:
message += " [ARRIVAL]"
case 2:
message += " [DEPARTURE]"
case 3:
message += " [SHIFTING]"
# pquery = SQLQuery.get_shipcall_participant_map_by_shipcall_id() # pquery = SQLQuery.get_shipcall_participant_map_by_shipcall_id()
pquery = "SELECT id, participant_id, type FROM shipcall_participant_map where shipcall_id = ?id?" pquery = "SELECT id, participant_id, type FROM shipcall_participant_map where shipcall_id = ?id?"
pdata = commands.query(pquery,param={"id" : schemaModel["id"]}) # existing list of assignments pdata = commands.query(pquery,param={"id" : schemaModel["id"]}) # existing list of assignments
if schemaModel.get("participants") is None:
schemaModel["participants"] = []
# loop across passed participant ids, creating entries for those not present in pdata # loop across passed participant ids, creating entries for those not present in pdata
existing_notifications = get_notification_for_shipcall_and_type(schemaModel["id"], 1) # type = 1 is assignment
for participant_assignment in schemaModel["participants"]: for participant_assignment in schemaModel["participants"]:
found_participant = False found_participant = False
for elem in pdata: for elem in pdata:
@ -240,8 +275,18 @@ def PutShipcalls(schemaModel):
break break
if not found_participant: if not found_participant:
# nquery = SQLQuery.get_shipcall_post_update_shipcall_participant_map() # nquery = SQLQuery.get_shipcall_post_update_shipcall_participant_map()
nquery = "INSERT INTO shipcall_participant_map (shipcall_id, participant_id, type) VALUES (?shipcall_id?, ?participant_id?, ?type?)" spquery = "INSERT INTO shipcall_participant_map (shipcall_id, participant_id, type) VALUES (?shipcall_id?, ?participant_id?, ?type?)"
commands.execute(nquery, param={"shipcall_id" : schemaModel["id"], "participant_id" : participant_assignment["participant_id"], "type" : participant_assignment["type"]}) commands.execute(spquery, param={"shipcall_id" : schemaModel["id"], "participant_id" : participant_assignment["participant_id"], "type" : participant_assignment["type"]})
# create a notification but only if there is no existing notification in level 0
found_notification = False
for existing_notification in existing_notifications:
if existing_notification["participant_id"] == participant_assignment["participant_id"] and existing_notification["level"] == 1:
found_notification = True
break
if not found_notification:
nquery = "INSERT INTO notification (shipcall_id, participant_id, level, type, message) VALUES (?shipcall_id?, ?participant_id?, 0, 1, ?message?)" # type = 1 is assignment
commands.execute(nquery, param={"shipcall_id" : schemaModel["id"], "participant_id" : participant_assignment["participant_id"], "message" : message})
# loop across existing pdata entries, deleting those not present in participant list # loop across existing pdata entries, deleting those not present in participant list
for elem in pdata: for elem in pdata:
@ -254,6 +299,25 @@ def PutShipcalls(schemaModel):
# dquery = SQLQuery.get_shipcall_participant_map_delete_by_id() # dquery = SQLQuery.get_shipcall_participant_map_delete_by_id()
dquery = "DELETE FROM shipcall_participant_map WHERE id = ?existing_id?" dquery = "DELETE FROM shipcall_participant_map WHERE id = ?existing_id?"
commands.execute(dquery, param={"existing_id" : elem["id"]}) commands.execute(dquery, param={"existing_id" : elem["id"]})
# TODO: Create un-assignment notification but only if level > 0 else delete existing notification
for existing_notification in existing_notifications:
if existing_notification["participant_id"] == elem["participant_id"]:
if existing_notification["level"] == 0:
nquery = "DELETE FROM notification WHERE id = ?nid?"
commands.execute(nquery, param={"nid" : existing_notification["id"]})
else:
# create un-assignment notification
nquery = "INSERT INTO notification (shipcall_id, participant_id, level, type, message) VALUES (?shipcall_id?, ?participant_id?, 0, 5, ?message?)"
commands.execute(nquery, param={"shipcall_id" : schemaModel["id"], "participant_id" : elem["participant_id"], "message" : message})
break
canceled_value = schemaModel.get("canceled")
if canceled_value is not None:
if canceled_value and not was_canceled:
# create a canceled notification for all currently assigned participants
stornoNotificationQuery = "INSERT INTO notification (shipcall_id, participant_id, level, type, message) VALUES (?shipcall_id?, ?participant_id?, 0, 7, ?message?)"
for participant_assignment in schemaModel["participants"]:
commands.execute(stornoNotificationQuery, param={"shipcall_id" : schemaModel["id"], "participant_id" : participant_assignment["participant_id"], "message" : message})
# save history data # save history data
# TODO: set ETA properly # TODO: set ETA properly

View File

@ -11,8 +11,8 @@ def GetShips(token):
No parameters, gets all entries No parameters, gets all entries
""" """
pooledConnection = None
try: try:
pooledConnection = local_db.getPoolConnection() pooledConnection = local_db.getPoolConnection()
commands = pydapper.using(pooledConnection) commands = pydapper.using(pooledConnection)
# query = SQLQuery.get_ships() # query = SQLQuery.get_ships()
@ -44,8 +44,8 @@ def PostShip(schemaModel):
# TODO: Validate the incoming data # TODO: Validate the incoming data
# This creates a *new* entry # This creates a *new* entry
pooledConnection = None
try: try:
pooledConnection = local_db.getPoolConnection() pooledConnection = local_db.getPoolConnection()
commands = pydapper.using(pooledConnection) commands = pydapper.using(pooledConnection)
@ -83,8 +83,6 @@ def PostShip(schemaModel):
# new_id = commands.execute_scalar(nquery) # new_id = commands.execute_scalar(nquery)
new_id = commands.execute_scalar("select last_insert_id()") new_id = commands.execute_scalar("select last_insert_id()")
pooledConnection.close()
return json.dumps({"id" : new_id}), 201, {'Content-Type': 'application/json; charset=utf-8'} return json.dumps({"id" : new_id}), 201, {'Content-Type': 'application/json; charset=utf-8'}
except Exception as ex: except Exception as ex:
@ -93,6 +91,9 @@ def PostShip(schemaModel):
result = {} result = {}
result["error_field"] = "call failed" result["error_field"] = "call failed"
return json.dumps(result), 500, {'Content-Type': 'application/json; charset=utf-8'} return json.dumps(result), 500, {'Content-Type': 'application/json; charset=utf-8'}
finally:
if pooledConnection is not None:
pooledConnection.close()
def PutShip(schemaModel): def PutShip(schemaModel):
@ -101,8 +102,8 @@ def PutShip(schemaModel):
""" """
# This updates an *existing* entry # This updates an *existing* entry
pooledConnection = None
try: try:
pooledConnection = local_db.getPoolConnection() pooledConnection = local_db.getPoolConnection()
commands = pydapper.using(pooledConnection) commands = pydapper.using(pooledConnection)
@ -125,8 +126,6 @@ def PutShip(schemaModel):
affected_rows = commands.execute(query, param=schemaModel) affected_rows = commands.execute(query, param=schemaModel)
pooledConnection.close()
return json.dumps({"id" : schemaModel["id"]}), 200, {'Content-Type': 'application/json; charset=utf-8'} return json.dumps({"id" : schemaModel["id"]}), 200, {'Content-Type': 'application/json; charset=utf-8'}
except Exception as ex: except Exception as ex:
@ -135,6 +134,9 @@ def PutShip(schemaModel):
result = {} result = {}
result["error_field"] = "call failed" result["error_field"] = "call failed"
return json.dumps(result), 500, {'Content-Type': 'application/json; charset=utf-8'} return json.dumps(result), 500, {'Content-Type': 'application/json; charset=utf-8'}
finally:
if pooledConnection is not None:
pooledConnection.close()
def DeleteShip(options): def DeleteShip(options):
@ -143,16 +145,14 @@ def DeleteShip(options):
options["id"] options["id"]
""" """
pooledConnection = None
try: try:
pooledConnection = local_db.getPoolConnection() pooledConnection = local_db.getPoolConnection()
commands = pydapper.using(pooledConnection) commands = pydapper.using(pooledConnection)
# query = SQLQuery.get_ship_delete_by_id() # query = SQLQuery.get_ship_delete_by_id()
# affected_rows = commands.execute(query, param={"id" : options["id"]}) # affected_rows = commands.execute(query, param={"id" : options["id"]})
affected_rows = commands.execute("UPDATE ship SET deleted = 1 WHERE id = ?id?", param={"id" : options["id"]}) affected_rows = commands.execute("UPDATE ship SET deleted = 1 WHERE id = ?id?", param={"id" : options["id"]})
pooledConnection.close()
if affected_rows == 1: if affected_rows == 1:
return json.dumps({"id" : options["id"]}), 200, {'Content-Type': 'application/json; charset=utf-8'} return json.dumps({"id" : options["id"]}), 200, {'Content-Type': 'application/json; charset=utf-8'}
@ -166,3 +166,6 @@ def DeleteShip(options):
result = {} result = {}
result["error_field"] = "call failed" result["error_field"] = "call failed"
return json.dumps(result), 500, {'Content-Type': 'application/json; charset=utf-8'} return json.dumps(result), 500, {'Content-Type': 'application/json; charset=utf-8'}
finally:
if pooledConnection is not None:
pooledConnection.close()

View File

@ -18,8 +18,8 @@ def GetTimes(options):
""" """
pooledConnection = None
try: try:
pooledConnection = local_db.getPoolConnection() pooledConnection = local_db.getPoolConnection()
commands = pydapper.using(pooledConnection) commands = pydapper.using(pooledConnection)
# query = SQLQuery.get_times() # query = SQLQuery.get_times()
@ -28,7 +28,6 @@ def GetTimes(options):
"zone_entry, zone_entry_fixed, operations_start, operations_end, remarks, shipcall_id, participant_id, " + "zone_entry, zone_entry_fixed, operations_start, operations_end, remarks, shipcall_id, participant_id, " +
"berth_id, berth_info, pier_side, participant_type, created, modified, ata, atd, eta_interval_end, etd_interval_end FROM times " + "berth_id, berth_info, pier_side, participant_type, created, modified, ata, atd, eta_interval_end, etd_interval_end FROM times " +
"WHERE times.shipcall_id = ?scid?", model=model.Times, param={"scid" : options["shipcall_id"]}) "WHERE times.shipcall_id = ?scid?", model=model.Times, param={"scid" : options["shipcall_id"]})
pooledConnection.close()
except Exception as ex: except Exception as ex:
logging.error(traceback.format_exc()) logging.error(traceback.format_exc())
@ -38,6 +37,10 @@ def GetTimes(options):
result["error_field"] = "call failed" result["error_field"] = "call failed"
return json.dumps(result), 500, {'Content-Type': 'application/json; charset=utf-8'} return json.dumps(result), 500, {'Content-Type': 'application/json; charset=utf-8'}
finally:
if pooledConnection is not None:
pooledConnection.close()
return json.dumps(data, default=model.obj_dict), 200, {'Content-Type': 'application/json; charset=utf-8'} return json.dumps(data, default=model.obj_dict), 200, {'Content-Type': 'application/json; charset=utf-8'}
@ -51,8 +54,8 @@ def PostTimes(schemaModel):
# TODO: Validate the upload data # TODO: Validate the upload data
# This creates a *new* entry # This creates a *new* entry
pooledConnection = None
try: try:
pooledConnection = local_db.getPoolConnection() pooledConnection = local_db.getPoolConnection()
commands = pydapper.using(pooledConnection) commands = pydapper.using(pooledConnection)
@ -112,36 +115,45 @@ def PostTimes(schemaModel):
pooledConnection.close() pooledConnection.close()
def PutTimes(schemaModel): def PutTimes(schemaModel, original_payload=None):
""" """
:param schemaModel: The deserialized model of the record to be inserted :param schemaModel: The deserialized model of the record to be inserted
""" """
# This updates an *existing* entry # This updates an *existing* entry
pooledConnection = None
try: try:
pooledConnection = local_db.getPoolConnection() pooledConnection = local_db.getPoolConnection()
commands = pydapper.using(pooledConnection) commands = pydapper.using(pooledConnection)
query = "UPDATE times SET " sentinel = object()
isNotFirst = False existing_times = commands.query_single_or_default("SELECT * FROM times WHERE id = ?id?", sentinel, param={"id": schemaModel["id"]})
if existing_times is sentinel:
return json.dumps("no such record"), 404, {'Content-Type': 'application/json; charset=utf-8'}
provided_keys = set(original_payload.keys()) if isinstance(original_payload, dict) else None
if "shipcall_id" not in schemaModel or (provided_keys is not None and "shipcall_id" not in provided_keys):
schemaModel["shipcall_id"] = existing_times["shipcall_id"]
schemaModel = {k:v.value if isinstance(v, (Enum, Flag)) else v for k,v in schemaModel.items()}
update_clauses = []
for key in schemaModel.keys(): for key in schemaModel.keys():
if provided_keys is not None and key not in provided_keys:
continue
if key == "id": if key == "id":
continue continue
if key == "created": if key == "created":
continue continue
if key == "modified": if key == "modified":
continue continue
if isNotFirst: update_clauses.append(f"{key} = ?{key}?")
query += ", "
isNotFirst = True
query += key + " = ?" + key + "? "
query += "WHERE id = ?id?" if update_clauses:
query = "UPDATE times SET " + ", ".join(update_clauses) + " WHERE id = ?id?"
schemaModel = {k:v.value if isinstance(v, (Enum, Flag)) else v for k,v in schemaModel.items()} commands.execute(query, param=schemaModel)
affected_rows = commands.execute(query, param=schemaModel)
# apply 'Traffic Light' evaluation to obtain 'GREEN', 'YELLOW' or 'RED' evaluation state. The function internally updates the mysql database 'shipcall' # apply 'Traffic Light' evaluation to obtain 'GREEN', 'YELLOW' or 'RED' evaluation state. The function internally updates the mysql database 'shipcall'
evaluate_shipcall_state(mysql_connector_instance=pooledConnection, shipcall_id=schemaModel["shipcall_id"]) # every times data object refers to the 'shipcall_id' evaluate_shipcall_state(mysql_connector_instance=pooledConnection, shipcall_id=schemaModel["shipcall_id"]) # every times data object refers to the 'shipcall_id'
@ -177,8 +189,8 @@ def DeleteTimes(options):
options["id"] options["id"]
""" """
pooledConnection = None
try: try:
pooledConnection = local_db.getPoolConnection() pooledConnection = local_db.getPoolConnection()
commands = pydapper.using(pooledConnection) commands = pydapper.using(pooledConnection)
shipcall_id = commands.execute_scalar("SELECT shipcall_id FROM times WHERE id = ?id?", param={"id" : options["id"]}) shipcall_id = commands.execute_scalar("SELECT shipcall_id FROM times WHERE id = ?id?", param={"id" : options["id"]})

View File

@ -14,8 +14,8 @@ def PutUser(schemaModel):
""" """
# This updates an *existing* entry # This updates an *existing* entry
pooledConnection = None
try: try:
pooledConnection = local_db.getPoolConnection() pooledConnection = local_db.getPoolConnection()
commands = pydapper.using(pooledConnection) commands = pydapper.using(pooledConnection)
@ -26,7 +26,6 @@ def PutUser(schemaModel):
# theuser = commands.query_single_or_default(query, sentinel, param={"id" : schemaModel["id"]}, model=model.User) # theuser = commands.query_single_or_default(query, sentinel, param={"id" : schemaModel["id"]}, model=model.User)
theuser = commands.query_single_or_default("SELECT * FROM user where id = ?id?", sentinel, param={"id" : schemaModel["id"]}, model=model.User) theuser = commands.query_single_or_default("SELECT * FROM user where id = ?id?", sentinel, param={"id" : schemaModel["id"]}, model=model.User)
if theuser is sentinel: if theuser is sentinel:
pooledConnection.close()
# #TODO: result = {"message":"no such record"} -> json.dumps # #TODO: result = {"message":"no such record"} -> json.dumps
return json.dumps("no such record"), 404, {'Content-Type': 'application/json; charset=utf-8'} return json.dumps("no such record"), 404, {'Content-Type': 'application/json; charset=utf-8'}
@ -35,7 +34,7 @@ def PutUser(schemaModel):
# should this be refactored? # should this be refactored?
# Also, what about the 'user_name'? # Also, what about the 'user_name'?
# 'participant_id' would also not trigger an update in isolation # 'participant_id' would also not trigger an update in isolation
if "first_name" in schemaModel or "last_name" in schemaModel or "user_phone" in schemaModel or "user_email" in schemaModel: if "first_name" in schemaModel or "last_name" in schemaModel or "user_phone" in schemaModel or "user_email" in schemaModel or "notify_email" in schemaModel or "notify_whatsapp" in schemaModel or "notify_signal" in schemaModel or "notify_popup" in schemaModel or "notify_on" in schemaModel:
# query = SQLQuery.get_user_put(schemaModel) # query = SQLQuery.get_user_put(schemaModel)
query = "UPDATE user SET " query = "UPDATE user SET "
isNotFirst = False isNotFirst = False
@ -49,7 +48,14 @@ def PutUser(schemaModel):
if isNotFirst: if isNotFirst:
query += ", " query += ", "
isNotFirst = True isNotFirst = True
if key != "notify_on":
query += key + " = ?" + key + "? " query += key + " = ?" + key + "? "
else:
flag_value = model.list_to_bitflag(schemaModel["notify_on"])
query += "notify_event = " + str(flag_value) + " "
query += "WHERE id = ?id?" query += "WHERE id = ?id?"
affected_rows = commands.execute(query, param=schemaModel) affected_rows = commands.execute(query, param=schemaModel)

View File

@ -1,42 +1,90 @@
import mysql.connector import mysql.connector
from mysql.connector import pooling
import pydapper import pydapper
import logging import logging
import json import json
import os import os
import sys import sys
from BreCal.schemas import defs
config_path = None config_path = None
secure_dir = None
_connection_pool = None
def initPool(instancePath, connection_filename="connection_data_devel.json"):
def _load_json(path):
with open(path, encoding="utf-8") as fh:
return json.load(fh)
def _build_pool_config(connection_data, pool_name, pool_size):
pool_config = dict(connection_data)
pool_config.setdefault("pool_name", pool_name)
pool_config.setdefault("pool_size", pool_size)
return pool_config
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, secure_dir, _connection_pool
try: try:
global config_path if config:
if(config_path == None): connection_filename = config.get("DB_CONNECTION_FILE", connection_filename)
config_path = os.path.join(instancePath,f'../../../secure/{connection_filename}') #connection_data_devel.json'); 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(secure_dir, connection_filename)
print(config_path) print(config_path)
if not os.path.exists(config_path): if not os.path.exists(config_path):
print('cannot find ' + os.path.abspath(config_path)) print('cannot find ' + os.path.abspath(config_path))
print("instance path", instancePath) print("instance path", instancePath)
sys.exit(1) sys.exit(1)
f = open(config_path); connection_data = _load_json(config_path)
connection_data = json.load(f) if _connection_pool is None:
pool_config = _build_pool_config(connection_data, pool_name, pool_size)
conn_from_pool = mysql.connector.connect(**connection_data) _connection_pool = pooling.MySQLConnectionPool(**pool_config)
conn_from_pool = _connection_pool.get_connection()
try:
commands = pydapper.using(conn_from_pool) commands = pydapper.using(conn_from_pool)
data = commands.query("SELECT id from `user`") commands.query("SELECT id from `user` LIMIT 1")
print("DB connection successful") print("DB connection successful")
finally:
conn_from_pool.close() conn_from_pool.close()
credentials_path = os.path.join(secure_dir, credentials_file)
if not os.path.exists(credentials_path):
print('cannot find ' + os.path.abspath(credentials_path))
sys.exit(1)
defs.email_credentials = _load_json(credentials_path)
except mysql.connector.PoolError as e: except mysql.connector.PoolError as e:
logging.error(f"Failed to create connection pool: {e}") logging.error(f"Failed to create connection pool: {e}")
print(e) print(e)
except Exception as e: except Exception as e:
logging.error("Failed to initialize DB pool: %s", e)
print(e) print(e)
def getPoolConnection(): def getPoolConnection():
global config_path if _connection_pool is None:
f = open(config_path); raise RuntimeError("Connection pool not initialized. Call initPool first.")
connection_data = json.load(f) try:
return mysql.connector.connect(**connection_data) return _connection_pool.get_connection()
except mysql.connector.PoolError as exc:
logging.error("Connection pool exhausted: %s", exc)
raise

View File

@ -0,0 +1,44 @@
[
{
"type" : 1,
"color" : "#0867ec",
"name" : "assignment",
"msg_text" : "Nominierung"
},
{
"type" : 2,
"color" : "#ea5c00",
"name" : "next24h",
"msg_text" : "Morgenrunde relevant"
},
{
"type" : 3,
"color" : "#f34336",
"name" : "time_conflict",
"msg_text" : "Zeitlicher Konflikt"
},
{
"type" : 4,
"color" : "#28b532",
"name" : "time_conflict_resolved",
"msg_text" : "Zeitlicher Konflikt gelöst"
},
{
"type" : 5,
"color" : "#a8a8a8",
"name" : "unassigned",
"msg_text" : "Nominierung abgewählt"
},
{
"type" : 6,
"color" : "#a8a800",
"name" : "missing_data",
"msg_text" : "Fehlende Daten"
},
{
"type" : 7,
"color" : "#808070",
"name" : "cancelled",
"msg_text" : "Storno"
}
]

View File

@ -0,0 +1,18 @@
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; box-sizing: border-box; width: 100%; min-width: 100%;" width="100%">
<tbody>
<tr>
<td align="left" style="font-family: Helvetica, sans-serif; font-size: 16px; vertical-align: top; padding-bottom: 16px;" valign="top">
<table role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: auto;">
<tbody>
<tr>
<td style="font-family: Helvetica, sans-serif; font-size: 16px; vertical-align: top; border-radius: 4px; text-align: center; background-color: [[color]];" valign="top" align="center" bgcolor="[[color]]">
<span style="font-size:14px; color: #ffffff;">[[notification_text]]</span><br/>
<a href="[[link]]" target="_blank" style="border: solid 2px [[color]]; border-radius: 4px; box-sizing: border-box; cursor: pointer; display: inline-block; font-size: 16px; font-weight: bold; margin: 0; padding: 12px 24px; text-decoration: none; text-transform: capitalize; background-color: [[color]]; border-color: [[color]]; color: #ffffff;">[[text]]</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>

View File

@ -0,0 +1,148 @@
<!doctype html>
<html lang="de">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Bremen calling Benachrichtigung</title>
<style media="all" type="text/css">
@media all {
.btn-primary table td:hover {
background-color: #ec0867 !important;
}
.btn-primary a:hover {
background-color: #ec0867 !important;
border-color: #ec0867 !important;
}
}
@media only screen and (max-width: 640px) {
.main p,
.main td,
.main span {
font-size: 16px !important;
}
.wrapper {
padding: 8px !important;
}
.content {
padding: 0 !important;
}
.container {
padding: 0 !important;
padding-top: 8px !important;
width: 100% !important;
}
.main {
border-left-width: 0 !important;
border-radius: 0 !important;
border-right-width: 0 !important;
}
.btn table {
max-width: 100% !important;
width: 100% !important;
}
.btn a {
font-size: 16px !important;
max-width: 100% !important;
width: 100% !important;
}
}
@media all {
.ExternalClass {
width: 100%;
}
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 100%;
}
.apple-link a {
color: inherit !important;
font-family: inherit !important;
font-size: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
text-decoration: none !important;
}
#MessageViewBody a {
color: inherit;
text-decoration: none;
font-size: inherit;
font-family: inherit;
font-weight: inherit;
line-height: inherit;
}
}
</style>
</head>
<body style="font-family: Helvetica, sans-serif; -webkit-font-smoothing: antialiased; font-size: 16px; line-height: 1.3; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; background-color: #f4f5f6; margin: 0; padding: 0;">
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="body" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; background-color: #f4f5f6; width: 100%;" width="100%" bgcolor="#f4f5f6">
<tr>
<td style="font-family: Helvetica, sans-serif; font-size: 16px; vertical-align: top;" valign="top">&nbsp;</td>
<td class="container" style="font-family: Helvetica, sans-serif; font-size: 16px; vertical-align: top; max-width: 600px; padding: 0; padding-top: 24px; width: 600px; margin: 0 auto;" width="600" valign="top">
<div class="content" style="box-sizing: border-box; display: block; margin: 0 auto; max-width: 600px; padding: 0;">
<!-- START CENTERED WHITE CONTAINER -->
<span class="preheader" style="color: transparent; display: none; height: 0; max-height: 0; max-width: 0; opacity: 0; overflow: hidden; mso-hide: all; visibility: hidden; width: 0;">Benachrichtung von Bremen calling!</span>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="main" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; background: #ffffff; border: 1px solid #eaebed; border-radius: 16px; width: 100%;" width="100%">
<!-- START MAIN CONTENT AREA -->
<tr>
<td class="wrapper" style="font-family: Helvetica, sans-serif; font-size: 16px; vertical-align: top; box-sizing: border-box; padding: 24px;" valign="top">
<p style="font-family: Helvetica, sans-serif; font-size: 16px; font-weight: normal; margin: 0; margin-bottom: 16px;">Hallo,</p>
<p style="font-family: Helvetica, sans-serif; font-size: 16px; font-weight: normal; margin: 0; margin-bottom: 16px;">Sie erhalten eine oder mehrere Benachrichtigungen von Bremen calling:</p>
<!-- notifications begin -->
[[NOTIFICATION_ELEMENTS]]
<!-- notifications end -->
<p style="font-family: Helvetica, sans-serif; font-size: 16px; font-weight: normal; margin: 0; margin-bottom: 16px;">Wenn Sie diese E-Mails nicht länger erhalten wollen, loggen Sie sich bitte in der Bremen Calling App ein. Im Bereich "Passwort ändern"
können Sie auch die Benachrichtungen anpassen.
</p>
</td>
</tr>
<!-- END MAIN CONTENT AREA -->
</table>
<!-- START FOOTER -->
<div class="footer" style="clear: both; padding-top: 24px; text-align: center; width: 100%;">
<table role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;" width="100%">
<tr>
<td class="content-block" style="font-family: Helvetica, sans-serif; vertical-align: top; color: #9a9ea6; font-size: 16px; text-align: center;" valign="top" align="center">
<span class="apple-link" style="color: #9a9ea6; font-size: 16px; text-align: center;">Bremer Schiffsmeldedienst GbR - Hafenkopf II / Überseetor 20 - 28217 Bremen / Germany</span>
</td>
</tr>
<tr>
<td class="content-block powered-by" style="font-family: Helvetica, sans-serif; vertical-align: top; color: #9a9ea6; font-size: 16px; text-align: center;" valign="top" align="center">
Kontaktieren Sie uns unter <a href="mailto:bremencalling@bsmd.de" style="color: #9a9ea6; font-size: 16px; text-align: center;">bremencalling@bsmd.de</a>.<br />
<a href="https://www.bsmd.de" style="color: #9a9ea6; font-size: 16px; text-align: center;">www.bsmd.de</a><br />
Tel.: +49 421 38 48 27
</td>
</tr>
</table>
</div>
<!-- END FOOTER -->
<!-- END CENTERED WHITE CONTAINER --></div>
</td>
<td style="font-family: Helvetica, sans-serif; font-size: 16px; vertical-align: top;" valign="top">&nbsp;</td>
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,20 @@
# Version: 1.7.0
# Constants for the notification system
NOTIFICATION_COOLDOWN_MINS = 10 # until a notification gets real and cannot be deleted anymore
NOTIFICATION_MAX_AGE_DAYS = 3 # 3 days until a notification gets deleted
# Placeholder for the email credentials filled by startup logic
email_credentials = dict()
# Holding var for global message notification type info
message_types = dict()
# Constants for the email display
shipcall_types = {
1: "Arrival",
2: "Departure",
3: "Shifting"
}

View File

@ -5,15 +5,17 @@ from marshmallow_enum import EnumField
from enum import IntEnum from enum import IntEnum
from marshmallow_dataclass import dataclass from marshmallow_dataclass import dataclass
from typing import List from typing import Iterable, List
import json import json
import re
import datetime import datetime
from BreCal.validators.time_logic import validate_time_is_in_not_too_distant_future from BreCal.validators.time_logic import validate_time_is_in_not_too_distant_future
from BreCal.validators.validation_base_utils import check_if_string_has_special_characters from BreCal.validators.validation_base_utils import check_if_string_has_special_characters
from BreCal.database.enums import ParticipantType, ParticipantFlag from BreCal.database.enums import ParticipantType, ParticipantFlag
# from BreCal. ... import check_if_user_is_bsmd_type
def obj_dict(obj): def obj_dict(obj):
if isinstance(obj, datetime.datetime): if isinstance(obj, datetime.datetime):
@ -65,23 +67,51 @@ class EvaluationType(IntEnum):
return cls.undefined return cls.undefined
class NotificationType(IntEnum): class NotificationType(IntEnum):
""" """
Any user has the attributes This type is not the way the user is informed but the type of the notification, e.g. time conflict, time conflict resolved, etc.
'notify_email' -> NotificationType.email It can be understood as an event type
'notify_popup' -> NotificationType.push
'notify_whatsapp' -> undeclared
'notify_signal' -> undeclared
""" """
undefined = 0
email = 1 assignment = 1
push = 2 next24h = 2
# whatsapp = 3 time_conflict = 3
# signal = 4 time_conflict_resolved = 4
unassigned = 5
missing_data = 6
cancelled = 7
@classmethod @classmethod
def _missing_(cls, value): def _missing_(cls, value):
return cls.undefined return cls.undefined
def bitflag_to_list(bitflag: int | None) -> list[NotificationType]:
"""Converts an integer bitflag to a list of NotificationType enums."""
if bitflag is None:
return []
return [nt for nt in NotificationType if bitflag & (1 << (nt.value - 1))]
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): class ShipcallType(IntEnum):
undefined = 0 undefined = 0
arrival = 1 arrival = 1
@ -145,6 +175,7 @@ class Notification:
""" """
id: int id: int
shipcall_id: int # 'shipcall record that caused the notification' shipcall_id: int # 'shipcall record that caused the notification'
participant_id: int # 'optional participant reference that needs to be specifically notified, if null all participants are notified'
level: int # 'severity of the notification' level: int # 'severity of the notification'
type: NotificationType # 'type of the notification' type: NotificationType # 'type of the notification'
message: str # 'individual message' message: str # 'individual message'
@ -155,6 +186,7 @@ class Notification:
return { return {
"id": self.id, "id": self.id,
"shipcall_id": self.shipcall_id, "shipcall_id": self.shipcall_id,
"participant_id": self.participant_id,
"level": self.level, "level": self.level,
"type": self.type.name if isinstance(self.type, IntEnum) else NotificationType(self.type).name, "type": self.type.name if isinstance(self.type, IntEnum) else NotificationType(self.type).name,
"message": self.message, "message": self.message,
@ -163,8 +195,8 @@ class Notification:
} }
@classmethod @classmethod
def from_query_row(self, id, shipcall_id, level, type, message, created, modified): def from_query_row(self, id, shipcall_id, participant_id, level, type, message, created, modified):
return self(id, shipcall_id, level, NotificationType(type), message, created, modified) return self(id, shipcall_id, participant_id, level, NotificationType(type), message, created, modified)
@dataclass @dataclass
class Participant(Schema): class Participant(Schema):
@ -181,7 +213,7 @@ class Participant(Schema):
ports: List[int] = field(default_factory=list) ports: List[int] = field(default_factory=list)
@validates("type") @validates("type")
def validate_type(self, value): def validate_type(self, value, **kwargs):
# e.g., when an IntFlag has the values 1,2,4; the maximum valid value is 7 # e.g., when an IntFlag has the values 1,2,4; the maximum valid value is 7
max_int = sum([int(val) for val in list(ParticipantType._value2member_map_.values())]) max_int = sum([int(val) for val in list(ParticipantType._value2member_map_.values())])
min_int = 0 min_int = 0
@ -192,7 +224,7 @@ class Participant(Schema):
@validates("flags") @validates("flags")
def validate_flags(self, value): def validate_flags(self, value, **kwargs):
# e.g., when an IntFlag has the values 1,2,4; the maximum valid value is 7 # e.g., when an IntFlag has the values 1,2,4; the maximum valid value is 7
max_int = sum([int(val) for val in list(ParticipantFlag._value2member_map_.values())]) max_int = sum([int(val) for val in list(ParticipantFlag._value2member_map_.values())])
min_int = 0 min_int = 0
@ -217,7 +249,7 @@ class ShipcallSchema(Schema):
id = fields.Integer(required=True) id = fields.Integer(required=True)
ship_id = fields.Integer(required=True) ship_id = fields.Integer(required=True)
port_id = fields.Integer(required=True) port_id = fields.Integer(required=True)
type = fields.Enum(ShipcallType, default=ShipcallType.undefined) type = fields.Enum(ShipcallType, load_default=ShipcallType.undefined, dump_default=ShipcallType.undefined)
eta = fields.DateTime(required=False, allow_none=True) eta = fields.DateTime(required=False, allow_none=True)
voyage = fields.String(allow_none=True, required=False, validate=[validate.Length(max=16)]) voyage = fields.String(allow_none=True, required=False, validate=[validate.Length(max=16)])
etd = fields.DateTime(required=False, allow_none=True) etd = fields.DateTime(required=False, allow_none=True)
@ -238,7 +270,7 @@ class ShipcallSchema(Schema):
anchored = fields.Bool(required=False, allow_none=True) anchored = fields.Bool(required=False, allow_none=True)
moored_lock = fields.Bool(required=False, allow_none=True) moored_lock = fields.Bool(required=False, allow_none=True)
canceled = fields.Bool(required=False, allow_none=True) canceled = fields.Bool(required=False, allow_none=True)
evaluation = fields.Enum(EvaluationType, required=False, allow_none=True, default=EvaluationType.undefined) evaluation = fields.Enum(EvaluationType, required=False, allow_none=True, load_default=EvaluationType.undefined, dump_default=ShipcallType.undefined)
evaluation_message = fields.Str(allow_none=True, required=False) evaluation_message = fields.Str(allow_none=True, required=False)
evaluation_time = fields.DateTime(required=False, allow_none=True) evaluation_time = fields.DateTime(required=False, allow_none=True)
evaluation_notifications_sent = fields.Bool(required=False, allow_none=True) evaluation_notifications_sent = fields.Bool(required=False, allow_none=True)
@ -261,7 +293,7 @@ class ShipcallSchema(Schema):
return data return data
@validates("type") @validates("type")
def validate_type(self, value): def validate_type(self, value, **kwargs):
valid_shipcall_type = int(value) in [item.value for item in ShipcallType] valid_shipcall_type = int(value) in [item.value for item in ShipcallType]
if not valid_shipcall_type: if not valid_shipcall_type:
@ -398,7 +430,7 @@ class TimesSchema(Schema):
berth_info = fields.String(required=False, allow_none=True, validate=[validate.Length(max=512)]) berth_info = fields.String(required=False, allow_none=True, validate=[validate.Length(max=512)])
pier_side = fields.Bool(required=False, allow_none = True) pier_side = fields.Bool(required=False, allow_none = True)
shipcall_id = fields.Integer(required=True) shipcall_id = fields.Integer(required=True)
participant_type = fields.Integer(Required = False, allow_none=True)# TODO: could become Enum. # participant_type = fields.Enum(ParticipantType, required=False, allow_none=True, default=ParticipantType.undefined) #fields.Integer(required=False, allow_none=True) participant_type = fields.Integer(required = False, allow_none=True) # TODO: could become Enum
ata = fields.DateTime(required=False, allow_none=True) ata = fields.DateTime(required=False, allow_none=True)
atd = fields.DateTime(required=False, allow_none=True) atd = fields.DateTime(required=False, allow_none=True)
eta_interval_end = fields.DateTime(required=False, allow_none=True) eta_interval_end = fields.DateTime(required=False, allow_none=True)
@ -407,7 +439,7 @@ class TimesSchema(Schema):
modified = fields.DateTime(required=False, allow_none=True) modified = fields.DateTime(required=False, allow_none=True)
@validates("participant_type") @validates("participant_type")
def validate_participant_type(self, value): def validate_participant_type(self, value, **kwargs):
# #TODO: it may also make sense to block multi-assignments, whereas a value could be BSMD+AGENCY # #TODO: it may also make sense to block multi-assignments, whereas a value could be BSMD+AGENCY
# while the validation fails when one of those multi-assignments is BSMD, it passes in cases, # while the validation fails when one of those multi-assignments is BSMD, it passes in cases,
# such as AGENCY+PILOT # such as AGENCY+PILOT
@ -420,56 +452,56 @@ class TimesSchema(Schema):
raise ValidationError({"participant_type":f"the participant_type must not be .BSMD"}) raise ValidationError({"participant_type":f"the participant_type must not be .BSMD"})
@validates("eta_berth") @validates("eta_berth")
def validate_eta_berth(self, value): def validate_eta_berth(self, value, **kwargs):
# violation when time is not in the future, but also does not exceed a threshold for the 'reasonable' future # violation when time is not in the future, but also does not exceed a threshold for the 'reasonable' future
# when 'value' is 'None', a ValidationError is not issued. # when 'value' is 'None', a ValidationError is not issued.
valid_time = validate_time_is_in_not_too_distant_future(raise_validation_error=True, value=value, months=12) valid_time = validate_time_is_in_not_too_distant_future(raise_validation_error=True, value=value, months=12)
return return
@validates("etd_berth") @validates("etd_berth")
def validate_etd_berth(self, value): def validate_etd_berth(self, value, **kwargs):
# violation when time is not in the future, but also does not exceed a threshold for the 'reasonable' future # violation when time is not in the future, but also does not exceed a threshold for the 'reasonable' future
# when 'value' is 'None', a ValidationError is not issued. # when 'value' is 'None', a ValidationError is not issued.
valid_time = validate_time_is_in_not_too_distant_future(raise_validation_error=True, value=value, months=12) valid_time = validate_time_is_in_not_too_distant_future(raise_validation_error=True, value=value, months=12)
return return
@validates("lock_time") @validates("lock_time")
def validate_lock_time(self, value): def validate_lock_time(self, value, **kwargs):
# violation when time is not in the future, but also does not exceed a threshold for the 'reasonable' future # violation when time is not in the future, but also does not exceed a threshold for the 'reasonable' future
# when 'value' is 'None', a ValidationError is not issued. # when 'value' is 'None', a ValidationError is not issued.
valid_time = validate_time_is_in_not_too_distant_future(raise_validation_error=True, value=value, months=12) valid_time = validate_time_is_in_not_too_distant_future(raise_validation_error=True, value=value, months=12)
return return
@validates("zone_entry") @validates("zone_entry")
def validate_zone_entry(self, value): def validate_zone_entry(self, value, **kwargs):
# violation when time is not in the future, but also does not exceed a threshold for the 'reasonable' future # violation when time is not in the future, but also does not exceed a threshold for the 'reasonable' future
# when 'value' is 'None', a ValidationError is not issued. # when 'value' is 'None', a ValidationError is not issued.
valid_time = validate_time_is_in_not_too_distant_future(raise_validation_error=True, value=value, months=12) valid_time = validate_time_is_in_not_too_distant_future(raise_validation_error=True, value=value, months=12)
return return
@validates("operations_start") @validates("operations_start")
def validate_operations_start(self, value): def validate_operations_start(self, value, **kwargs):
# violation when time is not in the future, but also does not exceed a threshold for the 'reasonable' future # violation when time is not in the future, but also does not exceed a threshold for the 'reasonable' future
# when 'value' is 'None', a ValidationError is not issued. # when 'value' is 'None', a ValidationError is not issued.
valid_time = validate_time_is_in_not_too_distant_future(raise_validation_error=True, value=value, months=12) valid_time = validate_time_is_in_not_too_distant_future(raise_validation_error=True, value=value, months=12)
return return
@validates("operations_end") @validates("operations_end")
def validate_operations_end(self, value): def validate_operations_end(self, value, **kwargs):
# violation when time is not in the future, but also does not exceed a threshold for the 'reasonable' future # violation when time is not in the future, but also does not exceed a threshold for the 'reasonable' future
# when 'value' is 'None', a ValidationError is not issued. # when 'value' is 'None', a ValidationError is not issued.
valid_time = validate_time_is_in_not_too_distant_future(raise_validation_error=True, value=value, months=12) valid_time = validate_time_is_in_not_too_distant_future(raise_validation_error=True, value=value, months=12)
return return
@validates("eta_interval_end") @validates("eta_interval_end")
def validate_eta_interval_end(self, value): def validate_eta_interval_end(self, value, **kwargs):
# violation when time is not in the future, but also does not exceed a threshold for the 'reasonable' future # violation when time is not in the future, but also does not exceed a threshold for the 'reasonable' future
# when 'value' is 'None', a ValidationError is not issued. # when 'value' is 'None', a ValidationError is not issued.
valid_time = validate_time_is_in_not_too_distant_future(raise_validation_error=True, value=value, months=12) valid_time = validate_time_is_in_not_too_distant_future(raise_validation_error=True, value=value, months=12)
return return
@validates("etd_interval_end") @validates("etd_interval_end")
def validate_etd_interval_end(self, value): def validate_etd_interval_end(self, value, **kwargs):
# violation when time is not in the future, but also does not exceed a threshold for the 'reasonable' future # violation when time is not in the future, but also does not exceed a threshold for the 'reasonable' future
# when 'value' is 'None', a ValidationError is not issued. # when 'value' is 'None', a ValidationError is not issued.
valid_time = validate_time_is_in_not_too_distant_future(raise_validation_error=True, value=value, months=12) valid_time = validate_time_is_in_not_too_distant_future(raise_validation_error=True, value=value, months=12)
@ -489,17 +521,22 @@ class UserSchema(Schema):
user_email = fields.String(allow_none=True, required=False, validate=[validate.Length(max=64)]) user_email = fields.String(allow_none=True, required=False, validate=[validate.Length(max=64)])
old_password = fields.String(allow_none=True, required=False, validate=[validate.Length(max=128)]) old_password = fields.String(allow_none=True, required=False, validate=[validate.Length(max=128)])
new_password = fields.String(allow_none=True, required=False, validate=[validate.Length(min=6, max=128)]) new_password = fields.String(allow_none=True, required=False, validate=[validate.Length(min=6, max=128)])
# #TODO: the user schema does not (yet) include the 'notify_' fields notify_email = fields.Bool(allow_none=True, required=False)
notify_whatsapp = fields.Bool(allow_none=True, required=False)
notify_signal = fields.Bool(allow_none=True, required=False)
notify_popup = fields.Bool(allow_none=True, required=False)
notify_on = fields.List(fields.Enum(NotificationType), required=False, allow_none=True)
@validates("user_phone") @validates("user_phone")
def validate_user_phone(self, value): def validate_user_phone(self, value, **kwargs):
if value is not None:
valid_characters = list(map(str,range(0,10)))+["+", " "] valid_characters = list(map(str,range(0,10)))+["+", " "]
if not all([v in valid_characters for v in value]): if not all([v in valid_characters for v in value]):
raise ValidationError({"user_phone":f"one of the phone number values is not valid."}) raise ValidationError({"user_phone":f"one of the phone number values is not valid."})
@validates("user_email") @validates("user_email")
def validate_user_email(self, value): def validate_user_email(self, value, **kwargs):
if not "@" in value: if value and not re.match(r"[^@]+@[^@]+\.[^@]+", value):
raise ValidationError({"user_email":f"invalid email address"}) raise ValidationError({"user_email":f"invalid email address"})
@ -542,12 +579,20 @@ class User:
user_phone: str user_phone: str
password_hash: str password_hash: str
api_key: str api_key: str
notify_email: bool # #TODO_clarify: should we use an IntFlag for multi-assignment? notify_email: bool
notify_whatsapp: bool # #TODO_clarify: should we use an IntFlag for multi-assignment? notify_whatsapp: bool
notify_signal: bool # #TODO_clarify: should we use an IntFlag for multi-assignment? notify_signal: bool
notify_popup: bool # #TODO_clarify: should we use an IntFlag for multi-assignment? notify_popup: bool
created: datetime created: datetime
modified: datetime modified: datetime
notify_event: int | None = 0
def __hash__(self):
return hash(id)
def wants_notifications(self, notification_type: NotificationType):
events = bitflag_to_list(self.notify_event)
return notification_type in events
@dataclass @dataclass
class Ship: class Ship:
@ -578,15 +623,15 @@ class ShipSchema(Schema):
participant_id = fields.Int(allow_none=True, required=False) participant_id = fields.Int(allow_none=True, required=False)
length = fields.Float(allow_none=True, required=False, validate=[validate.Range(min=0, max=1000, min_inclusive=False, max_inclusive=False)]) length = fields.Float(allow_none=True, required=False, validate=[validate.Range(min=0, max=1000, min_inclusive=False, max_inclusive=False)])
width = fields.Float(allow_none=True, required=False, validate=[validate.Range(min=0, max=100, min_inclusive=False, max_inclusive=False)]) width = fields.Float(allow_none=True, required=False, validate=[validate.Range(min=0, max=100, min_inclusive=False, max_inclusive=False)])
is_tug = fields.Bool(allow_none=True, required=False, default=False) is_tug = fields.Bool(allow_none=True, required=False, load_default=False, dump_default=False)
bollard_pull = fields.Int(allow_none=True, required=False) bollard_pull = fields.Int(allow_none=True, required=False)
eni = fields.Int(allow_none=True, required=False) eni = fields.Int(allow_none=True, required=False)
created = fields.DateTime(allow_none=True, required=False) created = fields.DateTime(allow_none=True, required=False)
modified = fields.DateTime(allow_none=True, required=False) modified = fields.DateTime(allow_none=True, required=False)
deleted = fields.Bool(allow_none=True, required=False, default=False) deleted = fields.Bool(allow_none=True, required=False, load_default=False, dump_default=False)
@validates("name") @validates("name")
def validate_name(self, value): def validate_name(self, value, **kwargs):
character_length = len(str(value)) character_length = len(str(value))
if character_length<1: if character_length<1:
raise ValidationError({"name":f"'name' argument should have at least one character"}) raise ValidationError({"name":f"'name' argument should have at least one character"})
@ -598,7 +643,7 @@ class ShipSchema(Schema):
return return
@validates("imo") @validates("imo")
def validate_imo(self, value): def validate_imo(self, value, **kwargs):
value = str(value).zfill(7) # 1 becomes '0000001' (7 characters). 12345678 becomes '12345678' (8 characters) value = str(value).zfill(7) # 1 becomes '0000001' (7 characters). 12345678 becomes '12345678' (8 characters)
imo_length = len(value) imo_length = len(value)
if imo_length != 7: if imo_length != 7:
@ -606,7 +651,7 @@ class ShipSchema(Schema):
return return
@validates("callsign") @validates("callsign")
def validate_callsign(self, value): def validate_callsign(self, value, **kwargs):
if value is not None: if value is not None:
callsign_length = len(str(value)) callsign_length = len(str(value))
if callsign_length>8: if callsign_length>8:

View File

@ -38,6 +38,9 @@ class EmailHandler():
self.server = smtplib.SMTP_SSL(self.mail_server, self.mail_port) # alternatively, SMTP 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): def check_state(self):
"""check, whether the server login took place and is open.""" """check, whether the server login took place and is open."""
try: try:

View File

@ -1,9 +1,17 @@
import logging import logging
import pydapper import pydapper
from BreCal.schemas import model import smtplib
import json
import os
from email.message import EmailMessage
from BreCal.schemas import model, defs
from BreCal.local_db import getPoolConnection from BreCal.local_db import getPoolConnection
from BreCal.database.update_database import evaluate_shipcall_state from BreCal.database.update_database import evaluate_shipcall_state
from BreCal.database.sql_queries import create_sql_query_shipcall_get from BreCal.database.sql_queries import create_sql_query_shipcall_get
from BreCal.database.sql_queries import SQLQuery
from BreCal.database.sql_utils import get_notification_for_shipcall_and_type
from BreCal.services.email_handling import EmailHandler
import threading import threading
import schedule import schedule
@ -23,6 +31,7 @@ def UpdateShipcalls(options:dict = {'past_days':2}):
options: options:
key: 'past_days'. Is used to execute a filtered query of all available shipcalls. Defaults to 2 (days) key: 'past_days'. Is used to execute a filtered query of all available shipcalls. Defaults to 2 (days)
""" """
pooledConnection = None
try: try:
pooledConnection = getPoolConnection() pooledConnection = getPoolConnection()
commands = pydapper.using(pooledConnection) commands = pydapper.using(pooledConnection)
@ -31,6 +40,8 @@ def UpdateShipcalls(options:dict = {'past_days':2}):
query = create_sql_query_shipcall_get(options) query = create_sql_query_shipcall_get(options)
data = commands.query(query, model=model.Shipcall) data = commands.query(query, model=model.Shipcall)
data = [s for s in data if not s.canceled] # filter out canceled shipcalls
# get the shipcall ids, which are of interest # get the shipcall ids, which are of interest
shipcall_ids = [dat.id for dat in data] shipcall_ids = [dat.id for dat in data]
@ -39,21 +50,280 @@ def UpdateShipcalls(options:dict = {'past_days':2}):
# apply 'Traffic Light' evaluation to obtain 'GREEN', 'YELLOW' or 'RED' evaluation state. The function internally updates the mysql database # apply 'Traffic Light' evaluation to obtain 'GREEN', 'YELLOW' or 'RED' evaluation state. The function internally updates the mysql database
evaluate_shipcall_state(mysql_connector_instance=pooledConnection, shipcall_id=shipcall_id) # new_id (last insert id) refers to the shipcall id evaluate_shipcall_state(mysql_connector_instance=pooledConnection, shipcall_id=shipcall_id) # new_id (last insert id) refers to the shipcall id
except Exception as ex:
logging.error(ex)
finally:
if pooledConnection is not None:
pooledConnection.close() pooledConnection.close()
return
def UpdateNotifications(cooldown_in_mins:int=10):
"""
This function evaluates all notifications in state ("level") 0 which have been recently created. If a specified amount of time has passed the
notification is updated to state 1 and a notification is received by the user
"""
pooledConnection = None
try:
pooledConnection = getPoolConnection()
commands = pydapper.using(pooledConnection)
query = f"SELECT * FROM notification WHERE level = 0 AND created < TIMESTAMP(NOW() - INTERVAL {cooldown_in_mins} MINUTE)"
data = commands.query(query, model=model.Notification)
for notification in data:
commands.execute("UPDATE notification SET level = 1 WHERE id = ?id?", param={"id":notification.id})
except Exception as ex: except Exception as ex:
logging.error(ex) logging.error(ex)
finally:
if pooledConnection is not None:
pooledConnection.close()
def ClearNotifications(max_age_in_days:int=3):
"""
This function clears all notifications in state ("level") 2 that are older than x days
"""
pooledConnection = None
try:
pooledConnection = getPoolConnection()
commands = pydapper.using(pooledConnection)
query = f"DELETE FROM notification WHERE level = 2 and created < TIMESTAMP(NOW() - INTERVAL {max_age_in_days} DAY)"
result = commands.execute(query)
if(result > 0):
logging.info(f"Deleted {result} notifications")
except Exception as ex:
logging.error(ex)
finally:
if pooledConnection is not None:
pooledConnection.close()
def SendEmails(email_dict):
"""
This function sends emails to all users in the emaildict
"""
pooledConnection = None
conn = None
try:
pooledConnection = getPoolConnection()
commands = pydapper.using(pooledConnection)
conn = smtplib.SMTP(defs.email_credentials["server"], defs.email_credentials["port"])
conn.set_debuglevel(1) # set this to 0 to disable debug output to log
conn.ehlo()
conn.starttls()
conn.ehlo()
conn.login(defs.email_credentials["sender"], defs.email_credentials["password_send"])
current_path = os.path.dirname(os.path.abspath(__file__))
if not defs.message_types:
f = open(os.path.join(current_path,"../msg/msg_types.json"), encoding='utf-8');
defs.message_types = json.load(f)
f.close()
for user_email, notifications in email_dict.items():
msg = EmailMessage()
msg["Subject"] = '[Bremen calling] Notification'
msg["From"] = defs.email_credentials["sender"]
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()
replacement = ""
for notification in notifications:
message_type = next((x for x in defs.message_types if x["type"] == notification.type), None)
if message_type is None:
logging.error(f"Message type {notification.type} not found")
continue
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"])
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
sentinel = object()
shipcall = commands.query_single_or_default("SELECT * FROM shipcall WHERE id = ?id?", sentinel, model=model.Shipcall, param={"id":notification.shipcall_id})
if shipcall is sentinel:
logging.error(f"Shipcall with id {notification.shipcall_id} not found")
continue
shipcall_type = defs.shipcall_types[shipcall.type]
eta_text = shipcall.eta.strftime("%d.%m.%Y %H:%M") if shipcall.type == 1 else shipcall.etd.strftime("%d.%m.%Y %H:%M")
ship = commands.query_single_or_default("SELECT * FROM ship WHERE id = ?id?", sentinel, model=model.Ship, param={"id":shipcall.ship_id})
if ship is sentinel:
logging.error(f"Ship with id {shipcall.ship_id} not found")
continue
berth_id = shipcall.arrival_berth_id if shipcall.type == 1 else shipcall.departure_berth_id
berth = commands.query_single_or_default("SELECT * FROM berth WHERE id = ?id?", sentinel, model=model.Berth, param={"id":berth_id})
berth_text = ""
if berth is not sentinel:
berth_text = berth.name
times = commands.query_single_or_default("SELECT * FROM times WHERE shipcall_id = ?id? and participant_type = 8", sentinel, model=model.Times, param={"id":notification.shipcall_id})
if times is not sentinel:
eta_text = times.eta_berth.strftime("%d.%m.%Y %H:%M") if shipcall.type == 1 else times.etd_berth.strftime("%d.%m.%Y %H:%M")
text = f"{ship.name} ({shipcall_type}) - {eta_text} - {berth_text}"
element = element.replace("[[text]]", text)
element = element.replace("[[notification_text]]", message_type["msg_text"])
replacement += element
body = body.replace("[[NOTIFICATION_ELEMENTS]]", replacement)
msg.set_content(body, subtype='html', charset='utf-8', cte='8bit')
conn.sendmail(defs.email_credentials["sender"], user_email, msg.as_string())
except Exception as ex:
logging.error(ex)
finally:
if conn is not None:
conn.quit()
if pooledConnection is not None:
pooledConnection.close()
def SendNotifications():
# perhaps this will be moved somewhere else later
pooledConnection = None
try:
# find all notifications in level 1
pooledConnection = getPoolConnection()
query = "SELECT * from notification WHERE level = 1"
commands = pydapper.using(pooledConnection)
data = commands.query(query, model=model.Notification)
if len(data) == 0:
return return
# cache participants and users for performance beforehand
query = "SELECT * from participant";
participants = commands.query(query, model=model.Participant)
email_dict = dict()
users_dict = dict()
user_query = "SELECT * from user"
users = commands.query(user_query)
for participant in participants:
for user in users:
if user["participant_id"] == participant.id:
if not participant.id in users_dict:
users_dict[participant.id] = []
users_dict[participant.id].append(user)
# break
for notification in data:
if not notification.participant_id: # no participant defined, this update goes to all participants of this shipcall
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["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"]:
# TBD
pass
else:
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})
# send emails (if any)
if len(email_dict) > 0:
SendEmails(email_dict)
except Exception as ex:
logging.error(ex)
finally:
if pooledConnection is not None:
pooledConnection.close()
def add_function_to_schedule__update_shipcalls(interval_in_minutes:int, options:dict={'past_days':2}): def add_function_to_schedule__update_shipcalls(interval_in_minutes:int, options:dict={'past_days':2}):
kwargs_ = {"options":options} kwargs_ = {"options":options}
schedule.every(interval_in_minutes).minutes.do(UpdateShipcalls, **kwargs_) schedule.every(interval_in_minutes).minutes.do(UpdateShipcalls, **kwargs_)
return return
def add_function_to_schedule__send_notifications(vr, interval_in_minutes:int=10): def add_function_to_evaluate_notifications(interval_in_minutes:int=1):
schedule.every(interval_in_minutes).minutes.do(vr.notifier.send_notifications) schedule.every(1).minutes.do(UpdateNotifications, interval_in_minutes)
return return
def add_function_to_clear_notifications(interval_in_days:int=3):
schedule.every(30).minutes.do(ClearNotifications, interval_in_days)
return
def add_function_to_schedule_send_notifications(interval_in_minutes:int=1):
schedule.every(interval_in_minutes).minutes.do(SendNotifications)
return
def eval_next_24_hrs():
pooledConnection = None
try:
pooledConnection = getPoolConnection()
commands = pydapper.using(pooledConnection)
query = SQLQuery.get_next24hrs_shipcalls()
data = commands.query(query)
nquery = "INSERT INTO notification (shipcall_id, participant_id, level, type, message) VALUES (?shipcall_id?, ?participant_id?, 0, 2, ?message?)"
for shipcall in data:
existing_notifications = get_notification_for_shipcall_and_type(shipcall["id"], 2)
query = SQLQuery.get_shipcall_participant_map_by_shipcall_id()
participants = commands.query(query, model=dict, param={"id":shipcall["id"]})
for participant in participants:
if participant["type"] == 1: # BSMD
continue
# if participant["type"] == 32: # PORT AUTHORITY # Christin: Brake möchte sie vielleicht doch haben
# continue
# check if "open" notification already exists
found_notification = False
for existing_notification in existing_notifications:
if existing_notification["participant_id"] == participant["id"] and existing_notification["level"] == 0:
found_notification = True
break
if not found_notification:
commands.execute(nquery, param={"shipcall_id":shipcall["id"], "participant_id": participant["participant_id"], "message":shipcall["name"]})
except Exception as ex:
logging.error(ex)
finally:
if pooledConnection is not None:
pooledConnection.close()
return
def setup_schedule(update_shipcalls_interval_in_minutes:int=60): def setup_schedule(update_shipcalls_interval_in_minutes:int=60):
@ -64,8 +334,14 @@ def setup_schedule(update_shipcalls_interval_in_minutes:int=60):
# update the evaluation state in every recent shipcall # update the evaluation state in every recent shipcall
add_function_to_schedule__update_shipcalls(update_shipcalls_interval_in_minutes) add_function_to_schedule__update_shipcalls(update_shipcalls_interval_in_minutes)
# placeholder: create/send notifications add_function_to_evaluate_notifications(defs.NOTIFICATION_COOLDOWN_MINS)
# add_function_to_schedule__send_notifications(...)
add_function_to_clear_notifications(defs.NOTIFICATION_MAX_AGE_DAYS)
schedule.every().day.at("09:00").do(eval_next_24_hrs)
add_function_to_schedule_send_notifications(1)
return return

View File

@ -24,6 +24,7 @@ def get_user_simple():
notify_signal = True notify_signal = True
notify_popup = True notify_popup = True
user = User( user = User(
user_id, user_id,
participant_id, participant_id,

View 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."})

View File

@ -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.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_int_is_valid_flag
from BreCal.validators.validation_base_utils import check_if_string_has_special_characters from BreCal.validators.validation_base_utils import check_if_string_has_special_characters
from BreCal.validators.input_validation_base import InputValidationBase
import werkzeug import werkzeug
class InputValidationShip(): class InputValidationShip(InputValidationBase):
""" """
This class combines a complex set of individual input validation functions into a joint object. 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. 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) # 3.) Check for reasonable Values (see BreCal.schemas.model.ShipSchema)
InputValidationShip.optionally_evaluate_bollard_pull_value(content) 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 return
@staticmethod @staticmethod
@ -70,6 +78,10 @@ class InputValidationShip():
# 4.) Check for reasonable Values (see BreCal.schemas.model.ShipSchema) # 4.) Check for reasonable Values (see BreCal.schemas.model.ShipSchema)
InputValidationShip.optionally_evaluate_bollard_pull_value(content) InputValidationShip.optionally_evaluate_bollard_pull_value(content)
# 5.) Check if tug is null
InputValidationShip.check_is_tug_null(content)
return return
@staticmethod @staticmethod
@ -110,7 +122,7 @@ class InputValidationShip():
ships = json.loads(response) ships = json.loads(response)
# extract only the 'imo' values # extract only the 'imo' values
ship_imos = [ship.get("imo") for ship in ships if not ship.deleted] ship_imos = [ship.get("imo") for ship in ships if not ship.get("deleted")]
# check, if the imo in the POST-request already exists in the list # check, if the imo in the POST-request already exists in the list
imo_already_exists = loadedModel.get("imo") in ship_imos imo_already_exists = loadedModel.get("imo") in ship_imos
@ -159,5 +171,11 @@ class InputValidationShip():
raise ValidationError({"deleted":f"The selected ship entry is already deleted."}) raise ValidationError({"deleted":f"The selected ship entry is already deleted."})
return 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

View File

@ -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.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_int_is_valid_flag
from BreCal.validators.validation_base_utils import check_if_string_has_special_characters 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 from BreCal.database.sql_queries import SQLQuery
import werkzeug import werkzeug
class InputValidationShipcall(): class InputValidationShipcall(InputValidationBase):
""" """
This class combines a complex set of individual input validation functions into a joint object. 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. 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) InputValidationShipcall.check_participant_list_not_empty_when_user_is_agency(loadedModel)
# check for reasonable values in the shipcall fields # 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 return
@staticmethod @staticmethod
@ -498,7 +503,7 @@ class InputValidationShipcall():
# if the *existing* shipcall in the database is canceled, it may not be changed # if the *existing* shipcall in the database is canceled, it may not be changed
if shipcall.get("canceled", False): if shipcall.get("canceled", False):
raise ValidationError({"canceled":f"The shipcall with id 'shipcall_id' is canceled. A canceled shipcall may not be changed."}) raise ValidationError({"canceled":f"The shipcall with id {shipcall_id} is canceled. A canceled shipcall may not be changed."})
return return
@staticmethod @staticmethod
@ -546,12 +551,18 @@ class InputValidationShipcall():
# query = 'SELECT * FROM shipcall_participant_map where (shipcall_id = ?shipcall_id? AND type=?participant_type?)' # query = 'SELECT * FROM shipcall_participant_map where (shipcall_id = ?shipcall_id? AND type=?participant_type?)'
# assigned_agency = execute_sql_query_standalone(query=query, model=ShipcallParticipantMap, param={"shipcall_id" : shipcall_id, "participant_type":int(ParticipantType.AGENCY)}) # assigned_agency = execute_sql_query_standalone(query=query, model=ShipcallParticipantMap, param={"shipcall_id" : shipcall_id, "participant_type":int(ParticipantType.AGENCY)})
assigned_agency = get_assigned_participant_of_type(shipcall_id, participant_type=ParticipantType.AGENCY) assigned_agency = get_assigned_participant_of_type(shipcall_id, participant_type=ParticipantType.AGENCY)
assigned_pilot = get_assigned_participant_of_type(shipcall_id, participant_type=ParticipantType.PILOT)
an_agency_is_assigned = True if assigned_agency is not None else False an_agency_is_assigned = True if assigned_agency is not None else False
a_pilot_is_assigned = True if assigned_pilot is not None else False
else: else:
# Agency assigned? User must belong to the assigned agency or be a BSMD user, in case the flag is set # Agency assigned? User must belong to the assigned agency or be a BSMD user, in case the flag is set
assigned_agency = [spm for spm in shipcall_participant_map if int(spm.type) == int(ParticipantType.AGENCY)] assigned_agency = [spm for spm in shipcall_participant_map if int(spm.type) == int(ParticipantType.AGENCY)]
assigned_pilot = [spm for spm in shipcall_participant_map if int(spm.type) == int(ParticipantType.PILOT)]
an_agency_is_assigned = len(assigned_agency)==1 an_agency_is_assigned = len(assigned_agency)==1
a_pilot_is_assigned = len(assigned_pilot)==1
if a_pilot_is_assigned:
assigned_pilot = assigned_pilot[0]
if len(assigned_agency)>1: if len(assigned_agency)>1:
raise ValidationError({"internal_error":f"Internal error? Found more than one assigned agency for the shipcall with ID {shipcall_id}. Found: {assigned_agency}"}) raise ValidationError({"internal_error":f"Internal error? Found more than one assigned agency for the shipcall with ID {shipcall_id}. Found: {assigned_agency}"})
@ -567,18 +578,19 @@ class InputValidationShipcall():
### USER authority ### ### USER authority ###
# determine, whether the user is a) the assigned agency or b) a BSMD participant # determine, whether the user is a) the assigned agency or b) a BSMD participant
user_is_assigned_agency = (user_participant_id == assigned_agency.id) user_is_assigned_agency = (user_participant_id == assigned_agency.id)
user_is_assigned_pilot = a_pilot_is_assigned and (user_participant_id == assigned_pilot.id)
# when the BSMD flag is set: the user must be either BSMD or the assigned agency # when the BSMD flag is set: the user must be either BSMD or the assigned agency
# when the BSMD flag is not set: the user must be the assigned agency # when the BSMD flag is not set: the user must be the assigned agency
user_is_authorized = (user_is_bsmd or user_is_assigned_agency) #if agency_has_bsmd_flag else user_is_assigned_agency user_is_authorized = (user_is_bsmd or user_is_assigned_agency or user_is_assigned_pilot) #if agency_has_bsmd_flag else user_is_assigned_agency
if not user_is_authorized: if not user_is_authorized:
raise werkzeug.exceptions.Forbidden(f"PUT Requests for shipcalls can only be issued by an assigned AGENCY or BSMD users (if the special-flag is enabled). Assigned Agency: {assigned_agency} with Flags: {assigned_agency.flags}") # Forbidden: 403 raise werkzeug.exceptions.Forbidden(f"PUT Requests for shipcalls can only be issued by an assigned AGENCY / BSMD / PILOT (if the special-flag is enabled). Assigned Agency: {assigned_agency} with Flags: {assigned_agency.flags}") # Forbidden: 403
else: else:
# when there is no assigned agency, only BSMD users can update the shipcall # when there is no assigned agency, only BSMD users can update the shipcall
if not user_is_bsmd: if not user_is_bsmd:
raise werkzeug.exceptions.Forbidden(f"PUT Requests for shipcalls can only be issued by an assigned AGENCY or BSMD users (if the special-flag is enabled). There is no assigned agency yet, so only BSMD users can change datasets.") # part of a pytest.raises. Forbidden: 403 raise werkzeug.exceptions.Forbidden(f"PUT Requests for shipcalls can only be issued by an assigned AGENCY / BSMD / PILOT users (if the special-flag is enabled). There is no assigned agency yet, so only BSMD users can change datasets.") # part of a pytest.raises. Forbidden: 403
return return

View File

@ -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 execute_sql_query_standalone
from BreCal.database.sql_handler import get_assigned_participant_of_type from BreCal.database.sql_handler import get_assigned_participant_of_type
from BreCal.database.sql_utils import get_times_data_for_id 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 from BreCal.validators.validation_base_utils import check_if_int_is_valid_flag, check_if_string_has_special_characters
import werkzeug 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. 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. 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 # 4.) Value checking
InputValidationTimes.check_dataset_values(user_data, loadedModel, content) 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 return
@staticmethod @staticmethod

View File

@ -1,20 +1,19 @@
import typing import typing
from string import ascii_letters, digits import re
_VALID = re.compile(r'^[\w &-]+$') # \w == Unicode letters+digits+underscore
def check_if_string_has_special_characters(text:typing.Optional[str]): def check_if_string_has_special_characters(text:typing.Optional[str]):
""" """
check, whether there are any characters within the provided string, which are not found in the ascii letters or digits check, whether there are any characters within the provided string, which are not found in the ascii letters or digits
ascii_letters: abcd (...) and ABCD (...)
digits: 0123 (...)
Formerly, this solution was used but was found to be too restrictive:
Source: https://stackoverflow.com/questions/57062794/is-there-a-way-to-check-if-a-string-contains-special-characters Source: https://stackoverflow.com/questions/57062794/is-there-a-way-to-check-if-a-string-contains-special-characters
User: https://stackoverflow.com/users/10035985/andrej-kesely User: https://stackoverflow.com/users/10035985/andrej-kesely
returns bool returns bool
""" """
if text is None: return not _VALID.fullmatch(text) if text else False
return False
return bool(set(text).difference(ascii_letters + digits + ' '))
def check_if_int_is_valid_flag(value, enum_object): def check_if_int_is_valid_flag(value, enum_object):

View File

@ -59,7 +59,7 @@ def create_validation_error_response(ex:ValidationError, status_code:int=400, cr
if create_log: if create_log:
logging.warning(ex) if ex is not None else logging.warning(message) 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) return (serialized_response, status_code)
def create_werkzeug_error_response(ex:Forbidden, status_code:int=403, create_log:bool=True)->typing.Tuple[str,int]: 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: if create_log:
logging.warning(ex) if ex is not None else logging.warning(message) 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 return serialized_response, status_code
def create_dynamic_exception_response(ex, status_code:int=400, message:typing.Optional[str]=None, create_log:bool=True): 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: if create_log:
logging.warning(ex) if ex is not None else logging.warning(message) 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) return (serialized_response, status_code)

View File

@ -1,5 +1,6 @@
import copy import copy
import logging import logging
import pydapper
import re import re
import numpy as np import numpy as np
import pandas as pd import pandas as pd
@ -7,6 +8,7 @@ import datetime
from BreCal.database.enums import StatusFlags from BreCal.database.enums import StatusFlags
from BreCal.validators.validation_rule_functions import ValidationRuleFunctions from BreCal.validators.validation_rule_functions import ValidationRuleFunctions
from BreCal.schemas.model import Shipcall from BreCal.schemas.model import Shipcall
from BreCal.local_db import getPoolConnection
class ValidationRules(ValidationRuleFunctions): class ValidationRules(ValidationRuleFunctions):
@ -50,6 +52,7 @@ class ValidationRules(ValidationRuleFunctions):
# 'translate' all error codes into readable, human-understandable format. # 'translate' all error codes into readable, human-understandable format.
evaluation_results = [(state, self.describe_error_message(msg)) for (state, msg) in evaluation_results] evaluation_results = [(state, self.describe_error_message(msg)) for (state, msg) in evaluation_results]
if evaluation_results:
logging.info(f"Validation results for shipcall {shipcall.id}: {evaluation_results}") logging.info(f"Validation results for shipcall {shipcall.id}: {evaluation_results}")
# check, what the maximum state flag is and return it # check, what the maximum state flag is and return it
@ -84,9 +87,54 @@ class ValidationRules(ValidationRuleFunctions):
# build the list of evaluation times ('now', as isoformat) # build the list of evaluation times ('now', as isoformat)
#evaluation_time = self.get_notification_times(evaluation_states_new) #evaluation_time = self.get_notification_times(evaluation_states_new)
send_notification = False
if evaluation_states_old is not None and evaluation_states_new is not None:
if len(evaluation_states_old) == 1 and len(evaluation_states_new) == 1:
if evaluation_states_old[0] != evaluation_states_new[0]:
pooledConnection = None
try:
pooledConnection = getPoolConnection()
commands = pydapper.using(pooledConnection)
notification_type = 3 # RED (mapped to time_conflict)
if evaluation_states_new[0] == 2:
match evaluation_states_old[0]:
case 0:
send_notification = True
case 1:
send_notification = True
notification_type = 6 # YELLOW (mapped to missing_data)
if evaluation_states_new[0] == 3:
match evaluation_states_old[0]:
case 0:
send_notification = True
case 1:
send_notification = True
case 2:
send_notification = True
if send_notification:
query = f"INSERT INTO notification (shipcall_id, type, level, message) VALUES (?shipcall_id?, {notification_type}, 0, ?message?)"
commands.execute(query, param={"shipcall_id" : int(shipcall_df.index[0]), "message" : violations[0]})
if evaluation_states_new[0] == 1 and evaluation_states_old[0] != 0: # this resolves the conflict
query = f"SELECT * from notification where shipcall_id = ?shipcall_id? and type = {notification_type} and level = 0"
existing_notification = commands.query(query, param={"shipcall_id" : int(shipcall_df.index[0])})
if len(existing_notification) > 0:
query = "DELETE from notification where id = ?id?"
commands.execute(query, param={"id" : existing_notification[0]["id"]})
else:
query = "INSERT INTO notification (shipcall_id, type, level) VALUES (?shipcall_id?, 4, 0)"
commands.execute(query, param={"shipcall_id" : int(shipcall_df.index[0])})
finally:
if pooledConnection is not None:
pooledConnection.close()
# build the list of 'evaluation_notifications_sent'. The value is 'False', when a notification should be created # build the list of 'evaluation_notifications_sent'. The value is 'False', when a notification should be created
#evaluation_notifications_sent = self.get_notification_states(evaluation_states_old, evaluation_states_new) #evaluation_notifications_sent = self.get_notification_states(evaluation_states_old, evaluation_states_new)
# TODO: detect evaluation state changes and create notifications
shipcall_df.loc[:,"evaluation"] = evaluation_states_new shipcall_df.loc[:,"evaluation"] = evaluation_states_new
shipcall_df.loc[:,"evaluation_message"] = violations shipcall_df.loc[:,"evaluation_message"] = violations
#shipcall_df.loc[:,"evaluation_time"] = evaluation_time #shipcall_df.loc[:,"evaluation_time"] = evaluation_time

View 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

View File

@ -1,20 +1,29 @@
import os
import sys
import logging import logging
import os
import runpy
import sys
from pathlib import Path
sys.path.insert(0, '/var/www/brecal_devel/src/server') BASE_DIR = Path(__file__).resolve().parent
sys.path.insert(0, '/var/www/venv/lib/python3.12/site-packages/') 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 app_root = config.get("APP_ROOT", str(BASE_DIR))
os.environ['SECRET_KEY'] = 'zdiTz8P3jXOc7jztIQAoelK4zztyuCpJ' site_packages = config.get("SITE_PACKAGES")
# Set up logging sys.path.insert(0, app_root)
logging.basicConfig(stream=sys.stderr, level=logging.DEBUG) 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 from BreCal import create_app
application = create_app() application = create_app(instance_path=config.get("INSTANCE_PATH"))

View File

@ -2,7 +2,7 @@ from setuptools import find_packages, setup
setup( setup(
name='BreCal', name='BreCal',
version='1.6.0', version='1.7.0',
packages=find_packages(), packages=find_packages(),
include_package_data=True, include_package_data=True,
zip_safe=False, zip_safe=False,

Some files were not shown because too many files have changed in this diff Show More