Compare commits

...

351 Commits

Author SHA1 Message Date
shibao d896257602 actually fix bar graph
continuous-integration/drone/push Build is passing Details
2024-03-19 00:28:30 -04:00
shibao 4ca51a3f53 update dependencies
continuous-integration/drone/tag Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-03-19 00:01:27 -04:00
shibao 96b05e8332 Make bar graph ignore gaps 2024-03-19 00:00:51 -04:00
shibao 557a2cac3d Improve login page autocomplete behavior
continuous-integration/drone/push Build is passing Details
2024-03-18 23:39:06 -04:00
shibao e16e04c114 combine imports
continuous-integration/drone/push Build is passing Details
2024-03-18 23:26:41 -04:00
shibao bbe4d82303 Use bar graph instead of line graph 2024-03-18 23:26:32 -04:00
shibao c69d7843ab fix layout issues
continuous-integration/drone/push Build is passing Details
2024-02-23 23:34:04 -05:00
shibao c18f59050c fix missing ssl and crypto packages
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
2024-02-23 21:57:25 -05:00
shibao 67d688fc1e create italian gettext
continuous-integration/drone/push Build is failing Details
continuous-integration/drone/tag Build is failing Details
2024-02-23 21:22:51 -05:00
Weblate 28e5fa56c3 Added translation using Weblate (Italian) 2024-02-23 21:21:42 -05:00
Weblate e301d3dd17 Added translation using Weblate (Italian) 2024-02-23 21:21:42 -05:00
Weblate 4881cf6edb Added translation using Weblate (Italian) 2024-02-23 21:21:42 -05:00
Weblate 6b61c45889 Added translation using Weblate (Italian) 2024-02-23 21:21:42 -05:00
Weblate 4a674a0504 Added translation using Weblate (Italian) 2024-02-23 21:21:42 -05:00
shibao 7e6959fb3b Added translation using Weblate (Italian) 2024-02-23 21:21:42 -05:00
shibao 22f13b0c57 make ammo packs in containers directly navigable in table view 2024-02-23 21:20:32 -05:00
shibao 31024dcc0d fix test mode warning 2024-02-23 21:16:46 -05:00
shibao e843014502 fix credo check for is_admin? 2024-02-23 21:13:34 -05:00
shibao 5d146ce6af fix credo check for is_already_admin? 2024-02-23 21:13:23 -05:00
shibao 27cda3733e update elixir deps 2024-02-23 21:12:11 -05:00
shibao 1965ecba32 bump version 2024-02-23 21:04:25 -05:00
shibao 69e40c6d18 update elixir deps 2024-02-23 21:04:25 -05:00
shibao 34b4b24e67 update npm deps 2024-02-23 21:04:25 -05:00
shibao 7ebed8d4c0 update tool versions 2024-02-23 21:04:25 -05:00
shibao b5619b8606 run mix format
continuous-integration/drone/push Build is passing Details
2023-09-07 19:15:07 -04:00
shibao ef28de53a1 update hex dependencies 2023-09-07 19:11:57 -04:00
shibao fcd5dbc605 bump version
continuous-integration/drone/push Build is failing Details
2023-09-07 19:07:56 -04:00
shibao 7738e68292 run npm audit fix --force 2023-09-07 19:06:56 -04:00
shibao df645a6188 update node packages 2023-09-07 19:06:21 -04:00
shibao bed4fbaf54 update dependencies 2023-09-07 19:05:35 -04:00
shibao f94ef0a2ca fix range page title
continuous-integration/drone/push Build is passing Details
2023-06-05 23:36:25 -04:00
shibao 7cdb8af690 update dependencies 2023-06-05 23:32:52 -04:00
shibao 52c4ab45d5 fix class filter helper functions
continuous-integration/drone/push Build is passing Details
2023-06-05 23:17:43 -04:00
shibao a35f43d6df rename Ammo.get_average_cost and Ammo.get_historical_count 2023-06-05 23:17:43 -04:00
shibao 9edeb1e803 improve Ammo.get_grouped_round_count 2023-06-05 23:17:43 -04:00
shibao 7e55446b3e improve ActivityLog.list_shot_records 2023-06-05 23:17:43 -04:00
shibao 9643e9f46d improve Ammo.get_round_count 2023-06-05 23:17:39 -04:00
shibao 8466fcd1f9 improve ActivityLog.get_grouped_used_counts 2023-06-05 23:16:47 -04:00
shibao e713a2e108 improve ActivityLog.get_used_count 2023-06-05 23:16:00 -04:00
shibao a8fa321040 use sr for shot record in sql 2023-06-05 23:16:00 -04:00
shibao f0536f3030 improve Ammo.get_grouped_packs_count 2023-06-05 23:15:57 -04:00
shibao a94d2eebf4 improve Ammo.get_packs_count 2023-06-05 23:06:28 -04:00
shibao cfc56519f5 fix user registration controller 2023-06-04 00:07:31 -04:00
shibao e80c2018be improve Ammo.list_packs 2023-06-04 00:00:51 -04:00
shibao 71fdd42d96 improve Ammo.list_types 2023-06-03 20:14:20 -04:00
shibao 8e99a57994 improve Containers.list_containers 2023-06-03 20:12:06 -04:00
shibao 7c42dd8a3a improve Containers.list_tags 2023-06-03 19:54:51 -04:00
shibao 79c97d7502 fix error/404 pages not rendering properly 2023-05-12 22:59:53 -04:00
shibao 2e488fa26c fix ammo type sql naming issues 2023-05-12 22:22:46 -04:00
shibao 2179bd5d86 fix table component ids 2023-05-12 21:55:59 -04:00
shibao 49628cb9bb pattern match on user struct in more cases 2023-05-12 21:48:19 -04:00
shibao 8a58d53dc1 fix pack sql naming issues 2023-05-12 21:48:04 -04:00
shibao 9306d0f970 disable arm builds
continuous-integration/drone/push Build is passing Details
2023-04-16 21:35:37 -04:00
shibao 763c86a379 build in arm64 and amd64
continuous-integration/drone/push Build is running Details
2023-04-16 17:05:05 -04:00
shibao b85b1735c0 remove maintain attrs
continuous-integration/drone/push Build is passing Details
2023-04-16 01:10:45 -04:00
shibao ab1a288928 change invite path
continuous-integration/drone/push Build is passing Details
2023-04-16 00:46:49 -04:00
shibao e6ef0a8c68 improve tests
continuous-integration/drone/push Build is passing Details
2023-04-15 21:47:50 -04:00
shibao beeaf521c5 update npm dependencies
continuous-integration/drone/push Build is failing Details
continuous-integration/drone/tag Build was killed Details
2023-04-14 23:56:22 -04:00
shibao 8cb6068b85 make tests async
continuous-integration/drone/push Build is failing Details
2023-04-14 23:48:50 -04:00
shibao 334d841d57 add pack lot number to search
continuous-integration/drone/push Build is failing Details
2023-04-14 23:38:28 -04:00
shibao 1037f37be2 upgrade to phoenix 1.7 2023-04-14 23:34:11 -04:00
shibao 1796fb822f fix logger errors 2023-04-14 18:25:06 -04:00
shibao 8ed64f9c87 update elixir dependencies 2023-04-14 18:20:53 -04:00
shibao dd4a9f7119 upgrade npm dependencies 2023-04-14 18:17:11 -04:00
shibao dbafaad500 update node and npm 2023-04-14 18:11:44 -04:00
shibao eb4ce07b5f update erlang 2023-04-14 18:10:59 -04:00
shibao 2b7550a954 update elixir 2023-04-14 18:09:39 -04:00
shibao abaccac9f0 use :rifle as default ammo type
continuous-integration/drone/push Build is passing Details
2023-04-10 20:13:56 -04:00
shibao 2b81978adb make bullet type rifle/pistol type-only field 2023-04-10 20:13:55 -04:00
shibao 17bfe1a987 remove duplicate chamber size column 2023-04-10 20:13:38 -04:00
shibao 81f21a02c4 export shotgun fields
continuous-integration/drone/push Build is passing Details
2023-03-31 22:34:48 -04:00
shibao 9a17d4ad24 add lot number to packs 2023-03-30 23:36:19 -04:00
shibao 9835fe3f5e bump version
continuous-integration/drone/push Build is passing Details
2023-03-30 22:32:53 -04:00
shibao 4dee8808f3 improve migrations 2023-03-30 22:24:29 -04:00
shibao 65c70ca398 fix name collisions 2023-03-30 22:23:54 -04:00
shibao 550f6a6420 add migration for ammo type table and column 2023-03-30 22:02:44 -04:00
shibao 88c3f15fc8 rename ammo type files to type 2023-03-30 22:02:36 -04:00
shibao c33f15603b rename ammo type to type 2023-03-30 22:02:36 -04:00
shibao 98775359af rename shot groups to shot records in database 2023-03-30 21:39:08 -04:00
shibao e0e7b25bc4 add more text replacements 2023-03-30 21:38:56 -04:00
shibao bdddf65685 remove unnecessary index churn 2023-03-30 21:38:09 -04:00
shibao 6f50702b11 rename shot group files to shot record 2023-03-30 20:44:41 -04:00
shibao 5f8d1a917f shot groups to shot records 2023-03-30 20:43:30 -04:00
shibao 32801828fa fix shot records table disappearing after selecting an empty ammo class
continuous-integration/drone/push Build is passing Details
2023-03-30 20:08:37 -04:00
shibao 6ed3312ea8 add db migrations for ammo group to pack and ammo type class 2023-03-30 20:08:26 -04:00
shibao b122253b9b improve tests 2023-03-30 20:08:20 -04:00
shibao a68a16bf06 fix ammo type table not displaying class 2023-03-30 20:08:14 -04:00
shibao 4b6d0952f8 rename ammo groups to packs everywhere 2023-03-30 20:08:11 -04:00
shibao 0544b58ab6 rename ammo group files to pack 2023-03-30 20:07:28 -04:00
shibao 6d26103784 rename ammo groups to packs 2023-03-30 20:07:16 -04:00
shibao 0cae7c2940 rename ammo_type type to class
continuous-integration/drone/push Build is passing Details
2023-03-28 23:08:40 -04:00
shibao 1e645b5bb8 generate fonts with correct filename
continuous-integration/drone/push Build is passing Details
2023-03-28 22:03:14 -04:00
shibao bab2b26c13 use atom keys in tests
continuous-integration/drone/push Build is passing Details
2023-03-28 21:57:29 -04:00
shibao 8c95536ffd add selectable ammo types
continuous-integration/drone/push Build is passing Details
2023-03-23 22:07:25 -04:00
shibao d9251c7e4c improve components 2023-03-23 00:21:26 -04:00
shibao fe4e4f4f17 add length limits to all items
continuous-integration/drone/push Build is passing Details
2023-03-19 23:52:25 -04:00
shibao e5e5449e8b improve modal accessibility
continuous-integration/drone/push Build is passing Details
2023-03-19 15:39:39 -04:00
shibao 355752598c show link to ammo pack in ammo pack table while viewing ammo type
continuous-integration/drone/push Build is passing Details
2023-03-19 15:09:44 -04:00
shibao 03f8a2e8a7 remove all n+1 queries for real this time 2023-03-19 15:05:09 -04:00
shibao 071eb1b3c9 fix some values not being sorted in tables properly 2023-03-19 14:31:53 -04:00
shibao 2987e4ff37 hide more ammo group table fields when not viewing historical information 2023-03-19 14:11:01 -04:00
shibao ca81924ebe fix ammo type table not displaying correct information 2023-03-19 14:07:23 -04:00
shibao 40e4f6fe0a remove :table path 2023-03-19 13:37:28 -04:00
shibao 213dcca973 fix duplicate entries showing up 2023-03-19 13:28:56 -04:00
shibao b32edd581d fix accessibility issues
continuous-integration/drone/push Build is passing Details
2023-03-19 12:35:26 -04:00
shibao 2e372ca2ab hide historical ammo type information until show_used is toggled 2023-03-19 11:43:13 -04:00
shibao fd0bac3bbf fix tables unable to sort on nil dates 2023-03-19 11:19:55 -04:00
shibao f83fbc5d99 add links to readme 2023-03-19 00:41:39 -04:00
shibao daab051026 remove unnecessary auth check on invite page 2023-03-19 00:23:59 -04:00
shibao 440dc5061b fix textareas resizing when typing in
continuous-integration/drone/push Build is passing Details
2023-03-18 22:14:04 -04:00
shibao c0d2c69144 run npm audit fix 2023-03-18 22:02:27 -04:00
shibao 7a7359fa66 run npx npm-check-updates -u 2023-03-18 22:01:07 -04:00
shibao 9e8fd00d65 add ncu as dev dependency 2023-03-18 21:56:21 -04:00
shibao f5f72b53e6 use hooks for datetime, remove alpinejs 2023-03-18 21:54:57 -04:00
shibao a54cf8b87d use strict context boundaries and remove all n+1 queries
continuous-integration/drone/push Build is passing Details
2023-03-18 21:06:50 -04:00
shibao 0b7146ba32 fix shot record error message 2023-03-18 01:05:09 -04:00
shibao c0441957b6 use .link helpers 2023-03-18 01:03:55 -04:00
shibao 7fa9933a9b use core components 2023-03-18 00:25:18 -04:00
shibao f4c7f22460 fix error message in ga locale
continuous-integration/drone/push Build is passing Details
2023-03-17 21:10:34 -04:00
shibao a01d97e360 use better domain for gettexts
continuous-integration/drone/push Build is passing Details
2023-03-17 21:00:24 -04:00
pehoulihan a53b352cf7 Translated using Weblate (Irish)
continuous-integration/drone/push Build is passing Details
Currently translated at 100.0% (14 of 14 strings)

Translation: cannery/emails
Translate-URL: https://weblate.bubbletea.dev/projects/cannery/emails/ga/
2023-03-18 00:52:59 +00:00
shibao ce07cc2569 use live navigation to update state
continuous-integration/drone/push Build is passing Details
2023-03-16 17:53:49 -04:00
shibao 3acecb9a93 remove extra @impl true
continuous-integration/drone/push Build is passing Details
2023-03-16 17:41:25 -04:00
shibao ab8561fcf0 use component macros for live_helper components
continuous-integration/drone/push Build is passing Details
2023-03-15 00:48:07 -04:00
shibao 8163b906a2 remove data-qa 2023-03-15 00:47:15 -04:00
shibao b29a5cce7b bump version
continuous-integration/drone/push Build is passing Details
2023-03-10 16:58:49 -05:00
shibao 9205a04ac5 tweak invite and settings pages
continuous-integration/drone/push Build is passing Details
2023-02-26 18:21:14 -05:00
shibao 632a9e1379 remove extraneous aliases
continuous-integration/drone/push Build is passing Details
2023-02-26 14:22:54 -05:00
shibao 92cc49630d merge base project into cannery
continuous-integration/drone/push Build is passing Details
2023-02-26 00:40:55 -05:00
shibao a778f5a61f rename to cannery 2023-02-25 16:02:08 -05:00
shibao 07ff796553 fix more gettexts
continuous-integration/drone/push Build is passing Details
2023-02-25 00:59:05 -05:00
shibao 07b31fcc86 remove doctests 2023-02-25 00:59:05 -05:00
shibao bc034c0361 remove doctests 2023-02-25 00:58:33 -05:00
shibao eb9280fa7e fix missing gettexts 2023-02-25 00:39:02 -05:00
shibao ad1e44fd42 improve user settings 2023-02-14 01:06:50 -05:00
shibao 4e9f66f006 bump version 2023-02-14 01:06:00 -05:00
shibao 81350f9898 improve user settings page 2023-02-14 00:06:43 -05:00
shibao 9c4a32896f improve logger
continuous-integration/drone/push Build is passing Details
2023-02-07 00:18:16 -05:00
shibao 56dae6cdfe improve logger 2023-02-07 00:14:20 -05:00
shibao 8ef3bd65a3 remove search from topbar 2023-02-07 00:06:25 -05:00
shibao c8cadd6246 remove phoenix logo 2023-02-07 00:03:10 -05:00
shibao 3f143262d4 improve ci
continuous-integration/drone/push Build is passing Details
2023-02-05 10:37:05 -05:00
shibao ebe09bcf84 improve ci 2023-02-05 10:24:47 -05:00
shibao 42e2d1c76e fix tests
continuous-integration/drone/push Build is passing Details
2023-02-05 00:51:51 -05:00
shibao 064d2d3988 improve oban logging 2023-02-05 00:51:51 -05:00
shibao fd0b2c455a fix tests 2023-02-04 21:50:43 -05:00
shibao f1139d0ec4 improve oban logging 2023-02-04 20:47:47 -05:00
shibao f6b5fc17fa fix padding on chrome 2023-02-04 20:41:29 -05:00
shibao cd6bb6fbc3 fix padding on chrome 2023-02-04 20:40:46 -05:00
shibao 2c0a4dd7ca improve invites, record usage 2023-02-04 17:33:26 -05:00
shibao 5c05f3b6fe use credo style 2023-02-04 17:32:40 -05:00
shibao 30d3f76fe1 record invites 2023-02-04 16:11:58 -05:00
shibao ed8c20e967 improve invites 2023-02-04 15:05:00 -05:00
shibao 47dab6490d improve templates 2023-02-04 13:01:50 -05:00
shibao 7b60938a75 improve templates 2023-02-04 13:01:11 -05:00
shibao f19d024d8a fix bug with public registration being disabled 2023-02-04 11:35:32 -05:00
shibao 084173909e fix bug with public registration 2023-02-04 10:27:57 -05:00
shibao 1fbed50b0f add crypto to extra applications 2023-02-01 22:39:38 -05:00
shibao 2f8af8ae4f add crypto to extra applications 2023-02-01 22:39:16 -05:00
shibao 737484c36e add accounts doctests 2023-01-29 15:24:14 -05:00
shibao 2cf705c46f add accounts doctests 2023-01-29 15:23:54 -05:00
shibao 725df05521 fix aliases
continuous-integration/drone/push Build is passing Details
2023-01-29 13:05:01 -05:00
shibao 3ae890c193 use atoms for role changeset 2023-01-29 12:57:07 -05:00
shibao 6dbadc58ae fix runtime config 2023-01-26 19:55:59 -05:00
shibao 75fcbb1e65 fix runtime config
continuous-integration/drone/push Build is passing Details
2023-01-26 19:48:58 -05:00
shibao e568a2f073 fix credo
continuous-integration/drone/push Build is passing Details
2023-01-26 18:11:56 -05:00
shibao 0b27c8f80b add qr code for invite link
continuous-integration/drone/push Build is failing Details
2023-01-26 00:46:25 -05:00
shibao f155a43ee8 add qr code for invite link 2023-01-26 00:46:21 -05:00
shibao 0ad1ee47de improve formatting 2023-01-26 00:40:48 -05:00
shibao 8ea2b06487 use list for classes 2023-01-26 00:39:53 -05:00
shibao bbaa1dfd6b update to liveview 0.18 2023-01-26 00:31:13 -05:00
shibao 2c2b9fefc9 use list for classes 2023-01-25 23:13:18 -05:00
shibao bafc824a32 add subpixel antialiasing 2023-01-25 22:15:18 -05:00
shibao 8c2f7e0509 fix padding at bottom of page in chrome 2023-01-25 22:14:17 -05:00
shibao 1e4accec9d fix padding at bottom of page in chrome 2023-01-25 22:14:04 -05:00
shibao 076d5eea18 add styles to background element 2023-01-25 22:07:33 -05:00
shibao 5b6bd00047 improve locale 2023-01-25 21:52:30 -05:00
shibao 22abc7a8d0 improve locale 2023-01-25 21:50:14 -05:00
shibao 5a685ac00e use topbar through npm 2023-01-25 21:18:00 -05:00
shibao 8dd471afa8 use topbar through npm 2023-01-25 21:17:45 -05:00
shibao a5c12b3e17 fix webpack 2023-01-25 21:14:55 -05:00
shibao 27af5acf8b minimize disconnection modal 2023-01-25 21:00:34 -05:00
shibao 469428c007 add topbar.js on form submit and page navigate 2023-01-25 20:31:36 -05:00
shibao 09d3754f92 remove name
continuous-integration/drone/push Build is passing Details
2023-01-23 00:05:15 -05:00
shibao fa67fd5a3b add comment
continuous-integration/drone/push Build is passing Details
2023-01-22 23:47:28 -05:00
shibao 0a56b32b17 make loading/reconnection less intrusive
continuous-integration/drone/push Build is passing Details
2023-01-22 23:39:54 -05:00
shibao 1cd28e43b8 update elixir and npm versions 2023-01-22 23:20:02 -05:00
shibao 5f214ab7f9 show topbar in more situations 2023-01-22 23:16:36 -05:00
shibao 0c95fa88f4 fix translations
continuous-integration/drone/push Build is passing Details
2023-01-22 22:50:57 -05:00
shibao 61f8776d2e update deps 2023-01-22 22:50:47 -05:00
shibao 695002c9d9 don't override core aliases 2023-01-22 22:41:42 -05:00
shibao dde60d71d1 update gettext 2023-01-22 22:37:49 -05:00
shibao 91794ddc55 upgrade dependencies 2023-01-22 22:36:20 -05:00
shibao 1e3cec95fe update drone config 2023-01-07 21:00:15 -05:00
shibao f0a8c515f9 update deps 2023-01-07 21:00:15 -05:00
shibao e99775eef2 fix gettext merge command 2023-01-07 21:00:15 -05:00
shibao 6760f83ca0 add gettext changes 2023-01-07 21:00:15 -05:00
shibao 10877bb754 rename page path to home path 2023-01-07 21:00:15 -05:00
shibao 38a581b639 add registered on date to user card 2023-01-07 21:00:15 -05:00
shibao 9408705430 update deps 2023-01-07 21:00:14 -05:00
shibao 302aa7eeda add locale to user settings 2023-01-07 21:00:14 -05:00
shibao fd4fdcc36b add table component 2023-01-07 21:00:14 -05:00
shibao 3cb723b9e4 update build settings 2023-01-07 21:00:14 -05:00
shibao 1f92c452d1 fix tests 2023-01-07 21:00:14 -05:00
shibao c10cff63ea add invites route 2023-01-07 21:00:14 -05:00
shibao 70faed71d0 style disconnection and loading screens 2023-01-07 21:00:14 -05:00
shibao b5c46c09ec fix oban 2023-01-07 21:00:14 -05:00
shibao 7745765fc0 update npm 2023-01-07 21:00:14 -05:00
shibao e16fbba810 gettext page_live 2023-01-07 21:00:14 -05:00
shibao e35bdf101b update components 2023-01-07 21:00:14 -05:00
shibao aa314e5ca1 update main routes 2023-01-07 21:00:14 -05:00
shibao 616de3c117 update tests 2023-01-07 21:00:14 -05:00
shibao 74bcec6cfe update controllers and auth 2023-01-07 21:00:14 -05:00
shibao 41090c46d0 add invites 2023-01-07 21:00:14 -05:00
shibao c3f5744ad6 fix layout 2023-01-07 21:00:14 -05:00
shibao 95a339fe02 add random script 2023-01-07 21:00:14 -05:00
shibao 1e3b027367 fix docker files 2023-01-07 21:00:14 -05:00
shibao dd46e1795f add credo 2023-01-07 21:00:14 -05:00
shibao 9e517e6477 add emails 2023-01-07 21:00:14 -05:00
shibao 26f75c7d15 add brea
continuous-integration/drone/push Build is passing Details
2022-12-06 20:59:19 -05:00
shibao 23f7af2a93 fix libcrypto 1.1 issue
continuous-integration/drone/push Build is passing Details
2022-12-03 22:59:43 -05:00
shibao 8e771a2d9a add spanish
continuous-integration/drone/push Build is passing Details
2022-12-03 22:50:21 -05:00
shibao 16155687c9 update translations
continuous-integration/drone/push Build was killed Details
2022-12-03 22:14:35 -05:00
shibao ef0d644ee3 improve more card styles 2022-12-03 22:09:32 -05:00
shibao faacf80b51 improve container card style 2022-12-03 21:56:14 -05:00
shibao 7a7754767f improve tag card style 2022-12-03 21:54:41 -05:00
shibao 0894293620 improve code in table components 2022-12-03 21:41:54 -05:00
shibao 58b32817ba use shot group table component instead 2022-12-03 21:40:05 -05:00
shibao 45da547f62 add search to range index 2022-12-03 21:27:39 -05:00
shibao 3ea4b77b67 add search to tag index 2022-12-03 20:35:54 -05:00
shibao d743336868 fixup! add search to container index 2022-12-03 20:26:22 -05:00
shibao c99fbabcaa use container table component instead 2022-12-03 20:23:15 -05:00
shibao 95642061db add search to container index 2022-12-03 20:23:12 -05:00
shibao 11ef53d1bf add search to ammo groups index 2022-12-03 19:44:57 -05:00
shibao 757eca47f7 use ammo type table component instead 2022-12-03 19:35:08 -05:00
shibao 7191fe8e4b add search to catalog index 2022-12-03 19:35:00 -05:00
Brea Foga 02253c74ac Translated using Weblate (Spanish)
continuous-integration/drone/push Build is passing Details
Currently translated at 100.0% (34 of 34 strings)

Translation: cannery/errors
Translate-URL: https://weblate.bubbletea.dev/projects/cannery/errors/es/
2022-12-01 19:21:30 +00:00
Brea Foga fa32b2a073 Translated using Weblate (Spanish)
Currently translated at 97.4% (193 of 198 strings)

Translation: cannery/default
Translate-URL: https://weblate.bubbletea.dev/projects/cannery/default/es/
2022-12-01 19:21:30 +00:00
Brea Foga aa0ea10fa4 Translated using Weblate (Spanish)
continuous-integration/drone/push Build is passing Details
Currently translated at 100.0% (42 of 42 strings)

Translation: cannery/actions
Translate-URL: https://weblate.bubbletea.dev/projects/cannery/actions/es/
2022-11-30 19:19:48 +00:00
Brea Foga 72ac611e15 Translated using Weblate (Spanish)
Currently translated at 100.0% (47 of 47 strings)

Translation: cannery/prompts
Translate-URL: https://weblate.bubbletea.dev/projects/cannery/prompts/es/
2022-11-30 19:19:48 +00:00
Hannah Winter ff0af84d5d Translated using Weblate (Spanish)
Currently translated at 100.0% (15 of 15 strings)

Translation: cannery/emails
Translate-URL: https://weblate.bubbletea.dev/projects/cannery/emails/es/
2022-11-30 19:19:48 +00:00
Brea Foga 851d0954ad Translated using Weblate (Spanish)
Currently translated at 100.0% (15 of 15 strings)

Translation: cannery/emails
Translate-URL: https://weblate.bubbletea.dev/projects/cannery/emails/es/
2022-11-30 19:19:48 +00:00
Brea Foga ea4336adc5 Translated using Weblate (Spanish)
Currently translated at 100.0% (34 of 34 strings)

Translation: cannery/errors
Translate-URL: https://weblate.bubbletea.dev/projects/cannery/errors/es/
2022-11-30 19:19:48 +00:00
Hannah Winter fcce6a6f40 Translated using Weblate (Spanish)
Currently translated at 100.0% (34 of 34 strings)

Translation: cannery/errors
Translate-URL: https://weblate.bubbletea.dev/projects/cannery/errors/es/
2022-11-30 19:19:48 +00:00
Brea Foga 4d3085da2a Translated using Weblate (Spanish)
Currently translated at 49.4% (98 of 198 strings)

Translation: cannery/default
Translate-URL: https://weblate.bubbletea.dev/projects/cannery/default/es/
2022-11-30 19:19:48 +00:00
shibao 2ec60ba342 fix admin user fixture 2022-11-23 21:14:29 -05:00
shibao e0ac714681 improve changesets and typespecs 2022-11-23 20:46:41 -05:00
shibao 20a2311229 bump to 0.7.1
continuous-integration/drone/push Build is passing Details
2022-11-19 15:24:20 -05:00
shibao a6ae8a872d update translations 2022-11-19 15:12:01 -05:00
shibao 81e448afc4 improve some wording 2022-11-19 15:11:34 -05:00
shibao dfebad713f replace ammo added on with purchased on 2022-11-19 15:10:15 -05:00
shibao bf0ea4168b fix bug with ammo group not updating after deleting shot group 2022-11-19 15:10:15 -05:00
shibao 5ffa627beb improve table route navigation 2022-11-19 15:10:15 -05:00
shibao 25d4f1916a display link to container in table 2022-11-19 15:10:15 -05:00
shibao fd583f2941 add ids to some cards 2022-11-19 15:10:15 -05:00
shibao 5cae646758 improve indexes 2022-11-19 15:10:15 -05:00
shibao d389515f76 be more specific on wording for show container 2022-11-19 15:10:15 -05:00
shibao c2568b6521 move toggle button to live_helpers 2022-11-19 15:10:15 -05:00
shibao 60462877c5 pull version number from config 2022-11-19 15:10:15 -05:00
shibao ba5a4e69b2 fix table alignment 2022-11-19 15:10:15 -05:00
Brea Foga ba36b4e4c5 Translated using Weblate (Spanish)
continuous-integration/drone/push Build is passing Details
Currently translated at 90.4% (38 of 42 strings)

Translation: cannery/actions
Translate-URL: https://weblate.bubbletea.dev/projects/cannery/actions/es/
2022-11-13 21:49:26 +00:00
Brea Foga f5a092e91f Translated using Weblate (Spanish)
Currently translated at 94.1% (32 of 34 strings)

Translation: cannery/errors
Translate-URL: https://weblate.bubbletea.dev/projects/cannery/errors/es/
2022-11-13 21:49:26 +00:00
shibao 09774e0aed bump to 0.7.0
continuous-integration/drone/push Build is passing Details
2022-11-12 14:38:40 -05:00
shibao 44d4d7c6f9 add CPR and original count to json export 2022-11-12 14:37:55 -05:00
shibao 1fed895b82 display cpr for ammo packs and add original count for ammo packs 2022-11-12 14:33:11 -05:00
shibao 3480aff61d make ammo type show page and container show page also display ammo groups as table 2022-11-12 13:53:23 -05:00
shibao 1ea4b99c81 fix whitespace when copying invite url 2022-11-11 21:36:57 -05:00
shibao 2191e7f4f4 fix chart to sum by day 2022-11-11 19:34:23 -05:00
shibao 8231683958 add shading to table component 2022-11-11 18:47:55 -05:00
shibao a3e7e3a86d disable broken qemu arm64 builds
continuous-integration/drone/push Build is passing Details
2022-11-10 22:49:25 -05:00
shibao 0c088ed06d add button to set shot group to zero
continuous-integration/drone/push Build is failing Details
2022-11-10 22:14:52 -05:00
shibao 1c7721887f fix moving ammo between containers 2022-11-10 22:14:39 -05:00
shibao 0dbd1af553 use better wording 2022-11-10 21:25:03 -05:00
shibao c828def2b2 add historical round and ammo pack data to ammo type index 2022-11-10 20:42:56 -05:00
shibao 7ef582510e improve ammo context 2022-11-10 20:26:11 -05:00
shibao 726ede7e46 don't run regular mix format in mix test 2022-11-10 19:27:31 -05:00
shibao 8fa75e2559 add cloning to ammo type index 2022-11-10 19:26:14 -05:00
shibao 0d6f6de7df add cloning to containers index 2022-11-10 19:26:13 -05:00
shibao 76d3554b4b add container index table 2022-11-10 19:25:14 -05:00
shibao 09394ea408 add cloning to ammo group index 2022-11-10 19:10:47 -05:00
shibao 3dd6430105 fix patch links to working properly
continuous-integration/drone/push Build is passing Details
2022-11-09 23:47:11 -05:00
shibao 53bb0ddb03 add json export in settings
continuous-integration/drone/push Build is passing Details
2022-11-09 23:33:50 -05:00
shibao e3ff04ced3 rename mix test alias to mix test.all 2022-11-09 23:33:41 -05:00
shibao db4b3fab24 add attrs to ammo group card
continuous-integration/drone/push Build is passing Details
2022-11-09 21:29:50 -05:00
shibao d86f5a6d98 add attrs to container card 2022-11-09 21:29:45 -05:00
shibao 9420337228 add attrs to invite card 2022-11-09 21:29:30 -05:00
shibao e2a4dc4b92 add attrs to tag card 2022-11-09 21:15:28 -05:00
shibao 175eef95fb add attrs to user card 2022-11-09 21:13:24 -05:00
shibao ba8d7988b3 add graph to range page 2022-11-09 21:04:57 -05:00
shibao 1f017ced4a add inital_key and initial_sort_mode to table component 2022-11-09 21:03:46 -05:00
shibao 67304ae22e fix some gettexts 2022-11-09 18:47:13 -05:00
shibao 30da5bc4f7 update node 2022-11-09 18:12:59 -05:00
shibao 580d703f12 add test for container name in ammo type 2022-11-08 22:33:51 -05:00
shibao cb4de9a6ff add container live test for empty ammo groups
continuous-integration/drone/push Build is passing Details
2022-11-08 21:41:14 -05:00
shibao ca4b5b9822 add test to ammo group live for empty ammo groups 2022-11-08 21:41:14 -05:00
shibao 13bf8ab2b1 fix gettext for percentages 2022-11-08 21:41:14 -05:00
shibao ffd1029e78 add ammo type live test for empty ammo groups 2022-11-08 21:41:14 -05:00
shibao 1d8f3360d8 update translations
continuous-integration/drone/push Build is passing Details
2022-11-07 22:55:44 -05:00
shibao 6b565abf93 update to liveview 0.18 and use elixir 1.14
continuous-integration/drone/push Build is passing Details
2022-11-07 22:36:38 -05:00
shibao 239b15a782 update npm 2022-11-07 21:16:23 -05:00
shibao cfbec3189c display used-up date on used-up ammo
continuous-integration/drone/push Build is passing Details
2022-11-07 21:01:57 -05:00
shibao cc31958bbe add used filtering to ammo index page 2022-11-07 00:47:22 -05:00
shibao 9e386f1631 add used filtering to container show page 2022-11-07 00:40:09 -05:00
shibao 36a0a1c6c8 add used filtering to ammo type show page 2022-11-07 00:40:09 -05:00
shibao 2e0bb861f1 show container name in ammo type listing 2022-11-07 00:40:09 -05:00
shibao dbc575d305 improve container show page layout 2022-11-07 00:40:09 -05:00
shibao 5390dcc4c2 improve ammo type show page layout 2022-11-07 00:40:09 -05:00
shibao dc355fcd8e add ammo count to ammo type index page 2022-11-07 00:40:09 -05:00
shibao 44fbd69e0f make ammo group index more compact 2022-11-06 23:07:13 -05:00
shibao 10c4f40864 update spanish translation
continuous-integration/drone/push Build encountered an error Details
2022-11-06 20:00:23 -05:00
shibao d512ec8e10 remove arm/v6
continuous-integration/drone/push Build is passing Details
2022-07-30 15:23:18 -04:00
shibao 81b68ba4af use buildx for multi-arch docker images
continuous-integration/drone/push Build is failing Details
2022-07-30 15:11:47 -04:00
shibao b6e0a543ec mix format
continuous-integration/drone/push Build is passing Details
2022-07-05 01:13:38 -04:00
shibao cf75007e4d add logo as favicon
continuous-integration/drone/push Build is failing Details
2022-07-05 01:09:47 -04:00
shibao 1583ad3503 remove logo from readme lol
continuous-integration/drone/push Build is passing Details
2022-07-04 21:55:57 -04:00
shibao 34118299e9 add exdoc 2022-02-25 21:13:50 -05:00
shibao 97a9b6d51a prevent unconfirmed users from logging in 2022-02-16 22:16:19 -05:00
shibao 059004ba78 mix format 2022-02-16 22:15:47 -05:00
shibao 5d02ed6369 update deps 2022-02-16 22:15:16 -05:00
shibao ec6acdbb5d update swoosh 2022-02-15 19:12:35 -05:00
shibao 33d82a902d add exdoc configs 2022-02-15 18:57:06 -05:00
shibao fc5b03d680 enable live dashboard os monitoring 2022-02-15 18:57:01 -05:00
shibao c918dbe4bf improve changeset errors 2022-02-15 18:22:58 -05:00
shibao 23b60e032d add datetime helper 2022-02-15 02:50:36 -05:00
shibao 7283932d85 use topbar component 2022-02-13 21:28:20 -05:00
shibao 8ff1fd0276 add gettext step to docker file 2022-02-13 15:50:48 -05:00
shibao 4ef09f5279 heex templates 2022-02-12 23:26:29 -05:00
shibao 9734be4966 add oban 2022-02-12 23:24:11 -05:00
shibao 485965d9c9 npm audit 2022-02-12 23:23:09 -05:00
shibao e9cdb0f717 add dockerhub build and publish step 2022-02-12 02:38:29 -05:00
shibao 1c07449b54 add alpine.js 2022-02-12 02:37:10 -05:00
shibao b64e85f65c add drone ci 2022-01-23 00:27:43 -05:00
shibao 9387756109 mix format 2022-01-23 00:00:53 -05:00
shibao f1f3082368 add MaintainAttrs hook 2022-01-22 23:59:58 -05:00
shibao 50a8a79596 update readme and docker-compose.yml 2022-01-22 23:44:47 -05:00
shibao 67b48e1a3f format with standard js 2022-01-22 22:56:53 -05:00
shibao 67c30d7f88 add standard js 2022-01-22 22:56:38 -05:00
shibao 728728a5a4 fix tests 2022-01-22 20:44:38 -05:00
shibao a64d92a6cf add dialyzer and credo to tests 2022-01-22 20:44:34 -05:00
shibao 6227d64072 set generator settings 2022-01-22 20:13:53 -05:00
shibao 46eed25a94 only start automigrator in prod env 2022-01-22 20:12:32 -05:00
shibao 3674eeaf5a fix tests 2022-01-22 15:24:04 -05:00
shibao f0676a2433 add mix format to tests 2022-01-22 15:24:04 -05:00
shibao a72a4b0cbe add heex_formatter 2022-01-22 15:24:04 -05:00
shibao a2dea04668 update to 1.6 2022-01-22 14:25:37 -05:00
shibao 3dc255b7c2 add touchless docker deploys 2021-09-04 16:19:23 -04:00
shibao bde1cff7a4 add font awesome 2021-09-02 23:32:53 -04:00
shibao 66cc11e9eb add liveview helpers 2021-09-02 23:32:53 -04:00
shibao 81a250206e update npm 2021-09-02 23:32:52 -04:00
shibao 6d5f7f68df style registration pages 2021-09-02 23:32:52 -04:00
shibao 5b5f1ce1e5 run phx.new and add phx.gen.auth 2021-08-15 19:11:09 -04:00
270 changed files with 34227 additions and 25076 deletions

