Compare commits

...

40 Commits

Author SHA1 Message Date
f25e508bbd fix: editing availability 2025-06-22 22:37:12 +02:00
b65b4c7a00 feat: validate availabilit changeset 2025-06-22 22:37:12 +02:00
b2969b988d refactor: custom context for validation 2025-06-22 21:33:01 +02:00
9666932915 feat: custom async validation 2025-06-19 16:24:27 +02:00
0b4248604a doc: bump version 2025-06-15 22:08:15 +02:00
5afaac6197 chore: update npm packages 2025-06-15 22:07:48 +02:00
95f807b51d style: profile and logout button 2025-06-15 22:03:53 +02:00
cca925f4eb feat: enforce implicit lowercase email address 2025-06-15 21:53:31 +02:00
90ac5c306d chore: remove obsolete spec file 2025-06-15 18:42:14 +02:00
03964d3542 refactor: add hostname to customizations 2025-06-10 09:15:27 +02:00
2774c6e48a doc: update readme for release 2025-06-10 09:15:09 +02:00
7f5941ba6a fix: failing tests 2025-06-09 20:03:12 +02:00
e591b419bb feat: calculate dates for export events 2025-06-09 19:26:57 +02:00
784b7cea4e fix: export for area manager 2025-06-09 18:47:16 +02:00
2b9e6cfefd feat: increase registration expiry to 5 days 2025-06-09 14:23:26 +02:00
2b2b2b4c34 doc: bump version 2025-06-09 14:13:17 +02:00
4dfa29d6f1 feat: favicon 2025-06-09 14:09:07 +02:00
bce103b086 feat: customization for imprint 2025-06-09 13:55:24 +02:00
fe2f616bea feat: use hostname for sender mail 2025-06-09 10:55:44 +02:00
f448d31193 feat: add tracing to test mail 2025-06-09 10:53:42 +02:00
c1f31fff7c refactor: required fuehrungsassistent 2025-06-04 16:01:05 +02:00
301a7a8af8 chore: regenerate sqlx queries 2025-06-04 15:48:07 +02:00
d8eb9ecbf3 feat: use filter for export 2025-06-04 15:47:00 +02:00
d1e1ccd906 refactor: correct output of event export 2025-06-04 15:24:27 +02:00
f953b6d208 refactor: move into models 2025-06-04 11:55:39 +02:00
075cdc713d refactor: fomatting 2025-05-31 22:41:39 +02:00
01cf373b98 feat: export events into xlsx 2025-05-25 22:00:15 +02:00
4af004456f feat: export events ui 2025-05-25 19:31:16 +02:00
1c4eb6ba83 feat: style navigation for mobile 2025-05-24 22:39:35 +02:00
a608204103 feat: customize webmaster email 2025-05-24 21:27:33 +02:00
513e8983b9 refactor: lift clothing input rules by safely escaping 2025-05-24 18:13:48 +02:00
f1a22f83aa feat: ordering of queries 2025-05-22 23:02:21 +02:00
e86d38d079 fix: availability template not displaying 2025-05-22 22:48:06 +02:00
10468ceba2 feat: use select clothing in event 2025-05-22 22:26:36 +02:00
f874fcd788 test: clothing 2025-05-22 22:15:45 +02:00
045a509daf feat: styling of clothing input 2025-05-22 21:43:32 +02:00
8d4a981055 feat: WIP clothing edit and delete 2025-05-20 20:55:36 +02:00
5b5e312152 feat: WIP add clothing table 2025-05-18 22:17:10 +02:00
c9b075216a refactor: date time formatting 2025-05-13 11:07:27 +02:00
55291d1cb0 doc: update rc.d 2025-05-12 22:23:08 +02:00
120 changed files with 2926 additions and 697 deletions

2
.env
View File

@ -6,9 +6,11 @@ SQLX_OFFLINE=true
# 64 byte long openssl rand -base64 64 # 64 byte long openssl rand -base64 64
SECRET_KEY="changeInProdOrHandAb11111111111111111111111111111111111111111111" SECRET_KEY="changeInProdOrHandAb11111111111111111111111111111111111111111111"
HOSTNAME="localhost" HOSTNAME="localhost"
WEBMASTER_EMAIL="admin@example.com"
SERVER_ADDRESS="127.0.0.1" SERVER_ADDRESS="127.0.0.1"
SERVER_PORT="8080" SERVER_PORT="8080"
APP_ENVIRONMENT="development"
SMTP_SERVER="localhost" SMTP_SERVER="localhost"
SMTP_PORT="1025" SMTP_PORT="1025"
# SMTP_LOGIN="" # SMTP_LOGIN=""

View File

@ -6,8 +6,10 @@ SQLX_OFFLINE=true
# 64 byte long openssl rand -base64 64 # 64 byte long openssl rand -base64 64
SECRET_KEY="changeInProdOrHandAb11111111111111111111111111111111111111111111" SECRET_KEY="changeInProdOrHandAb11111111111111111111111111111111111111111111"
HOSTNAME="localhost" HOSTNAME="localhost"
WEBMASTER_EMAIL="admin@example.com"
SERVER_ADDRESS="127.0.0.1" SERVER_ADDRESS="127.0.0.1"
SERVER_PORT="8080" SERVER_PORT="8080"
APP_ENVIRONMENT="development"
SMTP_SERVER="localhost" SMTP_SERVER="localhost"
SMTP_PORT="1025" SMTP_PORT="1025"

View File

@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "SELECT * FROM clothing ORDER by name;",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Text"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false
]
},
"hash": "03c789e902e84a8a3e753ac7d64c821e368b367eed085b3dc1bdc41cdf0ee6d2"
}

View File

@ -0,0 +1,49 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n availability.id,\n availability.userId,\n availability.startTimestamp,\n availability.endTimestamp,\n availability.comment\n FROM availability\n WHERE availability.userId = $1\n AND (availability.endtimestamp = $2\n OR availability.starttimestamp = $3)\n AND (availability.id <> $4 OR $4 IS NULL);\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "userid",
"type_info": "Int4"
},
{
"ordinal": 2,
"name": "starttimestamp",
"type_info": "Timestamptz"
},
{
"ordinal": 3,
"name": "endtimestamp",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "comment",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Int4",
"Timestamptz",
"Timestamptz",
"Int4"
]
},
"nullable": [
false,
false,
false,
false,
true
]
},
"hash": "2288f64f63f07e7dd947c036e5c2be4c563788b3b988b721bd12797fd19a7a95"
}

View File

@ -0,0 +1,106 @@
{
"db_name": "PostgreSQL",
"query": "select\n\tevent.starttimestamp,\n\tevent.endtimestamp,\n\tevent.amountofposten,\n event.voluntaryfuehrungsassistent,\n event.voluntarywachhabender,\n\tlocation.name as locationName,\n\tevent.name as eventName,\n\tarray (\n\tselect\n\t\trow (user_.name, assignment.function) ::simpleAssignment\n\tfrom\n\t\tassignment\n\tjoin availability on\n\t\tassignment.availabilityid = availability.id\n\tjoin user_ on\n\t\tavailability.userid = user_.id\n\twhere\n\t\tassignment.eventId = event.id) as \"assignments: Vec<SimpleAssignment>\",\n\t\tarray (\n\tselect\n\t\tvehicle.station || ' ' || vehicle.radiocallname\n\tfrom\n\t\tvehicleassignment\n\tjoin vehicle on\n\t\tvehicleassignment.vehicleId = vehicle.id\n\twhere\n\t\tvehicleassignment.eventId = event.id) as vehicles\nfrom\n\tevent\njoin location on\n\tevent.locationId = location.id\n where event.starttimestamp::date >= $1 and event.starttimestamp::date <= $2 and location.areaId = $3\n order by event.starttimestamp",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "starttimestamp",
"type_info": "Timestamptz"
},
{
"ordinal": 1,
"name": "endtimestamp",
"type_info": "Timestamptz"
},
{
"ordinal": 2,
"name": "amountofposten",
"type_info": "Int2"
},
{
"ordinal": 3,
"name": "voluntaryfuehrungsassistent",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "voluntarywachhabender",
"type_info": "Bool"
},
{
"ordinal": 5,
"name": "locationname",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "eventname",
"type_info": "Text"
},
{
"ordinal": 7,
"name": "assignments: Vec<SimpleAssignment>",
"type_info": {
"Custom": {
"name": "simpleassignment[]",
"kind": {
"Array": {
"Custom": {
"name": "simpleassignment",
"kind": {
"Composite": [
[
"name",
"Text"
],
[
"function",
{
"Custom": {
"name": "function",
"kind": {
"Enum": [
"posten",
"fuehrungsassistent",
"wachhabender"
]
}
}
}
]
]
}
}
}
}
}
}
},
{
"ordinal": 8,
"name": "vehicles",
"type_info": "TextArray"
}
],
"parameters": {
"Left": [
"Date",
"Date",
"Int4"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
null,
null
]
},
"hash": "366b4004828057e3ab47704c4856ea4e41c92af0a388862f77c37ea611a5d193"
}

View File

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "\n SELECT\n event.id AS eventId,\n event.startTimestamp,\n event.endTimestamp,\n event.name,\n event.locationId,\n event.voluntaryWachhabender,\n event.voluntaryFuehrungsassistent,\n event.amountOfPosten,\n event.clothing,\n event.canceled,\n event.note,\n location.id,\n location.name AS locationName,\n location.areaId AS locationAreaId\n FROM event\n JOIN location ON event.locationId = location.id\n WHERE event.id = $1;\n ", "query": "\n SELECT\n event.id AS eventId,\n event.startTimestamp,\n event.endTimestamp,\n event.name,\n event.locationId,\n event.voluntaryWachhabender,\n event.voluntaryFuehrungsassistent,\n event.amountOfPosten,\n event.clothing,\n event.canceled,\n event.note,\n location.id,\n location.name AS locationName,\n location.areaId AS locationAreaId,\n clothing.id AS clothingId,\n clothing.name AS clothingName\n FROM event\n JOIN location ON event.locationId = location.id\n JOIN clothing ON event.clothing = clothing.id\n WHERE starttimestamp::date = $1\n AND location.areaId = $2;\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@ -46,7 +46,7 @@
{ {
"ordinal": 8, "ordinal": 8,
"name": "clothing", "name": "clothing",
"type_info": "Text" "type_info": "Int4"
}, },
{ {
"ordinal": 9, "ordinal": 9,
@ -72,10 +72,21 @@
"ordinal": 13, "ordinal": 13,
"name": "locationareaid", "name": "locationareaid",
"type_info": "Int4" "type_info": "Int4"
},
{
"ordinal": 14,
"name": "clothingid",
"type_info": "Int4"
},
{
"ordinal": 15,
"name": "clothingname",
"type_info": "Text"
} }
], ],
"parameters": { "parameters": {
"Left": [ "Left": [
"Date",
"Int4" "Int4"
] ]
}, },
@ -93,8 +104,10 @@
true, true,
false, false,
false, false,
false,
false,
false false
] ]
}, },
"hash": "55a70284a5ddc7bff778ed1ea012b05b1daadbe41c77a8bd317f7fb17b7991cb" "hash": "4ceb2c7e3d921c2718e75ba131eee1706e008b783deb687e04eba08f4b919ac8"
} }

View File

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "SELECT * FROM location WHERE areaId = $1;", "query": "SELECT * FROM location WHERE areaId = $1 ORDER by name;",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@ -30,5 +30,5 @@
false false
] ]
}, },
"hash": "ea9f427b5d5a3e3c5f720d6bab2417cb3b42de0a5bf1d8b48b11a6e6275cc8e4" "hash": "57742178247d76e67dd81af0873854d1c5d14e189e338ae55900a873146be99f"
} }

View File

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "\n SELECT id,\n name,\n email,\n password,\n salt,\n role AS \"role: Role\",\n function AS \"function: UserFunction\",\n areaId,\n locked,\n lastLogin,\n receiveNotifications\n FROM user_;\n ", "query": "\n SELECT id,\n name,\n email,\n password,\n salt,\n role AS \"role: Role\",\n function AS \"function: UserFunction\",\n areaId,\n locked,\n lastLogin,\n receiveNotifications\n FROM user_\n ORDER BY id;\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@ -105,5 +105,5 @@
false false
] ]
}, },
"hash": "5573e93ccc0b6a5ecc6183a5d5c589ccd58f786e70a3ff1efa662085c2035156" "hash": "61ae80bcf916ac62220ffd16eb0be5e37e086f9f5b753d451725ea429ab84fbc"
} }

View File

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "\n SELECT id,\n name,\n email,\n password,\n salt,\n role AS \"role: Role\",\n function AS \"function: UserFunction\",\n areaId,\n locked,\n lastLogin,\n receiveNotifications\n FROM user_\n WHERE areaId = $1;\n ", "query": "\n SELECT id,\n name,\n email,\n password,\n salt,\n role AS \"role: Role\",\n function AS \"function: UserFunction\",\n areaId,\n locked,\n lastLogin,\n receiveNotifications\n FROM user_\n WHERE areaId = $1\n ORDER BY id;\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@ -107,5 +107,5 @@
false false
] ]
}, },
"hash": "483ad933fa1e935058cbe42b7ff083ceee80f74564ee3e8b7da6ab57e906368b" "hash": "6260f72ba85714e8529af2c4f3da77cf67556af87c72997b68fa8bd8bcae62a8"
} }

View File

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "\n SELECT\n event.id AS eventId,\n event.startTimestamp,\n event.endTimestamp,\n event.name,\n event.locationId,\n event.voluntaryWachhabender,\n event.voluntaryFuehrungsassistent,\n event.amountOfPosten,\n event.clothing,\n event.canceled,\n event.note,\n location.id,\n location.name AS locationName,\n location.areaId AS locationAreaId\n FROM event\n JOIN location ON event.locationId = location.id\n WHERE starttimestamp::date = $1\n AND location.areaId = $2;\n ", "query": "\n SELECT\n event.id AS eventId,\n event.startTimestamp,\n event.endTimestamp,\n event.name,\n event.locationId,\n event.voluntaryWachhabender,\n event.voluntaryFuehrungsassistent,\n event.amountOfPosten,\n event.clothing,\n event.canceled,\n event.note,\n location.id,\n location.name AS locationName,\n location.areaId AS locationAreaId,\n clothing.id AS clothingId,\n clothing.name AS clothingName\n FROM event\n JOIN location ON event.locationId = location.id\n JOIN clothing ON event.clothing = clothing.id\n WHERE event.id = $1;\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@ -46,7 +46,7 @@
{ {
"ordinal": 8, "ordinal": 8,
"name": "clothing", "name": "clothing",
"type_info": "Text" "type_info": "Int4"
}, },
{ {
"ordinal": 9, "ordinal": 9,
@ -72,11 +72,20 @@
"ordinal": 13, "ordinal": 13,
"name": "locationareaid", "name": "locationareaid",
"type_info": "Int4" "type_info": "Int4"
},
{
"ordinal": 14,
"name": "clothingid",
"type_info": "Int4"
},
{
"ordinal": 15,
"name": "clothingname",
"type_info": "Text"
} }
], ],
"parameters": { "parameters": {
"Left": [ "Left": [
"Date",
"Int4" "Int4"
] ]
}, },
@ -94,8 +103,10 @@
true, true,
false, false,
false, false,
false,
false,
false false
] ]
}, },
"hash": "d4a8fe79186f648212fb270323942e60edd5163b6463c2f0ef22baaf8be7bcd5" "hash": "6dc18993de451d1e0aa4080f00f46ce3339e020922b9ef130c4289b080a2af7d"
} }

View File

@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE clothing SET name = $1 WHERE id = $2;",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Int4"
]
},
"nullable": []
},
"hash": "770344caf4f209a95fd26cb778e7dfbb249c8edc24a0981c7c0e5bb7854f7fde"
}

View File

@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO clothing (name) VALUES ($1) RETURNING id;",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "8331b42d55c64827a0382b830e453158cbe3fc7919fec93c9ca03dcf570c9e9d"
}

