Feature/event notifications (#399)

* additional server events

* sort 'recent recipes' by updated

* remove duplicate code

* fixes #396

* set color

* consolidate tag/category pages

* set colors

* list unorganized recipes

* cleanup old code

* remove flash message, switch to global snackbar

* cancel to close

* cleanup

* notifications first pass

* test notification

* complete notification feature

* use background tasks

* add url param

* update documentation

Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
Hayden 2021-05-08 18:29:31 -08:00 committed by GitHub
parent 8923c1ecf8
commit 14b6ab7ec7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
49 changed files with 875 additions and 355 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

View File

@ -1,233 +0,0 @@
<svg host="65bd71144e" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="672px" height="802px" viewBox="-0.5 -0.5 672 802" content="&lt;mxfile host=&quot;23d2e92c-1798-4628-b714-afc635cb8bb4&quot; modified=&quot;2021-02-25T18:35:08.654Z&quot; agent=&quot;5.0 (Macintosh; Intel Mac OS X 11_2_1) AppleWebKit/537.36 (KHTML, like Gecko) Code-Insiders/1.54.0-insider Chrome/87.0.4280.141 Electron/11.3.0 Safari/537.36&quot; etag=&quot;VkjSc3HAwyf7iAB2Toef&quot; version=&quot;14.2.4&quot; type=&quot;embed&quot;&gt;&lt;diagram id=&quot;3j9sfWdaUOHFNAte1u_k&quot; name=&quot;Page-1&quot;&gt;7VvbcuM2Ev0aVW0e7KJ4keRH3zK7VU6tKzOpPKYgEiKxJgkuCVlyvj7duJAgCVnyhJqZeMYPtgg0cenuc7rRkGfBbbH/UJMq+4UnNJ/5XrKfBXcz35/74RL+YMuLbvGuQtWS1izRbV3DR/YnNYK6dcsS2vQEBee5YFW/MeZlSWPRayN1zXd9sQ3P+7NWJKWjho8xycetv7NEZKp1FXld+78pSzMz89zTPWsSP6U135Z6vpkf/Cx/VHdBzFhavslIwndWU3A/C25rzoX6VOxvaY7KNWpT7/18oLddd01LccoLoX7jmeRbapYsFyZejDJwP5UWo7Wge5cJyNqIe+M1zNudgctQXlBRv4DIvm904yzBSj3vOs2HRiaztH6l24g2dtqO3G0YPug9u/cfeMf3X3FWClrfP8N+cKr5LLhp7ebBQ0KajCb6IRNFboREzZ/oLc95DS0lL2HAm5ysaf7IGyYYL6E5pjg4dKBuGbjgw0BgzYXghSVwnbMUOwSvoJXop3YcWFqFKy/2KaLzsmhiQi9hC9uSXm4bWv+hDBrcbFiem9WBo95Hq8CPoH1swNZNhvYfG9Wy2tJhtMUURls6jLbIBWp/3rPd4v9bRNLNhpfiopE0cw0C81W17zrhU4p/fwPdQOcH1E5jBoS14JhKwjRWg1dS/Qqs3OPYYM1MCpa/2JO10sBUFH9LEOU5gAgNDiNs4BcaCmVERkQn2jQ8ZkSAsyEhplRktL6Ez79pafAcXqasTGW/PdeOSRVm5FkOFMe0aYwQjMLq4fAFJXmVk1IutEyG3UiBDU79KWMoIn/F2xp9BveLg+JeSvmwoURs5Rbk5tSq4O0FKdARy3VTWeoFnVedygdoBP8TJ8DM9m3d1FQkRtUEd1H39AlBdHfhI3R3GRP0I7TjVDsAD7RxcPlNLqGesSShALUbye8t4A8y4xhABynQIMUCTxCeifJC/9uj/PH2nYw/9yfYv+9ij/NQPpBqsrxae94ZeJ9vRc5KmMpkQN4pwYBvNixWYaC5JEnBSgZLJgKX+5Z44B9wjq8TDxZniQfXqJ4TA8EvwJZMMqt+STN2s61khBjQuZOGidzUVoWUhAgMDP+i+zjfJorRK2DgHa+T5ick3kcgJl4XZk5lRybHFaR5wmFz9qTWpAfQq8AwVdOGCtE128MrCUxldX/V8jkua00aqkQKluKcUkgt+LaNFgUpiY5E4NJSF2pCGTWubdeToYOUUgUNt/UCqxQ1i1XIqWnMKjoKigWpn2S/5mcdgiHbSZjiKNhLF5Fws99V5PEniTyTUG8YfP3Q4/dDjx86Yo93rtPGCaetb/u0MUHUkWxzLNgcc+rgaLBZnCvYXJ3t8NG8QkuvxJ5rSbFlmht6Q5zUVCfqkvokuUrOtWOQPDGQhsWo3Zo955jTm2iE7DnrzgZVzcFimvfV6C3Va1GbnynOynclcv39uNui75ILm7v1UaPP7IbJXbz93fD4AZff9137i5wgouM0ZiggfgG+SGgdHNCOZYW1UsrDum1oi2n/VbRjzKXqhfPo79LImyJF4FCxi2RWU2j4PBntXZu9eb/SnCDlNxmrPpN3Psl8yhBIbQ3Y4hgYAGhNtcgKQcIIhIRCMRMFVCBB/AeB32S8Fl2OqilADTDTdYOOQtTwg0qKLjn0XsEyxoWsY+BMN2jehG7INpcM1GbcVolkrVNmXWRpx+3U2Q4wLOrIzeCspF2s2Xg7YAaCaaZldALN26y1340AkMn3LqOaLDuKp3vIn5uBku09QL7d30G3cRkKLt8xW76O7FV0FNnuJDiYANvmEsHC9q/aVYYcumMFeC7qMs5YnjyQF8i/oK8R4Bjm6QaAw/4E+JPOQKQW+lIl8HoSH/FNrchOrXNNIPqd+co867V0Vje3IT6+T5FvHo3WvbbpgTTiaOY6ShodHjSBqeeLPosvVmNbz100PsmBJxon/BcdRUmmzgHFs0jnfxEyOoSxOWrowsp8lOya85xiLteTMTjpSQxcyWg7pxvxdgg/yLfuwoGbLA4eE6Qf6EOacjVBhPUM9jMecpKNI89tZAPgE206RfIThSOTqluAH+CdHLxhH7uR77Czq6g4DXbHWe7FTBfbXoOjvl2zsC1vlMbYNtmMLaojQU/23YE5fDOYXUaeBMzjuwCs5D7mDgr9Aei/D+jI/6rReOVEtAXYD13x8R3hbfnNBM+FP7KAW+U/wDZB9PSPh0/fVeqeBGyLca3/oj1sn5D52t8/0JItNUd37w2jiwO3NIcx6rTcJBgd157QRJf6YkaVM7Aygeq0Ssyz7qszHl//D68HpqsljCAzNEsBhyCcxllU6BcQbAv700CtLfoZqEUOUnUZLJzAYMsTbtX6GjhSiB0qyGmkKQgq7CcD5h6/pzQHPU3h5K5bnIHOaJJSEwYamhawq/uu6YaWyTV+1RS9MicNVkEPatFEFVelevSVkUGPHXfUGnFhb1W5pdPIoVPTpqq4z/3hXYrWMzzibaVl0au+ReeDERq+rWOqX+qsNRpnPhho6Q1sDmE+pWI0EBiEvFhi8jK1OXm9ZprOidSAnUu1Gj0NmSdclHyTyPS9foUUPPKLQXN5wiX5xNB8TxCcr8KB6Vafh8Fw8CW9aBirJsJgW1EbLPjQukbyofdZ+5gM5K67un8CyEN/GH/DLwfyE753/wPkh+OW3w9c0dVnBtpoAI6zBVr/1Ug7XpfnXtdkoB0X/uQhp7thPnDMMVd1+ojTjLz23Z5xBtn60vGl7fMdccaVO3nWvDRVgsMG6+q536HJhug+o83gsfu3NYXI7p8Dg/u/AA==&lt;/diagram&gt;&lt;/mxfile&gt;" style="background-color: rgb(255, 255, 255);">
<defs/>
<g>
<rect x="0" y="138" width="70" height="60" fill="none" stroke="none" pointer-events="all"/>
<path d="M 0 190.12 C 0.33 185.06 0.82 180.02 1.48 175 C 1.8 173.47 2.19 171.97 2.66 170.5 C 3.13 168.98 4 167.67 5.14 166.72 C 6.56 165.61 8.15 164.81 9.83 164.35 C 10.51 164.04 11.26 164.04 11.94 164.35 C 16.84 169.6 24.48 169.6 29.38 164.35 C 29.98 164.08 30.64 164.08 31.24 164.35 C 33.19 165.09 35.06 166.07 36.82 167.27 C 34.65 167.54 32.55 168.25 30.62 169.39 C 27.64 171.19 25.41 174.27 24.43 177.95 L 22.92 190.14 Z M 20.17 161.73 C 17.61 161.8 15.14 160.56 13.36 158.3 C 11.58 156.05 10.65 152.98 10.79 149.85 C 10.72 146.76 11.68 143.77 13.45 141.58 C 15.23 139.39 17.66 138.19 20.17 138.26 C 22.78 138 25.35 139.12 27.25 141.33 C 29.14 143.54 30.18 146.64 30.09 149.85 C 30.25 153.1 29.25 156.28 27.34 158.56 C 25.44 160.84 22.83 162 20.17 161.73 Z M 25.84 198 C 26.15 193.52 26.59 189.05 27.17 184.6 C 27.42 182.59 27.8 180.6 28.32 178.66 C 28.64 176.59 29.65 174.75 31.15 173.52 C 32.85 172.3 34.66 171.28 36.55 170.5 C 37.4 170.21 38.32 170.4 39.03 171 C 40.08 172.2 41.31 173.19 42.66 173.92 C 46.02 175.58 49.83 175.58 53.19 173.92 C 54.53 173.16 55.78 172.22 56.9 171.1 C 57.79 170.31 59.01 170.19 60 170.8 C 61.6 171.42 63.13 172.3 64.51 173.42 C 65.8 174.41 66.77 175.86 67.26 177.55 C 68.13 180.7 68.72 183.94 69.03 187.22 C 69.43 190.81 69.76 194.4 70 198 Z M 48.5 167.98 C 42.29 168.13 37.15 161.83 36.99 153.87 C 37.03 150.02 38.26 146.34 40.42 143.65 C 42.58 140.97 45.49 139.5 48.5 139.57 C 54.38 140.09 58.97 146.32 59.02 153.87 C 58.89 161.35 54.33 167.47 48.5 167.98 Z" fill="#e58325" stroke="none" pointer-events="all"/>
<rect x="70" y="138" width="340" height="90" fill="none" stroke="none" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe flex-start; justify-content: unsafe flex-start; width: 332px; height: 1px; padding-top: 128px; margin-left: 75px;">
<div style="box-sizing: border-box; font-size: 0; text-align: left; max-height: 100px; overflow: hidden; ">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
<h1 style="font-size: 18px">
User Groups
</h1>
<p>
User groups, or "family" groups are a collection of users that are associated together. Users belonging to groups will have access to their associated mealplans and associated pages. This is currently the only feature of groups.
</p>
</div>
</div>
</div>
</foreignObject>
<text x="75" y="140" fill="#000000" font-family="Helvetica" font-size="12px">
User Groups...
</text>
</switch>
</g>
<rect x="0" y="10" width="70" height="60" fill="none" stroke="none" pointer-events="all"/>
<path d="M 63.22 68.4 C 63.82 68.4 64.58 67.93 64.58 67.14 C 64.58 66.64 64.03 65.99 63.22 65.99 C 62.34 65.99 61.86 66.7 61.86 67.14 C 61.86 67.83 62.49 68.4 63.22 68.4 Z M 66.39 67.22 C 66.39 68.69 64.91 70 63.27 70 C 61.44 70 60.07 68.65 60.07 67.25 L 60.07 50.06 C 58.43 49.33 56.45 47.65 56.45 44.65 C 56.55 42.25 58.13 40.43 60.07 39.51 L 60.07 44.39 L 63.22 45.99 L 66.39 44.39 L 66.39 39.5 C 68.29 40.34 70 42.32 70 44.78 C 69.95 47.1 68.53 49.03 66.39 50.06 Z M 49.19 69.99 C 48.73 69.99 48.32 69.65 48.32 69.19 L 48.32 57.17 C 48.32 56.81 48.7 56.39 49.17 56.39 L 50.12 56.39 L 50.13 45.21 L 49.77 42.55 L 49.77 38.79 L 52.29 38.79 L 52.3 42.55 L 51.94 45.21 L 51.94 56.39 L 52.85 56.39 C 53.37 56.39 53.74 56.68 53.74 57.21 L 53.74 69.22 C 53.74 69.68 53.33 69.99 52.88 69.99 Z M 27.78 36.63 C 20.5 36.63 14 31.25 14 22.98 C 14 16.57 19.5 10 27.52 10 C 35.36 10 41.1 16.36 41.1 23.29 C 41.1 31.31 34.33 36.63 27.78 36.63 Z M 0 63.51 C 0.63 57.61 1.47 49.54 3.07 45.46 C 3.81 43.61 4.88 42.28 6.61 41.17 C 7.73 40.43 11.72 38.48 12.45 38.16 C 13.73 37.57 15.05 37.26 16.18 38.16 C 22.94 43.46 32.3 43.3 39.15 38.18 C 39.94 37.46 41.21 37.65 42.04 37.94 C 42.97 38.26 45.37 39.46 46.96 40.29 L 46.96 42.69 L 47.32 45.36 L 47.32 54.39 C 46.31 54.92 45.52 55.93 45.52 57.17 L 45.51 63.51 Z" fill="#e58325" stroke="#d79b00" stroke-miterlimit="10" pointer-events="all"/>
<rect x="70" y="10" width="340" height="120" fill="none" stroke="none" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe flex-start; justify-content: unsafe flex-start; width: 332px; height: 1px; padding-top: 0px; margin-left: 75px;">
<div style="box-sizing: border-box; font-size: 0; text-align: left; max-height: 130px; overflow: hidden; ">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
<h1 style="font-size: 18px">
Admins
</h1>
<p>
Mealie admins are super users that have access to all user data (excluding passwords). Perform administrative tasks like adding users, resetting user passwords, backing up the database, migrating data, and managing site settings. Administrators can also access restricted recipes that are marked hidden or uneditable by the user.
</p>
</div>
</div>
</div>
</foreignObject>
<text x="75" y="12" fill="#000000" font-family="Helvetica" font-size="12px">
Admins...
</text>
</switch>
</g>
<rect x="10" y="240" width="60" height="60" fill="none" stroke="none" pointer-events="all"/>
<path d="M 40.01 269.86 C 32.14 269.86 25.13 263.82 25.13 254.55 C 25.13 247.37 31.06 240 39.72 240 C 48.19 240 54.39 247.13 54.39 254.9 C 54.39 263.89 47.08 269.86 40.01 269.86 Z M 10 300 C 10.68 293.37 11.59 284.33 13.32 279.75 C 14.11 277.68 15.27 276.19 17.14 274.95 C 18.35 274.12 22.66 271.93 23.45 271.57 C 24.83 270.91 26.26 270.57 27.48 271.57 C 34.78 277.52 44.88 277.34 52.28 271.6 C 53.14 270.78 54.51 271 55.41 271.32 C 56.82 271.82 61.36 274.29 62.22 274.79 C 64.73 276.3 66.08 278.32 67.09 282.11 C 68.37 287.23 69.13 293.75 70 300 Z" fill="#e58325" stroke="none" pointer-events="all"/>
<rect x="70" y="240" width="340" height="90" fill="none" stroke="none" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe flex-start; justify-content: unsafe flex-start; width: 332px; height: 1px; padding-top: 230px; margin-left: 75px;">
<div style="box-sizing: border-box; font-size: 0; text-align: left; max-height: 100px; overflow: hidden; ">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
<h1 style="font-size: 18px">
Users
</h1>
<p>
A single user created by an Admin that has basic privlages to edit their profile, create and edit recipes they own. Edit recipes that are not hidden and are marked editable.
</p>
</div>
</div>
</div>
</foreignObject>
<text x="75" y="242" fill="#000000" font-family="Helvetica" font-size="12px">
Users...
</text>
</switch>
</g>
<path d="M 10 375 C 10 366.72 23.43 360 40 360 C 47.96 360 55.59 361.58 61.21 364.39 C 66.84 367.21 70 371.02 70 375 L 70 425 C 70 433.28 56.57 440 40 440 C 23.43 440 10 433.28 10 425 Z" fill="#e58325" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/>
<path d="M 70 375 C 70 383.28 56.57 390 40 390 C 23.43 390 10 383.28 10 375" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/>
<rect x="75" y="360" width="340" height="130" fill="none" stroke="none" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe flex-start; justify-content: unsafe flex-start; width: 332px; height: 1px; padding-top: 350px; margin-left: 80px;">
<div style="box-sizing: border-box; font-size: 0; text-align: left; max-height: 140px; overflow: hidden; ">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
<h1 style="font-size: 18px">
Database Relationships
</h1>
<p>
The basic relationship and ownership is diagramed below. In short users are owners of recipes and groups are the owners of meal-plans. By default all users will be added to the "default" group. If a recipe is added through a migration or through a backup where no user exists ownership will be set to the default Admin.
</p>
</div>
</div>
</div>
</foreignObject>
<text x="80" y="362" fill="#000000" font-family="Helvetica" font-size="12px">
Database Relationships...
</text>
</switch>
</g>
<path d="M 310 710 L 310 693.5 Q 310 680 296.5 680 L 163.5 680 Q 150 680 150 693.5 L 150 710" fill="none" stroke="#000000" stroke-width="2" stroke-miterlimit="10" pointer-events="all"/>
<path d="M 150 710 L 150 786.5 Q 150 800 163.5 800 L 296.5 800 Q 310 800 310 786.5 L 310 710" fill="none" stroke="#000000" stroke-width="2" stroke-miterlimit="10" pointer-events="none"/>
<path d="M 150 710 L 310 710" fill="none" stroke="#000000" stroke-width="2" stroke-miterlimit="10" pointer-events="none"/>
<g fill="#000000" font-family="Helvetica" font-weight="bold" pointer-events="none" text-anchor="middle" font-size="18px">
<text x="229.5" y="702.5">
Recipe
</text>
</g>
<g fill="#000000" font-family="Helvetica" pointer-events="none" font-size="16px">
<text x="155.5" y="731.5">
- owners: list[Users]
</text>
<text x="155.5" y="750.5">
- editable: boolean
</text>
<text x="155.5" y="769.5">
- hidden: boolean
</text>
</g>
<path d="M 200 550 L 200 533.5 Q 200 520 186.5 520 L 43.5 520 Q 30 520 30 533.5 L 30 550" fill="none" stroke="#000000" stroke-width="2" stroke-miterlimit="10" pointer-events="none"/>
<path d="M 30 550 L 30 626.5 Q 30 640 43.5 640 L 186.5 640 Q 200 640 200 626.5 L 200 550" fill="none" stroke="#000000" stroke-width="2" stroke-miterlimit="10" pointer-events="none"/>
<path d="M 30 550 L 200 550" fill="none" stroke="#000000" stroke-width="2" stroke-miterlimit="10" pointer-events="none"/>
<g fill="#000000" font-family="Helvetica" font-weight="bold" pointer-events="none" text-anchor="middle" font-size="18px">
<text x="114.5" y="542.5">
User
</text>
</g>
<g fill="#000000" font-family="Helvetica" pointer-events="none" font-size="16px">
<text x="35.5" y="571.5">
- admin: boolean
</text>
<text x="35.5" y="590.5">
- group: list[Group]
</text>
<text x="35.5" y="609.5">
- recipes: list[Recipe]
</text>
</g>
<path d="M 670 710 L 670 693.5 Q 670 680 656.5 680 L 523.5 680 Q 510 680 510 693.5 L 510 710" fill="none" stroke="#000000" stroke-width="2" stroke-miterlimit="10" pointer-events="none"/>
<path d="M 510 710 L 510 786.5 Q 510 800 523.5 800 L 656.5 800 Q 670 800 670 786.5 L 670 710" fill="none" stroke="#000000" stroke-width="2" stroke-miterlimit="10" pointer-events="none"/>
<path d="M 510 710 L 670 710" fill="none" stroke="#000000" stroke-width="2" stroke-miterlimit="10" pointer-events="none"/>
<g fill="#000000" font-family="Helvetica" font-weight="bold" pointer-events="none" text-anchor="middle" font-size="18px">
<text x="589.5" y="702.5">
MealPlan
</text>
</g>
<g fill="#000000" font-family="Helvetica" pointer-events="none" font-size="16px">
<text x="515.5" y="731.5">
- group: Group
</text>
</g>
<path d="M 610 550 L 610 533.5 Q 610 520 596.5 520 L 423.5 520 Q 410 520 410 533.5 L 410 550" fill="none" stroke="#000000" stroke-width="2" stroke-miterlimit="10" pointer-events="none"/>
<path d="M 410 550 L 410 626.5 Q 410 640 423.5 640 L 596.5 640 Q 610 640 610 626.5 L 610 550" fill="none" stroke="#000000" stroke-width="2" stroke-miterlimit="10" pointer-events="none"/>
<path d="M 410 550 L 610 550" fill="none" stroke="#000000" stroke-width="2" stroke-miterlimit="10" pointer-events="none"/>
<g fill="#000000" font-family="Helvetica" font-weight="bold" pointer-events="none" text-anchor="middle" font-size="18px">
<text x="509.5" y="542.5">
Group
</text>
</g>
<g fill="#000000" font-family="Helvetica" pointer-events="none" font-size="16px">
<text x="415.5" y="571.5">
- users: list[Users]
</text>
<text x="415.5" y="590.5">
- mealplans list[MealPlan]
</text>
</g>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 98px; height: 1px; padding-top: 570px; margin-left: 271px;">
<div style="box-sizing: border-box; font-size: 0; text-align: center; ">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: none; white-space: normal; word-wrap: normal; ">
User.group is backfilled by a Groups object
</div>
</div>
</div>
</foreignObject>
<text x="320" y="574" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">
User.group is bac...
</text>
</switch>
</g>
<rect x="34" y="636" width="10" height="10" fill="#ffffff" stroke="none" pointer-events="none"/>
<path d="M 39 611 L 39 690 Q 39 700 49 700 L 130.76 700" fill="none" stroke="#e58325" stroke-width="2" stroke-miterlimit="10" pointer-events="none"/>
<path d="M 136.76 700 L 128.76 704 L 130.76 700 L 128.76 696 Z" fill="#e58325" stroke="#e58325" stroke-width="2" stroke-miterlimit="10" pointer-events="none"/>
<rect x="195" y="583" width="10" height="10" fill="#ffffff" stroke="none" pointer-events="none"/>
<path d="M 174 588 L 234 588 Q 244 588 244 578 L 244 550 Q 244 540 254 540 L 391.76 540" fill="none" stroke="#e58325" stroke-width="2" stroke-miterlimit="10" pointer-events="none"/>
<path d="M 397.76 540 L 389.76 544 L 391.76 540 L 389.76 536 Z" fill="#e58325" stroke="#e58325" stroke-width="2" stroke-miterlimit="10" pointer-events="none"/>
<rect x="414" y="634" width="10" height="10" fill="#ffffff" stroke="none" pointer-events="none"/>
<path d="M 419 591 L 419 690 Q 419 700 429 700 L 480 700 Q 490 700 490.88 700 L 491.76 700" fill="none" stroke="#e58325" stroke-width="2" stroke-miterlimit="10" pointer-events="none"/>
<path d="M 497.76 700 L 489.76 704 L 491.76 700 L 489.76 696 Z" fill="#e58325" stroke="#e58325" stroke-width="2" stroke-miterlimit="10" pointer-events="none"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 98px; height: 1px; padding-top: 730px; margin-left: 35px;">
<div style="box-sizing: border-box; font-size: 0; text-align: center; ">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: none; white-space: normal; word-wrap: normal; ">
User.recipes is backfilled by Recipe objects
</div>
</div>
</div>
</foreignObject>
<text x="84" y="734" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">
User.recipes is b...
</text>
</switch>
</g>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 98px; height: 1px; padding-top: 730px; margin-left: 401px;">
<div style="box-sizing: border-box; font-size: 0; text-align: center; ">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: none; white-space: normal; word-wrap: normal; ">
Group.mealplan is backfilled by MealPlan objects
</div>
</div>
</div>
</foreignObject>
<text x="450" y="734" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">
Group.mealplan is...
</text>
</switch>
</g>
</g>
<switch>
<g requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"/>
<a transform="translate(0,-5)" xlink:href="https://www.diagrams.net/doc/faq/svg-export-text-problems" target="_blank">
<text text-anchor="middle" font-size="10px" x="50%" y="100%">
Viewer does not support full SVG 1.1
</text>
</a>
</switch>
</svg>

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 482 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@ -0,0 +1,59 @@
# Getting External Notifications
## Apprise
Using the [Apprise](https://github.com/caronc/apprise/) library Mealie is able to provided notification services for nearly every popular service. Some of our favorites are...
- [Gotify](https://github.com/caronc/apprise/wiki/Notify_gotify)
- [Discord](https://github.com/caronc/apprise/wiki/Notify_discord)
- [Home Assistant](https://github.com/caronc/apprise/wiki/Notify_homeassistant)
- [Matrix](https://github.com/caronc/apprise/wiki/Notify_matrix)
- [Pushover](https://github.com/caronc/apprise/wiki/Notify_pushover)
But there are some many to choose from! Take a look at their wiki for information on how to create their URL formats and that you can use to create a notification integration in Mealie.
## Subscribe Events
There are several categories of events that mealie logs that can be broadcast with the notifications feature. You can also see a feed of your events in the Admin Dashboard
- General Events
- Application Startup
- Recipe Events
- Create Recipe
- Delete Recipe
- Database Events
- Export/Import
- Database Initialization
- Scheduled Events
- MealPlan Webhooks Sent
- Group Events
- Create/Delete Groups
- User Events
- User Creation
- User Sign-up
- Sign-up Token Creation
- Invalid login attempts
In most cases the events will also provide details on which user performed the action. Now you'll know when your grandma deletes your favorite recipe!
!!! info
This is a new feature and we are still working through all the possibilities of events. if you have an idea for an event let us know!
## Creating a New Notification
New events can be created and viewed in admin Toolbox `/admin/toolbox?tab=event-notifications`. Select the "+ Notification" button and you'll be provided with a dialog. Complete the form using the URL for the service you'd like to connect to. Before saving be sure to use the test feature.
!!! tip
The feedback provided from the test feature is only an indicated of if the URL you provided is valid, not if the message was successfully sent. Be sure to check the notification feed for the test message.
![Add Notification Image](../../assets/img/add-notification.webp)
## Examples
### Discord
![Discord]()
### Gotify
![Gotify]()

View File

@ -19,4 +19,4 @@ Below are some general guidelines that were considered when creating the organiz
In the diagram below you will see what we came up with using the new custom pages feature. The large circles indicate pages, and the rectangles indicate categories. We've grouped several 'like' categories with each other as a way to quickly find similar items. In the diagram below you will see what we came up with using the new custom pages feature. The large circles indicate pages, and the rectangles indicate categories. We've grouped several 'like' categories with each other as a way to quickly find similar items.
![Mealie Diagram](../../assets/img/MealieDiagram.png) ![Mealie Diagram](../../assets/img/mealie-diagram.webp)

File diff suppressed because one or more lines are too long

View File

@ -10,7 +10,7 @@ Your sites settings panel can only be accessed by administrators. This where you
| Card Per Section | The amount of cards displayed in each section on the home page | | Card Per Section | The amount of cards displayed in each section on the home page |
| Home Page Sections | Category sections to include on the home page | | Home Page Sections | Category sections to include on the home page |
![Site Settings Image](../assets/img/site-settings.png) ![Site Settings Image](../assets/img/site-settings.webp)

View File

@ -56,7 +56,7 @@ There are two ways to create users in Mealie.
### Manually Creating a User ### Manually Creating a User
In the Manage Users section you are able to create a user by providing the necessary information in the pop-up dialog. In the Manage Users section you are able to create a user by providing the necessary information in the pop-up dialog.
![Create User Image](../assets/img/add-user.png){: align=right style="height:50%;width:50%"} ![Create User Image](../assets/img/add-user.webp){: align=right style="height:50%;width:50%"}
- User Name - User Name
- Email - Email
@ -69,7 +69,7 @@ When creating users manually, their password will be set from the default assign
### Sign Up Links ### Sign Up Links
You can generate sign-up links in the Manage Users section. Select the "create link" button and provide the name of the link and if the user will be an administrator. Once a link is created it will populate in the table where you'll be able to see all active links, delete a link, and copy the link as needed. You can generate sign-up links in the Manage Users section. Select the "create link" button and provide the name of the link and if the user will be an administrator. Once a link is created it will populate in the table where you'll be able to see all active links, delete a link, and copy the link as needed.
![Sign Up Links Image](../assets/img/sign-up-links.png) ![Sign Up Links Image](../assets/img/sign-up-links.webp)
!!! tip !!! tip
When a link is used it is automatically removed from the database. When a link is used it is automatically removed from the database.

View File

@ -55,6 +55,7 @@ nav:
- Working With Recipes: "getting-started/recipes.md" - Working With Recipes: "getting-started/recipes.md"
- Organizing Recipes: "getting-started/organizing-recipes.md" - Organizing Recipes: "getting-started/organizing-recipes.md"
- Planning Meals: "getting-started/meal-planner.md" - Planning Meals: "getting-started/meal-planner.md"
- External Notifications: "getting-started/notifications.md"
- iOS Shortcuts: "getting-started/ios.md" - iOS Shortcuts: "getting-started/ios.md"
- Site Administration: - Site Administration:
- User Settings: "site-administration/user-settings.md" - User Settings: "site-administration/user-settings.md"

View File

@ -0,0 +1 @@
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 245 240"><style>.st0{fill:#7289DA;}</style><path class="st0" d="M104.4 103.9c-5.7 0-10.2 5-10.2 11.1s4.6 11.1 10.2 11.1c5.7 0 10.2-5 10.2-11.1.1-6.1-4.5-11.1-10.2-11.1zM140.9 103.9c-5.7 0-10.2 5-10.2 11.1s4.6 11.1 10.2 11.1c5.7 0 10.2-5 10.2-11.1s-4.5-11.1-10.2-11.1z"/><path class="st0" d="M189.5 20h-134C44.2 20 35 29.2 35 40.6v135.2c0 11.4 9.2 20.6 20.5 20.6h113.4l-5.3-18.5 12.8 11.9 12.1 11.2 21.5 19V40.6c0-11.4-9.2-20.6-20.5-20.6zm-38.6 130.6s-3.6-4.3-6.6-8.1c13.1-3.7 18.1-11.9 18.1-11.9-4.1 2.7-8 4.6-11.5 5.9-5 2.1-9.8 3.5-14.5 4.3-9.6 1.8-18.4 1.3-25.9-.1-5.7-1.1-10.6-2.7-14.7-4.3-2.3-.9-4.8-2-7.3-3.4-.3-.2-.6-.3-.9-.5-.2-.1-.3-.2-.4-.3-1.8-1-2.8-1.7-2.8-1.7s4.8 8 17.5 11.8c-3 3.8-6.7 8.3-6.7 8.3-22.1-.7-30.5-15.2-30.5-15.2 0-32.2 14.4-58.3 14.4-58.3 14.4-10.8 28.1-10.5 28.1-10.5l1 1.2c-18 5.2-26.3 13.1-26.3 13.1s2.2-1.2 5.9-2.9c10.7-4.7 19.2-6 22.7-6.3.6-.1 1.1-.2 1.7-.2 6.1-.8 13-1 20.2-.2 9.5 1.1 19.7 3.9 30.1 9.6 0 0-7.9-7.5-24.9-12.7l1.4-1.6s13.7-.3 28.1 10.5c0 0 14.4 26.1 14.4 58.3 0 0-8.5 14.5-30.6 15.2z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="602px" height="602px" viewBox="57 57 602 602" version="1.1" xmlns="http://www.w3.org/2000/svg">
<g id="layer1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" transform="translate(58.964119, 58.887520)" opacity="0.91">
<ellipse style="fill: rgb(36, 157, 241); fill-rule: evenodd; stroke: rgb(255, 255, 255); stroke-width: 0;" transform="matrix(-0.674571, 0.73821, -0.73821, -0.674571, 556.833239, 241.613465)" cx="216.308" cy="152.076" rx="296.855" ry="296.855"/>
<path d="M 280.949 172.514 L 355.429 162.714 L 282.909 326.374 L 282.909 326.374 C 295.649 325.394 308.142 321.067 320.389 313.394 L 320.389 313.394 L 320.389 313.394 C 332.642 305.714 343.916 296.077 354.209 284.484 L 354.209 284.484 L 354.209 284.484 C 364.496 272.884 373.396 259.981 380.909 245.774 L 380.909 245.774 L 380.909 245.774 C 388.422 231.561 393.812 217.594 397.079 203.874 L 397.079 203.874 L 397.079 203.874 C 399.039 195.381 399.939 187.214 399.779 179.374 L 399.779 179.374 L 399.779 179.374 C 399.612 171.534 397.569 164.674 393.649 158.794 L 393.649 158.794 L 393.649 158.794 C 389.729 152.914 383.766 148.177 375.759 144.584 L 375.759 144.584 L 375.759 144.584 C 367.759 140.991 356.899 139.194 343.179 139.194 L 343.179 139.194 L 343.179 139.194 C 327.172 139.194 311.409 141.807 295.889 147.034 L 295.889 147.034 L 295.889 147.034 C 280.376 152.261 266.002 159.857 252.769 169.824 L 252.769 169.824 L 252.769 169.824 C 239.542 179.784 228.029 192.197 218.229 207.064 L 218.229 207.064 L 218.229 207.064 C 208.429 221.924 201.406 238.827 197.159 257.774 L 197.159 257.774 L 197.159 257.774 C 195.526 263.981 194.546 268.961 194.219 272.714 L 194.219 272.714 L 194.219 272.714 C 193.892 276.474 193.812 279.577 193.979 282.024 L 193.979 282.024 L 193.979 282.024 C 194.139 284.477 194.462 286.357 194.949 287.664 L 194.949 287.664 L 194.949 287.664 C 195.442 288.971 195.852 290.277 196.179 291.584 L 196.179 291.584 L 196.179 291.584 C 179.519 291.584 167.349 288.234 159.669 281.534 L 159.669 281.534 L 159.669 281.534 C 151.996 274.841 150.119 263.164 154.039 246.504 L 154.039 246.504 L 154.039 246.504 C 157.959 229.191 166.862 212.694 180.749 197.014 L 180.749 197.014 L 180.749 197.014 C 194.629 181.334 211.122 167.531 230.229 155.604 L 230.229 155.604 L 230.229 155.604 C 249.342 143.684 270.249 134.214 292.949 127.194 L 292.949 127.194 L 292.949 127.194 C 315.656 120.167 337.789 116.654 359.349 116.654 L 359.349 116.654 L 359.349 116.654 C 378.296 116.654 394.219 119.347 407.119 124.734 L 407.119 124.734 L 407.119 124.734 C 420.026 130.127 430.072 137.234 437.259 146.054 L 437.259 146.054 L 437.259 146.054 C 444.446 154.874 448.936 165.164 450.729 176.924 L 450.729 176.924 L 450.729 176.924 C 452.529 188.684 451.959 200.934 449.019 213.674 L 449.019 213.674 L 449.019 213.674 C 445.426 229.027 438.646 244.464 428.679 259.984 L 428.679 259.984 L 428.679 259.984 C 418.719 275.497 406.226 289.544 391.199 302.124 L 391.199 302.124 L 391.199 302.124 C 376.172 314.697 358.939 324.904 339.499 332.744 L 339.499 332.744 L 339.499 332.744 C 320.066 340.584 299.406 344.504 277.519 344.504 L 277.519 344.504 L 275.069 344.504 L 212.839 484.154 L 142.279 484.154 L 280.949 172.514 Z" transform="matrix(1, 0, 0, 1, 0, 0)" style="fill: rgb(255, 255, 255); fill-rule: nonzero; white-space: pre;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -1,5 +1,6 @@
import { baseURL } from "./api-utils"; import { baseURL } from "./api-utils";
import { apiReq } from "./api-utils"; import { apiReq } from "./api-utils";
import i18n from "@/i18n.js";
const prefix = baseURL + "about"; const prefix = baseURL + "about";
@ -12,6 +13,10 @@ const aboutURLs = {
statistics: `${prefix}/statistics`, statistics: `${prefix}/statistics`,
events: `${prefix}/events`, events: `${prefix}/events`,
event: id => `${prefix}/events/${id}`, event: id => `${prefix}/events/${id}`,
allNotifications: `${prefix}/events/notifications`,
testNotifications: `${prefix}/events/notifications/test`,
notification: id => `${prefix}/events/notifications/${id}`,
}; };
export const aboutAPI = { export const aboutAPI = {
@ -27,6 +32,39 @@ export const aboutAPI = {
const resposne = await apiReq.delete(aboutURLs.events); const resposne = await apiReq.delete(aboutURLs.events);
return resposne.data; return resposne.data;
}, },
async allEventNotifications() {
const response = await apiReq.get(aboutURLs.allNotifications);
return response.data;
},
async createNotification(data) {
const response = await apiReq.post(aboutURLs.allNotifications, data);
return response.data;
},
async deleteNotification(id) {
const response = await apiReq.delete(aboutURLs.notification(id));
return response.data;
},
async testNotificationByID(id) {
const response = await apiReq.post(
aboutURLs.testNotifications,
{ id: id },
() => i18n.t("events.something-went-wrong"),
() => i18n.t("events.test-message-sent")
);
return response.data;
},
async testNotificationByURL(url) {
const response = await apiReq.post(
aboutURLs.testNotifications,
{ test_url: url },
() => i18n.t("events.something-went-wrong"),
() => i18n.t("events.test-message-sent")
);
return response.data;
},
// async getAppInfo() { // async getAppInfo() {
// const response = await apiReq.get(aboutURLs.version); // const response = await apiReq.get(aboutURLs.version);
// return response.data; // return response.data;

View File

@ -71,7 +71,7 @@ export const userAPI = {
}, },
delete(id) { delete(id) {
return apiReq.delete(usersURLs.userID(id), null, deleteErrorText, function() { return apiReq.delete(usersURLs.userID(id), null, deleteErrorText, () => {
return i18n.t("user.user-deleted"); return i18n.t("user.user-deleted");
}); });
}, },

View File

@ -2,7 +2,7 @@
<div> <div>
<slot name="open" v-bind="{ open }"> </slot> <slot name="open" v-bind="{ open }"> </slot>
<v-dialog v-model="dialog" :width="modalWidth + 'px'" :content-class="top ? 'top-dialog' : undefined"> <v-dialog v-model="dialog" :width="modalWidth + 'px'" :content-class="top ? 'top-dialog' : undefined">
<v-card class="pb-10" height="100%"> <v-card height="100%">
<v-app-bar dark :color="color" class="mt-n1 mb-0"> <v-app-bar dark :color="color" class="mt-n1 mb-0">
<v-icon large left> <v-icon large left>
{{ titleIcon }} {{ titleIcon }}
@ -11,7 +11,9 @@
<v-spacer></v-spacer> <v-spacer></v-spacer>
</v-app-bar> </v-app-bar>
<v-progress-linear class="mt-1" v-if="loading" indeterminate color="primary"></v-progress-linear> <v-progress-linear class="mt-1" v-if="loading" indeterminate color="primary"></v-progress-linear>
<slot> </slot>
<slot v-bind="{ submitEvent }"> </slot>
<v-card-actions> <v-card-actions>
<slot name="card-actions"> <slot name="card-actions">
<v-btn text color="grey" @click="dialog = false"> <v-btn text color="grey" @click="dialog = false">
@ -22,13 +24,15 @@
<v-btn color="error" text @click="deleteEvent" v-if="$listeners.delete"> <v-btn color="error" text @click="deleteEvent" v-if="$listeners.delete">
{{ $t("general.delete") }} {{ $t("general.delete") }}
</v-btn> </v-btn>
<v-btn color="success" @click="submitEvent"> <v-btn color="success" type="submit" @click="submitEvent">
{{ submitText }} {{ submitText }}
</v-btn> </v-btn>
</slot> </slot>
</v-card-actions> </v-card-actions>
<slot name="below-actions"> </slot> <div class="pb-4" v-if="$slots['below-actions']">
<slot name="below-actions"> </slot>
</div>
</v-card> </v-card>
</v-dialog> </v-dialog>
</div> </div>
@ -59,6 +63,9 @@ export default {
submitText: { submitText: {
default: () => i18n.t("general.create"), default: () => i18n.t("general.create"),
}, },
keepOpen: {
default: false,
},
}, },
data() { data() {
return { return {
@ -68,7 +75,7 @@ export default {
}, },
computed: { computed: {
determineClose() { determineClose() {
return this.submitted && !this.loading; return this.submitted && !this.loading && !this.keepOpen;
}, },
}, },
watch: { watch: {
@ -82,6 +89,7 @@ export default {
}, },
methods: { methods: {
submitEvent() { submitEvent() {
console.log("Submit");
this.$emit("submit"); this.$emit("submit");
this.submitted = true; this.submitted = true;
}, },

View File

@ -1,6 +1,10 @@
<template> <template>
<div class="text-center ma-2"> <div class="text-center ma-2">
<v-snackbar v-model="snackbar.open" top :color="snackbar.color" timeout="3500"> <v-snackbar v-model="snackbar.open" top :color="snackbar.color" timeout="3500">
<v-icon dark left>
{{ icon }}
</v-icon>
{{ snackbar.title }} {{ snackbar.title }}
{{ snackbar.text }} {{ snackbar.text }}
@ -25,6 +29,18 @@ export default {
return this.$store.getters.getSnackbar; return this.$store.getters.getSnackbar;
}, },
}, },
icon() {
switch (this.snackbar.color) {
case "error":
return "mdi-alert";
case "success":
return "mdi-checkbox-marked-circle";
case "info":
return "mdi-information";
default:
return "mdi-bell-alert";
}
},
}, },
}; };
</script> </script>

View File

@ -31,6 +31,16 @@
"category-updated": "Category updated", "category-updated": "Category updated",
"uncategorized-count": "Uncategorized {count}" "uncategorized-count": "Uncategorized {count}"
}, },
"events": {
"notification": "Notification",
"apprise-url": "Apprise URL",
"subscribed-events": "Subscribed Events",
"scheduled": "Scheduled",
"database": "Database",
"test-message-sent": "Test Message Sent",
"something-went-wrong": "Something Went Wrong!",
"new-notification-form-description": "Mealie uses the Apprise library to generate notifications. They offer many options for services to use for notifications. Refer to their wiki for a comprehensive guide on how to create the URL for your service. If available, selecting the type of your notification may include extra features."
},
"general": { "general": {
"apply": "Apply", "apply": "Apply",
"cancel": "Cancel", "cancel": "Cancel",
@ -55,6 +65,7 @@
"file-uploaded": "File uploaded", "file-uploaded": "File uploaded",
"filter": "Filter", "filter": "Filter",
"friday": "Friday", "friday": "Friday",
"general": "General",
"get": "Get", "get": "Get",
"image": "Image", "image": "Image",
"image-upload-failed": "Image upload failed", "image-upload-failed": "Image upload failed",
@ -70,6 +81,7 @@
"random": "Random", "random": "Random",
"rating": "Rating", "rating": "Rating",
"recent": "Recent", "recent": "Recent",
"recipe": "Recipe",
"recipes": "Recipes", "recipes": "Recipes",
"rename-object": "Rename {0}", "rename-object": "Rename {0}",
"reset": "Reset", "reset": "Reset",
@ -88,6 +100,8 @@
"thursday": "Thursday", "thursday": "Thursday",
"token": "Token", "token": "Token",
"tuesday": "Tuesday", "tuesday": "Tuesday",
"type": "Type",
"test": "Test",
"update": "Update", "update": "Update",
"updated": "Updated", "updated": "Updated",
"upload": "Upload", "upload": "Upload",

View File

@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<base-dialog <BaseDialog
ref="assignDialog" ref="assignDialog"
title-icon="mdi-tag" title-icon="mdi-tag"
color="primary" color="primary"
@ -33,7 +33,7 @@
:single-column="true" :single-column="true"
/> />
</template> </template>
</base-dialog> </BaseDialog>
<v-btn @click="openDialog" small color="success"> <v-btn @click="openDialog" small color="success">
{{ $t("settings.toolbox.bulk-assign") }} {{ $t("settings.toolbox.bulk-assign") }}

View File

@ -0,0 +1,236 @@
<template>
<div>
<v-card outlined class="mt-n1">
<v-card-actions>
<v-spacer></v-spacer>
<BaseDialog
:keep-open="keepDialogOpen"
title-icon="mdi-bell-alert"
:title="$t('general.new') + ' ' + $t('events.notification')"
@submit="createNotification"
>
<template v-slot:open="{ open }">
<v-btn small color="info" @click="open">
<v-icon left>
mdi-plus
</v-icon>
{{ $t("events.notification") }}
</v-btn>
</template>
<template v-slot:default>
<v-card-text class="mt-2">
{{ $t("events.new-notification-form-description") }}
<div class="d-flex justify-space-around mt-1 mb-3">
<a href="https://github.com/caronc/apprise/wiki" target="_blanks"> Apprise </a>
<a href="https://github.com/caronc/apprise/wiki/Notify_gotify" target="_blanks"> Gotify </a>
<a href="https://github.com/caronc/apprise/wiki/Notify_discord" target="_blanks"> Discord </a>
<a href="https://github.com/caronc/apprise/wiki/Notify_homeassistant" target="_blanks">
Home Assistant
</a>
<a href="https://github.com/caronc/apprise/wiki/Notify_matrix" target="_blanks"> Matrix </a>
<a href="https://github.com/caronc/apprise/wiki/Notify_pushover" target="_blanks"> Pushover </a>
</div>
<v-form ref="notificationForm">
<v-select
:label="$t('general.type')"
:rules="[existsRule]"
:items="notificationTypes"
item-value="text"
v-model="newNotification.type"
>
</v-select>
<v-text-field :rules="[existsRule]" :label="$t('general.name')" v-model="newNotification.name">
</v-text-field>
<v-text-field
required
:rules="[existsRule]"
:label="$t('events.apprise-url')"
v-model="newNotification.notificationUrl"
>
</v-text-field>
<v-btn class="d-flex ml-auto" small color="info" @click="testByURL(newNotification.notificationUrl)">
<v-icon left> mdi-test-tube</v-icon>
{{ $t("general.test") }}
</v-btn>
<v-subheader class="pa-0 mb-0">
{{ $t("events.subscribed-events") }}
</v-subheader>
<v-row class="mt-1">
<v-col cols="3" v-for="(item, key, index) in newNotificationOptions" :key="index">
<v-checkbox class="my-n3 py-0" v-model="newNotificationOptions[key]" :label="key"> </v-checkbox>
</v-col>
</v-row>
</v-form>
</v-card-text>
</template>
</BaseDialog>
</v-card-actions>
<v-simple-table>
<template v-slot:default>
<thead>
<tr>
<th class="text-center">
{{ $t("general.type") }}
</th>
<th class="text-center">
{{ $t("general.name") }}
</th>
<th class="text-center">
{{ $t("general.general") }}
</th>
<th class="text-center">
{{ $t("general.recipe") }}
</th>
<th class="text-center">
{{ $t("events.database") }}
</th>
<th class="text-center">
{{ $t("events.scheduled") }}
</th>
<th class="text-center">
{{ $t("settings.migrations") }}
</th>
<th class="text-center">
{{ $t("group.group") }}
</th>
<th class="text-center">
{{ $t("user.user") }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in notifications" :key="index">
<td>
<v-avatar size="35" class="ma-1" :color="getIcon(item.type).icon ? 'primary' : undefined">
<v-icon dark v-if="getIcon(item.type).icon"> {{ getIcon(item.type).icon }}</v-icon>
<v-img v-else :src="getIcon(item.type).image"> </v-img>
</v-avatar>
{{ item.type }}
</td>
<td>
{{ item.name }}
</td>
<td class="text-center">
<v-icon color="success"> {{ item.general ? "mdi-check" : "" }} </v-icon>
</td>
<td class="text-center">
<v-icon color="success"> {{ item.recipe ? "mdi-check" : "" }} </v-icon>
</td>
<td class="text-center">
<v-icon color="success"> {{ item.backup ? "mdi-check" : "" }} </v-icon>
</td>
<td class="text-center">
<v-icon color="success"> {{ item.scheduled ? "mdi-check" : "" }} </v-icon>
</td>
<td class="text-center">
<v-icon color="success"> {{ item.migration ? "mdi-check" : "" }} </v-icon>
</td>
<td class="text-center">
<v-icon color="success"> {{ item.group ? "mdi-check" : "" }} </v-icon>
</td>
<td class="text-center">
<v-icon color="success"> {{ item.user ? "mdi-check" : "" }} </v-icon>
</td>
<td>
<v-btn class="mx-1" small color="error" @click="deleteNotification(item.id)">
<v-icon> mdi-delete </v-icon>
{{ $t("general.delete") }}
</v-btn>
<v-btn small color="info" @click="testByID(item.id)">
<v-icon left> mdi-test-tube</v-icon>
{{ $t("general.test") }}
</v-btn>
</td>
</tr>
</tbody>
</template>
</v-simple-table>
</v-card>
</div>
</template>
<script>
import BaseDialog from "@/components/UI/Dialogs/BaseDialog";
import { api } from "@/api";
import { validators } from "@/mixins/validators";
export default {
components: {
BaseDialog,
},
mixins: [validators],
data() {
return {
keepDialogOpen: false,
notifications: [],
newNotification: {
type: "General",
name: "",
notificationUrl: "",
},
newNotificationOptions: {
general: true,
recipe: true,
backup: true,
scheduled: true,
migration: true,
group: true,
user: true,
},
notificationTypes: [
{
text: "General",
icon: "mdi-bell-alert",
},
{
text: "Discord",
image: "./static/discord.svg",
},
{
text: "Gotify",
image: "./static/gotify.png",
},
{
text: "Home Assistant",
image: "./static/home-assistant.png",
},
{
text: "Pushover",
image: "./static/pushover.svg",
},
],
};
},
mounted() {
this.getAllNotifications();
},
methods: {
getIcon(textValue) {
return this.notificationTypes.find(x => x.text === textValue);
},
async getAllNotifications() {
this.notifications = await api.about.allEventNotifications();
},
async createNotification() {
if (this.$refs.notificationForm.validate()) {
this.keepDialogOpen = false;
await api.about.createNotification({ ...this.newNotification, ...this.newNotificationOptions });
this.getAllNotifications();
} else {
this.keepDialogOpen = true;
}
},
async deleteNotification(id) {
await api.about.deleteNotification(id);
this.getAllNotifications();
},
async testByID(id) {
await api.about.testNotificationByID(id);
},
async testByURL(url) {
await api.about.testNotificationByURL(url);
},
},
};
</script>

View File

@ -4,6 +4,10 @@
<v-tabs v-model="tab" background-color="primary" centered dark icons-and-text> <v-tabs v-model="tab" background-color="primary" centered dark icons-and-text>
<v-tabs-slider></v-tabs-slider> <v-tabs-slider></v-tabs-slider>
<v-tab href="#event-notifications">
Notify
<v-icon>mdi-bell-alert</v-icon>
</v-tab>
<v-tab href="#category-editor"> <v-tab href="#category-editor">
{{ $t("recipe.categories") }} {{ $t("recipe.categories") }}
<v-icon>mdi-tag-multiple-outline</v-icon> <v-icon>mdi-tag-multiple-outline</v-icon>
@ -20,20 +24,23 @@
</v-tabs> </v-tabs>
<v-tabs-items v-model="tab"> <v-tabs-items v-model="tab">
<v-tab-item value="event-notifications"> <EventNotification /></v-tab-item>
<v-tab-item value="category-editor"> <CategoryTagEditor :is-tags="false"/></v-tab-item> <v-tab-item value="category-editor"> <CategoryTagEditor :is-tags="false"/></v-tab-item>
<v-tab-item value="tag-editor"> <CategoryTagEditor :is-tags="true" /> </v-tab-item> <v-tab-item value="tag-editor"> <CategoryTagEditor :is-tags="true" /> </v-tab-item>
<v-tab-item value="organize"> <RecipeOrganizer :is-tags="true" /> </v-tab-item> <v-tab-item value="organize"> <RecipeOrganizer /> </v-tab-item>
</v-tabs-items> </v-tabs-items>
</v-card> </v-card>
</div> </div>
</template> </template>
<script> <script>
import EventNotification from "./EventNotification";
import CategoryTagEditor from "./CategoryTagEditor"; import CategoryTagEditor from "./CategoryTagEditor";
import RecipeOrganizer from "./RecipeOrganizer"; import RecipeOrganizer from "./RecipeOrganizer";
export default { export default {
components: { components: {
CategoryTagEditor, CategoryTagEditor,
EventNotification,
RecipeOrganizer, RecipeOrganizer,
}, },
computed: { computed: {

View File

@ -95,6 +95,7 @@ def determine_sqlite_path(path=False, suffix=DB_VERSION) -> str:
class AppSettings(BaseSettings): class AppSettings(BaseSettings):
global DATA_DIR global DATA_DIR
PRODUCTION: bool = Field(True, env="PRODUCTION") PRODUCTION: bool = Field(True, env="PRODUCTION")
BASE_URL: str = "http://localhost:8080"
IS_DEMO: bool = False IS_DEMO: bool = False
API_PORT: int = 9000 API_PORT: int = 9000
API_DOCS: bool = True API_DOCS: bool = True

View File

@ -1,7 +1,7 @@
from logging import getLogger from logging import getLogger
from mealie.db.db_base import BaseDocument from mealie.db.db_base import BaseDocument
from mealie.db.models.event import Event from mealie.db.models.event import Event, EventNotification
from mealie.db.models.group import Group from mealie.db.models.group import Group
from mealie.db.models.mealplan import MealPlanModel from mealie.db.models.mealplan import MealPlanModel
from mealie.db.models.recipe.recipe import Category, RecipeModel, Tag from mealie.db.models.recipe.recipe import Category, RecipeModel, Tag
@ -10,6 +10,7 @@ from mealie.db.models.sign_up import SignUp
from mealie.db.models.theme import SiteThemeModel from mealie.db.models.theme import SiteThemeModel
from mealie.db.models.users import LongLiveToken, User from mealie.db.models.users import LongLiveToken, User
from mealie.schema.category import RecipeCategoryResponse, RecipeTagResponse from mealie.schema.category import RecipeCategoryResponse, RecipeTagResponse
from mealie.schema.event_notifications import EventNotificationIn
from mealie.schema.events import Event as EventSchema from mealie.schema.events import Event as EventSchema
from mealie.schema.meal import MealPlanInDB from mealie.schema.meal import MealPlanInDB
from mealie.schema.recipe import Recipe from mealie.schema.recipe import Recipe
@ -156,6 +157,13 @@ class _Events(BaseDocument):
self.schema = EventSchema self.schema = EventSchema
class _EventNotification(BaseDocument):
def __init__(self) -> None:
self.primary_key = "id"
self.sql_model = EventNotification
self.schema = EventNotificationIn
class Database: class Database:
def __init__(self) -> None: def __init__(self) -> None:
self.recipes = _Recipes() self.recipes = _Recipes()
@ -170,6 +178,7 @@ class Database:
self.groups = _Groups() self.groups = _Groups()
self.custom_pages = _CustomPages() self.custom_pages = _CustomPages()
self.events = _Events() self.events = _Events()
self.event_notifications = _EventNotification()
db = Database() db = Database()

View File

@ -1,14 +1,45 @@
import sqlalchemy as sa
from mealie.db.models.model_base import BaseMixins, SqlAlchemyBase from mealie.db.models.model_base import BaseMixins, SqlAlchemyBase
from sqlalchemy import Boolean, Column, DateTime, Integer, String
class EventNotification(SqlAlchemyBase, BaseMixins):
__tablename__ = "event_notifications"
id = Column(Integer, primary_key=True)
name = Column(String)
type = Column(String)
notification_url = Column(String)
# Event Types
general = Column(Boolean, default=False)
recipe = Column(Boolean, default=False)
backup = Column(Boolean, default=False)
scheduled = Column(Boolean, default=False)
migration = Column(Boolean, default=False)
group = Column(Boolean, default=False)
user = Column(Boolean, default=False)
def __init__(
self, name, notification_url, type, general, recipe, backup, scheduled, migration, group, user, *args, **kwargs
) -> None:
self.name = name
self.notification_url = notification_url
self.type = type
self.general = general
self.recipe = recipe
self.backup = backup
self.scheduled = scheduled
self.migration = migration
self.group = group
self.user = user
class Event(SqlAlchemyBase, BaseMixins): class Event(SqlAlchemyBase, BaseMixins):
__tablename__ = "events" __tablename__ = "events"
id = sa.Column(sa.Integer, primary_key=True) id = Column(Integer, primary_key=True)
title = sa.Column(sa.String) title = Column(String)
text = sa.Column(sa.String) text = Column(String)
time_stamp = sa.Column(sa.DateTime) time_stamp = Column(DateTime)
category = sa.Column(sa.String) category = Column(String)
def __init__(self, title, text, time_stamp, category, *args, **kwargs) -> None: def __init__(self, title, text, time_stamp, category, *args, **kwargs) -> None:
self.title = title self.title = title

View File

@ -1,28 +1,97 @@
from fastapi import APIRouter, Depends from http.client import HTTPException
from fastapi import APIRouter, Depends, status
from mealie.core.root_logger import get_logger
from mealie.db.database import db from mealie.db.database import db
from mealie.db.db_setup import generate_session from mealie.db.db_setup import generate_session
from mealie.routes.deps import get_current_user from mealie.routes.deps import get_current_user
from mealie.schema.events import EventsOut from mealie.schema.event_notifications import EventNotificationIn, EventNotificationOut
from mealie.schema.events import EventsOut, TestEvent
from mealie.schema.user import UserInDB
from mealie.services.events import test_notification
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
router = APIRouter(prefix="/events", tags=["App Events"]) router = APIRouter(prefix="/events", tags=["App Events"])
logger = get_logger()
@router.get("", response_model=EventsOut) @router.get("", response_model=EventsOut)
async def get_events(session: Session = Depends(generate_session), current_user=Depends(get_current_user)): async def get_events(session: Session = Depends(generate_session), current_user: UserInDB = Depends(get_current_user)):
""" Get event from the Database """ """ Get event from the Database """
# Get Item # Get Item
return EventsOut(total=db.events.count_all(session), events=db.events.get_all(session, order_by="time_stamp")) return EventsOut(total=db.events.count_all(session), events=db.events.get_all(session, order_by="time_stamp"))
@router.delete("") @router.delete("")
async def delete_events(session: Session = Depends(generate_session), current_user=Depends(get_current_user)): async def delete_events(
session: Session = Depends(generate_session), current_user: UserInDB = Depends(get_current_user)
):
""" Get event from the Database """ """ Get event from the Database """
# Get Item # Get Item
return db.events.delete_all(session) return db.events.delete_all(session)
@router.delete("/{id}") @router.delete("/{id}")
async def delete_event(id: int, session: Session = Depends(generate_session), current_user=Depends(get_current_user)): async def delete_event(
id: int, session: Session = Depends(generate_session), current_user: UserInDB = Depends(get_current_user)
):
""" Delete event from the Database """ """ Delete event from the Database """
return db.events.delete(session, id) return db.events.delete(session, id)
@router.post("/notifications")
async def create_event_notification(
event_data: EventNotificationIn,
session: Session = Depends(generate_session),
current_user: UserInDB = Depends(get_current_user),
):
""" Create event_notification in the Database """
return db.event_notifications.create(session, event_data)
@router.post("/notifications/test")
async def test_notification_route(
test_data: TestEvent,
session: Session = Depends(generate_session),
current_user: UserInDB = Depends(get_current_user),
):
""" Create event_notification in the Database """
if test_data.id:
event_obj: EventNotificationIn = db.event_notifications.get(session, test_data.id)
test_data.test_url = event_obj.notification_url
try:
test_notification(test_data.test_url)
except Exception as e:
logger.error(e)
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR)
@router.get("/notifications", response_model=list[EventNotificationOut])
async def get_all_event_notification(
session: Session = Depends(generate_session), current_user: UserInDB = Depends(get_current_user)
):
""" Get all event_notification from the Database """
# Get Item
return db.event_notifications.get_all(session, override_schema=EventNotificationOut)
@router.put("/notifications/{id}")
async def update_event_notification(
id: int, session: Session = Depends(generate_session), current_user: UserInDB = Depends(get_current_user)
):
""" Update event_notification in the Database """
# Update Item
return {"details": "not yet implemented"}
@router.delete("/notifications/{id}")
async def delete_event_notification(
id: int, session: Session = Depends(generate_session), current_user: UserInDB = Depends(get_current_user)
):
""" Delete event_notification from the Database """
# Delete Item
return db.event_notifications.delete(session, id)

View File

@ -2,7 +2,7 @@ import operator
import shutil import shutil
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status from fastapi import APIRouter, BackgroundTasks, Depends, File, HTTPException, UploadFile, status
from mealie.core.config import app_dirs from mealie.core.config import app_dirs
from mealie.core.root_logger import get_logger from mealie.core.root_logger import get_logger
from mealie.core.security import create_file_token from mealie.core.security import create_file_token
@ -33,7 +33,7 @@ def available_imports():
@router.post("/export/database", status_code=status.HTTP_201_CREATED) @router.post("/export/database", status_code=status.HTTP_201_CREATED)
def export_database(data: BackupJob, session: Session = Depends(generate_session)): def export_database(background_tasks: BackgroundTasks, data: BackupJob, session: Session = Depends(generate_session)):
"""Generates a backup of the recipe database in json format.""" """Generates a backup of the recipe database in json format."""
try: try:
export_path = backup_all( export_path = backup_all(
@ -47,7 +47,9 @@ def export_database(data: BackupJob, session: Session = Depends(generate_session
export_users=data.options.users, export_users=data.options.users,
export_groups=data.options.groups, export_groups=data.options.groups,
) )
create_backup_event("Database Backup", f"Manual Backup Created '{Path(export_path).name}'", session) background_tasks.add_task(
create_backup_event, "Database Backup", f"Manual Backup Created '{Path(export_path).name}'", session
)
return {"export_path": export_path} return {"export_path": export_path}
except Exception as e: except Exception as e:
logger.error(e) logger.error(e)
@ -75,7 +77,12 @@ async def download_backup_file(file_name: str):
@router.post("/{file_name}/import", status_code=status.HTTP_200_OK) @router.post("/{file_name}/import", status_code=status.HTTP_200_OK)
def import_database(file_name: str, import_data: ImportJob, session: Session = Depends(generate_session)): def import_database(
background_tasks: BackgroundTasks,
file_name: str,
import_data: ImportJob,
session: Session = Depends(generate_session),
):
""" Import a database backup file generated from Mealie. """ """ Import a database backup file generated from Mealie. """
db_import = imports.import_database( db_import = imports.import_database(
@ -90,7 +97,7 @@ def import_database(file_name: str, import_data: ImportJob, session: Session = D
force_import=import_data.force, force_import=import_data.force,
rebase=import_data.rebase, rebase=import_data.rebase,
) )
create_backup_event("Database Restore", f"Restore File: {file_name}", session) background_tasks.add_task(create_backup_event, "Database Restore", f"Restore File: {file_name}", session)
return db_import return db_import

View File

@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
from mealie.db.database import db from mealie.db.database import db
from mealie.db.db_setup import generate_session from mealie.db.db_setup import generate_session
from mealie.routes.deps import get_current_user from mealie.routes.deps import get_current_user
@ -32,6 +32,7 @@ async def get_current_user_group(
@router.post("", status_code=status.HTTP_201_CREATED) @router.post("", status_code=status.HTTP_201_CREATED)
async def create_group( async def create_group(
background_tasks: BackgroundTasks,
group_data: GroupBase, group_data: GroupBase,
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
session: Session = Depends(generate_session), session: Session = Depends(generate_session),
@ -40,7 +41,7 @@ async def create_group(
try: try:
db.groups.create(session, group_data.dict()) db.groups.create(session, group_data.dict())
create_group_event("Group Created", f"'{group_data.name}' created") background_tasks.add_task(create_group_event, "Group Created", f"'{group_data.name}' created", session)
except Exception: except Exception:
raise HTTPException(status.HTTP_400_BAD_REQUEST) raise HTTPException(status.HTTP_400_BAD_REQUEST)
@ -58,7 +59,10 @@ async def update_group_data(
@router.delete("/{id}") @router.delete("/{id}")
async def delete_user_group( async def delete_user_group(
id: int, current_user=Depends(get_current_user), session: Session = Depends(generate_session) background_tasks: BackgroundTasks,
id: int,
current_user: UserInDB = Depends(get_current_user),
session: Session = Depends(generate_session),
): ):
""" Removes a user group from the database """ """ Removes a user group from the database """
@ -73,5 +77,8 @@ async def delete_user_group(
if group.users != []: if group.users != []:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="GROUP_WITH_USERS") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="GROUP_WITH_USERS")
create_group_event("Group Deleted", f"'{group.name}' Deleted") background_tasks.add_task(
create_group_event, "Group Deleted", f"'{group.name}' deleted by {current_user.full_name}", session
)
db.groups.delete(session, id) db.groups.delete(session, id)

View File

@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
from mealie.db.database import db from mealie.db.database import db
from mealie.db.db_setup import generate_session from mealie.db.db_setup import generate_session
from mealie.routes.deps import get_current_user from mealie.routes.deps import get_current_user
@ -25,16 +25,22 @@ def get_all_meals(
@router.post("/create", status_code=status.HTTP_201_CREATED) @router.post("/create", status_code=status.HTTP_201_CREATED)
def create_meal_plan( def create_meal_plan(
data: MealPlanIn, session: Session = Depends(generate_session), current_user: UserInDB = Depends(get_current_user) background_tasks: BackgroundTasks,
data: MealPlanIn,
session: Session = Depends(generate_session),
current_user: UserInDB = Depends(get_current_user),
): ):
""" Creates a meal plan database entry """ """ Creates a meal plan database entry """
processed_plan = process_meals(session, data) processed_plan = process_meals(session, data)
create_group_event("Meal Plan Created", f"Mealplan Created for '{current_user.group}'") background_tasks.add_task(
create_group_event, "Meal Plan Created", f"Mealplan Created for '{current_user.group}'", session=session
)
return db.meals.create(session, processed_plan.dict()) return db.meals.create(session, processed_plan.dict())
@router.put("/{plan_id}") @router.put("/{plan_id}")
def update_meal_plan( def update_meal_plan(
background_tasks: BackgroundTasks,
plan_id: str, plan_id: str,
meal_plan: MealPlanIn, meal_plan: MealPlanIn,
session: Session = Depends(generate_session), session: Session = Depends(generate_session),
@ -45,13 +51,16 @@ def update_meal_plan(
processed_plan = MealPlanInDB(uid=plan_id, **processed_plan.dict()) processed_plan = MealPlanInDB(uid=plan_id, **processed_plan.dict())
try: try:
db.meals.update(session, plan_id, processed_plan.dict()) db.meals.update(session, plan_id, processed_plan.dict())
create_group_event("Meal Plan Updated", f"Mealplan Updated for '{current_user.group}'") background_tasks.add_task(
create_group_event, "Meal Plan Updated", f"Mealplan Updated for '{current_user.group}'", session=session
)
except Exception: except Exception:
raise HTTPException(status.HTTP_400_BAD_REQUEST) raise HTTPException(status.HTTP_400_BAD_REQUEST)
@router.delete("/{plan_id}") @router.delete("/{plan_id}")
def delete_meal_plan( def delete_meal_plan(
background_tasks: BackgroundTasks,
plan_id, plan_id,
session: Session = Depends(generate_session), session: Session = Depends(generate_session),
current_user: UserInDB = Depends(get_current_user), current_user: UserInDB = Depends(get_current_user),
@ -60,7 +69,9 @@ def delete_meal_plan(
try: try:
db.meals.delete(session, plan_id) db.meals.delete(session, plan_id)
create_group_event("Meal Plan Deleted", f"Mealplan Deleted for '{current_user.group}'") background_tasks.add_task(
create_group_event, "Meal Plan Deleted", f"Mealplan Deleted for '{current_user.group}'", session=session
)
except Exception: except Exception:
raise HTTPException(status.HTTP_400_BAD_REQUEST) raise HTTPException(status.HTTP_400_BAD_REQUEST)

View File

@ -1,12 +1,14 @@
from shutil import copyfileobj from shutil import copyfileobj
from fastapi import APIRouter, Depends, File, Form, HTTPException, status from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, status
from fastapi.datastructures import UploadFile from fastapi.datastructures import UploadFile
from mealie.core.config import settings
from mealie.core.root_logger import get_logger from mealie.core.root_logger import get_logger
from mealie.db.database import db from mealie.db.database import db
from mealie.db.db_setup import generate_session from mealie.db.db_setup import generate_session
from mealie.routes.deps import get_current_user from mealie.routes.deps import get_current_user
from mealie.schema.recipe import Recipe, RecipeAsset, RecipeURLIn from mealie.schema.recipe import Recipe, RecipeAsset, RecipeURLIn
from mealie.schema.user import UserInDB
from mealie.services.events import create_recipe_event from mealie.services.events import create_recipe_event
from mealie.services.image.image import scrape_image, write_image from mealie.services.image.image import scrape_image, write_image
from mealie.services.recipe.media import check_assets, delete_assets from mealie.services.recipe.media import check_assets, delete_assets
@ -20,6 +22,7 @@ logger = get_logger()
@router.post("/create", status_code=201, response_model=str) @router.post("/create", status_code=201, response_model=str)
def create_from_json( def create_from_json(
background_tasks: BackgroundTasks,
data: Recipe, data: Recipe,
session: Session = Depends(generate_session), session: Session = Depends(generate_session),
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
@ -27,22 +30,36 @@ def create_from_json(
""" Takes in a JSON string and loads data into the database as a new entry""" """ Takes in a JSON string and loads data into the database as a new entry"""
recipe: Recipe = db.recipes.create(session, data.dict()) recipe: Recipe = db.recipes.create(session, data.dict())
create_recipe_event("Recipe Created", f"Recipe '{recipe.name}' created", session=session) background_tasks.add_task(
create_recipe_event,
"Recipe Created (URL)",
f"'{recipe.name}' by {current_user.full_name} \n {settings.BASE_URL}/recipe/{recipe.slug}",
session=session,
attachment=recipe.image_dir.joinpath("min-original.webp"),
)
return recipe.slug return recipe.slug
@router.post("/create-url", status_code=201, response_model=str) @router.post("/create-url", status_code=201, response_model=str)
def parse_recipe_url( def parse_recipe_url(
background_tasks: BackgroundTasks,
url: RecipeURLIn, url: RecipeURLIn,
session: Session = Depends(generate_session), session: Session = Depends(generate_session),
current_user=Depends(get_current_user), current_user: UserInDB = Depends(get_current_user),
): ):
""" Takes in a URL and attempts to scrape data and load it into the database """ """ Takes in a URL and attempts to scrape data and load it into the database """
recipe = create_from_url(url.url) recipe = create_from_url(url.url)
recipe: Recipe = db.recipes.create(session, recipe.dict()) recipe: Recipe = db.recipes.create(session, recipe.dict())
create_recipe_event("Recipe Created (URL)", f"'{recipe.name}' by {current_user.full_name}", session=session)
background_tasks.add_task(
create_recipe_event,
"Recipe Created (URL)",
f"'{recipe.name}' by {current_user.full_name} \n {settings.BASE_URL}/recipe/{recipe.slug}",
session=session,
attachment=recipe.image_dir.joinpath("min-original.webp"),
)
return recipe.slug return recipe.slug
@ -64,7 +81,6 @@ def update_recipe(
""" Updates a recipe by existing slug and data. """ """ Updates a recipe by existing slug and data. """
recipe: Recipe = db.recipes.update(session, recipe_slug, data.dict()) recipe: Recipe = db.recipes.update(session, recipe_slug, data.dict())
print(recipe.assets)
check_assets(original_slug=recipe_slug, recipe=recipe) check_assets(original_slug=recipe_slug, recipe=recipe)
@ -91,6 +107,7 @@ def patch_recipe(
@router.delete("/{recipe_slug}") @router.delete("/{recipe_slug}")
def delete_recipe( def delete_recipe(
background_tasks: BackgroundTasks,
recipe_slug: str, recipe_slug: str,
session: Session = Depends(generate_session), session: Session = Depends(generate_session),
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
@ -100,7 +117,12 @@ def delete_recipe(
try: try:
recipe: Recipe = db.recipes.delete(session, recipe_slug) recipe: Recipe = db.recipes.delete(session, recipe_slug)
delete_assets(recipe_slug=recipe_slug) delete_assets(recipe_slug=recipe_slug)
create_recipe_event("Recipe Deleted", f"'{recipe.name}' deleted by {current_user.full_name}", session=session) background_tasks.add_task(
create_recipe_event,
"Recipe Deleted",
f"'{recipe.name}' deleted by {current_user.full_name}",
session=session,
)
return recipe return recipe
except Exception: except Exception:
raise HTTPException(status.HTTP_400_BAD_REQUEST) raise HTTPException(status.HTTP_400_BAD_REQUEST)

View File

@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, Request, status from fastapi import APIRouter, BackgroundTasks, Depends, Request, status
from fastapi.exceptions import HTTPException from fastapi.exceptions import HTTPException
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from mealie.core import security from mealie.core import security
@ -15,6 +15,7 @@ router = APIRouter(prefix="/api/auth", tags=["Authentication"])
@router.post("/token/long") @router.post("/token/long")
@router.post("/token") @router.post("/token")
def get_token( def get_token(
background_tasks: BackgroundTasks,
request: Request, request: Request,
data: OAuth2PasswordRequestForm = Depends(), data: OAuth2PasswordRequestForm = Depends(),
session: Session = Depends(generate_session), session: Session = Depends(generate_session),
@ -25,7 +26,9 @@ def get_token(
user = authenticate_user(session, email, password) user = authenticate_user(session, email, password)
if not user: if not user:
create_user_event("Failed Login", f"Username: {email}, Source IP: '{request.client.host}'") background_tasks.add_task(
create_user_event, "Failed Login", f"Username: {email}, Source IP: '{request.client.host}'"
)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},

View File

@ -1,6 +1,6 @@
import shutil import shutil
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status from fastapi import APIRouter, BackgroundTasks, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from mealie.core import security from mealie.core import security
from mealie.core.config import app_dirs, settings from mealie.core.config import app_dirs, settings
@ -17,13 +17,16 @@ router = APIRouter(prefix="/api/users", tags=["Users"])
@router.post("", response_model=UserOut, status_code=201) @router.post("", response_model=UserOut, status_code=201)
async def create_user( async def create_user(
background_tasks: BackgroundTasks,
new_user: UserIn, new_user: UserIn,
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
session: Session = Depends(generate_session), session: Session = Depends(generate_session),
): ):
new_user.password = get_password_hash(new_user.password) new_user.password = get_password_hash(new_user.password)
create_user_event("User Created", f"Created by {current_user.full_name}", session=session) background_tasks.add_task(
create_user_event, "User Created", f"Created by {current_user.full_name}", session=session
)
return db.users.create(session, new_user.dict()) return db.users.create(session, new_user.dict())
@ -138,6 +141,7 @@ async def update_password(
@router.delete("/{id}") @router.delete("/{id}")
async def delete_user( async def delete_user(
background_tasks: BackgroundTasks,
id: int, id: int,
current_user: UserInDB = Depends(get_current_user), current_user: UserInDB = Depends(get_current_user),
session: Session = Depends(generate_session), session: Session = Depends(generate_session),
@ -150,6 +154,6 @@ async def delete_user(
if current_user.id == id or current_user.admin: if current_user.id == id or current_user.admin:
try: try:
db.users.delete(session, id) db.users.delete(session, id)
create_user_event("User Deleted", f"User ID: {id}", session=session) background_tasks.add_task(create_user_event, "User Deleted", f"User ID: {id}", session=session)
except Exception: except Exception:
raise HTTPException(status.HTTP_400_BAD_REQUEST) raise HTTPException(status.HTTP_400_BAD_REQUEST)

View File

@ -1,6 +1,6 @@
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
from mealie.core.security import get_password_hash from mealie.core.security import get_password_hash
from mealie.db.database import db from mealie.db.database import db
from mealie.db.db_setup import generate_session from mealie.db.db_setup import generate_session
@ -25,6 +25,7 @@ async def get_all_open_sign_ups(
@router.post("", response_model=SignUpToken) @router.post("", response_model=SignUpToken)
async def create_user_sign_up_key( async def create_user_sign_up_key(
background_tasks: BackgroundTasks,
key_data: SignUpIn, key_data: SignUpIn,
current_user: UserInDB = Depends(get_current_user), current_user: UserInDB = Depends(get_current_user),
session: Session = Depends(generate_session), session: Session = Depends(generate_session),
@ -39,12 +40,16 @@ async def create_user_sign_up_key(
"name": key_data.name, "name": key_data.name,
"admin": key_data.admin, "admin": key_data.admin,
} }
create_user_event("Sign-up Token Created", f"Created by {current_user.full_name}", session=session)
background_tasks.add_task(
create_user_event, "Sign-up Token Created", f"Created by {current_user.full_name}", session=session
)
return db.sign_ups.create(session, sign_up) return db.sign_ups.create(session, sign_up)
@router.post("/{token}") @router.post("/{token}")
async def create_user_with_token( async def create_user_with_token(
background_tasks: BackgroundTasks,
token: str, token: str,
new_user: UserIn, new_user: UserIn,
session: Session = Depends(generate_session), session: Session = Depends(generate_session),
@ -62,7 +67,9 @@ async def create_user_with_token(
db.users.create(session, new_user.dict()) db.users.create(session, new_user.dict())
# DeleteToken # DeleteToken
create_user_event("Sign-up Token Used", f"New User {new_user.full_name}", session=session) background_tasks.add_task(
create_user_event, "Sign-up Token Used", f"New User {new_user.full_name}", session=session
)
db.sign_ups.delete(session, token) db.sign_ups.delete(session, token)

View File

@ -0,0 +1,61 @@
from enum import Enum
from typing import Optional
from fastapi_camelcase import CamelModel
class DeclaredTypes(str, Enum):
general = "General"
discord = "Discord"
gotify = "Gotify"
pushover = "Pushover"
home_assistant = "Home Assistant"
class EventNotificationOut(CamelModel):
id: Optional[int]
name: str = ""
type: DeclaredTypes = DeclaredTypes.general
general: bool = True
recipe: bool = True
backup: bool = True
scheduled: bool = True
migration: bool = True
group: bool = True
user: bool = True
class Config:
orm_mode = True
class EventNotificationIn(EventNotificationOut):
notification_url: str = ""
class Config:
orm_mode = True
class Discord(CamelModel):
webhook_id: str
webhook_token: str
@property
def create_url(self) -> str:
return f"discord://{self.webhook_id}/{self.webhook_token}/"
class GotifyPriority(str, Enum):
low = "low"
moderate = "moderate"
normal = "normal"
high = "high"
class Gotify(CamelModel):
hostname: str
token: str
priority: GotifyPriority = GotifyPriority.normal
@property
def create_url(self) -> str:
return f"gotifys://{self.hostname}/{self.token}/?priority={self.priority}"

View File

@ -30,3 +30,8 @@ class Event(CamelModel):
class EventsOut(CamelModel): class EventsOut(CamelModel):
total: int total: int
events: list[Event] events: list[Event]
class TestEvent(CamelModel):
id: Optional[int]
test_url: Optional[str]

View File

@ -1,23 +1,60 @@
import apprise
from mealie.db.database import db from mealie.db.database import db
from mealie.db.db_setup import create_session from mealie.db.db_setup import create_session
from mealie.schema.events import Event, EventCategory from mealie.schema.events import Event, EventCategory
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
def save_event(title, text, category, session: Session): def test_notification(notification_url, event=None) -> bool:
if event is None:
event = Event(
title="Test Notification",
text="This is a test message from the Mealie API server",
category=EventCategory.general.value,
)
post_notifications(event, [notification_url], hard_fail=True)
def post_notifications(event: Event, notification_urls=list[str], hard_fail=False, attachment=None):
asset = apprise.AppriseAsset(async_mode=False)
apobj = apprise.Apprise(asset=asset)
for dest in notification_urls:
status = apobj.add(dest)
if not status and hard_fail:
raise Exception("Apprise URL Add Failed")
print(attachment)
apobj.notify(
body=event.text,
title=event.title,
attach=str(attachment),
)
def save_event(title, text, category, session: Session, attachment=None):
event = Event(title=title, text=text, category=category) event = Event(title=title, text=text, category=category)
session = session or create_session() session = session or create_session()
db.events.create(session, event.dict()) db.events.create(session, event.dict())
notification_objects = db.event_notifications.get(session=session, match_value=True, match_key=category, limit=9999)
notification_urls = [x.notification_url for x in notification_objects]
post_notifications(event, notification_urls, attachment=attachment)
def create_general_event(title, text, session=None): def create_general_event(title, text, session=None):
category = EventCategory.general category = EventCategory.general
save_event(title=title, text=text, category=category, session=session) save_event(title=title, text=text, category=category, session=session)
def create_recipe_event(title, text, session=None): def create_recipe_event(title, text, session=None, attachment=None):
category = EventCategory.recipe category = EventCategory.recipe
save_event(title=title, text=text, category=category, session=session)
save_event(title=title, text=text, category=category, session=session, attachment=attachment)
def create_backup_event(title, text, session=None): def create_backup_event(title, text, session=None):

95
poetry.lock generated
View File

@ -22,6 +22,23 @@ category = "main"
optional = false optional = false
python-versions = "*" python-versions = "*"
[[package]]
name = "apprise"
version = "0.9.2"
description = "Push Notifications that work with just about every platform!"
category = "main"
optional = false
python-versions = ">=2.7"
[package.dependencies]
click = ">=5.0"
cryptography = "*"
markdown = "*"
PyYAML = "*"
requests = "*"
requests-oauthlib = "*"
six = "*"
[[package]] [[package]]
name = "apscheduler" name = "apscheduler"
version = "3.7.0" version = "3.7.0"
@ -189,6 +206,25 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
[package.extras] [package.extras]
toml = ["toml"] toml = ["toml"]
[[package]]
name = "cryptography"
version = "3.4.7"
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
cffi = ">=1.12"
[package.extras]
docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1)", "sphinx-rtd-theme"]
docstest = ["doc8", "pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"]
pep8test = ["black", "flake8", "flake8-import-order", "pep8-naming"]
sdist = ["setuptools-rust (>=0.11.4)"]
ssh = ["bcrypt (>=3.1.5)"]
test = ["pytest (>=6.0)", "pytest-cov", "pytest-subtests", "pytest-xdist", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,!=3.79.2)"]
[[package]] [[package]]
name = "decorator" name = "decorator"
version = "5.0.7" version = "5.0.7"
@ -464,7 +500,7 @@ source = ["Cython (>=0.29.7)"]
name = "markdown" name = "markdown"
version = "3.3.4" version = "3.3.4"
description = "Python implementation of Markdown." description = "Python implementation of Markdown."
category = "dev" category = "main"
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6"
@ -573,6 +609,19 @@ plot = ["matplotlib"]
tgrep = ["pyparsing"] tgrep = ["pyparsing"]
twitter = ["twython"] twitter = ["twython"]
[[package]]
name = "oauthlib"
version = "3.1.0"
description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic"
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[package.extras]
rsa = ["cryptography"]
signals = ["blinker"]
signedtoken = ["cryptography", "pyjwt (>=1.0.0)"]
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "20.9" version = "20.9"
@ -916,6 +965,21 @@ urllib3 = ">=1.21.1,<1.27"
security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"]
socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"]
[[package]]
name = "requests-oauthlib"
version = "1.3.0"
description = "OAuthlib authentication support for Requests."
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[package.dependencies]
oauthlib = ">=3.0.0"
requests = ">=2.0.0"
[package.extras]
rsa = ["oauthlib[signedtoken] (>=3.0.0)"]
[[package]] [[package]]
name = "rsa" name = "rsa"
version = "4.7.2" version = "4.7.2"
@ -1172,7 +1236,7 @@ python-versions = "*"
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = "^3.9" python-versions = "^3.9"
content-hash = "bfdb4d3d5d69e53f16b315f993b712a703058d3f59e24644681ccc9062cf5143" content-hash = "73bac73c62e64c90a29816dde9ef1d896e8ca0b4271e67cde6ca8cc56bd87efd"
[metadata.files] [metadata.files]
aiofiles = [ aiofiles = [
@ -1187,6 +1251,10 @@ appdirs = [
{file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"},
{file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"},
] ]
apprise = [
{file = "apprise-0.9.2-py2.py3-none-any.whl", hash = "sha256:e3f34fcf7cd717704f04b194f6fd62719e563b7d338415467e0cd0fbd7fc5e61"},
{file = "apprise-0.9.2.tar.gz", hash = "sha256:263bd6ed81cb33f3d24c9353506921129890a654965fbe9014f7f562bf2ba9f9"},
]
apscheduler = [ apscheduler = [
{file = "APScheduler-3.7.0-py2.py3-none-any.whl", hash = "sha256:c06cc796d5bb9eb3c4f77727f6223476eb67749e7eea074d1587550702a7fbe3"}, {file = "APScheduler-3.7.0-py2.py3-none-any.whl", hash = "sha256:c06cc796d5bb9eb3c4f77727f6223476eb67749e7eea074d1587550702a7fbe3"},
{file = "APScheduler-3.7.0.tar.gz", hash = "sha256:1cab7f2521e107d07127b042155b632b7a1cd5e02c34be5a28ff62f77c900c6a"}, {file = "APScheduler-3.7.0.tar.gz", hash = "sha256:1cab7f2521e107d07127b042155b632b7a1cd5e02c34be5a28ff62f77c900c6a"},
@ -1329,6 +1397,20 @@ coverage = [
{file = "coverage-5.5-pp37-none-any.whl", hash = "sha256:2a3859cb82dcbda1cfd3e6f71c27081d18aa251d20a17d87d26d4cd216fb0af4"}, {file = "coverage-5.5-pp37-none-any.whl", hash = "sha256:2a3859cb82dcbda1cfd3e6f71c27081d18aa251d20a17d87d26d4cd216fb0af4"},
{file = "coverage-5.5.tar.gz", hash = "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c"}, {file = "coverage-5.5.tar.gz", hash = "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c"},
] ]
cryptography = [
{file = "cryptography-3.4.7-cp36-abi3-macosx_10_10_x86_64.whl", hash = "sha256:3d8427734c781ea5f1b41d6589c293089704d4759e34597dce91014ac125aad1"},
{file = "cryptography-3.4.7-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:8e56e16617872b0957d1c9742a3f94b43533447fd78321514abbe7db216aa250"},
{file = "cryptography-3.4.7-cp36-abi3-manylinux2010_x86_64.whl", hash = "sha256:37340614f8a5d2fb9aeea67fd159bfe4f5f4ed535b1090ce8ec428b2f15a11f2"},
{file = "cryptography-3.4.7-cp36-abi3-manylinux2014_aarch64.whl", hash = "sha256:240f5c21aef0b73f40bb9f78d2caff73186700bf1bc6b94285699aff98cc16c6"},
{file = "cryptography-3.4.7-cp36-abi3-manylinux2014_x86_64.whl", hash = "sha256:1e056c28420c072c5e3cb36e2b23ee55e260cb04eee08f702e0edfec3fb51959"},
{file = "cryptography-3.4.7-cp36-abi3-win32.whl", hash = "sha256:0f1212a66329c80d68aeeb39b8a16d54ef57071bf22ff4e521657b27372e327d"},
{file = "cryptography-3.4.7-cp36-abi3-win_amd64.whl", hash = "sha256:de4e5f7f68220d92b7637fc99847475b59154b7a1b3868fb7385337af54ac9ca"},
{file = "cryptography-3.4.7-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:26965837447f9c82f1855e0bc8bc4fb910240b6e0d16a664bb722df3b5b06873"},
{file = "cryptography-3.4.7-pp36-pypy36_pp73-manylinux2014_x86_64.whl", hash = "sha256:eb8cc2afe8b05acbd84a43905832ec78e7b3873fb124ca190f574dca7389a87d"},
{file = "cryptography-3.4.7-pp37-pypy37_pp73-manylinux2010_x86_64.whl", hash = "sha256:7ec5d3b029f5fa2b179325908b9cd93db28ab7b85bb6c1db56b10e0b54235177"},
{file = "cryptography-3.4.7-pp37-pypy37_pp73-manylinux2014_x86_64.whl", hash = "sha256:ee77aa129f481be46f8d92a1a7db57269a2f23052d5f2433b4621bb457081cc9"},
{file = "cryptography-3.4.7.tar.gz", hash = "sha256:3d10de8116d25649631977cb37da6cbdd2d6fa0e0281d014a5b7d337255ca713"},
]
decorator = [ decorator = [
{file = "decorator-5.0.7-py3-none-any.whl", hash = "sha256:945d84890bb20cc4a2f4a31fc4311c0c473af65ea318617f13a7257c9a58bc98"}, {file = "decorator-5.0.7-py3-none-any.whl", hash = "sha256:945d84890bb20cc4a2f4a31fc4311c0c473af65ea318617f13a7257c9a58bc98"},
{file = "decorator-5.0.7.tar.gz", hash = "sha256:6f201a6c4dac3d187352661f508b9364ec8091217442c9478f1f83c003a0f060"}, {file = "decorator-5.0.7.tar.gz", hash = "sha256:6f201a6c4dac3d187352661f508b9364ec8091217442c9478f1f83c003a0f060"},
@ -1617,6 +1699,10 @@ nltk = [
{file = "nltk-3.6.2-py3-none-any.whl", hash = "sha256:240e23ab1ab159ef9940777d30c7c72d7e76d91877099218a7585370c11f6b9e"}, {file = "nltk-3.6.2-py3-none-any.whl", hash = "sha256:240e23ab1ab159ef9940777d30c7c72d7e76d91877099218a7585370c11f6b9e"},
{file = "nltk-3.6.2.zip", hash = "sha256:57d556abed621ab9be225cc6d2df1edce17572efb67a3d754630c9f8381503eb"}, {file = "nltk-3.6.2.zip", hash = "sha256:57d556abed621ab9be225cc6d2df1edce17572efb67a3d754630c9f8381503eb"},
] ]
oauthlib = [
{file = "oauthlib-3.1.0-py2.py3-none-any.whl", hash = "sha256:df884cd6cbe20e32633f1db1072e9356f53638e4361bef4e8b03c9127c9328ea"},
{file = "oauthlib-3.1.0.tar.gz", hash = "sha256:bee41cc35fcca6e988463cacc3bcb8a96224f470ca547e697b604cc697b2f889"},
]
packaging = [ packaging = [
{file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"}, {file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"},
{file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"}, {file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"},
@ -1854,6 +1940,11 @@ requests = [
{file = "requests-2.25.1-py2.py3-none-any.whl", hash = "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"}, {file = "requests-2.25.1-py2.py3-none-any.whl", hash = "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"},
{file = "requests-2.25.1.tar.gz", hash = "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804"}, {file = "requests-2.25.1.tar.gz", hash = "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804"},
] ]
requests-oauthlib = [
{file = "requests-oauthlib-1.3.0.tar.gz", hash = "sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a"},
{file = "requests_oauthlib-1.3.0-py2.py3-none-any.whl", hash = "sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d"},
{file = "requests_oauthlib-1.3.0-py3.7.egg", hash = "sha256:fa6c47b933f01060936d87ae9327fead68768b69c6c9ea2109c48be30f2d4dbc"},
]
rsa = [ rsa = [
{file = "rsa-4.7.2-py3-none-any.whl", hash = "sha256:78f9a9bf4e7be0c5ded4583326e7461e3a3c5aae24073648b4bdfa797d78c9d2"}, {file = "rsa-4.7.2-py3-none-any.whl", hash = "sha256:78f9a9bf4e7be0c5ded4583326e7461e3a3c5aae24073648b4bdfa797d78c9d2"},
{file = "rsa-4.7.2.tar.gz", hash = "sha256:9d689e6ca1b3038bc82bf8d23e944b6b6037bc02301a574935b2dd946e0353b9"}, {file = "rsa-4.7.2.tar.gz", hash = "sha256:9d689e6ca1b3038bc82bf8d23e944b6b6037bc02301a574935b2dd946e0353b9"},

View File

@ -32,6 +32,7 @@ passlib = "^1.7.4"
lxml = "4.6.2" lxml = "4.6.2"
Pillow = "^8.2.0" Pillow = "^8.2.0"
pathvalidate = "^2.4.1" pathvalidate = "^2.4.1"
apprise = "^0.9.2"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]