View File

@ -13,28 +13,37 @@ steps:
mount:
- _build
- deps
- assets/node_modules/
- .npm
- .mix
- name: test
image: elixir:1.13.4-alpine
image: elixir:1.16.1-alpine
environment:
TEST_DATABASE_URL: ecto://postgres:postgres@database/cannery_test
HOST: testing.example.tld
MIX_HOME: /drone/src/.mix
MIX_ARCHIVES: /drone/src/.mix/archives
MIX_ENV: test
commands:
- apk add --no-cache build-base npm git python3
- mix local.rebar --force
- mix local.hex --force
- apk add --no-cache build-base npm git
- mix local.rebar --force --if-missing
- mix local.hex --force --if-missing
- mix deps.get
- mix deps.compile
- npm --prefix ./assets ci --progress=false --no-audit --loglevel=error
- npm set cache .npm
- npm --prefix ./assets ci --no-audit --prefer-offline
- npm run --prefix ./assets deploy
- mix do phx.digest, gettext.extract
- mix test
- mix test.all
- name: build and publish stable
image: plugins/docker
image: thegeeklab/drone-docker-buildx
privileged: true
settings:
repo: shibaobun/cannery
purge: true
compress: true
platforms:
- linux/amd64
username:
from_secret: docker_username
password:
@ -45,9 +54,14 @@ steps:
- stable
- name: build and publish tagged version
image: plugins/docker
image: thegeeklab/drone-docker-buildx
privileged: true
settings:
repo: shibaobun/cannery
purge: true
compress: true
platforms:
- linux/amd64
username:
from_secret: docker_username
password:
@ -68,7 +82,8 @@ steps:
mount:
- _build
- deps
- assets/node_modules/
- .npm
- .mix
services:
- name: database

View File

@ -1,6 +1,6 @@
[
import_deps: [:ecto, :phoenix],
inputs: ["*.{heex,ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{heex,ex,exs}"],
import_deps: [:ecto, :ecto_sql, :phoenix],
inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"],
subdirectories: ["priv/*/migrations"],
plugins: [Phoenix.LiveView.HTMLFormatter]
]

2
.gitignore vendored
View File

@ -25,7 +25,7 @@ cannery-*.tar
# If NPM crashes, it generates a log, let's ignore it too.
npm-debug.log
# Ignore assets that are produced by build tools.
# The directory NPM downloads your dependencies sources to.
/assets/node_modules/
# Since we are building assets from assets/,

View File

@ -1,3 +1,3 @@
elixir 1.13.4-otp-24
erlang 24.2
nodejs 16.13.2
elixir 1.16.1-otp-26
erlang 26.2.2
nodejs 21.6.2

View File

@ -1,3 +1,152 @@
# v0.9.9
- Actually fix bar graph
# v0.9.8
- Make bar graph ignore empty days
- Update dependencies
# v0.9.7
- Fix margin on bottom of page
- Use bar graph instead of line graph
- Improve login page autocomplete behavior
# v0.9.6
- Make ammo packs in containers directly navigable in table view
- Update dependencies
# v0.9.5
- Update dependencies
# v0.9.4
- Code quality fixes
- Fix error/404 pages not rendering properly
- Update dependencies
- Fix Range page title
# v0.9.3
- Update dependencies
- Add pack lot number to search
- Improve tests
- Change invite path slightly
- Disable arm builds since ci fails to build
# v0.9.2
- Add lot number to packs
- Don't show price paid and lot number columns when displaying packs if not used
- Fix additional shotgun fields not being exportable
- Fixes duplicate chamber size column for ammo types
- Hide bullet type field when editing/creating shotgun ammo types
- Fix ammo type creation not displaying all the necessary fields on first load
# v0.9.1
- Rename ammo type's "type" to "class" to avoid confusion
- Rename "ammo type" to "type" to avoid confusion
- Fixes type search
- Fixes shot records table disappearing after selecting an empty ammo class
- Code quality improvements
# v0.9.0
- Add length limits to all string fields
- Add selectable ammo types
- Improve onboarding experience slightly
- Remove show used view from a container since it doesn't really make that much
sense
# v0.8.6
- Fix duplicate entries showing up
- Show ammo packs under a type in a table by default
- Only show historical ammo type information when displaying "Show used" in table
- Only show historical ammo pack information when displaying "Show used" in table
- Fix some values not being sorted in tables properly
- Code quality improvements
- Show link to ammo pack in ammo pack table while viewing ammo type
# v0.8.5
- Add link in readme to github mirror
- Fix tables unable to sort on empty dates
- Only show historical ammo type information when displaying "Show used"
- Fix even more accessibility issues
# v0.8.4
- Improve accessibility
- Code quality improvements
- Fix dead link of example bullet abbreviations
- Fix inaccurate error message when updating shot records
- Fix tables not sorting dates correctly
- Fix dates displaying incorrectly
- Fix container table not displaying all fields
- Fix textareas resizing when typing in them
# v0.8.3
- Improve some styles
- Improve server log
- Various minor improvements
# v0.8.2
- Fix bug with public registration
- Improve templates
- Improve invites, record usage
- Fix padding on more pages when using chrome
- Add oban metrics to server log and live dashboard
# v0.8.1
- Update dependencies
- Show topbar on form submit/page refresh
- Make loading/reconnection less intrusive
- Add QR code for invite link
# v0.8.0
- Add search to catalog, ammo, container, tag and range index pages
- Tweak urls for catalog, ammo, containers, tags and shot records
- Fix bug with shot record chart not drawing lines between days correctly
- Improve cards across app (make them line up with each other)
- Update translations and add spanish!!! (thank you Brea and Hannah!)
# v0.7.2
- Code improvements
# v0.7.1
- Fix table component alignment and styling
- Fix toggle button styling
- Miscellanous code improvements
- Improve container index table
- Fix bug with ammo not updating after deleting shot record
- Replace ammo "added on" with "purchased on"
- Miscellaneous wording improvements
- Update translations
# v0.7.0
- Add shading to table component
- Fix chart to sum by day
- Fix whitespace when copying invite url
- Make ammo type show page also display packs as table
- Make container show page also display packs as table
- Display CPR for ammo packs
- Add original count for ammo packs
- Add ammo pack CPR and original count to json export
# v0.6.0
- Update translations
- Display used-up date on used-up ammo
- Make ammo index page a bit more compact
- Make ammo index page filter used-up ammo
- Make ammo catalog page include ammo count
- Make ammo type show page a bit more compact
- Make ammo type show page include container names for each ammo
- Make ammo type show page filter used-up ammo
- Make container index page optionally display a table
- Make container show page a bit more compact
- Make container show page filter used-up ammo
- Forgot to add the logo as the favicon whoops
- Add graph to range page
- Add JSON export of data
- Add ammo cloning
- Add ammo type cloning
- Add container cloning
- Fix bug with moving ammo packs between containers
- Add button to set rounds left to 0 when creating a shot record
- Update project dependencies
# v0.5.4
- Rename "Ammo" tab to "Catalog", and "Manage" tab is now "Ammo"
- Ammo groups are now just referred to as Ammo or "Packs"
@ -47,8 +196,8 @@
# v0.3.0
- Fix ammo type counts not showing when count is 0
- Add prompt to create first container before first ammo group
- Edit and delete shot groups from ammo group show page
- Use today's date when adding new shot groups
- Edit and delete shot records from ammo group show page
- Use today's date when adding new shot records
- Create multiple ammo groups at one time
# v0.2.3

View File