View File

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "SELECT * FROM area ORDER by id", "query": "SELECT * FROM area ORDER by name",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@ -22,5 +22,5 @@
false false
] ]
}, },
"hash": "7f6c89117e8d4249e032235d03d264c3d5d47bd119c563237486cf47e402ae2e" "hash": "852293a7ffbde434401e3b45847275b4992b509da4148055cbe65ca26526f3ca"
} }

View File

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "SELECT * FROM location", "query": "SELECT * FROM location ORDER BY name;",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@ -28,5 +28,5 @@
false false
] ]
}, },
"hash": "2d9f2d0728983dfac09f6649da74aa5659072539a8f222b8ae202786ce958c37" "hash": "9b3e6609e33be0e428d759f1294d5804c9463f06f7747bf4ea04599da9b4b531"
} }

View File

@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT * FROM clothing WHERE id = $1;",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Int4"
]
},
"nullable": [
false,
false
]
},
"hash": "9e7ed80577cdceb87b5e5c4903aeadb7c0aea19b94f43828d97a26446ddd1a81"
}

View File

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "\n SELECT\n user_.id AS userId,\n user_.name,\n user_.email,\n user_.password,\n user_.salt,\n user_.role AS \"role: Role\",\n function AS \"function: UserFunction\",\n user_.areaId,\n user_.locked,\n user_.lastLogin,\n user_.receiveNotifications,\n area.id,\n area.name AS areaName\n FROM user_\n JOIN area ON user_.areaId = area.id\n ", "query": "\n SELECT\n user_.id AS userId,\n user_.name,\n user_.email,\n user_.password,\n user_.salt,\n user_.role AS \"role: Role\",\n function AS \"function: UserFunction\",\n user_.areaId,\n user_.locked,\n user_.lastLogin,\n user_.receiveNotifications,\n area.id,\n area.name AS areaName\n FROM user_\n JOIN area ON user_.areaId = area.id\n ORDER BY userId;\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@ -117,5 +117,5 @@
false false
] ]
}, },
"hash": "a7f6e57733c655534c3ae6379b8616fc3aa63ce322cc2d718f4b4e4e23903a61" "hash": "aad65af88315c7c358c8d72c466139d8a0bef32d03c4dd8cd1e8c2316224da79"
} }

View File

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "SELECT * FROM vehicle;", "query": "SELECT * FROM vehicle ORDER BY radioCallName;",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@ -28,5 +28,5 @@
false false
] ]
}, },
"hash": "5b87f4da0924338da1a30d7b74711d8073f6d62cf30a42381484846f0917bc33" "hash": "b145ac6b3c43aa24082ca9d6642dc36e745b7b1e633b94cb61e980a5e0fc60b5"
} }

View File

@ -12,7 +12,7 @@
"Bool", "Bool",
"Bool", "Bool",
"Int2", "Int2",
"Text", "Int4",
"Text" "Text"
] ]
}, },

View File

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "SELECT location.id AS locationId, location.name, location.areaId, area.id, area.name AS areaName FROM location JOIN area ON location.areaId = area.id WHERE areaId = $1;", "query": "SELECT location.id AS locationId, location.name, location.areaId, area.id, area.name AS areaName FROM location JOIN area ON location.areaId = area.id WHERE areaId = $1 ORDER by name;",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@ -42,5 +42,5 @@
false false
] ]
}, },
"hash": "70850ec3f7c519c1fc104fead6a44d07ba76023567bc6ea0eec2267d1c592479" "hash": "b43b6a9e1bd8f05c79368115aa5e59b22a5d275c91c1aae39fbc17ecf9297efc"
} }

View File

@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id FROM user_ WHERE email = $1;",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "b4edfcac9404060d487db765b8c18ef8b7440699583e0bede95f4d214e668a87"
}

View File

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "SELECT location.id AS locationId, location.name, location.areaId, area.id, area.name AS areaName FROM location JOIN area ON location.areaId = area.id;", "query": "SELECT location.id AS locationId, location.name, location.areaId, area.id, area.name AS areaName FROM location JOIN area ON location.areaId = area.id ORDER BY name;",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@ -40,5 +40,5 @@
false false
] ]
}, },
"hash": "f94d7fe59a2d4b7d246711a796571367172bce9446b9fb1e7ba057917a98d958" "hash": "c9a989d3b08f4147643a540aecbb0d6275cb71ff217359f87467a46b32648ea7"
} }

View File

@ -12,7 +12,7 @@
"Bool", "Bool",
"Bool", "Bool",
"Int2", "Int2",
"Text", "Int4",
"Text", "Text",
"Int4" "Int4"
] ]

View File

@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM clothing WHERE id = $1;",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int4"
]
},
"nullable": []
},
"hash": "fc95525b2ce9e74c243c222a4051fb072d4112c082681b7ce83648eb7d5bd23a"
}

81
Cargo.lock generated
View File

@ -416,6 +416,15 @@ version = "1.0.98"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
[[package]]
name = "arbitrary"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223"
dependencies = [
"derive_arbitrary",
]
[[package]] [[package]]
name = "argon2" name = "argon2"
version = "0.5.3" version = "0.5.3"
@ -788,7 +797,7 @@ dependencies = [
[[package]] [[package]]
name = "brass-web" name = "brass-web"
version = "0.2.2" version = "1.0.1"
dependencies = [ dependencies = [
"actix-files", "actix-files",
"actix-http", "actix-http",
@ -814,6 +823,7 @@ dependencies = [
"quick-xml", "quick-xml",
"rand 0.9.1", "rand 0.9.1",
"regex", "regex",
"rust_xlsxwriter",
"serde", "serde",
"serde_json", "serde_json",
"sqlx", "sqlx",
@ -1191,6 +1201,17 @@ dependencies = [
"powerfmt", "powerfmt",
] ]
[[package]]
name = "derive_arbitrary"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.101",
]
[[package]] [[package]]
name = "derive_builder" name = "derive_builder"
version = "0.20.2" version = "0.20.2"
@ -1463,6 +1484,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece" checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece"
dependencies = [ dependencies = [
"crc32fast", "crc32fast",
"libz-rs-sys",
"miniz_oxide", "miniz_oxide",
] ]
@ -2127,6 +2149,7 @@ dependencies = [
"rustls", "rustls",
"socket2 0.5.9", "socket2 0.5.9",
"tokio", "tokio",
"tracing",
"url", "url",
"webpki-roots 1.0.0", "webpki-roots 1.0.0",
] ]
@ -2153,6 +2176,15 @@ dependencies = [
"vcpkg", "vcpkg",
] ]
[[package]]
name = "libz-rs-sys"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6489ca9bd760fe9642d7644e827b0c9add07df89857b0416ee15c1cc1a3b8c5a"
dependencies = [
"zlib-rs",
]
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.3.8" version = "0.3.8"
@ -2828,6 +2860,15 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "rust_xlsxwriter"
version = "0.87.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8079587c37b35a067846a853a524cfde7012754650de7274beecc35e43acd44b"
dependencies = [
"zip",
]
[[package]] [[package]]
name = "rustc-demangle" name = "rustc-demangle"
version = "0.1.24" version = "0.1.24"
@ -3048,6 +3089,12 @@ dependencies = [
"rand_core 0.6.4", "rand_core 0.6.4",
] ]
[[package]]
name = "simd-adler32"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe"
[[package]] [[package]]
name = "similar" name = "similar"
version = "2.7.0" version = "2.7.0"
@ -4228,6 +4275,38 @@ dependencies = [
"syn 2.0.101", "syn 2.0.101",
] ]
[[package]]
name = "zip"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12598812502ed0105f607f941c386f43d441e00148fce9dec3ca5ffb0bde9308"
dependencies = [
"arbitrary",
"crc32fast",
"flate2",
"indexmap",
"memchr",
"zopfli",
]
[[package]]
name = "zlib-rs"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "868b928d7949e09af2f6086dfc1e01936064cc7a819253bce650d4e2a2d63ba8"
[[package]]
name = "zopfli"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edfc5ee405f504cd4984ecc6f14d02d55cfda60fa4b689434ef4102aae150cd7"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]] [[package]]
name = "zstd" name = "zstd"
version = "0.13.3" version = "0.13.3"

View File

