Compare commits
40 Commits
Author | SHA1 | Date | |
---|---|---|---|
f25e508bbd | |||
b65b4c7a00 | |||
b2969b988d | |||
9666932915 | |||
0b4248604a | |||
5afaac6197 | |||
95f807b51d | |||
cca925f4eb | |||
90ac5c306d | |||
03964d3542 | |||
2774c6e48a | |||
7f5941ba6a | |||
e591b419bb | |||
784b7cea4e | |||
2b9e6cfefd | |||
2b2b2b4c34 | |||
4dfa29d6f1 | |||
bce103b086 | |||
fe2f616bea | |||
f448d31193 | |||
c1f31fff7c | |||
301a7a8af8 | |||
d8eb9ecbf3 | |||
d1e1ccd906 | |||
f953b6d208 | |||
075cdc713d | |||
01cf373b98 | |||
4af004456f | |||
1c4eb6ba83 | |||
a608204103 | |||
513e8983b9 | |||
f1a22f83aa | |||
e86d38d079 | |||
10468ceba2 | |||
f874fcd788 | |||
045a509daf | |||
8d4a981055 | |||
5b5e312152 | |||
c9b075216a | |||
55291d1cb0 |
2
.env
2
.env
@ -6,9 +6,11 @@ SQLX_OFFLINE=true
|
||||
# 64 byte long openssl rand -base64 64
|
||||
SECRET_KEY="changeInProdOrHandAb11111111111111111111111111111111111111111111"
|
||||
HOSTNAME="localhost"
|
||||
WEBMASTER_EMAIL="admin@example.com"
|
||||
SERVER_ADDRESS="127.0.0.1"
|
||||
SERVER_PORT="8080"
|
||||
|
||||
APP_ENVIRONMENT="development"
|
||||
SMTP_SERVER="localhost"
|
||||
SMTP_PORT="1025"
|
||||
# SMTP_LOGIN=""
|
||||
|
@ -6,8 +6,10 @@ SQLX_OFFLINE=true
|
||||
# 64 byte long openssl rand -base64 64
|
||||
SECRET_KEY="changeInProdOrHandAb11111111111111111111111111111111111111111111"
|
||||
HOSTNAME="localhost"
|
||||
WEBMASTER_EMAIL="admin@example.com"
|
||||
SERVER_ADDRESS="127.0.0.1"
|
||||
SERVER_PORT="8080"
|
||||
APP_ENVIRONMENT="development"
|
||||
|
||||
SMTP_SERVER="localhost"
|
||||
SMTP_PORT="1025"
|
||||
|
26
.sqlx/query-03c789e902e84a8a3e753ac7d64c821e368b367eed085b3dc1bdc41cdf0ee6d2.json
generated
Normal file
26
.sqlx/query-03c789e902e84a8a3e753ac7d64c821e368b367eed085b3dc1bdc41cdf0ee6d2.json
generated
Normal 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"
|
||||
}
|
49
.sqlx/query-2288f64f63f07e7dd947c036e5c2be4c563788b3b988b721bd12797fd19a7a95.json
generated
Normal file
49
.sqlx/query-2288f64f63f07e7dd947c036e5c2be4c563788b3b988b721bd12797fd19a7a95.json
generated
Normal 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"
|
||||
}
|
106
.sqlx/query-366b4004828057e3ab47704c4856ea4e41c92af0a388862f77c37ea611a5d193.json
generated
Normal file
106
.sqlx/query-366b4004828057e3ab47704c4856ea4e41c92af0a388862f77c37ea611a5d193.json
generated
Normal 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"
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"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": {
|
||||
"columns": [
|
||||
{
|
||||
@ -46,7 +46,7 @@
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "clothing",
|
||||
"type_info": "Text"
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
@ -72,10 +72,21 @@
|
||||
"ordinal": 13,
|
||||
"name": "locationareaid",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "clothingid",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "clothingname",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Date",
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
@ -93,8 +104,10 @@
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "55a70284a5ddc7bff778ed1ea012b05b1daadbe41c77a8bd317f7fb17b7991cb"
|
||||
"hash": "4ceb2c7e3d921c2718e75ba131eee1706e008b783deb687e04eba08f4b919ac8"
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT * FROM location WHERE areaId = $1;",
|
||||
"query": "SELECT * FROM location WHERE areaId = $1 ORDER by name;",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@ -30,5 +30,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "ea9f427b5d5a3e3c5f720d6bab2417cb3b42de0a5bf1d8b48b11a6e6275cc8e4"
|
||||
"hash": "57742178247d76e67dd81af0873854d1c5d14e189e338ae55900a873146be99f"
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"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": {
|
||||
"columns": [
|
||||
{
|
||||
@ -105,5 +105,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "5573e93ccc0b6a5ecc6183a5d5c589ccd58f786e70a3ff1efa662085c2035156"
|
||||
"hash": "61ae80bcf916ac62220ffd16eb0be5e37e086f9f5b753d451725ea429ab84fbc"
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"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": {
|
||||
"columns": [
|
||||
{
|
||||
@ -107,5 +107,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "483ad933fa1e935058cbe42b7ff083ceee80f74564ee3e8b7da6ab57e906368b"
|
||||
"hash": "6260f72ba85714e8529af2c4f3da77cf67556af87c72997b68fa8bd8bcae62a8"
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"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": {
|
||||
"columns": [
|
||||
{
|
||||
@ -46,7 +46,7 @@
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "clothing",
|
||||
"type_info": "Text"
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
@ -72,11 +72,20 @@
|
||||
"ordinal": 13,
|
||||
"name": "locationareaid",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "clothingid",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "clothingname",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Date",
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
@ -94,8 +103,10 @@
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "d4a8fe79186f648212fb270323942e60edd5163b6463c2f0ef22baaf8be7bcd5"
|
||||
"hash": "6dc18993de451d1e0aa4080f00f46ce3339e020922b9ef130c4289b080a2af7d"
|
||||
}
|
15
.sqlx/query-770344caf4f209a95fd26cb778e7dfbb249c8edc24a0981c7c0e5bb7854f7fde.json
generated
Normal file
15
.sqlx/query-770344caf4f209a95fd26cb778e7dfbb249c8edc24a0981c7c0e5bb7854f7fde.json
generated
Normal 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"
|
||||
}
|
22
.sqlx/query-8331b42d55c64827a0382b830e453158cbe3fc7919fec93c9ca03dcf570c9e9d.json
generated
Normal file
22
.sqlx/query-8331b42d55c64827a0382b830e453158cbe3fc7919fec93c9ca03dcf570c9e9d.json
generated
Normal 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"
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT * FROM area ORDER by id",
|
||||
"query": "SELECT * FROM area ORDER by name",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@ -22,5 +22,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "7f6c89117e8d4249e032235d03d264c3d5d47bd119c563237486cf47e402ae2e"
|
||||
"hash": "852293a7ffbde434401e3b45847275b4992b509da4148055cbe65ca26526f3ca"
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT * FROM location",
|
||||
"query": "SELECT * FROM location ORDER BY name;",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@ -28,5 +28,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "2d9f2d0728983dfac09f6649da74aa5659072539a8f222b8ae202786ce958c37"
|
||||
"hash": "9b3e6609e33be0e428d759f1294d5804c9463f06f7747bf4ea04599da9b4b531"
|
||||
}
|
28
.sqlx/query-9e7ed80577cdceb87b5e5c4903aeadb7c0aea19b94f43828d97a26446ddd1a81.json
generated
Normal file
28
.sqlx/query-9e7ed80577cdceb87b5e5c4903aeadb7c0aea19b94f43828d97a26446ddd1a81.json
generated
Normal 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"
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"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": {
|
||||
"columns": [
|
||||
{
|
||||
@ -117,5 +117,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "a7f6e57733c655534c3ae6379b8616fc3aa63ce322cc2d718f4b4e4e23903a61"
|
||||
"hash": "aad65af88315c7c358c8d72c466139d8a0bef32d03c4dd8cd1e8c2316224da79"
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT * FROM vehicle;",
|
||||
"query": "SELECT * FROM vehicle ORDER BY radioCallName;",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@ -28,5 +28,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "5b87f4da0924338da1a30d7b74711d8073f6d62cf30a42381484846f0917bc33"
|
||||
"hash": "b145ac6b3c43aa24082ca9d6642dc36e745b7b1e633b94cb61e980a5e0fc60b5"
|
||||
}
|
@ -12,7 +12,7 @@
|
||||
"Bool",
|
||||
"Bool",
|
||||
"Int2",
|
||||
"Text",
|
||||
"Int4",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"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": {
|
||||
"columns": [
|
||||
{
|
||||
@ -42,5 +42,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "70850ec3f7c519c1fc104fead6a44d07ba76023567bc6ea0eec2267d1c592479"
|
||||
"hash": "b43b6a9e1bd8f05c79368115aa5e59b22a5d275c91c1aae39fbc17ecf9297efc"
|
||||
}
|
22
.sqlx/query-b4edfcac9404060d487db765b8c18ef8b7440699583e0bede95f4d214e668a87.json
generated
Normal file
22
.sqlx/query-b4edfcac9404060d487db765b8c18ef8b7440699583e0bede95f4d214e668a87.json
generated
Normal 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"
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"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": {
|
||||
"columns": [
|
||||
{
|
||||
@ -40,5 +40,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "f94d7fe59a2d4b7d246711a796571367172bce9446b9fb1e7ba057917a98d958"
|
||||
"hash": "c9a989d3b08f4147643a540aecbb0d6275cb71ff217359f87467a46b32648ea7"
|
||||
}
|
@ -12,7 +12,7 @@
|
||||
"Bool",
|
||||
"Bool",
|
||||
"Int2",
|
||||
"Text",
|
||||
"Int4",
|
||||
"Text",
|
||||
"Int4"
|
||||
]
|
||||
|
14
.sqlx/query-fc95525b2ce9e74c243c222a4051fb072d4112c082681b7ce83648eb7d5bd23a.json
generated
Normal file
14
.sqlx/query-fc95525b2ce9e74c243c222a4051fb072d4112c082681b7ce83648eb7d5bd23a.json
generated
Normal 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
81
Cargo.lock
generated
@ -416,6 +416,15 @@ version = "1.0.98"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
@ -788,7 +797,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "brass-web"
|
||||
version = "0.2.2"
|
||||
version = "1.0.1"
|
||||
dependencies = [
|
||||
"actix-files",
|
||||
"actix-http",
|
||||
@ -814,6 +823,7 @@ dependencies = [
|
||||
"quick-xml",
|
||||
"rand 0.9.1",
|
||||
"regex",
|
||||
"rust_xlsxwriter",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
@ -1191,6 +1201,17 @@ dependencies = [
|
||||
"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]]
|
||||
name = "derive_builder"
|
||||
version = "0.20.2"
|
||||
@ -1463,6 +1484,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"libz-rs-sys",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
@ -2127,6 +2149,7 @@ dependencies = [
|
||||
"rustls",
|
||||
"socket2 0.5.9",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
"webpki-roots 1.0.0",
|
||||
]
|
||||
@ -2153,6 +2176,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.3.8"
|
||||
@ -2828,6 +2860,15 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust_xlsxwriter"
|
||||
version = "0.87.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8079587c37b35a067846a853a524cfde7012754650de7274beecc35e43acd44b"
|
||||
dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-demangle"
|
||||
version = "0.1.24"
|
||||
@ -3048,6 +3089,12 @@ dependencies = [
|
||||
"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]]
|
||||
name = "similar"
|
||||
version = "2.7.0"
|
||||
@ -4228,6 +4275,38 @@ dependencies = [
|
||||
"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]]
|
||||
name = "zstd"
|
||||
version = "0.13.3"
|
||||
|
99
README.md
99
README.md
@ -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
|
||||
1. Clone the repository.
|
||||
2. Install and configure Postgresql. Create a new database for brass: `createdb brass`.
|
||||
3. TODO: Configure DB name, DB user & pass, DB connection string, ...
|
||||
4. Install sqlx-cli: `cargo install sqlx-cli`
|
||||
5. Migrate the database: `sqlx database setup`
|
||||
6. Create superuse: `cargo r -- createadmin`
|
||||
3. Configure database connection string in `.env` config file.
|
||||
4. Install required development tools `cargo install <tool>`
|
||||
- sqlx-cli
|
||||
- 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
|
||||
- cargo-watch, cargo-add
|
||||
- mailtutan
|
||||
# Build & Deploy
|
||||
1. Clone the repository.
|
||||
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
|
||||
```
|
||||
#!/bin/ksh
|
||||
# Project Structure
|
||||
- TODO
|
||||
|
||||
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"
|
||||
# Further Reading
|
||||
More in depth documentation about design decisions, helpful commands and database schema can be found in `docs/` directory.
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
|
||||
```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
|
||||
```
|
||||
# Copyright & License
|
||||
Copyright 2025 Max Hohlfeld
|
||||
Brass is licensed under [GNU AGPLv3](https://www.gnu.org/licenses/agpl-3.0.en.html#license-text).
|
||||
|
@ -17,6 +17,7 @@ pub struct Config {
|
||||
pub smtp_login: Option<String>,
|
||||
pub smtp_password: Option<String>,
|
||||
pub smtp_tlstype: SmtpTlsType,
|
||||
pub webmaster_email: String
|
||||
}
|
||||
|
||||
#[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_password: env::var("SMTP_PASSWORD").map(Some).unwrap_or(None),
|
||||
smtp_tlstype: SmtpTlsType::from(env::var("SMTP_TLSTYPE")?),
|
||||
webmaster_email: env::var("WEBMASTER_EMAIL")?,
|
||||
};
|
||||
|
||||
Ok(config)
|
||||
|
Binary file not shown.
8
doc/drop_test_databases.md
Normal file
8
doc/drop_test_databases.md
Normal 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
|
||||
```
|
31
doc/example_deployment_openbsd.md
Normal file
31
doc/example_deployment_openbsd.md
Normal 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
|
||||
```
|
11
migrations/20250515174927_clothing_table.sql
Normal file
11
migrations/20250515174927_clothing_table.sql
Normal 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;
|
@ -0,0 +1,4 @@
|
||||
CREATE TYPE simpleAssignment AS (
|
||||
name text,
|
||||
function function
|
||||
);
|
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "brass-web"
|
||||
version = "0.2.2"
|
||||
version = "1.0.1"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0"
|
||||
authors = ["Max Hohlfeld <maxhohlfeld@posteo.de>"]
|
||||
@ -20,7 +20,7 @@ futures-util = "0.3.30"
|
||||
serde_json = "1.0.114"
|
||||
pico-args = "0.5.0"
|
||||
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"] }
|
||||
actix-web-static-files = "4.0"
|
||||
static-files = "0.2.1"
|
||||
@ -36,6 +36,8 @@ tracing-actix-web = "0.7.18"
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
||||
tracing-panic = "0.1.2"
|
||||
rust_xlsxwriter = "0.87.0"
|
||||
regex = "1.11.1"
|
||||
|
||||
[build-dependencies]
|
||||
built = "0.7.4"
|
||||
|
25
web/build.rs
25
web/build.rs
@ -18,6 +18,7 @@ fn main() -> std::io::Result<()> {
|
||||
|
||||
let dist_path = Path::new("./static/dist");
|
||||
let nm_path = Path::new("./static/node_modules");
|
||||
let static_path = Path::new("./static");
|
||||
|
||||
if fs::metadata(dist_path).is_err() {
|
||||
fs::create_dir(dist_path)?;
|
||||
@ -43,14 +44,22 @@ fn main() -> std::io::Result<()> {
|
||||
nm_path.join("hyperscript.org/dist/_hyperscript.min.js"),
|
||||
dist_path.join("_hyperscript.min.js"),
|
||||
)?;
|
||||
copy(
|
||||
Path::new("./static/utils.js"),
|
||||
dist_path.join("utils.js"),
|
||||
)?;
|
||||
copy(
|
||||
Path::new("./static/brass.jpeg"),
|
||||
dist_path.join("brass.jpeg"),
|
||||
)?;
|
||||
|
||||
let static_files = vec![
|
||||
"utils.js",
|
||||
"brass.jpeg",
|
||||
"android-chrome-192x192.png",
|
||||
"android-chrome-512x512.png",
|
||||
"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()
|
||||
}
|
||||
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -124,7 +124,7 @@ snapshot_kind: text
|
||||
|
||||
<div class="field is-horizontal">
|
||||
<div class="field-label">
|
||||
<label class="label">Führungsassistent durch FF gestellt?</label>
|
||||
<label class="label">Führungsassistent benötigt?</label>
|
||||
</div>
|
||||
<div class="field-body">
|
||||
<div class="field is-narrow">
|
||||
@ -163,10 +163,17 @@ snapshot_kind: text
|
||||
<label class="label">Anzugsordnung</label>
|
||||
</div>
|
||||
<div class="field-body">
|
||||
<div class="field">
|
||||
<div class="field is-narrow">
|
||||
<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>
|
||||
|
@ -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 askama::Template;
|
||||
use chrono::{NaiveDate, Utc};
|
||||
use serde::Deserialize;
|
||||
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)]
|
||||
pub struct CalendarQuery {
|
||||
|
@ -1,9 +1,10 @@
|
||||
use crate::filters;
|
||||
use askama::Template;
|
||||
use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
|
||||
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 get_new;
|
||||
@ -23,7 +24,7 @@ struct NewOrEditAvailabilityTemplate<'a> {
|
||||
end: Option<NaiveTime>,
|
||||
comment: Option<&'a str>,
|
||||
slot_suggestions: Vec<(NaiveDateTime, NaiveDateTime)>,
|
||||
datetomorrow: NaiveDate
|
||||
datetomorrow: NaiveDate,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@ -34,16 +35,3 @@ pub struct AvailabilityForm {
|
||||
pub endtime: NaiveTime,
|
||||
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;
|
||||
}
|
||||
|
@ -1,11 +1,10 @@
|
||||
use actix_web::{http::header::LOCATION, web, HttpResponse, Responder};
|
||||
use garde::Validate;
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::{
|
||||
endpoints::availability::{find_adjacend_availability, AvailabilityForm},
|
||||
endpoints::availability::AvailabilityForm,
|
||||
models::{Availability, AvailabilityChangeset, AvailabilityContext, User},
|
||||
utils::{self, ApplicationError},
|
||||
utils::{self, validation::AsyncValidate, ApplicationError},
|
||||
};
|
||||
|
||||
#[actix_web::post("/availability/new")]
|
||||
@ -14,32 +13,33 @@ pub async fn post(
|
||||
pool: web::Data<PgPool>,
|
||||
form: web::Form<AvailabilityForm>,
|
||||
) -> 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 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 {
|
||||
time: (start, end),
|
||||
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()));
|
||||
};
|
||||
|
||||
if let Some(a) = find_adjacend_availability(&changeset, None, &existing_availabilities) {
|
||||
let (changeset_start, changeset_end) = changeset.time;
|
||||
|
||||
if a.end == changeset_start {
|
||||
if let Some(a) =
|
||||
Availability::find_adjacent_by_time_for_user(pool.get_ref(), &start, &end, user.id, None)
|
||||
.await?
|
||||
{
|
||||
if a.end == start {
|
||||
changeset.time.0 = a.start;
|
||||
}
|
||||
|
||||
if a.start == changeset_end {
|
||||
if a.start == end {
|
||||
changeset.time.1 = a.end;
|
||||
}
|
||||
|
||||
|
@ -1,14 +1,10 @@
|
||||
use actix_web::{http::header::LOCATION, web, HttpResponse, Responder};
|
||||
use garde::Validate;
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::{
|
||||
endpoints::{
|
||||
availability::{find_adjacend_availability, AvailabilityForm},
|
||||
IdPath,
|
||||
},
|
||||
endpoints::{availability::AvailabilityForm, IdPath},
|
||||
models::{Availability, AvailabilityChangeset, AvailabilityContext, User},
|
||||
utils::{self, ApplicationError},
|
||||
utils::{self, validation::AsyncValidate, ApplicationError},
|
||||
};
|
||||
|
||||
#[actix_web::post("/availability/edit/{id}")]
|
||||
@ -26,39 +22,38 @@ pub async fn post(
|
||||
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 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 {
|
||||
time: (start, end),
|
||||
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()));
|
||||
};
|
||||
|
||||
if let Some(a) =
|
||||
find_adjacend_availability(&changeset, Some(availability.id), &existing_availabilities)
|
||||
if let Some(a) = Availability::find_adjacent_by_time_for_user(
|
||||
pool.get_ref(),
|
||||
&start,
|
||||
&end,
|
||||
user.id,
|
||||
Some(availability.id),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
let (changeset_start, changeset_end) = changeset.time;
|
||||
|
||||
if a.end == changeset_start {
|
||||
if a.end == start {
|
||||
changeset.time.0 = a.start;
|
||||
}
|
||||
|
||||
if a.start == changeset_end {
|
||||
if a.start == end {
|
||||
changeset.time.1 = a.end;
|
||||
}
|
||||
|
||||
|
91
web/src/endpoints/clothing/delete.rs
Normal file
91
web/src/endpoints/clothing/delete.rs
Normal 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());
|
||||
}
|
||||
}
|
85
web/src/endpoints/clothing/get_edit.rs
Normal file
85
web/src/endpoints/clothing/get_edit.rs
Normal 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);
|
||||
}
|
||||
}
|
21
web/src/endpoints/clothing/get_new.rs
Normal file
21
web/src/endpoints/clothing/get_new.rs
Normal 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()?)
|
||||
}
|
86
web/src/endpoints/clothing/get_overview.rs
Normal file
86
web/src/endpoints/clothing/get_overview.rs
Normal 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);
|
||||
}
|
||||
}
|
82
web/src/endpoints/clothing/get_read.rs
Normal file
82
web/src/endpoints/clothing/get_read.rs
Normal 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);
|
||||
}
|
||||
}
|
33
web/src/endpoints/clothing/mod.rs
Normal file
33
web/src/endpoints/clothing/mod.rs
Normal 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,
|
||||
}
|
40
web/src/endpoints/clothing/post_edit.rs
Normal file
40
web/src/endpoints/clothing/post_edit.rs
Normal 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()?)
|
||||
}
|
30
web/src/endpoints/clothing/post_new.rs
Normal file
30
web/src/endpoints/clothing/post_new.rs
Normal 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()?)
|
||||
}
|
@ -11,7 +11,7 @@ use chrono::{NaiveDate, NaiveTime};
|
||||
|
||||
use crate::{
|
||||
endpoints::{events::NewOrEditEventTemplate, IdPath},
|
||||
models::{Assignment, Event, Function, Location, Role, User},
|
||||
models::{Assignment, Clothing, Event, Function, Location, Role, User},
|
||||
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 clothing_options = Clothing::read_all(pool.get_ref()).await?;
|
||||
|
||||
let template = NewOrEditEventTemplate {
|
||||
user: user.into_inner(),
|
||||
date: event.start.date(),
|
||||
@ -59,7 +61,8 @@ pub async fn get(
|
||||
voluntary_wachhabender: event.voluntary_wachhabender,
|
||||
voluntary_fuehrungsassistent: event.voluntary_fuehrungsassistent,
|
||||
amount_of_posten: Some(event.amount_of_posten),
|
||||
clothing: Some(event.clothing),
|
||||
clothing: Some(event.clothing.id),
|
||||
clothing_options,
|
||||
canceled: event.canceled,
|
||||
note: event.note,
|
||||
};
|
||||
@ -107,7 +110,7 @@ async fn produces_template(context: &DbTestContext) {
|
||||
voluntary_fuehrungsassistent: false,
|
||||
voluntary_wachhabender: false,
|
||||
amount_of_posten: 2,
|
||||
clothing: "Tuchuniform".to_string(),
|
||||
clothing: 1,
|
||||
note: None,
|
||||
};
|
||||
|
||||
|
@ -4,7 +4,7 @@ use sqlx::PgPool;
|
||||
|
||||
use crate::{
|
||||
endpoints::{events::NewOrEditEventTemplate, NaiveDateQuery},
|
||||
models::{Location, Role, User},
|
||||
models::{Clothing, Location, Role, User},
|
||||
utils::{ApplicationError, TemplateResponse},
|
||||
};
|
||||
|
||||
@ -24,6 +24,8 @@ pub async fn get(
|
||||
Location::read_by_area(pool.get_ref(), user.area_id).await?
|
||||
};
|
||||
|
||||
let clothing_options = Clothing::read_all(pool.get_ref()).await?;
|
||||
|
||||
let template = NewOrEditEventTemplate {
|
||||
user: user.into_inner(),
|
||||
date: query.date,
|
||||
@ -42,6 +44,7 @@ pub async fn get(
|
||||
voluntary_fuehrungsassistent: false,
|
||||
amount_of_posten: None,
|
||||
clothing: None,
|
||||
clothing_options,
|
||||
canceled: false,
|
||||
note: None,
|
||||
};
|
||||
|
@ -1,10 +1,11 @@
|
||||
use crate::filters;
|
||||
use askama::Template;
|
||||
use chrono::{Days, NaiveDateTime};
|
||||
use chrono::{NaiveDate, NaiveTime};
|
||||
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 get_edit;
|
||||
@ -29,7 +30,8 @@ pub struct NewOrEditEventTemplate {
|
||||
voluntary_wachhabender: bool,
|
||||
voluntary_fuehrungsassistent: bool,
|
||||
amount_of_posten: Option<i16>,
|
||||
clothing: Option<String>,
|
||||
clothing: Option<i32>,
|
||||
clothing_options: Vec<Clothing>,
|
||||
canceled: bool,
|
||||
note: Option<String>,
|
||||
amount_of_planned_posten: usize,
|
||||
@ -48,7 +50,7 @@ pub struct NewOrEditEventForm {
|
||||
voluntarywachhabender: bool,
|
||||
voluntaryfuehrungsassistent: bool,
|
||||
amount: i16,
|
||||
clothing: String,
|
||||
clothing: i32,
|
||||
note: Option<String>,
|
||||
}
|
||||
|
||||
|
@ -45,7 +45,7 @@ pub async fn post(
|
||||
|
||||
let changeset = EventChangeset {
|
||||
amount_of_posten: form.amount,
|
||||
clothing: form.clothing.clone(),
|
||||
clothing: form.clothing,
|
||||
location_id: form.location,
|
||||
time: (form.date.and_time(form.start), form.end),
|
||||
name: form.name.clone(),
|
||||
|
@ -28,7 +28,7 @@ pub async fn post(
|
||||
|
||||
let changeset = EventChangeset {
|
||||
amount_of_posten: form.amount,
|
||||
clothing: form.clothing.clone(),
|
||||
clothing: form.clothing,
|
||||
location_id: form.location,
|
||||
time: (form.date.and_time(form.start), form.end),
|
||||
name: form.name.clone(),
|
||||
|
101
web/src/endpoints/export/get_events.rs
Normal file
101
web/src/endpoints/export/get_events.rs
Normal 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()
|
||||
}
|
191
web/src/endpoints/export/get_events_data.rs
Normal file
191
web/src/endpoints/export/get_events_data.rs
Normal 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));
|
||||
}
|
@ -1,2 +1,4 @@
|
||||
pub mod get_availability;
|
||||
pub mod get_availability_data;
|
||||
pub mod get_events;
|
||||
pub mod get_events_data;
|
||||
|
@ -1,15 +1,23 @@
|
||||
use actix_web::Responder;
|
||||
use actix_web::{web, Responder};
|
||||
use askama::Template;
|
||||
|
||||
use crate::utils::{ApplicationError, TemplateResponse};
|
||||
use crate::utils::{ApplicationError, Customization, TemplateResponse};
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "imprint.html")]
|
||||
struct ImprintTemplate {}
|
||||
struct ImprintTemplate {
|
||||
webmaster_mail: String,
|
||||
hostname: String,
|
||||
}
|
||||
|
||||
#[actix_web::get("/imprint")]
|
||||
pub async fn get_imprint() -> Result<impl Responder, ApplicationError> {
|
||||
let template = ImprintTemplate {};
|
||||
pub async fn get_imprint(
|
||||
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()?)
|
||||
}
|
||||
|
@ -5,11 +5,12 @@ use serde::Deserialize;
|
||||
mod area;
|
||||
mod assignment;
|
||||
mod availability;
|
||||
mod clothing;
|
||||
mod events;
|
||||
mod export;
|
||||
mod imprint;
|
||||
mod location;
|
||||
pub mod user;
|
||||
pub mod user; // TODO: why pub?
|
||||
mod vehicle;
|
||||
mod vehicle_assignment;
|
||||
|
||||
@ -80,6 +81,8 @@ pub fn init(cfg: &mut ServiceConfig) {
|
||||
|
||||
cfg.service(export::get_availability::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);
|
||||
|
||||
@ -92,4 +95,12 @@ pub fn init(cfg: &mut ServiceConfig) {
|
||||
|
||||
cfg.service(vehicle_assignment::post_new::post);
|
||||
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);
|
||||
}
|
||||
|
@ -6,14 +6,16 @@ use sqlx::PgPool;
|
||||
|
||||
use crate::{
|
||||
models::PasswordReset,
|
||||
utils::{ApplicationError, TemplateResponse},
|
||||
utils::{ApplicationError, Customization, TemplateResponse},
|
||||
};
|
||||
|
||||
use super::ResetPasswordTemplate;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "user/forgot_password.html")]
|
||||
struct ForgotPasswordTemplate {}
|
||||
struct ForgotPasswordTemplate<'a> {
|
||||
webmaster_email: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TokenQuery {
|
||||
@ -25,6 +27,7 @@ pub async fn get(
|
||||
user: Option<Identity>,
|
||||
pool: web::Data<PgPool>,
|
||||
query: web::Query<TokenQuery>,
|
||||
customization: web::Data<Customization>,
|
||||
) -> Result<impl Responder, ApplicationError> {
|
||||
if user.is_some() {
|
||||
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()?)
|
||||
}
|
||||
|
@ -1,11 +1,10 @@
|
||||
use actix_web::{http::header::LOCATION, web, HttpResponse, Responder};
|
||||
use garde::Validate;
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::{
|
||||
endpoints::{user::NewOrEditUserForm, IdPath},
|
||||
models::{Area, Function, Role, User, UserChangeset},
|
||||
utils::ApplicationError,
|
||||
models::{Function, Role, User, UserChangeset},
|
||||
utils::{validation::{AsyncValidate, DbContext}, ApplicationError},
|
||||
};
|
||||
|
||||
#[actix_web::post("/users/edit/{id}")]
|
||||
@ -28,14 +27,12 @@ pub async fn post_edit(
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@ -53,14 +50,22 @@ pub async fn post_edit(
|
||||
|
||||
let changeset = UserChangeset {
|
||||
name: form.name.clone(),
|
||||
email: form.email.clone(),
|
||||
email: form.email.to_lowercase(),
|
||||
role,
|
||||
functions,
|
||||
area_id,
|
||||
};
|
||||
|
||||
if let Err(e) = changeset.validate() {
|
||||
return Ok(HttpResponse::BadRequest().body(e.to_string()));
|
||||
if let Some(existing_id) = User::exists(pool.get_ref(), &changeset.email).await? {
|
||||
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?;
|
||||
@ -145,4 +150,67 @@ mod tests {
|
||||
let response = test_post(&context.db_pool, app, &config, form).await;
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
@ -18,7 +18,7 @@ async fn post(
|
||||
request: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
) -> 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 hash = hash_plain_password_with_salt(&form.password, &salt).unwrap();
|
||||
|
@ -1,12 +1,11 @@
|
||||
use actix_web::{http::header::LOCATION, web, HttpResponse, Responder};
|
||||
use garde::Validate;
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::{
|
||||
endpoints::user::NewOrEditUserForm,
|
||||
mail::Mailer,
|
||||
models::{Function, Registration, Role, User, UserChangeset},
|
||||
utils::ApplicationError,
|
||||
utils::{validation::{AsyncValidate, DbContext}, ApplicationError},
|
||||
};
|
||||
|
||||
#[actix_web::post("/users/new")]
|
||||
@ -47,14 +46,20 @@ pub async fn post_new(
|
||||
|
||||
let changeset = UserChangeset {
|
||||
name: form.name.clone(),
|
||||
email: form.email.clone(),
|
||||
email: form.email.to_lowercase(),
|
||||
role,
|
||||
functions,
|
||||
area_id,
|
||||
};
|
||||
|
||||
if let Err(e) = changeset.validate() {
|
||||
return Ok(HttpResponse::BadRequest().body(e.to_string()));
|
||||
if let Some(_) = User::exists(pool.get_ref(), &changeset.email).await? {
|
||||
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?;
|
||||
|
@ -29,7 +29,9 @@ async fn post(
|
||||
&& form.password.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?;
|
||||
mailer
|
||||
.send_forgot_password_mail(&user, &reset.token)
|
||||
|
@ -2,8 +2,9 @@ use std::fmt::Display;
|
||||
|
||||
use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
|
||||
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> {
|
||||
let char = if first { '?' } else { '&' };
|
||||
@ -28,7 +29,8 @@ where
|
||||
T: Display,
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
@ -73,38 +75,34 @@ pub fn show_tree(f: &UserFunction) -> askama::Result<String> {
|
||||
Ok(html.into_string())
|
||||
}
|
||||
|
||||
pub fn dt_f(v: &NaiveDateTime) -> askama::Result<String> {
|
||||
Ok(v.format("%Y-%m-%dT%H:%M").to_string())
|
||||
pub fn fmt_date(v: &NaiveDate, format: DateTimeFormat) -> askama::Result<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> {
|
||||
Ok(v.format("%d.%m.%Y %H:%M").to_string())
|
||||
pub fn fmt_datetime(v: &NaiveDateTime, format: DateTimeFormat) -> askama::Result<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> {
|
||||
Ok(v.format("%d.%m.%Y").to_string())
|
||||
pub fn fmt_time(v: &NaiveTime, format: DateTimeFormat) -> askama::Result<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> {
|
||||
Ok(v.format("%d.%m").to_string())
|
||||
}
|
||||
fn escape_html(string: String) -> String {
|
||||
let s = string
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'");
|
||||
|
||||
pub fn date_c_and_time(v: &NaiveDateTime) -> askama::Result<String> {
|
||||
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())
|
||||
s
|
||||
}
|
||||
|
@ -1,8 +1,8 @@
|
||||
use askama::Template;
|
||||
use lettre::{
|
||||
message::{Mailbox, MultiPart, SinglePart},
|
||||
AsyncTransport, Message,
|
||||
Address, AsyncTransport, Message,
|
||||
};
|
||||
use askama::Template;
|
||||
|
||||
use crate::{models::User, utils::ApplicationError};
|
||||
|
||||
@ -55,9 +55,14 @@ fn build(
|
||||
}
|
||||
.to_string();
|
||||
|
||||
let sender_mailbox = Mailbox::new(
|
||||
Some("noreply".to_string()),
|
||||
Address::new("noreply", &hostname)?,
|
||||
);
|
||||
|
||||
let message = Message::builder()
|
||||
.from("noreply <noreply@brasiwa-leipzig.de>".parse()?)
|
||||
.reply_to("noreply <noreply@brasiwa-leipzig.de>".parse()?)
|
||||
.from(sender_mailbox.clone())
|
||||
.reply_to(sender_mailbox)
|
||||
.to(Mailbox::new(Some(name.to_string()), email.parse()?))
|
||||
.subject("Brass: Zurücksetzen des Passworts angefordert")
|
||||
.multipart(
|
||||
|
@ -14,13 +14,13 @@ mod forgot_password;
|
||||
mod registration;
|
||||
mod testmail;
|
||||
|
||||
#[derive(Clone)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Mailer {
|
||||
transport: Transports,
|
||||
hostname: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
#[derive(Clone, Debug)]
|
||||
enum Transports {
|
||||
SmtpTransport(AsyncSmtpTransport<AsyncStd1Executor>),
|
||||
#[allow(unused)]
|
||||
|
@ -1,8 +1,7 @@
|
||||
use lettre::{
|
||||
message::{Mailbox, MultiPart, SinglePart},
|
||||
AsyncTransport, Message,
|
||||
};
|
||||
use askama::Template;
|
||||
use lettre::{
|
||||
message::{Mailbox, MultiPart, SinglePart}, Address, AsyncTransport, Message
|
||||
};
|
||||
|
||||
use crate::{models::User, utils::ApplicationError};
|
||||
|
||||
@ -59,9 +58,14 @@ fn build(
|
||||
}
|
||||
.to_string();
|
||||
|
||||
let sender_mailbox = Mailbox::new(
|
||||
Some("noreply".to_string()),
|
||||
Address::new("noreply", &hostname)?,
|
||||
);
|
||||
|
||||
let message = Message::builder()
|
||||
.from("noreply <noreply@brasiwa-leipzig.de>".parse()?)
|
||||
.reply_to("noreply <noreply@brasiwa-leipzig.de>".parse()?)
|
||||
.from(sender_mailbox.clone())
|
||||
.reply_to(sender_mailbox)
|
||||
.to(Mailbox::new(Some(name.to_string()), email.parse()?))
|
||||
.subject("Brass: Registrierung deines Accounts")
|
||||
.multipart(
|
||||
|
@ -24,7 +24,7 @@ ur noch ein Passwort festlegen. Kopiere daf=C3=BCr folgenden Link in deinen=
|
||||
|
||||
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
|
||||
--boundary
|
||||
@ -41,7 +41,7 @@ nden Link in deinen Browser:</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>
|
||||
--boundary--
|
||||
|
@ -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 super::Mailer;
|
||||
|
||||
impl Mailer {
|
||||
#[instrument]
|
||||
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()
|
||||
.from("noreply <noreply@brasiwa-leipzig.de>".parse()?)
|
||||
.reply_to("noreply <noreply@brasiwa-leipzig.de>".parse()?)
|
||||
.from(sender_mailbox.clone())
|
||||
.reply_to(sender_mailbox)
|
||||
.to(to.parse()?)
|
||||
.subject("Brass: Test E-Mail")
|
||||
.singlepart(SinglePart::plain(
|
||||
"Testmail von Brass. E-Mail Versand funktioniert!".to_string(),
|
||||
))?;
|
||||
|
||||
debug!("constructed test message");
|
||||
|
||||
self.transport.send(message).await?;
|
||||
debug!("sent test message");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
@ -19,6 +19,7 @@ use tracing_panic::panic_hook;
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
use tracing_subscriber::{fmt, EnvFilter};
|
||||
use utils::Customization;
|
||||
|
||||
use crate::postgres_session_store::SqlxPostgresqlSessionStore;
|
||||
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 mailer = Mailer::new(&config)?;
|
||||
let customization = Customization {
|
||||
webmaster_email: config.webmaster_email.clone(),
|
||||
hostname: config.hostname.clone()
|
||||
};
|
||||
|
||||
handle_command(args.command, &pool, &mailer).await?;
|
||||
|
||||
@ -56,10 +61,17 @@ async fn main() -> anyhow::Result<()> {
|
||||
let port = config.server_port;
|
||||
info!("Starting server on http://{address}:{port}.");
|
||||
|
||||
HttpServer::new(move || create_app(config.clone(), pool.clone(), mailer.clone()))
|
||||
.bind((address, port))?
|
||||
.run()
|
||||
.await?;
|
||||
HttpServer::new(move || {
|
||||
create_app(
|
||||
config.clone(),
|
||||
pool.clone(),
|
||||
mailer.clone(),
|
||||
customization.clone(),
|
||||
)
|
||||
})
|
||||
.bind((address, port))?
|
||||
.run()
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@ -68,6 +80,7 @@ pub fn create_app(
|
||||
config: Config,
|
||||
pool: Pool<Postgres>,
|
||||
mailer: Mailer,
|
||||
customization: Customization,
|
||||
) -> App<
|
||||
impl ServiceFactory<
|
||||
ServiceRequest,
|
||||
@ -84,6 +97,7 @@ pub fn create_app(
|
||||
App::new()
|
||||
.app_data(web::Data::new(pool))
|
||||
.app_data(web::Data::new(mailer))
|
||||
.app_data(web::Data::new(customization))
|
||||
.configure(endpoints::init)
|
||||
.wrap(middleware::ErrorAppender)
|
||||
.wrap(TracingLogger::default())
|
||||
|
@ -25,7 +25,7 @@ impl 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)
|
||||
.await?;
|
||||
|
||||
|
@ -341,6 +341,49 @@ impl Availability {
|
||||
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<()> {
|
||||
query!(
|
||||
"UPDATE availability SET startTimestamp = $1, endTimestamp = $2, comment = $3 WHERE id = $4",
|
||||
|
@ -1,38 +1,63 @@
|
||||
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 {
|
||||
#[garde(
|
||||
custom(time_is_not_already_made_available),
|
||||
custom(start_date_time_lies_before_end_date_time)
|
||||
)]
|
||||
pub time: (NaiveDateTime, NaiveDateTime),
|
||||
pub comment: Option<String>,
|
||||
}
|
||||
|
||||
pub struct AvailabilityContext {
|
||||
pub existing_availabilities: Vec<Availability>,
|
||||
pub struct AvailabilityContext<'a> {
|
||||
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(
|
||||
value: &(NaiveDateTime, NaiveDateTime),
|
||||
context: &AvailabilityContext,
|
||||
) -> garde::Result {
|
||||
if context.existing_availabilities.is_empty() {
|
||||
existing_availabilities: &Vec<Availability>,
|
||||
) -> Result<(), AsyncValidateError> {
|
||||
if existing_availabilities.is_empty() {
|
||||
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() {
|
||||
return Err(garde::Error::new(
|
||||
return Err(AsyncValidateError::new(
|
||||
"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);
|
||||
|
||||
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",
|
||||
));
|
||||
}
|
||||
|
67
web/src/models/clothing.rs
Normal file
67
web/src/models/clothing.rs
Normal 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(())
|
||||
}
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
use chrono::{NaiveDate, NaiveDateTime};
|
||||
use sqlx::{query, PgPool};
|
||||
|
||||
use super::{event_changeset::EventChangeset, Location, Result};
|
||||
use super::{event_changeset::EventChangeset, Clothing, Location, Result};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Event {
|
||||
@ -14,7 +14,7 @@ pub struct Event {
|
||||
pub voluntary_wachhabender: bool,
|
||||
pub voluntary_fuehrungsassistent: bool,
|
||||
pub amount_of_posten: i16,
|
||||
pub clothing: String,
|
||||
pub clothing: Clothing,
|
||||
pub canceled: bool,
|
||||
pub note: Option<String>,
|
||||
}
|
||||
@ -51,9 +51,12 @@ impl Event {
|
||||
event.note,
|
||||
location.id,
|
||||
location.name AS locationName,
|
||||
location.areaId AS locationAreaId
|
||||
location.areaId AS locationAreaId,
|
||||
clothing.id AS clothingId,
|
||||
clothing.name AS clothingName
|
||||
FROM event
|
||||
JOIN location ON event.locationId = location.id
|
||||
JOIN clothing ON event.clothing = clothing.id
|
||||
WHERE starttimestamp::date = $1
|
||||
AND location.areaId = $2;
|
||||
"#,
|
||||
@ -80,7 +83,10 @@ impl Event {
|
||||
voluntary_wachhabender: record.voluntarywachhabender,
|
||||
voluntary_fuehrungsassistent: record.voluntaryfuehrungsassistent,
|
||||
amount_of_posten: record.amountofposten,
|
||||
clothing: record.clothing.to_string(),
|
||||
clothing: Clothing {
|
||||
id: record.clothingid,
|
||||
name: record.clothingname,
|
||||
},
|
||||
canceled: record.canceled,
|
||||
note: record.note,
|
||||
})
|
||||
@ -106,9 +112,12 @@ impl Event {
|
||||
event.note,
|
||||
location.id,
|
||||
location.name AS locationName,
|
||||
location.areaId AS locationAreaId
|
||||
location.areaId AS locationAreaId,
|
||||
clothing.id AS clothingId,
|
||||
clothing.name AS clothingName
|
||||
FROM event
|
||||
JOIN location ON event.locationId = location.id
|
||||
JOIN clothing ON event.clothing = clothing.id
|
||||
WHERE event.id = $1;
|
||||
"#,
|
||||
id
|
||||
@ -131,7 +140,10 @@ impl Event {
|
||||
voluntary_wachhabender: record.voluntarywachhabender,
|
||||
voluntary_fuehrungsassistent: record.voluntaryfuehrungsassistent,
|
||||
amount_of_posten: record.amountofposten,
|
||||
clothing: record.clothing.to_string(),
|
||||
clothing: Clothing {
|
||||
id: record.clothingid,
|
||||
name: record.clothingname,
|
||||
},
|
||||
canceled: record.canceled,
|
||||
note: record.note,
|
||||
});
|
||||
|
@ -25,7 +25,7 @@ pub struct EventChangeset {
|
||||
pub voluntary_fuehrungsassistent: bool,
|
||||
#[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 clothing: String,
|
||||
pub clothing: i32,
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
@ -39,7 +39,7 @@ impl EventChangeset {
|
||||
voluntary_wachhabender: true,
|
||||
voluntary_fuehrungsassistent: true,
|
||||
amount_of_posten: 5,
|
||||
clothing: "Tuchuniform".to_string(),
|
||||
clothing: 1,
|
||||
note: None,
|
||||
};
|
||||
|
||||
|
101
web/src/models/export_event_row.rs
Normal file
101
web/src/models/export_event_row.rs
Normal 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)
|
||||
}
|
||||
}
|
@ -42,3 +42,13 @@ impl Default for Function {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -26,9 +26,12 @@ impl 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)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
let records = query!(
|
||||
"SELECT * FROM location WHERE areaId = $1 ORDER by name;",
|
||||
area_id
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let locations = records
|
||||
.iter()
|
||||
@ -44,7 +47,7 @@ impl 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)
|
||||
.await?;
|
||||
|
||||
@ -56,7 +59,7 @@ impl Location {
|
||||
area_id: lr.areaid,
|
||||
area: Some(Area {
|
||||
id: lr.id,
|
||||
name: lr.areaname.to_string()
|
||||
name: lr.areaname.to_string(),
|
||||
}),
|
||||
})
|
||||
.collect();
|
||||
@ -65,7 +68,9 @@ impl 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
|
||||
.iter()
|
||||
@ -81,7 +86,7 @@ impl 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)
|
||||
.await?;
|
||||
|
||||
@ -107,11 +112,11 @@ impl Location {
|
||||
.await?;
|
||||
|
||||
let location = record.map(|r| Location {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
area_id: r.areaid,
|
||||
area: None,
|
||||
});
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
area_id: r.areaid,
|
||||
area: None,
|
||||
});
|
||||
|
||||
Ok(location)
|
||||
}
|
||||
|
@ -4,8 +4,10 @@ mod assignment_changeset;
|
||||
mod availability;
|
||||
mod availability_assignment_state;
|
||||
mod availability_changeset;
|
||||
mod clothing;
|
||||
mod event;
|
||||
mod event_changeset;
|
||||
mod export_event_row;
|
||||
mod function;
|
||||
mod location;
|
||||
mod password_reset;
|
||||
@ -25,8 +27,10 @@ pub use availability_assignment_state::AvailabilityAssignmentState;
|
||||
pub use availability_changeset::{
|
||||
find_free_date_time_slots, AvailabilityChangeset, AvailabilityContext,
|
||||
};
|
||||
pub use clothing::Clothing;
|
||||
pub use event::Event;
|
||||
pub use event_changeset::{EventChangeset, EventContext};
|
||||
pub use export_event_row::{ExportEventRow, SimpleAssignment};
|
||||
pub use function::Function;
|
||||
pub use location::Location;
|
||||
pub use password_reset::{NoneToken, PasswordReset, Token};
|
||||
|
@ -13,7 +13,7 @@ use super::{password_reset::Token, Result};
|
||||
|
||||
impl 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!(
|
||||
Registration,
|
||||
|
@ -147,6 +147,14 @@ impl User {
|
||||
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>> {
|
||||
let records = sqlx::query!(
|
||||
r#"
|
||||
@ -161,7 +169,8 @@ impl User {
|
||||
locked,
|
||||
lastLogin,
|
||||
receiveNotifications
|
||||
FROM user_;
|
||||
FROM user_
|
||||
ORDER BY id;
|
||||
"#,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
@ -207,6 +216,7 @@ impl User {
|
||||
area.name AS areaName
|
||||
FROM user_
|
||||
JOIN area ON user_.areaId = area.id
|
||||
ORDER BY userId;
|
||||
"#
|
||||
)
|
||||
.fetch_all(pool)
|
||||
@ -251,7 +261,8 @@ impl User {
|
||||
lastLogin,
|
||||
receiveNotifications
|
||||
FROM user_
|
||||
WHERE areaId = $1;
|
||||
WHERE areaId = $1
|
||||
ORDER BY id;
|
||||
"#,
|
||||
area_id
|
||||
)
|
||||
|
@ -1,24 +1,44 @@
|
||||
#[cfg(test)]
|
||||
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))]
|
||||
#[garde(allow_unvalidated)]
|
||||
pub struct UserChangeset {
|
||||
#[cfg_attr(test, dummy(faker = "Name()"))]
|
||||
pub name: String,
|
||||
#[garde(email)]
|
||||
#[cfg_attr(test, dummy(faker = "SafeEmail()"))]
|
||||
pub email: String,
|
||||
#[cfg_attr(test, dummy(expr = "Role::Staff"))]
|
||||
pub role: Role,
|
||||
#[cfg_attr(test, dummy(expr = "vec![Function::Posten]"))]
|
||||
pub functions: Vec<Function>,
|
||||
/// check before: must exist and user can create other user for this area
|
||||
#[cfg_attr(test, dummy(expr = "1"))]
|
||||
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(())
|
||||
}
|
||||
|
@ -22,7 +22,9 @@ impl 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
|
||||
.into_iter()
|
||||
@ -42,10 +44,10 @@ impl Vehicle {
|
||||
.await?;
|
||||
|
||||
let vehicle = record.map(|v| Vehicle {
|
||||
id: v.id,
|
||||
radio_call_name: v.radiocallname,
|
||||
station: v.station,
|
||||
});
|
||||
id: v.id,
|
||||
radio_call_name: v.radiocallname,
|
||||
station: v.station,
|
||||
});
|
||||
|
||||
Ok(vehicle)
|
||||
}
|
||||
|
5
web/src/utils/app_customization.rs
Normal file
5
web/src/utils/app_customization.rs
Normal file
@ -0,0 +1,5 @@
|
||||
#[derive(Clone)]
|
||||
pub struct Customization {
|
||||
pub hostname: String,
|
||||
pub webmaster_email: String,
|
||||
}
|
21
web/src/utils/date_time_format.rs
Normal file
21
web/src/utils/date_time_format.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
@ -1,19 +1,24 @@
|
||||
mod app_customization;
|
||||
mod application_error;
|
||||
pub mod auth;
|
||||
mod date_time_format;
|
||||
pub mod event_planning_template;
|
||||
pub mod manage_commands;
|
||||
pub mod password_change;
|
||||
mod template_response_trait;
|
||||
pub mod token_generation;
|
||||
pub mod validation;
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod test_helper;
|
||||
|
||||
pub use app_customization::Customization;
|
||||
pub use application_error::ApplicationError;
|
||||
use chrono::{NaiveDate, Utc};
|
||||
|
||||
pub use date_time_format::DateTimeFormat;
|
||||
pub use template_response_trait::TemplateResponse;
|
||||
|
||||
use chrono::{NaiveDate, Utc};
|
||||
|
||||
pub fn get_return_url_for_date(date: &NaiveDate) -> String {
|
||||
let today = Utc::now().date_naive();
|
||||
if date == &today {
|
||||
|
@ -8,6 +8,7 @@ use actix_web::{
|
||||
};
|
||||
use rand::{distr::Alphanumeric, rng, Rng};
|
||||
|
||||
use crate::utils::Customization;
|
||||
use crate::{create_app, mail::Mailer};
|
||||
use brass_config::{load_config, Config, Environment};
|
||||
use regex::{Captures, Regex};
|
||||
@ -29,10 +30,16 @@ impl DbTestContext {
|
||||
Response = ServiceResponse<impl MessageBody>,
|
||||
Error = actix_web::error::Error,
|
||||
> {
|
||||
let customization = Customization {
|
||||
webmaster_email: self.config.webmaster_email.clone(),
|
||||
hostname: self.config.hostname.clone()
|
||||
};
|
||||
|
||||
init_service(create_app(
|
||||
self.config.clone(),
|
||||
self.db_pool.clone(),
|
||||
Mailer::new_stub(),
|
||||
customization,
|
||||
))
|
||||
.await
|
||||
}
|
||||
|
@ -21,6 +21,7 @@ pub struct RequestConfig {
|
||||
}
|
||||
|
||||
impl RequestConfig {
|
||||
/// Creates a new [`RequestConfig`] with User as [`Role::Staff`] and [`Function::Posten`] in Area 1.
|
||||
pub fn new(uri: &str) -> Self {
|
||||
Self {
|
||||
uri: uri.to_string(),
|
||||
|
58
web/src/utils/validation/email.rs
Normal file
58
web/src/utils/validation/email.rs
Normal 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());
|
||||
}
|
29
web/src/utils/validation/error.rs
Normal file
29
web/src/utils/validation/error.rs
Normal 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!")
|
||||
}
|
||||
}
|
32
web/src/utils/validation/mod.rs
Normal file
32
web/src/utils/validation/mod.rs
Normal 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(())
|
||||
}
|
8
web/src/utils/validation/trait.rs
Normal file
8
web/src/utils/validation/trait.rs
Normal 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>;
|
||||
}
|
||||
|
BIN
web/static/android-chrome-192x192.png
Normal file
BIN
web/static/android-chrome-192x192.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 42 KiB |
BIN
web/static/android-chrome-512x512.png
Normal file
BIN
web/static/android-chrome-512x512.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 201 KiB |
BIN
web/static/apple-touch-icon.png
Normal file
BIN
web/static/apple-touch-icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 38 KiB |
BIN
web/static/favicon-16x16.png
Normal file
BIN
web/static/favicon-16x16.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 881 B |
BIN
web/static/favicon-32x32.png
Normal file
BIN
web/static/favicon-32x32.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.4 KiB |
BIN
web/static/favicon.ico
Normal file
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
Loading…
x
Reference in New Issue
Block a user