@ -17,8 +17,8 @@ If you're multilingual, this project can use your translations! Visit
functions as short as possible while keeping variable names descriptive! For
instance, use inline `do:` blocks for short functions and make your aliases as
short as possible without introducing ambiguity.
- I.e. since there's only one `Changeset` in the app, please alias
`Changeset.t(Type.t())` instead of using `Ecto.Changeset.t(Long.Type.t())`
- I.e. since there's only one `Pack` in the app, please alias
`Pack.t()` instead of using `Cannery.Ammo.Pack.t()`
- Use pipelines when possible. If only calling a single method, a pipeline isn't
strictly necessary but still encouraged for future modification.
- Please add typespecs to your functions! Even your private functions may be
@ -63,7 +63,8 @@ And as always, thank you!
[`phx_gen_auth`](https://hexdocs.pm/phx_gen_auth/).
- `Dockerfile` and example `docker-compose.yml`
- Automatic migrations in `MIX_ENV=prod` or Docker image
- JS linting with [standard.js](https://standardjs.com)
- JS linting with [standard.js](https://standardjs.com), HEEx linting with
[heex_formatter](https://github.com/feliperenan/heex_formatter)
## Docs
@ -109,7 +110,7 @@ In `dev` mode, Cannery will listen for these environment variables at runtime.
- `POOL_SIZE`: Controls the pool size to use with PostgreSQL. Defaults to `10`.
- `REGISTRATION`: Controls if user sign-up should be invite only or set to public. Set to `public` to enable public registration. Defaults to `invite`.
- `LOCALE`: Sets a custom default locale. Defaults to `en_US`.
- Available options: `en_US`, `de`, and `fr`
- Available options: `en_US`, `de`, `fr`, and `es`
## `MIX_ENV=test`
@ -144,3 +145,4 @@ Thank you so much for your contributions!
- kaia (https://shitposter.club/users/kaia)
- duponin (https://udongein.xyz/users/duponin)
- kalli (https://twitter.com/t0kkuro)
- brea (https://refusal.biz/users/tarperfume)

View File

@ -1,4 +1,4 @@
FROM elixir:1.13.4-alpine AS build
FROM elixir:1.16.1-alpine AS build
# install build dependencies
RUN apk add --no-cache build-base npm git python3
@ -37,7 +37,7 @@ RUN mix do compile, release
FROM alpine:latest AS app
RUN apk upgrade --no-cache && \
apk add --no-cache bash openssl libgcc libstdc++ ncurses-libs
apk add --no-cache bash openssl libssl3 libcrypto3 libgcc libstdc++ ncurses-libs
WORKDIR /app

View File

@ -1,6 +1,5 @@
# Cannery
![logo](https://gitea.bubbletea.dev/shibao/cannery/raw/branch/stable/assets/static/images/cannery.png)
![old screenshot](https://gitea.bubbletea.dev/shibao/cannery/raw/branch/stable/home.png)
The self-hosted firearm tracker website.
@ -14,8 +13,8 @@ The self-hosted firearm tracker website.
# Features
- Create containers to store your ammunition, and tag them with custom tags
- Add ammunition types to Cannery, and then ammunition groups to your containers
- Stage groups of ammo for range day and record your ammo usage
- Add ammunition types to Cannery, and then ammo packs to your containers
- Stage ammo packs for range day and track your usage with shot records
- Invitations via invite tokens or public registration
# Installation
@ -65,7 +64,7 @@ You can use the following environment variables to configure Cannery in
- `REGISTRATION`: Controls if user sign-up should be invite only or set to
public. Set to `public` to enable public registration. Defaults to `invite`.
- `LOCALE`: Sets a custom default locale. Defaults to `en_US`
- Available options: `en_US`, `de`, and `fr`
- Available options: `en_US`, `de`, `fr` and `es`
- `SMTP_HOST`: The url for your SMTP email provider. Must be set
- `SMTP_PORT`: The port for your SMTP relay. Defaults to `587`.
- `SMTP_USERNAME`: The username for your SMTP relay. Must be set!
@ -93,6 +92,15 @@ Cannery is licensed under AGPLv3 or later. A copy of the latest version of the
license can be found at
[LICENSE.md](https://gitea.bubbletea.dev/shibao/cannery/src/branch/stable/LICENSE.md).
# Links
- [Gitea](https://gitea.bubbletea.dev/shibao/cannery): Main repo, feature
requests and bug reports
- [Github](https://github.com/shibaobun/cannery): Source code mirror, please
don't open pull requests to this repository
- [Weblate](https://weblate.bubbletea.dev/engage/cannery): Contribute to
translations!
---
[![Build

View File

@ -25,12 +25,13 @@ $fa-font-path: "@fortawesome/fontawesome-free/webfonts";
100% { scale: 1.0; opacity: 1; }
}
.phx-connected > #disconnect, #loading {
// disconnect toast
.phx-connected > #disconnect {
opacity: 0 !important;
pointer-events: none;
}
.phx-loading:not(.phx-error) > #loading, .phx-error > #disconnect {
.phx-error > #disconnect {
opacity: 0.95 !important;
}

View File

@ -25,7 +25,6 @@
}
.btn {
@apply inline-block break-words min-w-4;
@apply focus:outline-none px-4 py-2 rounded-lg;
@apply shadow-sm focus:shadow-lg;
@apply transition-all duration-300 ease-in-out;
@ -52,7 +51,6 @@
}
.link {
@apply inline-block break-all min-w-4;
@apply hover:underline;
@apply transition-colors duration-500 ease-in-out;
}

View File

@ -24,29 +24,23 @@ import 'phoenix_html'
// Establish Phoenix Socket and LiveView configuration.
import { Socket } from 'phoenix'
import { LiveSocket } from 'phoenix_live_view'
import topbar from '../vendor/topbar'
import MaintainAttrs from './maintain_attrs'
import Alpine from 'alpinejs'
import topbar from 'topbar'
import ShotLogChart from './shot_log_chart'
import Date from './date'
import DateTime from './datetime'
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute('content')
const liveSocket = new LiveSocket('/live', Socket, {
dom: {
onBeforeElUpdated (from, to) {
if (from._x_dataStack) { window.Alpine.clone(from, to) }
}
},
params: { _csrf_token: csrfToken },
hooks: { MaintainAttrs }
hooks: { Date, DateTime, ShotLogChart }
})
// alpine.js
window.Alpine = Alpine
Alpine.start()
// Show progress bar on live navigation and form submits
topbar.config({ barColors: { 0: '#29d' }, shadowColor: 'rgba(0, 0, 0, .3)' })
window.addEventListener('phx:page-loading-start', info => topbar.show())
window.addEventListener('phx:page-loading-stop', info => topbar.hide())
window.addEventListener('submit', info => topbar.show())
window.addEventListener('beforeunload', info => topbar.show())
// connect if there are any LiveViews on the page
liveSocket.connect()
@ -66,3 +60,8 @@ window.addEventListener('cannery:clipcopy', (event) => {
window.alert('Sorry, your browser does not support clipboard copy.')
}
})
// Set input value to 0
window.addEventListener('cannery:set-zero', (event) => {
event.target.value = 0
})

11
assets/js/date.js Normal file
View File

@ -0,0 +1,11 @@
export default {
displayDate (el) {
const date =
Intl.DateTimeFormat([], { timeZone: 'Etc/UTC', dateStyle: 'short' })
.format(new Date(el.dateTime))
el.innerText = date
},
mounted () { this.displayDate(this.el) },
updated () { this.displayDate(this.el) }
}

11
assets/js/datetime.js Normal file
View File

@ -0,0 +1,11 @@
export default {
displayDateTime (el) {
const date =
Intl.DateTimeFormat([], { dateStyle: 'short', timeStyle: 'long' })
.format(new Date(el.dateTime))
el.innerText = date
},
mounted () { this.displayDateTime(this.el) },
updated () { this.displayDateTime(this.el) }
}

View File

@ -1,11 +0,0 @@
// maintain user adjusted attributes, like textbox length on phoenix liveview
// update. https://github.com/phoenixframework/phoenix_live_view/issues/1011
export default {
attrs () {
const attrs = this.el.getAttribute('data-attrs')
if (attrs) { return attrs.split(', ') } else { return [] }
},
beforeUpdate () { this.prevAttrs = this.attrs().map(name => [name, this.el.getAttribute(name)]) },
updated () { this.prevAttrs.forEach(([name, val]) => this.el.setAttribute(name, val)) }
}

109
assets/js/shot_log_chart.js Normal file
View File

@ -0,0 +1,109 @@
import Chart from 'chart.js/auto'
import 'chartjs-adapter-date-fns'
export default {
initalizeChart (el) {
const data = JSON.parse(el.dataset.chartData)
this.el.chart = new Chart(el, {
type: 'bar',
data: {
datasets: [{
label: el.dataset.label,
data: data.map(({ date, count, label }) => ({
label,
x: date,
y: count
})),
backgroundColor: `${el.dataset.color}77`,
borderColor: el.dataset.color,
fill: true,
borderWidth: 3,
pointBorderWidth: 1
}]
},
options: {
elements: {
point: {
radius: 9,
hoverRadius: 12
}
},
plugins: {
legend: {
position: 'bottom',
labels: {
padding: 20
}
},
tooltip: {
displayColors: false,
callbacks: {
title: (contexts) => contexts.map(({ raw: { x } }) => Intl.DateTimeFormat([], { timeZone: 'Etc/UTC', dateStyle: 'short' }).format(new Date(x))),
label: ({ raw: { label } }) => label
}
}
},
scales: {
y: {
beginAtZero: true,
stacked: true,
grace: '15%',
ticks: {
padding: 15,
precision: 0
}
},
x: {
type: 'timeseries',
time: {
unit: 'day'
},
ticks: {
source: 'data'
}
}
},
transitions: {
show: {
animations: {
x: {
from: 0
}
}
},
hide: {
animations: {
x: {
to: 0
}
}
}
}
}
})
},
updateChart (el) {
const data = JSON.parse(el.dataset.chartData)
this.el.chart.data = {
datasets: [{
label: el.dataset.label,
data: data.map(({ date, count, label }) => ({
label,
x: date,
y: count
})),
backgroundColor: `${el.dataset.color}77`,
borderColor: el.dataset.color,
fill: true,
borderWidth: 3,
pointBorderWidth: 1
}]
}
this.el.chart.update()
},
mounted () { this.initalizeChart(this.el) },
updated () { this.updateChart(this.el) }
}

21758
assets/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -2,6 +2,10 @@
"repository": {},
"description": " ",
"license": "MIT",
"engines": {
"node": "v21.6.2",
"npm": "10.2.4"
},
"scripts": {
"deploy": "NODE_ENV=production webpack --mode production",
"watch": "webpack --mode development --watch --watch-options-stdin",
@ -9,33 +13,37 @@
"test": "standard"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^6.1.1",
"alpinejs": "^3.10.2",
"@fortawesome/fontawesome-free": "^6.5.1",
"chart.js": "^4.4.1",
"chartjs-adapter-date-fns": "^3.0.0",
"date-fns": "^3.3.1",
"phoenix": "file:../deps/phoenix",
"phoenix_html": "file:../deps/phoenix_html",
"phoenix_live_view": "file:../deps/phoenix_live_view",
"topbar": "^1.0.1"
"topbar": "^2.0.2"
},
"devDependencies": {
"@babel/core": "^7.17.10",
"@babel/preset-env": "^7.17.10",
"autoprefixer": "^10.4.7",
"babel-loader": "^8.2.5",
"copy-webpack-plugin": "^10.2.4",
"css-loader": "^6.7.1",
"css-minimizer-webpack-plugin": "^3.4.1",
"@babel/core": "^7.23.9",
"@babel/preset-env": "^7.23.9",
"autoprefixer": "^10.4.17",
"babel-loader": "^9.1.3",
"copy-webpack-plugin": "^12.0.2",
"css-loader": "^6.10.0",
"css-minimizer-webpack-plugin": "^6.0.0",
"file-loader": "^6.2.0",
"mini-css-extract-plugin": "^2.6.0",
"postcss": "^8.4.13",
"postcss-import": "^14.1.0",
"postcss-loader": "^6.2.1",
"postcss-preset-env": "^7.5.0",
"sass-loader": "^12.6.0",
"standard": "^17.0.0",
"tailwindcss": "^3.0.24",
"terser-webpack-plugin": "^5.3.1",
"webpack": "^5.72.0",
"webpack-cli": "^4.9.2",
"webpack-dev-server": "^4.9.0"
"mini-css-extract-plugin": "^2.8.0",
"npm-check-updates": "^16.14.15",
"postcss": "^8.4.35",
"postcss-import": "^16.0.1",
"postcss-loader": "^8.1.0",
"postcss-preset-env": "^9.4.0",
"sass": "^1.71.1",
"sass-loader": "^14.1.1",
"standard": "^17.1.0",
"tailwindcss": "^3.4.1",
"terser-webpack-plugin": "^5.3.10",
"webpack": "^5.90.3",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^5.0.2"
}
}

View File

@ -1,157 +0,0 @@
/**
* @license MIT
* topbar 1.0.0, 2021-01-06
* https://buunguyen.github.io/topbar
* Copyright (c) 2021 Buu Nguyen
*/
(function (window, document) {
"use strict";
// https://gist.github.com/paulirish/1579671
(function () {
var lastTime = 0;
var vendors = ["ms", "moz", "webkit", "o"];
for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) {
window.requestAnimationFrame =
window[vendors[x] + "RequestAnimationFrame"];
window.cancelAnimationFrame =
window[vendors[x] + "CancelAnimationFrame"] ||
window[vendors[x] + "CancelRequestAnimationFrame"];
}
if (!window.requestAnimationFrame)
window.requestAnimationFrame = function (callback, element) {
var currTime = new Date().getTime();
var timeToCall = Math.max(0, 16 - (currTime - lastTime));
var id = window.setTimeout(function () {
callback(currTime + timeToCall);
}, timeToCall);
lastTime = currTime + timeToCall;
return id;
};
if (!window.cancelAnimationFrame)
window.cancelAnimationFrame = function (id) {
clearTimeout(id);
};
})();
var canvas,
progressTimerId,
fadeTimerId,
currentProgress,
showing,
addEvent = function (elem, type, handler) {
if (elem.addEventListener) elem.addEventListener(type, handler, false);
else if (elem.attachEvent) elem.attachEvent("on" + type, handler);
else elem["on" + type] = handler;
},
options = {
autoRun: true,
barThickness: 3,
barColors: {
0: "rgba(26, 188, 156, .9)",
".25": "rgba(52, 152, 219, .9)",
".50": "rgba(241, 196, 15, .9)",
".75": "rgba(230, 126, 34, .9)",
"1.0": "rgba(211, 84, 0, .9)",
},
shadowBlur: 10,
shadowColor: "rgba(0, 0, 0, .6)",
className: null,
},
repaint = function () {
canvas.width = window.innerWidth;
canvas.height = options.barThickness * 5; // need space for shadow
var ctx = canvas.getContext("2d");
ctx.shadowBlur = options.shadowBlur;
ctx.shadowColor = options.shadowColor;
var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
for (var stop in options.barColors)
lineGradient.addColorStop(stop, options.barColors[stop]);
ctx.lineWidth = options.barThickness;
ctx.beginPath();
ctx.moveTo(0, options.barThickness / 2);
ctx.lineTo(
Math.ceil(currentProgress * canvas.width),
options.barThickness / 2
);
ctx.strokeStyle = lineGradient;
ctx.stroke();
},
createCanvas = function () {
canvas = document.createElement("canvas");
var style = canvas.style;
style.position = "fixed";
style.top = style.left = style.right = style.margin = style.padding = 0;
style.zIndex = 100001;
style.display = "none";
if (options.className) canvas.classList.add(options.className);
document.body.appendChild(canvas);
addEvent(window, "resize", repaint);
},
topbar = {
config: function (opts) {
for (var key in opts)
if (options.hasOwnProperty(key)) options[key] = opts[key];
},
show: function () {
if (showing) return;
showing = true;
if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId);
if (!canvas) createCanvas();
canvas.style.opacity = 1;
canvas.style.display = "block";
topbar.progress(0);
if (options.autoRun) {
(function loop() {
progressTimerId = window.requestAnimationFrame(loop);
topbar.progress(
"+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2)
);
})();
}
},
progress: function (to) {
if (typeof to === "undefined") return currentProgress;
if (typeof to === "string") {
to =
(to.indexOf("+") >= 0 || to.indexOf("-") >= 0
? currentProgress
: 0) + parseFloat(to);
}
currentProgress = to > 1 ? 1 : to;
repaint();
return currentProgress;
},
hide: function () {
if (!showing) return;
showing = false;
if (progressTimerId != null) {
window.cancelAnimationFrame(progressTimerId);
progressTimerId = null;
}
(function loop() {
if (topbar.progress("+.1") >= 1) {
canvas.style.opacity -= 0.05;
if (canvas.style.opacity <= 0.05) {
canvas.style.display = "none";
fadeTimerId = null;
return;
}
}
fadeTimerId = window.requestAnimationFrame(loop);
})();
},
};
if (typeof module === "object" && typeof module.exports === "object") {
module.exports = topbar;
} else if (typeof define === "function" && define.amd) {
define(function () {
return topbar;
});
} else {
this.topbar = topbar;
}
}.call(this, window, document));

View File

@ -45,7 +45,7 @@ module.exports = (env, options) => {
{
test: /\.(woff(2)?|ttf|eot|svg|otf)(\?v=[0-9]\.[0-9]\.[0-9])?$/,
type: 'asset/resource',
generator: { filename: 'fonts/[name][ext]' }
generator: { filename: 'fonts/[name].[ext]' }
}
]
},

View File

@ -11,15 +11,19 @@ config :cannery,
ecto_repos: [Cannery.Repo],
generators: [binary_id: true]
config :cannery, Cannery.Accounts, registration: System.get_env("REGISTRATION", "invite")
# Configures the endpoint
config :cannery, CanneryWeb.Endpoint,
url: [scheme: "https", host: System.get_env("HOST") || "localhost", port: "443"],
http: [port: String.to_integer(System.get_env("PORT") || "4000")],
secret_key_base: "KH59P0iZixX5gP/u+zkxxG8vAAj6vgt0YqnwEB5JP5K+E567SsqkCz69uWShjE7I",
render_errors: [view: CanneryWeb.ErrorView, accepts: ~w(html json), layout: false],
render_errors: [
formats: [html: CanneryWeb.ErrorHTML, json: CanneryWeb.ErrorJSON],
layout: false
],
pubsub_server: Cannery.PubSub,
live_view: [signing_salt: "zOLgd3lr"],
registration: System.get_env("REGISTRATION") || "invite"
live_view: [signing_salt: "zOLgd3lr"]
config :cannery, Cannery.Application, automigrate: false

View File

@ -59,13 +59,13 @@ config :cannery, CanneryWeb.Endpoint,
patterns: [
~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$",
~r"priv/gettext/.*(po)$",
~r"lib/cannery_web/(live|views)/.*(ex)$",
~r"lib/cannery_web/templates/.*(eex)$"
~r"lib/cannery_web/*/.*(ex)$"
]
]
# Do not include metadata nor timestamps in development logs
config :logger, :console, format: "[$level] $message\n"
config :logger, :console,
format: "[$level] $message $metadata\n\n",
metadata: [:data]
# Set a higher stacktrace during development. Avoid configuring such
# in production as building large stacktraces may be expensive.

View File

@ -12,20 +12,21 @@ if System.get_env("PHX_SERVER") && System.get_env("RELEASE_NAME") do
config :cannery, CanneryWeb.Endpoint, server: true
end
config :cannery, CanneryWeb.ViewHelpers, shibao_mode: System.get_env("SHIBAO_MODE") == "true"
config :cannery, CanneryWeb.HTMLHelpers, shibao_mode: System.get_env("SHIBAO_MODE") == "true"
# Set default locale
config :gettext, :default_locale, System.get_env("LOCALE") || "en_US"
config :gettext, :default_locale, System.get_env("LOCALE", "en_US")
maybe_ipv6 = if System.get_env("ECTO_IPV6") == "true", do: [:inet6], else: []
database_url =
if config_env() == :test do
System.get_env("TEST_DATABASE_URL") ||
System.get_env(
"TEST_DATABASE_URL",
"ecto://postgres:postgres@localhost/cannery_test#{System.get_env("MIX_TEST_PARTITION")}"
)
else
System.get_env("DATABASE_URL") ||
"ecto://postgres:postgres@cannery-db/cannery"
System.get_env("DATABASE_URL", "ecto://postgres:postgres@cannery-db/cannery")
end
host =
@ -40,7 +41,7 @@ interface =
config :cannery, Cannery.Repo,
# ssl: true,
url: database_url,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
pool_size: String.to_integer(System.get_env("POOL_SIZE", "10")),
socket_options: maybe_ipv6
config :cannery, CanneryWeb.Endpoint,
@ -49,10 +50,13 @@ config :cannery, CanneryWeb.Endpoint,
# See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html
# for details about using IPv6 vs IPv4 and loopback vs public addresses.
ip: interface,
port: String.to_integer(System.get_env("PORT") || "4000")
port: String.to_integer(System.get_env("PORT", "4000"))
],
server: true,
registration: System.get_env("REGISTRATION") || "invite"
server: true
if config_env() in [:dev, :prod] do
config :cannery, Cannery.Accounts, registration: System.get_env("REGISTRATION", "invite")
end
if config_env() == :prod do
# The secret key base is used to sign/encrypt cookies and other secrets.
@ -76,12 +80,12 @@ if config_env() == :prod do
config :cannery, Cannery.Mailer,
adapter: Swoosh.Adapters.SMTP,
relay: System.get_env("SMTP_HOST") || raise("No SMTP_HOST set!"),
port: System.get_env("SMTP_PORT") || 587,
port: System.get_env("SMTP_PORT", "587"),
username: System.get_env("SMTP_USERNAME") || raise("No SMTP_USERNAME set!"),
password: System.get_env("SMTP_PASSWORD") || raise("No SMTP_PASSWORD set!"),
ssl: System.get_env("SMTP_SSL") == "true",
email_from: System.get_env("EMAIL_FROM") || "no-reply@#{System.get_env("HOST")}",
email_name: System.get_env("EMAIL_NAME") || "Cannery"
email_from: System.get_env("EMAIL_FROM", "no-reply@#{System.get_env("HOST")}"),
email_name: System.get_env("EMAIL_NAME", "Cannery")
# ## Using releases
#

View File

@ -22,8 +22,11 @@ config :cannery, CanneryWeb.Endpoint,
# In test we don't send emails.
config :cannery, Cannery.Mailer, adapter: Swoosh.Adapters.Test
# Don't require invites for signups
config :cannery, Cannery.Accounts, registration: "public"
# Print only warnings and errors during test
config :logger, level: :warn
config :logger, level: :warning
# Initialize plugs at runtime for faster test compilation
config :phoenix, :plug_init_mode, :runtime

View File

@ -5,7 +5,7 @@ defmodule Cannery.Accounts do
import Ecto.Query, warn: false
alias Cannery.{Mailer, Repo}
alias Cannery.Accounts.{User, UserToken}
alias Cannery.Accounts.{Invite, Invites, User, UserToken}
alias Ecto.{Changeset, Multi}
alias Oban.Job
@ -23,22 +23,24 @@ defmodule Cannery.Accounts do
nil
"""
@spec get_user_by_email(String.t()) :: User.t() | nil
def get_user_by_email(email) when is_binary(email), do: Repo.get_by(User, email: email)
@spec get_user_by_email(email :: String.t()) :: User.t() | nil
def get_user_by_email(email) when is_binary(email) do
Repo.get_by(User, email: email)
end
@doc """
Gets a user by email and password.
## Examples
iex> get_user_by_email_and_password("foo@example.com", "correct_password")
iex> get_user_by_email_and_password("foo@example.com", "valid_password")
%User{}
iex> get_user_by_email_and_password("foo@example.com", "invalid_password")
nil
"""
@spec get_user_by_email_and_password(String.t(), String.t()) ::
@spec get_user_by_email_and_password(email :: String.t(), password :: String.t()) ::
User.t() | nil
def get_user_by_email_and_password(email, password)
when is_binary(email) and is_binary(password) do
@ -53,28 +55,30 @@ defmodule Cannery.Accounts do
## Examples
iex> get_user!(123)
%User{}
iex> get_user!(user_id)
user
iex> get_user!(456)
iex> get_user!()
** (Ecto.NoResultsError)
"""
@spec get_user!(User.t()) :: User.t()
def get_user!(id), do: Repo.get!(User, id)
@spec get_user!(User.id()) :: User.t()
def get_user!(id) do
Repo.get!(User, id)
end
@doc """
Returns all users grouped by role.
## Examples
iex> list_users_by_role(%User{id: 123, role: :admin})
[admin: [%User{}], user: [%User{}, %User{}]]
iex> list_all_users_by_role(user1)
%{admin: [%User{role: :admin}], user: [%User{role: :user}]}
"""
@spec list_all_users_by_role(User.t()) :: %{String.t() => [User.t()]}
@spec list_all_users_by_role(User.t()) :: %{User.role() => [User.t()]}
def list_all_users_by_role(%User{role: :admin}) do
Repo.all(from u in User, order_by: u.email) |> Enum.group_by(fn user -> user.role end)
Repo.all(from u in User, order_by: u.email) |> Enum.group_by(fn %{role: role} -> role end)
end
@doc """
@ -82,13 +86,12 @@ defmodule Cannery.Accounts do
## Examples
iex> list_users_by_role(%User{id: 123, role: :admin})
[%User{}]
iex> list_users_by_role(:admin)
[%User{role: :admin}]
"""
@spec list_users_by_role(:admin | :user) :: [User.t()]
def list_users_by_role(role) do
role = role |> to_string()
@spec list_users_by_role(:admin) :: [User.t()]
def list_users_by_role(:admin = role) do
Repo.all(from u in User, where: u.role == ^role, order_by: u.email)
end
@ -99,22 +102,38 @@ defmodule Cannery.Accounts do
## Examples
iex> register_user(%{field: value})
{:ok, %User{}}
iex> register_user(%{email: "foo@example.com", password: "valid_password"})
{:ok, %User{email: "foo@example.com"}}
iex> register_user(%{field: bad_value})
iex> register_user(%{email: "foo@example"})
{:error, %Changeset{}}
"""
@spec register_user(map()) :: {:ok, User.t()} | {:error, Changeset.t(User.new_user())}
def register_user(attrs) do
# if no registered users, make first user an admin
role =
if Repo.one!(from u in User, select: count(u.id), distinct: true) == 0,
do: "admin",
else: "user"
%User{} |> User.registration_changeset(attrs |> Map.put("role", role)) |> Repo.insert()
@spec register_user(attrs :: map()) ::
{:ok, User.t()} | {:error, :invalid_token | User.changeset()}
@spec register_user(attrs :: map(), Invite.token() | nil) ::
{:ok, User.t()} | {:error, :invalid_token | User.changeset()}
def register_user(attrs, invite_token \\ nil) do
Multi.new()
|> Multi.one(:users_count, from(u in User, select: count(u.id), distinct: true))
|> Multi.run(:use_invite, fn _changes_so_far, _repo ->
if allow_registration?() and invite_token |> is_nil() do
{:ok, nil}
else
Invites.use_invite(invite_token)
end
end)
|> Multi.insert(:add_user, fn %{users_count: count, use_invite: invite} ->
# if no registered users, make first user an admin
role = if count == 0, do: :admin, else: :user
User.registration_changeset(attrs, invite) |> User.role_changeset(role)
end)
|> Repo.transaction()
|> case do
{:ok, %{add_user: user}} -> {:ok, user}
{:error, :use_invite, :invalid_token, _changes_so_far} -> {:error, :invalid_token}
{:error, :add_user, changeset, _changes_so_far} -> {:error, changeset}
end
end
@doc """
@ -122,16 +141,18 @@ defmodule Cannery.Accounts do
## Examples
iex> change_user_registration(user)
%Changeset{data: %User{}}
iex> change_user_registration()
%Changeset{}
iex> change_user_registration(%{password: "hi"}
%Changeset{}
"""
@spec change_user_registration(User.t() | User.new_user()) ::
Changeset.t(User.t() | User.new_user())
@spec change_user_registration(User.t() | User.new_user(), map()) ::
Changeset.t(User.t() | User.new_user())
def change_user_registration(user, attrs \\ %{}),
do: User.registration_changeset(user, attrs, hash_password: false)
@spec change_user_registration() :: User.changeset()
@spec change_user_registration(attrs :: map()) :: User.changeset()
def change_user_registration(attrs \\ %{}) do
User.registration_changeset(attrs, nil, hash_password: false)
end
## Settings
@ -140,24 +161,29 @@ defmodule Cannery.Accounts do
## Examples
iex> change_user_email(user)
%Changeset{data: %User{}}
iex> change_user_email(%User{email: "foo@example.com"})
%Changeset{}
"""
@spec change_user_email(User.t(), map()) :: Changeset.t(User.t())
def change_user_email(user, attrs \\ %{}), do: User.email_changeset(user, attrs)
@spec change_user_email(User.t()) :: User.changeset()
@spec change_user_email(User.t(), attrs :: map()) :: User.changeset()
def change_user_email(user, attrs \\ %{}) do
User.email_changeset(user, attrs)
end
@doc """
Returns an `%Changeset{}` for changing the user role.
## Examples
iex> change_user_role(user)
%Changeset{data: %User{}}
iex> change_user_role(%User{}, :user)
%Changeset{}
"""
@spec change_user_role(User.t(), atom()) :: Changeset.t(User.t())
def change_user_role(user, role), do: User.role_changeset(user, role)
@spec change_user_role(User.t(), User.role()) :: User.changeset()
def change_user_role(user, role) do
User.role_changeset(user, role)
end
@doc """
Emulates that the email will change without actually changing
@ -165,15 +191,15 @@ defmodule Cannery.Accounts do
## Examples
iex> apply_user_email(user, "valid password", %{email: ...})
iex> apply_user_email(user, "valid_password", %{email: "new_email@account.com"})
{:ok, %User{}}
iex> apply_user_email(user, "invalid password", %{email: ...})
iex> apply_user_email(user, "invalid password", %{email: "new_email@account"})
{:error, %Changeset{}}
"""
@spec apply_user_email(User.t(), String.t(), map()) ::
{:ok, User.t()} | {:error, Changeset.t(User.t())}
@spec apply_user_email(User.t(), email :: String.t(), attrs :: map()) ::
{:ok, User.t()} | {:error, User.changeset()}
def apply_user_email(user, password, attrs) do
user
|> User.email_changeset(attrs)
@ -187,7 +213,7 @@ defmodule Cannery.Accounts do
If the token matches, the user email is updated and the token is deleted.
The confirmed_at date is also updated to the current time.
"""
@spec update_user_email(User.t(), String.t()) :: :ok | :error
@spec update_user_email(User.t(), token :: String.t()) :: :ok | :error
def update_user_email(user, token) do
context = "change:#{user.email}"
@ -200,7 +226,7 @@ defmodule Cannery.Accounts do
end
end
@spec user_email_multi(User.t(), String.t(), String.t()) :: Multi.t()
@spec user_email_multi(User.t(), email :: String.t(), context :: String.t()) :: Multi.t()
defp user_email_multi(user, email, context) do
changeset = user |> User.email_changeset(%{email: email}) |> User.confirm_changeset()
@ -214,11 +240,12 @@ defmodule Cannery.Accounts do
## Examples
iex> deliver_update_email_instructions(user, current_email, &Routes.user_update_email_url(conn, :edit, &1))
{:ok, %{to: ..., body: ...}}
iex> deliver_update_email_instructions(user, "new_foo@example.com", fn _token -> "example url" end)
%Oban.Job{args: %{email: :update_email, user_id: ^user_id, attrs: %{url: "example url"}}}
"""
@spec deliver_update_email_instructions(User.t(), String.t(), function) :: Job.t()
@spec deliver_update_email_instructions(User.t(), current_email :: String.t(), function) ::
Job.t()
def deliver_update_email_instructions(user, current_email, update_email_url_fun)
when is_function(update_email_url_fun, 1) do
{encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}")
@ -231,28 +258,32 @@ defmodule Cannery.Accounts do
## Examples
iex> change_user_password(user)
%Changeset{data: %User{}}
iex> change_user_password(%User{})
%Changeset{}
"""
@spec change_user_password(User.t(), map()) :: Changeset.t(User.t())
def change_user_password(user, attrs \\ %{}),
do: User.password_changeset(user, attrs, hash_password: false)
@spec change_user_password(User.t(), attrs :: map()) :: User.changeset()
def change_user_password(user, attrs \\ %{}) do
User.password_changeset(user, attrs, hash_password: false)
end
@doc """
Updates the user password.
## Examples
iex> update_user_password(user, "valid password", %{password: ...})
iex> reset_user_password(user, %{
...> password: "new password",
...> password_confirmation: "new password"
...> })
{:ok, %User{}}
iex> update_user_password(user, "invalid password", %{password: ...})
iex> update_user_password(user, "invalid password", %{password: "123"})
{:error, %Changeset{}}
"""
@spec update_user_password(User.t(), String.t(), map()) ::
{:ok, User.t()} | {:error, Changeset.t(User.t())}
@spec update_user_password(User.t(), String.t(), attrs :: map()) ::
{:ok, User.t()} | {:error, User.changeset()}
def update_user_password(user, password, attrs) do
changeset =
user
@ -270,49 +301,54 @@ defmodule Cannery.Accounts do
end
@doc """
Returns an `%Changeset{}` for changing the user locale.
Returns an `Ecto.Changeset.t()` for changing the user locale.
## Examples
iex> change_user_locale(user)
%Changeset{data: %User{}}
iex> change_user_locale(%User{})
%Changeset{}
"""
@spec change_user_locale(User.t()) :: Changeset.t(User.t())
def change_user_locale(%{locale: locale} = user), do: User.locale_changeset(user, locale)
@spec change_user_locale(User.t()) :: User.changeset()
def change_user_locale(%{locale: locale} = user) do
User.locale_changeset(user, locale)
end
@doc """
Updates the user locale.
## Examples
iex> update_user_locale(user, "valid locale")
iex> update_user_locale(user, "en_US")
{:ok, %User{}}
iex> update_user_password(user, "invalid locale")
{:error, %Changeset{}}
"""
@spec update_user_locale(User.t(), locale :: String.t()) ::
{:ok, User.t()} | {:error, Changeset.t(User.t())}
def update_user_locale(user, locale),
do: user |> User.locale_changeset(locale) |> Repo.update()
{:ok, User.t()} | {:error, User.changeset()}
def update_user_locale(user, locale) do
user |> User.locale_changeset(locale) |> Repo.update()
end
@doc """
Deletes a user. must be performed by an admin or the same user!
## Examples
iex> delete_user!(user_to_delete, %User{id: 123, role: :admin})
iex> delete_user!(user, %User{id: 123, role: :admin})
%User{}
iex> delete_user!(%User{id: 123}, %User{id: 123})
iex> delete_user!(user, user)
%User{}
"""
@spec delete_user!(User.t(), User.t()) :: User.t()
def delete_user!(user, %User{role: :admin}), do: user |> Repo.delete!()
def delete_user!(%User{id: user_id} = user, %User{id: user_id}), do: user |> Repo.delete!()
@spec delete_user!(user_to_delete :: User.t(), User.t()) :: User.t()
def delete_user!(user, %User{role: :admin}) do
user |> Repo.delete!()
end
def delete_user!(%User{id: user_id} = user, %User{id: user_id}) do
user |> Repo.delete!()
end
## Session
@ -329,7 +365,7 @@ defmodule Cannery.Accounts do
@doc """
Gets the user with the given signed token.
"""
@spec get_user_by_session_token(String.t()) :: User.t()
@spec get_user_by_session_token(token :: String.t()) :: User.t()
def get_user_by_session_token(token) do
{:ok, query} = UserToken.verify_session_token_query(token)
Repo.one(query)
@ -338,9 +374,9 @@ defmodule Cannery.Accounts do
@doc """
Deletes the signed token with the given context.
"""
@spec delete_session_token(String.t()) :: :ok
def delete_session_token(token) do
Repo.delete_all(UserToken.token_and_context_query(token, "session"))
@spec delete_user_session_token(token :: String.t()) :: :ok
def delete_user_session_token(token) do
UserToken.token_and_context_query(token, "session") |> Repo.delete_all()
:ok
end
@ -349,19 +385,53 @@ defmodule Cannery.Accounts do
"""
@spec allow_registration?() :: boolean()
def allow_registration? do
Application.get_env(:cannery, CanneryWeb.Endpoint)[:registration] == "public" or
list_users_by_role(:admin) |> Enum.empty?()
registration_mode() == :public or list_users_by_role(:admin) |> Enum.empty?()
end
@doc """
Returns an atom representing the current configured registration mode
"""
@spec registration_mode() :: :public | :invite_only
def registration_mode do
case Application.get_env(:cannery, Cannery.Accounts)[:registration] do
"public" -> :public
_other -> :invite_only
end
end
@doc """
Checks if user is an admin
## Examples
iex> admin?(%User{role: :admin})
true
iex> admin?(%User{})
false
"""
@spec is_admin?(User.t()) :: boolean()
def is_admin?(%User{id: user_id}) do
Repo.one(from u in User, where: u.id == ^user_id and u.role == :admin)
|> is_nil()
@spec admin?(User.t()) :: boolean()
def admin?(%User{id: user_id}) do
Repo.exists?(from u in User, where: u.id == ^user_id, where: u.role == :admin)
end
@doc """
Checks to see if user has the admin role
## Examples
iex> already_admin?(%User{role: :admin})
true
iex> already_admin?(%User{})
false
"""
@spec already_admin?(User.t() | nil) :: boolean()
def already_admin?(%User{role: :admin}), do: true
def already_admin?(_invalid_user), do: false
## Confirmation
@doc """
@ -369,10 +439,10 @@ defmodule Cannery.Accounts do
## Examples
iex> deliver_user_confirmation_instructions(user, &Routes.user_confirmation_url(conn, :confirm, &1))
{:ok, %{to: ..., body: ...}}
iex> deliver_user_confirmation_instructions(user, fn _token -> "example url" end)
%Oban.Job{args: %{email: :welcome, user_id: ^user_id, attrs: %{url: "example url"}}}
iex> deliver_user_confirmation_instructions(confirmed_user, &Routes.user_confirmation_url(conn, :confirm, &1))
iex> deliver_user_confirmation_instructions(user, fn _token -> "example url" end)
{:error, :already_confirmed}
"""
@ -394,7 +464,7 @@ defmodule Cannery.Accounts do
If the token matches, the user account is marked as confirmed
and the token is deleted.
"""
@spec confirm_user(String.t()) :: {:ok, User.t()} | atom()
@spec confirm_user(token :: String.t()) :: {:ok, User.t()} | :error
def confirm_user(token) do
with {:ok, query} <- UserToken.verify_email_token_query(token, "confirm"),
%User{} = user <- Repo.one(query),
@ -419,8 +489,8 @@ defmodule Cannery.Accounts do
## Examples
iex> deliver_user_reset_password_instructions(user, &Routes.user_reset_password_url(conn, :edit, &1))
{:ok, %{to: ..., body: ...}}
iex> deliver_user_reset_password_instructions(user, fn _token -> "example url" end)
%Oban.Job{args: %{email: :reset_password, user_id: ^user_id, attrs: %{url: "example url"}}}
"""
@spec deliver_user_reset_password_instructions(User.t(), function()) :: Job.t()
@ -436,14 +506,14 @@ defmodule Cannery.Accounts do
## Examples
iex> get_user_by_reset_password_token("validtoken")
iex> get_user_by_reset_password_token(encoded_token)
%User{}
iex> get_user_by_reset_password_token("invalidtoken")
nil
"""
@spec get_user_by_reset_password_token(String.t()) :: User.t() | nil
@spec get_user_by_reset_password_token(token :: String.t()) :: User.t() | nil
def get_user_by_reset_password_token(token) do
with {:ok, query} <- UserToken.verify_email_token_query(token, "reset_password"),
%User{} = user <- Repo.one(query) do
@ -458,14 +528,18 @@ defmodule Cannery.Accounts do
## Examples
iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"})
iex> reset_user_password(user, %{
...> password: "new password",
...> password_confirmation: "new password"
...> })
{:ok, %User{}}
iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"})
{:error, %Changeset{}}
"""
@spec reset_user_password(User.t(), map()) :: {:ok, User.t()} | {:error, Changeset.t(User.t())}
@spec reset_user_password(User.t(), attrs :: map()) ::
{:ok, User.t()} | {:error, User.changeset()}
def reset_user_password(user, attrs) do
Multi.new()
|> Multi.update(:user, User.password_changeset(user, attrs))

View File

@ -3,14 +3,15 @@ defmodule Cannery.Email do
Emails that can be sent using Swoosh.
You can find the base email templates at
`lib/cannery_web/templates/layout/email.html.heex` for html emails and
`lib/cannery_web/templates/layout/email.txt.heex` for text emails.
`lib/cannery_web/components/layouts/email_html.html.heex` for html emails and
`lib/cannery_web/components/layouts/email_text.txt.eex` for text emails.
"""
use Phoenix.Swoosh, view: CanneryWeb.EmailView, layout: {CanneryWeb.LayoutView, :email}
import Swoosh.Email
import CanneryWeb.Gettext
import Phoenix.Template
alias Cannery.Accounts.User
alias CanneryWeb.EmailView
alias CanneryWeb.{EmailHTML, Layouts}
@typedoc """
Represents an HTML and text body email that can be sent
@ -27,22 +28,34 @@ defmodule Cannery.Email do
@spec generate_email(key :: String.t(), User.t(), attrs :: map()) :: t()
def generate_email("welcome", user, %{"url" => url}) do
user
|> base_email(dgettext("emails", "Confirm your %{name} account", name: "Cannery"))
|> render_body("confirm_email.html", %{user: user, url: url})
|> text_body(EmailView.render("confirm_email.txt", %{user: user, url: url}))
|> base_email(dgettext("emails", "Confirm your Cannery account"))
|> html_email(:confirm_email_html, %{user: user, url: url})
|> text_email(:confirm_email_text, %{user: user, url: url})
end
def generate_email("reset_password", user, %{"url" => url}) do
user
|> base_email(dgettext("emails", "Reset your %{name} password", name: "Cannery"))
|> render_body("reset_password.html", %{user: user, url: url})
|> text_body(EmailView.render("reset_password.txt", %{user: user, url: url}))
|> base_email(dgettext("emails", "Reset your Cannery password"))
|> html_email(:reset_password_html, %{user: user, url: url})
|> text_email(:reset_password_text, %{user: user, url: url})
end
def generate_email("update_email", user, %{"url" => url}) do
user
|> base_email(dgettext("emails", "Update your %{name} email", name: "Cannery"))
|> render_body("update_email.html", %{user: user, url: url})
|> text_body(EmailView.render("update_email.txt", %{user: user, url: url}))
|> base_email(dgettext("emails", "Update your Cannery email"))
|> html_email(:update_email_html, %{user: user, url: url})
|> text_email(:update_email_text, %{user: user, url: url})
end
defp html_email(email, atom, assigns) do
heex = apply(EmailHTML, atom, [assigns])
html = render_to_string(Layouts, "email_html", "html", email: email, inner_content: heex)
email |> html_body(html)
end
defp text_email(email, atom, assigns) do
heex = apply(EmailHTML, atom, [assigns])
text = render_to_string(Layouts, "email_text", "text", email: email, inner_content: heex)
email |> text_body(text)
end
end

View File

@ -1,4 +1,4 @@
defmodule Cannery.Invites.Invite do
defmodule Cannery.Accounts.Invite do
@moduledoc """
An invite, created by an admin to allow someone to join their instance. An
invite can be enabled or disabled, and can have an optional number of uses if
@ -7,8 +7,8 @@ defmodule Cannery.Invites.Invite do
use Ecto.Schema
import Ecto.Changeset
alias Ecto.{Changeset, UUID}
alias Cannery.{Accounts.User, Invites.Invite}
alias Cannery.Accounts.User
alias Ecto.{Association, Changeset, UUID}
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
@ -18,42 +18,48 @@ defmodule Cannery.Invites.Invite do
field :uses_left, :integer, default: nil
field :disabled_at, :naive_datetime
belongs_to :user, User
belongs_to :created_by, User
has_many :users, User
timestamps()
end
@type t :: %Invite{
@type t :: %__MODULE__{
id: id(),
name: String.t(),
token: String.t(),
token: token(),
uses_left: integer() | nil,
disabled_at: NaiveDateTime.t(),
user: User.t(),
user_id: User.id(),
created_by: User.t() | nil | Association.NotLoaded.t(),
created_by_id: User.id() | nil,
users: [User.t()] | Association.NotLoaded.t(),
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
@type new_invite :: %Invite{}
@type new_invite :: %__MODULE__{}
@type id :: UUID.t()
@type changeset :: Changeset.t(t() | new_invite())
@type token :: String.t()
@doc false
@spec create_changeset(new_invite(), User.t(), token :: binary(), attrs :: map()) ::
Changeset.t(new_invite())
def create_changeset(invite, %User{id: user_id}, token, attrs) do
invite
|> change(token: token, user_id: user_id)
@spec create_changeset(User.t(), token(), attrs :: map()) :: changeset()
def create_changeset(%User{id: user_id}, token, attrs) do
%__MODULE__{}
|> change(token: token, created_by_id: user_id)
|> cast(attrs, [:name, :uses_left, :disabled_at])
|> validate_required([:name, :token, :user_id])
|> validate_length(:name, max: 255)
|> validate_number(:uses_left, greater_than_or_equal_to: 0)
|> validate_required([:name, :token, :created_by_id])
end
@doc false
@spec update_changeset(t() | new_invite(), attrs :: map()) :: Changeset.t(t() | new_invite())
@spec update_changeset(t() | new_invite(), attrs :: map()) :: changeset()
def update_changeset(invite, attrs) do
invite
|> cast(attrs, [:name, :uses_left, :disabled_at])
|> validate_required([:name])
|> validate_length(:name, max: 255)
|> validate_number(:uses_left, greater_than_or_equal_to: 0)
|> validate_required([:name])
end
end

View File

@ -0,0 +1,208 @@
defmodule Cannery.Accounts.Invites do
@moduledoc """
The Invites context.
"""
import Ecto.Query, warn: false
alias Ecto.Multi
alias Cannery.Accounts.{Invite, User}
alias Cannery.Repo
@invite_token_length 20
@doc """
Returns the list of invites.
## Examples
iex> list_invites(%User{id: 123, role: :admin})
[%Invite{}, ...]
"""
@spec list_invites(User.t()) :: [Invite.t()]
def list_invites(%User{role: :admin}) do
Repo.all(from i in Invite, order_by: i.name)
end
@doc """
Gets a single invite for a user
Raises `Ecto.NoResultsError` if the Invite does not exist.
## Examples
iex> get_invite!(123, %User{id: 123, role: :admin})
%Invite{}
> get_invite!(456, %User{id: 123, role: :admin})
** (Ecto.NoResultsError)
"""
@spec get_invite!(Invite.id(), User.t()) :: Invite.t()
def get_invite!(id, %User{role: :admin}) do
Repo.get!(Invite, id)
end
@doc """
Returns if an invite token is still valid
## Examples
iex> valid_invite_token?("valid_token")
%Invite{}
iex> valid_invite_token?("invalid_token")
nil
"""
@spec valid_invite_token?(Invite.token() | nil) :: boolean()
def valid_invite_token?(token) when token in [nil, ""], do: false
def valid_invite_token?(token) do
Repo.exists?(
from i in Invite,
where: i.token == ^token,
where: i.disabled_at |> is_nil()
)
end
@doc """
Uses invite by decrementing uses_left, or marks invite invalid if it's been
completely used.
"""
@spec use_invite(Invite.token()) :: {:ok, Invite.t()} | {:error, :invalid_token}
def use_invite(invite_token) do
Multi.new()
|> Multi.run(:invite, fn _changes_so_far, _repo ->
invite_token |> get_invite_by_token()
end)
|> Multi.update(:decrement_invite, fn %{invite: invite} ->
decrement_invite_changeset(invite)
end)
|> Repo.transaction()
|> case do
{:ok, %{decrement_invite: invite}} -> {:ok, invite}
{:error, :invite, :invalid_token, _changes_so_far} -> {:error, :invalid_token}
end
end
@spec get_invite_by_token(Invite.token() | nil) :: {:ok, Invite.t()} | {:error, :invalid_token}
defp get_invite_by_token(token) when token in [nil, ""], do: {:error, :invalid_token}
defp get_invite_by_token(token) do
Repo.one(
from i in Invite,
where: i.token == ^token,
where: i.disabled_at |> is_nil()
)
|> case do
nil -> {:error, :invalid_token}
invite -> {:ok, invite}
end
end
@spec get_use_count(Invite.t(), User.t()) :: non_neg_integer() | nil
def get_use_count(%Invite{id: invite_id} = invite, user) do
[invite] |> get_use_counts(user) |> Map.get(invite_id)
end
@spec get_use_counts([Invite.t()], User.t()) ::
%{optional(Invite.id()) => non_neg_integer()}
def get_use_counts(invites, %User{role: :admin}) do
invite_ids = invites |> Enum.map(fn %{id: invite_id} -> invite_id end)
Repo.all(
from u in User,
where: u.invite_id in ^invite_ids,
group_by: u.invite_id,
select: {u.invite_id, count(u.id)}
)
|> Map.new()
end
@spec decrement_invite_changeset(Invite.t()) :: Invite.changeset()
defp decrement_invite_changeset(%Invite{uses_left: nil} = invite) do
invite |> Invite.update_changeset(%{})
end
defp decrement_invite_changeset(%Invite{uses_left: 1} = invite) do
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
invite |> Invite.update_changeset(%{uses_left: 0, disabled_at: now})
end
defp decrement_invite_changeset(%Invite{uses_left: uses_left} = invite) do
invite |> Invite.update_changeset(%{uses_left: uses_left - 1})
end
@doc """
Creates a invite.
## Examples
iex> create_invite(%User{id: 123, role: :admin}, %{field: value})
{:ok, %Invite{}}
iex> create_invite(%User{id: 123, role: :admin}, %{field: bad_value})
{:error, %Changeset{}}
"""
@spec create_invite(User.t(), attrs :: map()) ::
{:ok, Invite.t()} | {:error, Invite.changeset()}
def create_invite(%User{role: :admin} = user, attrs) do
token =
:crypto.strong_rand_bytes(@invite_token_length)
|> Base.url_encode64()
|> binary_part(0, @invite_token_length)
Invite.create_changeset(user, token, attrs) |> Repo.insert()
end
@doc """
Updates a invite.
## Examples
iex> update_invite(invite, %{field: new_value}, %User{id: 123, role: :admin})
{:ok, %Invite{}}
iex> update_invite(invite, %{field: bad_value}, %User{id: 123, role: :admin})
{:error, %Changeset{}}
"""
@spec update_invite(Invite.t(), attrs :: map(), User.t()) ::
{:ok, Invite.t()} | {:error, Invite.changeset()}
def update_invite(invite, attrs, %User{role: :admin}) do
invite |> Invite.update_changeset(attrs) |> Repo.update()
end
@doc """
Deletes a invite.
## Examples
iex> delete_invite(invite, %User{id: 123, role: :admin})
{:ok, %Invite{}}
iex> delete_invite(invite, %User{id: 123, role: :admin})
{:error, %Changeset{}}
"""
@spec delete_invite(Invite.t(), User.t()) ::
{:ok, Invite.t()} | {:error, Invite.changeset()}
def delete_invite(invite, %User{role: :admin}) do
invite |> Repo.delete()
end
@doc """
Deletes a invite.
## Examples
iex> delete_invite(invite, %User{id: 123, role: :admin})
%Invite{}
"""
@spec delete_invite!(Invite.t(), User.t()) :: Invite.t()
def delete_invite!(invite, %User{role: :admin}) do
invite |> Repo.delete!()
end
end

View File

@ -1,14 +1,24 @@
defmodule Cannery.Accounts.User do
@moduledoc """
A cannery user
A Cannery user
"""
use Ecto.Schema
import Ecto.Changeset
import CanneryWeb.Gettext
alias Ecto.{Changeset, UUID}
alias Cannery.{Accounts.User, Invites.Invite}
alias Ecto.{Association, Changeset, UUID}
alias Cannery.Accounts.{Invite, User}
@derive {Jason.Encoder,
only: [
:id,
:email,
:confirmed_at,
:role,
:locale,
:inserted_at,
:updated_at
]}
@derive {Inspect, except: [:password]}
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
@ -20,7 +30,9 @@ defmodule Cannery.Accounts.User do
field :role, Ecto.Enum, values: [:admin, :user], default: :user
field :locale, :string
has_many :invites, Invite, on_delete: :delete_all
has_many :created_invites, Invite, foreign_key: :created_by_id
belongs_to :invite, Invite
timestamps()
end
@ -31,14 +43,18 @@ defmodule Cannery.Accounts.User do
password: String.t(),
hashed_password: String.t(),
confirmed_at: NaiveDateTime.t(),
role: atom(),
role: role(),
locale: String.t() | nil,
invites: [Invite.t()],
created_invites: [Invite.t()] | Association.NotLoaded.t(),
invite: Invite.t() | nil | Association.NotLoaded.t(),
invite_id: Invite.id() | nil,
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
@type new_user :: %User{}
@type id :: UUID.t()
@type changeset :: Changeset.t(t() | new_user())
@type role :: :admin | :user
@doc """
A user changeset for registration.
@ -57,26 +73,26 @@ defmodule Cannery.Accounts.User do
validations on a LiveView form), this option can be set to `false`.
Defaults to `true`.
"""
@spec registration_changeset(t() | new_user(), attrs :: map()) :: Changeset.t(t() | new_user())
@spec registration_changeset(t() | new_user(), attrs :: map(), opts :: keyword()) ::
Changeset.t(t() | new_user())
def registration_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:email, :password, :role, :locale])
@spec registration_changeset(attrs :: map(), Invite.t() | nil) :: changeset()
@spec registration_changeset(attrs :: map(), Invite.t() | nil, opts :: keyword()) :: changeset()
def registration_changeset(attrs, invite, opts \\ []) do
%User{}
|> cast(attrs, [:email, :password, :locale])
|> put_change(:invite_id, if(invite, do: invite.id))
|> validate_length(:locale, max: 255)
|> validate_email()
|> validate_password(opts)
end
@doc """
A user changeset for role.
"""
@spec role_changeset(t(), role :: atom()) :: Changeset.t(t())
@spec role_changeset(t() | new_user() | changeset(), role()) :: changeset()
def role_changeset(user, role) do
user |> cast(%{"role" => role}, [:role])
user |> change(role: role)
end
@spec validate_email(Changeset.t(t() | new_user())) :: Changeset.t(t() | new_user())
@spec validate_email(changeset()) :: changeset()
defp validate_email(changeset) do
changeset
|> validate_required([:email])
@ -88,8 +104,8 @@ defmodule Cannery.Accounts.User do
|> unique_constraint(:email)
end
@spec validate_password(Changeset.t(t() | new_user()), opts :: keyword()) ::
Changeset.t(t() | new_user())
@spec validate_password(changeset(), opts :: keyword()) ::
changeset()
defp validate_password(changeset, opts) do
changeset
|> validate_required([:password])
@ -100,8 +116,7 @@ defmodule Cannery.Accounts.User do
|> maybe_hash_password(opts)
end
@spec maybe_hash_password(Changeset.t(t() | new_user()), opts :: keyword()) ::
Changeset.t(t() | new_user())
@spec maybe_hash_password(changeset(), opts :: keyword()) :: changeset()
defp maybe_hash_password(changeset, opts) do
hash_password? = Keyword.get(opts, :hash_password, true)
password = get_change(changeset, :password)
@ -120,7 +135,7 @@ defmodule Cannery.Accounts.User do
It requires the email to change otherwise an error is added.
"""
@spec email_changeset(t(), attrs :: map()) :: Changeset.t(t())
@spec email_changeset(t(), attrs :: map()) :: changeset()
def email_changeset(user, attrs) do
user
|> cast(attrs, [:email])
@ -143,8 +158,8 @@ defmodule Cannery.Accounts.User do
validations on a LiveView form), this option can be set to `false`.
Defaults to `true`.
"""
@spec password_changeset(t(), attrs :: map()) :: Changeset.t(t())
@spec password_changeset(t(), attrs :: map(), opts :: keyword()) :: Changeset.t(t())
@spec password_changeset(t(), attrs :: map()) :: changeset()
@spec password_changeset(t(), attrs :: map(), opts :: keyword()) :: changeset()
def password_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:password])
@ -155,7 +170,7 @@ defmodule Cannery.Accounts.User do
@doc """
Confirms the account by setting `confirmed_at`.
"""
@spec confirm_changeset(t() | Changeset.t(t())) :: Changeset.t(t())
@spec confirm_changeset(t() | changeset()) :: changeset()
def confirm_changeset(user_or_changeset) do
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
user_or_changeset |> change(confirmed_at: now)
@ -181,7 +196,7 @@ defmodule Cannery.Accounts.User do
@doc """
Validates the current password otherwise adds an error to the changeset.
"""
@spec validate_current_password(Changeset.t(t()), String.t()) :: Changeset.t(t())
@spec validate_current_password(changeset(), String.t()) :: changeset()
def validate_current_password(changeset, password) do
if valid_password?(changeset.data, password),
do: changeset,
@ -191,10 +206,11 @@ defmodule Cannery.Accounts.User do
@doc """
A changeset for changing the user's locale
"""
@spec locale_changeset(t() | Changeset.t(t()), locale :: String.t() | nil) :: Changeset.t(t())
@spec locale_changeset(t() | changeset(), locale :: String.t() | nil) :: changeset()
def locale_changeset(user_or_changeset, locale) do
user_or_changeset
|> cast(%{"locale" => locale}, [:locale])
|> validate_length(:locale, max: 255)
|> validate_required(:locale)
end
end

View File

@ -1,12 +1,12 @@
defmodule Cannery.Accounts.UserToken do
@moduledoc """
Schema for serialized user session and authentication tokens
Schema for a user's session token
"""
use Ecto.Schema
import Ecto.Query
alias Ecto.{Query, UUID}
alias Cannery.{Accounts.User, Accounts.UserToken}
alias Cannery.Accounts.User
alias Ecto.{Association, UUID}
@hash_algorithm :sha256
@rand_size 32
@ -30,27 +30,27 @@ defmodule Cannery.Accounts.UserToken do
timestamps(updated_at: false)
end
@type t :: %UserToken{
@type t :: %__MODULE__{
id: id(),
token: String.t(),
token: token(),
context: String.t(),
sent_to: String.t(),
user: User.t(),
user_id: User.id(),
user: User.t() | Association.NotLoaded.t(),
user_id: User.id() | nil,
inserted_at: NaiveDateTime.t()
}
@type new_token :: %UserToken{}
@type new_user_token :: %__MODULE__{}
@type id :: UUID.t()
@type token :: binary()
@doc """
Generates a token that will be stored in a signed place,
such as session or cookie. As they are signed, those
tokens do not need to be hashed.
"""
@spec build_session_token(User.t()) :: {token :: String.t(), new_token()}
def build_session_token(%{id: user_id}) do
def build_session_token(user) do
token = :crypto.strong_rand_bytes(@rand_size)
{token, %UserToken{token: token, context: "session", user_id: user_id}}
{token, %__MODULE__{token: token, context: "session", user_id: user.id}}
end
@doc """
@ -58,7 +58,6 @@ defmodule Cannery.Accounts.UserToken do
The query returns the user found by the token.
"""
@spec verify_session_token_query(token :: String.t()) :: {:ok, Query.t()}
def verify_session_token_query(token) do
query =
from token in token_and_context_query(token, "session"),
@ -77,19 +76,16 @@ defmodule Cannery.Accounts.UserToken do
The token is valid for a week as long as users don't change
their email.
"""
@spec build_email_token(User.t(), context :: String.t()) :: {token :: String.t(), new_token()}
def build_email_token(user, context) do
build_hashed_token(user, context, user.email)
end
@spec build_hashed_token(User.t(), String.t(), String.t()) ::
{String.t(), new_token()}
defp build_hashed_token(user, context, sent_to) do
token = :crypto.strong_rand_bytes(@rand_size)
hashed_token = :crypto.hash(@hash_algorithm, token)
{Base.url_encode64(token, padding: false),
%UserToken{
%__MODULE__{
token: hashed_token,
context: context,
sent_to: sent_to,
@ -102,8 +98,6 @@ defmodule Cannery.Accounts.UserToken do
The query returns the user found by the token.
"""
@spec verify_email_token_query(token :: String.t(), context :: String.t()) ::
{:ok, Query.t()} | :error
def verify_email_token_query(token, context) do
case Base.url_decode64(token, padding: false) do
{:ok, decoded_token} ->
@ -123,7 +117,6 @@ defmodule Cannery.Accounts.UserToken do
end
end
@spec days_for_context(context :: <<_::56>>) :: non_neg_integer()
defp days_for_context("confirm"), do: @confirm_validity_in_days
defp days_for_context("reset_password"), do: @reset_password_validity_in_days
@ -132,8 +125,6 @@ defmodule Cannery.Accounts.UserToken do
The query returns the user token record.
"""
@spec verify_change_email_token_query(token :: String.t(), context :: String.t()) ::
{:ok, Query.t()} | :error
def verify_change_email_token_query(token, context) do
case Base.url_decode64(token, padding: false) do
{:ok, decoded_token} ->
@ -153,21 +144,18 @@ defmodule Cannery.Accounts.UserToken do
@doc """
Returns the given token with the given context.
"""
@spec token_and_context_query(token :: String.t(), context :: String.t()) :: Query.t()
def token_and_context_query(token, context) do
from UserToken, where: [token: ^token, context: ^context]
from __MODULE__, where: [token: ^token, context: ^context]
end
@doc """
Gets all tokens for the given user for the given contexts.
"""
@spec user_and_contexts_query(User.t(), contexts :: :all | nonempty_maybe_improper_list()) ::
Query.t()
def user_and_contexts_query(%{id: user_id}, :all) do
from t in UserToken, where: t.user_id == ^user_id
def user_and_contexts_query(user, :all) do
from t in __MODULE__, where: t.user_id == ^user.id
end
def user_and_contexts_query(%{id: user_id}, [_ | _] = contexts) do
from t in UserToken, where: t.user_id == ^user_id and t.context in ^contexts
def user_and_contexts_query(user, [_ | _] = contexts) do
from t in __MODULE__, where: t.user_id == ^user.id and t.context in ^contexts
end
end

View File

@ -4,180 +4,436 @@ defmodule Cannery.ActivityLog do
"""
import Ecto.Query, warn: false
alias Cannery.{Accounts.User, ActivityLog.ShotGroup, Ammo.AmmoGroup, Repo}
alias Ecto.{Changeset, Multi}
alias Cannery.Ammo.{Pack, Type}
alias Cannery.{Accounts.User, ActivityLog.ShotRecord, Repo}
alias Ecto.{Multi, Queryable}
@type list_shot_records_option ::
{:search, String.t() | nil}
| {:class, Type.class() | :all | nil}
| {:pack_id, Pack.id() | nil}
@type list_shot_records_options :: [list_shot_records_option()]
@doc """
Returns the list of shot_groups.
Returns the list of shot_records.
## Examples
iex> list_shot_groups(%User{id: 123})
[%ShotGroup{}, ...]
iex> list_shot_records(%User{id: 123})
[%ShotRecord{}, ...]
iex> list_shot_records(%User{id: 123}, search: "cool")
[%ShotRecord{notes: "My cool shot record"}, ...]
iex> list_shot_records(%User{id: 123}, search: "cool", class: :rifle)
[%ShotRecord{notes: "Shot some rifle rounds"}, ...]
iex> list_shot_records(%User{id: 123}, pack_id: 456)
[%ShotRecord{pack_id: 456}, ...]
"""
@spec list_shot_groups(User.t()) :: [ShotGroup.t()]
def list_shot_groups(%User{id: user_id}) do
Repo.all(from(sg in ShotGroup, where: sg.user_id == ^user_id))
@spec list_shot_records(User.t()) :: [ShotRecord.t()]
@spec list_shot_records(User.t(), list_shot_records_options()) :: [ShotRecord.t()]
def list_shot_records(%User{id: user_id}, opts \\ []) do
from(sr in ShotRecord,
as: :sr,
left_join: p in Pack,
as: :p,
on: sr.pack_id == p.id,
on: p.user_id == ^user_id,
left_join: t in Type,
as: :t,
on: p.type_id == t.id,
on: t.user_id == ^user_id,
where: sr.user_id == ^user_id,
distinct: sr.id
)
|> list_shot_records_search(Keyword.get(opts, :search))
|> list_shot_records_class(Keyword.get(opts, :class))
|> list_shot_records_pack_id(Keyword.get(opts, :pack_id))
|> Repo.all()
end
@spec list_shot_records_search(Queryable.t(), search :: String.t() | nil) ::
Queryable.t()
defp list_shot_records_search(query, search) when search in ["", nil], do: query
defp list_shot_records_search(query, search) when search |> is_binary() do
trimmed_search = String.trim(search)
query
|> where(
[sr: sr, p: p, t: t],
fragment(
"? @@ websearch_to_tsquery('english', ?)",
sr.search,
^trimmed_search
) or
fragment(
"? @@ websearch_to_tsquery('english', ?)",
p.search,
^trimmed_search
) or
fragment(
"? @@ websearch_to_tsquery('english', ?)",
t.search,
^trimmed_search
)
)
|> order_by([sr: sr], {
:desc,
fragment(
"ts_rank_cd(?, websearch_to_tsquery('english', ?), 4)",
sr.search,
^trimmed_search
)
})
end
@spec list_shot_records_class(Queryable.t(), Type.class() | :all | nil) :: Queryable.t()
defp list_shot_records_class(query, class) when class in [:rifle, :pistol, :shotgun],
do: query |> where([t: t], t.class == ^class)
defp list_shot_records_class(query, _all), do: query
@spec list_shot_records_pack_id(Queryable.t(), Pack.id() | nil) :: Queryable.t()
defp list_shot_records_pack_id(query, pack_id) when pack_id |> is_binary(),
do: query |> where([sr: sr], sr.pack_id == ^pack_id)
defp list_shot_records_pack_id(query, _all), do: query
@doc """
Returns a count of shot records.
## Examples
iex> get_shot_record_count!(%User{id: 123})
3
"""
@spec get_shot_record_count!(User.t()) :: integer()
def get_shot_record_count!(%User{id: user_id}) do
Repo.one(
from sr in ShotRecord,
where: sr.user_id == ^user_id,
select: count(sr.id),
distinct: true
) || 0
end
@doc """
Gets a single shot_group.
Gets a single shot_record.
Raises `Ecto.NoResultsError` if the Shot group does not exist.
Raises `Ecto.NoResultsError` if the shot record does not exist.
## Examples
iex> get_shot_group!(123, %User{id: 123})
%ShotGroup{}
iex> get_shot_record!(123, %User{id: 123})
%ShotRecord{}
iex> get_shot_group!(456, %User{id: 123})
iex> get_shot_record!(456, %User{id: 123})
** (Ecto.NoResultsError)
"""
@spec get_shot_group!(ShotGroup.id(), User.t()) :: ShotGroup.t()
def get_shot_group!(id, %User{id: user_id}) do
@spec get_shot_record!(ShotRecord.id(), User.t()) :: ShotRecord.t()
def get_shot_record!(id, %User{id: user_id}) do
Repo.one!(
from sg in ShotGroup,
where: sg.id == ^id,
where: sg.user_id == ^user_id,
order_by: sg.date
from sr in ShotRecord,
where: sr.id == ^id,
where: sr.user_id == ^user_id,
order_by: sr.date
)
end
@doc """
Creates a shot_group.
Creates a shot_record.
## Examples
iex> create_shot_group(%{field: value}, %User{id: 123})
{:ok, %ShotGroup{}}
iex> create_shot_record(%{field: value}, %User{id: 123})
{:ok, %ShotRecord{}}
iex> create_shot_group(%{field: bad_value}, %User{id: 123})
iex> create_shot_record(%{field: bad_value}, %User{id: 123})
{:error, %Ecto.Changeset{}}
"""
@spec create_shot_group(attrs :: map(), User.t(), AmmoGroup.t()) ::
{:ok, ShotGroup.t()} | {:error, Changeset.t(ShotGroup.t()) | nil}
def create_shot_group(attrs, user, ammo_group) do
@spec create_shot_record(attrs :: map(), User.t(), Pack.t()) ::
{:ok, ShotRecord.t()} | {:error, ShotRecord.changeset() | nil}
def create_shot_record(attrs, user, pack) do
Multi.new()
|> Multi.insert(
:create_shot_group,
%ShotGroup{} |> ShotGroup.create_changeset(user, ammo_group, attrs)
:create_shot_record,
%ShotRecord{} |> ShotRecord.create_changeset(user, pack, attrs)
)
|> Multi.run(
:ammo_group,
fn repo, %{create_shot_group: %{ammo_group_id: ammo_group_id, user_id: user_id}} ->
{:ok,
repo.one(from ag in AmmoGroup, where: ag.id == ^ammo_group_id and ag.user_id == ^user_id)}
:pack,
fn _repo, %{create_shot_record: %{pack_id: pack_id, user_id: user_id}} ->
pack =
Repo.one(
from p in Pack,
where: p.id == ^pack_id,
where: p.user_id == ^user_id
)
{:ok, pack}
end
)
|> Multi.update(
:update_ammo_group,
fn %{create_shot_group: %{count: shot_group_count}, ammo_group: %{count: ammo_group_count}} ->
ammo_group |> AmmoGroup.range_changeset(%{"count" => ammo_group_count - shot_group_count})
:update_pack,
fn %{create_shot_record: %{count: shot_record_count}, pack: %{count: pack_count}} ->
pack |> Pack.range_changeset(%{"count" => pack_count - shot_record_count})
end
)
|> Repo.transaction()
|> case do
{:ok, %{create_shot_group: shot_group}} -> {:ok, shot_group}
{:error, :create_shot_group, changeset, _changes_so_far} -> {:error, changeset}
{:ok, %{create_shot_record: shot_record}} -> {:ok, shot_record}
{:error, :create_shot_record, changeset, _changes_so_far} -> {:error, changeset}
{:error, _other_transaction, _value, _changes_so_far} -> {:error, nil}
end
end
@doc """
Updates a shot_group.
Updates a shot_record.
## Examples
iex> update_shot_group(shot_group, %{field: new_value}, %User{id: 123})
{:ok, %ShotGroup{}}
iex> update_shot_record(shot_record, %{field: new_value}, %User{id: 123})
{:ok, %ShotRecord{}}
iex> update_shot_group(shot_group, %{field: bad_value}, %User{id: 123})
iex> update_shot_record(shot_record, %{field: bad_value}, %User{id: 123})
{:error, %Ecto.Changeset{}}
"""
@spec update_shot_group(ShotGroup.t(), attrs :: map(), User.t()) ::
{:ok, ShotGroup.t()} | {:error, Changeset.t(ShotGroup.t()) | nil}
def update_shot_group(
%ShotGroup{count: count, user_id: user_id} = shot_group,
@spec update_shot_record(ShotRecord.t(), attrs :: map(), User.t()) ::
{:ok, ShotRecord.t()} | {:error, ShotRecord.changeset() | nil}
def update_shot_record(
%ShotRecord{count: count, user_id: user_id} = shot_record,
attrs,
%User{id: user_id} = user
) do
Multi.new()
|> Multi.update(
:update_shot_group,
shot_group |> ShotGroup.update_changeset(user, attrs)
:update_shot_record,
shot_record |> ShotRecord.update_changeset(user, attrs)
)
|> Multi.run(
:ammo_group,
fn repo, %{update_shot_group: %{ammo_group_id: ammo_group_id, user_id: user_id}} ->
{:ok,
repo.one(from ag in AmmoGroup, where: ag.id == ^ammo_group_id and ag.user_id == ^user_id)}
:pack,
fn repo, %{update_shot_record: %{pack_id: pack_id, user_id: user_id}} ->
{:ok, repo.one(from p in Pack, where: p.id == ^pack_id and p.user_id == ^user_id)}
end
)
|> Multi.update(
:update_ammo_group,
:update_pack,
fn %{
update_shot_group: %{count: new_count},
ammo_group: %{count: ammo_group_count} = ammo_group
update_shot_record: %{count: new_count},
pack: %{count: pack_count} = pack
} ->
shot_diff_to_add = new_count - count
new_ammo_group_count = ammo_group_count - shot_diff_to_add
ammo_group |> AmmoGroup.range_changeset(%{"count" => new_ammo_group_count})
new_pack_count = pack_count - shot_diff_to_add
pack |> Pack.range_changeset(%{"count" => new_pack_count})
end
)
|> Repo.transaction()
|> case do
{:ok, %{update_shot_group: shot_group}} -> {:ok, shot_group}
{:error, :update_shot_group, changeset, _changes_so_far} -> {:error, changeset}
{:ok, %{update_shot_record: shot_record}} -> {:ok, shot_record}
{:error, :update_shot_record, changeset, _changes_so_far} -> {:error, changeset}
{:error, _other_transaction, _value, _changes_so_far} -> {:error, nil}
end
end
@doc """
Deletes a shot_group.
Deletes a shot_record.
## Examples
iex> delete_shot_group(shot_group, %User{id: 123})
{:ok, %ShotGroup{}}
iex> delete_shot_record(shot_record, %User{id: 123})
{:ok, %ShotRecord{}}
iex> delete_shot_group(shot_group, %User{id: 123})
iex> delete_shot_record(shot_record, %User{id: 123})
{:error, %Ecto.Changeset{}}
"""
@spec delete_shot_group(ShotGroup.t(), User.t()) ::
{:ok, ShotGroup.t()} | {:error, Changeset.t(ShotGroup.t())}
def delete_shot_group(
%ShotGroup{user_id: user_id} = shot_group,
@spec delete_shot_record(ShotRecord.t(), User.t()) ::
{:ok, ShotRecord.t()} | {:error, ShotRecord.changeset()}
def delete_shot_record(
%ShotRecord{user_id: user_id} = shot_record,
%User{id: user_id}
) do
Multi.new()
|> Multi.delete(:delete_shot_group, shot_group)
|> Multi.delete(:delete_shot_record, shot_record)
|> Multi.run(
:ammo_group,
fn repo, %{delete_shot_group: %{ammo_group_id: ammo_group_id, user_id: user_id}} ->
{:ok,
repo.one(from ag in AmmoGroup, where: ag.id == ^ammo_group_id and ag.user_id == ^user_id)}
:pack,
fn repo, %{delete_shot_record: %{pack_id: pack_id, user_id: user_id}} ->
{:ok, repo.one(from p in Pack, where: p.id == ^pack_id and p.user_id == ^user_id)}
end
)
|> Multi.update(
:update_ammo_group,
:update_pack,
fn %{
delete_shot_group: %{count: count},
ammo_group: %{count: ammo_group_count} = ammo_group
delete_shot_record: %{count: count},
pack: %{count: pack_count} = pack
} ->
new_ammo_group_count = ammo_group_count + count
ammo_group |> AmmoGroup.range_changeset(%{"count" => new_ammo_group_count})
new_pack_count = pack_count + count
pack |> Pack.range_changeset(%{"count" => new_pack_count})
end
)
|> Repo.transaction()
|> case do
{:ok, %{delete_shot_group: shot_group}} -> {:ok, shot_group}
{:error, :delete_shot_group, changeset, _changes_so_far} -> {:error, changeset}
{:ok, %{delete_shot_record: shot_record}} -> {:ok, shot_record}
{:error, :delete_shot_record, changeset, _changes_so_far} -> {:error, changeset}
{:error, _other_transaction, _value, _changes_so_far} -> {:error, nil}
end
end
@doc """
Returns the last entered shot record date for a pack
"""
@spec get_last_used_date(Pack.t(), User.t()) :: Date.t() | nil
def get_last_used_date(%Pack{id: pack_id} = pack, user) do
[pack]
|> get_last_used_dates(user)
|> Map.get(pack_id)
end
@doc """
Returns the last entered shot record date for a pack
"""
@spec get_last_used_dates([Pack.t()], User.t()) :: %{optional(Pack.id()) => Date.t()}
def get_last_used_dates(packs, %User{id: user_id}) do
pack_ids =
packs
|> Enum.map(fn %Pack{id: pack_id, user_id: ^user_id} -> pack_id end)
Repo.all(
from sr in ShotRecord,
where: sr.pack_id in ^pack_ids,
where: sr.user_id == ^user_id,
group_by: sr.pack_id,
select: {sr.pack_id, max(sr.date)}
)
|> Map.new()
end
@type get_used_count_option :: {:pack_id, Pack.id() | nil} | {:type_id, Type.id() | nil}
@type get_used_count_options :: [get_used_count_option()]
@doc """
Gets the total number of rounds shot for a type
Raises `Ecto.NoResultsError` if the type does not exist.
## Examples
iex> get_used_count(%User{id: 123}, type_id: 123)
35
iex> get_used_count(%User{id: 123}, pack_id: 456)
50
"""
@spec get_used_count(User.t(), get_used_count_options()) :: non_neg_integer()
def get_used_count(%User{id: user_id}, opts) do
from(sr in ShotRecord,
as: :sr,
left_join: p in Pack,
on: sr.pack_id == p.id,
on: p.user_id == ^user_id,
as: :p,
where: sr.user_id == ^user_id,
where: not (sr.count |> is_nil()),
select: sum(sr.count),
distinct: true
)
|> get_used_count_type_id(Keyword.get(opts, :type_id))
|> get_used_count_pack_id(Keyword.get(opts, :pack_id))
|> Repo.one() || 0
end
@spec get_used_count_pack_id(Queryable.t(), Pack.id() | nil) :: Queryable.t()
defp get_used_count_pack_id(query, pack_id) when pack_id |> is_binary() do
query |> where([sr: sr], sr.pack_id == ^pack_id)
end
defp get_used_count_pack_id(query, _nil), do: query
@spec get_used_count_type_id(Queryable.t(), Type.id() | nil) :: Queryable.t()
defp get_used_count_type_id(query, type_id) when type_id |> is_binary() do
query |> where([p: p], p.type_id == ^type_id)
end
defp get_used_count_type_id(query, _nil), do: query
@type get_grouped_used_counts_option ::
{:packs, [Pack.t()] | nil}
| {:types, [Type.t()] | nil}
| {:group_by, :type_id | :pack_id}
@type get_grouped_used_counts_options :: [get_grouped_used_counts_option()]
@doc """
Gets the total number of rounds shot for multiple types or packs
## Examples
iex> get_grouped_used_counts(
...> %User{id: 123},
...> group_by: :type_id,
...> types: [%Type{id: 456, user_id: 123}]
...> )
35
iex> get_grouped_used_counts(
...> %User{id: 123},
...> group_by: :pack_id,
...> packs: [%Pack{id: 456, user_id: 123}]
...> )
22
"""
@spec get_grouped_used_counts(User.t(), get_grouped_used_counts_options()) ::
%{optional(Type.id() | Pack.id()) => non_neg_integer()}
def get_grouped_used_counts(%User{id: user_id}, opts) do
from(p in Pack,
as: :p,
left_join: sr in ShotRecord,
on: p.id == sr.pack_id,
on: p.user_id == ^user_id,
as: :sr,
where: sr.user_id == ^user_id,
where: not (sr.count |> is_nil())
)
|> get_grouped_used_counts_group_by(Keyword.fetch!(opts, :group_by))
|> get_grouped_used_counts_types(Keyword.get(opts, :types))
|> get_grouped_used_counts_packs(Keyword.get(opts, :packs))
|> Repo.all()
|> Map.new()
end
@spec get_grouped_used_counts_group_by(Queryable.t(), :type_id | :pack_id) :: Queryable.t()
defp get_grouped_used_counts_group_by(query, :type_id) do
query
|> group_by([p: p], p.type_id)
|> select([sr: sr, p: p], {p.type_id, sum(sr.count)})
end
defp get_grouped_used_counts_group_by(query, :pack_id) do
query
|> group_by([sr: sr], sr.pack_id)
|> select([sr: sr], {sr.pack_id, sum(sr.count)})
end
@spec get_grouped_used_counts_types(Queryable.t(), [Type.t()] | nil) :: Queryable.t()
defp get_grouped_used_counts_types(query, types) when types |> is_list() do
type_ids = types |> Enum.map(fn %Type{id: type_id} -> type_id end)
query |> where([p: p], p.type_id in ^type_ids)
end
defp get_grouped_used_counts_types(query, _nil), do: query
@spec get_grouped_used_counts_packs(Queryable.t(), [Pack.t()] | nil) :: Queryable.t()
defp get_grouped_used_counts_packs(query, packs) when packs |> is_list() do
pack_ids = packs |> Enum.map(fn %Pack{id: pack_id} -> pack_id end)
query |> where([p: p], p.id in ^pack_ids)
end
defp get_grouped_used_counts_packs(query, _nil), do: query
end

View File

@ -1,121 +0,0 @@
defmodule Cannery.ActivityLog.ShotGroup do
@moduledoc """
A shot group records a group of ammo shot during a range trip
"""
use Ecto.Schema
import CanneryWeb.Gettext
import Ecto.Changeset
alias Cannery.{Accounts.User, ActivityLog.ShotGroup, Ammo.AmmoGroup, Repo}
alias Ecto.{Changeset, UUID}
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "shot_groups" do
field :count, :integer
field :date, :date
field :notes, :string
belongs_to :user, User
belongs_to :ammo_group, AmmoGroup
timestamps()
end
@type t :: %ShotGroup{
id: id(),
count: integer,
notes: String.t() | nil,
date: Date.t() | nil,
ammo_group: AmmoGroup.t() | nil,
ammo_group_id: AmmoGroup.id(),
user: User.t() | nil,
user_id: User.id(),
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
@type new_shot_group :: %ShotGroup{}
@type id :: UUID.t()
@doc false
@spec create_changeset(
new_shot_group(),
User.t() | any(),
AmmoGroup.t() | any(),
attrs :: map()
) ::
Changeset.t(new_shot_group())
def create_changeset(
shot_group,
%User{id: user_id},
%AmmoGroup{id: ammo_group_id, user_id: user_id} = ammo_group,
attrs
)
when not (user_id |> is_nil()) and not (ammo_group_id |> is_nil()) do
shot_group
|> change(user_id: user_id)
|> change(ammo_group_id: ammo_group_id)
|> cast(attrs, [:count, :notes, :date])
|> validate_number(:count, greater_than: 0)
|> validate_create_shot_group_count(ammo_group)
|> validate_required([:count, :ammo_group_id, :user_id])
end
def create_changeset(shot_group, _invalid_user, _invalid_ammo_group, attrs) do
shot_group
|> cast(attrs, [:count, :notes, :date])
|> validate_number(:count, greater_than: 0)
|> validate_required([:count, :ammo_group_id, :user_id])
|> add_error(:invalid, dgettext("errors", "Please select a valid user and ammo group"))
end
defp validate_create_shot_group_count(changeset, %AmmoGroup{count: ammo_group_count}) do
if changeset |> Changeset.get_field(:count) > ammo_group_count do
error = dgettext("errors", "Count must be less than %{count}", count: ammo_group_count)
changeset |> Changeset.add_error(:count, error)
else
changeset
end
end
@doc false
@spec update_changeset(t() | new_shot_group(), User.t(), attrs :: map()) ::
Changeset.t(t() | new_shot_group())
def update_changeset(
%ShotGroup{user_id: user_id} = shot_group,
%User{id: user_id} = user,
attrs
)
when not (user_id |> is_nil()) do
shot_group
|> cast(attrs, [:count, :notes, :date])
|> validate_number(:count, greater_than: 0)
|> validate_required([:count])
|> validate_update_shot_group_count(shot_group, user)
end
defp validate_update_shot_group_count(
changeset,
%ShotGroup{count: count} = shot_group,
%User{id: user_id}
)
when not (user_id |> is_nil()) do
%{ammo_group: %AmmoGroup{count: ammo_group_count, user_id: ^user_id}} =
shot_group |> Repo.preload(:ammo_group)
new_shot_group_count = changeset |> Changeset.get_field(:count)
shot_diff_to_add = new_shot_group_count - count
cond do
shot_diff_to_add > ammo_group_count ->
error = dgettext("errors", "Count must be less than %{count}", count: ammo_group_count)
changeset |> Changeset.add_error(:count, error)
new_shot_group_count <= 0 ->
changeset |> Changeset.add_error(:count, dgettext("errors", "Count must be at least 1"))
true ->
changeset
end
end
end

View File

@ -0,0 +1,126 @@
defmodule Cannery.ActivityLog.ShotRecord do
@moduledoc """
A shot record records a group of ammo shot during a range trip
"""
use Ecto.Schema
import CanneryWeb.Gettext
import Ecto.Changeset
alias Cannery.{Accounts.User, Ammo, Ammo.Pack}
alias Ecto.{Changeset, UUID}
@derive {Jason.Encoder,
only: [
:id,
:count,
:date,
:notes,
:pack_id
]}
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "shot_records" do
field :count, :integer
field :date, :date
field :notes, :string
field :user_id, :binary_id
field :pack_id, :binary_id
timestamps()
end
@type t :: %__MODULE__{
id: id(),
count: integer,
notes: String.t() | nil,
date: Date.t() | nil,
pack_id: Pack.id(),
user_id: User.id(),
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
@type new_shot_record :: %__MODULE__{}
@type id :: UUID.t()
@type changeset :: Changeset.t(t() | new_shot_record())
@doc false
@spec create_changeset(
new_shot_record(),
User.t() | any(),
Pack.t() | any(),
attrs :: map()
) :: changeset()
def create_changeset(
shot_record,
%User{id: user_id},
%Pack{id: pack_id, user_id: user_id} = pack,
attrs
) do
shot_record
|> change(user_id: user_id)
|> change(pack_id: pack_id)
|> cast(attrs, [:count, :notes, :date])
|> validate_length(:notes, max: 255)
|> validate_create_shot_record_count(pack)
|> validate_required([:date, :pack_id, :user_id])
end
def create_changeset(shot_record, _invalid_user, _invalid_pack, attrs) do
shot_record
|> cast(attrs, [:count, :notes, :date])
|> validate_length(:notes, max: 255)
|> validate_required([:pack_id, :user_id])
|> add_error(:invalid, dgettext("errors", "Please select a valid user and ammo pack"))
end
defp validate_create_shot_record_count(changeset, %Pack{count: pack_count}) do
case changeset |> Changeset.get_field(:count) do
nil ->
changeset |> Changeset.add_error(:ammo_left, dgettext("errors", "can't be blank"))
count when count > pack_count ->
changeset
|> Changeset.add_error(:ammo_left, dgettext("errors", "Ammo left must be at least 0"))
count when count <= 0 ->
error =
dgettext("errors", "Ammo left can be at most %{count} rounds", count: pack_count - 1)
changeset |> Changeset.add_error(:ammo_left, error)
_valid_count ->
changeset
end
end
@doc false
@spec update_changeset(t() | new_shot_record(), User.t(), attrs :: map()) :: changeset()
def update_changeset(%__MODULE__{} = shot_record, user, attrs) do
shot_record
|> cast(attrs, [:count, :notes, :date])
|> validate_length(:notes, max: 255)
|> validate_number(:count, greater_than: 0)
|> validate_required([:count, :date])
|> validate_update_shot_record_count(shot_record, user)
end
defp validate_update_shot_record_count(
changeset,
%__MODULE__{pack_id: pack_id, count: count},
user
) do
%{count: pack_count} = Ammo.get_pack!(pack_id, user)
new_shot_record_count = changeset |> Changeset.get_field(:count)
shot_diff_to_add = new_shot_record_count - count
if shot_diff_to_add > pack_count do
error = dgettext("errors", "Count can be at most %{count} shots", count: pack_count + count)
changeset |> Changeset.add_error(:count, error)
else
changeset
end
end
end

File diff suppressed because it is too large Load Diff

View File

@ -1,108 +0,0 @@
defmodule Cannery.Ammo.AmmoGroup do
@moduledoc """
A group of a certain ammunition type.
Can be placed in a container, and contains auxiliary information such as the
amount paid for that ammunition, or what condition it is in
"""
use Ecto.Schema
import CanneryWeb.Gettext
import Ecto.Changeset
alias Cannery.Ammo.{AmmoGroup, AmmoType}
alias Cannery.{Accounts.User, ActivityLog.ShotGroup, Containers.Container}
alias Ecto.{Changeset, UUID}
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "ammo_groups" do
field :count, :integer
field :notes, :string
field :price_paid, :float
field :staged, :boolean, default: false
belongs_to :ammo_type, AmmoType
belongs_to :container, Container
belongs_to :user, User
has_many :shot_groups, ShotGroup
timestamps()
end
@type t :: %AmmoGroup{
id: id(),
count: integer,
notes: String.t() | nil,
price_paid: float() | nil,
staged: boolean(),
ammo_type: AmmoType.t() | nil,
ammo_type_id: AmmoType.id(),
container: Container.t() | nil,
container_id: Container.id(),
user: User.t() | nil,
user_id: User.id(),
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
@type new_ammo_group :: %AmmoGroup{}
@type id :: UUID.t()
@doc false
@spec create_changeset(
new_ammo_group(),
AmmoType.t() | nil,
Container.t() | nil,
User.t(),
attrs :: map()
) :: Changeset.t(new_ammo_group())
def create_changeset(
ammo_group,
%AmmoType{id: ammo_type_id},
%Container{id: container_id, user_id: user_id},
%User{id: user_id},
attrs
)
when not (ammo_type_id |> is_nil()) and not (container_id |> is_nil()) and
not (user_id |> is_nil()) do
ammo_group
|> change(ammo_type_id: ammo_type_id)
|> change(user_id: user_id)
|> change(container_id: container_id)
|> cast(attrs, [:count, :price_paid, :notes, :staged])
|> validate_number(:count, greater_than: 0)
|> validate_required([:count, :staged, :ammo_type_id, :container_id, :user_id])
end
@doc """
Invalid changeset, used to prompt user to select ammo type and container
"""
def create_changeset(ammo_group, _invalid_ammo_type, _invalid_container, _invalid_user, attrs) do
ammo_group
|> cast(attrs, [:ammo_type_id, :container_id])
|> validate_required([:ammo_type_id, :container_id])
|> add_error(:invalid, dgettext("errors", "Please select an ammo type and container"))
end
@doc false
@spec update_changeset(t() | new_ammo_group(), attrs :: map()) ::
Changeset.t(t() | new_ammo_group())
def update_changeset(ammo_group, attrs) do
ammo_group
|> cast(attrs, [:count, :price_paid, :notes, :staged])
|> validate_number(:count, greater_than_or_equal_to: 0)
|> validate_required([:count, :staged])
end
@doc """
This range changeset is used when "using up" ammo groups, and allows for
updating the count to 0
"""
@spec range_changeset(t() | new_ammo_group(), attrs :: map()) ::
Changeset.t(t() | new_ammo_group())
def range_changeset(ammo_group, attrs) do
ammo_group
|> cast(attrs, [:count, :staged])
|> validate_required([:count, :staged])
end
end

View File

@ -1,125 +0,0 @@
defmodule Cannery.Ammo.AmmoType do
@moduledoc """
An ammunition type.
Contains statistical information about the ammunition.
"""
use Ecto.Schema
import Ecto.Changeset
alias Cannery.Accounts.User
alias Cannery.Ammo.{AmmoGroup, AmmoType}
alias Ecto.{Changeset, UUID}
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "ammo_types" do
field :name, :string
field :desc, :string
# https://en.wikipedia.org/wiki/Bullet#Abbreviations
field :bullet_type, :string
field :bullet_core, :string
field :cartridge, :string
field :caliber, :string
field :case_material, :string
field :jacket_type, :string
field :muzzle_velocity, :integer
field :powder_type, :string
field :powder_grains_per_charge, :integer
field :grains, :integer
field :pressure, :string
field :primer_type, :string
field :firing_type, :string
field :tracer, :boolean, default: false
field :incendiary, :boolean, default: false
field :blank, :boolean, default: false
field :corrosive, :boolean, default: false
field :manufacturer, :string
field :upc, :string
belongs_to :user, User
has_many :ammo_groups, AmmoGroup
timestamps()
end
@type t :: %AmmoType{
id: id(),
name: String.t(),
desc: String.t() | nil,
bullet_type: String.t() | nil,
bullet_core: String.t() | nil,
cartridge: String.t() | nil,
caliber: String.t() | nil,
case_material: String.t() | nil,
jacket_type: String.t() | nil,
muzzle_velocity: integer() | nil,
powder_type: String.t() | nil,
powder_grains_per_charge: integer() | nil,
grains: integer() | nil,
pressure: String.t() | nil,
primer_type: String.t() | nil,
firing_type: String.t() | nil,
tracer: boolean(),
incendiary: boolean(),
blank: boolean(),
corrosive: boolean(),
manufacturer: String.t() | nil,
upc: String.t() | nil,
user_id: User.id(),
user: User.t() | nil,
ammo_groups: [AmmoGroup.t()] | nil,
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
@type new_ammo_type :: %AmmoType{}
@type id :: UUID.t()
@spec changeset_fields() :: [atom()]
defp changeset_fields,
do: [
:name,
:desc,
:bullet_type,
:bullet_core,
:cartridge,
:caliber,
:case_material,
:jacket_type,
:muzzle_velocity,
:powder_type,
:powder_grains_per_charge,
:grains,
:pressure,
:primer_type,
:firing_type,
:tracer,
:incendiary,
:blank,
:corrosive,
:manufacturer,
:upc
]
@doc false
@spec create_changeset(new_ammo_type(), User.t(), attrs :: map()) ::
Changeset.t(new_ammo_type())
def create_changeset(ammo_type, %User{id: user_id}, attrs) do
ammo_type
|> change(user_id: user_id)
|> cast(attrs, changeset_fields())
|> validate_required([:name, :user_id])
end
@doc false
@spec update_changeset(t() | new_ammo_type(), attrs :: map()) ::
Changeset.t(t() | new_ammo_type())
def update_changeset(ammo_type, attrs) do
ammo_type
|> cast(attrs, changeset_fields())
|> validate_required(:name)
end
end

143
lib/cannery/ammo/pack.ex Normal file
View File

@ -0,0 +1,143 @@
defmodule Cannery.Ammo.Pack do
@moduledoc """
A group of a certain ammunition type.
Can be placed in a container, and contains auxiliary information such as the
amount paid for that ammunition, or what condition it is in
"""
use Ecto.Schema
import CanneryWeb.Gettext
import Ecto.Changeset
alias Cannery.Ammo.Type
alias Cannery.{Accounts.User, Containers, Containers.Container}
alias Ecto.{Changeset, UUID}
@derive {Jason.Encoder,
only: [
:id,
:count,
:notes,
:price_paid,
:lot_number,
:staged,
:type_id,
:container_id
]}
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "packs" do
field :count, :integer
field :notes, :string
field :price_paid, :float
field :staged, :boolean, default: false
field :lot_number, :string
field :purchased_on, :date
belongs_to :type, Type
field :container_id, :binary_id
field :user_id, :binary_id
timestamps()
end
@type t :: %__MODULE__{
id: id(),
count: integer,
notes: String.t() | nil,
price_paid: float() | nil,
staged: boolean(),
lot_number: String.t() | nil,
purchased_on: Date.t(),
type: Type.t() | nil,
type_id: Type.id(),
container_id: Container.id(),
user_id: User.id(),
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
@type new_pack :: %__MODULE__{}
@type id :: UUID.t()
@type changeset :: Changeset.t(t() | new_pack())
@doc false
@spec create_changeset(
new_pack(),
Type.t() | nil,
Container.t() | nil,
User.t(),
attrs :: map()
) :: changeset()
def create_changeset(
pack,
%Type{id: type_id},
%Container{id: container_id, user_id: user_id},
%User{id: user_id},
attrs
)
when is_binary(type_id) and is_binary(container_id) and is_binary(user_id) do
pack
|> change(type_id: type_id)
|> change(user_id: user_id)
|> change(container_id: container_id)
|> cast(attrs, [:count, :price_paid, :notes, :staged, :purchased_on, :lot_number])
|> validate_number(:count, greater_than: 0)
|> validate_number(:price_paid, greater_than_or_equal_to: 0)
|> validate_length(:lot_number, max: 255)
|> validate_required([:count, :staged, :purchased_on, :type_id, :container_id, :user_id])
end
@doc """
Invalid changeset, used to prompt user to select type and container
"""
def create_changeset(pack, _invalid_type, _invalid_container, _invalid_user, attrs) do
pack
|> cast(attrs, [:type_id, :container_id])
|> validate_required([:type_id, :container_id])
|> validate_number(:count, greater_than: 0)
|> validate_number(:price_paid, greater_than_or_equal_to: 0)
|> validate_length(:lot_number, max: 255)
|> add_error(:invalid, dgettext("errors", "Please select a type and container"))
end
@doc false
@spec update_changeset(t() | new_pack(), attrs :: map(), User.t()) :: changeset()
def update_changeset(pack, attrs, user) do
pack
|> cast(attrs, [
:count,
:price_paid,
:notes,
:staged,
:purchased_on,
:lot_number,
:container_id
])
|> validate_number(:count, greater_than_or_equal_to: 0)
|> validate_number(:price_paid, greater_than_or_equal_to: 0)
|> validate_container_id(user)
|> validate_length(:lot_number, max: 255)
|> validate_required([:count, :staged, :purchased_on, :container_id])
end
defp validate_container_id(changeset, user) do
container_id = changeset |> Changeset.get_field(:container_id)
if container_id do
Containers.get_container!(container_id, user)
end
changeset
end
@doc """
This range changeset is used when "using up" packs, and allows for
updating the count to 0
"""
@spec range_changeset(t() | new_pack(), attrs :: map()) :: changeset()
def range_changeset(pack, attrs) do
pack
|> cast(attrs, [:count, :staged])
|> validate_required([:count, :staged])
end
end

234
lib/cannery/ammo/type.ex Normal file
View File

@ -0,0 +1,234 @@
defmodule Cannery.Ammo.Type do
@moduledoc """
An ammunition type.
Contains statistical information about the ammunition.
"""
use Ecto.Schema
import Ecto.Changeset
alias Cannery.Accounts.User
alias Cannery.Ammo.Pack
alias Ecto.{Changeset, UUID}
@derive {Jason.Encoder,
only: [
:name,
:desc,
:class,
:bullet_type,
:bullet_core,
:caliber,
:case_material,
:powder_type,
:grains,
:pressure,
:primer_type,
:firing_type,
:manufacturer,
:upc,
:tracer,
:incendiary,
:blank,
:corrosive,
:cartridge,
:jacket_type,
:powder_grains_per_charge,
:muzzle_velocity,
:wadding,
:shot_type,
:shot_material,
:shot_size,
:unfired_length,
:brass_height,
:chamber_size,
:load_grains,
:shot_charge_weight,
:dram_equivalent
]}
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "types" do
field :name, :string
field :desc, :string
field :class, Ecto.Enum, values: [:rifle, :shotgun, :pistol], default: :rifle
# common fields
field :bullet_core, :string
# also gauge for shotguns
field :caliber, :string
field :case_material, :string
field :powder_type, :string
field :grains, :integer
field :pressure, :string
field :primer_type, :string
field :firing_type, :string
field :manufacturer, :string
field :upc, :string
field :tracer, :boolean, default: false
field :incendiary, :boolean, default: false
field :blank, :boolean, default: false
field :corrosive, :boolean, default: false
# rifle/pistol fields
# https://shootersreference.com/reloadingdata/bullet_abbreviations/
field :bullet_type, :string
field :cartridge, :string
field :jacket_type, :string
field :powder_grains_per_charge, :integer
field :muzzle_velocity, :integer
# shotgun fields
field :wadding, :string
field :shot_type, :string
field :shot_material, :string
field :shot_size, :string
field :unfired_length, :string
field :brass_height, :string
field :chamber_size, :string
field :load_grains, :integer
field :shot_charge_weight, :string
field :dram_equivalent, :string
field :user_id, :binary_id
has_many :packs, Pack
timestamps()
end
@type t :: %__MODULE__{
id: id(),
name: String.t(),
desc: String.t() | nil,
class: class(),
bullet_type: String.t() | nil,
bullet_core: String.t() | nil,
caliber: String.t() | nil,
case_material: String.t() | nil,
powder_type: String.t() | nil,
grains: integer() | nil,
pressure: String.t() | nil,
primer_type: String.t() | nil,
firing_type: String.t() | nil,
manufacturer: String.t() | nil,
upc: String.t() | nil,
tracer: boolean(),
incendiary: boolean(),
blank: boolean(),
corrosive: boolean(),
cartridge: String.t() | nil,
jacket_type: String.t() | nil,
powder_grains_per_charge: integer() | nil,
muzzle_velocity: integer() | nil,
wadding: String.t() | nil,
shot_type: String.t() | nil,
shot_material: String.t() | nil,
shot_size: String.t() | nil,
unfired_length: String.t() | nil,
brass_height: String.t() | nil,
chamber_size: String.t() | nil,
load_grains: integer() | nil,
shot_charge_weight: String.t() | nil,
dram_equivalent: String.t() | nil,
user_id: User.id(),
packs: [Pack.t()] | nil,
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
@type new_type :: %__MODULE__{}
@type id :: UUID.t()
@type changeset :: Changeset.t(t() | new_type())
@type class :: :rifle | :shotgun | :pistol | nil
@spec changeset_fields() :: [atom()]
defp changeset_fields,
do: [
:name,
:desc,
:class,
:bullet_type,
:bullet_core,
:caliber,
:case_material,
:powder_type,
:grains,
:pressure,
:primer_type,
:firing_type,
:manufacturer,
:upc,
:tracer,
:incendiary,
:blank,
:corrosive,
:cartridge,
:jacket_type,
:powder_grains_per_charge,
:muzzle_velocity,
:wadding,
:shot_type,
:shot_material,
:shot_size,
:unfired_length,
:brass_height,
:chamber_size,
:load_grains,
:shot_charge_weight,
:dram_equivalent
]
@spec string_fields() :: [atom()]
defp string_fields,
do: [
:name,
:desc,
:bullet_type,
:bullet_core,
:caliber,
:case_material,
:powder_type,
:pressure,
:primer_type,
:firing_type,
:manufacturer,
:upc,
:cartridge,
:jacket_type,
:wadding,
:shot_type,
:shot_material,
:shot_size,
:unfired_length,
:brass_height,
:chamber_size,
:shot_charge_weight,
:dram_equivalent
]
@doc false
@spec create_changeset(new_type(), User.t(), attrs :: map()) :: changeset()
def create_changeset(type, %User{id: user_id}, attrs) do
changeset =
type
|> change(user_id: user_id)
|> cast(attrs, changeset_fields())
string_fields()
|> Enum.reduce(changeset, fn field, acc -> acc |> validate_length(field, max: 255) end)
|> validate_required([:name, :user_id])
end
@doc false
@spec update_changeset(t() | new_type(), attrs :: map()) :: changeset()
def update_changeset(type, attrs) do
changeset =
type
|> cast(attrs, changeset_fields())
string_fields()
|> Enum.reduce(changeset, fn field, acc -> acc |> validate_length(field, max: 255) end)
|> validate_required(:name)
end
end

View File

@ -4,6 +4,7 @@ defmodule Cannery.Application do
@moduledoc false
use Application
alias Cannery.Logger
@impl true
def start(_type, _args) do
@ -17,16 +18,24 @@ defmodule Cannery.Application do
# Start the Endpoint (http/https)
CanneryWeb.Endpoint,
# Add Oban
{Oban, oban_config()}
{Oban, oban_config()},
Cannery.Repo.Migrator
# Start a worker by calling: Cannery.Worker.start_link(arg)
# {Cannery.Worker, arg}
]
# Automatically migrate on start in prod
children =
if Application.get_env(:cannery, Cannery.Application, automigrate: false)[:automigrate],
do: children ++ [Cannery.Repo.Migrator],
else: children
# Oban events logging https://hexdocs.pm/oban/Oban.html#module-reporting-errors
:ok =
:telemetry.attach_many(
"oban-logger",
[
[:oban, :job, :exception],
[:oban, :job, :start],
[:oban, :job, :stop]
],
&Logger.handle_event/4,
[]
)
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options

View File

@ -0,0 +1,12 @@
defmodule Cannery.ComparableDate do
@moduledoc """
A custom `Date` module that provides a `compare/2` function that is comparable
with nil values
"""
@spec compare(Date.t() | any(), Date.t() | any()) :: :lt | :gt | :eq
def compare(%Date{} = date_1, %Date{} = date_2), do: Date.compare(date_1, date_2)
def compare(%Date{}, _date_2), do: :lt
def compare(_date_1, %Date{}), do: :gt
def compare(_date_1, _date_2), do: :eq
end

View File

@ -0,0 +1,15 @@
defmodule Cannery.ComparableDateTime do
@moduledoc """
A custom `DateTime` module that provides a `compare/2` function that is
comparable with nil values
"""
@spec compare(DateTime.t() | any(), DateTime.t() | any()) :: :lt | :gt | :eq
def compare(%DateTime{} = datetime_1, %DateTime{} = datetime_2) do
DateTime.compare(datetime_1, datetime_2)
end
def compare(%DateTime{}, _datetime_2), do: :lt
def compare(_datetime_1, %DateTime{}), do: :gt
def compare(_datetime_1, _datetime_2), do: :eq
end

View File

@ -5,9 +5,14 @@ defmodule Cannery.Containers do
import CanneryWeb.Gettext
import Ecto.Query, warn: false
alias Cannery.{Accounts.User, Ammo.AmmoGroup, Repo, Tags.Tag}
alias Cannery.Containers.{Container, ContainerTag}
alias Ecto.Changeset
alias Cannery.{Accounts.User, Ammo.Pack, Repo}
alias Cannery.Containers.{Container, ContainerTag, Tag}
alias Ecto.{Changeset, Queryable}
@container_preloads [:tags]
@type list_containers_option :: {:search, String.t() | nil}
@type list_containers_options :: [list_containers_option()]
@doc """
Returns the list of containers.
@ -17,16 +22,55 @@ defmodule Cannery.Containers do
iex> list_containers(%User{id: 123})
[%Container{}, ...]
iex> list_containers(%User{id: 123}, search: "cool")
[%Container{name: "my cool container"}, ...]
"""
@spec list_containers(User.t()) :: [Container.t()]
def list_containers(%User{id: user_id}) do
Repo.all(
from c in Container,
left_join: t in assoc(c, :tags),
left_join: ag in assoc(c, :ammo_groups),
where: c.user_id == ^user_id,
order_by: c.name,
preload: [tags: t, ammo_groups: ag]
@spec list_containers(User.t(), list_containers_options()) :: [Container.t()]
def list_containers(%User{id: user_id}, opts \\ []) do
from(c in Container,
as: :c,
left_join: t in assoc(c, :tags),
on: c.user_id == t.user_id,
as: :t,
where: c.user_id == ^user_id,
distinct: c.id,
preload: ^@container_preloads
)
|> list_containers_search(Keyword.get(opts, :search))
|> Repo.all()
end
@spec list_containers_search(Queryable.t(), search :: String.t() | nil) :: Queryable.t()
defp list_containers_search(query, search) when search in ["", nil],
do: query |> order_by([c: c], c.name)
defp list_containers_search(query, search) when search |> is_binary() do
trimmed_search = String.trim(search)
query
|> where(
[c: c, t: t],
fragment(
"? @@ websearch_to_tsquery('english', ?)",
c.search,
^trimmed_search
) or
fragment(
"? @@ websearch_to_tsquery('english', ?)",
t.search,
^trimmed_search
)
)
|> order_by(
[c: c],
desc:
fragment(
"ts_rank_cd(?, websearch_to_tsquery('english', ?), 4)",
c.search,
^trimmed_search
)
)
end
@ -52,7 +96,7 @@ defmodule Cannery.Containers do
@doc """
Gets a single container.
Raises `Ecto.NoResultsError` if the Container does not exist.
Raises `KeyError` if the Container does not exist.
## Examples
@ -60,20 +104,37 @@ defmodule Cannery.Containers do
%Container{}
iex> get_container!(456, %User{id: 123})
** (Ecto.NoResultsError)
** (KeyError)
"""
@spec get_container!(Container.id(), User.t()) :: Container.t()
def get_container!(id, %User{id: user_id}) do
Repo.one!(
def get_container!(id, user) do
[id]
|> get_containers(user)
|> Map.fetch!(id)
end
@doc """
Gets multiple containers.
## Examples
iex> get_containers([123], %User{id: 123})
%{123 => %Container{}}
"""
@spec get_containers([Container.id()], User.t()) :: %{optional(Container.id()) => Container.t()}
def get_containers(ids, %User{id: user_id}) do
Repo.all(
from c in Container,
left_join: t in assoc(c, :tags),
left_join: ag in assoc(c, :ammo_groups),
where: c.user_id == ^user_id,
where: c.id == ^id,
where: c.id in ^ids,
order_by: c.name,
preload: [tags: t, ammo_groups: ag]
preload: ^@container_preloads,
select: {c.id, c}
)
|> Map.new()
end
@doc """
@ -89,9 +150,21 @@ defmodule Cannery.Containers do
"""
@spec create_container(attrs :: map(), User.t()) ::
{:ok, Container.t()} | {:error, Changeset.t(Container.new_container())}
{:ok, Container.t()} | {:error, Container.changeset()}
def create_container(attrs, %User{} = user) do
%Container{} |> Container.create_changeset(user, attrs) |> Repo.insert()
%Container{}
|> Container.create_changeset(user, attrs)
|> Repo.insert()
|> case do
{:ok, container} -> {:ok, container |> preload_container()}
{:error, changeset} -> {:error, changeset}
end
end
@spec preload_container(Container.t()) :: Container.t()
@spec preload_container([Container.t()]) :: [Container.t()]
def preload_container(container) do
container |> Repo.preload(@container_preloads)
end
@doc """
@ -107,9 +180,15 @@ defmodule Cannery.Containers do
"""
@spec update_container(Container.t(), User.t(), attrs :: map()) ::
{:ok, Container.t()} | {:error, Changeset.t(Container.t())}
{:ok, Container.t()} | {:error, Container.changeset()}
def update_container(%Container{user_id: user_id} = container, %User{id: user_id}, attrs) do
container |> Container.update_changeset(attrs) |> Repo.update()
container
|> Container.update_changeset(attrs)
|> Repo.update()
|> case do
{:ok, container} -> {:ok, container |> preload_container()}
{:error, changeset} -> {:error, changeset}
end
end
@doc """
@ -125,23 +204,28 @@ defmodule Cannery.Containers do
"""
@spec delete_container(Container.t(), User.t()) ::
{:ok, Container.t()} | {:error, Changeset.t(Container.t())}
{:ok, Container.t()} | {:error, Container.changeset()}
def delete_container(%Container{user_id: user_id} = container, %User{id: user_id}) do
Repo.one(
from ag in AmmoGroup,
where: ag.container_id == ^container.id,
select: count(ag.id)
from p in Pack,
where: p.container_id == ^container.id,
select: count(p.id)
)
|> case do
0 ->
container |> Repo.delete()
container
|> Repo.delete()
|> case do
{:ok, container} -> {:ok, container |> preload_container()}
{:error, changeset} -> {:error, changeset}
end
_amount ->
error = dgettext("errors", "Container must be empty before deleting")
container
|> Container.update_changeset(%{})
|> Changeset.add_error(:ammo_groups, error)
|> Changeset.add_error(:packs, error)
|> Changeset.apply_action(:delete)
end
end
@ -175,8 +259,11 @@ defmodule Cannery.Containers do
%Container{user_id: user_id} = container,
%Tag{user_id: user_id} = tag,
%User{id: user_id}
),
do: %ContainerTag{} |> ContainerTag.create_changeset(tag, container) |> Repo.insert!()
) do
%ContainerTag{}
|> ContainerTag.create_changeset(tag, container)
|> Repo.insert!()
end
@doc """
Removes a tag from a container
@ -187,44 +274,182 @@ defmodule Cannery.Containers do
%Container{}
"""
@spec remove_tag!(Container.t(), Tag.t(), User.t()) :: non_neg_integer()
@spec remove_tag!(Container.t(), Tag.t(), User.t()) :: {non_neg_integer(), [ContainerTag.t()]}
def remove_tag!(
%Container{id: container_id, user_id: user_id},
%Tag{id: tag_id, user_id: user_id},
%User{id: user_id}
) do
{count, _} =
{count, results} =
Repo.delete_all(
from ct in ContainerTag,
where: ct.container_id == ^container_id,
where: ct.tag_id == ^tag_id
where: ct.tag_id == ^tag_id,
select: ct
)
if count == 0, do: raise("could not delete container tag"), else: count
if count == 0, do: raise("could not delete container tag"), else: {count, results}
end
# Container Tags
@type list_tags_option :: {:search, String.t() | nil}
@type list_tags_options :: [list_tags_option()]
@doc """
Returns the list of tags.
## Examples
iex> list_tags(%User{id: 123})
[%Tag{}, ...]
iex> list_tags(%User{id: 123}, search: "cool")
[%Tag{name: "my cool tag"}, ...]
"""
@spec list_tags(User.t()) :: [Tag.t()]
@spec list_tags(User.t(), list_tags_options()) :: [Tag.t()]
def list_tags(%User{id: user_id}, opts \\ []) do
from(t in Tag, as: :t, where: t.user_id == ^user_id)
|> list_tags_search(Keyword.get(opts, :search))
|> Repo.all()
end
@spec list_tags_search(Queryable.t(), search :: String.t() | nil) :: Queryable.t()
defp list_tags_search(query, search) when search in ["", nil],
do: query |> order_by([t: t], t.name)
defp list_tags_search(query, search) when search |> is_binary() do
trimmed_search = String.trim(search)
query
|> where(
[t: t],
fragment(
"? @@ websearch_to_tsquery('english', ?)",
t.search,
^trimmed_search
)
)
|> order_by([t: t], {
:desc,
fragment(
"ts_rank_cd(?, websearch_to_tsquery('english', ?), 4)",
t.search,
^trimmed_search
)
})
end
@doc """
Returns number of rounds in container. If data is already preloaded, then
there will be no db hit.
Gets a single tag.
## Examples
iex> get_tag(123, %User{id: 123})
{:ok, %Tag{}}
iex> get_tag(456, %User{id: 123})
{:error, :not_found}
"""
@spec get_container_ammo_group_count!(Container.t()) :: non_neg_integer()
def get_container_ammo_group_count!(%Container{} = container) do
container
|> Repo.preload(:ammo_groups)
|> Map.fetch!(:ammo_groups)
|> Enum.count()
@spec get_tag(Tag.id(), User.t()) :: {:ok, Tag.t()} | {:error, :not_found}
def get_tag(id, %User{id: user_id}) do
Repo.one(from t in Tag, where: t.id == ^id and t.user_id == ^user_id)
|> case do
nil -> {:error, :not_found}
tag -> {:ok, tag}
end
end
@doc """
Returns number of rounds in container. If data is already preloaded, then
there will be no db hit.
Gets a single tag.
Raises `Ecto.NoResultsError` if the Tag does not exist.
## Examples
iex> get_tag!(123, %User{id: 123})
%Tag{}
iex> get_tag!(456, %User{id: 123})
** (Ecto.NoResultsError)
"""
@spec get_container_rounds!(Container.t()) :: non_neg_integer()
def get_container_rounds!(%Container{} = container) do
container
|> Repo.preload(:ammo_groups)
|> Map.fetch!(:ammo_groups)
|> Enum.map(fn %{count: count} -> count end)
|> Enum.sum()
@spec get_tag!(Tag.id(), User.t()) :: Tag.t()
def get_tag!(id, %User{id: user_id}) do
Repo.one!(
from t in Tag,
where: t.id == ^id,
where: t.user_id == ^user_id
)
end
@doc """
Creates a tag.
## Examples
iex> create_tag(%{field: value}, %User{id: 123})
{:ok, %Tag{}}
iex> create_tag(%{field: bad_value}, %User{id: 123})
{:error, %Changeset{}}
"""
@spec create_tag(attrs :: map(), User.t()) ::
{:ok, Tag.t()} | {:error, Tag.changeset()}
def create_tag(attrs, %User{} = user) do
%Tag{} |> Tag.create_changeset(user, attrs) |> Repo.insert()
end
@doc """
Updates a tag.
## Examples
iex> update_tag(tag, %{field: new_value}, %User{id: 123})
{:ok, %Tag{}}
iex> update_tag(tag, %{field: bad_value}, %User{id: 123})
{:error, %Changeset{}}
"""
@spec update_tag(Tag.t(), attrs :: map(), User.t()) ::
{:ok, Tag.t()} | {:error, Tag.changeset()}
def update_tag(%Tag{user_id: user_id} = tag, attrs, %User{id: user_id}) do
tag |> Tag.update_changeset(attrs) |> Repo.update()
end
@doc """
Deletes a tag.
## Examples
iex> delete_tag(tag, %User{id: 123})
{:ok, %Tag{}}
iex> delete_tag(tag, %User{id: 123})
{:error, %Changeset{}}
"""
@spec delete_tag(Tag.t(), User.t()) :: {:ok, Tag.t()} | {:error, Tag.changeset()}
def delete_tag(%Tag{user_id: user_id} = tag, %User{id: user_id}) do
tag |> Repo.delete()
end
@doc """
Deletes a tag.
## Examples
iex> delete_tag!(tag, %User{id: 123})
%Tag{}
"""
@spec delete_tag!(Tag.t(), User.t()) :: Tag.t()
def delete_tag!(%Tag{user_id: user_id} = tag, %User{id: user_id}) do
tag |> Repo.delete!()
end
end

View File

@ -6,9 +6,17 @@ defmodule Cannery.Containers.Container do
use Ecto.Schema
import Ecto.Changeset
alias Ecto.{Changeset, UUID}
alias Cannery.Containers.{Container, ContainerTag}
alias Cannery.{Accounts.User, Ammo.AmmoGroup, Tags.Tag}
alias Cannery.{Accounts.User, Containers.ContainerTag, Containers.Tag}
@derive {Jason.Encoder,
only: [
:id,
:name,
:desc,
:location,
:type,
:tags
]}
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "containers" do
@ -17,46 +25,46 @@ defmodule Cannery.Containers.Container do
field :location, :string
field :type, :string
belongs_to :user, User
field :user_id, :binary_id
has_many :ammo_groups, AmmoGroup
many_to_many :tags, Tag, join_through: ContainerTag
timestamps()
end
@type t :: %Container{
@type t :: %__MODULE__{
id: id(),
name: String.t(),
desc: String.t(),
location: String.t(),
type: String.t(),
user: User.t(),
user_id: User.id(),
ammo_groups: [AmmoGroup.t()] | nil,
tags: [Tag.t()] | nil,
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
@type new_container :: %Container{}
@type new_container :: %__MODULE__{}
@type id :: UUID.t()
@type changeset :: Changeset.t(t() | new_container())
@doc false
@spec create_changeset(new_container(), User.t(), attrs :: map()) ::
Changeset.t(new_container())
@spec create_changeset(new_container(), User.t(), attrs :: map()) :: changeset()
def create_changeset(container, %User{id: user_id}, attrs) do
container
|> change(user_id: user_id)
|> cast(attrs, [:name, :desc, :type, :location])
|> validate_length(:name, max: 255)
|> validate_length(:type, max: 255)
|> validate_required([:name, :type, :user_id])
end
@doc false
@spec update_changeset(t() | new_container(), attrs :: map()) ::
Changeset.t(t() | new_container())
@spec update_changeset(t() | new_container(), attrs :: map()) :: changeset()
def update_changeset(container, attrs) do
container
|> cast(attrs, [:name, :desc, :type, :location])
|> validate_length(:name, max: 255)
|> validate_length(:type, max: 255)
|> validate_required([:name, :type])
end
end

View File

@ -1,12 +1,12 @@
defmodule Cannery.Containers.ContainerTag do
@moduledoc """
Thru-table struct for associating Cannery.Containers.Container and
Cannery.Tags.Tag.
Cannery.Containers.Tag.
"""
use Ecto.Schema
import Ecto.Changeset
alias Cannery.{Containers.Container, Containers.ContainerTag, Tags.Tag}
alias Cannery.Containers.{Container, Tag}
alias Ecto.{Changeset, UUID}
@primary_key {:id, :binary_id, autogenerate: true}
@ -18,7 +18,7 @@ defmodule Cannery.Containers.ContainerTag do
timestamps()
end
@type t :: %ContainerTag{
@type t :: %__MODULE__{
id: id(),
container: Container.t(),
container_id: Container.id(),
@ -27,12 +27,12 @@ defmodule Cannery.Containers.ContainerTag do
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
@type new_container_tag :: %ContainerTag{}
@type new_container_tag :: %__MODULE__{}
@type id :: UUID.t()
@type changeset :: Changeset.t(t() | new_container_tag())
@doc false
@spec create_changeset(new_container_tag(), Tag.t(), Container.t()) ::
Changeset.t(new_container_tag())
@spec create_changeset(new_container_tag(), Tag.t(), Container.t()) :: changeset()
def create_changeset(
container_tag,
%Tag{id: tag_id, user_id: user_id},

View File

@ -1,4 +1,4 @@
defmodule Cannery.Tags.Tag do
defmodule Cannery.Containers.Tag do
@moduledoc """
Tags are added to containers to help organize, and can include custom-defined
text and bg colors.
@ -6,9 +6,16 @@ defmodule Cannery.Tags.Tag do
use Ecto.Schema
import Ecto.Changeset
alias Cannery.Accounts.User
alias Ecto.{Changeset, UUID}
alias Cannery.{Accounts.User, Tags.Tag}
@derive {Jason.Encoder,
only: [
:id,
:name,
:bg_color,
:text_color
]}
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "tags" do
@ -16,38 +23,44 @@ defmodule Cannery.Tags.Tag do
field :bg_color, :string
field :text_color, :string
belongs_to :user, User
field :user_id, :binary_id
timestamps()
end
@type t :: %Tag{
@type t :: %__MODULE__{
id: id(),
name: String.t(),
bg_color: String.t(),
text_color: String.t(),
user: User.t() | nil,
user_id: User.id(),
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
@type new_tag() :: %Tag{}
@type new_tag() :: %__MODULE__{}
@type id() :: UUID.t()
@type changeset() :: Changeset.t(t() | new_tag())
@doc false
@spec create_changeset(new_tag(), User.t(), attrs :: map()) :: Changeset.t(new_tag())
@spec create_changeset(new_tag(), User.t(), attrs :: map()) :: changeset()
def create_changeset(tag, %User{id: user_id}, attrs) do
tag
|> change(user_id: user_id)
|> cast(attrs, [:name, :bg_color, :text_color])
|> validate_length(:name, max: 255)
|> validate_length(:bg_color, max: 12)
|> validate_length(:text_color, max: 12)
|> validate_required([:name, :bg_color, :text_color, :user_id])
end
@doc false
@spec update_changeset(t() | new_tag(), attrs :: map()) :: Changeset.t(t() | new_tag())
@spec update_changeset(t() | new_tag(), attrs :: map()) :: changeset()
def update_changeset(tag, attrs) do
tag
|> cast(attrs, [:name, :bg_color, :text_color])
|> validate_length(:name, max: 255)
|> validate_length(:bg_color, max: 12)
|> validate_length(:text_color, max: 12)
|> validate_required([:name, :bg_color, :text_color])
end
end

View File

@ -1,156 +0,0 @@
defmodule Cannery.Invites do
@moduledoc """
The Invites context.
"""
import Ecto.Query, warn: false
alias Cannery.{Accounts.User, Invites.Invite, Repo}
alias Ecto.Changeset
@invite_token_length 20
@doc """
Returns the list of invites.
## Examples
iex> list_invites(%User{id: 123, role: :admin})
[%Invite{}, ...]
"""
@spec list_invites(User.t()) :: [Invite.t()]
def list_invites(%User{role: :admin}) do
Repo.all(from i in Invite, order_by: i.name)
end
@doc """
Gets a single invite.
Raises `Ecto.NoResultsError` if the Invite does not exist.
## Examples
iex> get_invite!(123, %User{id: 123, role: :admin})
%Invite{}
iex> get_invite!(456, %User{id: 123, role: :admin})
** (Ecto.NoResultsError)
"""
@spec get_invite!(Invite.id(), User.t()) :: Invite.t()
def get_invite!(id, %User{role: :admin}) do
Repo.get!(Invite, id)
end
@doc """
Returns a valid invite or nil based on the attempted token
## Examples
iex> get_invite_by_token("valid_token")
%Invite{}
iex> get_invite_by_token("invalid_token")
nil
"""
@spec get_invite_by_token(token :: String.t() | nil) :: Invite.t() | nil
def get_invite_by_token(nil), do: nil
def get_invite_by_token(""), do: nil
def get_invite_by_token(token) do
Repo.one(
from(i in Invite,
where: i.token == ^token and i.disabled_at |> is_nil()
)
)
end
@doc """
Uses invite by decrementing uses_left, or marks invite invalid if it's been
completely used.
"""
@spec use_invite!(Invite.t()) :: Invite.t()
def use_invite!(%Invite{uses_left: nil} = invite), do: invite
def use_invite!(%Invite{uses_left: uses_left} = invite) do
new_uses_left = uses_left - 1
attrs =
if new_uses_left <= 0 do
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
%{"uses_left" => 0, "disabled_at" => now}
else
%{"uses_left" => new_uses_left}
end
invite |> Invite.update_changeset(attrs) |> Repo.update!()
end
@doc """
Creates a invite.
## Examples
iex> create_invite(%User{id: 123, role: :admin}, %{field: value})
{:ok, %Invite{}}
iex> create_invite(%User{id: 123, role: :admin}, %{field: bad_value})
{:error, %Changeset{}}
"""
@spec create_invite(User.t(), attrs :: map()) ::
{:ok, Invite.t()} | {:error, Changeset.t(Invite.new_invite())}
def create_invite(%User{role: :admin} = user, attrs) do
token =
:crypto.strong_rand_bytes(@invite_token_length)
|> Base.url_encode64()
|> binary_part(0, @invite_token_length)
%Invite{} |> Invite.create_changeset(user, token, attrs) |> Repo.insert()
end
@doc """
Updates a invite.
## Examples
iex> update_invite(invite, %{field: new_value}, %User{id: 123, role: :admin})
{:ok, %Invite{}}
iex> update_invite(invite, %{field: bad_value}, %User{id: 123, role: :admin})
{:error, %Changeset{}}
"""
@spec update_invite(Invite.t(), attrs :: map(), User.t()) ::
{:ok, Invite.t()} | {:error, Changeset.t(Invite.t())}
def update_invite(invite, attrs, %User{role: :admin}),
do: invite |> Invite.update_changeset(attrs) |> Repo.update()
@doc """
Deletes a invite.
## Examples
iex> delete_invite(invite, %User{id: 123, role: :admin})
{:ok, %Invite{}}
iex> delete_invite(invite, %User{id: 123, role: :admin})
{:error, %Changeset{}}
"""
@spec delete_invite(Invite.t(), User.t()) ::
{:ok, Invite.t()} | {:error, Changeset.t(Invite.t())}
def delete_invite(invite, %User{role: :admin}), do: invite |> Repo.delete()
@doc """
Deletes a invite.
## Examples
iex> delete_invite(invite, %User{id: 123, role: :admin})
%Invite{}
"""
@spec delete_invite!(Invite.t(), User.t()) :: Invite.t()
def delete_invite!(invite, %User{role: :admin}), do: invite |> Repo.delete!()
end

63
lib/cannery/logger.ex Normal file
View File

@ -0,0 +1,63 @@
defmodule Cannery.Logger do
@moduledoc """
Custom logger for telemetry events
Oban implementation taken from
https://hexdocs.pm/oban/Oban.html#module-reporting-errors
"""
require Logger
def handle_event([:oban, :job, :exception], measure, %{stacktrace: stacktrace} = meta, _config) do
data =
get_oban_job_data(meta, measure)
|> Map.put(:stacktrace, Exception.format_stacktrace(stacktrace))
|> pretty_encode()
Logger.error("#{meta.reason}: #{data}")
end
def handle_event([:oban, :job, :start], measure, meta, _config) do
data = get_oban_job_data(meta, measure) |> pretty_encode()
Logger.info("Started oban job: #{data}")
end
def handle_event([:oban, :job, :stop], measure, meta, _config) do
data = get_oban_job_data(meta, measure) |> pretty_encode()
Logger.info("Finished oban job: #{data}")
end
def handle_event([:oban, :job, unhandled_event], measure, meta, _config) do
data =
get_oban_job_data(meta, measure)
|> Map.put(:event, unhandled_event)
|> pretty_encode()
Logger.warning("Unhandled oban job event: #{data}")
end
def handle_event(unhandled_event, measure, meta, config) do
data =
pretty_encode(%{
event: unhandled_event,
meta: meta,
measurements: measure,
config: config
})
Logger.warning("Unhandled telemetry event: #{data}")
end
defp get_oban_job_data(%{job: job}, measure) do
%{
job: job |> Map.take([:id, :args, :meta, :queue, :worker]),
measurements: measure
}
end
defp pretty_encode(data) do
data
|> Jason.encode!()
|> Jason.Formatter.pretty_print()
end
end

View File

@ -9,7 +9,9 @@ defmodule Cannery.Release do
def rollback(repo, version) do
load_app()
{:ok, _fun, _opts} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
{:ok, _fun_return, _apps} =
Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
end
defp load_app do
@ -20,7 +22,8 @@ defmodule Cannery.Release do
load_app()
for repo <- Application.fetch_env!(@app, :ecto_repos) do
{:ok, _fun, _opts} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
{:ok, _fun_return, _apps} =
Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
end
end
end

View File

@ -1,6 +1,6 @@
defmodule Cannery.Repo.Migrator do
@moduledoc """
Genserver to automatically run migrations in prod env
Genserver to automatically perform all migration on app start
"""
use GenServer
@ -11,12 +11,15 @@ defmodule Cannery.Repo.Migrator do
end
def init(_opts) do
migrate!()
{:ok, nil}
{:ok, if(automigrate_enabled?(), do: migrate!())}
end
def migrate! do
path = Application.app_dir(:cannery, "priv/repo/migrations")
Ecto.Migrator.run(Cannery.Repo, path, :up, all: true)
end
defp automigrate_enabled? do
Application.get_env(:cannery, Cannery.Application, automigrate: false)[:automigrate]
end
end

View File

@ -1,137 +0,0 @@
defmodule Cannery.Tags do
@moduledoc """
The Tags context.
"""
import Ecto.Query, warn: false
import CanneryWeb.Gettext
alias Cannery.{Accounts.User, Repo, Tags.Tag}
alias Ecto.Changeset
@doc """
Returns the list of tags.
## Examples
iex> list_tags(%User{id: 123})
[%Tag{}, ...]
"""
@spec list_tags(User.t()) :: [Tag.t()]
def list_tags(%{id: user_id}),
do: Repo.all(from t in Tag, where: t.user_id == ^user_id, order_by: t.name)
@doc """
Gets a single tag.
## Examples
iex> get_tag(123, %User{id: 123})
{:ok, %Tag{}}
iex> get_tag(456, %User{id: 123})
{:error, "tag not found"}
"""
@spec get_tag(Tag.id(), User.t()) :: {:ok, Tag.t()} | {:error, String.t()}
def get_tag(id, %User{id: user_id}) do
Repo.one(from t in Tag, where: t.id == ^id and t.user_id == ^user_id)
|> case do
nil -> {:error, dgettext("errors", "Tag not found")}
tag -> {:ok, tag}
end
end
@doc """
Gets a single tag.
Raises `Ecto.NoResultsError` if the Tag does not exist.
## Examples
iex> get_tag!(123, %User{id: 123})
%Tag{}
iex> get_tag!(456, %User{id: 123})
** (Ecto.NoResultsError)
"""
@spec get_tag!(Tag.id(), User.t()) :: Tag.t()
def get_tag!(id, %User{id: user_id}),
do: Repo.one!(from t in Tag, where: t.id == ^id and t.user_id == ^user_id)
@doc """
Creates a tag.
## Examples
iex> create_tag(%{field: value}, %User{id: 123})
{:ok, %Tag{}}
iex> create_tag(%{field: bad_value}, %User{id: 123})
{:error, %Changeset{}}
"""
@spec create_tag(attrs :: map(), User.t()) ::
{:ok, Tag.t()} | {:error, Changeset.t(Tag.new_tag())}
def create_tag(attrs, %User{} = user),
do: %Tag{} |> Tag.create_changeset(user, attrs) |> Repo.insert()
@doc """
Updates a tag.
## Examples
iex> update_tag(tag, %{field: new_value}, %User{id: 123})
{:ok, %Tag{}}
iex> update_tag(tag, %{field: bad_value}, %User{id: 123})
{:error, %Changeset{}}
"""
@spec update_tag(Tag.t(), attrs :: map(), User.t()) ::
{:ok, Tag.t()} | {:error, Changeset.t(Tag.t())}
def update_tag(%Tag{user_id: user_id} = tag, attrs, %User{id: user_id}),
do: tag |> Tag.update_changeset(attrs) |> Repo.update()
@doc """
Deletes a tag.
## Examples
iex> delete_tag(tag, %User{id: 123})
{:ok, %Tag{}}
iex> delete_tag(tag, %User{id: 123})
{:error, %Changeset{}}
"""
@spec delete_tag(Tag.t(), User.t()) :: {:ok, Tag.t()} | {:error, Changeset.t(Tag.t())}
def delete_tag(%Tag{user_id: user_id} = tag, %User{id: user_id}), do: tag |> Repo.delete()
@doc """
Deletes a tag.
## Examples
iex> delete_tag!(tag, %User{id: 123})
%Tag{}
"""
@spec delete_tag!(Tag.t(), User.t()) :: Tag.t()
def delete_tag!(%Tag{user_id: user_id} = tag, %User{id: user_id}), do: tag |> Repo.delete!()
@doc """
Get a random tag bg_color in `#ffffff` hex format
## Examples
iex> random_color()
"#cc0066"
"""
@spec random_bg_color() :: <<_::7>>
def random_bg_color do
["#cc0066", "#ff6699", "#6666ff", "#0066cc", "#00cc66", "#669900", "#ff9900", "#996633"]
|> Enum.random()
end
end

View File

@ -1,78 +1,29 @@
defmodule CanneryWeb do
@moduledoc """
The entrypoint for defining your web interface, such
as controllers, views, channels and so on.
as controllers, components, channels, and so on.
This can be used in your application as:
use CanneryWeb, :controller
use CanneryWeb, :view
use CanneryWeb, :html
The definitions below will be executed for every view,
controller, etc, so keep them short and clean, focused
The definitions below will be executed for every controller,
component, etc, so keep them short and clean, focused
on imports, uses and aliases.
Do NOT define functions inside the quoted expressions
below. Instead, define any helper function in modules
and import those modules here.
below. Instead, define additional modules and import
those modules here.
"""
def controller do
quote do
use Phoenix.Controller, namespace: CanneryWeb
import Plug.Conn
import CanneryWeb.Gettext
alias CanneryWeb.Router.Helpers, as: Routes
end
end
def view do
quote do
use Phoenix.View,
root: "lib/cannery_web/templates",
namespace: CanneryWeb
# Import convenience functions from controllers
import Phoenix.Controller,
only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1]
# Include shared imports and aliases for views
unquote(view_helpers())
end
end
def live_view do
quote do
use Phoenix.LiveView,
layout: {CanneryWeb.LayoutView, "live.html"}
on_mount CanneryWeb.InitAssigns
unquote(view_helpers())
end
end
def live_component do
quote do
use Phoenix.LiveComponent
unquote(view_helpers())
end
end
def component do
quote do
use Phoenix.Component
unquote(view_helpers())
end
end
def static_paths, do: ~w(css js fonts images favicon.ico robots.txt)
def router do
quote do
use Phoenix.Router
use Phoenix.Router, helpers: false
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
# Import common connection and controller functions to use in pipelines
import Plug.Conn
import Phoenix.Controller
import Phoenix.LiveView.Router
@ -81,30 +32,75 @@ defmodule CanneryWeb do
def channel do
quote do
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
use Phoenix.Channel
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
import CanneryWeb.Gettext
end
end
defp view_helpers do
def controller do
quote do
# Use all HTML functionality (forms, tags, etc)
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
use Phoenix.HTML
# Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc)
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
import Phoenix.LiveView.Helpers
# Import basic rendering functionality (render, render_layout, etc)
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
import Phoenix.View
use Phoenix.Controller,
formats: [:html, :json],
layouts: [html: CanneryWeb.Layouts]
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
import CanneryWeb.{ErrorHelpers, Gettext, LiveHelpers, ViewHelpers}
alias CanneryWeb.Router.Helpers, as: Routes
import Plug.Conn
import CanneryWeb.Gettext
unquote(verified_routes())
end
end
def live_view do
quote do
use Phoenix.LiveView,
layout: {CanneryWeb.Layouts, :app}
unquote(html_helpers())
end
end
def live_component do
quote do
use Phoenix.LiveComponent
unquote(html_helpers())
end
end
def html do
quote do
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
use Phoenix.Component
# Import convenience functions from controllers
import Phoenix.Controller,
only: [get_csrf_token: 0, view_module: 1, view_template: 1]
# Include general helpers for rendering HTML
unquote(html_helpers())
end
end
defp html_helpers do
quote do
use PhoenixHTMLHelpers
import Phoenix.{Component, HTML, HTML.Form}
import CanneryWeb.{ErrorHelpers, Gettext, CoreComponents, HTMLHelpers}
# Shortcut for generating JS commands
alias Phoenix.LiveView.JS
# Routes generation with the ~p sigil
unquote(verified_routes())
end
end
def verified_routes do
quote do
use Phoenix.VerifiedRoutes,
endpoint: CanneryWeb.Endpoint,
router: CanneryWeb.Router,
statics: CanneryWeb.static_paths()
end
end

View File

@ -1,78 +0,0 @@
defmodule CanneryWeb.Components.AddShotGroupComponent do
@moduledoc """
Livecomponent that can create a ShotGroup
"""
use CanneryWeb, :live_component
alias Cannery.{Accounts.User, ActivityLog, ActivityLog.ShotGroup, Ammo.AmmoGroup}
alias Phoenix.LiveView.Socket
@impl true
@spec update(
%{
required(:current_user) => User.t(),
required(:ammo_group) => AmmoGroup.t(),
optional(any()) => any()
},
Socket.t()
) :: {:ok, Socket.t()}
def update(%{ammo_group: ammo_group, current_user: current_user} = assigns, socket) do
changeset =
%ShotGroup{date: NaiveDateTime.utc_now(), count: 1}
|> ShotGroup.create_changeset(current_user, ammo_group, %{})
{:ok, socket |> assign(assigns) |> assign(:changeset, changeset)}
end
@impl true
def handle_event(
"validate",
%{"shot_group" => shot_group_params},
%{assigns: %{ammo_group: ammo_group, current_user: current_user}} = socket
) do
params = shot_group_params |> process_params(ammo_group)
changeset =
%ShotGroup{}
|> ShotGroup.create_changeset(current_user, ammo_group, params)
|> Map.put(:action, :validate)
{:noreply, socket |> assign(:changeset, changeset)}
end
def handle_event(
"save",
%{"shot_group" => shot_group_params},
%{
assigns: %{ammo_group: ammo_group, current_user: current_user, return_to: return_to}
} = socket
) do
socket =
shot_group_params
|> process_params(ammo_group)
|> ActivityLog.create_shot_group(current_user, ammo_group)
|> case do
{:ok, _shot_group} ->
prompt = dgettext("prompts", "Shots recorded successfully")
socket |> put_flash(:info, prompt) |> push_redirect(to: return_to)
{:error, %Ecto.Changeset{} = changeset} ->
socket |> assign(changeset: changeset)
end
{:noreply, socket}
end
# calculate count from shots left
defp process_params(params, %AmmoGroup{count: count}) do
new_count =
if params |> Map.get("ammo_left", "0") == "" do
"0"
else
params |> Map.get("ammo_left", "0")
end
|> String.to_integer()
params |> Map.put("count", count - new_count)
end
end

View File

@ -0,0 +1,82 @@
defmodule CanneryWeb.Components.AddShotRecordComponent do
@moduledoc """
Livecomponent that can create a ShotRecord
"""
use CanneryWeb, :live_component
alias Cannery.{Accounts.User, ActivityLog, ActivityLog.ShotRecord, Ammo.Pack}
alias Ecto.Changeset
alias Phoenix.LiveView.{JS, Socket}
@impl true
@spec update(
%{
required(:current_user) => User.t(),
required(:pack) => Pack.t(),
optional(any()) => any()
},
Socket.t()
) :: {:ok, Socket.t()}
def update(%{pack: pack, current_user: current_user} = assigns, socket) do
changeset =
%ShotRecord{date: Date.utc_today()}
|> ShotRecord.create_changeset(current_user, pack, %{})
{:ok, socket |> assign(assigns) |> assign(:changeset, changeset)}
end
@impl true
def handle_event(
"validate",
%{"shot_record" => shot_record_params},
%{assigns: %{pack: pack, current_user: current_user}} = socket
) do
params = shot_record_params |> process_params(pack)
changeset = %ShotRecord{} |> ShotRecord.create_changeset(current_user, pack, params)
changeset =
case changeset |> Changeset.apply_action(:validate) do
{:ok, _data} -> changeset
{:error, changeset} -> changeset
end
{:noreply, socket |> assign(:changeset, changeset)}
end
def handle_event(
"save",
%{"shot_record" => shot_record_params},
%{
assigns: %{pack: pack, current_user: current_user, return_to: return_to}
} = socket
) do
socket =
shot_record_params
|> process_params(pack)
|> ActivityLog.create_shot_record(current_user, pack)
|> case do
{:ok, _shot_record} ->
prompt = dgettext("prompts", "Shots recorded successfully")
socket |> put_flash(:info, prompt) |> push_navigate(to: return_to)
{:error, %Changeset{} = changeset} ->
socket |> assign(changeset: changeset)
end
{:noreply, socket}
end
# calculate count from shots left
defp process_params(params, %Pack{count: count}) do
shot_record_count =
if params |> Map.get("ammo_left", "") == "" do
nil
else
new_count = params |> Map.get("ammo_left") |> String.to_integer()
count - new_count
end
params |> Map.put("count", shot_record_count)
end
end

View File

@ -4,38 +4,48 @@
</h2>
<.form
let={f}
:let={f}
for={@changeset}
id="shot-group-form"
id="shot-record-form"
class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center"
phx-target={@myself}
phx-change="validate"
phx-submit="save"
>
<%= if @changeset.action && not @changeset.valid? do %>
<div class="invalid-feedback col-span-3 text-center">
<%= changeset_errors(@changeset) %>
</div>
<% end %>
<div
:if={@changeset.action && not @changeset.valid?()}
class="invalid-feedback col-span-3 text-center"
>
<%= changeset_errors(@changeset) %>
</div>
<%= label(f, :ammo_left, gettext("Rounds left"), class: "title text-lg text-primary-600") %>
<%= number_input(f, :ammo_left,
min: 0,
max: @ammo_group.count - 1,
placeholder: 0,
class: "input input-primary col-span-2"
max: @pack.count - 1,
placeholder: gettext("Rounds left"),
class: "input input-primary"
) %>
<button
type="button"
class="mx-2 my-1 text-sm btn btn-primary"
phx-click={JS.dispatch("cannery:set-zero", to: "#shot-record-form_ammo_left")}
>
<%= gettext("Used up!") %>
</button>
<%= error_tag(f, :ammo_left, "col-span-3") %>
<%= label(f, :notes, gettext("Notes"), class: "title text-lg text-primary-600") %>
<%= textarea(f, :notes,
id: "add-shot-record-form-notes",
class: "input input-primary col-span-2",
placeholder: "Really great weather",
phx_hook: "MaintainAttrs"
maxlength: 255,
placeholder: gettext("Really great weather"),
phx_update: "ignore"
) %>
<%= error_tag(f, :notes, "col-span-3") %>
<%= label(f, :date, gettext("Date (UTC)"), class: "title text-lg text-primary-600") %>
<%= label(f, :date, gettext("Date"), class: "title text-lg text-primary-600") %>
<%= date_input(f, :date,
class: "input input-primary col-span-2",
phx_update: "ignore",

View File

@ -1,63 +0,0 @@
defmodule CanneryWeb.Components.AmmoGroupCard do
@moduledoc """
Display card for an ammo group
"""
use CanneryWeb, :component
alias Cannery.Repo
alias CanneryWeb.Endpoint
def ammo_group_card(assigns) do
assigns = assigns |> assign(:ammo_group, assigns.ammo_group |> Repo.preload(:ammo_type))
~H"""
<div
id={"ammo_group-#{@ammo_group.id}"}
class="mx-4 my-2 px-8 py-4 flex flex-col justify-center items-center
border border-gray-400 rounded-lg shadow-lg hover:shadow-md
transition-all duration-300 ease-in-out"
>
<%= live_redirect to: Routes.ammo_group_show_path(Endpoint, :show, @ammo_group),
class: "mb-2 link" do %>
<h1 class="title text-xl title-primary-500">
<%= @ammo_group.ammo_type.name %>
</h1>
<% end %>
<div class="flex flex-col justify-center items-center">
<span class="rounded-lg title text-lg">
<%= gettext("Count:") %>
<%= @ammo_group.count %>
</span>
<%= if @ammo_group.notes do %>
<span class="rounded-lg title text-lg">
<%= gettext("Notes:") %>
<%= @ammo_group.notes %>
</span>
<% end %>
<span class="rounded-lg title text-lg">
<%= gettext("Added on:") %>
<%= @ammo_group.inserted_at |> display_datetime() %>
</span>
<%= if @ammo_group.price_paid do %>
<span class="rounded-lg title text-lg">
<%= gettext("Price paid:") %>
<%= gettext("$%{amount}",
amount: @ammo_group.price_paid |> :erlang.float_to_binary(decimals: 2)
) %>
</span>
<% end %>
</div>
<%= if assigns |> Map.has_key?(:inner_block) do %>
<div class="mt-4 flex space-x-4 justify-center items-center">
<%= render_slot(@inner_block) %>
</div>
<% end %>
</div>
"""
end
end

View File

@ -1,81 +0,0 @@
defmodule CanneryWeb.Components.ContainerCard do
@moduledoc """
Display card for a container
"""
use CanneryWeb, :component
import CanneryWeb.Components.TagCard
alias Cannery.{Containers, Repo}
alias CanneryWeb.Endpoint
def container_card(%{container: container} = assigns) do
assigns = assigns |> Map.put(:container, container |> Repo.preload([:tags, :ammo_groups]))
~H"""
<div
id={"container-#{@container.id}"}
class="overflow-hidden max-w-full mx-4 my-2 px-8 py-4 flex flex-col justify-center items-center space-y-4
border border-gray-400 rounded-lg shadow-lg hover:shadow-md
transition-all duration-300 ease-in-out"
>
<div class="max-w-full mb-4 flex flex-col justify-center items-center space-y-2">
<%= live_redirect to: Routes.container_show_path(Endpoint, :show, @container),
class: "link" do %>
<h1 class="px-4 py-2 rounded-lg title text-xl">
<%= @container.name %>
</h1>
<% end %>
<%= if @container.desc do %>
<span class="rounded-lg title text-lg">
<%= gettext("Description:") %>
<%= @container.desc %>
</span>
<% end %>
<span class="rounded-lg title text-lg">
<%= gettext("Type:") %>
<%= @container.type %>
</span>
<%= if @container.location do %>
<span class="rounded-lg title text-lg">
<%= gettext("Location:") %>
<%= @container.location %>
</span>
<% end %>
<%= unless @container.ammo_groups |> Enum.empty?() do %>
<span class="rounded-lg title text-lg">
<%= gettext("Packs:") %>
<%= @container |> Containers.get_container_ammo_group_count!() %>
</span>
<span class="rounded-lg title text-lg">
<%= gettext("Rounds:") %>
<%= @container |> Containers.get_container_rounds!() %>
</span>
<% end %>
<div class="flex flex-wrap justify-center items-center">
<%= unless @container.tags |> Enum.empty?() do %>
<%= for tag <- @container.tags do %>
<.simple_tag_card tag={tag} />
<% end %>
<% end %>
<%= if assigns |> Map.has_key?(:tag_actions) do %>
<%= render_slot(@tag_actions) %>
<% end %>
</div>
</div>
<%= if assigns |> Map.has_key?(:inner_block) do %>
<div class="flex space-x-4 justify-center items-center">
<%= render_slot(@inner_block) %>
</div>
<% end %>
</div>
"""
end
end

View File

@ -0,0 +1,167 @@
defmodule CanneryWeb.Components.ContainerTableComponent do
@moduledoc """
A component that displays a list of containers
"""
use CanneryWeb, :live_component
alias Cannery.{Accounts.User, Ammo, Containers.Container}
alias Ecto.UUID
alias Phoenix.LiveView.{Rendered, Socket}
@impl true
@spec update(
%{
required(:id) => UUID.t(),
required(:current_user) => User.t(),
optional(:containers) => [Container.t()],
optional(:tag_actions) => Rendered.t(),
optional(:actions) => Rendered.t(),
optional(any()) => any()
},
Socket.t()
) :: {:ok, Socket.t()}
def update(%{id: _id, containers: _containers, current_user: _current_user} = assigns, socket) do
socket =
socket
|> assign(assigns)
|> assign_new(:tag_actions, fn -> [] end)
|> assign_new(:actions, fn -> [] end)
|> display_containers()
{:ok, socket}
end
defp display_containers(
%{
assigns: %{
containers: containers,
current_user: current_user,
tag_actions: tag_actions,
actions: actions
}
} = socket
) do
columns =
[
%{label: gettext("Name"), key: :name, type: :string},
%{label: gettext("Description"), key: :desc, type: :string},
%{label: gettext("Location"), key: :location, type: :string},
%{label: gettext("Type"), key: :type, type: :string}
]
|> Enum.filter(fn %{key: key, type: type} ->
# remove columns if all values match defaults
default_value =
case type do
:boolean -> false
_other_type -> nil
end
containers
|> Enum.any?(fn container ->
type in [:tags, :actions] or not (container |> Map.get(key) == default_value)
end)
end)
|> Enum.concat([
%{label: gettext("Packs"), key: :packs, type: :integer},
%{label: gettext("Rounds"), key: :rounds, type: :integer},
%{label: gettext("Tags"), key: :tags, type: :tags},
%{label: gettext("Actions"), key: :actions, sortable: false, type: :actions}
])
extra_data = %{
current_user: current_user,
tag_actions: tag_actions,
actions: actions,
pack_count:
Ammo.get_grouped_packs_count(current_user,
containers: containers,
group_by: :container_id
),
round_count:
Ammo.get_grouped_round_count(current_user,
containers: containers,
group_by: :container_id
)
}
rows =
containers
|> Enum.map(fn container ->
container |> get_row_data_for_container(columns, extra_data)
end)
socket
|> assign(
columns: columns,
rows: rows
)
end
@impl true
def render(assigns) do
~H"""
<div id={@id} class="w-full">
<.live_component
module={CanneryWeb.Components.TableComponent}
id={"table-#{@id}"}
columns={@columns}
rows={@rows}
/>
</div>
"""
end
@spec get_row_data_for_container(Container.t(), columns :: [map()], extra_data :: map) :: map()
defp get_row_data_for_container(container, columns, extra_data) do
columns
|> Map.new(fn %{key: key} -> {key, get_value_for_key(key, container, extra_data)} end)
end
@spec get_value_for_key(atom(), Container.t(), extra_data :: map) :: any()
defp get_value_for_key(:name, %{name: container_name} = assigns, _extra_data) do
{container_name,
~H"""
<div class="flex flex-wrap justify-center items-center">
<.link navigate={~p"/container/#{@id}"} class="link">
<%= @name %>
</.link>
</div>
"""}
end
defp get_value_for_key(:packs, %{id: container_id}, %{pack_count: pack_count}) do
pack_count |> Map.get(container_id, 0)
end
defp get_value_for_key(:rounds, %{id: container_id}, %{round_count: round_count}) do
round_count |> Map.get(container_id, 0)
end
defp get_value_for_key(:tags, container, %{tag_actions: tag_actions}) do
assigns = %{tag_actions: tag_actions, container: container}
tag_names =
container.tags
|> Enum.map(fn %{name: name} -> name end)
|> Enum.sort()
|> Enum.join(" ")
{tag_names,
~H"""
<div class="flex flex-wrap justify-center items-center">
<.simple_tag_card :for={tag <- @container.tags} :if={@container.tags} tag={tag} />
<%= render_slot(@tag_actions, @container) %>
</div>
"""}
end
defp get_value_for_key(:actions, container, %{actions: actions}) do
assigns = %{actions: actions, container: container}
~H"""
<%= render_slot(@actions, @container) %>
"""
end
defp get_value_for_key(key, container, _extra_data), do: container |> Map.get(key)
end

View File

@ -0,0 +1,149 @@
defmodule CanneryWeb.CoreComponents do
@moduledoc """
Provides core UI components.
"""
use Phoenix.Component
use CanneryWeb, :verified_routes
import CanneryWeb.{Gettext, HTMLHelpers}
alias Cannery.{Accounts, Accounts.Invite, Accounts.User}
alias Cannery.{Ammo, Ammo.Pack}
alias Cannery.{Containers.Container, Containers.Tag}
alias CanneryWeb.Router.Helpers, as: Routes
alias Phoenix.LiveView.{JS, Rendered}
embed_templates "core_components/*"
attr :title_content, :string, default: nil
attr :current_user, User, default: nil
def topbar(assigns)
attr :return_to, :string, required: true
slot(:inner_block)
@doc """
Renders a live component inside a modal.
The rendered modal receives a `:return_to` option to properly update
the URL when the modal is closed.
## Examples
<.modal return_to={~p"/\#{<%= schema.plural %>}"}>
<.live_component
module={<%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.FormComponent}
id={@<%= schema.singular %>.id || :new}
title={@page_title}
action={@live_action}
return_to={~p"/\#{<%= schema.singular %>}"}
<%= schema.singular %>: @<%= schema.singular %>
/>
</.modal>
"""
def modal(assigns)
defp hide_modal(js \\ %JS{}) do
js
|> JS.hide(to: "#modal", transition: "fade-out")
|> JS.hide(to: "#modal-bg", transition: "fade-out")
|> JS.hide(to: "#modal-content", transition: "fade-out-scale")
end
attr :action, :string, required: true
attr :value, :boolean, required: true
attr :id, :string, default: nil
slot(:inner_block)
@doc """
A toggle button element that can be directed to a liveview or a
live_component's `handle_event/3`.
## Examples
<.toggle_button action="my_liveview_action" value={@some_value}>
<span>Toggle me!</span>
</.toggle_button>
<.toggle_button action="my_live_component_action" target={@myself} value={@some_value}>
<span>Whatever you want</span>
</.toggle_button>
"""
def toggle_button(assigns)
attr :container, Container, required: true
attr :current_user, User, required: true
slot(:tag_actions)
slot(:inner_block)
@spec container_card(assigns :: map()) :: Rendered.t()
def container_card(assigns)
attr :tag, Tag, required: true
slot(:inner_block, required: true)
def tag_card(assigns)
attr :tag, Tag, required: true
def simple_tag_card(assigns)
attr :pack, Pack, required: true
attr :current_user, User, required: true
attr :original_count, :integer, default: nil
attr :cpr, :integer, default: nil
attr :last_used_date, Date, default: nil
attr :container, Container, default: nil
slot(:inner_block)
def pack_card(assigns)
@spec display_currency(float()) :: String.t()
defp display_currency(float), do: :erlang.float_to_binary(float, decimals: 2)
attr :user, User, required: true
slot(:inner_block, required: true)
def user_card(assigns)
attr :invite, Invite, required: true
attr :use_count, :integer, default: nil
attr :current_user, User, required: true
slot(:inner_block)
slot(:code_actions)
def invite_card(assigns)
attr :content, :string, required: true
attr :filename, :string, default: "qrcode", doc: "filename without .png extension"
attr :image_class, :string, default: "w-64 h-max"
attr :width, :integer, default: 384, doc: "width of png to generate"
@doc """
Creates a downloadable QR Code element
"""
def qr_code(assigns)
attr :id, :string, required: true
attr :date, :any, required: true, doc: "A `Date` struct or nil"
@doc """
Phoenix.Component for a <date> element that renders the Date in the user's
local timezone
"""
def date(assigns)
attr :id, :string, required: true
attr :datetime, :any, required: true, doc: "A `DateTime` struct or nil"
@doc """
Phoenix.Component for a <time> element that renders the naivedatetime in the
user's local timezone
"""
def datetime(assigns)
@spec cast_datetime(NaiveDateTime.t() | nil) :: String.t()
defp cast_datetime(%NaiveDateTime{} = datetime) do
datetime |> DateTime.from_naive!("Etc/UTC") |> DateTime.to_iso8601(:extended)
end
defp cast_datetime(_datetime), do: ""
end

View File

@ -0,0 +1,58 @@
<div
id={"container-#{@container.id}"}
class="overflow-hidden max-w-full mx-4 mb-4 px-8 py-4
flex flex-col justify-around items-center space-y-4
border border-gray-400 rounded-lg shadow-lg hover:shadow-md
transition-all duration-300 ease-in-out"
>
<.link navigate={~p"/container/#{@container}"} class="link">
<h1 class="px-4 py-2 rounded-lg title text-xl">
<%= @container.name %>
</h1>
</.link>
<div class="flex flex-col justify-center items-center space-y-2">
<span :if={@container.desc} class="rounded-lg title text-lg">
<%= gettext("Description:") %>
<%= @container.desc %>
</span>
<span class="rounded-lg title text-lg">
<%= gettext("Type:") %>
<%= @container.type %>
</span>
<span :if={@container.location} class="rounded-lg title text-lg">
<%= gettext("Location:") %>
<%= @container.location %>
</span>
<%= if Ammo.get_packs_count(@current_user, container_id: @container.id) != 0 do %>
<span class="rounded-lg title text-lg">
<%= gettext("Packs:") %>
<%= Ammo.get_packs_count(@current_user, container_id: @container.id) %>
</span>
<span class="rounded-lg title text-lg">
<%= gettext("Rounds:") %>
<%= Ammo.get_round_count(@current_user, container_id: @container.id) %>
</span>
<% end %>
<div
:if={@tag_actions || @container.tags != []}
class="flex flex-wrap justify-center items-center"
>
<.simple_tag_card :for={tag <- @container.tags} tag={tag} />
<%= if @tag_actions, do: render_slot(@tag_actions) %>
</div>
</div>
<div
:if={assigns |> Map.has_key?(:inner_block)}
class="flex space-x-4 justify-center items-center"
>
<%= render_slot(@inner_block) %>
</div>
</div>

View File

@ -0,0 +1,3 @@
<time :if={@date} id={@id} datetime={Date.to_iso8601(@date, :extended)} phx-hook="Date">
<%= Date.to_iso8601(@date, :extended) %>
</time>

View File

@ -0,0 +1,3 @@
<time :if={@datetime} id={@id} datetime={cast_datetime(@datetime)} phx-hook="DateTime">
<%= cast_datetime(@datetime) %>
</time>

View File

@ -0,0 +1,46 @@
<div class="mx-4 my-2 px-8 py-4 flex flex-col justify-center items-center space-y-4
border border-gray-400 rounded-lg shadow-lg hover:shadow-md
transition-all duration-300 ease-in-out">
<h1 class="title text-xl">
<%= @invite.name %>
</h1>
<%= if @invite.disabled_at |> is_nil() do %>
<h2 class="title text-md">
<%= if @invite.uses_left do %>
<%= gettext(
"Uses Left: %{uses_left_count}",
uses_left_count: @invite.uses_left
) %>
<% else %>
<%= gettext("Uses Left: Unlimited") %>
<% end %>
</h2>
<% else %>
<h2 class="title text-md">
<%= gettext("Invite Disabled") %>
</h2>
<% end %>
<.qr_code
content={url(CanneryWeb.Endpoint, ~p"/users/register?invite=#{@invite.token}")}
filename={@invite.name}
/>
<h2 :if={@use_count && @use_count != 0} class="title text-md">
<%= gettext("Uses: %{uses_count}", uses_count: @use_count) %>
</h2>
<div class="flex flex-row flex-wrap justify-center items-center">
<code
id={"code-#{@invite.id}"}
class="mx-2 my-1 text-xs px-4 py-2 rounded-lg text-center break-all text-gray-100 bg-primary-800"
phx-no-format
><%= url(CanneryWeb.Endpoint, ~p"/users/register?invite=#{@invite.token}") %></code>
<%= if @code_actions, do: render_slot(@code_actions) %>
</div>
<div :if={@inner_block} class="flex space-x-4 justify-center items-center">
<%= render_slot(@inner_block) %>
</div>
</div>

View File

@ -0,0 +1,44 @@
<.link
patch={@return_to}
id="modal-bg"
class="fade-in fixed z-10 left-0 top-0
w-full h-full overflow-hidden
p-8 flex flex-col justify-center items-center cursor-auto"
style="background-color: rgba(0,0,0,0.4);"
phx-remove={hide_modal()}
aria-label={gettext("Close modal")}
>
<span class="hidden"></span>
</.link>
<div
id="modal"
class="fixed z-10 left-0 top-0 pointer-events-none
w-full h-full overflow-hidden
p-4 sm:p-8 flex flex-col justify-center items-center"
>
<div
id="modal-content"
class="fade-in-scale w-full max-w-3xl relative
pointer-events-auto overflow-hidden
px-8 py-4 sm:py-8
flex flex-col justify-start items-center
bg-white border-2 rounded-lg"
>
<.link
patch={@return_to}
id="close"
class="absolute top-8 right-10
text-gray-500 hover:text-gray-800
transition-all duration-500 ease-in-out"
phx-remove={hide_modal()}
aria-label={gettext("Close modal")}
>
<i class="fa-fw fa-lg fas fa-times"></i>
</.link>
<div class="overflow-x-hidden overflow-y-auto w-full p-8 flex flex-col space-y-4 justify-start items-center">
<%= render_slot(@inner_block) %>
</div>
</div>
</div>

View File

@ -0,0 +1,67 @@
<div
id={"pack-#{@pack.id}"}
class="mx-4 my-2 px-8 py-4
flex flex-col justify-center items-center
border border-gray-400 rounded-lg shadow-lg hover:shadow-md
transition-all duration-300 ease-in-out"
>
<.link navigate={~p"/ammo/show/#{@pack}"} class="mb-2 link">
<h1 class="title text-xl title-primary-500">
<%= @pack.type.name %>
</h1>
</.link>
<div class="flex flex-col justify-center items-center">
<span class="rounded-lg title text-lg">
<%= gettext("Count:") %>
<%= if @pack.count == 0, do: gettext("Empty"), else: @pack.count %>
</span>
<span :if={@original_count && @original_count != @pack.count} class="rounded-lg title text-lg">
<%= gettext("Original Count:") %>
<%= @original_count %>
</span>
<span :if={@pack.notes} class="rounded-lg title text-lg">
<%= gettext("Notes:") %>
<%= @pack.notes %>
</span>
<span :if={@pack.purchased_on} class="rounded-lg title text-lg">
<%= gettext("Purchased on:") %>
<.date id={"#{@pack.id}-purchased-on"} date={@pack.purchased_on} />
</span>
<span :if={@last_used_date} class="rounded-lg title text-lg">
<%= gettext("Last used on:") %>
<.date id={"#{@pack.id}-last-used-on"} date={@last_used_date} />
</span>
<span :if={@pack.price_paid} class="rounded-lg title text-lg">
<%= gettext("Price paid:") %>
<%= gettext("$%{amount}", amount: display_currency(@pack.price_paid)) %>
</span>
<span :if={@cpr} class="rounded-lg title text-lg">
<%= gettext("CPR:") %>
<%= gettext("$%{amount}", amount: display_currency(@cpr)) %>
</span>
<span :if={@pack.lot_number} class="rounded-lg title text-lg">
<%= gettext("Lot number:") %>
<%= @pack.lot_number %>
</span>
<span :if={@container} class="rounded-lg title text-lg">
<%= gettext("Container:") %>
<.link navigate={~p"/container/#{@container}"} class="link">
<%= @container.name %>
</.link>
</span>
</div>
<div :if={@inner_block} class="mt-4 flex space-x-4 justify-center items-center">
<%= render_slot(@inner_block) %>
</div>
</div>

View File

@ -0,0 +1,3 @@
<a href={qr_code_image(@content)} download={@filename <> ".png"}>
<img class={@image_class} alt={@filename} src={qr_code_image(@content)} />
</a>

View File

@ -0,0 +1,6 @@
<h1
class="inline-block break-all mx-2 my-1 px-4 py-2 rounded-lg title text-xl"
style={"color: #{@tag.text_color}; background-color: #{@tag.bg_color}"}
>
<%= @tag.name %>
</h1>

View File

@ -0,0 +1,9 @@
<div
id={"tag-#{@tag.id}"}
class="mx-4 mb-4 px-8 py-4 space-x-4 flex justify-center items-center
border border-gray-400 rounded-lg shadow-lg hover:shadow-md
transition-all duration-300 ease-in-out"
>
<.simple_tag_card tag={@tag} />
<%= render_slot(@inner_block) %>
</div>

View File

@ -0,0 +1,30 @@
<label for={@id || @action} class="relative inline-flex items-center cursor-pointer">
<input
id={@id || @action}
type="checkbox"
value={@value}
checked={@value}
class="sr-only peer"
aria-labelledby={"#{@id || @action}-label"}
{
if assigns |> Map.has_key?(:target),
do: %{"phx-click": @action, "phx-value-value": @value, "phx-target": @target},
else: %{"phx-click": @action, "phx-value-value": @value}
}
/>
<div class="w-11 h-6 bg-gray-300 rounded-full peer
peer-focus:ring-4 peer-focus:ring-teal-300 dark:peer-focus:ring-teal-800
peer-checked:bg-gray-600
peer-checked:after:translate-x-full peer-checked:after:border-white
after:content-[''] after:absolute after:top-1 after:left-[2px] after:bg-white after:border-gray-300
after:border after:rounded-full after:h-5 after:w-5
after:transition-all after:duration-250 after:ease-in-out
transition-colors duration-250 ease-in-out">
</div>
<span
id={"#{@id || @action}-label"}
class="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300 whitespace-nowrap"
>
<%= render_slot(@inner_block) %>
</span>
</label>

View File

@ -0,0 +1,100 @@
<nav role="navigation" class="mb-8 px-8 py-4 w-full bg-primary-500">
<div class="flex flex-col sm:flex-row justify-between items-center">
<div class="mb-4 sm:mb-0 sm:mr-8 flex flex-row justify-start items-center space-x-2">
<.link navigate={~p"/"} class="inline mx-2 my-1 leading-5 text-xl text-white">
<img
src={~p"/images/cannery.svg"}
alt={gettext("Cannery logo")}
class="inline-block h-8 mx-1"
/>
<h1 class="inline hover:underline">Cannery</h1>
</.link>
<%= if @title_content do %>
<span class="mx-2 my-1">
|
</span>
<%= @title_content %>
<% end %>
</div>
<hr class="mb-2 sm:hidden hr-light" />
<ul class="flex flex-row flex-wrap justify-center items-center
text-lg text-white text-ellipsis">
<%= if @current_user do %>
<li class="mx-2 my-1">
<.link navigate={~p"/tags"} class="text-white hover:underline">
<%= gettext("Tags") %>
</.link>
</li>
<li class="mx-2 my-1">
<.link navigate={~p"/containers"} class="text-white hover:underline">
<%= gettext("Containers") %>
</.link>
</li>
<li class="mx-2 my-1">
<.link navigate={~p"/catalog"} class="text-white hover:underline">
<%= gettext("Catalog") %>
</.link>
</li>
<li class="mx-2 my-1">
<.link navigate={~p"/ammo"} class="text-white hover:underline">
<%= gettext("Ammo") %>
</.link>
</li>
<li class="mx-2 my-1">
<.link navigate={~p"/range"} class="text-white hover:underline">
<%= gettext("Range") %>
</.link>
</li>
<li :if={@current_user |> Accounts.already_admin?()} class="mx-2 my-1">
<.link navigate={~p"/invites"} class="text-white hover:underline">
<%= gettext("Invites") %>
</.link>
</li>
<li class="mx-2 my-1">
<.link href={~p"/users/settings"} class="text-white hover:underline truncate">
<%= @current_user.email %>
</.link>
</li>
<li class="mx-2 my-1">
<.link
href={~p"/users/log_out"}
method="delete"
data-confirm={dgettext("prompts", "Are you sure you want to log out?")}
aria-label={gettext("Log out")}
>
<i class="fas fa-sign-out-alt"></i>
</.link>
</li>
<li
:if={
@current_user |> Accounts.already_admin?() and
function_exported?(Routes, :live_dashboard_path, 2)
}
class="mx-2 my-1"
>
<.link
navigate={~p"/dashboard"}
class="text-white hover:underline"
aria-label={gettext("Live Dashboard")}
>
<i class="fas fa-gauge"></i>
</.link>
</li>
<% else %>
<li :if={Accounts.allow_registration?()} class="mx-2 my-1">
<.link href={~p"/users/register"} class="text-white hover:underline truncate">
<%= dgettext("actions", "Register") %>
</.link>
</li>
<li class="mx-2 my-1">
<.link href={~p"/users/log_in"} class="text-white hover:underline truncate">
<%= dgettext("actions", "Log in") %>
</.link>
</li>
<% end %>
</ul>
</div>
</nav>

View File

@ -0,0 +1,36 @@
<div
id={"user-#{@user.id}"}
class="mx-4 my-2 px-8 py-4 flex flex-col justify-center items-center text-center
border border-gray-400 rounded-lg shadow-lg hover:shadow-md
transition-all duration-300 ease-in-out"
>
<h1 class="px-4 py-2 rounded-lg title text-xl break-all">
<%= @user.email %>
</h1>
<h3 class="px-4 py-2 rounded-lg title text-lg">
<p>
<%= if @user.confirmed_at do %>
<%= gettext(
"User was confirmed at%{confirmed_datetime}",
confirmed_datetime: ""
) %>
<.datetime id={"#{@user.id}-confirmed-at"} datetime={@user.confirmed_at} />
<% else %>
<%= gettext("Email unconfirmed") %>
<% end %>
</p>
<p>
<%= gettext(
"User registered on%{registered_datetime}",
registered_datetime: ""
) %>
<.datetime id={"#{@user.id}-inserted-at"} datetime={@user.inserted_at} />
</p>
</h3>
<div :if={@inner_block} class="px-4 py-2 flex space-x-4 justify-center items-center">
<%= render_slot(@inner_block) %>
</div>
</div>

View File

@ -0,0 +1,10 @@
defmodule CanneryWeb.EmailHTML do
@moduledoc """
Renders email templates
"""
use CanneryWeb, :html
embed_templates "email_html/*.html", suffix: "_html"
embed_templates "email_html/*.txt", suffix: "_text"
end

View File

@ -3,23 +3,21 @@
<%= dgettext("emails", "Hi %{email},", email: @user.email) %>
</span>
<br/>
<br />
<span style="margin-bottom: 1em; font-size: 1.25em;">
<%= dgettext("emails", "Welcome to %{name}!", name: "Cannery") %>
<%= dgettext("emails", "Welcome to Cannery") %>
</span>
<br/>
<br />
<%= dgettext("emails", "You can confirm your account by visiting the URL below:") %>
<br/>
<br />
<a style="margin: 1em; color: rgb(31, 31, 31);" href="<%= @url %>"><%= @url %></a>
<a style="margin: 1em; color: rgb(31, 31, 31);" href={@url}><%= @url %></a>
<br/>
<br />
<%= dgettext("emails",
"If you didn't create an account at %{name}, please ignore this.",
name: "Cannery") %>
<%= dgettext("emails", "If you didn't create an account at Cannery, please ignore this.") %>
</div>

View File

@ -1,7 +1,7 @@
<%= dgettext("emails", "Hi %{email},", email: @user.email) %>
<%= dgettext("emails", "Welcome to %{name}%!", name: "Cannery") %>
<%= dgettext("emails", "Welcome to Cannery") %>
<%= dgettext("emails", "You can confirm your account by visiting the URL below:") %>
@ -9,4 +9,4 @@
<%= dgettext("emails",
"If you didn't create an account at %{url}, please ignore this.",
url: Routes.live_url(Endpoint, HomeLive)) %>
url: ~p"/") %>

View File

@ -3,17 +3,15 @@
<%= dgettext("emails", "Hi %{email},", email: @user.email) %>
</span>
<br/>
<br />
<%= dgettext("emails", "You can reset your password by visiting the URL below:") %>
<br/>
<br />
<a style="margin: 1em; color: rgb(31, 31, 31);" href="<%= @url %>"><%= @url %></a>
<a style="margin: 1em; color: rgb(31, 31, 31);" href={@url}><%= @url %></a>
<br/>
<br />
<%= dgettext("emails",
"If you didn't request this change from %{name}, please ignore this.",
name: "Cannery") %>
<%= dgettext("emails", "If you didn't request this change from Cannery, please ignore this.") %>
</div>

View File

@ -7,4 +7,4 @@
<%= dgettext("emails",
"If you didn't request this change from %{url}, please ignore this.",
url: Routes.live_url(Endpoint, HomeLive)) %>
url: ~p"/") %>

View File

@ -3,17 +3,18 @@
<%= dgettext("emails", "Hi %{email},", email: @user.email) %>
</span>
<br/>
<br />
<%= dgettext("emails", "You can change your email by visiting the URL below:") %>
<br/>
<br />
<a style="margin: 1em; color: rgb(31, 31, 31);" href="<%= @url %>"><%= @url %></a>
<a style="margin: 1em; color: rgb(31, 31, 31);" href={@url}><%= @url %></a>
<br/>
<br />
<%= dgettext("emails",
"If you didn't request this change from %{name}, please ignore this.",
name: "Cannery") %>
<%= dgettext(
"emails",
"If you didn't request this change from Cannery, please ignore this."
) %>
</div>

View File

@ -7,4 +7,4 @@
<%= dgettext("emails",
"If you didn't request this change from %{url}, please ignore this.",
url: Routes.live_url(Endpoint, HomeLive)) %>
url: ~p"/") %>

View File

@ -1,50 +0,0 @@
defmodule CanneryWeb.Components.InviteCard do
@moduledoc """
Display card for an invite
"""
use CanneryWeb, :component
alias CanneryWeb.Endpoint
def invite_card(assigns) do
~H"""
<div class="mx-4 my-2 px-8 py-4 flex flex-col justify-center items-center space-y-4
border border-gray-400 rounded-lg shadow-lg hover:shadow-md
transition-all duration-300 ease-in-out">
<h1 class="title text-xl">
<%= @invite.name %>
</h1>
<%= if @invite.disabled_at |> is_nil() do %>
<h2 class="title text-md">
<%= gettext("Uses Left:") %>
<%= @invite.uses_left || "Unlimited" %>
</h2>
<% else %>
<h2 class="title text-md">
<%= gettext("Invite Disabled") %>
</h2>
<% end %>
<div class="flex flex-row flex-wrap justify-center items-center">
<code
id={"code-#{@invite.id}"}
class="mx-2 my-1 text-xs px-4 py-2 rounded-lg text-center break-all text-gray-100 bg-primary-800"
>
<%= Routes.user_registration_url(Endpoint, :new, invite: @invite.token) %>
</code>
<%= if @code_actions do %>
<%= render_slot(@code_actions) %>
<% end %>
</div>
<%= if @inner_block do %>
<div class="flex space-x-4 justify-center items-center">
<%= render_slot(@inner_block) %>
</div>
<% end %>
</div>
"""
end
end

View File

@ -0,0 +1,17 @@
defmodule CanneryWeb.Layouts do
@moduledoc """
The root layouts for the entire application
"""
use CanneryWeb, :html
embed_templates "layouts/*"
def get_title(%{assigns: %{title: title}}) when title not in [nil, ""] do
gettext("Cannery | %{title}", title: title)
end
def get_title(_conn) do
gettext("Cannery")
end
end

View File

@ -0,0 +1,45 @@
<main class="pb-8 min-w-full">
<header>
<.topbar current_user={assigns[:current_user]} />
<div class="mx-8 my-2 flex flex-col space-y-4 text-center">
<p
:if={@flash && @flash |> Map.has_key?("info")}
class="alert alert-info"
role="alert"
phx-click="lv:clear-flash"
phx-value-key="info"
>
<%= live_flash(@flash, "info") %>
</p>
<p
:if={@flash && @flash |> Map.has_key?("error")}
class="alert alert-danger"
role="alert"
phx-click="lv:clear-flash"
phx-value-key="error"
>
<%= live_flash(@flash, "error") %>
</p>
</div>
</header>
<div class="mx-4 sm:mx-8 md:mx-16 flex flex-col justify-center items-stretch">
<%= @inner_content %>
</div>
</main>
<div
id="disconnect"
class="z-50 fixed opacity-0 bottom-12 right-12 px-8 py-4 w-max h-max
border border-primary-200 shadow-lg rounded-lg bg-white
flex justify-center items-center space-x-4
transition-opacity ease-in-out duration-500 delay-[2000ms]"
>
<i class="fas fa-fade text-md fa-satellite-dish"></i>
<h1 class="title text-md title-primary-500">
<%= gettext("Reconnecting...") %>
</h1>
</div>

View File

@ -9,11 +9,10 @@
<hr style="margin: 2em auto; border-width: 1px; border-color: rgb(212, 212, 216); width: 100%; max-width: 42rem;" />
<a style="color: rgb(31, 31, 31);" href={Routes.live_url(Endpoint, HomeLive)}>
<a style="color: rgb(31, 31, 31);" href={~p"/"}>
<%= dgettext(
"emails",
"This email was sent from %{name}, the self-hosted firearm tracker website.",
name: "Cannery"
"This email was sent from Cannery, the self-hosted firearm tracker website."
) %>
</a>
</body>

View File

@ -7,6 +7,5 @@
=====================
<%= dgettext("emails",
"This email was sent from %{name} at %{url}, the self-hosted firearm tracker website.",
name: "Cannery",
url: Routes.live_url(Endpoint, HomeLive)) %>
"This email was sent from Cannery at %{url}, the self-hosted firearm tracker website.",
url: ~p"/") %>

View File

@ -0,0 +1 @@
<%= @inner_block %>

View File

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en" class="m-0 p-0 w-full h-full bg-white">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<%= csrf_meta_tag() %>
<link rel="shortcut icon" type="image/jpg" href={~p"/images/cannery.svg"} />
<.live_title suffix={" | #{gettext("Cannery")}"}>
<%= assigns[:page_title] || gettext("Cannery") %>
</.live_title>
<link phx-track-static rel="stylesheet" href={~p"/css/app.css"} />
<script defer phx-track-static type="text/javascript" src={~p"/js/app.js"}>
</script>
</head>
<body class="m-0 p-0 w-full h-full subpixel-antialiased">
<%= @inner_content %>
</body>
</html>

View File

@ -1,28 +1,27 @@
defmodule CanneryWeb.Components.MoveAmmoGroupComponent do
defmodule CanneryWeb.Components.MovePackComponent do
@moduledoc """
Livecomponent that can move an ammo group to another container
Livecomponent that can move a pack to another container
"""
use CanneryWeb, :live_component
alias Cannery.{Accounts.User, Ammo, Ammo.AmmoGroup, Containers, Containers.Container}
alias CanneryWeb.Endpoint
alias Cannery.{Accounts.User, Ammo, Ammo.Pack, Containers, Containers.Container}
alias Ecto.Changeset
alias Phoenix.LiveView.Socket
@impl true
@spec update(
%{
required(:current_user) => User.t(),
required(:ammo_group) => AmmoGroup.t(),
required(:pack) => Pack.t(),
optional(any()) => any()
},
Socket.t()
) :: {:ok, Socket.t()}
def update(
%{ammo_group: %{container_id: container_id} = ammo_group, current_user: current_user} =
assigns,
%{pack: %{container_id: container_id} = pack, current_user: current_user} = assigns,
socket
) do
changeset = ammo_group |> AmmoGroup.update_changeset(%{})
changeset = pack |> Pack.update_changeset(%{}, current_user)
containers =
Containers.list_containers(current_user)
@ -40,21 +39,19 @@ defmodule CanneryWeb.Components.MoveAmmoGroupComponent do
def handle_event(
"move",
%{"container_id" => container_id},
%{assigns: %{ammo_group: ammo_group, current_user: current_user, return_to: return_to}} =
socket
%{assigns: %{pack: pack, current_user: current_user, return_to: return_to}} = socket
) do
%{name: container_name} = Containers.get_container!(container_id, current_user)
socket =
ammo_group
|> Ammo.update_ammo_group(%{"container_id" => container_id}, current_user)
pack
|> Ammo.update_pack(%{"container_id" => container_id}, current_user)
|> case do
{:ok, _ammo_group} ->
{:ok, _pack} ->
prompt = dgettext("prompts", "Ammo moved to %{name} successfully", name: container_name)
socket |> put_flash(:info, prompt) |> push_navigate(to: return_to)
socket |> put_flash(:info, prompt) |> push_redirect(to: return_to)
{:error, %Ecto.Changeset{} = changeset} ->
{:error, %Changeset{} = changeset} ->
socket |> assign(changeset: changeset)
end
@ -64,10 +61,10 @@ defmodule CanneryWeb.Components.MoveAmmoGroupComponent do
@impl true
def render(%{containers: containers} = assigns) do
columns = [
%{label: gettext("Container"), key: "name"},
%{label: gettext("Type"), key: "type"},
%{label: gettext("Location"), key: "location"},
%{label: nil, key: "actions", sortable: false}
%{label: gettext("Container"), key: :name},
%{label: gettext("Type"), key: :type},
%{label: gettext("Location"), key: :location},
%{label: gettext("Actions"), key: :actions, sortable: false}
]
rows = containers |> get_rows_for_containers(assigns, columns)
@ -77,7 +74,7 @@ defmodule CanneryWeb.Components.MoveAmmoGroupComponent do
~H"""
<div class="w-full flex flex-col space-y-8 justify-center items-center">
<h2 class="mb-8 text-center title text-xl text-primary-600">
<%= gettext("Move ammo") %>
<%= dgettext("actions", "Move ammo") %>
</h2>
<%= if @containers |> Enum.empty?() do %>
@ -86,14 +83,13 @@ defmodule CanneryWeb.Components.MoveAmmoGroupComponent do
<%= display_emoji("😔") %>
</h2>
<%= live_patch(dgettext("actions", "Add another container!"),
to: Routes.container_index_path(Endpoint, :new),
class: "btn btn-primary"
) %>
<.link navigate={~p"/containers/new"} class="btn btn-primary">
<%= dgettext("actions", "Add another container!") %>
</.link>
<% else %>
<.live_component
module={CanneryWeb.Components.TableComponent}
id="move_ammo_group_table"
id="move-pack-table"
columns={@columns}
rows={@rows}
/>
@ -107,12 +103,12 @@ defmodule CanneryWeb.Components.MoveAmmoGroupComponent do
containers
|> Enum.map(fn container ->
columns
|> Enum.into(%{}, fn %{key: key} -> {key, get_row_value_by_key(key, container, assigns)} end)
|> Map.new(fn %{key: key} -> {key, get_row_value_by_key(key, container, assigns)} end)
end)
end
@spec get_row_value_by_key(String.t(), Container.t(), map()) :: any()
defp get_row_value_by_key("actions", container, assigns) do
@spec get_row_value_by_key(atom(), Container.t(), map()) :: any()
defp get_row_value_by_key(:actions, container, assigns) do
assigns = assigns |> Map.put(:container, container)
~H"""
@ -122,7 +118,7 @@ defmodule CanneryWeb.Components.MoveAmmoGroupComponent do
class="btn btn-primary"
phx-click="move"
phx-target={@myself}
phx-value-container_id={container.id}
phx-value-container_id={@container.id}
>
<%= dgettext("actions", "Select") %>
</button>
@ -130,6 +126,5 @@ defmodule CanneryWeb.Components.MoveAmmoGroupComponent do
"""
end
defp get_row_value_by_key(key, container, _assigns),
do: container |> Map.get(key |> String.to_existing_atom())
defp get_row_value_by_key(key, container, _assigns), do: container |> Map.get(key)
end

View File

@ -0,0 +1,274 @@
defmodule CanneryWeb.Components.PackTableComponent do
@moduledoc """
A component that displays a list of packs
"""
use CanneryWeb, :live_component
alias Cannery.{Accounts.User, Ammo.Pack, ComparableDate}
alias Cannery.{ActivityLog, Ammo, Containers}
alias CanneryWeb.Components.TableComponent
alias Ecto.UUID
alias Phoenix.LiveView.{Rendered, Socket}
@impl true
@spec update(
%{
required(:id) => UUID.t(),
required(:current_user) => User.t(),
required(:packs) => [Pack.t()],
required(:show_used) => boolean(),
optional(:type) => Rendered.t(),
optional(:range) => Rendered.t(),
optional(:container) => Rendered.t(),
optional(:actions) => Rendered.t(),
optional(any()) => any()
},
Socket.t()
) :: {:ok, Socket.t()}
def update(
%{id: _id, packs: _pack, current_user: _current_user, show_used: _show_used} = assigns,
socket
) do
socket =
socket
|> assign(assigns)
|> assign_new(:type, fn -> [] end)
|> assign_new(:range, fn -> [] end)
|> assign_new(:container, fn -> [] end)
|> assign_new(:actions, fn -> [] end)
|> display_packs()
{:ok, socket}
end
defp display_packs(
%{
assigns: %{
packs: packs,
current_user: current_user,
type: type,
range: range,
container: container,
actions: actions,
show_used: show_used
}
} = socket
) do
lot_number_used = packs |> Enum.any?(fn %{lot_number: lot_number} -> !!lot_number end)
price_paid_used = packs |> Enum.any?(fn %{price_paid: price_paid} -> !!price_paid end)
columns =
[]
|> TableComponent.maybe_compose_columns(
%{label: gettext("Actions"), key: :actions, sortable: false},
actions != []
)
|> TableComponent.maybe_compose_columns(%{
label: gettext("Last used on"),
key: :used_up_on,
type: ComparableDate
})
|> TableComponent.maybe_compose_columns(%{
label: gettext("Purchased on"),
key: :purchased_on,
type: ComparableDate
})
|> TableComponent.maybe_compose_columns(
%{label: gettext("Container"), key: :container},
container != []
)
|> TableComponent.maybe_compose_columns(
%{label: gettext("Range"), key: :range},
range != []
)
|> TableComponent.maybe_compose_columns(
%{label: gettext("Lot number"), key: :lot_number},
lot_number_used
)
|> TableComponent.maybe_compose_columns(
%{label: gettext("CPR"), key: :cpr},
price_paid_used
)
|> TableComponent.maybe_compose_columns(
%{label: gettext("Price paid"), key: :price_paid},
price_paid_used
)
|> TableComponent.maybe_compose_columns(
%{label: gettext("% left"), key: :remaining},
show_used
)
|> TableComponent.maybe_compose_columns(
%{label: gettext("Original Count"), key: :original_count},
show_used
)
|> TableComponent.maybe_compose_columns(%{
label: if(show_used, do: gettext("Current Count"), else: gettext("Count")),
key: :count
})
|> TableComponent.maybe_compose_columns(
%{label: gettext("Type"), key: :type},
type != []
)
containers =
packs
|> Enum.map(fn %{container_id: container_id} -> container_id end)
|> Containers.get_containers(current_user)
extra_data = %{
current_user: current_user,
type: type,
columns: columns,
container: container,
containers: containers,
original_counts: Ammo.get_original_counts(packs, current_user),
cprs: Ammo.get_cprs(packs, current_user),
last_used_dates: ActivityLog.get_last_used_dates(packs, current_user),
percentages_remaining: Ammo.get_percentages_remaining(packs, current_user),
actions: actions,
range: range
}
rows =
packs
|> Enum.map(fn pack ->
pack |> get_row_data_for_pack(extra_data)
end)
socket |> assign(columns: columns, rows: rows)
end
@impl true
def render(assigns) do
~H"""
<div id={@id} class="w-full">
<.live_component
module={TableComponent}
id={"pack-table-#{@id}"}
columns={@columns}
rows={@rows}
/>
</div>
"""
end
@spec get_row_data_for_pack(Pack.t(), additional_data :: map()) :: map()
defp get_row_data_for_pack(pack, %{columns: columns} = additional_data) do
columns
|> Map.new(fn %{key: key} ->
{key, get_value_for_key(key, pack, additional_data)}
end)
end
@spec get_value_for_key(atom(), Pack.t(), additional_data :: map()) ::
any() | {any(), Rendered.t()}
defp get_value_for_key(
:type,
%{type: %{name: type_name} = type},
%{type: type_block}
) do
assigns = %{type: type, type_block: type_block}
{type_name,
~H"""
<%= render_slot(@type_block, @type) %>
"""}
end
defp get_value_for_key(:price_paid, %{price_paid: nil}, _additional_data),
do: {0, gettext("No cost information")}
defp get_value_for_key(:price_paid, %{price_paid: price_paid}, _additional_data),
do: {price_paid, gettext("$%{amount}", amount: display_currency(price_paid))}
defp get_value_for_key(:purchased_on, %{purchased_on: purchased_on} = assigns, _additional_data) do
{purchased_on,
~H"""
<.date id={"#{@id}-purchased-on"} date={@purchased_on} />
"""}
end
defp get_value_for_key(:used_up_on, %{id: pack_id}, %{last_used_dates: last_used_dates}) do
last_used_date = last_used_dates |> Map.get(pack_id)
assigns = %{id: pack_id, last_used_date: last_used_date}
{last_used_date,
~H"""
<%= if @last_used_date do %>
<.date id={"#{@id}-last-used-date"} date={@last_used_date} />
<% else %>
<%= gettext("Never used") %>
<% end %>
"""}
end
defp get_value_for_key(:range, %{staged: staged} = pack, %{range: range}) do
assigns = %{range: range, pack: pack}
{staged,
~H"""
<%= render_slot(@range, @pack) %>
"""}
end
defp get_value_for_key(
:remaining,
%{id: pack_id},
%{percentages_remaining: percentages_remaining}
) do
percentage = Map.fetch!(percentages_remaining, pack_id)
{percentage, gettext("%{percentage}%", percentage: percentage)}
end
defp get_value_for_key(:actions, pack, %{actions: actions}) do
assigns = %{actions: actions, pack: pack}
~H"""
<%= render_slot(@actions, @pack) %>
"""
end
defp get_value_for_key(:container, %{container: nil}, _additional_data), do: {nil, nil}
defp get_value_for_key(
:container,
%{container_id: container_id} = pack,
%{container: container_block, containers: containers}
) do
container = %{name: container_name} = Map.fetch!(containers, container_id)
assigns = %{
container: container,
container_block: container_block,
pack: pack
}
{container_name,
~H"""
<%= render_slot(@container_block, {@pack, @container}) %>
"""}
end
defp get_value_for_key(
:original_count,
%{id: pack_id},
%{original_counts: original_counts}
) do
Map.fetch!(original_counts, pack_id)
end
defp get_value_for_key(:cpr, %{price_paid: nil}, _additional_data),
do: {0, gettext("No cost information")}
defp get_value_for_key(:cpr, %{id: pack_id}, %{cprs: cprs}) do
amount = Map.fetch!(cprs, pack_id)
{amount, gettext("$%{amount}", amount: display_currency(amount))}
end
defp get_value_for_key(:count, %{count: count}, _additional_data),
do: if(count == 0, do: {0, gettext("Empty")}, else: count)
defp get_value_for_key(key, pack, _additional_data), do: pack |> Map.get(key)
@spec display_currency(float()) :: String.t()
defp display_currency(float), do: :erlang.float_to_binary(float, decimals: 2)
end

View File

@ -0,0 +1,123 @@
defmodule CanneryWeb.Components.ShotRecordTableComponent do
@moduledoc """
A component that displays a list of shot records
"""
use CanneryWeb, :live_component
alias Cannery.{Accounts.User, ActivityLog.ShotRecord, Ammo, ComparableDate}
alias Ecto.UUID
alias Phoenix.LiveView.{Rendered, Socket}
@impl true
@spec update(
%{
required(:id) => UUID.t(),
required(:current_user) => User.t(),
optional(:shot_records) => [ShotRecord.t()],
optional(:actions) => Rendered.t(),
optional(any()) => any()
},
Socket.t()
) :: {:ok, Socket.t()}
def update(
%{id: _id, shot_records: _shot_records, current_user: _current_user} = assigns,
socket
) do
socket =
socket
|> assign(assigns)
|> assign_new(:actions, fn -> [] end)
|> display_shot_records()
{:ok, socket}
end
defp display_shot_records(
%{
assigns: %{
shot_records: shot_records,
current_user: current_user,
actions: actions
}
} = socket
) do
columns = [
%{label: gettext("Ammo"), key: :name},
%{label: gettext("Rounds shot"), key: :count},
%{label: gettext("Notes"), key: :notes},
%{label: gettext("Date"), key: :date, type: ComparableDate},
%{label: gettext("Actions"), key: :actions, sortable: false}
]
packs =
shot_records
|> Enum.map(fn %{pack_id: pack_id} -> pack_id end)
|> Ammo.get_packs(current_user)
extra_data = %{current_user: current_user, actions: actions, packs: packs}
rows =
shot_records
|> Enum.map(fn shot_record ->
shot_record |> get_row_data_for_shot_record(columns, extra_data)
end)
socket
|> assign(
columns: columns,
rows: rows
)
end
@impl true
def render(assigns) do
~H"""
<div id={@id} class="w-full">
<.live_component
module={CanneryWeb.Components.TableComponent}
id={"shot-record-table-#{@id}"}
columns={@columns}
rows={@rows}
initial_key={:date}
initial_sort_mode={:desc}
/>
</div>
"""
end
@spec get_row_data_for_shot_record(ShotRecord.t(), columns :: [map()], extra_data :: map()) ::
map()
defp get_row_data_for_shot_record(shot_record, columns, extra_data) do
columns
|> Map.new(fn %{key: key} ->
{key, get_row_value(key, shot_record, extra_data)}
end)
end
defp get_row_value(:name, %{pack_id: pack_id}, %{packs: packs}) do
assigns = %{pack: pack = Map.fetch!(packs, pack_id)}
{pack.type.name,
~H"""
<.link navigate={~p"/ammo/show/#{@pack}"} class="link">
<%= @pack.type.name %>
</.link>
"""}
end
defp get_row_value(:date, %{date: date} = assigns, _extra_data) do
{date,
~H"""
<.date id={"#{@id}-date"} date={@date} />
"""}
end
defp get_row_value(:actions, shot_record, %{actions: actions}) do
assigns = %{actions: actions, shot_record: shot_record}
~H"""
<%= render_slot(@actions, @shot_record) %>
"""
end
defp get_row_value(key, shot_record, _extra_data), do: shot_record |> Map.get(key)
end

View File

@ -6,7 +6,7 @@ defmodule CanneryWeb.Components.TableComponent do
- `:columns`: An array of maps containing the following keys
- `:label`: A gettext'd or otherwise user-facing string label for the
column. Can be nil
- `:key`: A string key used for sorting
- `:key`: An atom key used for sorting
- `:class`: Extra classes to be applied to the column element, if desired.
Optional
- `:sortable`: If false, will prevent the user from sorting with it.
@ -21,6 +21,7 @@ defmodule CanneryWeb.Components.TableComponent do
use CanneryWeb, :live_component
alias Phoenix.LiveView.Socket
require Integer
@impl true
@spec update(
@ -28,26 +29,53 @@ defmodule CanneryWeb.Components.TableComponent do
required(:columns) =>
list(%{
required(:label) => String.t() | nil,
required(:key) => String.t() | nil,
required(:key) => atom() | nil,
optional(:class) => String.t(),
optional(:sortable) => false
optional(:row_class) => String.t(),
optional(:alternate_row_class) => String.t(),
optional(:sortable) => false,
optional(:type) => module()
}),
required(:rows) =>
list(%{
(key :: String.t()) => any() | {custom_sort_value :: String.t(), value :: any()}
(key :: atom()) => any() | {custom_sort_value :: String.t(), value :: any()}
}),
optional(:inital_key) => atom(),
optional(:initial_sort_mode) => atom(),
optional(any()) => any()
},
Socket.t()
) :: {:ok, Socket.t()}
def update(%{columns: columns, rows: rows} = assigns, socket) do
initial_key = columns |> List.first() |> Map.get(:key)
rows = rows |> Enum.sort_by(fn row -> row |> Map.get(initial_key) end, :asc)
initial_key =
if assigns |> Map.has_key?(:initial_key) do
assigns.initial_key
else
columns |> List.first(%{}) |> Map.get(:key)
end
initial_sort_mode =
if assigns |> Map.has_key?(:initial_sort_mode) do
assigns.initial_sort_mode
else
:asc
end
type = columns |> Enum.find(%{}, fn %{key: key} -> key == initial_key end) |> Map.get(:type)
rows = rows |> sort_by_custom_sort_value_or_value(initial_key, initial_sort_mode, type)
socket =
socket
|> assign(assigns)
|> assign(columns: columns, rows: rows, last_sort_key: initial_key, sort_mode: :asc)
|> assign(
columns: columns,
rows: rows,
key: initial_key,
last_sort_key: initial_key,
sort_mode: initial_sort_mode
)
|> assign_new(:row_class, fn -> "bg-white" end)
|> assign_new(:alternate_row_class, fn -> "bg-gray-200" end)
{:ok, socket}
end
@ -56,23 +84,46 @@ defmodule CanneryWeb.Components.TableComponent do
def handle_event(
"sort_by",
%{"sort-key" => key},
%{assigns: %{rows: rows, last_sort_key: key, sort_mode: sort_mode}} = socket
%{
assigns: %{
columns: columns,
rows: rows,
last_sort_key: last_sort_key,
sort_mode: sort_mode
}
} = socket
) do
sort_mode = if sort_mode == :asc, do: :desc, else: :asc
rows = rows |> sort_by_custom_sort_value_or_value(key, sort_mode)
{:noreply, socket |> assign(sort_mode: sort_mode, rows: rows)}
key = key |> String.to_existing_atom()
sort_mode =
case {key, sort_mode} do
{^last_sort_key, :asc} -> :desc
{^last_sort_key, :desc} -> :asc
{_new_sort_key, _last_sort_mode} -> :asc
end
type =
columns |> Enum.find(%{}, fn %{key: column_key} -> column_key == key end) |> Map.get(:type)
rows = rows |> sort_by_custom_sort_value_or_value(key, sort_mode, type)
{:noreply, socket |> assign(last_sort_key: key, sort_mode: sort_mode, rows: rows)}
end
def handle_event(
"sort_by",
%{"sort-key" => key},
%{assigns: %{rows: rows}} = socket
) do
rows = rows |> sort_by_custom_sort_value_or_value(key, :asc)
{:noreply, socket |> assign(last_sort_key: key, sort_mode: :asc, rows: rows)}
defp sort_by_custom_sort_value_or_value(rows, key, sort_mode, type)
when type in [Date, DateTime] do
rows
|> Enum.sort_by(
fn row ->
case row |> Map.get(key) do
{custom_sort_key, _value} -> custom_sort_key
value -> value
end
end,
{sort_mode, type}
)
end
defp sort_by_custom_sort_value_or_value(rows, key, sort_mode) do
defp sort_by_custom_sort_value_or_value(rows, key, sort_mode, _type) do
rows
|> Enum.sort_by(
fn row ->
@ -84,4 +135,25 @@ defmodule CanneryWeb.Components.TableComponent do
sort_mode
)
end
@doc """
Conditionally composes elements into the columns list, supports maps and
lists. Works tail to front in order for efficiency
iex> []
...> |> maybe_compose_columns(%{label: "Column 3"}, true)
...> |> maybe_compose_columns(%{label: "Column 2"}, false)
...> |> maybe_compose_columns(%{label: "Column 1"})
[%{label: "Column 1"}, %{label: "Column 3"}]
"""
@spec maybe_compose_columns(list(), element_to_add :: list() | map()) :: list()
@spec maybe_compose_columns(list(), element_to_add :: list() | map(), boolean()) :: list()
def maybe_compose_columns(columns, element_or_elements, add? \\ true)
def maybe_compose_columns(columns, elements, true) when is_list(elements),
do: Enum.concat(elements, columns)
def maybe_compose_columns(columns, element, true) when is_map(element), do: [element | columns]
def maybe_compose_columns(columns, _element_or_elements, false), do: columns
end

View File

@ -1,31 +1,32 @@
<div class="w-full overflow-x-auto border border-gray-600 rounded-lg shadow-lg bg-black">
<div id={@id} class="w-full overflow-x-auto border border-gray-600 rounded-lg shadow-lg bg-white">
<table class="min-w-full table-auto text-center bg-white">
<thead class="border-b border-primary-600">
<tr>
<%= for %{key: key, label: label} = column <- @columns do %>
<%= if column |> Map.get(:sortable, true) do %>
<th class={"p-2 #{column[:class]}"}>
<th class={["p-2", column[:class]]}>
<span
class="cursor-pointer"
class="cursor-pointer flex justify-center items-center space-x-2"
phx-click="sort_by"
phx-value-sort-key={key}
phx-target={@myself}
>
<span class="underline"><%= label %></span>
<i class="w-0 float-right fas fa-sm fa-chevron-up opacity-0"></i>
<span class={if @last_sort_key == key, do: "underline"}><%= label %></span>
<%= if @last_sort_key == key do %>
<%= case @sort_mode do %>
<% :asc -> %>
<i class="fas fa-sm fa-chevron-down"></i>
<i class="w-0 float-right fas fa-sm fa-chevron-down"></i>
<% :desc -> %>
<i class="fas fa-sm fa-chevron-up"></i>
<i class="w-0 float-right fas fa-sm fa-chevron-up"></i>
<% end %>
<% else %>
<i class="fas fa-sm fa-chevron-up opacity-0"></i>
<i class="w-0 float-right fas fa-sm fa-chevron-up opacity-0"></i>
<% end %>
</span>
</th>
<% else %>
<th class={"p-2 #{column[:class]}"}>
<th class={["p-2 cursor-not-allowed", column[:class]]}>
<%= label %>
</th>
<% end %>
@ -33,20 +34,19 @@
</tr>
</thead>
<tbody>
<%= for values <- @rows do %>
<tr>
<%= for %{key: key} = value <- @columns do %>
<td class={"p-2 #{value[:class]}"}>
<%= case values |> Map.get(key) do %>
<% {_custom_sort_value, value} -> %>
<%= value %>
<% value -> %>
<%= value %>
<% end %>
</td>
<tr
:for={{values, i} <- @rows |> Enum.with_index()}
class={if i |> Integer.is_even(), do: @row_class, else: @alternate_row_class}
>
<td :for={%{key: key} = value <- @columns} class={["p-2", value[:class]]}>
<%= case values |> Map.get(key) do %>
<% {_custom_sort_value, value} -> %>
<%= value %>
<% value -> %>
<%= value %>
<% end %>
</tr>
<% end %>
</td>
</tr>
</tbody>
</table>
</div>

View File

@ -1,32 +0,0 @@
defmodule CanneryWeb.Components.TagCard do
@moduledoc """
Display card for a tag
"""
use CanneryWeb, :component
def tag_card(assigns) do
~H"""
<div
id={"tag-#{@tag.id}"}
class="mx-4 my-2 px-8 py-4 space-x-4 flex justify-center items-center
border border-gray-400 rounded-lg shadow-lg hover:shadow-md
transition-all duration-300 ease-in-out"
>
<.simple_tag_card tag={@tag} />
<%= render_slot(@inner_block) %>
</div>
"""
end
def simple_tag_card(assigns) do
~H"""
<h1
class="inline-block break-all mx-2 my-1 px-4 py-2 rounded-lg title text-xl"
style={"color: #{@tag.text_color}; background-color: #{@tag.bg_color}"}
>
<%= @tag.name %>
</h1>
"""
end
end

View File

@ -1,123 +0,0 @@
defmodule CanneryWeb.Components.Topbar do
@moduledoc """
Component that renders a topbar with user functions/links
"""
use CanneryWeb, :component
alias Cannery.Accounts
alias CanneryWeb.{Endpoint, HomeLive}
def topbar(assigns) do
assigns =
%{results: [], title_content: nil, flash: nil, current_user: nil} |> Map.merge(assigns)
~H"""
<nav role="navigation" class="mb-8 px-8 py-4 w-full bg-primary-400">
<div class="flex flex-col sm:flex-row justify-between items-center">
<div class="mb-4 sm:mb-0 sm:mr-8 flex flex-row justify-start items-center space-x-2">
<%= live_redirect to: Routes.live_path(Endpoint, HomeLive),
class: "inline mx-2 my-1 leading-5 text-xl text-white"
do %>
<img
src={Routes.static_path(Endpoint, "/images/cannery.svg")}
alt={gettext("Cannery logo")}
class="inline-block h-8 mx-1"
/>
<h1 class="inline hover:underline">Cannery</h1>
<% end %>
<%= if @title_content do %>
<span class="mx-2 my-1">
|
</span>
<%= @title_content %>
<% end %>
</div>
<hr class="mb-2 sm:hidden hr-light" />
<ul class="flex flex-row flex-wrap justify-center items-center
text-lg text-white text-ellipsis">
<%= if @current_user do %>
<li class="mx-2 my-1">
<%= live_redirect(gettext("Tags"),
to: Routes.tag_index_path(Endpoint, :index),
class: "text-primary-600 text-white hover:underline"
) %>
</li>
<li class="mx-2 my-1">
<%= live_redirect(gettext("Containers"),
to: Routes.container_index_path(Endpoint, :index),
class: "text-primary-600 text-white hover:underline"
) %>
</li>
<li class="mx-2 my-1">
<%= live_redirect(gettext("Catalog"),
to: Routes.ammo_type_index_path(Endpoint, :index),
class: "text-primary-600 text-white hover:underline"
) %>
</li>
<li class="mx-2 my-1">
<%= live_redirect(gettext("Ammo"),
to: Routes.ammo_group_index_path(Endpoint, :index),
class: "text-primary-600 text-white hover:underline"
) %>
</li>
<li class="mx-2 my-1">
<%= live_redirect(gettext("Range"),
to: Routes.range_index_path(Endpoint, :index),
class: "text-primary-600 text-white hover:underline"
) %>
</li>
<%= if @current_user.role == :admin do %>
<li class="mx-2 my-1">
<%= live_redirect(gettext("Invites"),
to: Routes.invite_index_path(Endpoint, :index),
class: "text-primary-600 text-white hover:underline"
) %>
</li>
<% end %>
<li class="mx-2 my-1">
<%= live_redirect(@current_user.email,
to: Routes.user_settings_path(Endpoint, :edit),
class: "text-primary-600 text-white hover:underline truncate"
) %>
</li>
<li class="mx-2 my-1">
<%= link to: Routes.user_session_path(Endpoint, :delete),
method: :delete,
data: [confirm: dgettext("prompts", "Are you sure you want to log out?")] do %>
<i class="fas fa-sign-out-alt"></i>
<% end %>
</li>
<%= if @current_user.role == :admin and function_exported?(Routes, :live_dashboard_path, 2) do %>
<li class="mx-2 my-1">
<%= live_redirect to: Routes.live_dashboard_path(Endpoint, :home),
class: "text-primary-600 text-white hover:underline" do %>
<i class="fas fa-gauge"></i>
<% end %>
</li>
<% end %>
<% else %>
<%= if Accounts.allow_registration?() do %>
<li class="mx-2 my-1">
<%= live_redirect(dgettext("actions", "Register"),
to: Routes.user_registration_path(Endpoint, :new),
class: "text-primary-600 text-white hover:underline truncate"
) %>
</li>
<% end %>
<li class="mx-2 my-1">
<%= live_redirect(dgettext("actions", "Log in"),
to: Routes.user_session_path(Endpoint, :new),
class: "text-primary-600 text-white hover:underline truncate"
) %>
</li>
<% end %>
</ul>
</div>
</nav>
"""
end
end

View File

@ -0,0 +1,300 @@
defmodule CanneryWeb.Components.TypeTableComponent do
@moduledoc """
A component that displays a list of types
"""
use CanneryWeb, :live_component
alias Cannery.{Accounts.User, ActivityLog, Ammo, Ammo.Type}
alias CanneryWeb.Components.TableComponent
alias Ecto.UUID
alias Phoenix.LiveView.{Rendered, Socket}
@impl true
@spec update(
%{
required(:id) => UUID.t(),
required(:current_user) => User.t(),
optional(:class) => Type.class() | nil,
optional(:show_used) => boolean(),
optional(:types) => [Type.t()],
optional(:actions) => Rendered.t(),
optional(any()) => any()
},
Socket.t()
) :: {:ok, Socket.t()}
def update(%{id: _id, types: _types, current_user: _current_user} = assigns, socket) do
socket =
socket
|> assign(assigns)
|> assign_new(:show_used, fn -> false end)
|> assign_new(:class, fn -> :all end)
|> assign_new(:actions, fn -> [] end)
|> display_types()
{:ok, socket}
end
defp display_types(
%{
assigns: %{
types: types,
current_user: current_user,
show_used: show_used,
class: class,
actions: actions
}
} = socket
) do
filtered_columns =
[
%{label: gettext("Cartridge"), key: :cartridge, type: :string},
%{
label: if(class == :shotgun, do: gettext("Gauge"), else: gettext("Caliber")),
key: :caliber,
type: :string
},
%{label: gettext("Unfired shell length"), key: :unfired_length, type: :string},
%{label: gettext("Brass height"), key: :brass_height, type: :string},
%{label: gettext("Chamber size"), key: :chamber_size, type: :string},
%{label: gettext("Grains"), key: :grains, type: :string},
%{label: gettext("Bullet type"), key: :bullet_type, type: :string},
%{
label: if(class == :shotgun, do: gettext("Slug core"), else: gettext("Bullet core")),
key: :bullet_core,
type: :string
},
%{label: gettext("Jacket type"), key: :jacket_type, type: :string},
%{label: gettext("Case material"), key: :case_material, type: :string},
%{label: gettext("Wadding"), key: :wadding, type: :string},
%{label: gettext("Shot type"), key: :shot_type, type: :string},
%{label: gettext("Shot material"), key: :shot_material, type: :string},
%{label: gettext("Shot size"), key: :shot_size, type: :string},
%{label: gettext("Load grains"), key: :load_grains, type: :string},
%{label: gettext("Shot charge weight"), key: :shot_charge_weight, type: :string},
%{label: gettext("Powder type"), key: :powder_type, type: :string},
%{
label: gettext("Powder grains per charge"),
key: :powder_grains_per_charge,
type: :string
},
%{label: gettext("Pressure"), key: :pressure, type: :string},
%{label: gettext("Dram equivalent"), key: :dram_equivalent, type: :string},
%{label: gettext("Muzzle velocity"), key: :muzzle_velocity, type: :string},
%{label: gettext("Primer type"), key: :primer_type, type: :string},
%{label: gettext("Firing type"), key: :firing_type, type: :string},
%{label: gettext("Tracer"), key: :tracer, type: :atom},
%{label: gettext("Incendiary"), key: :incendiary, type: :atom},
%{label: gettext("Blank"), key: :blank, type: :atom},
%{label: gettext("Corrosive"), key: :corrosive, type: :atom},
%{label: gettext("Manufacturer"), key: :manufacturer, type: :string}
]
|> Enum.filter(fn %{key: key, type: type} ->
# remove columns if all values match defaults
default_value = if type == :atom, do: false, else: nil
types
|> Enum.any?(fn type -> Map.get(type, key, default_value) != default_value end)
end)
columns =
[%{label: gettext("Actions"), key: "actions", type: :actions, sortable: false}]
|> TableComponent.maybe_compose_columns(%{
label: gettext("Average CPR"),
key: :avg_price_paid,
type: :avg_price_paid
})
|> TableComponent.maybe_compose_columns(
%{
label: gettext("Total ever packs"),
key: :historical_pack_count,
type: :historical_pack_count
},
show_used
)
|> TableComponent.maybe_compose_columns(
%{
label: gettext("Used packs"),
key: :used_pack_count,
type: :used_pack_count
},
show_used
)
|> TableComponent.maybe_compose_columns(%{
label: gettext("Packs"),
key: :ammo_count,
type: :ammo_count
})
|> TableComponent.maybe_compose_columns(
%{
label: gettext("Total ever rounds"),
key: :historical_round_count,
type: :historical_round_count
},
show_used
)
|> TableComponent.maybe_compose_columns(
%{
label: gettext("Used rounds"),
key: :used_round_count,
type: :used_round_count
},
show_used
)
|> TableComponent.maybe_compose_columns(%{
label: gettext("Rounds"),
key: :round_count,
type: :round_count
})
|> TableComponent.maybe_compose_columns(filtered_columns)
|> TableComponent.maybe_compose_columns(
%{label: gettext("Class"), key: :class, type: :atom},
class in [:all, nil]
)
|> TableComponent.maybe_compose_columns(%{label: gettext("Name"), key: :name, type: :name})
round_counts = Ammo.get_grouped_round_count(current_user, types: types, group_by: :type_id)
packs_count = Ammo.get_grouped_packs_count(current_user, types: types, group_by: :type_id)
average_costs = Ammo.get_average_costs(types, current_user)
[used_counts, historical_round_counts, historical_pack_counts, used_pack_counts] =
if show_used do
[
ActivityLog.get_grouped_used_counts(current_user, types: types, group_by: :type_id),
Ammo.get_historical_counts(types, current_user),
Ammo.get_grouped_packs_count(current_user,
types: types,
group_by: :type_id,
show_used: true
),
Ammo.get_grouped_packs_count(current_user,
types: types,
group_by: :type_id,
show_used: :only_used
)
]
else
[nil, nil, nil, nil]
end
extra_data = %{
actions: actions,
current_user: current_user,
used_counts: used_counts,
round_counts: round_counts,
historical_round_counts: historical_round_counts,
packs_count: packs_count,
used_pack_counts: used_pack_counts,
historical_pack_counts: historical_pack_counts,
average_costs: average_costs
}
rows =
types
|> Enum.map(fn type ->
type |> get_type_values(columns, extra_data)
end)
socket |> assign(columns: columns, rows: rows)
end
@impl true
def render(assigns) do
~H"""
<div id={@id} class="w-full">
<.live_component
module={TableComponent}
id={"type-table-#{@id}"}
columns={@columns}
rows={@rows}
/>
</div>
"""
end
defp get_type_values(type, columns, extra_data) do
columns
|> Map.new(fn %{key: key, type: column_type} ->
{key, get_type_value(column_type, key, type, extra_data)}
end)
end
defp get_type_value(:atom, key, type, _other_data),
do: type |> Map.get(key) |> humanize()
defp get_type_value(:round_count, _key, %{id: type_id}, %{round_counts: round_counts}),
do: Map.get(round_counts, type_id, 0)
defp get_type_value(
:historical_round_count,
_key,
%{id: type_id},
%{historical_round_counts: historical_round_counts}
) do
Map.get(historical_round_counts, type_id, 0)
end
defp get_type_value(
:used_round_count,
_key,
%{id: type_id},
%{used_counts: used_counts}
) do
Map.get(used_counts, type_id, 0)
end
defp get_type_value(
:historical_pack_count,
_key,
%{id: type_id},
%{historical_pack_counts: historical_pack_counts}
) do
Map.get(historical_pack_counts, type_id, 0)
end
defp get_type_value(
:used_pack_count,
_key,
%{id: type_id},
%{used_pack_counts: used_pack_counts}
) do
Map.get(used_pack_counts, type_id, 0)
end
defp get_type_value(:ammo_count, _key, %{id: type_id}, %{packs_count: packs_count}),
do: Map.get(packs_count, type_id)
defp get_type_value(
:avg_price_paid,
_key,
%{id: type_id},
%{average_costs: average_costs}
) do
case Map.get(average_costs, type_id) do
nil -> {0, gettext("No cost information")}
count -> {count, gettext("$%{amount}", amount: display_currency(count))}
end
end
defp get_type_value(:name, _key, %{name: type_name} = assigns, _other_data) do
{type_name,
~H"""
<.link navigate={~p"/type/#{@id}"} class="link">
<%= @name %>
</.link>
"""}
end
defp get_type_value(:actions, _key, type, %{actions: actions}) do
assigns = %{actions: actions, type: type}
~H"""
<%= render_slot(@actions, @type) %>
"""
end
defp get_type_value(nil, _key, _type, _other_data), do: nil
defp get_type_value(_other, key, type, _other_data), do: type |> Map.get(key)
@spec display_currency(float()) :: String.t()
defp display_currency(float), do: :erlang.float_to_binary(float, decimals: 2)
end

View File

@ -1,43 +0,0 @@
defmodule CanneryWeb.Components.UserCard do
@moduledoc """
Display card for a user
"""
use CanneryWeb, :component
def user_card(assigns) do
~H"""
<div
id={"user-#{@user.id}"}
class="mx-4 my-2 px-8 py-4 flex flex-col justify-center items-center text-center
border border-gray-400 rounded-lg shadow-lg hover:shadow-md
transition-all duration-300 ease-in-out"
>
<h1 class="px-4 py-2 rounded-lg title text-xl break-all">
<%= @user.email %>
</h1>
<h3 class="px-4 py-2 rounded-lg title text-lg">
<p>
<%= if @user.confirmed_at |> is_nil() do %>
Email unconfirmed
<% else %>
User was confirmed at <%= @user.confirmed_at |> display_datetime() %>
<% end %>
</p>
<p>
<%= gettext("User registered on") %>
<%= @user.inserted_at |> display_datetime() %>
</p>
</h3>
<%= if @inner_block do %>
<div class="px-4 py-2 flex space-x-4 justify-center items-center">
<%= render_slot(@inner_block) %>
</div>
<% end %>
</div>
"""
end
end

View File

@ -6,7 +6,8 @@ defmodule CanneryWeb.EmailController do
use CanneryWeb, :controller
alias Cannery.Accounts.User
plug :put_layout, {CanneryWeb.LayoutView, :email}
plug :put_root_layout, html: {CanneryWeb.Layouts, :email_html}
plug :put_layout, false
@sample_assigns %{
email: %{subject: "Example subject"},
@ -18,6 +19,6 @@ defmodule CanneryWeb.EmailController do
Debug route used to preview emails
"""
def preview(conn, %{"id" => template}) do
render(conn, "#{template |> to_string()}.html", @sample_assigns)
render(conn, String.to_existing_atom(template), @sample_assigns)
end
end

View File

@ -0,0 +1,16 @@
defmodule CanneryWeb.ErrorHTML do
use CanneryWeb, :html
embed_templates "error_html/*"
def render(template, _assigns) do
error_string =
case template do
"404.html" -> dgettext("errors", "Not found")
"401.html" -> dgettext("errors", "Unauthorized")
_other_path -> dgettext("errors", "Internal server error")
end
error(%{error_string: error_string})
end
end

View File

@ -5,7 +5,7 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>
<%= dgettext("errors", "Error") %>| Cannery
<%= dgettext("errors", "Error") %> | <%= gettext("Cannery") %>
</title>
<link rel="stylesheet" href="/css/app.css" />
<script defer type="text/javascript" src="/js/app.js">
@ -13,7 +13,7 @@
</head>
<body class="pb-8 m-0 p-0 w-full h-full">
<header>
<.topbar current_user={assigns[:current_user]}></.topbar>
<.topbar current_user={assigns[:current_user]} />
</header>
<div class="pb-8 w-full flex flex-col justify-center items-center text-center">
@ -24,9 +24,9 @@
<hr class="w-full hr" />
<a href={Routes.live_path(Endpoint, HomeLive)} class="link title text-primary-600 text-lg">
<.link href={~p"/"} class="link title text-primary-600 text-lg">
<%= dgettext("errors", "Go back home") %>
</a>
</.link>
</div>
</div>
</body>

View File

@ -0,0 +1,14 @@
defmodule CanneryWeb.ErrorJSON do
import CanneryWeb.Gettext
def render(template, _assigns) do
error_string =
case template do
"404.json" -> dgettext("errors", "Not found")
"401.json" -> dgettext("errors", "Unauthorized")
_other_path -> dgettext("errors", "Internal server error")
end
%{errors: %{detail: error_string}}
end
end

View File

@ -0,0 +1,83 @@
defmodule CanneryWeb.ExportController do
use CanneryWeb, :controller
alias Cannery.{ActivityLog, Ammo, Containers}
def export(%{assigns: %{current_user: current_user}} = conn, %{"mode" => "json"}) do
types = Ammo.list_types(current_user)
used_counts =
ActivityLog.get_grouped_used_counts(current_user, types: types, group_by: :type_id)
round_counts = Ammo.get_grouped_round_count(current_user, types: types, group_by: :type_id)
pack_counts = Ammo.get_grouped_packs_count(current_user, types: types, group_by: :type_id)
total_pack_counts =
Ammo.get_grouped_packs_count(current_user,
types: types,
group_by: :type_id,
show_used: true
)
average_costs = Ammo.get_average_costs(types, current_user)
types =
types
|> Enum.map(fn %{id: type_id} = type ->
type
|> Jason.encode!()
|> Jason.decode!()
|> Map.merge(%{
"average_cost" => Map.get(average_costs, type_id),
"round_count" => Map.get(round_counts, type_id, 0),
"used_count" => Map.get(used_counts, type_id, 0),
"pack_count" => Map.get(pack_counts, type_id, 0),
"total_pack_count" => Map.get(total_pack_counts, type_id, 0)
})
end)
packs = Ammo.list_packs(current_user, show_used: true)
used_counts =
ActivityLog.get_grouped_used_counts(current_user, packs: packs, group_by: :pack_id)
original_counts = packs |> Ammo.get_original_counts(current_user)
cprs = packs |> Ammo.get_cprs(current_user)
percentages_remaining = packs |> Ammo.get_percentages_remaining(current_user)
packs =
packs
|> Enum.map(fn %{id: pack_id} = pack ->
pack
|> Jason.encode!()
|> Jason.decode!()
|> Map.merge(%{
"used_count" => Map.get(used_counts, pack_id),
"percentage_remaining" => Map.fetch!(percentages_remaining, pack_id),
"original_count" => Map.get(original_counts, pack_id),
"cpr" => Map.get(cprs, pack_id)
})
end)
shot_records = ActivityLog.list_shot_records(current_user)
containers =
Containers.list_containers(current_user)
|> Enum.map(fn container ->
container
|> Jason.encode!()
|> Jason.decode!()
|> Map.merge(%{
"pack_count" => Ammo.get_packs_count(current_user, container_id: container.id),
"round_count" => Ammo.get_round_count(current_user, container_id: container.id)
})
end)
json(conn, %{
user: current_user,
types: types,
packs: packs,
shot_records: shot_records,
containers: containers
})
end
end

View File

@ -1,7 +0,0 @@
defmodule CanneryWeb.HomeController do
use CanneryWeb, :controller
def index(conn, _params) do
render(conn, "index.html")
end
end

View File

@ -0,0 +1,5 @@
defmodule CanneryWeb.HomeHTML do
use CanneryWeb, :html
embed_templates "home_html/*"
end

View File

@ -3,12 +3,11 @@ defmodule CanneryWeb.UserAuth do
Functions for user session and authentication
"""
use CanneryWeb, :verified_routes
import Plug.Conn
import Phoenix.Controller
import CanneryWeb.Gettext
alias Cannery.{Accounts, Accounts.User}
alias CanneryWeb.HomeLive
alias CanneryWeb.Router.Helpers, as: Routes
# Make the remember me cookie valid for 60 days.
# If you want bump or reduce this value, also change
@ -39,7 +38,7 @@ defmodule CanneryWeb.UserAuth do
dgettext("errors", "You must confirm your account and log in to access this page.")
)
|> maybe_store_return_to()
|> redirect(to: Routes.user_session_path(conn, :new))
|> redirect(to: ~p"/users/log_in")
|> halt()
end
@ -49,8 +48,7 @@ defmodule CanneryWeb.UserAuth do
conn
|> renew_session()
|> put_session(:user_token, token)
|> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}")
|> put_token_in_session(token)
|> maybe_write_remember_me_cookie(token, params)
|> redirect(to: user_return_to || signed_in_path(conn))
end
@ -96,7 +94,7 @@ defmodule CanneryWeb.UserAuth do
"""
def log_out_user(conn) do
user_token = get_session(conn, :user_token)
user_token && Accounts.delete_session_token(user_token)
user_token && Accounts.delete_user_session_token(user_token)
if live_socket_id = get_session(conn, :live_socket_id) do
CanneryWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
@ -105,7 +103,7 @@ defmodule CanneryWeb.UserAuth do
conn
|> renew_session()
|> delete_resp_cookie(@remember_me_cookie)
|> redirect(to: "/")
|> redirect(to: ~p"/")
end
@doc """
@ -119,19 +117,110 @@ defmodule CanneryWeb.UserAuth do
end
defp ensure_user_token(conn) do
if user_token = get_session(conn, :user_token) do
{user_token, conn}
if token = get_session(conn, :user_token) do
{token, conn}
else
conn = fetch_cookies(conn, signed: [@remember_me_cookie])
if user_token = conn.cookies[@remember_me_cookie] do
{user_token, put_session(conn, :user_token, user_token)}
if token = conn.cookies[@remember_me_cookie] do
{token, put_token_in_session(conn, token)}
else
{nil, conn}
end
end
end
@doc """
Handles mounting and authenticating the current_user in LiveViews.
## `on_mount` arguments
* `:mount_current_user` - Assigns current_user
to socket assigns based on user_token, or nil if
there's no user_token or no matching user.
* `:ensure_authenticated` - Authenticates the user from the session,
and assigns the current_user to socket assigns based
on user_token.
Redirects to login page if there's no logged user.
* `:redirect_if_user_is_authenticated` - Authenticates the user from the session.
Redirects to signed_in_path if there's a logged user.
## Examples
Use the `on_mount` lifecycle macro in LiveViews to mount or authenticate
the current_user:
defmodule CanneryWeb.PageLive do
use CanneryWeb, :live_view
on_mount {CanneryWeb.UserAuth, :mount_current_user}
...
end
Or use the `live_session` of your router to invoke the on_mount callback:
live_session :authenticated, on_mount: [{CanneryWeb.UserAuth, :ensure_authenticated}] do
live "/profile", ProfileLive, :index
end
"""
def on_mount(:mount_current_user, _params, session, socket) do
{:cont, mount_current_user(session, socket)}
end
def on_mount(:ensure_authenticated, _params, session, socket) do
socket = mount_current_user(session, socket)
if socket.assigns.current_user do
{:cont, socket}
else
error_flash = dgettext("errors", "You must log in to access this page.")
socket =
socket
|> Phoenix.LiveView.put_flash(:error, error_flash)
|> Phoenix.LiveView.redirect(to: ~p"/users/log_in")
{:halt, socket}
end
end
def on_mount(:ensure_admin, _params, session, socket) do
socket = mount_current_user(session, socket)
if socket.assigns.current_user && socket.assigns.current_user.role == :admin do
{:cont, socket}
else
error_flash = dgettext("errors", "You must log in as an administrator to access this page.")
socket =
socket
|> Phoenix.LiveView.put_flash(:error, error_flash)
|> Phoenix.LiveView.redirect(to: ~p"/users/log_in")
{:halt, socket}
end
end
def on_mount(:redirect_if_user_is_authenticated, _params, session, socket) do
socket = mount_current_user(session, socket)
if socket.assigns.current_user do
{:halt, Phoenix.LiveView.redirect(socket, to: signed_in_path(socket))}
else
{:cont, socket}
end
end
defp mount_current_user(session, socket) do
Phoenix.Component.assign_new(socket, :current_user, fn ->
if user_token = session["user_token"] do
Accounts.get_user_by_session_token(user_token)
end
end)
end
@doc """
Used for routes that require the user to not be authenticated.
"""
@ -161,7 +250,7 @@ defmodule CanneryWeb.UserAuth do
dgettext("errors", "You must confirm your account and log in to access this page.")
)
|> maybe_store_return_to()
|> redirect(to: Routes.user_session_path(conn, :new))
|> redirect(to: ~p"/users/log_in")
|> halt()
end
end
@ -176,16 +265,34 @@ defmodule CanneryWeb.UserAuth do
conn
|> put_flash(:error, dgettext("errors", "You are not authorized to view this page."))
|> maybe_store_return_to()
|> redirect(to: Routes.live_path(conn, HomeLive))
|> redirect(to: ~p"/")
|> halt()
end
end
def put_user_locale(%{assigns: %{current_user: %{locale: locale}}} = conn, _opts) do
default = Application.fetch_env!(:gettext, :default_locale)
Gettext.put_locale(locale || default)
conn |> put_session(:locale, locale || default)
end
def put_user_locale(conn, _opts) do
default = Application.fetch_env!(:gettext, :default_locale)
Gettext.put_locale(default)
conn |> put_session(:locale, default)
end
defp put_token_in_session(conn, token) do
conn
|> put_session(:user_token, token)
|> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}")
end
defp maybe_store_return_to(%{method: "GET"} = conn) do
put_session(conn, :user_return_to, current_path(conn))
end
defp maybe_store_return_to(conn), do: conn
defp signed_in_path(_conn), do: "/"
defp signed_in_path(_conn), do: ~p"/"
end

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