@ -1,66 +1,49 @@
# Brass
A webservice to plan and organize personnel deployment for [Brandsicherheitswachen](https://de.wikipedia.org/wiki/Brandsicherheitswache) (german; fire watch).
# Key Technologies
- [actix-web](https://actix.rs/)
- [sqlx](https://github.com/launchbadge/sqlx)
- [askama](https://github.com/askama-rs/askama)
- [lettre](https://lettre.rs/)
- [htmx](https://htmx.org/)
- [hyperscript](https://hyperscript.org/)
- [bulma](https://bulma.io/)
- great inspiration for project structure and tooling: [gerust.rs](https://gerust.rs)
# Getting started with developing # Getting started with developing
1. Clone the repository. 1. Clone the repository.
2. Install and configure Postgresql. Create a new database for brass: `createdb brass`. 2. Install and configure Postgresql. Create a new database for brass: `createdb brass`.
3. TODO: Configure DB name, DB user & pass, DB connection string, ... 3. Configure database connection string in `.env` config file.
4. Install sqlx-cli: `cargo install sqlx-cli` 4. Install required development tools `cargo install <tool>`
5. Migrate the database: `sqlx database setup` - sqlx-cli
6. Create superuse: `cargo r -- createadmin` - mailtutan
- cargo-watch
- cargo-nextest
6. Migrate the development and test database: `cargo db migrate -e development` & `cargo db migrate -e test`
7. Create superuser: `cargo r -- createadmin`
8. Run and recompile application on file change: `cargo w`
9. Run tests via nextest and review possible snapshot changes: `cargo t`
## Useful stuff # Build & Deploy
- cargo-watch, cargo-add 1. Clone the repository.
- mailtutan 2. Build release `cargo b --release`.
3. Copy the artifact `target/release/brass-web` to the desired location. Make it executable `chmod +x brass-web`.
4. Create Postgresql database on the target host, configure your mail server.
5. Configuration for Brass is done via Environment Variables, see `.env` for a list.
6. Migrate the database `[LIST_OF_ENV_VARIABLES] brass-web migrate`.
7. Create a superuser `[LIST_OF_ENV_VARIABLES] brass-web createadmin`.
8. Create some sort of service file (systemd .service, openbsd rc.conf, ...) to run Brass in the background. Examples can be found in `docs/` directory.
# Contributing & Issues
Code lies on my private gitea instance, thus there's no easy way for creating issues or making contributions. If you've got an issue or want to contribute, write me an email and we'll figure it out.
## Example Deployment OpenBSD # Project Structure
``` - TODO
#!/bin/ksh
DATABASE_URL=postgresql://brass:pw@localhost/brass # Further Reading
SECRET_KEY="" More in depth documentation about design decisions, helpful commands and database schema can be found in `docs/` directory.
HOSTNAME="brass.tfld.de"
SERVER_ADDRESS="127.0.0.1"
SERVER_PORT="8081"
SMTP_SERVER="localhost"
SMTP_PORT="25"
SMTP_TLSTYPE="none"
ENVLIST="DATABASE_URL=$DATABASE_URL SECRET_KEY=$SECRET_KEY HOSTNAME=$HOSTNAME SERVER_ADDRESS=$SERVER_ADDRESS SERVER_PORT=$SERVER_PORT SMTP_SERVER=$SMTP_SERVER SMTP_PORT=$SMTP_PORT SMTP_TLSTYPE=$SMTP_TLSTYPE"
daemon="$ENVLIST /usr/local/bin/brass" # Copyright & License
daemon_user="www" Copyright 2025 Max Hohlfeld
daemon_logger="daemon.info" Brass is licensed under [GNU AGPLv3](https://www.gnu.org/licenses/agpl-3.0.en.html#license-text).
. /etc/rc.d/rc.subr
pexp=".*/usr/local/bin/brass.*"
rc_bg=YES
rc_cmd $1
```
```ini
# Postgres
# DATABASE_URL=postgres://postgres@localhost/my_database
# SQLite
DATABASE_URL=postgresql://brass:password@localhost/brass
# 64 byte long
SECRET_KEY="secret key"
HOSTNAME="brass.tfld.de"
ADDRESS="127.0.0.1"
PORT="8081"
SMTP_SERVER="localhost"
SMTP_PORT="25"
# SMTP_LOGIN=""
# SMTP_PASSWORD=""
SMTP_TLSTYPE="none"
```
## drop test databases
```bash
for dbname in $(psql -c "copy (select datname from pg_database where datname like 'brass_test_%') to stdout") ; do
echo "$dbname"
#dropdb -i "$dbname"
done
```

View File

@ -17,6 +17,7 @@ pub struct Config {
pub smtp_login: Option<String>, pub smtp_login: Option<String>,
pub smtp_password: Option<String>, pub smtp_password: Option<String>,
pub smtp_tlstype: SmtpTlsType, pub smtp_tlstype: SmtpTlsType,
pub webmaster_email: String
} }
#[derive(Clone)] #[derive(Clone)]
@ -67,6 +68,7 @@ pub fn load_config(env: &Environment) -> Result<Config, anyhow::Error> {
smtp_login: env::var("SMTP_LOGIN").map(Some).unwrap_or(None), smtp_login: env::var("SMTP_LOGIN").map(Some).unwrap_or(None),
smtp_password: env::var("SMTP_PASSWORD").map(Some).unwrap_or(None), smtp_password: env::var("SMTP_PASSWORD").map(Some).unwrap_or(None),
smtp_tlstype: SmtpTlsType::from(env::var("SMTP_TLSTYPE")?), smtp_tlstype: SmtpTlsType::from(env::var("SMTP_TLSTYPE")?),
webmaster_email: env::var("WEBMASTER_EMAIL")?,
}; };
Ok(config) Ok(config)

Binary file not shown.

View File

@ -0,0 +1,8 @@
# Drop all test databases
```bash
for dbname in $(psql -c "copy (select datname from pg_database where datname like 'brass_test_%') to stdout") ; do
echo "$dbname"
#dropdb -i "$dbname"
done
```

View File

@ -0,0 +1,31 @@
# Example Deployment OpenBSD
- list of env variables may not be up to date
```sh
#!/bin/ksh
DATABASE_URL=postgresql://brass:pw@localhost/brass
SECRET_KEY=""
HOSTNAME="brass.tfld.de"
SERVER_ADDRESS="127.0.0.1"
SERVER_PORT="8081"
SMTP_SERVER="localhost"
SMTP_PORT="25"
SMTP_TLSTYPE="none"
ENVLIST="DATABASE_URL=$DATABASE_URL SECRET_KEY=$SECRET_KEY HOSTNAME=$HOSTNAME SERVER_ADDRESS=$SERVER_ADDRESS SERVER_PORT=$SERVER_PORT SMTP_SERVER=$SMTP_SERVER SMTP_PORT=$SMTP_PORT SMTP_TLSTYPE=$SMTP_TLSTYPE"
RUST_LOG="info,actix_server=error"
ENVLIST="DATABASE_URL=$DATABASE_URL SECRET_KEY=$SECRET_KEY HOSTNAME=$HOSTNAME SERVER_ADDRESS=$SERVER_ADDRESS SERVER_PORT=$SERVER_PORT SMTP_SERVER=$SMTP_SERVER SMTP_LOGIN=$SMTP_LOGIN SMTP_PASSWORD=$SMTP_PASSWORD SMTP_PORT=$SMTP_PORT SMTP_TLSTYPE=$SMTP_TLSTYPE RUST_LOG=$RUST_LOG"
daemon="$ENVLIST /usr/local/bin/brass"
daemon_user="www"
daemon_logger="daemon.info"
. /etc/rc.d/rc.subr
pexp=".*/usr/local/bin/brass.*"
rc_bg=YES
rc_cmd $1
```

View File

@ -0,0 +1,11 @@
CREATE TABLE clothing
(
id SERIAL PRIMARY KEY,
name TEXT NOT NULL
);
INSERT INTO clothing (name) VALUES ('Tuchuniform');
ALTER TABLE event
ALTER COLUMN clothing SET DATA TYPE INTEGER USING 1,
ADD FOREIGN KEY (clothing) REFERENCES clothing (id) ON DELETE CASCADE;

View File

@ -0,0 +1,4 @@
CREATE TYPE simpleAssignment AS (
name text,
function function
);

View File

@ -1,6 +1,6 @@
[package] [package]
name = "brass-web" name = "brass-web"
version = "0.2.2" version = "1.0.1"
edition = "2021" edition = "2021"
license = "AGPL-3.0" license = "AGPL-3.0"
authors = ["Max Hohlfeld <maxhohlfeld@posteo.de>"] authors = ["Max Hohlfeld <maxhohlfeld@posteo.de>"]
@ -20,7 +20,7 @@ futures-util = "0.3.30"
serde_json = "1.0.114" serde_json = "1.0.114"
pico-args = "0.5.0" pico-args = "0.5.0"
rand = { version = "0.9", features = ["os_rng"] } rand = { version = "0.9", features = ["os_rng"] }
lettre = { version = "0.11.11", default-features = false, features = ["builder", "smtp-transport", "async-std1-rustls-tls"] } lettre = { version = "0.11.11", default-features = false, features = ["builder", "smtp-transport", "async-std1-rustls-tls", "tracing"] }
quick-xml = { version = "0.37", features = ["serde", "serialize"] } quick-xml = { version = "0.37", features = ["serde", "serialize"] }
actix-web-static-files = "4.0" actix-web-static-files = "4.0"
static-files = "0.2.1" static-files = "0.2.1"
@ -36,6 +36,8 @@ tracing-actix-web = "0.7.18"
tracing = "0.1.41" tracing = "0.1.41"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
tracing-panic = "0.1.2" tracing-panic = "0.1.2"
rust_xlsxwriter = "0.87.0"
regex = "1.11.1"
[build-dependencies] [build-dependencies]
built = "0.7.4" built = "0.7.4"

View File

@ -18,6 +18,7 @@ fn main() -> std::io::Result<()> {
let dist_path = Path::new("./static/dist"); let dist_path = Path::new("./static/dist");
let nm_path = Path::new("./static/node_modules"); let nm_path = Path::new("./static/node_modules");
let static_path = Path::new("./static");
if fs::metadata(dist_path).is_err() { if fs::metadata(dist_path).is_err() {
fs::create_dir(dist_path)?; fs::create_dir(dist_path)?;
@ -43,14 +44,22 @@ fn main() -> std::io::Result<()> {
nm_path.join("hyperscript.org/dist/_hyperscript.min.js"), nm_path.join("hyperscript.org/dist/_hyperscript.min.js"),
dist_path.join("_hyperscript.min.js"), dist_path.join("_hyperscript.min.js"),
)?; )?;
copy(
Path::new("./static/utils.js"), let static_files = vec![
dist_path.join("utils.js"), "utils.js",
)?; "brass.jpeg",
copy( "android-chrome-192x192.png",
Path::new("./static/brass.jpeg"), "android-chrome-512x512.png",
dist_path.join("brass.jpeg"), "apple-touch-icon.png",
)?; "favicon-16x16.png",
"favicon-32x32.png",
"favicon.ico",
"site.webmanifest",
];
for file in static_files {
copy(static_path.join(file), dist_path.join(file))?;
}
resource_dir("./static/dist").build() resource_dir("./static/dist").build()
} }

View File

@ -0,0 +1,36 @@
---
source: web/src/endpoints/clothing/get_edit.rs
expression: body
snapshot_kind: text
---
<form hx-post="/clothing/1" hx-target="closest a" hx-target-422="find p">
<div class="level">
<div class="level-left">
<div class="level-item">
<div class="field">
<label class="label"></label>
<div class="control">
<input class="input" name="name" type="text" value="Tuchuniform"
_="on input put '' into the next <p/>" placeholder="Tuchuniform" minlength="3" />
</div>
<p class="help is-danger"></p>
</div>
</div>
<div class="level-item buttons are-small">
<button class="button is-success">
<svg class="icon">
<use href="/static/feather-sprite.svg#check-circle" />
</svg>
</button>
<button class="button is-warning is-light" type="button" hx-get="/clothing/1" hx-target="closest a">
<svg class="icon">
<use href="/static/feather-sprite.svg#x-circle" />
</svg>
</button>
</div>
</div>
</div>
</form>

View File

@ -0,0 +1,72 @@
---
source: web/src/endpoints/clothing/get_overview.rs
expression: body
snapshot_kind: text
---
<section class="section">
<div class="container">
<h3 class="title is-3">
Anzugsordnungen
</h3>
<p class="subtitle is-5">zur Auswahl bei der Erstellung von Events</p>
<div class="panel p-2">
<a class="panel-block is-active">
<div class="level">
<div class="level-left">
<div class="level-item">
Schutzkleidung Form 1
</div>
<div class="level-item buttons are-small">
<button class="button is-success is-light" hx-get="/clothing/edit/2" hx-target="closest a">
<svg class="icon">
<use href="/static/feather-sprite.svg#edit" />
</svg>
</button>
<button class="button is-danger is-light" hx-delete="/clothing/2" hx-swap="delete"
hx-target="closest a" hx-trigger="confirmed">
<svg class="icon">
<use href="/static/feather-sprite.svg#trash-2" />
</svg>
</button>
</div>
</div>
</div>
</a>
<a class="panel-block is-active">
<div class="level">
<div class="level-left">
<div class="level-item">
Tuchuniform
</div>
<div class="level-item buttons are-small">
<button class="button is-success is-light" hx-get="/clothing/edit/1" hx-target="closest a">
<svg class="icon">
<use href="/static/feather-sprite.svg#edit" />
</svg>
</button>
<button class="button is-danger is-light" hx-delete="/clothing/1" hx-swap="delete"
hx-target="closest a" hx-trigger="confirmed">
<svg class="icon">
<use href="/static/feather-sprite.svg#trash-2" />
</svg>
</button>
</div>
</div>
</div>
</a>
<div class="panel-block">
<button class="button is-link is-light" hx-get="/clothing/new" hx-swap="beforebegin" hx-target="closest div">
<svg class="icon">
<use href="/static/feather-sprite.svg#plus-circle" />
</svg>
<span>neue Anzugsordnung</span>
</button>
</div>
</div>
</section>

View File

@ -0,0 +1,26 @@
---
source: web/src/endpoints/clothing/get_read.rs
expression: body
snapshot_kind: text
---
<div class="level">
<div class="level-left">
<div class="level-item">
Tuchuniform
</div>
<div class="level-item buttons are-small">
<button class="button is-success is-light" hx-get="/clothing/edit/1" hx-target="closest a">
<svg class="icon">
<use href="/static/feather-sprite.svg#edit" />
</svg>
</button>
<button class="button is-danger is-light" hx-delete="/clothing/1" hx-swap="delete"
hx-target="closest a" hx-trigger="confirmed">
<svg class="icon">
<use href="/static/feather-sprite.svg#trash-2" />
</svg>
</button>
</div>
</div>
</div>

View File

@ -124,7 +124,7 @@ snapshot_kind: text
<div class="field is-horizontal"> <div class="field is-horizontal">
<div class="field-label"> <div class="field-label">
<label class="label">Führungsassistent durch FF gestellt?</label> <label class="label">Führungsassistent benötigt?</label>
</div> </div>
<div class="field-body"> <div class="field-body">
<div class="field is-narrow"> <div class="field is-narrow">
@ -163,10 +163,17 @@ snapshot_kind: text
<label class="label">Anzugsordnung</label> <label class="label">Anzugsordnung</label>
</div> </div>
<div class="field-body"> <div class="field-body">
<div class="field"> <div class="field is-narrow">
<div class="control"> <div class="control">
<input class="input" name="clothing" placeholder="Tuchuniform" required value="Tuchuniform" <div class="select is-fullwidth">
/> <select name="clothing" required >
<option value="1"
selected>
Tuchuniform</option>
</select>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,18 +1,19 @@
use crate::{
filters,
models::{find_free_date_time_slots, Assignment, Function, Vehicle},
utils::{
event_planning_template::generate_vehicles_assigned_and_available, ApplicationError,
TemplateResponse,
},
};
use actix_web::{web, HttpResponse, Responder}; use actix_web::{web, HttpResponse, Responder};
use askama::Template; use askama::Template;
use chrono::{NaiveDate, Utc}; use chrono::{NaiveDate, Utc};
use serde::Deserialize; use serde::Deserialize;
use sqlx::PgPool; use sqlx::PgPool;
use crate::models::{Area, Availability, Event, Role, User}; use crate::filters;
use crate::models::{
find_free_date_time_slots, Area, Assignment, Availability, Event, Function, Role, User, Vehicle,
};
use crate::utils::{
event_planning_template::generate_vehicles_assigned_and_available,
ApplicationError,
DateTimeFormat::{DayMonthYear, DayMonthYearHourMinute, HourMinute},
TemplateResponse,
};
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct CalendarQuery { pub struct CalendarQuery {

View File

@ -1,9 +1,10 @@
use crate::filters;
use askama::Template; use askama::Template;
use chrono::{NaiveDate, NaiveDateTime, NaiveTime}; use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
use serde::Deserialize; use serde::Deserialize;
use crate::models::{Availability, AvailabilityChangeset, Role, User}; use crate::filters;
use crate::models::{Role, User};
use crate::utils::DateTimeFormat::{DayMonth, DayMonthYear, DayMonthYearHourMinute, HourMinute};
pub mod delete; pub mod delete;
pub mod get_new; pub mod get_new;
@ -23,7 +24,7 @@ struct NewOrEditAvailabilityTemplate<'a> {
end: Option<NaiveTime>, end: Option<NaiveTime>,
comment: Option<&'a str>, comment: Option<&'a str>,
slot_suggestions: Vec<(NaiveDateTime, NaiveDateTime)>, slot_suggestions: Vec<(NaiveDateTime, NaiveDateTime)>,
datetomorrow: NaiveDate datetomorrow: NaiveDate,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -34,16 +35,3 @@ pub struct AvailabilityForm {
pub endtime: NaiveTime, pub endtime: NaiveTime,
pub comment: Option<String>, pub comment: Option<String>,
} }
fn find_adjacend_availability<'a>(
changeset: &AvailabilityChangeset,
availability_id_to_be_updated: Option<i32>,
existing_availabilities: &'a [Availability],
) -> Option<&'a Availability> {
let existing_availability = existing_availabilities
.iter()
.filter(|a| availability_id_to_be_updated.is_none_or(|id| a.id != id))
.find(|a| a.start == changeset.time.1 || a.end == changeset.time.0);
return existing_availability;
}

View File

@ -1,11 +1,10 @@
use actix_web::{http::header::LOCATION, web, HttpResponse, Responder}; use actix_web::{http::header::LOCATION, web, HttpResponse, Responder};
use garde::Validate;
use sqlx::PgPool; use sqlx::PgPool;
use crate::{ use crate::{
endpoints::availability::{find_adjacend_availability, AvailabilityForm}, endpoints::availability::AvailabilityForm,
models::{Availability, AvailabilityChangeset, AvailabilityContext, User}, models::{Availability, AvailabilityChangeset, AvailabilityContext, User},
utils::{self, ApplicationError}, utils::{self, validation::AsyncValidate, ApplicationError},
}; };
#[actix_web::post("/availability/new")] #[actix_web::post("/availability/new")]
@ -14,32 +13,33 @@ pub async fn post(
pool: web::Data<PgPool>, pool: web::Data<PgPool>,
form: web::Form<AvailabilityForm>, form: web::Form<AvailabilityForm>,
) -> Result<impl Responder, ApplicationError> { ) -> Result<impl Responder, ApplicationError> {
let existing_availabilities =
Availability::read_by_user_and_date(pool.get_ref(), user.id, &form.startdate).await?;
let context = AvailabilityContext {
existing_availabilities: existing_availabilities.clone(),
};
let start = form.startdate.and_time(form.starttime); let start = form.startdate.and_time(form.starttime);
let end = form.enddate.and_time(form.endtime); let end = form.enddate.and_time(form.endtime);
let context = AvailabilityContext {
pool: pool.get_ref(),
user_id: user.id,
availability_to_get_edited: None,
};
let mut changeset = AvailabilityChangeset { let mut changeset = AvailabilityChangeset {
time: (start, end), time: (start, end),
comment: form.comment.clone(), comment: form.comment.clone(),
}; };
if let Err(e) = changeset.validate_with(&context) { if let Err(e) = changeset.validate_with_context(&context).await {
return Ok(HttpResponse::BadRequest().body(e.to_string())); return Ok(HttpResponse::BadRequest().body(e.to_string()));
}; };
if let Some(a) = find_adjacend_availability(&changeset, None, &existing_availabilities) { if let Some(a) =
let (changeset_start, changeset_end) = changeset.time; Availability::find_adjacent_by_time_for_user(pool.get_ref(), &start, &end, user.id, None)
.await?
if a.end == changeset_start { {
if a.end == start {
changeset.time.0 = a.start; changeset.time.0 = a.start;
} }
if a.start == changeset_end { if a.start == end {
changeset.time.1 = a.end; changeset.time.1 = a.end;
} }

View File

@ -1,14 +1,10 @@
use actix_web::{http::header::LOCATION, web, HttpResponse, Responder}; use actix_web::{http::header::LOCATION, web, HttpResponse, Responder};
use garde::Validate;
use sqlx::PgPool; use sqlx::PgPool;
use crate::{ use crate::{
endpoints::{ endpoints::{availability::AvailabilityForm, IdPath},
availability::{find_adjacend_availability, AvailabilityForm},
IdPath,
},
models::{Availability, AvailabilityChangeset, AvailabilityContext, User}, models::{Availability, AvailabilityChangeset, AvailabilityContext, User},
utils::{self, ApplicationError}, utils::{self, validation::AsyncValidate, ApplicationError},
}; };
#[actix_web::post("/availability/edit/{id}")] #[actix_web::post("/availability/edit/{id}")]
@ -26,39 +22,38 @@ pub async fn post(
return Err(ApplicationError::Unauthorized); return Err(ApplicationError::Unauthorized);
} }
let existing_availabilities: Vec<Availability> =
Availability::read_by_user_and_date(pool.get_ref(), user.id, &availability.start.date())
.await?
.into_iter()
.filter(|a| a.id != availability.id)
.collect();
let context = AvailabilityContext {
existing_availabilities: existing_availabilities.clone(),
};
let start = form.startdate.and_time(form.starttime); let start = form.startdate.and_time(form.starttime);
let end = form.enddate.and_time(form.endtime); let end = form.enddate.and_time(form.endtime);
let context = AvailabilityContext {
pool: pool.get_ref(),
user_id: user.id,
availability_to_get_edited: Some(availability.id),
};
let mut changeset = AvailabilityChangeset { let mut changeset = AvailabilityChangeset {
time: (start, end), time: (start, end),
comment: form.comment.clone(), comment: form.comment.clone(),
}; };
if let Err(e) = changeset.validate_with(&context) { if let Err(e) = changeset.validate_with_context(&context).await {
return Ok(HttpResponse::BadRequest().body(e.to_string())); return Ok(HttpResponse::BadRequest().body(e.to_string()));
}; };
if let Some(a) = if let Some(a) = Availability::find_adjacent_by_time_for_user(
find_adjacend_availability(&changeset, Some(availability.id), &existing_availabilities) pool.get_ref(),
&start,
&end,
user.id,
Some(availability.id),
)
.await?
{ {
let (changeset_start, changeset_end) = changeset.time; if a.end == start {
if a.end == changeset_start {
changeset.time.0 = a.start; changeset.time.0 = a.start;
} }
if a.start == changeset_end { if a.start == end {
changeset.time.1 = a.end; changeset.time.1 = a.end;
} }

View File

@ -0,0 +1,91 @@
use actix_web::{web, HttpResponse, Responder};
use sqlx::PgPool;
use crate::{
endpoints::IdPath,
models::{Clothing, Role, User},
utils::ApplicationError,
};
#[actix_web::delete("/clothing/{id}")]
pub async fn delete(
user: web::ReqData<User>,
pool: web::Data<PgPool>,
path: web::Path<IdPath>,
) -> Result<impl Responder, ApplicationError> {
if user.role != Role::Admin {
return Err(ApplicationError::Unauthorized);
}
let Some(clothing) = Clothing::read(pool.get_ref(), path.id).await? else {
return Ok(HttpResponse::NotFound().finish());
};
Clothing::delete(pool.get_ref(), clothing.id).await?;
Ok(HttpResponse::Ok().finish())
}
#[cfg(test)]
mod tests {
use crate::{
models::{Clothing, Role},
utils::test_helper::{
test_delete, DbTestContext, RequestConfig,
StatusCode,
},
};
use brass_macros::db_test;
#[db_test]
async fn deletes_clothing_fine_when_user_is_admin(context: &DbTestContext) {
let app = context.app().await;
Clothing::create(&context.db_pool, "Tuchuniform")
.await
.unwrap();
assert_eq!(2, Clothing::read_all(&context.db_pool).await.unwrap().len());
let config = RequestConfig::new("/clothing/1").with_role(Role::Admin);
let response = test_delete(&context.db_pool, app, &config).await;
assert_eq!(StatusCode::OK, response.status());
assert_eq!(1, Clothing::read_all(&context.db_pool).await.unwrap().len());
// TODO: reduce numbers by one when db migrations are joined together
}
#[db_test]
async fn returns_unauthorized_when_user_is_user(context: &DbTestContext) {
let app = context.app().await;
let response = test_delete(&context.db_pool, app, &RequestConfig::new("/clothing/1")).await;
assert_eq!(StatusCode::UNAUTHORIZED, response.status());
}
#[db_test]
async fn returns_unauthorized_when_user_is_area_manager(context: &DbTestContext) {
let app = context.app().await;
let response = test_delete(
&context.db_pool,
app,
&RequestConfig::new("/clothing/1").with_role(Role::AreaManager),
)
.await;
assert_eq!(StatusCode::UNAUTHORIZED, response.status());
}
#[db_test]
async fn returns_not_found_when_clothing_does_not_exist(context: &DbTestContext) {
let app = context.app().await;
let response = test_delete(
&context.db_pool,
app,
&RequestConfig::new("/clothing/100").with_role(Role::Admin),
)
.await;
assert_eq!(StatusCode::NOT_FOUND, response.status());
}
}

View File

@ -0,0 +1,85 @@
use actix_web::{web, HttpResponse, Responder};
use sqlx::PgPool;
use crate::{
endpoints::{clothing::EditClothingPartialTemplate, IdPath},
models::{Clothing, Role, User},
utils::{ApplicationError, TemplateResponse},
};
#[actix_web::get("/clothing/edit/{id}")]
pub async fn get(
user: web::ReqData<User>,
pool: web::Data<PgPool>,
path: web::Path<IdPath>,
) -> Result<impl Responder, ApplicationError> {
if user.role != Role::Admin {
return Err(ApplicationError::Unauthorized);
}
let Some(clothing) = Clothing::read(pool.get_ref(), path.id).await? else {
return Ok(HttpResponse::NotFound().finish());
};
let template = EditClothingPartialTemplate {
id: Some(clothing.id),
name: Some(clothing.name),
};
Ok(template.to_response()?)
}
#[cfg(test)]
mod tests {
use crate::{
models::{Clothing, Role},
utils::test_helper::{
assert_snapshot, read_body, test_get, DbTestContext, RequestConfig, StatusCode,
},
};
use brass_macros::db_test;
#[db_test]
async fn user_cant_view_edit_entity(context: &DbTestContext) {
Clothing::create(&context.db_pool, "Tuchuniform")
.await
.unwrap();
let app = context.app().await;
let config = RequestConfig::new("/clothing/edit/1");
let response = test_get(&context.db_pool, &app, &config).await;
assert_eq!(StatusCode::UNAUTHORIZED, response.status());
}
#[db_test]
async fn area_manager_cant_view_edit_entity(context: &DbTestContext) {
Clothing::create(&context.db_pool, "Tuchuniform")
.await
.unwrap();
let app = context.app().await;
let config = RequestConfig::new("/clothing/edit/1").with_role(Role::AreaManager);
let response = test_get(&context.db_pool, &app, &config).await;
assert_eq!(StatusCode::UNAUTHORIZED, response.status());
}
#[db_test]
async fn produces_template_fine_when_user_is_admin(context: &DbTestContext) {
let app = context.app().await;
Clothing::create(&context.db_pool, "Schutzkleidung Form 1")
.await
.unwrap();
let config = RequestConfig::new("/clothing/edit/1").with_role(Role::Admin);
let response = test_get(&context.db_pool, &app, &config).await;
assert_eq!(StatusCode::OK, response.status());
let body = read_body(response).await;
assert_snapshot!(body);
}
}

View File

@ -0,0 +1,21 @@
use actix_web::{web, Responder};
use crate::{
endpoints::clothing::EditClothingPartialTemplate,
models::{Role, User},
utils::{ApplicationError, TemplateResponse},
};
#[actix_web::get("/clothing/new")]
pub async fn get(user: web::ReqData<User>) -> Result<impl Responder, ApplicationError> {
if user.role != Role::Admin {
return Err(ApplicationError::Unauthorized);
}
let template = EditClothingPartialTemplate {
id: None,
name: None,
};
Ok(template.to_response()?)
}

View File

@ -0,0 +1,86 @@
use actix_web::{web, Responder};
use askama::Template;
use sqlx::PgPool;
use crate::{
models::{Clothing, Role, User},
utils::{ApplicationError, TemplateResponse},
};
#[derive(Template)]
#[cfg_attr(not(test), template(path = "clothing/overview.html"))]
#[cfg_attr(
test,
template(path = "clothing/overview.html", block = "content"),
allow(dead_code)
)]
pub struct ClothingOverviewTemplate {
user: User,
clothings: Vec<Clothing>,
}
#[actix_web::get("/clothing")]
pub async fn get(
user: web::ReqData<User>,
pool: web::Data<PgPool>,
) -> Result<impl Responder, ApplicationError> {
if user.role != Role::Admin {
return Err(ApplicationError::Unauthorized);
}
let clothings = Clothing::read_all(pool.get_ref()).await?;
let template = ClothingOverviewTemplate {
user: user.into_inner(),
clothings,
};
Ok(template.to_response()?)
}
#[cfg(test)]
mod tests {
use crate::{
models::{Clothing, Role},
utils::test_helper::{
assert_snapshot, read_body, test_get, DbTestContext, RequestConfig, StatusCode,
},
};
use brass_macros::db_test;
#[db_test]
async fn user_cant_view_overview(context: &DbTestContext) {
let app = context.app().await;
let config = RequestConfig::new("/clothing");
let response = test_get(&context.db_pool, &app, &config).await;
assert_eq!(StatusCode::UNAUTHORIZED, response.status());
}
#[db_test]
async fn area_manager_cant_view_overview(context: &DbTestContext) {
let app = context.app().await;
let config = RequestConfig::new("/clothing").with_role(Role::AreaManager);
let response = test_get(&context.db_pool, &app, &config).await;
assert_eq!(StatusCode::UNAUTHORIZED, response.status());
}
#[db_test]
async fn produces_template_fine_when_user_is_admin(context: &DbTestContext) {
let app = context.app().await;
Clothing::create(&context.db_pool, "Schutzkleidung Form 1")
.await
.unwrap();
let config = RequestConfig::new("/clothing").with_role(Role::Admin);
let response = test_get(&context.db_pool, &app, &config).await;
assert_eq!(StatusCode::OK, response.status());
let body = read_body(response).await;
assert_snapshot!(body);
}
}

View File

@ -0,0 +1,82 @@
use actix_web::{web, HttpResponse, Responder};
use sqlx::PgPool;
use crate::{
endpoints::{clothing::ReadClothingPartialTemplate, IdPath},
models::{Clothing, Role, User},
utils::{ApplicationError, TemplateResponse},
};
#[actix_web::get("/clothing/{id}")]
pub async fn get(
user: web::ReqData<User>,
pool: web::Data<PgPool>,
path: web::Path<IdPath>,
) -> Result<impl Responder, ApplicationError> {
if user.role != Role::Admin {
return Err(ApplicationError::Unauthorized);
}
let Some(clothing) = Clothing::read(pool.get_ref(), path.id).await? else {
return Ok(HttpResponse::NotFound().finish());
};
let template = ReadClothingPartialTemplate { c: clothing };
Ok(template.to_response()?)
}
#[cfg(test)]
mod tests {
use crate::{
models::{Clothing, Role},
utils::test_helper::{
assert_snapshot, read_body, test_get, DbTestContext, RequestConfig, StatusCode,
},
};
use brass_macros::db_test;
#[db_test]
async fn user_cant_view_single_entity(context: &DbTestContext) {
Clothing::create(&context.db_pool, "Tuchuniform")
.await
.unwrap();
let app = context.app().await;
let config = RequestConfig::new("/clothing/1");
let response = test_get(&context.db_pool, &app, &config).await;
assert_eq!(StatusCode::UNAUTHORIZED, response.status());
}
#[db_test]
async fn area_manager_cant_view_single_entity(context: &DbTestContext) {
Clothing::create(&context.db_pool, "Tuchuniform")
.await
.unwrap();
let app = context.app().await;
let config = RequestConfig::new("/clothing/1").with_role(Role::AreaManager);
let response = test_get(&context.db_pool, &app, &config).await;
assert_eq!(StatusCode::UNAUTHORIZED, response.status());
}
#[db_test]
async fn produces_template_fine_when_user_is_admin(context: &DbTestContext) {
let app = context.app().await;
Clothing::create(&context.db_pool, "Schutzkleidung Form 1")
.await
.unwrap();
let config = RequestConfig::new("/clothing/1").with_role(Role::Admin);
let response = test_get(&context.db_pool, &app, &config).await;
assert_eq!(StatusCode::OK, response.status());
let body = read_body(response).await;
assert_snapshot!(body);
}
}

View File

@ -0,0 +1,33 @@
use askama::Template;
use garde::Validate;
use serde::Deserialize;
use crate::models::Clothing;
use crate::filters;
pub mod delete;
pub mod get_edit;
pub mod get_new;
pub mod get_overview;
pub mod get_read;
pub mod post_edit;
pub mod post_new;
#[derive(Template)]
#[template(path = "clothing/clothing_entry_edit.html")]
struct EditClothingPartialTemplate {
id: Option<i32>,
name: Option<String>,
}
#[derive(Template)]
#[template(path = "clothing/clothing_entry_read.html")]
struct ReadClothingPartialTemplate {
c: Clothing,
}
#[derive(Deserialize, Validate)]
struct NewOrEditClothingForm {
#[garde(length(min=3))]
name: String,
}

View File

@ -0,0 +1,40 @@
use actix_web::{web, HttpResponse, Responder};
use garde::Validate;
use sqlx::PgPool;
use crate::{
endpoints::{
clothing::{NewOrEditClothingForm, ReadClothingPartialTemplate},
IdPath,
},
models::{Clothing, Role, User},
utils::{ApplicationError, TemplateResponse},
};
#[actix_web::post("/clothing/{id}")]
pub async fn post(
user: web::ReqData<User>,
pool: web::Data<PgPool>,
path: web::Path<IdPath>,
form: web::Form<NewOrEditClothingForm>,
) -> Result<impl Responder, ApplicationError> {
if user.role != Role::Admin {
return Err(ApplicationError::Unauthorized);
}
let Some(mut clothing) = Clothing::read(pool.get_ref(), path.id).await? else {
return Ok(HttpResponse::NotFound().finish());
};
if let Err(e) = form.validate() {
return Ok(HttpResponse::UnprocessableEntity().body(e.to_string()));
};
clothing.name = form.name.to_string();
Clothing::update(pool.get_ref(), clothing.id, &clothing.name).await?;
let template = ReadClothingPartialTemplate { c: clothing };
Ok(template.to_response()?)
}

View File

@ -0,0 +1,30 @@
use actix_web::{web, HttpResponse, Responder};
use garde::Validate;
use sqlx::PgPool;
use crate::{
endpoints::clothing::{NewOrEditClothingForm, ReadClothingPartialTemplate},
models::{Clothing, Role, User},
utils::{ApplicationError, TemplateResponse},
};
#[actix_web::post("/clothing")]
pub async fn post(
user: web::ReqData<User>,
pool: web::Data<PgPool>,
form: web::Form<NewOrEditClothingForm>,
) -> Result<impl Responder, ApplicationError> {
if user.role != Role::Admin {
return Err(ApplicationError::Unauthorized);
}
if let Err(e) = form.validate() {
return Ok(HttpResponse::UnprocessableEntity().body(e.to_string()));
};
let clothing = Clothing::create(pool.get_ref(), &form.name).await?;
let template = ReadClothingPartialTemplate { c: clothing };
Ok(template.to_response()?)
}

View File

@ -11,7 +11,7 @@ use chrono::{NaiveDate, NaiveTime};
use crate::{ use crate::{
endpoints::{events::NewOrEditEventTemplate, IdPath}, endpoints::{events::NewOrEditEventTemplate, IdPath},
models::{Assignment, Event, Function, Location, Role, User}, models::{Assignment, Clothing, Event, Function, Location, Role, User},
utils::{ApplicationError, TemplateResponse}, utils::{ApplicationError, TemplateResponse},
}; };
@ -37,6 +37,8 @@ pub async fn get(
let assignments = Assignment::read_all_by_event(pool.get_ref(), event.id).await?; let assignments = Assignment::read_all_by_event(pool.get_ref(), event.id).await?;
let clothing_options = Clothing::read_all(pool.get_ref()).await?;
let template = NewOrEditEventTemplate { let template = NewOrEditEventTemplate {
user: user.into_inner(), user: user.into_inner(),
date: event.start.date(), date: event.start.date(),
@ -59,7 +61,8 @@ pub async fn get(
voluntary_wachhabender: event.voluntary_wachhabender, voluntary_wachhabender: event.voluntary_wachhabender,
voluntary_fuehrungsassistent: event.voluntary_fuehrungsassistent, voluntary_fuehrungsassistent: event.voluntary_fuehrungsassistent,
amount_of_posten: Some(event.amount_of_posten), amount_of_posten: Some(event.amount_of_posten),
clothing: Some(event.clothing), clothing: Some(event.clothing.id),
clothing_options,
canceled: event.canceled, canceled: event.canceled,
note: event.note, note: event.note,
}; };
@ -107,7 +110,7 @@ async fn produces_template(context: &DbTestContext) {
voluntary_fuehrungsassistent: false, voluntary_fuehrungsassistent: false,
voluntary_wachhabender: false, voluntary_wachhabender: false,
amount_of_posten: 2, amount_of_posten: 2,
clothing: "Tuchuniform".to_string(), clothing: 1,
note: None, note: None,
}; };

View File

@ -4,7 +4,7 @@ use sqlx::PgPool;
use crate::{ use crate::{
endpoints::{events::NewOrEditEventTemplate, NaiveDateQuery}, endpoints::{events::NewOrEditEventTemplate, NaiveDateQuery},
models::{Location, Role, User}, models::{Clothing, Location, Role, User},
utils::{ApplicationError, TemplateResponse}, utils::{ApplicationError, TemplateResponse},
}; };
@ -24,6 +24,8 @@ pub async fn get(
Location::read_by_area(pool.get_ref(), user.area_id).await? Location::read_by_area(pool.get_ref(), user.area_id).await?
}; };
let clothing_options = Clothing::read_all(pool.get_ref()).await?;
let template = NewOrEditEventTemplate { let template = NewOrEditEventTemplate {
user: user.into_inner(), user: user.into_inner(),
date: query.date, date: query.date,
@ -42,6 +44,7 @@ pub async fn get(
voluntary_fuehrungsassistent: false, voluntary_fuehrungsassistent: false,
amount_of_posten: None, amount_of_posten: None,
clothing: None, clothing: None,
clothing_options,
canceled: false, canceled: false,
note: None, note: None,
}; };

View File

@ -1,10 +1,11 @@
use crate::filters;
use askama::Template; use askama::Template;
use chrono::{Days, NaiveDateTime}; use chrono::{Days, NaiveDateTime};
use chrono::{NaiveDate, NaiveTime}; use chrono::{NaiveDate, NaiveTime};
use serde::Deserialize; use serde::Deserialize;
use crate::models::{Location, Role, User}; use crate::filters;
use crate::models::{Clothing, Location, Role, User};
use crate::utils::DateTimeFormat::{DayMonthYear, HourMinute, YearMonthDayTHourMinute};
pub mod delete; pub mod delete;
pub mod get_edit; pub mod get_edit;
@ -29,7 +30,8 @@ pub struct NewOrEditEventTemplate {
voluntary_wachhabender: bool, voluntary_wachhabender: bool,
voluntary_fuehrungsassistent: bool, voluntary_fuehrungsassistent: bool,
amount_of_posten: Option<i16>, amount_of_posten: Option<i16>,
clothing: Option<String>, clothing: Option<i32>,
clothing_options: Vec<Clothing>,
canceled: bool, canceled: bool,
note: Option<String>, note: Option<String>,
amount_of_planned_posten: usize, amount_of_planned_posten: usize,
@ -48,7 +50,7 @@ pub struct NewOrEditEventForm {
voluntarywachhabender: bool, voluntarywachhabender: bool,
voluntaryfuehrungsassistent: bool, voluntaryfuehrungsassistent: bool,
amount: i16, amount: i16,
clothing: String, clothing: i32,
note: Option<String>, note: Option<String>,
} }

View File

@ -45,7 +45,7 @@ pub async fn post(
let changeset = EventChangeset { let changeset = EventChangeset {
amount_of_posten: form.amount, amount_of_posten: form.amount,
clothing: form.clothing.clone(), clothing: form.clothing,
location_id: form.location, location_id: form.location,
time: (form.date.and_time(form.start), form.end), time: (form.date.and_time(form.start), form.end),
name: form.name.clone(), name: form.name.clone(),

View File

@ -28,7 +28,7 @@ pub async fn post(
let changeset = EventChangeset { let changeset = EventChangeset {
amount_of_posten: form.amount, amount_of_posten: form.amount,
clothing: form.clothing.clone(), clothing: form.clothing,
location_id: form.location, location_id: form.location,
time: (form.date.and_time(form.start), form.end), time: (form.date.and_time(form.start), form.end),
name: form.name.clone(), name: form.name.clone(),

View File

@ -0,0 +1,101 @@
use actix_web::{web, Responder};
use askama::Template;
use chrono::{Datelike, Months, NaiveDate, Utc};
use sqlx::PgPool;
use crate::{
models::{Area, Role, User},
utils::{ApplicationError, TemplateResponse},
};
#[derive(Template)]
#[template(path = "export/events.html")]
struct EventExportTemplate {
user: User,
areas: Option<Vec<Area>>,
daterange: (NaiveDate, NaiveDate),
current_month: (NaiveDate, NaiveDate),
current_quarter: (NaiveDate, NaiveDate),
next_month: (NaiveDate, NaiveDate),
next_quarter: (NaiveDate, NaiveDate),
}
#[actix_web::get("/export/events")]
pub async fn get(
user: web::ReqData<User>,
pool: web::Data<PgPool>,
) -> Result<impl Responder, ApplicationError> {
if user.role != Role::Admin && user.role != Role::AreaManager {
return Err(ApplicationError::Unauthorized);
}
let areas = if user.role == Role::Admin {
Some(Area::read_all(pool.get_ref()).await?)
} else {
None
};
let today = Utc::now().date_naive();
let today_plus_month = today.checked_add_months(Months::new(1)).unwrap();
let today_plus_three_month = today.checked_add_months(Months::new(3)).unwrap();
let template = EventExportTemplate {
user: user.into_inner(),
areas,
daterange: (
first_day_of_month(&today).unwrap(),
last_day_of_month(&today).unwrap(),
),
current_month: (
first_day_of_month(&today).unwrap(),
last_day_of_month(&today).unwrap(),
),
next_month: (
first_day_of_month(&today_plus_month).unwrap(),
last_day_of_month(&today_plus_month).unwrap(),
),
current_quarter: (
first_day_of_quarter(&today).unwrap(),
last_day_of_quarter(&today).unwrap(),
),
next_quarter: (
first_day_of_quarter(&today_plus_three_month).unwrap(),
last_day_of_quarter(&today_plus_three_month).unwrap(),
),
};
Ok(template.to_response()?)
}
fn first_day_of_month(date: &NaiveDate) -> Option<NaiveDate> {
NaiveDate::from_ymd_opt(date.year(), date.month0() + 1, 1)
}
fn last_day_of_month(date: &NaiveDate) -> Option<NaiveDate> {
let month0 = date.month0() + 1;
let year = if month0 > 11 {
date.year() + 1
} else {
date.year()
};
let month = (month0 % 12) + 1;
NaiveDate::from_ymd_opt(year, month, 1)?.pred_opt()
}
fn first_day_of_quarter(date: &NaiveDate) -> Option<NaiveDate> {
let start_month = (date.quarter() * 3) - 2;
NaiveDate::from_ymd_opt(date.year(), start_month, 1)
}
fn last_day_of_quarter(date: &NaiveDate) -> Option<NaiveDate> {
let quarter = date.quarter();
let (year, next_month) = if quarter == 4 {
(date.year() + 1, 1)
} else {
(date.year(), (quarter * 3) + 1)
};
NaiveDate::from_ymd_opt(year, next_month, 1)?.pred_opt()
}

View File

@ -0,0 +1,191 @@
use crate::models::{ExportEventRow, Function, SimpleAssignment};
use actix_http::header::CONTENT_DISPOSITION;
use actix_web::{http::header::ContentDisposition, web, HttpResponse, Responder};
use chrono::{Datelike, NaiveDate};
use rust_xlsxwriter::workbook::Workbook;
use serde::Deserialize;
use sqlx::PgPool;
use crate::{
models::{Role, User},
utils::{ApplicationError, DateTimeFormat},
};
#[derive(Deserialize)]
struct ExportQuery {
start: NaiveDate,
end: NaiveDate,
area: Option<i32>,
}
#[derive(PartialEq)]
struct EventExportEntry {
date: String,
weekday: String,
start_time: String,
end_time: String,
hours: f32,
location: String,
name: String,
assigned_name: Option<String>,
assigned_function: Option<String>,
}
fn read(rows: Vec<ExportEventRow>) -> Vec<EventExportEntry> {
let mut entries = Vec::new();
for r in rows {
let create_new_entry = |n: Option<&str>, f: Option<&str>| EventExportEntry {
date: r
.start_timestamp
.format(DateTimeFormat::DayMonthYear.into())
.to_string(),
weekday: r.start_timestamp.weekday().to_string(),
start_time: r
.start_timestamp
.time()
.format(DateTimeFormat::HourMinute.into())
.to_string(),
end_time: r
.end_timestamp
.time()
.format(DateTimeFormat::HourMinute.into())
.to_string(),
hours: (r.end_timestamp - r.start_timestamp).as_seconds_f32() / 3600.0 + 1.0,
location: r.location_name.to_string(),
name: r.event_name.to_string(),
assigned_name: n.and_then(|s| Some(s.to_string())),
assigned_function: f.and_then(|s| Some(s.to_string())),
};
if let Some(assigned_wh) = r
.assignments
.iter()
.find(|a| a.function == Function::Wachhabender)
{
entries.push(create_new_entry(
Some(&assigned_wh.name),
Some(&assigned_wh.function.short_display()),
));
} else {
let function = if r.voluntary_wachhabender {
"WH"
} else {
"BF-WH"
};
entries.push(create_new_entry(None, Some(function)));
}
if let Some(assigned_fuass) = r
.assignments
.iter()
.find(|a| a.function == Function::Fuehrungsassistent)
{
entries.push(create_new_entry(
Some(&assigned_fuass.name),
Some(&assigned_fuass.function.short_display()),
));
} else if r.voluntary_fuehrungsassistent {
entries.push(create_new_entry(
None,
Some(&Function::Fuehrungsassistent.short_display()),
));
}
let assigned_po: Vec<&SimpleAssignment> = r
.assignments
.iter()
.filter(|a| a.function == Function::Posten)
.collect();
for i in 0..r.amount_of_posten {
if let Some(po) = assigned_po.get(i as usize) {
entries.push(create_new_entry(
Some(&po.name),
Some(&po.function.short_display()),
));
} else {
entries.push(create_new_entry(
None,
Some(&Function::Posten.short_display()),
));
}
}
for v in r.vehicles {
entries.push(create_new_entry(Some(&v), Some("Fzg")));
}
}
entries
}
#[actix_web::get("/export/eventsdata")]
pub async fn get(
pool: web::Data<PgPool>,
user: web::ReqData<User>,
query: web::Query<ExportQuery>,
) -> Result<impl Responder, ApplicationError> {
if user.role != Role::Admin && user.role != Role::AreaManager {
return Err(ApplicationError::Unauthorized);
}
let area = query.area.unwrap_or(user.area_id);
let rows_to_export = ExportEventRow::read_all_for_timerange_and_area(
pool.get_ref(),
(query.start, query.end),
area,
)
.await?;
let entries = read(rows_to_export);
let mut workbook = Workbook::new();
let worksheet = workbook.add_worksheet();
const HEADER: [&str; 10] = [
"Datum",
"Wochentag",
"Beginn",
"Ende",
"Stunden",
"Ort",
"VA",
"Namen",
"Fkt",
"Reserve",
];
worksheet.write_row(0, 0, HEADER).unwrap();
for (i, entry) in entries.iter().enumerate() {
let i = (i + 1) as u32;
worksheet.write(i, 0, &entry.date).unwrap();
worksheet.write(i, 1, &entry.weekday).unwrap();
worksheet.write(i, 2, &entry.start_time).unwrap();
worksheet.write(i, 3, &entry.end_time).unwrap();
worksheet.write(i, 4, entry.hours).unwrap();
worksheet
.write(i, 4, format!("{:.1}", entry.hours))
.unwrap();
worksheet.write(i, 5, &entry.location).unwrap();
worksheet.write(i, 6, &entry.name).unwrap();
worksheet.write(i, 7, entry.assigned_name.as_ref()).unwrap();
worksheet
.write(i, 8, entry.assigned_function.as_ref())
.unwrap();
}
worksheet.autofit();
let buffer = workbook.save_to_buffer().unwrap();
return Ok(HttpResponse::Ok()
.content_type("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
.insert_header((
CONTENT_DISPOSITION,
ContentDisposition::attachment("export.xlsx"),
))
.body(buffer));
}

View File

@ -1,2 +1,4 @@
pub mod get_availability; pub mod get_availability;
pub mod get_availability_data; pub mod get_availability_data;
pub mod get_events;
pub mod get_events_data;

View File

@ -1,15 +1,23 @@
use actix_web::Responder; use actix_web::{web, Responder};
use askama::Template; use askama::Template;
use crate::utils::{ApplicationError, TemplateResponse}; use crate::utils::{ApplicationError, Customization, TemplateResponse};
#[derive(Template)] #[derive(Template)]
#[template(path = "imprint.html")] #[template(path = "imprint.html")]
struct ImprintTemplate {} struct ImprintTemplate {
webmaster_mail: String,
hostname: String,
}
#[actix_web::get("/imprint")] #[actix_web::get("/imprint")]
pub async fn get_imprint() -> Result<impl Responder, ApplicationError> { pub async fn get_imprint(
let template = ImprintTemplate {}; customization: web::Data<Customization>,
) -> Result<impl Responder, ApplicationError> {
let template = ImprintTemplate {
webmaster_mail: customization.webmaster_email.clone(),
hostname: customization.hostname.clone(),
};
Ok(template.to_response()?) Ok(template.to_response()?)
} }

View File

@ -5,11 +5,12 @@ use serde::Deserialize;
mod area; mod area;
mod assignment; mod assignment;
mod availability; mod availability;
mod clothing;
mod events; mod events;
mod export; mod export;
mod imprint; mod imprint;
mod location; mod location;
pub mod user; pub mod user; // TODO: why pub?
mod vehicle; mod vehicle;
mod vehicle_assignment; mod vehicle_assignment;
@ -80,6 +81,8 @@ pub fn init(cfg: &mut ServiceConfig) {
cfg.service(export::get_availability::get); cfg.service(export::get_availability::get);
cfg.service(export::get_availability_data::get); cfg.service(export::get_availability_data::get);
cfg.service(export::get_events::get);
cfg.service(export::get_events_data::get);
cfg.service(imprint::get_imprint); cfg.service(imprint::get_imprint);
@ -92,4 +95,12 @@ pub fn init(cfg: &mut ServiceConfig) {
cfg.service(vehicle_assignment::post_new::post); cfg.service(vehicle_assignment::post_new::post);
cfg.service(vehicle_assignment::delete::delete); cfg.service(vehicle_assignment::delete::delete);
cfg.service(clothing::get_overview::get);
cfg.service(clothing::get_new::get);
cfg.service(clothing::get_edit::get);
cfg.service(clothing::get_read::get);
cfg.service(clothing::delete::delete);
cfg.service(clothing::post_edit::post);
cfg.service(clothing::post_new::post);
} }

View File

@ -6,14 +6,16 @@ use sqlx::PgPool;
use crate::{ use crate::{
models::PasswordReset, models::PasswordReset,
utils::{ApplicationError, TemplateResponse}, utils::{ApplicationError, Customization, TemplateResponse},
}; };
use super::ResetPasswordTemplate; use super::ResetPasswordTemplate;
#[derive(Template)] #[derive(Template)]
#[template(path = "user/forgot_password.html")] #[template(path = "user/forgot_password.html")]
struct ForgotPasswordTemplate {} struct ForgotPasswordTemplate<'a> {
webmaster_email: &'a str,
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct TokenQuery { struct TokenQuery {
@ -25,6 +27,7 @@ pub async fn get(
user: Option<Identity>, user: Option<Identity>,
pool: web::Data<PgPool>, pool: web::Data<PgPool>,
query: web::Query<TokenQuery>, query: web::Query<TokenQuery>,
customization: web::Data<Customization>,
) -> Result<impl Responder, ApplicationError> { ) -> Result<impl Responder, ApplicationError> {
if user.is_some() { if user.is_some() {
return Ok(HttpResponse::Found() return Ok(HttpResponse::Found()
@ -45,6 +48,8 @@ pub async fn get(
} }
} }
let template = ForgotPasswordTemplate {}; let template = ForgotPasswordTemplate {
webmaster_email: &customization.webmaster_email,
};
Ok(template.to_response()?) Ok(template.to_response()?)
} }

View File

@ -1,11 +1,10 @@
use actix_web::{http::header::LOCATION, web, HttpResponse, Responder}; use actix_web::{http::header::LOCATION, web, HttpResponse, Responder};
use garde::Validate;
use sqlx::PgPool; use sqlx::PgPool;
use crate::{ use crate::{
endpoints::{user::NewOrEditUserForm, IdPath}, endpoints::{user::NewOrEditUserForm, IdPath},
models::{Area, Function, Role, User, UserChangeset}, models::{Function, Role, User, UserChangeset},
utils::ApplicationError, utils::{validation::{AsyncValidate, DbContext}, ApplicationError},
}; };
#[actix_web::post("/users/edit/{id}")] #[actix_web::post("/users/edit/{id}")]
@ -28,14 +27,12 @@ pub async fn post_edit(
}; };
let role = form.role.try_into()?; let role = form.role.try_into()?;
if user.role == Role::AreaManager && (user.area_id != user_in_db.area_id || role == Role::Admin) { if user.role == Role::AreaManager && (user.area_id != user_in_db.area_id || role == Role::Admin)
{
return Err(ApplicationError::Unauthorized); return Err(ApplicationError::Unauthorized);
} }
let area_id = form.area.unwrap_or(user_in_db.area_id); let area_id = form.area.unwrap_or(user_in_db.area_id);
if Area::read_by_id(pool.get_ref(), area_id).await?.is_none() {
return Err(ApplicationError::Unauthorized);
}
let mut functions = Vec::with_capacity(3); let mut functions = Vec::with_capacity(3);
@ -53,14 +50,22 @@ pub async fn post_edit(
let changeset = UserChangeset { let changeset = UserChangeset {
name: form.name.clone(), name: form.name.clone(),
email: form.email.clone(), email: form.email.to_lowercase(),
role, role,
functions, functions,
area_id, area_id,
}; };
if let Err(e) = changeset.validate() { if let Some(existing_id) = User::exists(pool.get_ref(), &changeset.email).await? {
return Ok(HttpResponse::BadRequest().body(e.to_string())); if existing_id != user_in_db.id {
return Ok(HttpResponse::UnprocessableEntity()
.body("email: an user already exists with the same email"));
}
}
let context = DbContext::new(pool.get_ref());
if let Err(e) = changeset.validate_with_context(&context).await {
return Ok(HttpResponse::UnprocessableEntity().body(e.to_string()));
}; };
User::update(pool.get_ref(), user_in_db.id, changeset).await?; User::update(pool.get_ref(), user_in_db.id, changeset).await?;
@ -145,4 +150,67 @@ mod tests {
let response = test_post(&context.db_pool, app, &config, form).await; let response = test_post(&context.db_pool, app, &config, form).await;
assert_eq!(StatusCode::BAD_REQUEST, response.status()); assert_eq!(StatusCode::BAD_REQUEST, response.status());
} }
#[db_test]
async fn email_gets_cast_to_lowercase(context: &DbTestContext) {
User::create(&context.db_pool, Faker.fake()).await.unwrap();
Area::create(&context.db_pool, "Süd").await.unwrap();
let app = context.app().await;
let config = RequestConfig::new("/users/edit/1").with_role(Role::Admin);
let new_name: String = Name().fake();
let new_mail: String = String::from("NONLowercaseEMAIL@example.com");
let form = NewOrEditUserForm {
name: new_name.clone(),
email: new_mail.clone(),
role: Role::AreaManager as u8,
is_posten: None,
is_wachhabender: None,
is_fuehrungsassistent: Some(true),
area: Some(2),
};
let response = test_post(&context.db_pool, app, &config, form).await;
assert_eq!(StatusCode::FOUND, response.status());
let updated_user = User::read_by_id(&context.db_pool, 1)
.await
.unwrap()
.unwrap();
assert_eq!(new_mail.to_lowercase(), updated_user.email);
}
#[db_test]
async fn fails_when_email_already_present(context: &DbTestContext) {
User::create(&context.db_pool, Faker.fake()).await.unwrap();
User::create(&context.db_pool, Faker.fake()).await.unwrap();
Area::create(&context.db_pool, "Süd").await.unwrap();
let app = context.app().await;
let config = RequestConfig::new("/users/edit/1").with_role(Role::Admin);
let second_user = User::read_by_id(&context.db_pool, 2)
.await
.unwrap()
.unwrap();
let new_name: String = Name().fake();
let new_mail: String = second_user.email;
let form = NewOrEditUserForm {
name: new_name.clone(),
email: new_mail.clone(),
role: Role::AreaManager as u8,
is_posten: None,
is_wachhabender: None,
is_fuehrungsassistent: Some(true),
area: Some(2),
};
let response = test_post(&context.db_pool, app, &config, form).await;
assert_eq!(StatusCode::UNPROCESSABLE_ENTITY, response.status());
}
} }

View File

@ -18,7 +18,7 @@ async fn post(
request: HttpRequest, request: HttpRequest,
pool: web::Data<PgPool>, pool: web::Data<PgPool>,
) -> impl Responder { ) -> impl Responder {
if let Ok(user) = User::read_for_login(pool.get_ref(), &form.email).await { if let Ok(user) = User::read_for_login(pool.get_ref(), &form.email.to_lowercase()).await {
let salt = user.salt.unwrap(); let salt = user.salt.unwrap();
let hash = hash_plain_password_with_salt(&form.password, &salt).unwrap(); let hash = hash_plain_password_with_salt(&form.password, &salt).unwrap();

View File

@ -1,12 +1,11 @@
use actix_web::{http::header::LOCATION, web, HttpResponse, Responder}; use actix_web::{http::header::LOCATION, web, HttpResponse, Responder};
use garde::Validate;
use sqlx::PgPool; use sqlx::PgPool;
use crate::{ use crate::{
endpoints::user::NewOrEditUserForm, endpoints::user::NewOrEditUserForm,
mail::Mailer, mail::Mailer,
models::{Function, Registration, Role, User, UserChangeset}, models::{Function, Registration, Role, User, UserChangeset},
utils::ApplicationError, utils::{validation::{AsyncValidate, DbContext}, ApplicationError},
}; };
#[actix_web::post("/users/new")] #[actix_web::post("/users/new")]
@ -47,14 +46,20 @@ pub async fn post_new(
let changeset = UserChangeset { let changeset = UserChangeset {
name: form.name.clone(), name: form.name.clone(),
email: form.email.clone(), email: form.email.to_lowercase(),
role, role,
functions, functions,
area_id, area_id,
}; };
if let Err(e) = changeset.validate() { if let Some(_) = User::exists(pool.get_ref(), &changeset.email).await? {
return Ok(HttpResponse::BadRequest().body(e.to_string())); return Ok(HttpResponse::UnprocessableEntity()
.body("email: an user already exists with the same email"));
}
let context = DbContext::new(pool.get_ref());
if let Err(e) = changeset.validate_with_context(&context).await {
return Ok(HttpResponse::UnprocessableEntity().body(e.to_string()));
}; };
let id = User::create(pool.get_ref(), changeset).await?; let id = User::create(pool.get_ref(), changeset).await?;

View File

@ -29,7 +29,9 @@ async fn post(
&& form.password.is_none() && form.password.is_none()
&& form.passwordretyped.is_none() && form.passwordretyped.is_none()
{ {
if let Ok(user) = User::read_for_login(pool.get_ref(), form.email.as_ref().unwrap()).await { if let Ok(user) =
User::read_for_login(pool.get_ref(), &form.email.as_ref().unwrap().to_lowercase()).await
{
let reset = PasswordReset::insert_new_for_user(pool.get_ref(), user.id).await?; let reset = PasswordReset::insert_new_for_user(pool.get_ref(), user.id).await?;
mailer mailer
.send_forgot_password_mail(&user, &reset.token) .send_forgot_password_mail(&user, &reset.token)

View File

@ -2,8 +2,9 @@ use std::fmt::Display;
use chrono::{NaiveDate, NaiveDateTime, NaiveTime}; use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
use maud::html; use maud::html;
use tracing::trace;
use crate::models::UserFunction; use crate::{models::UserFunction, utils::DateTimeFormat};
pub fn show_area_query(a: &Option<i32>, first: bool) -> askama::Result<String> { pub fn show_area_query(a: &Option<i32>, first: bool) -> askama::Result<String> {
let char = if first { '?' } else { '&' }; let char = if first { '?' } else { '&' };
@ -28,7 +29,8 @@ where
T: Display, T: Display,
{ {
if let Some(val) = option { if let Some(val) = option {
let s = format!(r#"value="{val}""#); let escaped = escape_html(val.to_string());
let s = format!(r#"value="{escaped}""#);
return Ok(s); return Ok(s);
} }
@ -73,38 +75,34 @@ pub fn show_tree(f: &UserFunction) -> askama::Result<String> {
Ok(html.into_string()) Ok(html.into_string())
} }
pub fn dt_f(v: &NaiveDateTime) -> askama::Result<String> { pub fn fmt_date(v: &NaiveDate, format: DateTimeFormat) -> askama::Result<String> {
Ok(v.format("%Y-%m-%dT%H:%M").to_string()) let format_string = format.into();
trace!(format=format_string, "formatting naivedate into string with format");
Ok(v.format(format_string).to_string())
} }
pub fn dt_ff(v: &NaiveDateTime) -> askama::Result<String> { pub fn fmt_datetime(v: &NaiveDateTime, format: DateTimeFormat) -> askama::Result<String> {
Ok(v.format("%d.%m.%Y %H:%M").to_string()) let format_string = format.into();
trace!(format=format_string, "formatting naivedatetime into string with format");
Ok(v.format(format_string).to_string())
} }
pub fn date_d(v: &NaiveDate) -> askama::Result<String> { pub fn fmt_time(v: &NaiveTime, format: DateTimeFormat) -> askama::Result<String> {
Ok(v.format("%d.%m.%Y").to_string()) let format_string = format.into();
trace!(format=format_string, "formatting naivetime into string with format");
Ok(v.format(format_string).to_string())
} }
pub fn date_c(v: &NaiveDate) -> askama::Result<String> { fn escape_html(string: String) -> String {
Ok(v.format("%d.%m").to_string()) let s = string
} .replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#x27;");
pub fn date_c_and_time(v: &NaiveDateTime) -> askama::Result<String> { s
Ok(v.format("%d.%m. %H:%M").to_string())
}
pub fn time(v: &NaiveTime) -> askama::Result<String> {
Ok(v.format("%H:%M").to_string())
}
pub fn time_opt(v: &Option<NaiveTime>, default: &str) -> askama::Result<String> {
if let Some(t) = v {
return time(t);
}
Ok(default.to_string())
}
pub fn dt_t(v: &NaiveDateTime) -> askama::Result<String> {
Ok(v.format("%R").to_string())
} }

View File

@ -1,8 +1,8 @@
use askama::Template;
use lettre::{ use lettre::{
message::{Mailbox, MultiPart, SinglePart}, message::{Mailbox, MultiPart, SinglePart},
AsyncTransport, Message, Address, AsyncTransport, Message,
}; };
use askama::Template;
use crate::{models::User, utils::ApplicationError}; use crate::{models::User, utils::ApplicationError};
@ -55,9 +55,14 @@ fn build(
} }
.to_string(); .to_string();
let sender_mailbox = Mailbox::new(
Some("noreply".to_string()),
Address::new("noreply", &hostname)?,
);
let message = Message::builder() let message = Message::builder()
.from("noreply <noreply@brasiwa-leipzig.de>".parse()?) .from(sender_mailbox.clone())
.reply_to("noreply <noreply@brasiwa-leipzig.de>".parse()?) .reply_to(sender_mailbox)
.to(Mailbox::new(Some(name.to_string()), email.parse()?)) .to(Mailbox::new(Some(name.to_string()), email.parse()?))
.subject("Brass: Zurücksetzen des Passworts angefordert") .subject("Brass: Zurücksetzen des Passworts angefordert")
.multipart( .multipart(

View File

@ -14,13 +14,13 @@ mod forgot_password;
mod registration; mod registration;
mod testmail; mod testmail;
#[derive(Clone)] #[derive(Clone, Debug)]
pub struct Mailer { pub struct Mailer {
transport: Transports, transport: Transports,
hostname: String, hostname: String,
} }
#[derive(Clone)] #[derive(Clone, Debug)]
enum Transports { enum Transports {
SmtpTransport(AsyncSmtpTransport<AsyncStd1Executor>), SmtpTransport(AsyncSmtpTransport<AsyncStd1Executor>),
#[allow(unused)] #[allow(unused)]

View File

@ -1,8 +1,7 @@
use lettre::{
message::{Mailbox, MultiPart, SinglePart},
AsyncTransport, Message,
};
use askama::Template; use askama::Template;
use lettre::{
message::{Mailbox, MultiPart, SinglePart}, Address, AsyncTransport, Message
};
use crate::{models::User, utils::ApplicationError}; use crate::{models::User, utils::ApplicationError};
@ -59,9 +58,14 @@ fn build(
} }
.to_string(); .to_string();
let sender_mailbox = Mailbox::new(
Some("noreply".to_string()),
Address::new("noreply", &hostname)?,
);
let message = Message::builder() let message = Message::builder()
.from("noreply <noreply@brasiwa-leipzig.de>".parse()?) .from(sender_mailbox.clone())
.reply_to("noreply <noreply@brasiwa-leipzig.de>".parse()?) .reply_to(sender_mailbox)
.to(Mailbox::new(Some(name.to_string()), email.parse()?)) .to(Mailbox::new(Some(name.to_string()), email.parse()?))
.subject("Brass: Registrierung deines Accounts") .subject("Brass: Registrierung deines Accounts")
.multipart( .multipart(

View File

@ -24,7 +24,7 @@ ur noch ein Passwort festlegen. Kopiere daf=C3=BCr folgenden Link in deinen=
https://brasiwa-leipzig.de/register?token=3D123456789 https://brasiwa-leipzig.de/register?token=3D123456789
Bitte beachte, dass der Link nur 24 Stunden g=C3=BCltig ist. Bitte beachte, dass der Link nur 5 Tage lang g=C3=BCltig ist.
Viele Gr=C3=BC=C3=9Fe Viele Gr=C3=BC=C3=9Fe
--boundary --boundary
@ -41,7 +41,7 @@ nden Link in deinen Browser:</p>
<p>https://brasiwa-leipzig.de/register?token=3D123456789</p> <p>https://brasiwa-leipzig.de/register?token=3D123456789</p>
<p>Bitte beachte, dass der Link <b>nur 24 Stunden g=C3=BCltig</b> ist.</p> <p>Bitte beachte, dass der Link <b>nur 5 Tage lang g=C3=BCltig</b> ist.</p>
<p>Viele Gr=C3=BC=C3=9Fe</p> <p>Viele Gr=C3=BC=C3=9Fe</p>
--boundary-- --boundary--

View File

@ -1,21 +1,35 @@
use lettre::{message::SinglePart, AsyncTransport, Message}; use lettre::{
message::{Mailbox, SinglePart},
Address, AsyncTransport, Message,
};
use tracing::{debug, instrument};
use crate::utils::ApplicationError; use crate::utils::ApplicationError;
use super::Mailer; use super::Mailer;
impl Mailer { impl Mailer {
#[instrument]
pub async fn send_test_mail(&self, to: &str) -> Result<(), ApplicationError> { pub async fn send_test_mail(&self, to: &str) -> Result<(), ApplicationError> {
let sender_mailbox = Mailbox::new(
Some("noreply".to_string()),
Address::new("noreply", &self.hostname)?,
);
let message = Message::builder() let message = Message::builder()
.from("noreply <noreply@brasiwa-leipzig.de>".parse()?) .from(sender_mailbox.clone())
.reply_to("noreply <noreply@brasiwa-leipzig.de>".parse()?) .reply_to(sender_mailbox)
.to(to.parse()?) .to(to.parse()?)
.subject("Brass: Test E-Mail") .subject("Brass: Test E-Mail")
.singlepart(SinglePart::plain( .singlepart(SinglePart::plain(
"Testmail von Brass. E-Mail Versand funktioniert!".to_string(), "Testmail von Brass. E-Mail Versand funktioniert!".to_string(),
))?; ))?;
debug!("constructed test message");
self.transport.send(message).await?; self.transport.send(message).await?;
debug!("sent test message");
Ok(()) Ok(())
} }
} }

View File

@ -19,6 +19,7 @@ use tracing_panic::panic_hook;
use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt; use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::{fmt, EnvFilter}; use tracing_subscriber::{fmt, EnvFilter};
use utils::Customization;
use crate::postgres_session_store::SqlxPostgresqlSessionStore; use crate::postgres_session_store::SqlxPostgresqlSessionStore;
use crate::utils::manage_commands::{handle_command, parse_args}; use crate::utils::manage_commands::{handle_command, parse_args};
@ -49,6 +50,10 @@ async fn main() -> anyhow::Result<()> {
let pool = PgPool::connect(&config.database_url).await?; let pool = PgPool::connect(&config.database_url).await?;
let mailer = Mailer::new(&config)?; let mailer = Mailer::new(&config)?;
let customization = Customization {
webmaster_email: config.webmaster_email.clone(),
hostname: config.hostname.clone()
};
handle_command(args.command, &pool, &mailer).await?; handle_command(args.command, &pool, &mailer).await?;
@ -56,10 +61,17 @@ async fn main() -> anyhow::Result<()> {
let port = config.server_port; let port = config.server_port;
info!("Starting server on http://{address}:{port}."); info!("Starting server on http://{address}:{port}.");
HttpServer::new(move || create_app(config.clone(), pool.clone(), mailer.clone())) HttpServer::new(move || {
.bind((address, port))? create_app(
.run() config.clone(),
.await?; pool.clone(),
mailer.clone(),
customization.clone(),
)
})
.bind((address, port))?
.run()
.await?;
Ok(()) Ok(())
} }
@ -68,6 +80,7 @@ pub fn create_app(
config: Config, config: Config,
pool: Pool<Postgres>, pool: Pool<Postgres>,
mailer: Mailer, mailer: Mailer,
customization: Customization,
) -> App< ) -> App<
impl ServiceFactory< impl ServiceFactory<
ServiceRequest, ServiceRequest,
@ -84,6 +97,7 @@ pub fn create_app(
App::new() App::new()
.app_data(web::Data::new(pool)) .app_data(web::Data::new(pool))
.app_data(web::Data::new(mailer)) .app_data(web::Data::new(mailer))
.app_data(web::Data::new(customization))
.configure(endpoints::init) .configure(endpoints::init)
.wrap(middleware::ErrorAppender) .wrap(middleware::ErrorAppender)
.wrap(TracingLogger::default()) .wrap(TracingLogger::default())

View File

@ -25,7 +25,7 @@ impl Area {
} }
pub async fn read_all(pool: &PgPool) -> Result<Vec<Area>> { pub async fn read_all(pool: &PgPool) -> Result<Vec<Area>> {
let records = query_as!(Area, "SELECT * FROM area ORDER by id") let records = query_as!(Area, "SELECT * FROM area ORDER by name")
.fetch_all(pool) .fetch_all(pool)
.await?; .await?;

View File

@ -341,6 +341,49 @@ impl Availability {
Ok(availabilities) Ok(availabilities)
} }
pub async fn find_adjacent_by_time_for_user(
pool: &PgPool,
start: &NaiveDateTime,
end: &NaiveDateTime,
user: i32,
availability_to_ignore: Option<i32>,
) -> Result<Option<Availability>> {
let records = query!(
r##"
SELECT
availability.id,
availability.userId,
availability.startTimestamp,
availability.endTimestamp,
availability.comment
FROM availability
WHERE availability.userId = $1
AND (availability.endtimestamp = $2
OR availability.starttimestamp = $3)
AND (availability.id <> $4 OR $4 IS NULL);
"##,
user,
start.and_utc(),
end.and_utc(),
availability_to_ignore
)
.fetch_all(pool) // possible to find up to two availabilities (upper and lower), for now we only pick one and extend it
.await?;
let adjacent_avaialability = records.first().and_then(|r| {
Some(Availability {
id: r.id,
user_id: r.userid,
user: None,
start: r.starttimestamp.naive_utc(),
end: r.endtimestamp.naive_utc(),
comment: r.comment.clone(),
})
});
Ok(adjacent_avaialability)
}
pub async fn update(pool: &PgPool, id: i32, changeset: AvailabilityChangeset) -> Result<()> { pub async fn update(pool: &PgPool, id: i32, changeset: AvailabilityChangeset) -> Result<()> {
query!( query!(
"UPDATE availability SET startTimestamp = $1, endTimestamp = $2, comment = $3 WHERE id = $4", "UPDATE availability SET startTimestamp = $1, endTimestamp = $2, comment = $3 WHERE id = $4",

View File

@ -1,38 +1,63 @@
use chrono::{Days, NaiveDateTime}; use chrono::{Days, NaiveDateTime};
use garde::Validate; use sqlx::PgPool;
use crate::{END_OF_DAY, START_OF_DAY}; use crate::{
utils::validation::{
start_date_time_lies_before_end_date_time, AsyncValidate, AsyncValidateError,
},
END_OF_DAY, START_OF_DAY,
};
use super::{start_date_time_lies_before_end_date_time, Availability}; use super::Availability;
#[derive(Validate)]
#[garde(allow_unvalidated)]
#[garde(context(AvailabilityContext))]
pub struct AvailabilityChangeset { pub struct AvailabilityChangeset {
#[garde(
custom(time_is_not_already_made_available),
custom(start_date_time_lies_before_end_date_time)
)]
pub time: (NaiveDateTime, NaiveDateTime), pub time: (NaiveDateTime, NaiveDateTime),
pub comment: Option<String>, pub comment: Option<String>,
} }
pub struct AvailabilityContext { pub struct AvailabilityContext<'a> {
pub existing_availabilities: Vec<Availability>, pub pool: &'a PgPool,
pub user_id: i32,
pub availability_to_get_edited: Option<i32>,
}
impl<'a> AsyncValidate<'a> for AvailabilityChangeset {
type Context = AvailabilityContext<'a>;
async fn validate_with_context(
&self,
context: &'a Self::Context,
) -> Result<(), AsyncValidateError> {
let mut existing_availabilities =
Availability::read_by_user_and_date(context.pool, context.user_id, &self.time.0.date())
.await?;
if let Some(existing) = context.availability_to_get_edited {
existing_availabilities = existing_availabilities
.into_iter()
.filter(|a| a.id != existing)
.collect();
}
time_is_not_already_made_available(&self.time, &existing_availabilities)?;
start_date_time_lies_before_end_date_time(&self.time.0, &self.time.1)?;
Ok(())
}
} }
fn time_is_not_already_made_available( fn time_is_not_already_made_available(
value: &(NaiveDateTime, NaiveDateTime), value: &(NaiveDateTime, NaiveDateTime),
context: &AvailabilityContext, existing_availabilities: &Vec<Availability>,
) -> garde::Result { ) -> Result<(), AsyncValidateError> {
if context.existing_availabilities.is_empty() { if existing_availabilities.is_empty() {
return Ok(()); return Ok(());
} }
let free_slots = find_free_date_time_slots(&context.existing_availabilities); let free_slots = find_free_date_time_slots(existing_availabilities);
if free_slots.is_empty() { if free_slots.is_empty() {
return Err(garde::Error::new( return Err(AsyncValidateError::new(
"cant create a availability as every time slot is already filled", "cant create a availability as every time slot is already filled",
)); ));
} }
@ -41,7 +66,7 @@ fn time_is_not_already_made_available(
let free_block_found_for_end = free_slots.iter().any(|s| s.0 <= value.1 && s.1 >= value.1); let free_block_found_for_end = free_slots.iter().any(|s| s.0 <= value.1 && s.1 >= value.1);
if !free_block_found_for_start || !free_block_found_for_end { if !free_block_found_for_start || !free_block_found_for_end {
return Err(garde::Error::new( return Err(AsyncValidateError::new(
"cant create availability as there exists already a availability with the desired time", "cant create availability as there exists already a availability with the desired time",
)); ));
} }

View File

@ -0,0 +1,67 @@
use sqlx::{query, PgPool};
use super::Result;
#[derive(Debug, Clone)]
pub struct Clothing {
pub id: i32,
pub name: String,
}
impl Clothing {
pub async fn create(pool: &PgPool, name: &str) -> Result<Clothing> {
let r = query!("INSERT INTO clothing (name) VALUES ($1) RETURNING id;", name)
.fetch_one(pool)
.await?;
let created_clothing = Clothing {
id: r.id,
name: name.to_string()
};
Ok(created_clothing)
}
pub async fn read_all(pool: &PgPool) -> Result<Vec<Clothing>> {
let records = query!("SELECT * FROM clothing ORDER by name;").fetch_all(pool).await?;
let clothing_options = records
.into_iter()
.map(|v| Clothing {
id: v.id,
name: v.name,
})
.collect();
Ok(clothing_options)
}
pub async fn read(pool: &PgPool, id: i32) -> Result<Option<Clothing>> {
let record = query!("SELECT * FROM clothing WHERE id = $1;", id)
.fetch_optional(pool)
.await?;
let vehicle = record.map(|v| Clothing {
id: v.id,
name: v.name,
});
Ok(vehicle)
}
pub async fn update(pool: &PgPool, id: i32, name: &str) -> Result<()> {
query!("UPDATE clothing SET name = $1 WHERE id = $2;", name, id)
.execute(pool)
.await?;
Ok(())
}
pub async fn delete(pool: &PgPool, id: i32) -> Result<()> {
query!("DELETE FROM clothing WHERE id = $1;", id)
.execute(pool)
.await?;
Ok(())
}
}

View File

@ -1,7 +1,7 @@
use chrono::{NaiveDate, NaiveDateTime}; use chrono::{NaiveDate, NaiveDateTime};
use sqlx::{query, PgPool}; use sqlx::{query, PgPool};
use super::{event_changeset::EventChangeset, Location, Result}; use super::{event_changeset::EventChangeset, Clothing, Location, Result};
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Event { pub struct Event {
@ -14,7 +14,7 @@ pub struct Event {
pub voluntary_wachhabender: bool, pub voluntary_wachhabender: bool,
pub voluntary_fuehrungsassistent: bool, pub voluntary_fuehrungsassistent: bool,
pub amount_of_posten: i16, pub amount_of_posten: i16,
pub clothing: String, pub clothing: Clothing,
pub canceled: bool, pub canceled: bool,
pub note: Option<String>, pub note: Option<String>,
} }
@ -51,9 +51,12 @@ impl Event {
event.note, event.note,
location.id, location.id,
location.name AS locationName, location.name AS locationName,
location.areaId AS locationAreaId location.areaId AS locationAreaId,
clothing.id AS clothingId,
clothing.name AS clothingName
FROM event FROM event
JOIN location ON event.locationId = location.id JOIN location ON event.locationId = location.id
JOIN clothing ON event.clothing = clothing.id
WHERE starttimestamp::date = $1 WHERE starttimestamp::date = $1
AND location.areaId = $2; AND location.areaId = $2;
"#, "#,
@ -80,7 +83,10 @@ impl Event {
voluntary_wachhabender: record.voluntarywachhabender, voluntary_wachhabender: record.voluntarywachhabender,
voluntary_fuehrungsassistent: record.voluntaryfuehrungsassistent, voluntary_fuehrungsassistent: record.voluntaryfuehrungsassistent,
amount_of_posten: record.amountofposten, amount_of_posten: record.amountofposten,
clothing: record.clothing.to_string(), clothing: Clothing {
id: record.clothingid,
name: record.clothingname,
},
canceled: record.canceled, canceled: record.canceled,
note: record.note, note: record.note,
}) })
@ -106,9 +112,12 @@ impl Event {
event.note, event.note,
location.id, location.id,
location.name AS locationName, location.name AS locationName,
location.areaId AS locationAreaId location.areaId AS locationAreaId,
clothing.id AS clothingId,
clothing.name AS clothingName
FROM event FROM event
JOIN location ON event.locationId = location.id JOIN location ON event.locationId = location.id
JOIN clothing ON event.clothing = clothing.id
WHERE event.id = $1; WHERE event.id = $1;
"#, "#,
id id
@ -131,7 +140,10 @@ impl Event {
voluntary_wachhabender: record.voluntarywachhabender, voluntary_wachhabender: record.voluntarywachhabender,
voluntary_fuehrungsassistent: record.voluntaryfuehrungsassistent, voluntary_fuehrungsassistent: record.voluntaryfuehrungsassistent,
amount_of_posten: record.amountofposten, amount_of_posten: record.amountofposten,
clothing: record.clothing.to_string(), clothing: Clothing {
id: record.clothingid,
name: record.clothingname,
},
canceled: record.canceled, canceled: record.canceled,
note: record.note, note: record.note,
}); });

View File

@ -25,7 +25,7 @@ pub struct EventChangeset {
pub voluntary_fuehrungsassistent: bool, pub voluntary_fuehrungsassistent: bool,
#[garde(range(min = ctx.as_ref().map(|c: &EventContext| c.amount_of_assigned_posten).unwrap_or(0), max = 100))] #[garde(range(min = ctx.as_ref().map(|c: &EventContext| c.amount_of_assigned_posten).unwrap_or(0), max = 100))]
pub amount_of_posten: i16, pub amount_of_posten: i16,
pub clothing: String, pub clothing: i32,
pub note: Option<String>, pub note: Option<String>,
} }
@ -39,7 +39,7 @@ impl EventChangeset {
voluntary_wachhabender: true, voluntary_wachhabender: true,
voluntary_fuehrungsassistent: true, voluntary_fuehrungsassistent: true,
amount_of_posten: 5, amount_of_posten: 5,
clothing: "Tuchuniform".to_string(), clothing: 1,
note: None, note: None,
}; };

View File

@ -0,0 +1,101 @@
use chrono::{NaiveDate, NaiveDateTime};
use sqlx::{
postgres::{PgHasArrayType, PgTypeInfo},
query, PgPool,
};
use crate::utils::ApplicationError;
use super::Function;
pub struct ExportEventRow {
pub start_timestamp: NaiveDateTime,
pub end_timestamp: NaiveDateTime,
pub amount_of_posten: i16,
pub voluntary_fuehrungsassistent: bool,
pub voluntary_wachhabender: bool,
pub location_name: String,
pub event_name: String,
pub assignments: Vec<SimpleAssignment>,
pub vehicles: Vec<String>,
}
#[derive(Debug, sqlx::Type)]
#[sqlx(type_name = "function", no_pg_array)]
pub struct SimpleAssignment {
pub name: String,
pub function: Function,
}
impl PgHasArrayType for SimpleAssignment {
fn array_type_info() -> sqlx::postgres::PgTypeInfo {
PgTypeInfo::with_name("simpleAssignment[]")
}
}
impl ExportEventRow {
pub async fn read_all_for_timerange_and_area(
pool: &PgPool,
time: (NaiveDate, NaiveDate),
area: i32,
) -> Result<Vec<ExportEventRow>, ApplicationError> {
let rows = query!(
"select
event.starttimestamp,
event.endtimestamp,
event.amountofposten,
event.voluntaryfuehrungsassistent,
event.voluntarywachhabender,
location.name as locationName,
event.name as eventName,
array (
select
row (user_.name, assignment.function) ::simpleAssignment
from
assignment
join availability on
assignment.availabilityid = availability.id
join user_ on
availability.userid = user_.id
where
assignment.eventId = event.id) as \"assignments: Vec<SimpleAssignment>\",
array (
select
vehicle.station || ' ' || vehicle.radiocallname
from
vehicleassignment
join vehicle on
vehicleassignment.vehicleId = vehicle.id
where
vehicleassignment.eventId = event.id) as vehicles
from
event
join location on
event.locationId = location.id
where event.starttimestamp::date >= $1 and event.starttimestamp::date <= $2 and location.areaId = $3
order by event.starttimestamp",
time.0,
time.1,
area
)
.fetch_all(pool)
.await?;
let export_rows = rows
.into_iter()
.map(|r| ExportEventRow {
start_timestamp: r.starttimestamp.naive_utc(),
end_timestamp: r.endtimestamp.naive_utc(),
amount_of_posten: r.amountofposten,
voluntary_fuehrungsassistent: r.voluntaryfuehrungsassistent,
voluntary_wachhabender: r.voluntarywachhabender,
location_name: r.locationname,
event_name: r.eventname,
assignments: r.assignments.unwrap_or(Vec::new()),
vehicles: r.vehicles.unwrap_or(Vec::new()),
})
.collect();
Ok(export_rows)
}
}

View File

@ -42,3 +42,13 @@ impl Default for Function {
Self::Posten Self::Posten
} }
} }
impl Function {
pub fn short_display(&self) -> String {
match self {
Function::Posten => "PO".to_string(),
Function::Fuehrungsassistent => "FüAss".to_string(),
Function::Wachhabender => "WH".to_string(),
}
}
}

View File

@ -26,9 +26,12 @@ impl Location {
} }
pub async fn read_by_area(pool: &PgPool, area_id: i32) -> Result<Vec<Location>> { pub async fn read_by_area(pool: &PgPool, area_id: i32) -> Result<Vec<Location>> {
let records = query!("SELECT * FROM location WHERE areaId = $1;", area_id) let records = query!(
.fetch_all(pool) "SELECT * FROM location WHERE areaId = $1 ORDER by name;",
.await?; area_id
)
.fetch_all(pool)
.await?;
let locations = records let locations = records
.iter() .iter()
@ -44,7 +47,7 @@ impl Location {
} }
pub async fn read_by_area_including_area(pool: &PgPool, area_id: i32) -> Result<Vec<Location>> { pub async fn read_by_area_including_area(pool: &PgPool, area_id: i32) -> Result<Vec<Location>> {
let records = query!("SELECT location.id AS locationId, location.name, location.areaId, area.id, area.name AS areaName FROM location JOIN area ON location.areaId = area.id WHERE areaId = $1;", area_id) let records = query!("SELECT location.id AS locationId, location.name, location.areaId, area.id, area.name AS areaName FROM location JOIN area ON location.areaId = area.id WHERE areaId = $1 ORDER by name;", area_id)
.fetch_all(pool) .fetch_all(pool)
.await?; .await?;
@ -56,7 +59,7 @@ impl Location {
area_id: lr.areaid, area_id: lr.areaid,
area: Some(Area { area: Some(Area {
id: lr.id, id: lr.id,
name: lr.areaname.to_string() name: lr.areaname.to_string(),
}), }),
}) })
.collect(); .collect();
@ -65,7 +68,9 @@ impl Location {
} }
pub async fn read_all(pool: &PgPool) -> Result<Vec<Location>> { pub async fn read_all(pool: &PgPool) -> Result<Vec<Location>> {
let records = query!("SELECT * FROM location").fetch_all(pool).await?; let records = query!("SELECT * FROM location ORDER BY name;")
.fetch_all(pool)
.await?;
let locations = records let locations = records
.iter() .iter()
@ -81,7 +86,7 @@ impl Location {
} }
pub async fn read_all_including_area(pool: &PgPool) -> Result<Vec<Location>> { pub async fn read_all_including_area(pool: &PgPool) -> Result<Vec<Location>> {
let records = query!("SELECT location.id AS locationId, location.name, location.areaId, area.id, area.name AS areaName FROM location JOIN area ON location.areaId = area.id;") let records = query!("SELECT location.id AS locationId, location.name, location.areaId, area.id, area.name AS areaName FROM location JOIN area ON location.areaId = area.id ORDER BY name;")
.fetch_all(pool) .fetch_all(pool)
.await?; .await?;
@ -107,11 +112,11 @@ impl Location {
.await?; .await?;
let location = record.map(|r| Location { let location = record.map(|r| Location {
id: r.id, id: r.id,
name: r.name, name: r.name,
area_id: r.areaid, area_id: r.areaid,
area: None, area: None,
}); });
Ok(location) Ok(location)
} }

View File

@ -4,8 +4,10 @@ mod assignment_changeset;
mod availability; mod availability;
mod availability_assignment_state; mod availability_assignment_state;
mod availability_changeset; mod availability_changeset;
mod clothing;
mod event; mod event;
mod event_changeset; mod event_changeset;
mod export_event_row;
mod function; mod function;
mod location; mod location;
mod password_reset; mod password_reset;
@ -25,8 +27,10 @@ pub use availability_assignment_state::AvailabilityAssignmentState;
pub use availability_changeset::{ pub use availability_changeset::{
find_free_date_time_slots, AvailabilityChangeset, AvailabilityContext, find_free_date_time_slots, AvailabilityChangeset, AvailabilityContext,
}; };
pub use clothing::Clothing;
pub use event::Event; pub use event::Event;
pub use event_changeset::{EventChangeset, EventContext}; pub use event_changeset::{EventChangeset, EventContext};
pub use export_event_row::{ExportEventRow, SimpleAssignment};
pub use function::Function; pub use function::Function;
pub use location::Location; pub use location::Location;
pub use password_reset::{NoneToken, PasswordReset, Token}; pub use password_reset::{NoneToken, PasswordReset, Token};

View File

@ -13,7 +13,7 @@ use super::{password_reset::Token, Result};
impl Registration { impl Registration {
pub async fn insert_new_for_user(pool: &PgPool, user_id: i32) -> Result<Registration> { pub async fn insert_new_for_user(pool: &PgPool, user_id: i32) -> Result<Registration> {
let (token, expires) = generate_token_and_expiration(64, TimeDelta::hours(24)); let (token, expires) = generate_token_and_expiration(64, TimeDelta::days(5));
let inserted = query_as!( let inserted = query_as!(
Registration, Registration,

View File

@ -147,6 +147,14 @@ impl User {
Ok(result) Ok(result)
} }
pub async fn exists(pool: &PgPool, email: &str) -> Result<Option<i32>> {
let record = sqlx::query!("SELECT id FROM user_ WHERE email = $1;", email)
.fetch_optional(pool)
.await?;
Ok(record.and_then(|r| Some(r.id)))
}
pub async fn read_all(pool: &PgPool) -> anyhow::Result<Vec<User>> { pub async fn read_all(pool: &PgPool) -> anyhow::Result<Vec<User>> {
let records = sqlx::query!( let records = sqlx::query!(
r#" r#"
@ -161,7 +169,8 @@ impl User {
locked, locked,
lastLogin, lastLogin,
receiveNotifications receiveNotifications
FROM user_; FROM user_
ORDER BY id;
"#, "#,
) )
.fetch_all(pool) .fetch_all(pool)
@ -207,6 +216,7 @@ impl User {
area.name AS areaName area.name AS areaName
FROM user_ FROM user_
JOIN area ON user_.areaId = area.id JOIN area ON user_.areaId = area.id
ORDER BY userId;
"# "#
) )
.fetch_all(pool) .fetch_all(pool)
@ -251,7 +261,8 @@ impl User {
lastLogin, lastLogin,
receiveNotifications receiveNotifications
FROM user_ FROM user_
WHERE areaId = $1; WHERE areaId = $1
ORDER BY id;
"#, "#,
area_id area_id
) )

View File

@ -1,24 +1,44 @@
#[cfg(test)] #[cfg(test)]
use fake::{faker::internet::en::SafeEmail, faker::name::en::Name, Dummy}; use fake::{faker::internet::en::SafeEmail, faker::name::en::Name, Dummy};
use garde::Validate; use sqlx::PgPool;
use super::{Function, Role}; use crate::utils::validation::{email_is_valid, AsyncValidate, AsyncValidateError, DbContext};
#[derive(Debug, Validate)] use super::{Area, Function, Role};
#[derive(Debug)]
#[cfg_attr(test, derive(Dummy))] #[cfg_attr(test, derive(Dummy))]
#[garde(allow_unvalidated)]
pub struct UserChangeset { pub struct UserChangeset {
#[cfg_attr(test, dummy(faker = "Name()"))] #[cfg_attr(test, dummy(faker = "Name()"))]
pub name: String, pub name: String,
#[garde(email)]
#[cfg_attr(test, dummy(faker = "SafeEmail()"))] #[cfg_attr(test, dummy(faker = "SafeEmail()"))]
pub email: String, pub email: String,
#[cfg_attr(test, dummy(expr = "Role::Staff"))] #[cfg_attr(test, dummy(expr = "Role::Staff"))]
pub role: Role, pub role: Role,
#[cfg_attr(test, dummy(expr = "vec![Function::Posten]"))] #[cfg_attr(test, dummy(expr = "vec![Function::Posten]"))]
pub functions: Vec<Function>, pub functions: Vec<Function>,
/// check before: must exist and user can create other user for this area
#[cfg_attr(test, dummy(expr = "1"))] #[cfg_attr(test, dummy(expr = "1"))]
pub area_id: i32, pub area_id: i32,
} }
impl <'a>AsyncValidate<'a> for UserChangeset {
type Context = DbContext<'a>;
async fn validate_with_context(&self, context: &'a Self::Context) -> Result<(), AsyncValidateError> {
email_is_valid(&self.email)?;
area_exists(context.pool, self.area_id).await?;
Ok(())
}
}
async fn area_exists(pool: &PgPool, id: i32) -> Result<(), AsyncValidateError> {
if Area::read_by_id(pool, id).await?.is_none() {
return Err(AsyncValidateError::new(
"Angegebener Bereich für Nutzer existiert nicht!",
));
}
Ok(())
}

View File

@ -22,7 +22,9 @@ impl Vehicle {
} }
pub async fn read_all(pool: &PgPool) -> Result<Vec<Vehicle>> { pub async fn read_all(pool: &PgPool) -> Result<Vec<Vehicle>> {
let records = query!("SELECT * FROM vehicle;").fetch_all(pool).await?; let records = query!("SELECT * FROM vehicle ORDER BY radioCallName;")
.fetch_all(pool)
.await?;
let vehicles = records let vehicles = records
.into_iter() .into_iter()
@ -42,10 +44,10 @@ impl Vehicle {
.await?; .await?;
let vehicle = record.map(|v| Vehicle { let vehicle = record.map(|v| Vehicle {
id: v.id, id: v.id,
radio_call_name: v.radiocallname, radio_call_name: v.radiocallname,
station: v.station, station: v.station,
}); });
Ok(vehicle) Ok(vehicle)
} }

View File

@ -0,0 +1,5 @@
#[derive(Clone)]
pub struct Customization {
pub hostname: String,
pub webmaster_email: String,
}

View File

@ -0,0 +1,21 @@
pub enum DateTimeFormat {
DayMonth,
DayMonthHourMinute,
DayMonthYear,
DayMonthYearHourMinute,
YearMonthDayTHourMinute,
HourMinute
}
impl From<DateTimeFormat> for &'static str {
fn from(value: DateTimeFormat) -> Self {
match value {
DateTimeFormat::DayMonth => "%d.%m.",
DateTimeFormat::DayMonthHourMinute => "%d.%m. %H:%M",
DateTimeFormat::DayMonthYear => "%d.%m.%Y",
DateTimeFormat::DayMonthYearHourMinute => "%d.%m.%Y %H:%M",
DateTimeFormat::YearMonthDayTHourMinute => "%Y-%m-%dT%H:%M",
DateTimeFormat::HourMinute => "%H:%M" // equivalent to %R,
}
}
}

View File

@ -1,19 +1,24 @@
mod app_customization;
mod application_error; mod application_error;
pub mod auth; pub mod auth;
mod date_time_format;
pub mod event_planning_template; pub mod event_planning_template;
pub mod manage_commands; pub mod manage_commands;
pub mod password_change; pub mod password_change;
mod template_response_trait; mod template_response_trait;
pub mod token_generation; pub mod token_generation;
pub mod validation;
#[cfg(test)] #[cfg(test)]
pub mod test_helper; pub mod test_helper;
pub use app_customization::Customization;
pub use application_error::ApplicationError; pub use application_error::ApplicationError;
use chrono::{NaiveDate, Utc}; pub use date_time_format::DateTimeFormat;
pub use template_response_trait::TemplateResponse; pub use template_response_trait::TemplateResponse;
use chrono::{NaiveDate, Utc};
pub fn get_return_url_for_date(date: &NaiveDate) -> String { pub fn get_return_url_for_date(date: &NaiveDate) -> String {
let today = Utc::now().date_naive(); let today = Utc::now().date_naive();
if date == &today { if date == &today {

View File

@ -8,6 +8,7 @@ use actix_web::{
}; };
use rand::{distr::Alphanumeric, rng, Rng}; use rand::{distr::Alphanumeric, rng, Rng};
use crate::utils::Customization;
use crate::{create_app, mail::Mailer}; use crate::{create_app, mail::Mailer};
use brass_config::{load_config, Config, Environment}; use brass_config::{load_config, Config, Environment};
use regex::{Captures, Regex}; use regex::{Captures, Regex};
@ -29,10 +30,16 @@ impl DbTestContext {
Response = ServiceResponse<impl MessageBody>, Response = ServiceResponse<impl MessageBody>,
Error = actix_web::error::Error, Error = actix_web::error::Error,
> { > {
let customization = Customization {
webmaster_email: self.config.webmaster_email.clone(),
hostname: self.config.hostname.clone()
};
init_service(create_app( init_service(create_app(
self.config.clone(), self.config.clone(),
self.db_pool.clone(), self.db_pool.clone(),
Mailer::new_stub(), Mailer::new_stub(),
customization,
)) ))
.await .await
} }

View File

@ -21,6 +21,7 @@ pub struct RequestConfig {
} }
impl RequestConfig { impl RequestConfig {
/// Creates a new [`RequestConfig`] with User as [`Role::Staff`] and [`Function::Posten`] in Area 1.
pub fn new(uri: &str) -> Self { pub fn new(uri: &str) -> Self {
Self { Self {
uri: uri.to_string(), uri: uri.to_string(),

View File

@ -0,0 +1,58 @@
// great inspiration taken from https://github.com/jprochazk/garde/blob/main/garde/src/rules/email.rs
use regex::Regex;
use super::AsyncValidateError;
pub fn email_is_valid(email: &str) -> Result<(), AsyncValidateError> {
if email.is_empty() {
return Err(AsyncValidateError::new("E-Mail ist leer!"));
}
let (user, domain) = email
.split_once('@')
.ok_or(AsyncValidateError::new("E-Mail enthält kein '@'!"))?;
if user.len() > 64 {
return Err(AsyncValidateError::new("Nutzerteil der E-Mail zu lang!"));
}
let user_re = Regex::new(r"(?i-u)^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+\z").unwrap();
if !user_re.is_match(user) {
return Err(AsyncValidateError::new(
"Nutzerteil der E-Mail enthält unerlaubte Zeichen.",
));
}
if domain.len() > 255 {
return Err(AsyncValidateError::new(
"Domainteil der E-Mail ist zu lang.",
));
}
let domain_re = Regex::new(
r"(?i-u)^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$",
)
.unwrap();
if !domain_re.is_match(domain) {
return Err(AsyncValidateError::new(
"Domainteil der E-Mail enthält unerlaubte Zeichen!",
));
}
Ok(())
}
#[test]
pub fn email_validation_works_correctly() {
assert!(email_is_valid("abc@example.com").is_ok());
assert!(email_is_valid("admin.new@example-domain.de").is_ok());
assert!(email_is_valid("admin!new@sub.web.www.example-domain.de").is_ok());
assert!(email_is_valid("admin.domain.de").is_err());
assert!(email_is_valid("admin@web@domain.de").is_err());
assert!(email_is_valid("@domain.de").is_err());
assert!(email_is_valid("user@").is_err());
assert!(email_is_valid("").is_err());
}

View File

@ -0,0 +1,29 @@
use tracing::error;
#[derive(Debug)]
pub struct AsyncValidateError {
message: String,
}
impl std::fmt::Display for AsyncValidateError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for AsyncValidateError {}
impl AsyncValidateError {
pub fn new(message: &str) -> Self {
AsyncValidateError {
message: message.to_string(),
}
}
}
impl From<sqlx::Error> for AsyncValidateError {
fn from(value: sqlx::Error) -> Self {
error!(error = %value, "database error while validation input");
AsyncValidateError::new("Datenbankfehler beim Validieren!")
}
}

View File

@ -0,0 +1,32 @@
mod email;
mod error;
mod r#trait;
use chrono::NaiveDateTime;
pub use email::email_is_valid;
pub use error::AsyncValidateError;
pub use r#trait::AsyncValidate;
use sqlx::PgPool;
pub struct DbContext<'a> {
pub pool: &'a PgPool,
}
impl<'a> DbContext<'a> {
pub fn new(pool: &'a PgPool) -> Self {
Self { pool }
}
}
pub fn start_date_time_lies_before_end_date_time(
start: &NaiveDateTime,
end: &NaiveDateTime,
) -> Result<(), AsyncValidateError> {
if start >= end {
return Err(AsyncValidateError::new(
"endtime can't lie before starttime",
));
}
Ok(())
}

View File

@ -0,0 +1,8 @@
use super::AsyncValidateError;
pub trait AsyncValidate<'a> {
type Context: 'a;
async fn validate_with_context(&self, context: &'a Self::Context) -> Result<(), AsyncValidateError>;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 881 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
web/static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

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