Compare commits
	
		
			540 Commits
		
	
	
		
			3ce8eda712
			...
			weblate
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 3fac142454 | |||
| 14615ac107 | |||
| 05b1630a96 | |||
| 1fd3a1bebe | |||
| 6e9bc94f35 | |||
| 4e1cf81242 | |||
| 09e59c94f0 | |||
| 859e5756ae | |||
| 45b46b761d | |||
| bda051ebc8 | |||
| 01fa306429 | |||
| 5cff5d8280 | |||
| 71778d12a6 | |||
| 9b721a170b | |||
| 366a6d160d | |||
| 926d2fe6c2 | |||
| c7bd7238c6 | |||
| e2c17b6b51 | |||
| 20988ac1ec | |||
| 37d101a71e | |||
| 449a92e4b7 | |||
| 5d17ee0a11 | |||
| b6b6cecc0a | |||
| 08916a504f | |||
| 3eda522903 | |||
| 2e6e26006d | |||
| b66d0ea8a1 | |||
| 839e1d7124 | |||
| 76834845a3 | |||
| cc1413ade5 | |||
| 668e4c611b | |||
| ab3d3721d6 | |||
| 7e14f292a6 | |||
| 16a5cb9254 | |||
| f722f9901b | |||
| 6adae82e94 | |||
| a87bf15f72 | |||
| 75c0f8642b | |||
| ec782515ac | |||
| e1cb46cb97 | |||
| 56a49ed2e3 | |||
| f25c151956 | |||
| c2ddc2ae0d | |||
| 33e4d26a3d | |||
| 179d67a896 | |||
| 15354d6004 | |||
| e358cd6e4e | |||
| 202b70dc66 | |||
| b963baa49d | |||
| 70701a27d3 | |||
| 67dc16d222 | |||
| fa35038426 | |||
| d896257602 | |||
| 4ca51a3f53 | |||
| 96b05e8332 | |||
| 557a2cac3d | |||
| e16e04c114 | |||
| bbe4d82303 | |||
| c69d7843ab | |||
| c18f59050c | |||
| 67d688fc1e | |||
|  | 28e5fa56c3 | ||
|  | e301d3dd17 | ||
|  | 4881cf6edb | ||
|  | 6b61c45889 | ||
|  | 4a674a0504 | ||
| 7e6959fb3b | |||
| 22f13b0c57 | |||
| 31024dcc0d | |||
| e843014502 | |||
| 5d146ce6af | |||
| 27cda3733e | |||
| 1965ecba32 | |||
| 69e40c6d18 | |||
| 34b4b24e67 | |||
| 7ebed8d4c0 | |||
| b5619b8606 | |||
| ef28de53a1 | |||
| fcd5dbc605 | |||
| 7738e68292 | |||
| df645a6188 | |||
| bed4fbaf54 | |||
| f94ef0a2ca | |||
| 7cdb8af690 | |||
| 52c4ab45d5 | |||
| a35f43d6df | |||
| 9edeb1e803 | |||
| 7e55446b3e | |||
| 9643e9f46d | |||
| 8466fcd1f9 | |||
| e713a2e108 | |||
| a8fa321040 | |||
| f0536f3030 | |||
| a94d2eebf4 | |||
| cfc56519f5 | |||
| e80c2018be | |||
| 71fdd42d96 | |||
| 8e99a57994 | |||
| 7c42dd8a3a | |||
| 79c97d7502 | |||
| 2e488fa26c | |||
| 2179bd5d86 | |||
| 49628cb9bb | |||
| 8a58d53dc1 | |||
| 9306d0f970 | |||
| 763c86a379 | |||
| b85b1735c0 | |||
| ab1a288928 | |||
| e6ef0a8c68 | |||
| beeaf521c5 | |||
| 8cb6068b85 | |||
| 334d841d57 | |||
| 1037f37be2 | |||
| 1796fb822f | |||
| 8ed64f9c87 | |||
| dd4a9f7119 | |||
| dbafaad500 | |||
| eb4ce07b5f | |||
| 2b7550a954 | |||
| abaccac9f0 | |||
| 2b81978adb | |||
| 17bfe1a987 | |||
| 81f21a02c4 | |||
| 9a17d4ad24 | |||
| 9835fe3f5e | |||
| 4dee8808f3 | |||
| 65c70ca398 | |||
| 550f6a6420 | |||
| 88c3f15fc8 | |||
| c33f15603b | |||
| 98775359af | |||
| e0e7b25bc4 | |||
| bdddf65685 | |||
| 6f50702b11 | |||
| 5f8d1a917f | |||
| 32801828fa | |||
| 6ed3312ea8 | |||
| b122253b9b | |||
| a68a16bf06 | |||
| 4b6d0952f8 | |||
| 0544b58ab6 | |||
| 6d26103784 | |||
| 0cae7c2940 | |||
| 1e645b5bb8 | |||
| bab2b26c13 | |||
| 8c95536ffd | |||
| d9251c7e4c | |||
| fe4e4f4f17 | |||
| e5e5449e8b | |||
| 355752598c | |||
| 03f8a2e8a7 | |||
| 071eb1b3c9 | |||
| 2987e4ff37 | |||
| ca81924ebe | |||
| 40e4f6fe0a | |||
| 213dcca973 | |||
| b32edd581d | |||
| 2e372ca2ab | |||
| fd0bac3bbf | |||
| f83fbc5d99 | |||
| daab051026 | |||
| 440dc5061b | |||
| c0d2c69144 | |||
| 7a7359fa66 | |||
| 9e8fd00d65 | |||
| f5f72b53e6 | |||
| a54cf8b87d | |||
| 0b7146ba32 | |||
| c0441957b6 | |||
| 7fa9933a9b | |||
| f4c7f22460 | |||
| a01d97e360 | |||
|  | a53b352cf7 | ||
| ce07cc2569 | |||
| 3acecb9a93 | |||
| ab8561fcf0 | |||
| 8163b906a2 | |||
| b29a5cce7b | |||
| 9205a04ac5 | |||
| 632a9e1379 | |||
| 92cc49630d | |||
| a778f5a61f | |||
| 07ff796553 | |||
| 07b31fcc86 | |||
| bc034c0361 | |||
| eb9280fa7e | |||
| ad1e44fd42 | |||
| 4e9f66f006 | |||
| 81350f9898 | |||
| 9c4a32896f | |||
| 56dae6cdfe | |||
| 8ef3bd65a3 | |||
| c8cadd6246 | |||
| 3f143262d4 | |||
| ebe09bcf84 | |||
| 42e2d1c76e | |||
| 064d2d3988 | |||
| fd0b2c455a | |||
| f1139d0ec4 | |||
| f6b5fc17fa | |||
| cd6bb6fbc3 | |||
| 2c0a4dd7ca | |||
| 5c05f3b6fe | |||
| 30d3f76fe1 | |||
| ed8c20e967 | |||
| 47dab6490d | |||
| 7b60938a75 | |||
| f19d024d8a | |||
| 084173909e | |||
| 1fbed50b0f | |||
| 2f8af8ae4f | |||
| 737484c36e | |||
| 2cf705c46f | |||
| 725df05521 | |||
| 3ae890c193 | |||
| 6dbadc58ae | |||
| 75fcbb1e65 | |||
| e568a2f073 | |||
| 0b27c8f80b | |||
| f155a43ee8 | |||
| 0ad1ee47de | |||
| 8ea2b06487 | |||
| bbaa1dfd6b | |||
| 2c2b9fefc9 | |||
| bafc824a32 | |||
| 8c2f7e0509 | |||
| 1e4accec9d | |||
| 076d5eea18 | |||
| 5b6bd00047 | |||
| 22abc7a8d0 | |||
| 5a685ac00e | |||
| 8dd471afa8 | |||
| a5c12b3e17 | |||
| 27af5acf8b | |||
| 469428c007 | |||
| 09d3754f92 | |||
| fa67fd5a3b | |||
| 0a56b32b17 | |||
| 1cd28e43b8 | |||
| 5f214ab7f9 | |||
| 0c95fa88f4 | |||
| 61f8776d2e | |||
| 695002c9d9 | |||
| dde60d71d1 | |||
| 91794ddc55 | |||
| 1e3cec95fe | |||
| f0a8c515f9 | |||
| e99775eef2 | |||
| 6760f83ca0 | |||
| 10877bb754 | |||
| 38a581b639 | |||
| 9408705430 | |||
| 302aa7eeda | |||
| fd4fdcc36b | |||
| 3cb723b9e4 | |||
| 1f92c452d1 | |||
| c10cff63ea | |||
| 70faed71d0 | |||
| b5c46c09ec | |||
| 7745765fc0 | |||
| e16fbba810 | |||
| e35bdf101b | |||
| aa314e5ca1 | |||
| 616de3c117 | |||
| 74bcec6cfe | |||
| 41090c46d0 | |||
| c3f5744ad6 | |||
| 95a339fe02 | |||
| 1e3b027367 | |||
| dd46e1795f | |||
| 9e517e6477 | |||
| 26f75c7d15 | |||
| 23f7af2a93 | |||
| 8e771a2d9a | |||
| 16155687c9 | |||
| ef0d644ee3 | |||
| faacf80b51 | |||
| 7a7754767f | |||
| 0894293620 | |||
| 58b32817ba | |||
| 45da547f62 | |||
| 3ea4b77b67 | |||
| d743336868 | |||
| c99fbabcaa | |||
| 95642061db | |||
| 11ef53d1bf | |||
| 757eca47f7 | |||
| 7191fe8e4b | |||
|  | 02253c74ac | ||
|  | fa32b2a073 | ||
|  | aa0ea10fa4 | ||
|  | 72ac611e15 | ||
|  | ff0af84d5d | ||
|  | 851d0954ad | ||
|  | ea4336adc5 | ||
|  | fcce6a6f40 | ||
|  | 4d3085da2a | ||
| 2ec60ba342 | |||
| e0ac714681 | |||
| 20a2311229 | |||
| a6ae8a872d | |||
| 81e448afc4 | |||
| dfebad713f | |||
| bf0ea4168b | |||
| 5ffa627beb | |||
| 25d4f1916a | |||
| fd583f2941 | |||
| 5cae646758 | |||
| d389515f76 | |||
| c2568b6521 | |||
| 60462877c5 | |||
| ba5a4e69b2 | |||
|  | ba36b4e4c5 | ||
|  | f5a092e91f | ||
| 09774e0aed | |||
| 44d4d7c6f9 | |||
| 1fed895b82 | |||
| 3480aff61d | |||
| 1ea4b99c81 | |||
| 2191e7f4f4 | |||
| 8231683958 | |||
| a3e7e3a86d | |||
| 0c088ed06d | |||
| 1c7721887f | |||
| 0dbd1af553 | |||
| c828def2b2 | |||
| 7ef582510e | |||
| 726ede7e46 | |||
| 8fa75e2559 | |||
| 0d6f6de7df | |||
| 76d3554b4b | |||
| 09394ea408 | |||
| 3dd6430105 | |||
| 53bb0ddb03 | |||
| e3ff04ced3 | |||
| db4b3fab24 | |||
| d86f5a6d98 | |||
| 9420337228 | |||
| e2a4dc4b92 | |||
| 175eef95fb | |||
| ba8d7988b3 | |||
| 1f017ced4a | |||
| 67304ae22e | |||
| 30da5bc4f7 | |||
| 580d703f12 | |||
| cb4de9a6ff | |||
| ca4b5b9822 | |||
| 13bf8ab2b1 | |||
| ffd1029e78 | |||
| 1d8f3360d8 | |||
| 6b565abf93 | |||
| 239b15a782 | |||
| cfbec3189c | |||
| cc31958bbe | |||
| 9e386f1631 | |||
| 36a0a1c6c8 | |||
| 2e0bb861f1 | |||
| dbc575d305 | |||
| 5390dcc4c2 | |||
| dc355fcd8e | |||
| 44fbd69e0f | |||
| 10c4f40864 | |||
| d512ec8e10 | |||
| 81b68ba4af | |||
| b6e0a543ec | |||
| cf75007e4d | |||
| 1583ad3503 | |||
| 2af9c7a4f9 | |||
| 7e7d6258d5 | |||
| f246b9db93 | |||
| 5836a82ff7 | |||
| 7464947497 | |||
| 3adb8c9aae | |||
| dce04e4d7f | |||
| ee6266be3f | |||
| 36f56528ee | |||
| 76bbab1de9 | |||
| 6c09261368 | |||
| 3593334c85 | |||
| 57b5cb432d | |||
| 5b5fd7173b | |||
| 41bcc2f456 | |||
| 947659b207 | |||
| 9ebca20dc6 | |||
| 3cbd62e84c | |||
| 67010640f0 | |||
| 5fd46c326f | |||
| 8c62a39c97 | |||
| 9a1a104c6d | |||
| 11b1ae9980 | |||
| 948fa929ec | |||
| 268085e761 | |||
| a1efd7cc60 | |||
| 2db9ab968f | |||
| 7863b06215 | |||
| eb8cae27c0 | |||
| ecca9ee4a9 | |||
|  | a0c4f20afd | ||
|  | 0e46a05360 | ||
|  | 1062d97f79 | ||
|  | 1b5df8c56c | ||
|  | da5c136313 | ||
| ef206d273f | |||
|  | ef513a8108 | ||
|  | ed1765c652 | ||
|  | 541e6a4612 | ||
|  | 43589a88c3 | ||
|  | 15a6e75e7d | ||
|  | 2681a37fad | ||
| d086c4c813 | |||
| f49fe21da5 | |||
| 717c898d48 | |||
| 085411132d | |||
| c8a6a9d81e | |||
| a0389db9ef | |||
| e6e4db7410 | |||
| 78542a533a | |||
| 46665dce88 | |||
| 4b420f313c | |||
| 42b4d0758f | |||
| 901c2a948f | |||
| 6fe5a29ebd | |||
| ce7223597c | |||
| 4791a2849e | |||
| 7edc9642d4 | |||
| 2ae0c3133c | |||
|  | f284773f68 | ||
|  | d35a81ef39 | ||
|  | 4c3ebaec53 | ||
|  | a13a57c6ee | ||
|  | 8c7b9f9a9b | ||
|  | 7b7604f48f | ||
|  | b1b52b9edc | ||
|  | 3fcea8e679 | ||
|  | 98ec324a2c | ||
|  | 0868789811 | ||
|  | 87c62f9ef8 | ||
|  | 0919ee2a72 | ||
|  | 0b910af077 | ||
|  | cb81eb4116 | ||
|  | b1d7cfe591 | ||
| 1431b92e42 | |||
| c555022ea7 | |||
| 0917aff37b | |||
| 7f9e6f9eff | |||
| 9e754fe630 | |||
| 45d905b384 | |||
| 3297130890 | |||
| d27b54386d | |||
| ebf32c49bf | |||
|  | 119f2af6bb | ||
| 9db6b2c316 | |||
| 22281486e0 | |||
| 1b7546aede | |||
|  | e153893a5b | ||
|  | 2f7c17aad3 | ||
| 664c65d136 | |||
|  | ef76eb002d | ||
|  | 5b40ac4137 | ||
|  | ed20cdd858 | ||
|  | 1e0ec82f3e | ||
|  | d07ac801aa | ||
|  | 420e7c2d71 | ||
|  | 40877d1ac0 | ||
|  | 1dd30e6a5b | ||
|  | eedaf33e25 | ||
| b0a100cd6c | |||
| 6455e2710d | |||
| 6523b28aa2 | |||
| bad1a23dfe | |||
| e0ddefe1d7 | |||
| 5d6ecba9f7 | |||
| a2d1ff9b89 | |||
| 34288a0070 | |||
| f9b08222e1 | |||
| c0179f48bd | |||
| 8bb4aab49c | |||
| 9f2cc54738 | |||
| 0309e9d714 | |||
| af4af84515 | |||
| ec6946068e | |||
| f120e54c3e | |||
| 3d115c6383 | |||
| 1d4622a285 | |||
| d9e7948bb0 | |||
| da8c788992 | |||
| bd20820361 | |||
| d0ee81093a | |||
| 6080fdbe64 | |||
| f42aaf9099 | |||
| a6aa6f3386 | |||
| 34118299e9 | |||
| 8405513337 | |||
| d0857eccc1 | |||
| 4c9e707181 | |||
| e6a4fbcfb5 | |||
| a6b2c6181e | |||
| d79d0fa179 | |||
| 46387e8d7a | |||
| dccea1b9de | |||
| 5ca21c64fd | |||
| 9773ccc6ff | |||
| 9cd2bc574b | |||
| ee28de1178 | |||
| 91cf9d0eb5 | |||
| 97a9b6d51a | |||
| 059004ba78 | |||
| 5d02ed6369 | |||
| ec6acdbb5d | |||
| 33d82a902d | |||
| fc5b03d680 | |||
| c918dbe4bf | |||
| 23b60e032d | |||
| 7283932d85 | |||
| 8ff1fd0276 | |||
| 4ef09f5279 | |||
| 9734be4966 | |||
| 485965d9c9 | |||
| e9cdb0f717 | |||
| 1c07449b54 | |||
| b64e85f65c | |||
| 9387756109 | |||
| f1f3082368 | |||
| 50a8a79596 | |||
| 67b48e1a3f | |||
| 67c30d7f88 | |||
| 728728a5a4 | |||
| a64d92a6cf | |||
| 6227d64072 | |||
| 46eed25a94 | |||
| 3674eeaf5a | |||
| f0676a2433 | |||
| a72a4b0cbe | |||
| a2dea04668 | |||
| 3dc255b7c2 | |||
| bde1cff7a4 | |||
| 66cc11e9eb | |||
| 81a250206e | |||
| 6d5f7f68df | |||
| 5b5f1ce1e5 | 
							
								
								
									
										14
									
								
								.credo.exs
									
									
									
									
									
								
							
							
						
						
									
										14
									
								
								.credo.exs
									
									
									
									
									
								
							| @@ -157,17 +157,17 @@ | ||||
|         # | ||||
|         # Controversial and experimental checks (opt-in, just replace `false` with `[]`) | ||||
|         # | ||||
|         {Credo.Check.Consistency.MultiAliasImportRequireUse, false}, | ||||
|         {Credo.Check.Consistency.UnusedVariableNames, false}, | ||||
|         {Credo.Check.Consistency.MultiAliasImportRequireUse, []}, | ||||
|         {Credo.Check.Consistency.UnusedVariableNames, [force: :meaningful]}, | ||||
|         {Credo.Check.Design.DuplicatedCode, false}, | ||||
|         {Credo.Check.Readability.AliasAs, false}, | ||||
|         {Credo.Check.Readability.BlockPipe, false}, | ||||
|         {Credo.Check.Readability.ImplTrue, false}, | ||||
|         {Credo.Check.Readability.MultiAlias, false}, | ||||
|         {Credo.Check.Readability.SeparateAliasRequire, false}, | ||||
|         {Credo.Check.Readability.SeparateAliasRequire, []}, | ||||
|         {Credo.Check.Readability.SinglePipe, false}, | ||||
|         {Credo.Check.Readability.Specs, false}, | ||||
|         {Credo.Check.Readability.StrictModuleLayout, false}, | ||||
|         {Credo.Check.Readability.StrictModuleLayout, []}, | ||||
|         {Credo.Check.Readability.WithCustomTaggedTuple, false}, | ||||
|         {Credo.Check.Refactor.ABCSize, false}, | ||||
|         {Credo.Check.Refactor.AppendSingleItem, false}, | ||||
| @@ -176,9 +176,9 @@ | ||||
|         {Credo.Check.Refactor.NegatedIsNil, false}, | ||||
|         {Credo.Check.Refactor.PipeChainStart, false}, | ||||
|         {Credo.Check.Refactor.VariableRebinding, false}, | ||||
|         {Credo.Check.Warning.LeakyEnvironment, false}, | ||||
|         {Credo.Check.Warning.MapGetUnsafePass, false}, | ||||
|         {Credo.Check.Warning.UnsafeToAtom, false} | ||||
|         {Credo.Check.Warning.LeakyEnvironment, []}, | ||||
|         {Credo.Check.Warning.MapGetUnsafePass, []}, | ||||
|         {Credo.Check.Warning.UnsafeToAtom, []} | ||||
|  | ||||
|         # | ||||
|         # Custom checks can be created using `mix credo.gen.check`. | ||||
|   | ||||
							
								
								
									
										34
									
								
								.drone.yml
									
									
									
									
									
								
							
							
						
						
									
										34
									
								
								.drone.yml
									
									
									
									
									
								
							| @@ -13,24 +13,38 @@ steps: | ||||
|     mount: | ||||
|       - _build | ||||
|       - deps | ||||
|       - assets/node_modules/ | ||||
|       - .npm | ||||
|       - .mix | ||||
|  | ||||
| - name: test | ||||
|   image: bitwalker/alpine-elixir-phoenix:1.13 | ||||
|   image: elixir:1.18.4-otp-28-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 | ||||
|   - mix local.rebar --force | ||||
|   - mix local.hex --force | ||||
|   - mix deps.get | ||||
|   - npm install --prefix assets | ||||
|   - mix test | ||||
|   - npm set cache .npm | ||||
|   - npm --prefix ./assets ci --no-audit --prefer-offline | ||||
|   - mix do phx.digest, gettext.extract, assets.deploy | ||||
|   - mix test.all | ||||
|  | ||||
| - name: build and publish stable | ||||
|   image: plugins/docker | ||||
|   privileged: true | ||||
|   settings: | ||||
|     repo: shibaobun/cannery | ||||
|     purge: true | ||||
|     compress: true | ||||
|     platforms: | ||||
|       - linux/amd64 | ||||
|       - linux/arm64 | ||||
|       - linux/arm/v7 | ||||
|     username: | ||||
|       from_secret: docker_username | ||||
|     password: | ||||
| @@ -42,8 +56,15 @@ steps: | ||||
|  | ||||
| - name: build and publish tagged version | ||||
|   image: plugins/docker | ||||
|   privileged: true | ||||
|   settings: | ||||
|     repo: shibaobun/cannery | ||||
|     purge: true | ||||
|     compress: true | ||||
|     platforms: | ||||
|       - linux/amd64 | ||||
|       - linux/arm64 | ||||
|       - linux/arm/v7 | ||||
|     username: | ||||
|       from_secret: docker_username | ||||
|     password: | ||||
| @@ -64,7 +85,8 @@ steps: | ||||
|     mount: | ||||
|       - _build | ||||
|       - deps | ||||
|       - assets/node_modules/ | ||||
|       - .npm | ||||
|       - .mix | ||||
|  | ||||
| services: | ||||
| - name: database | ||||
| @@ -77,7 +99,7 @@ services: | ||||
| volumes: | ||||
|   - name: cache | ||||
|     host: | ||||
|       path: /tmp/drone-cache | ||||
|       path: /run/media/default/ssdsrv/gitea/drone-cache | ||||
|   - name: docker_sock | ||||
|     host: | ||||
|       path: /var/run/docker.sock | ||||
|   | ||||
| @@ -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: [HeexFormatter] | ||||
|   plugins: [Phoenix.LiveView.HTMLFormatter] | ||||
| ] | ||||
|   | ||||
							
								
								
									
										11
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										11
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -25,13 +25,14 @@ 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/, | ||||
| # we ignore priv/static. You may want to comment | ||||
| # this depending on your deployment strategy. | ||||
| /priv/static/ | ||||
| # Ignore assets that are produced by build tools. | ||||
| /priv/static/assets/ | ||||
|  | ||||
| # Ignore digested assets cache. | ||||
| /priv/static/cache_manifest.json | ||||
|  | ||||
| # vs code | ||||
| .elixir_ls/ | ||||
|   | ||||
| @@ -1,3 +1,3 @@ | ||||
| elixir 1.13.2-otp-24 | ||||
| erlang 24.2 | ||||
| nodejs 16.13.2 | ||||
| elixir 1.18.4-otp-28 | ||||
| erlang 28.0.1 | ||||
| nodejs 24.3.0 | ||||
|   | ||||
							
								
								
									
										240
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						
									
										240
									
								
								CHANGELOG.md
									
									
									
									
									
								
							| @@ -1,5 +1,245 @@ | ||||
| # v0.9.16 | ||||
| - Fix bug with ammo type selector | ||||
| - Add a row number column to all tables | ||||
|  | ||||
| # v0.9.15 | ||||
| - Update deps | ||||
| - Add better reverse proxy and database upgrade instructions | ||||
|  | ||||
| # v0.9.14 | ||||
| - Update deps | ||||
| - Fix wrapping issues with search bars | ||||
| - Improve accuracy of timestamps | ||||
|  | ||||
| # v0.9.13 | ||||
| - Add button to resend email verification email | ||||
| - Move staging to container, rather than ammo | ||||
| - Add date restriction dropdown to range page | ||||
| - Fix dates not rendering properly in table | ||||
| - Update deps | ||||
|  | ||||
| # v0.9.12 | ||||
| - Allow filtering ammo types when creating new packs | ||||
| - Add SlimSelect to select elements with user content | ||||
| - Fix registration page not offering all translations | ||||
| - Update deps | ||||
|  | ||||
| # v0.9.11 | ||||
| - Fix an issue with emails not being able to be sent for real this time | ||||
| - Fix some dropdowns not filling in the correct data | ||||
| - Add debounces to more fields | ||||
| - Update deps | ||||
|  | ||||
| # v0.9.10 | ||||
| - Fix issue with logger failing on oban exceptions | ||||
| - Fix an issue with emails not being able to be sent | ||||
| - Update deps | ||||
|  | ||||
| # 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" | ||||
| - URL paths now reflect new names | ||||
| - Add pack and round count to container information | ||||
| - Add cute logo >:3 Thank you [kalli](https://twitter.com/t0kkuro)! | ||||
| - Add note about deleting an ammo type deleting all ammo of that type as well | ||||
| - Prompt to create first ammo type before trying to create first ammo | ||||
| - Add note about creating unlimited invites | ||||
| - Update screenshot lol | ||||
|  | ||||
| # v0.5.3 | ||||
| - Update French translation: Thank you [duponin](https://udongein.xyz/users/duponin)! | ||||
| - Update German translation: Thank you [Kaia](https://shitposter.club/users/kaia)! | ||||
|  | ||||
| # v0.5.2 | ||||
| - Add "Added on" date to ammo groups | ||||
| - Add "Added on" date to ammo types | ||||
| - Add "Registered on" date to user information | ||||
| - Add language in user settings. The `LOCALE` environment variable will continue | ||||
|   to set the default locale for the application. | ||||
| - Add involvement links to home page | ||||
| - Fix button text-wrapping | ||||
| - Update dependencies | ||||
|  | ||||
| # v0.5.1 | ||||
| - Add French translation: Thank you [duponin](https://udongein.xyz/users/duponin)! | ||||
|  | ||||
| # v0.5.0 | ||||
| - Add German translation: Thank you [Kaia](https://shitposter.club/users/kaia)! | ||||
| - Fix not being able to edit ammo group when fully used up | ||||
| - Fix bug with average price per round calculation | ||||
| - Show average price per round on ammo type table | ||||
| - Use Elixir v1.13.4 | ||||
|  | ||||
| # v0.4.1 | ||||
| - Fix button and tag text wrapping | ||||
| - Code quality fixes | ||||
|  | ||||
| # v0.4.0 | ||||
| - Make tables sortable | ||||
| - Add link to changelog from version number | ||||
| - Fix some elements flashing with black background | ||||
| - Fix bug with moving ammo group to new container | ||||
| - Fix bug with no error showing up for create ammo group form | ||||
|  | ||||
| # 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 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 | ||||
| - Fix modals with overflowing forms | ||||
| - Fix grids having uneven margins in phone mode | ||||
| - Add page titles to registration and setting pages | ||||
|  | ||||
| # v0.2.2 | ||||
|   | ||||
| @@ -10,6 +10,9 @@ status](https://weblate.bubbletea.dev/widgets/cannery/-/287x66-black.png)](https | ||||
|  | ||||
| If you're multilingual, this project can use your translations! Visit | ||||
| [weblate](https://weblate.bubbletea.dev/engage/cannery/) for more information. | ||||
| Also, if your language isn't displayed here, I'd love to add that language so | ||||
| you can start! Please contact me at | ||||
| (shibao@bubbletea.dev)[mailto:shibao@bubbletea.dev] and let me know! | ||||
|  | ||||
| ## Style Tips | ||||
|  | ||||
| @@ -17,8 +20,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 | ||||
| @@ -109,7 +112,8 @@ In `dev` mode, Cannery will listen for these environment variables at runtime. | ||||
|   Defaults to `false`. | ||||
| - `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 locale. Defaults to `en_US`. | ||||
| - `LOCALE`: Sets a custom default locale. Defaults to `en_US`. | ||||
|   - Available options: `en_US`, `de`, `fr`, and `es` | ||||
|  | ||||
| ## `MIX_ENV=test` | ||||
|  | ||||
| @@ -126,7 +130,7 @@ In `test` mode (or in the Docker container), Cannery will listen for the same en | ||||
| In `prod` mode (or in the Docker container), Cannery will listen for the same environment variables as dev mode, but also include the following at runtime: | ||||
|  | ||||
| - `SECRET_KEY_BASE`: Secret key base used to sign cookies. Must be generated | ||||
|   with `docker run -it shibaobun/cannery mix phx.gen.secret` and set for server to start. | ||||
|   with `docker run -it shibaobun/cannery priv/random.sh` and set for server to start. | ||||
| - `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! | ||||
| @@ -141,3 +145,7 @@ In `prod` mode (or in the Docker container), Cannery will listen for the same en | ||||
| Thank you so much for your contributions! | ||||
|  | ||||
| - shibao (https://misskey.bubbletea.dev/@shibao) | ||||
| - kaia (https://shitposter.club/users/kaia) | ||||
| - duponin (https://udongein.xyz/users/duponin) | ||||
| - kalli (https://twitter.com/t0kkuro) | ||||
| - brea (https://refusal.biz/users/tarperfume) | ||||
|   | ||||
							
								
								
									
										13
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										13
									
								
								Dockerfile
									
									
									
									
									
								
							| @@ -1,4 +1,4 @@ | ||||
| FROM elixir:1.13-alpine AS build | ||||
| FROM elixir:1.18.4-otp-28-alpine AS build | ||||
|  | ||||
| # install build dependencies | ||||
| RUN apk add --no-cache build-base npm git python3 | ||||
| @@ -7,8 +7,8 @@ RUN apk add --no-cache build-base npm git python3 | ||||
| WORKDIR /app | ||||
|  | ||||
| # install hex + rebar | ||||
| RUN mix local.hex --force && \ | ||||
|     mix local.rebar --force | ||||
| RUN mix local.rebar --force && \ | ||||
|     mix local.hex --force | ||||
|  | ||||
| # set build ENV | ||||
| ENV MIX_ENV=prod | ||||
| @@ -25,24 +25,25 @@ RUN npm --prefix ./assets ci --progress=false --no-audit --loglevel=error | ||||
| COPY lib lib | ||||
| COPY priv priv | ||||
| COPY assets assets | ||||
| RUN npm run --prefix ./assets deploy | ||||
| RUN mix do phx.digest, gettext.extract | ||||
|  | ||||
| # compile and build release | ||||
| # uncomment COPY if rel/ exists | ||||
| # COPY rel rel | ||||
| RUN mix do compile, release | ||||
| RUN mix do assets.deploy, compile, release | ||||
|  | ||||
| # prepare release image | ||||
| 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 | ||||
|  | ||||
| RUN chown nobody:nobody /app | ||||
|  | ||||
| ENV MIX_ENV=prod | ||||
|  | ||||
| USER nobody:nobody | ||||
|  | ||||
| COPY --from=build --chown=nobody:nobody /app/_build/prod/rel/cannery ./ | ||||
|   | ||||
							
								
								
									
										45
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										45
									
								
								README.md
									
									
									
									
									
								
							| @@ -1,6 +1,6 @@ | ||||
| # Cannery | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| The self-hosted firearm tracker website. | ||||
|  | ||||
| @@ -13,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 | ||||
| @@ -46,6 +46,16 @@ ports: | ||||
| ``` | ||||
| and reverse proxy to `http://localhost:4000`. | ||||
|  | ||||
| If you don't already have a reverse proxy on the machine, I recommend installing | ||||
| [Nginx Proxy Manager](https://nginxproxymanager.com/), which is a GUI for Nginx | ||||
| that makes it easy to configure and modify as your hosting needs change. By | ||||
| adding NPM to cannery's `docker-compose.yml`, you can avoid needing to bind any | ||||
| ports to your machine and have all the internal traffic routed through the | ||||
| generated docker network, which can be a bit more secure. The example | ||||
| configuration is commented out in the `docker-compose.yml` file, and more | ||||
| information can be found on their documentation | ||||
| [here](https://nginxproxymanager.com/setup/). | ||||
|  | ||||
| # Configuration | ||||
|  | ||||
| You can use the following environment variables to configure Cannery in | ||||
| @@ -60,10 +70,11 @@ You can use the following environment variables to configure Cannery in | ||||
|   Defaults to `false`. | ||||
| - `POOL_SIZE`: Controls the pool size to use with PostgreSQL. Defaults to `10`. | ||||
| - `SECRET_KEY_BASE`: Secret key base used to sign cookies. Must be generated | ||||
|   with `docker run -it shibaobun/cannery mix phx.gen.secret` and set for server to start. | ||||
|   with `docker run -it shibaobun/cannery priv/random.sh` and set for server to start. | ||||
| - `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 locale. Defaults to `en_US`. | ||||
| - `LOCALE`: Sets a custom default locale. Defaults to `en_US` | ||||
|   - 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! | ||||
| @@ -73,6 +84,20 @@ You can use the following environment variables to configure Cannery in | ||||
|   `no-reply@HOST` where `HOST` was previously defined. | ||||
| - `EMAIL_NAME`: Sets the sender name in sent emails. Defaults to "Cannery". | ||||
|  | ||||
| # Upgrading the Database | ||||
|  | ||||
| Typically, PostgreSQL updates can improve the performance of the database, and | ||||
| the cannery app. However, these require some additional maintenance. While the | ||||
| typical method is to manually dump and restore the database using the `pg_dump` | ||||
| tool, I recommend using the | ||||
| [pgautoupgrade tool](https://github.com/pgautoupgrade/docker-pgautoupgrade), | ||||
| which can perform this for you automatically. In the `docker-compose.yml` file, | ||||
| you can do this easily by switching the `image:` value from for example, | ||||
| `postgres:13` to `pgautoupgrade/pgautoupgrade:17-alpine` and rerun | ||||
| `docker-compose up -d`. This will automatically migrate your database to | ||||
| Postgres 17, and then you can switch back to the original `postgres:17` image | ||||
| for additional performance, or keep using the upgrade image if you'd like. | ||||
|  | ||||
| # Contribution | ||||
|  | ||||
| Contributions are greatly appreciated, no ability to code needed! You can browse | ||||
| @@ -91,6 +116,16 @@ 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 | ||||
|  | ||||
| - [Website](https://cannery.app): Project website | ||||
| - [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 | ||||
|   | ||||
| @@ -1,5 +0,0 @@ | ||||
| { | ||||
|     "presets": [ | ||||
|         "@babel/preset-env" | ||||
|     ] | ||||
| } | ||||
| @@ -1,153 +0,0 @@ | ||||
| @import "tailwindcss/base"; | ||||
| @import "tailwindcss/components"; | ||||
| @import "tailwindcss/utilities"; | ||||
|  | ||||
| $fa-font-path: "@fortawesome/fontawesome-free/webfonts"; | ||||
| @import "@fortawesome/fontawesome-free/scss/fontawesome"; | ||||
| @import "@fortawesome/fontawesome-free/scss/regular"; | ||||
| @import "@fortawesome/fontawesome-free/scss/solid"; | ||||
| @import "@fortawesome/fontawesome-free/scss/brands"; | ||||
|  | ||||
| @import "components"; | ||||
|  | ||||
| /* fix firefox scrollbars */ | ||||
| * { | ||||
|   scrollbar-width: auto; | ||||
|   scrollbar-color: rgba(161, 161, 170, var(--tw-bg-opacity)) white; | ||||
| } | ||||
|  | ||||
| .fa-fade { | ||||
|   animation: pulse 1s ease-in-out 0s infinite alternate; | ||||
| } | ||||
|  | ||||
| @keyframes pulse { | ||||
|   0% { scale: 0.95; opacity: 0.5; } | ||||
|   100% { scale: 1.0; opacity: 1; } | ||||
| } | ||||
|  | ||||
| .phx-connected > #disconnect, #loading { | ||||
|   opacity: 0 !important; | ||||
|   pointer-events: none; | ||||
| } | ||||
|  | ||||
| .phx-loading:not(.phx-error) > #loading, .phx-error > #disconnect { | ||||
|   opacity: 0.95 !important; | ||||
| } | ||||
|  | ||||
| /* Alerts and form errors used by phx.new */ | ||||
| .alert { | ||||
|   padding: 15px; | ||||
|   margin-bottom: 20px; | ||||
|   border: 1px solid transparent; | ||||
|   border-radius: 4px; | ||||
| } | ||||
| .alert-info { | ||||
|   color: #31708f; | ||||
|   background-color: #d9edf7; | ||||
|   border-color: #bce8f1; | ||||
| } | ||||
| .alert-warning { | ||||
|   color: #8a6d3b; | ||||
|   background-color: #fcf8e3; | ||||
|   border-color: #faebcc; | ||||
| } | ||||
| .alert-danger { | ||||
|   color: #a94442; | ||||
|   background-color: #f2dede; | ||||
|   border-color: #ebccd1; | ||||
| } | ||||
| .alert p { | ||||
|   margin-bottom: 0; | ||||
| } | ||||
| .alert:empty { | ||||
|   display: none; | ||||
| } | ||||
| .invalid-feedback { | ||||
|   color: #a94442; | ||||
|   display: block; | ||||
|   margin: -1rem 0 2rem; | ||||
| } | ||||
|  | ||||
| /* LiveView specific classes for your customization */ | ||||
| .phx-no-feedback.invalid-feedback, | ||||
| .phx-no-feedback .invalid-feedback { | ||||
|   display: none; | ||||
| } | ||||
|  | ||||
| .phx-click-loading { | ||||
|   opacity: 0.5; | ||||
|   transition: opacity 1s ease-out; | ||||
| } | ||||
|  | ||||
| .phx-loading{ | ||||
|   cursor: wait; | ||||
| } | ||||
|  | ||||
| .phx-modal { | ||||
|   opacity: 1!important; | ||||
|   position: fixed; | ||||
|   z-index: 1; | ||||
|   left: 0; | ||||
|   top: 0; | ||||
|   width: 100%; | ||||
|   height: 100%; | ||||
|   overflow: auto; | ||||
|   background-color: rgba(0,0,0,0.4); | ||||
| } | ||||
|  | ||||
| .phx-modal-content { | ||||
|   background-color: #fefefe; | ||||
|   margin: 15vh auto; | ||||
|   padding: 20px; | ||||
|   border: 1px solid #888; | ||||
|   width: 80%; | ||||
| } | ||||
|  | ||||
| .phx-modal-close { | ||||
|   color: #aaa; | ||||
|   float: right; | ||||
|   font-size: 28px; | ||||
|   font-weight: bold; | ||||
| } | ||||
|  | ||||
| .phx-modal-close:hover, | ||||
| .phx-modal-close:focus { | ||||
|   color: black; | ||||
|   text-decoration: none; | ||||
|   cursor: pointer; | ||||
| } | ||||
|  | ||||
| .fade-in-scale { | ||||
|   animation: 0.2s ease-in 0s normal forwards 1 fade-in-scale-keys; | ||||
| } | ||||
|  | ||||
| .fade-out-scale { | ||||
|   animation: 0.2s ease-out 0s normal forwards 1 fade-out-scale-keys; | ||||
| } | ||||
|  | ||||
| .fade-in { | ||||
|   animation: 0.2s ease-out 0s normal forwards 1 fade-in-keys; | ||||
| } | ||||
| .fade-out { | ||||
|   animation: 0.2s ease-out 0s normal forwards 1 fade-out-keys; | ||||
| } | ||||
|  | ||||
| @keyframes fade-in-scale-keys{ | ||||
|   0% { scale: 0.95; opacity: 0; } | ||||
|   100% { scale: 1.0; opacity: 1; } | ||||
| } | ||||
|  | ||||
| @keyframes fade-out-scale-keys{ | ||||
|   0% { scale: 1.0; opacity: 1; } | ||||
|   100% { scale: 0.95; opacity: 0; } | ||||
| } | ||||
|  | ||||
| @keyframes fade-in-keys{ | ||||
|   0% { opacity: 0; } | ||||
|   100% { opacity: 1; } | ||||
| } | ||||
|  | ||||
| @keyframes fade-out-keys{ | ||||
|   0% { opacity: 1; } | ||||
|   100% { opacity: 0; } | ||||
| } | ||||
| @@ -1,57 +0,0 @@ | ||||
| @layer components { | ||||
|   .input { | ||||
|     @apply rounded-lg px-4 py-2 border focus:outline-none; | ||||
|     @apply shadow-sm focus:shadow-lg; | ||||
|     @apply transition-all duration-300 ease-in-out; | ||||
|   } | ||||
|  | ||||
|   .input-primary { | ||||
|     @apply border-primary-500 hover:border-primary-600 active:border-primary-600; | ||||
|     @apply text-black; | ||||
|   } | ||||
|  | ||||
|   .checkbox { | ||||
|     -ms-transform: scale(1.5); | ||||
|     -moz-transform: scale(1.5); | ||||
|     -webkit-transform: scale(1.5); | ||||
|     -o-transform: scale(1.5); | ||||
|     transform: scale(1.5); | ||||
|     padding: 10px; | ||||
|     margin: 1em auto; | ||||
|   } | ||||
|  | ||||
|   .title { | ||||
|     @apply leading-5 tracking-wide; | ||||
|   } | ||||
|  | ||||
|   .btn { | ||||
|     @apply focus:outline-none px-4 py-2 rounded-lg; | ||||
|     @apply shadow-sm focus:shadow-lg; | ||||
|     @apply transition-all duration-300 ease-in-out; | ||||
|   } | ||||
|  | ||||
|   .btn-primary { | ||||
|     @apply bg-primary-500 focus:bg-primary-600 active:bg-primary-600; | ||||
|     @apply border-primary-500 focus:border-primary-600 active:border-primary-600; | ||||
|     @apply text-white; | ||||
|   } | ||||
|  | ||||
|   .btn-alert { | ||||
|     @apply bg-red-600 focus:bg-red-700 active:bg-red-800; | ||||
|     @apply border-red-600 focus:border-red-700 active:border-red-800; | ||||
|     @apply text-white; | ||||
|   } | ||||
|  | ||||
|   .hr { | ||||
|     @apply border border-primary-300 w-full max-w-2xl; | ||||
|   } | ||||
|  | ||||
|   .hr-light { | ||||
|     @apply border border-white w-full max-w-2xl; | ||||
|   } | ||||
|  | ||||
|   .link { | ||||
|     @apply hover:underline; | ||||
|     @apply transition-colors duration-500 ease-in-out; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										266
									
								
								assets/css/style.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										266
									
								
								assets/css/style.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,266 @@ | ||||
| @import "tailwindcss" source("../.."); | ||||
|  | ||||
| @theme { | ||||
|   --color-primary-50: oklch(0.985 0.002 247.839); | ||||
|   --color-primary-100: oklch(0.967 0.003 264.542); | ||||
|   --color-primary-200: oklch(0.928 0.006 264.531); | ||||
|   --color-primary-300: oklch(0.872 0.01 258.338); | ||||
|   --color-primary-400: oklch(0.707 0.022 261.325); | ||||
|   --color-primary-500: oklch(0.551 0.027 264.364); | ||||
|   --color-primary-600: oklch(0.446 0.03 256.802); | ||||
|   --color-primary-700: oklch(0.373 0.034 259.733); | ||||
|   --color-primary-800: oklch(0.278 0.033 256.848); | ||||
|   --color-primary-900: oklch(0.21 0.034 264.665); | ||||
|   --color-primary-950: oklch(0.13 0.028 261.692); | ||||
|  | ||||
| } | ||||
|  | ||||
| @import "@fortawesome/fontawesome-free/css/fontawesome" source("../.."); | ||||
| @import "@fortawesome/fontawesome-free/css/regular" source("../.."); | ||||
| @import "@fortawesome/fontawesome-free/css/solid" source("../.."); | ||||
| @import "@fortawesome/fontawesome-free/css/brands" source("../.."); | ||||
|  | ||||
| @import "slim-select/styles" source("../.."); | ||||
|  | ||||
| /* fix firefox scrollbars */ | ||||
| * { | ||||
|   scrollbar-width: auto; | ||||
|   scrollbar-color: rgba(161, 161, 170, var(--tw-bg-opacity)) white; | ||||
| } | ||||
|  | ||||
| .fa-fade { | ||||
|   animation: pulse 1s ease-in-out 0s infinite alternate; | ||||
| } | ||||
|  | ||||
| @keyframes pulse { | ||||
|   0% { scale: 0.95; opacity: 0.5; } | ||||
|   100% { scale: 1.0; opacity: 1; } | ||||
| } | ||||
|  | ||||
| /* disconnect toast */ | ||||
| .phx-connected > #disconnect { | ||||
|   opacity: 0 !important; | ||||
|   pointer-events: none; | ||||
| } | ||||
|  | ||||
| .phx-error > #disconnect { | ||||
|   opacity: 0.95 !important; | ||||
| } | ||||
|  | ||||
| /* Alerts and form errors used by phx.new */ | ||||
| .alert { | ||||
|   padding: 15px; | ||||
|   margin-bottom: 20px; | ||||
|   border: 1px solid transparent; | ||||
|   border-radius: 4px; | ||||
| } | ||||
| .alert-info { | ||||
|   color: #31708f; | ||||
|   background-color: #d9edf7; | ||||
|   border-color: #bce8f1; | ||||
| } | ||||
| .alert-warning { | ||||
|   color: #8a6d3b; | ||||
|   background-color: #fcf8e3; | ||||
|   border-color: #faebcc; | ||||
| } | ||||
| .alert-danger { | ||||
|   color: #a94442; | ||||
|   background-color: #f2dede; | ||||
|   border-color: #ebccd1; | ||||
| } | ||||
| .alert p { | ||||
|   margin-bottom: 0; | ||||
| } | ||||
| .alert:empty { | ||||
|   display: none; | ||||
| } | ||||
| .invalid-feedback { | ||||
|   color: #a94442; | ||||
|   display: block; | ||||
|   margin: -1rem 0 2rem; | ||||
| } | ||||
|  | ||||
| /* LiveView specific classes for your customization */ | ||||
| .phx-no-feedback.invalid-feedback, | ||||
| .phx-no-feedback .invalid-feedback { | ||||
|   display: none; | ||||
| } | ||||
|  | ||||
| .phx-click-loading { | ||||
|   opacity: 0.5; | ||||
|   transition: opacity 1s ease-out; | ||||
| } | ||||
|  | ||||
| .phx-loading{ | ||||
|   cursor: wait; | ||||
| } | ||||
|  | ||||
| .phx-modal { | ||||
|   opacity: 1!important; | ||||
|   position: fixed; | ||||
|   z-index: 1; | ||||
|   left: 0; | ||||
|   top: 0; | ||||
|   width: 100%; | ||||
|   height: 100%; | ||||
|   overflow: auto; | ||||
|   background-color: rgba(0,0,0,0.4); | ||||
| } | ||||
|  | ||||
| .phx-modal-content { | ||||
|   background-color: #fefefe; | ||||
|   margin: 15vh auto; | ||||
|   padding: 20px; | ||||
|   border: 1px solid #888; | ||||
|   width: 80%; | ||||
| } | ||||
|  | ||||
| .phx-modal-close { | ||||
|   color: #aaa; | ||||
|   float: right; | ||||
|   font-size: 28px; | ||||
|   font-weight: bold; | ||||
| } | ||||
|  | ||||
| .phx-modal-close:hover, | ||||
| .phx-modal-close:focus { | ||||
|   color: black; | ||||
|   text-decoration: none; | ||||
|   cursor: pointer; | ||||
| } | ||||
|  | ||||
| .fade-in-scale { | ||||
|   animation: 0.2s ease-in 0s normal forwards 1 fade-in-scale-keys; | ||||
| } | ||||
|  | ||||
| .fade-out-scale { | ||||
|   animation: 0.2s ease-out 0s normal forwards 1 fade-out-scale-keys; | ||||
| } | ||||
|  | ||||
| .fade-in { | ||||
|   animation: 0.2s ease-out 0s normal forwards 1 fade-in-keys; | ||||
| } | ||||
| .fade-out { | ||||
|   animation: 0.2s ease-out 0s normal forwards 1 fade-out-keys; | ||||
| } | ||||
|  | ||||
| @keyframes fade-in-scale-keys{ | ||||
|   0% { scale: 0.95; opacity: 0; } | ||||
|   100% { scale: 1.0; opacity: 1; } | ||||
| } | ||||
|  | ||||
| @keyframes fade-out-scale-keys{ | ||||
|   0% { scale: 1.0; opacity: 1; } | ||||
|   100% { scale: 0.95; opacity: 0; } | ||||
| } | ||||
|  | ||||
| @keyframes fade-in-keys{ | ||||
|   0% { opacity: 0; } | ||||
|   100% { opacity: 1; } | ||||
| } | ||||
|  | ||||
| @keyframes fade-out-keys{ | ||||
|   0% { opacity: 1; } | ||||
|   100% { opacity: 0; } | ||||
| } | ||||
|  | ||||
| /* components */ | ||||
| .input, .ss-main, .ss-content, .ss-search input[type="search"] { | ||||
|   @apply px-4 py-2 rounded-lg border focus:outline-hidden; | ||||
|   @apply shadow-sm focus:shadow-lg; | ||||
|   @apply transition-all duration-300 ease-in-out; | ||||
| } | ||||
|  | ||||
| .input-primary { | ||||
|   @apply border-primary-500 hover:border-primary-600 active:border-primary-600; | ||||
|   @apply text-black; | ||||
| } | ||||
|  | ||||
| .checkbox { | ||||
|   -ms-transform: scale(1.5); | ||||
|   -moz-transform: scale(1.5); | ||||
|   -webkit-transform: scale(1.5); | ||||
|   -o-transform: scale(1.5); | ||||
|   transform: scale(1.5); | ||||
|   padding: 10px; | ||||
|   margin: 1em auto; | ||||
| } | ||||
|  | ||||
| .title { | ||||
|   @apply tracking-wide leading-5; | ||||
| } | ||||
|  | ||||
| .btn { | ||||
|   @apply px-4 py-2 rounded-lg focus:outline-hidden; | ||||
|   @apply shadow-sm focus:shadow-lg; | ||||
|   @apply transition-all duration-300 ease-in-out; | ||||
| } | ||||
|  | ||||
| .btn-primary { | ||||
|   @apply bg-primary-500 focus:bg-primary-600 active:bg-primary-600; | ||||
|   @apply border-primary-500 focus:border-primary-600 active:border-primary-600; | ||||
|   @apply text-white; | ||||
| } | ||||
|  | ||||
| .btn-alert { | ||||
|   @apply bg-rose-600 focus:bg-rose-700 active:bg-rose-800; | ||||
|   @apply border-rose-600 focus:border-rose-700 active:border-rose-800; | ||||
|   @apply text-white; | ||||
| } | ||||
|  | ||||
| .hr { | ||||
|   @apply w-full max-w-2xl border border-primary-300; | ||||
| } | ||||
|  | ||||
| .hr-light { | ||||
|   @apply w-full max-w-2xl border border-white; | ||||
| } | ||||
|  | ||||
| .link { | ||||
|   @apply hover:underline; | ||||
|   @apply transition-colors duration-500 ease-in-out; | ||||
| } | ||||
|  | ||||
| /* slim select */ | ||||
| .ss-main.input-primary { | ||||
|   @apply border-primary-500 hover:border-primary-600 active:border-primary-600; | ||||
| } | ||||
|  | ||||
| .ss-content.input-primary { | ||||
|   @apply border-primary-500 hover:border-primary-600 active:border-primary-600; | ||||
| } | ||||
|  | ||||
| .ss-content.ss-open-above { | ||||
|   border-bottom-left-radius: 0px; | ||||
|   border-bottom-right-radius: 0px; | ||||
| } | ||||
|  | ||||
| .ss-content.ss-open-below { | ||||
|   border-top-left-radius: 0px; | ||||
|   border-top-right-radius: 0px; | ||||
| } | ||||
|  | ||||
| .ss-content.input-primary .ss-search input[type="search"] { | ||||
|   @apply border-primary-500 hover:border-primary-600 active:border-primary-600; | ||||
| } | ||||
|  | ||||
| .ss-content.ss-open-above .ss-search { | ||||
|   padding: var(--ss-spacing-l) 0 0 0; | ||||
| } | ||||
|  | ||||
| .ss-content.ss-open-below .ss-search { | ||||
|   padding: 0 0 var(--ss-spacing-l) 0; | ||||
| } | ||||
|  | ||||
| .ss-content.ss-open-above .ss-list > *:not(:first-child) { | ||||
|   margin: var(--ss-spacing-l) 0 0 0; | ||||
| } | ||||
|  | ||||
| .ss-content.ss-open-below .ss-list > *:not(:last-child) { | ||||
|   margin: 0 0 var(--ss-spacing-l) 0; | ||||
| } | ||||
|  | ||||
| .ss-content .ss-list .ss-option { | ||||
|   border-radius: var(--ss-border-radius); | ||||
| } | ||||
| @@ -1,7 +1,3 @@ | ||||
| // We import the CSS which is extracted to its own file by esbuild. | ||||
| // Remove this line if you add a your own CSS build pipeline (e.g postcss). | ||||
| import '../css/app.scss' | ||||
|  | ||||
| // If you want to use Phoenix channels, run `mix help phx.gen.channel` | ||||
| // to get started and then uncomment the line below. | ||||
| // import "./user_socket.js" | ||||
| @@ -24,29 +20,24 @@ 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 Date from './date' | ||||
| import DateTime from './datetime' | ||||
| import ShotLogChart from './shot_log_chart' | ||||
| import SlimSelect from './slim_select' | ||||
| import topbar from 'topbar' | ||||
|  | ||||
| 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, SlimSelect } | ||||
| }) | ||||
|  | ||||
| // 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 +57,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
									
								
							
							
						
						
									
										11
									
								
								assets/js/date.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| export default { | ||||
|   displayDate (el) { | ||||
|     const date = | ||||
|       Intl.DateTimeFormat([], { timeZone: '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
									
								
							
							
						
						
									
										11
									
								
								assets/js/datetime.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| export default { | ||||
|   displayDateTime (el) { | ||||
|     const date = | ||||
|       Intl.DateTimeFormat([], { timeZone: 'UTC', dateStyle: 'short', timeStyle: 'long' }) | ||||
|         .format(new Date(el.dateTime)) | ||||
|  | ||||
|     el.innerText = date | ||||
|   }, | ||||
|   mounted () { this.displayDateTime(this.el) }, | ||||
|   updated () { this.displayDateTime(this.el) } | ||||
| } | ||||
| @@ -1,8 +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 () { return this.el.getAttribute('data-attrs').split(', ') }, | ||||
|   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
									
								
							
							
						
						
									
										109
									
								
								assets/js/shot_log_chart.js
									
									
									
									
									
										Normal 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) } | ||||
| } | ||||
							
								
								
									
										28
									
								
								assets/js/slim_select.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								assets/js/slim_select.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | ||||
| import SlimSelect from 'slim-select' | ||||
|  | ||||
| export default { | ||||
|   initalizeSlimSelect (el) { | ||||
|     // eslint-disable-next-line no-new | ||||
|     el.slimselect = new SlimSelect({ | ||||
|       select: el, | ||||
|       settings: { | ||||
|         contentPosition: 'fixed' | ||||
|       } | ||||
|     }) | ||||
|  | ||||
|     const main = document.querySelector(`.ss-main[data-id="${el.dataset.id}"]`) | ||||
|     main.setAttribute('id', `${el.dataset.id}-main`) | ||||
|     main.setAttribute('phx-update', 'ignore') | ||||
|  | ||||
|     const content = document.querySelector(`.ss-content[data-id="${el.dataset.id}"]`) | ||||
|     content.setAttribute('id', `${el.dataset.id}-content`) | ||||
|     content.setAttribute('phx-update', 'ignore') | ||||
|   }, | ||||
|   updated () { | ||||
|     this.el.slimselect?.destroy() | ||||
|     this.initalizeSlimSelect(this.el) | ||||
|   }, | ||||
|   mounted () { | ||||
|     this.initalizeSlimSelect(this.el) | ||||
|   } | ||||
| } | ||||
							
								
								
									
										22239
									
								
								assets/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										22239
									
								
								assets/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,44 +1,22 @@ | ||||
| { | ||||
|   "repository": {}, | ||||
|   "description": " ", | ||||
|   "license": "MIT", | ||||
|   "engines": { | ||||
|     "node": "v24.3.0", | ||||
|     "npm": "11.4.2" | ||||
|   }, | ||||
|   "scripts": { | ||||
|     "deploy": "NODE_ENV=production webpack --mode production", | ||||
|     "watch": "webpack --mode development --watch --watch-options-stdin", | ||||
|     "format": "standard --fix", | ||||
|     "test": "standard" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "@fortawesome/fontawesome-free": "^5.15.4", | ||||
|     "alpinejs": "^3.9.0", | ||||
|     "phoenix": "file:../deps/phoenix", | ||||
|     "phoenix_html": "file:../deps/phoenix_html", | ||||
|     "phoenix_live_view": "file:../deps/phoenix_live_view", | ||||
|     "topbar": "^0.1.4" | ||||
|     "@fortawesome/fontawesome-free": "^6.7.2", | ||||
|     "chart.js": "^4.5.0", | ||||
|     "chartjs-adapter-date-fns": "^3.0.0", | ||||
|     "date-fns": "^4.1.0", | ||||
|     "slim-select": "^2.12.1", | ||||
|     "topbar": "^3.0.0" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@babel/core": "^7.15.0", | ||||
|     "@babel/preset-env": "^7.15.0", | ||||
|     "autoprefixer": "^10.2.6", | ||||
|     "babel-loader": "^8.2.2", | ||||
|     "copy-webpack-plugin": "^9.0.0", | ||||
|     "css-loader": "^5.2.7", | ||||
|     "css-minimizer-webpack-plugin": "^3.0.1", | ||||
|     "file-loader": "^6.2.0", | ||||
|     "hard-source-webpack-plugin": "^0.13.1", | ||||
|     "mini-css-extract-plugin": "^1.6.0", | ||||
|     "node-sass": "^7.0.1", | ||||
|     "postcss": "^8.3.6", | ||||
|     "postcss-import": "^14.0.2", | ||||
|     "postcss-loader": "^6.1.1", | ||||
|     "postcss-preset-env": "^7.3.1", | ||||
|     "sass-loader": "^12.1.0", | ||||
|     "standard": "^16.0.4", | ||||
|     "style-loader": "^3.2.1", | ||||
|     "tailwindcss": "^2.2.7", | ||||
|     "terser-webpack-plugin": "^5.1.3", | ||||
|     "webpack": "^5.67.0", | ||||
|     "webpack-cli": "^4.8.0", | ||||
|     "webpack-dev-server": "^4.7.4" | ||||
|     "npm-check-updates": "^18.0.1", | ||||
|     "standard": "^17.1.2" | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,7 +0,0 @@ | ||||
| module.exports = { | ||||
|   plugins: { | ||||
|     'postcss-import': {}, | ||||
|     tailwindcss: {}, | ||||
|     autoprefixer: {} | ||||
|   } | ||||
| } | ||||
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 14 KiB | 
| @@ -1,41 +0,0 @@ | ||||
| const colors = require('tailwindcss/colors') | ||||
|  | ||||
| module.exports = { | ||||
|   purge: [ | ||||
|     '../lib/**/*.ex', | ||||
|     '../lib/**/*.heex', | ||||
|     '../lib/**/*.leex', | ||||
|     '../lib/**/*.eex', | ||||
|     './js/**/*.js' | ||||
|   ], | ||||
|   darkMode: 'media', | ||||
|   theme: { | ||||
|     colors: { | ||||
|       transparent: 'transparent', | ||||
|       current: 'currentColor', | ||||
|  | ||||
|       primary: colors.gray, | ||||
|  | ||||
|       black: colors.black, | ||||
|       white: colors.white, | ||||
|       gray: colors.trueGray, | ||||
|       indigo: colors.indigo, | ||||
|       red: colors.rose, | ||||
|       yellow: colors.amber | ||||
|     }, | ||||
|     extend: { | ||||
|       spacing: { | ||||
|         128: '32rem', | ||||
|         192: '48rem', | ||||
|         256: '64rem' | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   variants: { | ||||
|     extend: { | ||||
|       backgroundColor: ['active'], | ||||
|       borderColor: ['active'] | ||||
|     } | ||||
|   }, | ||||
|   plugins: [] | ||||
| } | ||||
							
								
								
									
										157
									
								
								assets/vendor/topbar.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										157
									
								
								assets/vendor/topbar.js
									
									
									
									
										vendored
									
									
								
							| @@ -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)); | ||||
| @@ -1,62 +0,0 @@ | ||||
| const path = require('path') | ||||
| const glob = require('glob') | ||||
| const MiniCssExtractPlugin = require('mini-css-extract-plugin') | ||||
| const TerserPlugin = require('terser-webpack-plugin') | ||||
| const CssMinimizerPlugin = require('css-minimizer-webpack-plugin') | ||||
| const CopyWebpackPlugin = require('copy-webpack-plugin') | ||||
|  | ||||
| module.exports = (env, options) => { | ||||
|   const devMode = options.mode !== 'production' | ||||
|  | ||||
|   return { | ||||
|     optimization: { | ||||
|       minimizer: [ | ||||
|         new TerserPlugin({ parallel: true, extractComments: true }), | ||||
|         new CssMinimizerPlugin({}) | ||||
|       ] | ||||
|     }, | ||||
|     entry: { | ||||
|       app: glob.sync('./vendor/**/*.js').concat(['./js/app.js']) | ||||
|     }, | ||||
|     output: { | ||||
|       filename: '[name].js', | ||||
|       path: path.resolve(__dirname, '../priv/static/js'), | ||||
|       publicPath: '/js/' | ||||
|     }, | ||||
|     devtool: devMode ? 'eval-cheap-module-source-map' : undefined, | ||||
|     module: { | ||||
|       rules: [ | ||||
|         { | ||||
|           test: /\.js$/, | ||||
|           exclude: /node_modules/, | ||||
|           use: { | ||||
|             loader: 'babel-loader' | ||||
|           } | ||||
|         }, | ||||
|         { | ||||
|           test: /\.s?css$/, | ||||
|           use: [ | ||||
|             MiniCssExtractPlugin.loader, | ||||
|             'css-loader', | ||||
|             'postcss-loader', | ||||
|             'sass-loader' | ||||
|           ] | ||||
|         }, | ||||
|         { | ||||
|           test: /\.(woff(2)?|ttf|eot|svg|otf)(\?v=[0-9]\.[0-9]\.[0-9])?$/, | ||||
|           loader: 'file-loader', | ||||
|           options: { | ||||
|             name: '[name].[ext]?[hash]', | ||||
|             outputPath: '../fonts' | ||||
|           } | ||||
|         } | ||||
|       ] | ||||
|     }, | ||||
|     plugins: [ | ||||
|       new MiniCssExtractPlugin({ filename: '../css/app.css' }), | ||||
|       new CopyWebpackPlugin({ | ||||
|         patterns: [{ from: 'static/', to: '../' }] | ||||
|       }) | ||||
|     ] | ||||
|   } | ||||
| } | ||||
| @@ -8,25 +8,31 @@ | ||||
| import Config | ||||
|  | ||||
| config :cannery, | ||||
|   ecto_repos: [Cannery.Repo], | ||||
|   generators: [binary_id: true] | ||||
|   env: :dev, | ||||
|   ecto_repos: [Cannery.Repo] | ||||
|  | ||||
| config :cannery, Cannery.Accounts, registration: System.get_env("REGISTRATION", "invite") | ||||
|  | ||||
| # Configures the endpoint | ||||
| config :cannery, CanneryWeb.Endpoint, | ||||
|   adapter: Bandit.PhoenixAdapter, | ||||
|   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 | ||||
|  | ||||
| config :cannery, :generators, | ||||
|   migration: true, | ||||
|   binary_id: true, | ||||
|   sample_binary_id: "11111111-1111-1111-1111-111111111111" | ||||
|   migration: true, | ||||
|   sample_binary_id: "11111111-1111-1111-1111-111111111111", | ||||
|   timestamp_type: :utc_datetime_usec | ||||
|  | ||||
| # Configures the mailer | ||||
| # | ||||
| @@ -50,14 +56,25 @@ config :cannery, Oban, | ||||
|   queues: [default: 10, mailers: 20] | ||||
|  | ||||
| # Configure esbuild (the version is required) | ||||
| # config :esbuild, | ||||
| #   version: "0.14.0", | ||||
| #   default: [ | ||||
| #     args: | ||||
| #       ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), | ||||
| #     cd: Path.expand("../assets", __DIR__), | ||||
| #     env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} | ||||
| #   ] | ||||
| config :esbuild, | ||||
|   version: "0.17.11", | ||||
|   cannery: [ | ||||
|     args: | ||||
|       ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), | ||||
|     cd: Path.expand("../assets", __DIR__), | ||||
|     env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} | ||||
|   ] | ||||
|  | ||||
| # Configure tailwind (the version is required) | ||||
| config :tailwind, | ||||
|   version: "4.0.0", | ||||
|   cannery: [ | ||||
|     args: ~w( | ||||
|       --input=css/style.css | ||||
|       --output=../priv/static/assets/style.css | ||||
|     ), | ||||
|     cd: Path.expand("../assets", __DIR__) | ||||
|   ] | ||||
|  | ||||
| # Configures Elixir's Logger | ||||
| config :logger, :console, | ||||
|   | ||||
| @@ -2,6 +2,7 @@ import Config | ||||
|  | ||||
| # Configure your database | ||||
| config :cannery, Cannery.Repo, | ||||
|   stacktrace: true, | ||||
|   show_sensitive_data_on_connection_error: true, | ||||
|   pool_size: 10 | ||||
|  | ||||
| @@ -12,21 +13,14 @@ config :cannery, Cannery.Repo, | ||||
| # watchers to your application. For example, we use it | ||||
| # with esbuild to bundle .js and .css sources. | ||||
| config :cannery, CanneryWeb.Endpoint, | ||||
|   http: [ip: {0, 0, 0, 0}, port: 4000], | ||||
|   check_origin: false, | ||||
|   code_reloader: true, | ||||
|   debug_errors: true, | ||||
|   secret_key_base: "dg2lccMgaY3+ZeKppR+ondk4ZRaANZGIN0LMZT1u1uzscH4jO5W9a9b9V9BkC+MW", | ||||
|   watchers: [ | ||||
|     # Start the esbuild watcher by calling Esbuild.install_and_run(:default, args) | ||||
|     # esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]} | ||||
|     node: [ | ||||
|       "node_modules/webpack/bin/webpack.js", | ||||
|       "--mode", | ||||
|       "development", | ||||
|       "--watch", | ||||
|       "--watch-options-stdin", | ||||
|       cd: Path.expand("../assets", __DIR__) | ||||
|     ] | ||||
|     esbuild: {Esbuild, :install_and_run, [:cannery, ~w(--sourcemap=inline --watch)]}, | ||||
|     tailwind: {Tailwind, :install_and_run, [:cannery, ~w(--watch)]} | ||||
|   ] | ||||
|  | ||||
| # ## SSL Support | ||||
| @@ -57,15 +51,15 @@ config :cannery, CanneryWeb.Endpoint, | ||||
| config :cannery, CanneryWeb.Endpoint, | ||||
|   live_reload: [ | ||||
|     patterns: [ | ||||
|       ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", | ||||
|       ~r"priv/static/(?!uploads/).*(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. | ||||
| @@ -73,3 +67,9 @@ config :phoenix, :stacktrace_depth, 20 | ||||
|  | ||||
| # Initialize plugs at runtime for faster development compilation | ||||
| config :phoenix, :plug_init_mode, :runtime | ||||
|  | ||||
| config :phoenix_live_view, | ||||
|   # Include HEEx debug annotations as HTML comments in rendered markup | ||||
|   debug_heex_annotations: true, | ||||
|   # Enable helpful, but potentially expensive runtime checks | ||||
|   enable_expensive_runtime_checks: true | ||||
|   | ||||
| @@ -14,6 +14,8 @@ config :cannery, CanneryWeb.Endpoint, cache_static_manifest: "priv/static/cache_ | ||||
| # Do not print debug messages in production | ||||
| config :logger, level: :info | ||||
|  | ||||
| config :cannery, env: :prod | ||||
|  | ||||
| # ## SSL Support | ||||
| # | ||||
| # To get SSL working, you will need to add the `https` key | ||||
|   | ||||
| @@ -7,25 +7,36 @@ import Config | ||||
| # any compile-time configuration in here, as it won't be applied. | ||||
| # The block below contains prod specific runtime configuration. | ||||
|  | ||||
| # Start the phoenix server if environment is set and running in a release | ||||
| if System.get_env("PHX_SERVER") && System.get_env("RELEASE_NAME") do | ||||
| # ## Using releases | ||||
| # | ||||
| # If you use `mix release`, you need to explicitly enable the server | ||||
| # by passing the PHX_SERVER=true when you start it: | ||||
| # | ||||
| #     PHX_SERVER=true bin/cannery start | ||||
| # | ||||
| # Alternatively, you can use `mix phx.gen.release` to generate a `bin/server` | ||||
| # script that automatically sets the env var above. | ||||
| if System.get_env("PHX_SERVER") 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 locale | ||||
| Gettext.put_locale(System.get_env("LOCALE") || "en_US") | ||||
| config :cannery, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") | ||||
|  | ||||
| # Set default locale | ||||
| 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 +51,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 +60,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. | ||||
| @@ -64,7 +78,7 @@ if config_env() == :prod do | ||||
|     System.get_env("SECRET_KEY_BASE") || | ||||
|       raise """ | ||||
|       environment variable SECRET_KEY_BASE is missing. | ||||
|       You can generate one by calling: mix phx.gen.secret | ||||
|       You can generate one by calling: priv/random.sh | ||||
|       """ | ||||
|  | ||||
|   config :cannery, CanneryWeb.Endpoint, secret_key_base: secret_key_base | ||||
| @@ -76,12 +90,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 | ||||
|   # | ||||
|   | ||||
| @@ -9,8 +9,9 @@ config :bcrypt_elixir, :log_rounds, 1 | ||||
| # to provide built-in test partitioning in CI environment. | ||||
| # Run `mix help test` for more information. | ||||
| config :cannery, Cannery.Repo, | ||||
|   pool_size: 10, | ||||
|   pool: Ecto.Adapters.SQL.Sandbox, | ||||
|   pool_size: 10 | ||||
|   timeout: 60000 | ||||
|  | ||||
| # We don't run a server during test. If one is required, | ||||
| # you can enable the server option below. | ||||
| @@ -19,14 +20,19 @@ config :cannery, CanneryWeb.Endpoint, | ||||
|   secret_key_base: "S3qq9QtUdsFtlYej+HTjAVN95uP5i5tf2sPYINWSQfCKJghFj2B1+wTAoljZyHOK", | ||||
|   server: false | ||||
|  | ||||
| config :cannery, env: :test | ||||
|  | ||||
| # 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 | ||||
|  | ||||
| # Disable Oban | ||||
| config :cannery, Oban, queues: false, plugins: false | ||||
| config :cannery, Oban, queues: false, plugins: false, testing: :manual | ||||
|   | ||||
| @@ -32,7 +32,12 @@ services: | ||||
|       - cannery-db | ||||
|  | ||||
|   cannery-db: | ||||
|     image: postgres:13 | ||||
|     image: postgres:17 | ||||
|     # To upgrade your database from a previous version, replace the existing | ||||
|     # image with the following line. Once the container has been booted and your | ||||
|     # database updated, you can revert back to the original `postgres:17` image | ||||
|     # if you'd like | ||||
|     # image: pgautoupgrade/pgautoupgrade:17-alpine | ||||
|     container_name: cannery-db | ||||
|     environment: | ||||
|       - POSTGRES_USER="postgres" | ||||
| @@ -40,4 +45,47 @@ services: | ||||
|       - POSTGRES_DB="cannery" | ||||
|     restart: always | ||||
|     volumes: | ||||
|       - ./data:/var/lib/postgresql/data | ||||
|       - ./cannery/data:/var/lib/postgresql/data | ||||
|  | ||||
|   # Optional, if you don't have a reverse proxy set up already. You can replace | ||||
|   # this with any other reverse proxy like nginx, apache or caddy if you would | ||||
|   # like something more minimalist. | ||||
|  | ||||
|   # nginx: | ||||
|   #   image: jc21/nginx-proxy-manager | ||||
|   #   container_name: nginx | ||||
|   #   restart: on-failure | ||||
|   #   ports: | ||||
|   #     - 443:443 | ||||
|   #     - 80:80 | ||||
|   #    # Access via http://localhost:81, although once you access this, you can | ||||
|   #    # reverse proxy itself to a domain name to control it from anywhere if | ||||
|   #    # you'd like, and then you can remove the following line | ||||
|   #     - 127.0.0.1:81:81 | ||||
|   #   # expose: | ||||
|   #   #   - 81 | ||||
|   #   environment: | ||||
|   #     # DISABLE_IPV6: 'true' | ||||
|   #     DB_MYSQL_HOST: "nginx-db" | ||||
|   #     DB_MYSQL_NAME: "nginx" | ||||
|   #     DB_MYSQL_PASSWORD: "my-password" | ||||
|   #     DB_MYSQL_PORT: 3306 | ||||
|   #     DB_MYSQL_USER: "nginx" | ||||
|   #     PGID: 9000 | ||||
|   #     PUID: 9000 | ||||
|   #   volumes: | ||||
|   #     - /etc/letsencrypt:/etc/letsencrypt | ||||
|   #     - ./nginx/data:/data | ||||
|   #     - /tmp:/tmp | ||||
|  | ||||
|   # nginx-db: | ||||
|   #   image: 'jc21/mariadb-aria:latest' | ||||
|   #   container_name: nginx-db | ||||
|   #   restart: on-failure | ||||
|   #   environment: | ||||
|   #     MYSQL_DATABASE: 'nginx' | ||||
|   #     MYSQL_PASSWORD: 'my-password' | ||||
|   #     MYSQL_ROOT_PASSWORD: 'my-root-password' | ||||
|   #     MYSQL_USER: 'nginx' | ||||
|   #   volumes: | ||||
|   #     - ./nginx/mysql:/var/lib/mysql | ||||
|   | ||||
							
								
								
									
										
											BIN
										
									
								
								home.png
									
									
									
									
									
								
							
							
						
						
									
										
											BIN
										
									
								
								home.png
									
									
									
									
									
								
							
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 113 KiB | 
| @@ -6,4 +6,35 @@ defmodule Cannery do | ||||
|   Contexts are also responsible for managing your data, regardless | ||||
|   if it comes from the database, an external API or others. | ||||
|   """ | ||||
|  | ||||
|   def context do | ||||
|     quote do | ||||
|       use Gettext, backend: CanneryWeb.Gettext | ||||
|       import Ecto.Query | ||||
|       alias Cannery.Accounts.User | ||||
|       alias Cannery.Repo | ||||
|       alias Ecto.{Changeset, Multi, Queryable, UUID} | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def schema do | ||||
|     quote do | ||||
|       use Ecto.Schema | ||||
|       use Gettext, backend: CanneryWeb.Gettext | ||||
|       import Ecto.{Changeset, Query} | ||||
|       alias Cannery.Accounts.User | ||||
|       alias Ecto.{Association, Changeset, Queryable, UUID} | ||||
|  | ||||
|       @primary_key {:id, :binary_id, autogenerate: true} | ||||
|       @foreign_key_type :binary_id | ||||
|       @timestamps_opts [type: :utc_datetime_usec] | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   When used, dispatch to the appropriate context/schema/etc. | ||||
|   """ | ||||
|   defmacro __using__(which) when is_atom(which) do | ||||
|     apply(__MODULE__, which, []) | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -3,10 +3,9 @@ defmodule Cannery.Accounts do | ||||
|   The Accounts context. | ||||
|   """ | ||||
|  | ||||
|   import Ecto.Query, warn: false | ||||
|   alias Cannery.{Mailer, Repo} | ||||
|   alias Cannery.Accounts.{User, UserToken} | ||||
|   alias Ecto.{Changeset, Multi} | ||||
|   use Cannery, :context | ||||
|   alias Cannery.Mailer | ||||
|   alias Cannery.Accounts.{Invite, Invites, UserToken} | ||||
|   alias Oban.Job | ||||
|  | ||||
|   ## Database getters | ||||
| @@ -23,22 +22,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 +54,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 +85,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 +101,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 +140,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 +160,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 +190,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,20 +212,20 @@ 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}" | ||||
|  | ||||
|     with {:ok, query} <- UserToken.verify_change_email_token_query(token, context), | ||||
|          %UserToken{sent_to: email} <- Repo.one(query), | ||||
|          {:ok, _} <- Repo.transaction(user_email_multi(user, email, context)) do | ||||
|          {:ok, _result} <- Repo.transaction(user_email_multi(user, email, context)) do | ||||
|       :ok | ||||
|     else | ||||
|       _ -> :error | ||||
|       _error_tuple -> :error | ||||
|     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 +239,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 +257,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 | ||||
| @@ -265,25 +295,59 @@ defmodule Cannery.Accounts do | ||||
|     |> Repo.transaction() | ||||
|     |> case do | ||||
|       {:ok, %{user: user}} -> {:ok, user} | ||||
|       {:error, :user, changeset, _} -> {:error, changeset} | ||||
|       {:error, :user, changeset, _changes_so_far} -> {:error, changeset} | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Returns an `Ecto.Changeset.t()` for changing the user locale. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> change_user_locale(%User{}) | ||||
|       %Changeset{} | ||||
|  | ||||
|   """ | ||||
|   @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, "en_US") | ||||
|       {:ok, %User{}} | ||||
|  | ||||
|   """ | ||||
|   @spec update_user_locale(User.t(), locale :: String.t()) :: | ||||
|           {: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 | ||||
|  | ||||
| @@ -300,7 +364,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) | ||||
| @@ -309,9 +373,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 | ||||
|  | ||||
| @@ -320,19 +384,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 """ | ||||
| @@ -340,10 +438,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} | ||||
|  | ||||
|   """ | ||||
| @@ -365,14 +463,14 @@ 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), | ||||
|          {:ok, %{user: user}} <- Repo.transaction(confirm_user_multi(user)) do | ||||
|       {:ok, user} | ||||
|     else | ||||
|       _ -> :error | ||||
|       _error_tuple -> :error | ||||
|     end | ||||
|   end | ||||
|  | ||||
| @@ -390,8 +488,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() | ||||
| @@ -407,20 +505,20 @@ 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 | ||||
|       user | ||||
|     else | ||||
|       _ -> nil | ||||
|       _error_tuple -> nil | ||||
|     end | ||||
|   end | ||||
|  | ||||
| @@ -429,14 +527,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)) | ||||
| @@ -444,7 +546,7 @@ defmodule Cannery.Accounts do | ||||
|     |> Repo.transaction() | ||||
|     |> case do | ||||
|       {:ok, %{user: user}} -> {:ok, user} | ||||
|       {:error, :user, changeset, _} -> {:error, changeset} | ||||
|       {:error, :user, changeset, _changes_so_far} -> {:error, changeset} | ||||
|     end | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -1,48 +0,0 @@ | ||||
| defmodule Cannery.Email do | ||||
|   @moduledoc """ | ||||
|   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. | ||||
|   """ | ||||
|  | ||||
|   use Phoenix.Swoosh, view: CanneryWeb.EmailView, layout: {CanneryWeb.LayoutView, :email} | ||||
|   import CanneryWeb.Gettext | ||||
|   alias Cannery.Accounts.User | ||||
|   alias CanneryWeb.EmailView | ||||
|  | ||||
|   @typedoc """ | ||||
|   Represents an HTML and text body email that can be sent | ||||
|   """ | ||||
|   @type t() :: Swoosh.Email.t() | ||||
|  | ||||
|   @spec base_email(User.t(), String.t()) :: t() | ||||
|   defp base_email(%User{email: email}, subject) do | ||||
|     from = Application.get_env(:cannery, Cannery.Mailer)[:email_from] || "noreply@localhost" | ||||
|     name = Application.get_env(:cannery, Cannery.Mailer)[:email_name] | ||||
|     new() |> to(email) |> from({name, from}) |> subject(subject) | ||||
|   end | ||||
|  | ||||
|   @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})) | ||||
|   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})) | ||||
|   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})) | ||||
|   end | ||||
| end | ||||
							
								
								
									
										60
									
								
								lib/cannery/accounts/invite.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								lib/cannery/accounts/invite.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,60 @@ | ||||
| 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 | ||||
|   `:uses_left` is defined. | ||||
|   """ | ||||
|  | ||||
|   use Cannery, :schema | ||||
|  | ||||
|   schema "invites" do | ||||
|     field :name, :string | ||||
|     field :token, :string | ||||
|     field :uses_left, :integer, default: nil | ||||
|     field :disabled_at, :utc_datetime_usec | ||||
|  | ||||
|     belongs_to :created_by, User | ||||
|  | ||||
|     has_many :users, User | ||||
|  | ||||
|     timestamps(type: :utc_datetime_usec) | ||||
|   end | ||||
|  | ||||
|   @type t :: %__MODULE__{ | ||||
|           id: id(), | ||||
|           name: String.t(), | ||||
|           token: token(), | ||||
|           uses_left: integer() | nil, | ||||
|           disabled_at: DateTime.t(), | ||||
|           created_by: User.t() | nil | Association.NotLoaded.t(), | ||||
|           created_by_id: User.id() | nil, | ||||
|           users: [User.t()] | Association.NotLoaded.t(), | ||||
|           inserted_at: DateTime.t(), | ||||
|           updated_at: DateTime.t() | ||||
|         } | ||||
|   @type new_invite :: %__MODULE__{} | ||||
|   @type id :: UUID.t() | ||||
|   @type changeset :: Changeset.t(t() | new_invite()) | ||||
|   @type token :: String.t() | ||||
|  | ||||
|   @doc false | ||||
|   @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_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() | ||||
|   def update_changeset(invite, attrs) do | ||||
|     invite | ||||
|     |> cast(attrs, [:name, :uses_left, :disabled_at]) | ||||
|     |> validate_length(:name, max: 255) | ||||
|     |> validate_number(:uses_left, greater_than_or_equal_to: 0) | ||||
|     |> validate_required([:name]) | ||||
|   end | ||||
| end | ||||
							
								
								
									
										206
									
								
								lib/cannery/accounts/invites.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										206
									
								
								lib/cannery/accounts/invites.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,206 @@ | ||||
| defmodule Cannery.Accounts.Invites do | ||||
|   @moduledoc """ | ||||
|   The Invites context. | ||||
|   """ | ||||
|  | ||||
|   use Cannery, :context | ||||
|   alias Cannery.Accounts.Invite | ||||
|  | ||||
|   @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 = DateTime.utc_now() | ||||
|     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 | ||||
| @@ -1,27 +1,36 @@ | ||||
| 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} | ||||
|   use Cannery, :schema | ||||
|   alias Cannery.Accounts.Invite | ||||
|  | ||||
|   @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 | ||||
|   schema "users" do | ||||
|     field :email, :string | ||||
|     field :password, :string, virtual: true | ||||
|     field :hashed_password, :string | ||||
|     field :confirmed_at, :naive_datetime | ||||
|     field :current_password, :string, virtual: true, redact: true | ||||
|     field :confirmed_at, :utc_datetime_usec | ||||
|     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 | ||||
|  | ||||
|     timestamps() | ||||
|     belongs_to :invite, Invite | ||||
|  | ||||
|     timestamps(type: :utc_datetime_usec) | ||||
|   end | ||||
|  | ||||
|   @type t :: %User{ | ||||
| @@ -29,14 +38,19 @@ defmodule Cannery.Accounts.User do | ||||
|           email: String.t(), | ||||
|           password: String.t(), | ||||
|           hashed_password: String.t(), | ||||
|           confirmed_at: NaiveDateTime.t(), | ||||
|           role: atom(), | ||||
|           invites: [Invite.t()], | ||||
|           inserted_at: NaiveDateTime.t(), | ||||
|           updated_at: NaiveDateTime.t() | ||||
|           confirmed_at: DateTime.t(), | ||||
|           role: role(), | ||||
|           locale: String.t() | nil, | ||||
|           created_invites: [Invite.t()] | Association.NotLoaded.t(), | ||||
|           invite: Invite.t() | nil | Association.NotLoaded.t(), | ||||
|           invite_id: Invite.id() | nil, | ||||
|           inserted_at: DateTime.t(), | ||||
|           updated_at: DateTime.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. | ||||
| @@ -55,26 +69,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]) | ||||
|   @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]) | ||||
| @@ -86,8 +100,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]) | ||||
| @@ -98,8 +112,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) | ||||
| @@ -118,13 +131,13 @@ 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]) | ||||
|     |> validate_email() | ||||
|     |> case do | ||||
|       %{changes: %{email: _}} = changeset -> changeset | ||||
|       %{changes: %{email: _email}} = changeset -> changeset | ||||
|       %{} = changeset -> add_error(changeset, :email, dgettext("errors", "did not change")) | ||||
|     end | ||||
|   end | ||||
| @@ -141,8 +154,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]) | ||||
| @@ -153,9 +166,9 @@ 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) | ||||
|     now = DateTime.utc_now() | ||||
|     user_or_changeset |> change(confirmed_at: now) | ||||
|   end | ||||
|  | ||||
| @@ -171,7 +184,7 @@ defmodule Cannery.Accounts.User do | ||||
|     Bcrypt.verify_pass(password, hashed_password) | ||||
|   end | ||||
|  | ||||
|   def valid_password?(_, _) do | ||||
|   def valid_password?(_invalid_user, _invalid_password) do | ||||
|     Bcrypt.no_user_verify() | ||||
|     false | ||||
|   end | ||||
| @@ -179,10 +192,23 @@ 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 | ||||
|     changeset = cast(changeset, %{current_password: password}, [:current_password]) | ||||
|  | ||||
|     if valid_password?(changeset.data, password), | ||||
|       do: changeset, | ||||
|       else: changeset |> add_error(:current_password, dgettext("errors", "is not valid")) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   A changeset for changing the user's locale | ||||
|   """ | ||||
|   @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 | ||||
|   | ||||
| @@ -1,12 +1,9 @@ | ||||
| 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} | ||||
|   use Cannery, :schema | ||||
|  | ||||
|   @hash_algorithm :sha256 | ||||
|   @rand_size 32 | ||||
| @@ -18,8 +15,6 @@ defmodule Cannery.Accounts.UserToken do | ||||
|   @change_email_validity_in_days 7 | ||||
|   @session_validity_in_days 60 | ||||
|  | ||||
|   @primary_key {:id, :binary_id, autogenerate: true} | ||||
|   @foreign_key_type :binary_id | ||||
|   schema "users_tokens" do | ||||
|     field :token, :binary | ||||
|     field :context, :string | ||||
| @@ -30,27 +25,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(), | ||||
|           inserted_at: NaiveDateTime.t() | ||||
|           user: User.t() | Association.NotLoaded.t(), | ||||
|           user_id: User.id() | nil, | ||||
|           inserted_at: DateTime.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 +53,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 +71,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 +93,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 +112,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 +120,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 +139,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, [_first | _rest] = contexts) do | ||||
|     from t in __MODULE__, where: t.user_id == ^user.id and t.context in ^contexts | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -3,196 +3,453 @@ defmodule Cannery.ActivityLog do | ||||
|   The ActivityLog context. | ||||
|   """ | ||||
|  | ||||
|   import Ecto.Query, warn: false | ||||
|   import CanneryWeb.Gettext | ||||
|   alias Cannery.{Accounts.User, ActivityLog.ShotGroup, Ammo, Ammo.AmmoGroup, Repo} | ||||
|   alias Ecto.{Changeset, Multi} | ||||
|   use Cannery, :context | ||||
|   alias Cannery.{ActivityLog.ShotRecord, Ammo.Pack, Ammo.Type} | ||||
|  | ||||
|   @type list_shot_records_option :: | ||||
|           {:search, String.t() | nil} | ||||
|           | {:class, Type.class() | :all | nil} | ||||
|           | {:start_date, String.t() | nil} | ||||
|           | {:end_date, String.t() | 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)) | ||||
|     |> list_shot_records_start_date(Keyword.get(opts, :start_date)) | ||||
|     |> list_shot_records_end_date(Keyword.get(opts, :end_date)) | ||||
|     |> 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 | ||||
|  | ||||
|   @spec list_shot_records_start_date(Queryable.t(), String.t() | nil) :: Queryable.t() | ||||
|   defp list_shot_records_start_date(query, start_date) when start_date |> is_binary() do | ||||
|     query |> where([sr: sr], sr.date >= ^Date.from_iso8601!(start_date)) | ||||
|   end | ||||
|  | ||||
|   defp list_shot_records_start_date(query, _all), do: query | ||||
|  | ||||
|   @spec list_shot_records_end_date(Queryable.t(), String.t() | nil) :: Queryable.t() | ||||
|   defp list_shot_records_end_date(query, end_date) when end_date |> is_binary() do | ||||
|     query |> where([sr: sr], sr.date <= ^Date.from_iso8601!(end_date)) | ||||
|   end | ||||
|  | ||||
|   defp list_shot_records_end_date(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{id: user_id}, | ||||
|         %AmmoGroup{id: ammo_group_id, count: ammo_group_count, user_id: user_id} = ammo_group | ||||
|       ) do | ||||
|     attrs = attrs |> Map.merge(%{"user_id" => user_id, "ammo_group_id" => ammo_group_id}) | ||||
|     changeset = %ShotGroup{} |> ShotGroup.create_changeset(attrs) | ||||
|     shot_group_count = changeset |> Changeset.get_field(:count) | ||||
|  | ||||
|     if shot_group_count > ammo_group_count do | ||||
|       error = dgettext("errors", "Count must be less than %{count}", count: ammo_group_count) | ||||
|       changeset = changeset |> Changeset.add_error(:count, error) | ||||
|       {:error, changeset} | ||||
|     else | ||||
|       Multi.new() | ||||
|       |> Multi.insert(:create_shot_group, changeset) | ||||
|       |> Multi.update( | ||||
|         :update_ammo_group, | ||||
|         ammo_group |> AmmoGroup.range_changeset(%{"count" => ammo_group_count - shot_group_count}) | ||||
|       ) | ||||
|       |> Repo.transaction() | ||||
|       |> case do | ||||
|         {:ok, %{create_shot_group: shot_group}} -> {:ok, shot_group} | ||||
|         {:error, :create_shot_group, changeset, _changes_so_far} -> {:error, changeset} | ||||
|         {:error, _other_transaction, _value, _changes_so_far} -> {:error, nil} | ||||
|       end | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Updates a shot_group. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> update_shot_group(shot_group, %{field: new_value}, %User{id: 123}) | ||||
|       {:ok, %ShotGroup{}} | ||||
|  | ||||
|       iex> update_shot_group(shot_group, %{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, ammo_group_id: ammo_group_id} = shot_group, | ||||
|         attrs, | ||||
|         %User{id: user_id} = user | ||||
|       ) do | ||||
|     %{count: ammo_group_count, user_id: ^user_id} = | ||||
|       ammo_group = ammo_group_id |> Ammo.get_ammo_group!(user) | ||||
|  | ||||
|     changeset = shot_group |> ShotGroup.update_changeset(attrs) | ||||
|     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 |> Changeset.add_error(:count, error) | ||||
|         {:error, changeset} | ||||
|  | ||||
|       new_shot_group_count <= 0 -> | ||||
|         error = dgettext("errors", "Count must be at least 1") | ||||
|         changeset = changeset |> Changeset.add_error(:count, error) | ||||
|         {:error, changeset} | ||||
|  | ||||
|       true -> | ||||
|         Multi.new() | ||||
|         |> Multi.update(:update_shot_group, changeset) | ||||
|         |> Multi.update( | ||||
|           :update_ammo_group, | ||||
|           ammo_group | ||||
|           |> AmmoGroup.range_changeset(%{"count" => ammo_group_count - shot_diff_to_add}) | ||||
|         ) | ||||
|         |> Repo.transaction() | ||||
|         |> case do | ||||
|           {:ok, %{update_shot_group: shot_group}} -> {:ok, shot_group} | ||||
|           {:error, :update_shot_group, changeset, _changes_so_far} -> {:error, changeset} | ||||
|           {:error, _other_transaction, _value, _changes_so_far} -> {:error, nil} | ||||
|         end | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Deletes a shot_group. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> delete_shot_group(shot_group, %User{id: 123}) | ||||
|       {:ok, %ShotGroup{}} | ||||
|  | ||||
|       iex> delete_shot_group(shot_group, %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{count: count, user_id: user_id, ammo_group_id: ammo_group_id} = shot_group, | ||||
|         %User{id: user_id} = user | ||||
|       ) do | ||||
|     %{count: ammo_group_count, user_id: ^user_id} = | ||||
|       ammo_group = ammo_group_id |> Ammo.get_ammo_group!(user) | ||||
|  | ||||
|   @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.delete(:delete_shot_group, shot_group) | ||||
|     |> Multi.insert( | ||||
|       :create_shot_record, | ||||
|       %ShotRecord{} |> ShotRecord.create_changeset(user, pack, attrs) | ||||
|     ) | ||||
|     |> Multi.run( | ||||
|       :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, | ||||
|       ammo_group | ||||
|       |> AmmoGroup.range_changeset(%{"count" => ammo_group_count + 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, %{delete_shot_group: shot_group}} -> {:ok, shot_group} | ||||
|       {:error, :delete_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 """ | ||||
|   Returns an `%Ecto.Changeset{}` for tracking shot_group changes. | ||||
|   Updates a shot_record. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> change_shot_group(shot_group) | ||||
|       %Ecto.Changeset{data: %ShotGroup{}} | ||||
|       iex> update_shot_record(shot_record, %{field: new_value}, %User{id: 123}) | ||||
|       {:ok, %ShotRecord{}} | ||||
|  | ||||
|       iex> update_shot_record(shot_record, %{field: bad_value}, %User{id: 123}) | ||||
|       {:error, %Ecto.Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   @spec change_shot_group(ShotGroup.t() | ShotGroup.new_shot_group()) :: | ||||
|           Changeset.t(ShotGroup.t() | ShotGroup.new_shot_group()) | ||||
|   @spec change_shot_group(ShotGroup.t() | ShotGroup.new_shot_group(), attrs :: map()) :: | ||||
|           Changeset.t(ShotGroup.t() | ShotGroup.new_shot_group()) | ||||
|   def change_shot_group(%ShotGroup{} = shot_group, attrs \\ %{}) do | ||||
|     shot_group |> ShotGroup.update_changeset(attrs) | ||||
|   @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_record, | ||||
|       shot_record |> ShotRecord.update_changeset(user, attrs) | ||||
|     ) | ||||
|     |> Multi.run( | ||||
|       :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_pack, | ||||
|       fn %{ | ||||
|            update_shot_record: %{count: new_count}, | ||||
|            pack: %{count: pack_count} = pack | ||||
|          } -> | ||||
|         shot_diff_to_add = new_count - 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_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_record. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> delete_shot_record(shot_record, %User{id: 123}) | ||||
|       {:ok, %ShotRecord{}} | ||||
|  | ||||
|       iex> delete_shot_record(shot_record, %User{id: 123}) | ||||
|       {:error, %Ecto.Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   @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_record, shot_record) | ||||
|     |> Multi.run( | ||||
|       :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_pack, | ||||
|       fn %{ | ||||
|            delete_shot_record: %{count: count}, | ||||
|            pack: %{count: pack_count} = pack | ||||
|          } -> | ||||
|         new_pack_count = pack_count + count | ||||
|         pack |> Pack.range_changeset(%{"count" => new_pack_count}) | ||||
|       end | ||||
|     ) | ||||
|     |> Repo.transaction() | ||||
|     |> case do | ||||
|       {: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 | ||||
|   | ||||
| @@ -1,57 +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 Ecto.Changeset | ||||
|   alias Cannery.{Accounts.User, ActivityLog.ShotGroup, Ammo.AmmoGroup} | ||||
|   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(), attrs :: map()) :: Changeset.t(new_shot_group()) | ||||
|   def create_changeset(shot_group, attrs) do | ||||
|     shot_group | ||||
|     |> cast(attrs, [:count, :notes, :date, :ammo_group_id, :user_id]) | ||||
|     |> validate_number(:count, greater_than: 0) | ||||
|     |> validate_required([:count, :ammo_group_id, :user_id]) | ||||
|   end | ||||
|  | ||||
|   @doc false | ||||
|   @spec update_changeset(t() | new_shot_group(), attrs :: map()) :: | ||||
|           Changeset.t(t() | new_shot_group()) | ||||
|   def update_changeset(shot_group, attrs) do | ||||
|     shot_group | ||||
|     |> cast(attrs, [:count, :notes, :date]) | ||||
|     |> validate_number(:count, greater_than: 0) | ||||
|     |> validate_required([:count]) | ||||
|   end | ||||
| end | ||||
							
								
								
									
										123
									
								
								lib/cannery/activity_log/shot_record.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										123
									
								
								lib/cannery/activity_log/shot_record.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,123 @@ | ||||
| defmodule Cannery.ActivityLog.ShotRecord do | ||||
|   @moduledoc """ | ||||
|   A shot record records a group of ammo shot during a range trip | ||||
|   """ | ||||
|  | ||||
|   use Cannery, :schema | ||||
|   alias Cannery.{Ammo, Ammo.Pack} | ||||
|  | ||||
|   @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(type: :utc_datetime_usec) | ||||
|   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: DateTime.t(), | ||||
|           updated_at: DateTime.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 | ||||
							
								
								
									
										1406
									
								
								lib/cannery/ammo.ex
									
									
									
									
									
								
							
							
						
						
									
										1406
									
								
								lib/cannery/ammo.ex
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,80 +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 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(), attrs :: map()) :: Changeset.t(new_ammo_group()) | ||||
|   def create_changeset(ammo_group, attrs) do | ||||
|     ammo_group | ||||
|     |> cast(attrs, [:count, :price_paid, :notes, :staged, :ammo_type_id, :container_id, :user_id]) | ||||
|     |> validate_number(:count, greater_than: 0) | ||||
|     |> validate_required([:count, :staged, :ammo_type_id, :container_id, :user_id]) | ||||
|   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, :ammo_type_id, :container_id]) | ||||
|     |> validate_number(:count, greater_than: 0) | ||||
|     |> validate_required([:count, :staged, :ammo_type_id, :container_id, :user_id]) | ||||
|   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, :ammo_type_id, :container_id, :user_id]) | ||||
|   end | ||||
| end | ||||
| @@ -1,123 +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, null: false, default: false | ||||
|     field :incendiary, :boolean, null: false, default: false | ||||
|     field :blank, :boolean, null: false, default: false | ||||
|     field :corrosive, :boolean, null: false, 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(), attrs :: map()) :: Changeset.t(new_ammo_type()) | ||||
|   def create_changeset(ammo_type, attrs) do | ||||
|     ammo_type | ||||
|     |> cast(attrs, [:user_id | 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, :user_id]) | ||||
|   end | ||||
| end | ||||
							
								
								
									
										158
									
								
								lib/cannery/ammo/pack.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										158
									
								
								lib/cannery/ammo/pack.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,158 @@ | ||||
| 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 Cannery, :schema | ||||
|   alias Cannery.{Ammo.Type, Containers, Containers.Container} | ||||
|  | ||||
|   @derive {Jason.Encoder, | ||||
|            only: [ | ||||
|              :container_id, | ||||
|              :count, | ||||
|              :id, | ||||
|              :lot_number, | ||||
|              :notes, | ||||
|              :price_paid, | ||||
|              :type_id | ||||
|            ]} | ||||
|   schema "packs" do | ||||
|     field :count, :integer | ||||
|     field :lot_number, :string | ||||
|     field :notes, :string | ||||
|     field :price_paid, :float | ||||
|     field :purchased_on, :date | ||||
|  | ||||
|     belongs_to :type, Type | ||||
|     field :container_id, :binary_id | ||||
|     field :user_id, :binary_id | ||||
|  | ||||
|     timestamps(type: :utc_datetime_usec) | ||||
|   end | ||||
|  | ||||
|   @type t :: %__MODULE__{ | ||||
|           count: integer, | ||||
|           id: id(), | ||||
|           lot_number: String.t() | nil, | ||||
|           notes: String.t() | nil, | ||||
|           price_paid: float() | nil, | ||||
|           purchased_on: Date.t(), | ||||
|           type: Type.t() | nil, | ||||
|           type_id: Type.id(), | ||||
|           container_id: Container.id(), | ||||
|           user_id: User.id(), | ||||
|           inserted_at: DateTime.t(), | ||||
|           updated_at: DateTime.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, | ||||
|         container, | ||||
|         %User{id: user_id}, | ||||
|         attrs | ||||
|       ) | ||||
|       when is_binary(user_id) do | ||||
|     type_id = | ||||
|       case type do | ||||
|         %Type{id: type_id} when is_binary(type_id) -> | ||||
|           type_id | ||||
|  | ||||
|         _invalid_type -> | ||||
|           nil | ||||
|       end | ||||
|  | ||||
|     container_id = | ||||
|       case container do | ||||
|         %Container{id: container_id, user_id: ^user_id} when is_binary(container_id) -> | ||||
|           container_id | ||||
|  | ||||
|         _invalid_container -> | ||||
|           nil | ||||
|       end | ||||
|  | ||||
|     pack | ||||
|     |> change(type_id: type_id) | ||||
|     |> change(container_id: container_id) | ||||
|     |> change(user_id: user_id) | ||||
|     |> cast(attrs, [ | ||||
|       :count, | ||||
|       :lot_number, | ||||
|       :notes, | ||||
|       :price_paid, | ||||
|       :purchased_on | ||||
|     ]) | ||||
|     |> validate_required(:type_id, message: dgettext("errors", "Please select a valid type")) | ||||
|     |> validate_required(:container_id, | ||||
|       message: dgettext("errors", "Please select a valid container") | ||||
|     ) | ||||
|     |> validate_number(:count, greater_than: 0) | ||||
|     |> validate_number(:price_paid, greater_than_or_equal_to: 0) | ||||
|     |> validate_length(:lot_number, max: 255) | ||||
|     |> validate_required([ | ||||
|       :container_id, | ||||
|       :count, | ||||
|       :purchased_on, | ||||
|       :type_id, | ||||
|       :user_id | ||||
|     ]) | ||||
|   end | ||||
|  | ||||
|   @doc false | ||||
|   @spec update_changeset(t() | new_pack(), attrs :: map(), User.t()) :: changeset() | ||||
|   def update_changeset(pack, attrs, user) do | ||||
|     pack | ||||
|     |> cast(attrs, [ | ||||
|       :container_id, | ||||
|       :count, | ||||
|       :lot_number, | ||||
|       :notes, | ||||
|       :price_paid, | ||||
|       :purchased_on | ||||
|     ]) | ||||
|     |> 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([ | ||||
|       :container_id, | ||||
|       :count, | ||||
|       :purchased_on | ||||
|     ]) | ||||
|   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]) | ||||
|     |> validate_required([:count]) | ||||
|   end | ||||
| end | ||||
							
								
								
									
										229
									
								
								lib/cannery/ammo/type.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										229
									
								
								lib/cannery/ammo/type.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,229 @@ | ||||
| defmodule Cannery.Ammo.Type do | ||||
|   @moduledoc """ | ||||
|   An ammunition type. | ||||
|  | ||||
|   Contains statistical information about the ammunition. | ||||
|   """ | ||||
|  | ||||
|   use Cannery, :schema | ||||
|   alias Cannery.Ammo.Pack | ||||
|  | ||||
|   @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 | ||||
|            ]} | ||||
|   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(type: :utc_datetime_usec) | ||||
|   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: DateTime.t(), | ||||
|           updated_at: DateTime.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 | ||||
| @@ -4,6 +4,7 @@ defmodule Cannery.Application do | ||||
|   @moduledoc false | ||||
|  | ||||
|   use Application | ||||
|   alias Cannery.Logger | ||||
|  | ||||
|   @impl true | ||||
|   def start(_type, _args) do | ||||
| @@ -14,19 +15,28 @@ defmodule Cannery.Application do | ||||
|       CanneryWeb.Telemetry, | ||||
|       # Start the PubSub system | ||||
|       {Phoenix.PubSub, name: Cannery.PubSub}, | ||||
|       {DNSCluster, query: Application.get_env(:cannery, :dns_cluster_query) || :ignore}, | ||||
|       # 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 | ||||
|   | ||||
							
								
								
									
										12
									
								
								lib/cannery/comparable_date.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								lib/cannery/comparable_date.ex
									
									
									
									
									
										Normal 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 | ||||
							
								
								
									
										15
									
								
								lib/cannery/comparable_datetime.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								lib/cannery/comparable_datetime.ex
									
									
									
									
									
										Normal 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 | ||||
| @@ -3,11 +3,16 @@ defmodule Cannery.Containers do | ||||
|   The Containers context. | ||||
|   """ | ||||
|  | ||||
|   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 | ||||
|   use Cannery, :context | ||||
|   alias Cannery.Ammo.Pack | ||||
|   alias Cannery.Containers.{Container, ContainerTag, Tag} | ||||
|  | ||||
|   @container_preloads [:tags] | ||||
|  | ||||
|   @type list_containers_option :: | ||||
|           {:search, String.t() | nil} | ||||
|           | {:staged, boolean() | nil} | ||||
|   @type list_containers_options :: [list_containers_option()] | ||||
|  | ||||
|   @doc """ | ||||
|   Returns the list of containers. | ||||
| @@ -17,23 +22,91 @@ defmodule Cannery.Containers do | ||||
|       iex> list_containers(%User{id: 123}) | ||||
|       [%Container{}, ...] | ||||
|  | ||||
|       iex> list_containers(%User{id: 123}, | ||||
|       ...>   search: "cool", | ||||
|       ...>   staged: true | ||||
|       ...> ) | ||||
|       [%Container{name: "my cool container"}, ...] | ||||
|  | ||||
|   """ | ||||
|   @spec list_containers(User.t()) :: [Container.t()] | ||||
|   def list_containers(%User{id: user_id}) do | ||||
|     Repo.all( | ||||
|   @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)) | ||||
|     |> list_containers_staged(Keyword.get(opts, :staged)) | ||||
|     |> Repo.all() | ||||
|   end | ||||
|  | ||||
|   @spec list_containers_staged(Queryable.t(), staged :: boolean() | nil) :: Queryable.t() | ||||
|   defp list_containers_staged(query, staged) when staged |> is_boolean(), | ||||
|     do: query |> where([c: c], c.staged == ^staged) | ||||
|  | ||||
|   defp list_containers_staged(query, _nil), do: query | ||||
|  | ||||
|   @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 | ||||
|  | ||||
|   @doc """ | ||||
|   Returns a count of containers. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> get_containers_count!(%User{id: 123}) | ||||
|       3 | ||||
|  | ||||
|   """ | ||||
|   @spec get_containers_count!(User.t()) :: integer() | ||||
|   def get_containers_count!(%User{id: user_id}) do | ||||
|     Repo.one( | ||||
|       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] | ||||
|         select: count(c.id), | ||||
|         distinct: true | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Gets a single container. | ||||
|  | ||||
|   Raises `Ecto.NoResultsError` if the Container does not exist. | ||||
|   Raises `KeyError` if the Container does not exist. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
| @@ -41,20 +114,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 """ | ||||
| @@ -70,10 +160,21 @@ defmodule Cannery.Containers do | ||||
|  | ||||
|   """ | ||||
|   @spec create_container(attrs :: map(), User.t()) :: | ||||
|           {:ok, Container.t()} | {:error, Changeset.t(Container.new_container())} | ||||
|   def create_container(attrs, %User{id: user_id}) do | ||||
|     attrs = attrs |> Map.put("user_id", user_id) | ||||
|     %Container{} |> Container.create_changeset(attrs) |> Repo.insert() | ||||
|           {:ok, Container.t()} | {:error, Container.changeset()} | ||||
|   def create_container(attrs, %User{} = user) do | ||||
|     %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 """ | ||||
| @@ -89,9 +190,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 """ | ||||
| @@ -107,23 +214,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 | ||||
|         |> change_container() | ||||
|         |> Changeset.add_error(:ammo_groups, error) | ||||
|         |> Container.update_changeset(%{}) | ||||
|         |> Changeset.add_error(:packs, error) | ||||
|         |> Changeset.apply_action(:delete) | ||||
|     end | ||||
|   end | ||||
| @@ -143,25 +255,6 @@ defmodule Cannery.Containers do | ||||
|     container | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Returns an `%Changeset{}` for tracking container changes. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> change_container(container) | ||||
|       %Changeset{data: %Container{}} | ||||
|  | ||||
|       iex> change_container(%Changeset{}) | ||||
|       %Changeset{data: %Container{}} | ||||
|  | ||||
|   """ | ||||
|   @spec change_container(Container.t() | Container.new_container()) :: | ||||
|           Changeset.t(Container.t() | Container.new_container()) | ||||
|   @spec change_container(Container.t() | Container.new_container(), attrs :: map()) :: | ||||
|           Changeset.t(Container.t() | Container.new_container()) | ||||
|   def change_container(container, attrs \\ %{}), | ||||
|     do: container |> Container.update_changeset(attrs) | ||||
|  | ||||
|   @doc """ | ||||
|   Adds a tag to a container | ||||
|  | ||||
| @@ -173,12 +266,12 @@ defmodule Cannery.Containers do | ||||
|   """ | ||||
|   @spec add_tag!(Container.t(), Tag.t(), User.t()) :: ContainerTag.t() | ||||
|   def add_tag!( | ||||
|         %Container{id: container_id, user_id: user_id}, | ||||
|         %Tag{id: tag_id, user_id: user_id}, | ||||
|         %Container{user_id: user_id} = container, | ||||
|         %Tag{user_id: user_id} = tag, | ||||
|         %User{id: user_id} | ||||
|       ) do | ||||
|     %ContainerTag{} | ||||
|     |> ContainerTag.changeset(%{"container_id" => container_id, "tag_id" => tag_id}) | ||||
|     |> ContainerTag.create_changeset(tag, container) | ||||
|     |> Repo.insert!() | ||||
|   end | ||||
|  | ||||
| @@ -191,32 +284,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_rounds!(Container.t()) :: non_neg_integer() | ||||
|   def get_container_rounds!(%Container{} = container) do | ||||
|     container | ||||
|     |> Repo.preload(:ammo_groups) | ||||
|     |> Map.get(:ammo_groups) | ||||
|     |> Enum.map(fn %{count: count} -> count end) | ||||
|     |> Enum.sum() | ||||
|   @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 """ | ||||
|   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, | ||||
|         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 | ||||
|   | ||||
| @@ -3,58 +3,88 @@ defmodule Cannery.Containers.Container do | ||||
|   A container that holds ammunition and belongs to a user. | ||||
|   """ | ||||
|  | ||||
|   use Ecto.Schema | ||||
|   import Ecto.Changeset | ||||
|   alias Ecto.{Changeset, UUID} | ||||
|   alias Cannery.Containers.{Container, ContainerTag} | ||||
|   alias Cannery.{Accounts.User, Ammo.AmmoGroup, Tags.Tag} | ||||
|   use Cannery, :schema | ||||
|   alias Cannery.{Containers.ContainerTag, Containers.Tag} | ||||
|  | ||||
|   @primary_key {:id, :binary_id, autogenerate: true} | ||||
|   @foreign_key_type :binary_id | ||||
|   @derive {Jason.Encoder, | ||||
|            only: [ | ||||
|              :desc, | ||||
|              :id, | ||||
|              :location, | ||||
|              :name, | ||||
|              :staged, | ||||
|              :tags, | ||||
|              :type | ||||
|            ]} | ||||
|   schema "containers" do | ||||
|     field :name, :string | ||||
|     field :desc, :string | ||||
|     field :location, :string | ||||
|     field :name, :string | ||||
|     field :staged, :boolean, default: false | ||||
|     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() | ||||
|     timestamps(type: :utc_datetime_usec) | ||||
|   end | ||||
|  | ||||
|   @type t :: %Container{ | ||||
|           id: id(), | ||||
|           name: String.t(), | ||||
|   @type t :: %__MODULE__{ | ||||
|           desc: String.t(), | ||||
|           id: id(), | ||||
|           location: String.t(), | ||||
|           name: String.t(), | ||||
|           staged: boolean(), | ||||
|           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() | ||||
|           inserted_at: DateTime.t(), | ||||
|           updated_at: DateTime.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(), attrs :: map()) :: Changeset.t(new_container()) | ||||
|   def create_changeset(container, attrs) do | ||||
|   @spec create_changeset(new_container(), User.t(), attrs :: map()) :: changeset() | ||||
|   def create_changeset(container, %User{id: user_id}, attrs) do | ||||
|     container | ||||
|     |> cast(attrs, [:name, :desc, :type, :location, :user_id]) | ||||
|     |> validate_required([:name, :type, :user_id]) | ||||
|     |> change(user_id: user_id) | ||||
|     |> cast(attrs, [ | ||||
|       :desc, | ||||
|       :location, | ||||
|       :name, | ||||
|       :staged, | ||||
|       :type | ||||
|     ]) | ||||
|     |> validate_length(:name, max: 255) | ||||
|     |> validate_length(:type, max: 255) | ||||
|     |> validate_required([ | ||||
|       :name, | ||||
|       :staged, | ||||
|       :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_required([:name, :type, :user_id]) | ||||
|     |> cast(attrs, [ | ||||
|       :desc, | ||||
|       :location, | ||||
|       :name, | ||||
|       :staged, | ||||
|       :type | ||||
|     ]) | ||||
|     |> validate_length(:name, max: 255) | ||||
|     |> validate_length(:type, max: 255) | ||||
|     |> validate_required([ | ||||
|       :name, | ||||
|       :staged, | ||||
|       :type | ||||
|     ]) | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -1,40 +1,42 @@ | ||||
| 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 Ecto.{Changeset, UUID} | ||||
|   use Cannery, :schema | ||||
|   alias Cannery.Containers.{Container, Tag} | ||||
|  | ||||
|   @primary_key {:id, :binary_id, autogenerate: true} | ||||
|   @foreign_key_type :binary_id | ||||
|   schema "container_tags" do | ||||
|     belongs_to :container, Container | ||||
|     belongs_to :tag, Tag | ||||
|  | ||||
|     timestamps() | ||||
|     timestamps(type: :utc_datetime_usec) | ||||
|   end | ||||
|  | ||||
|   @type t :: %ContainerTag{ | ||||
|   @type t :: %__MODULE__{ | ||||
|           id: id(), | ||||
|           container: Container.t(), | ||||
|           container_id: Container.id(), | ||||
|           tag: Tag.t(), | ||||
|           tag_id: Tag.id(), | ||||
|           inserted_at: NaiveDateTime.t(), | ||||
|           updated_at: NaiveDateTime.t() | ||||
|           inserted_at: DateTime.t(), | ||||
|           updated_at: DateTime.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 changeset(new_container_tag(), attrs :: map()) :: Changeset.t(new_container_tag()) | ||||
|   def changeset(container_tag, attrs) do | ||||
|   @spec create_changeset(new_container_tag(), Tag.t(), Container.t()) :: changeset() | ||||
|   def create_changeset( | ||||
|         container_tag, | ||||
|         %Tag{id: tag_id, user_id: user_id}, | ||||
|         %Container{id: container_id, user_id: user_id} | ||||
|       ) do | ||||
|     container_tag | ||||
|     |> cast(attrs, [:tag_id, :container_id]) | ||||
|     |> change(tag_id: tag_id) | ||||
|     |> change(container_id: container_id) | ||||
|     |> validate_required([:tag_id, :container_id]) | ||||
|   end | ||||
| end | ||||
|   | ||||
							
								
								
									
										61
									
								
								lib/cannery/containers/tag.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								lib/cannery/containers/tag.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,61 @@ | ||||
| defmodule Cannery.Containers.Tag do | ||||
|   @moduledoc """ | ||||
|   Tags are added to containers to help organize, and can include custom-defined | ||||
|   text and bg colors. | ||||
|   """ | ||||
|  | ||||
|   use Cannery, :schema | ||||
|  | ||||
|   @derive {Jason.Encoder, | ||||
|            only: [ | ||||
|              :id, | ||||
|              :name, | ||||
|              :bg_color, | ||||
|              :text_color | ||||
|            ]} | ||||
|   schema "tags" do | ||||
|     field :name, :string | ||||
|     field :bg_color, :string | ||||
|     field :text_color, :string | ||||
|  | ||||
|     field :user_id, :binary_id | ||||
|  | ||||
|     timestamps(type: :utc_datetime_usec) | ||||
|   end | ||||
|  | ||||
|   @type t :: %__MODULE__{ | ||||
|           id: id(), | ||||
|           name: String.t(), | ||||
|           bg_color: String.t(), | ||||
|           text_color: String.t(), | ||||
|           user_id: User.id(), | ||||
|           inserted_at: DateTime.t(), | ||||
|           updated_at: DateTime.t() | ||||
|         } | ||||
|   @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() | ||||
|   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() | ||||
|   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 | ||||
							
								
								
									
										61
									
								
								lib/cannery/email.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								lib/cannery/email.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,61 @@ | ||||
| defmodule Cannery.Email do | ||||
|   @moduledoc """ | ||||
|   Emails that can be sent using Swoosh. | ||||
|  | ||||
|   You can find the base email templates at | ||||
|   `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 Gettext, backend: CanneryWeb.Gettext | ||||
|   import Swoosh.Email | ||||
|   import Phoenix.Template | ||||
|   alias Cannery.Accounts.User | ||||
|   alias CanneryWeb.{EmailHTML, Layouts} | ||||
|  | ||||
|   @typedoc """ | ||||
|   Represents an HTML and text body email that can be sent | ||||
|   """ | ||||
|   @type t() :: Swoosh.Email.t() | ||||
|  | ||||
|   @spec base_email(User.t(), String.t()) :: t() | ||||
|   defp base_email(%User{email: email}, subject) do | ||||
|     from = Application.get_env(:cannery, Cannery.Mailer)[:email_from] || "noreply@localhost" | ||||
|     name = Application.get_env(:cannery, Cannery.Mailer)[:email_name] | ||||
|     new() |> to(email) |> from({name, from}) |> subject(subject) | ||||
|   end | ||||
|  | ||||
|   @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 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 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 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 | ||||
| @@ -1,173 +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{id: user_id, role: :admin}, attrs) do | ||||
|     token = | ||||
|       :crypto.strong_rand_bytes(@invite_token_length) | ||||
|       |> Base.url_encode64() | ||||
|       |> binary_part(0, @invite_token_length) | ||||
|  | ||||
|     attrs = attrs |> Map.merge(%{"user_id" => user_id, "token" => token}) | ||||
|  | ||||
|     %Invite{} |> Invite.create_changeset(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!() | ||||
|  | ||||
|   @doc """ | ||||
|   Returns an `%Changeset{}` for tracking invite changes. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> change_invite(invite) | ||||
|       %Changeset{data: %Invite{}} | ||||
|  | ||||
|   """ | ||||
|   @spec change_invite(Invite.t() | Invite.new_invite()) :: | ||||
|           Changeset.t(Invite.t() | Invite.new_invite()) | ||||
|   @spec change_invite(Invite.t() | Invite.new_invite(), attrs :: map()) :: | ||||
|           Changeset.t(Invite.t() | Invite.new_invite()) | ||||
|   def change_invite(invite, attrs \\ %{}), do: invite |> Invite.update_changeset(attrs) | ||||
| end | ||||
| @@ -1,57 +0,0 @@ | ||||
| defmodule Cannery.Invites.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 | ||||
|   `:uses_left` is defined. | ||||
|   """ | ||||
|  | ||||
|   use Ecto.Schema | ||||
|   import Ecto.Changeset | ||||
|   alias Ecto.{Changeset, UUID} | ||||
|   alias Cannery.{Accounts.User, Invites.Invite} | ||||
|  | ||||
|   @primary_key {:id, :binary_id, autogenerate: true} | ||||
|   @foreign_key_type :binary_id | ||||
|   schema "invites" do | ||||
|     field :name, :string | ||||
|     field :token, :string | ||||
|     field :uses_left, :integer, default: nil | ||||
|     field :disabled_at, :naive_datetime | ||||
|  | ||||
|     belongs_to :user, User | ||||
|  | ||||
|     timestamps() | ||||
|   end | ||||
|  | ||||
|   @type t :: %Invite{ | ||||
|           id: id(), | ||||
|           name: String.t(), | ||||
|           token: String.t(), | ||||
|           uses_left: integer() | nil, | ||||
|           disabled_at: NaiveDateTime.t(), | ||||
|           user: User.t(), | ||||
|           user_id: User.id(), | ||||
|           inserted_at: NaiveDateTime.t(), | ||||
|           updated_at: NaiveDateTime.t() | ||||
|         } | ||||
|   @type new_invite :: %Invite{} | ||||
|   @type id :: UUID.t() | ||||
|  | ||||
|   @doc false | ||||
|   @spec create_changeset(new_invite(), attrs :: map()) :: Changeset.t(new_invite()) | ||||
|   def create_changeset(invite, attrs) do | ||||
|     invite | ||||
|     |> cast(attrs, [:name, :token, :uses_left, :disabled_at, :user_id]) | ||||
|     |> validate_required([:name, :token, :user_id]) | ||||
|     |> validate_number(:uses_left, greater_than_or_equal_to: 0) | ||||
|   end | ||||
|  | ||||
|   @doc false | ||||
|   @spec update_changeset(t() | new_invite(), attrs :: map()) :: Changeset.t(t() | new_invite()) | ||||
|   def update_changeset(invite, attrs) do | ||||
|     invite | ||||
|     |> cast(attrs, [:name, :uses_left, :disabled_at]) | ||||
|     |> validate_required([:name, :token, :user_id]) | ||||
|     |> validate_number(:uses_left, greater_than_or_equal_to: 0) | ||||
|   end | ||||
| end | ||||
							
								
								
									
										63
									
								
								lib/cannery/logger.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								lib/cannery/logger.ex
									
									
									
									
									
										Normal 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("Oban exception: #{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 | ||||
| @@ -9,7 +9,9 @@ defmodule Cannery.Release do | ||||
|  | ||||
|   def rollback(repo, version) do | ||||
|     load_app() | ||||
|     {:ok, _, _} = 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, _, _} = 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 | ||||
|   | ||||
| @@ -1,22 +1,25 @@ | ||||
| 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 | ||||
|   require Logger | ||||
|  | ||||
|   def start_link(_) do | ||||
|   def start_link(_opts) do | ||||
|     GenServer.start_link(__MODULE__, [], []) | ||||
|   end | ||||
|  | ||||
|   def init(_) do | ||||
|     migrate!() | ||||
|     {:ok, nil} | ||||
|   def init(_opts) do | ||||
|     {: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 | ||||
|   | ||||
| @@ -1,151 +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{id: user_id}), | ||||
|     do: %Tag{} |> Tag.create_changeset(attrs |> Map.put("user_id", user_id)) |> 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 """ | ||||
|   Returns an `%Changeset{}` for tracking tag changes. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> change_tag(tag) | ||||
|       %Changeset{data: %Tag{}} | ||||
|  | ||||
|   """ | ||||
|   @spec change_tag(Tag.t() | Tag.new_tag()) :: Changeset.t(Tag.t() | Tag.new_tag()) | ||||
|   @spec change_tag(Tag.t() | Tag.new_tag(), attrs :: map()) :: | ||||
|           Changeset.t(Tag.t() | Tag.new_tag()) | ||||
|   def change_tag(tag, attrs \\ %{}), do: Tag.update_changeset(tag, attrs) | ||||
|  | ||||
|   @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 | ||||
| @@ -1,52 +0,0 @@ | ||||
| defmodule Cannery.Tags.Tag do | ||||
|   @moduledoc """ | ||||
|   Tags are added to containers to help organize, and can include custom-defined | ||||
|   text and bg colors. | ||||
|   """ | ||||
|  | ||||
|   use Ecto.Schema | ||||
|   import Ecto.Changeset | ||||
|   alias Ecto.{Changeset, UUID} | ||||
|   alias Cannery.{Accounts.User, Tags.Tag} | ||||
|  | ||||
|   @primary_key {:id, :binary_id, autogenerate: true} | ||||
|   @foreign_key_type :binary_id | ||||
|   schema "tags" do | ||||
|     field :name, :string | ||||
|     field :bg_color, :string | ||||
|     field :text_color, :string | ||||
|  | ||||
|     belongs_to :user, User | ||||
|  | ||||
|     timestamps() | ||||
|   end | ||||
|  | ||||
|   @type t :: %Tag{ | ||||
|           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 id() :: UUID.t() | ||||
|  | ||||
|   @doc false | ||||
|   @spec create_changeset(new_tag(), attrs :: map()) :: Changeset.t(new_tag()) | ||||
|   def create_changeset(tag, attrs) do | ||||
|     tag | ||||
|     |> cast(attrs, [:name, :bg_color, :text_color, :user_id]) | ||||
|     |> 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()) | ||||
|   def update_changeset(tag, attrs) do | ||||
|     tag | ||||
|     |> cast(attrs, [:name, :bg_color, :text_color]) | ||||
|     |> validate_required([:name, :bg_color, :text_color, :user_id]) | ||||
|   end | ||||
| end | ||||
| @@ -1,76 +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"} | ||||
|  | ||||
|       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(assets fonts images favicon.ico robots.txt webfonts) | ||||
|  | ||||
|   def router do | ||||
|     quote do | ||||
|       use Phoenix.Router | ||||
|       use Phoenix.Router, helpers: false | ||||
|  | ||||
|       # Import common connection and controller functions to use in pipelines | ||||
|       import Plug.Conn | ||||
|       import Phoenix.Controller | ||||
|       import Phoenix.LiveView.Router | ||||
| @@ -80,23 +33,76 @@ defmodule CanneryWeb do | ||||
|   def channel do | ||||
|     quote do | ||||
|       use Phoenix.Channel | ||||
|       import CanneryWeb.Gettext | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   defp view_helpers do | ||||
|   def controller do | ||||
|     quote do | ||||
|       # Use all HTML functionality (forms, tags, etc) | ||||
|       use Phoenix.HTML | ||||
|       use Phoenix.Controller, | ||||
|         formats: [:html, :json], | ||||
|         layouts: [html: CanneryWeb.Layouts] | ||||
|  | ||||
|       # Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc) | ||||
|       import Phoenix.LiveView.Helpers | ||||
|       use Gettext, backend: CanneryWeb.Gettext | ||||
|  | ||||
|       # Import basic rendering functionality (render, render_layout, etc) | ||||
|       import Phoenix.View | ||||
|       # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse | ||||
|       import Plug.Conn | ||||
|  | ||||
|       import CanneryWeb.{ErrorHelpers, Gettext, LiveHelpers, ViewHelpers} | ||||
|       alias CanneryWeb.Router.Helpers, as: Routes | ||||
|       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 | ||||
|       use Gettext, backend: CanneryWeb.Gettext | ||||
|       import Phoenix.{Component, HTML, HTML.Form} | ||||
|       import CanneryWeb.{ErrorHelpers, 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 | ||||
|  | ||||
|   | ||||
| @@ -1,90 +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} |> ActivityLog.change_shot_group() | ||||
|  | ||||
|     {:ok, socket |> assign(assigns) |> assign(:changeset, changeset)} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event( | ||||
|         "validate", | ||||
|         %{"shot_group" => shot_group_params}, | ||||
|         %{ | ||||
|           assigns: %{ | ||||
|             ammo_group: %AmmoGroup{id: ammo_group_id} = ammo_group, | ||||
|             current_user: %User{id: user_id} | ||||
|           } | ||||
|         } = socket | ||||
|       ) do | ||||
|     shot_group_params = | ||||
|       shot_group_params | ||||
|       |> process_params(ammo_group) | ||||
|       |> Map.merge(%{"ammo_group_id" => ammo_group_id, "user_id" => user_id}) | ||||
|  | ||||
|     changeset = | ||||
|       %ShotGroup{} | ||||
|       |> ActivityLog.change_shot_group(shot_group_params) | ||||
|       |> Map.put(:action, :validate) | ||||
|  | ||||
|     {:noreply, socket |> assign(:changeset, changeset)} | ||||
|   end | ||||
|  | ||||
|   def handle_event( | ||||
|         "save", | ||||
|         %{"shot_group" => shot_group_params}, | ||||
|         %{ | ||||
|           assigns: %{ | ||||
|             ammo_group: %{id: ammo_group_id} = ammo_group, | ||||
|             current_user: %{id: user_id} = current_user, | ||||
|             return_to: return_to | ||||
|           } | ||||
|         } = socket | ||||
|       ) do | ||||
|     socket = | ||||
|       shot_group_params | ||||
|       |> process_params(ammo_group) | ||||
|       |> Map.merge(%{"ammo_group_id" => ammo_group_id, "user_id" => user_id}) | ||||
|       |> 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 | ||||
| @@ -1,47 +0,0 @@ | ||||
| <div> | ||||
|   <h2 class="mb-8 text-center title text-xl text-primary-600"> | ||||
|     <%= gettext("Record shots") %> | ||||
|   </h2> | ||||
|  | ||||
|   <.form | ||||
|     let={f} | ||||
|     for={@changeset} | ||||
|     id="shot-group-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 %> | ||||
|  | ||||
|     <%= 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" | ||||
|     ) %> | ||||
|     <%= error_tag(f, :ammo_left, "col-span-3") %> | ||||
|  | ||||
|     <%= label(f, :notes, gettext("Notes"), class: "title text-lg text-primary-600") %> | ||||
|     <%= textarea(f, :notes, | ||||
|       class: "input input-primary col-span-2", | ||||
|       placeholder: "Really great weather", | ||||
|       phx_hook: "MaintainAttrs" | ||||
|     ) %> | ||||
|     <%= error_tag(f, :notes, "col-span-3") %> | ||||
|  | ||||
|     <%= label(f, :date, gettext("Date (UTC)"), class: "title text-lg text-primary-600") %> | ||||
|     <%= date_input(f, :date, class: "input input-primary col-span-2") %> | ||||
|     <%= error_tag(f, :notes, "col-span-3") %> | ||||
|  | ||||
|     <%= submit(dgettext("actions", "Save"), | ||||
|       class: "mx-auto btn btn-primary col-span-3", | ||||
|       phx_disable_with: dgettext("prompts", "Saving...") | ||||
|     ) %> | ||||
|   </.form> | ||||
| </div> | ||||
							
								
								
									
										84
									
								
								lib/cannery_web/components/add_shot_record_component.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								lib/cannery_web/components/add_shot_record_component.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,84 @@ | ||||
| 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 | ||||
| @@ -0,0 +1,62 @@ | ||||
| <div> | ||||
|   <h2 class="mb-8 text-center title text-xl text-primary-600"> | ||||
|     {gettext("Record shots")} | ||||
|   </h2> | ||||
|  | ||||
|   <.form | ||||
|     :let={f} | ||||
|     for={@changeset} | ||||
|     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" | ||||
|   > | ||||
|     <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: @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, | ||||
|       class: "input input-primary col-span-2", | ||||
|       id: "add-shot-record-form-notes", | ||||
|       maxlength: 255, | ||||
|       phx_debounce: 300, | ||||
|       phx_update: "ignore", | ||||
|       placeholder: gettext("Really great weather") | ||||
|     )} | ||||
|     {error_tag(f, :notes, "col-span-3")} | ||||
|  | ||||
|     {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", | ||||
|       value: Date.utc_today() | ||||
|     )} | ||||
|     {error_tag(f, :notes, "col-span-3")} | ||||
|  | ||||
|     {submit(dgettext("actions", "Save"), | ||||
|       class: "mx-auto btn btn-primary col-span-3", | ||||
|       phx_disable_with: dgettext("prompts", "Saving...") | ||||
|     )} | ||||
|   </.form> | ||||
| </div> | ||||
| @@ -1,58 +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 %> | ||||
|  | ||||
|         <%= 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 | ||||
| @@ -1,76 +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="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="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 %> | ||||
|  | ||||
|         <%= if @container.ammo_groups do %> | ||||
|           <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 | ||||
							
								
								
									
										189
									
								
								lib/cannery_web/components/container_table_component.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										189
									
								
								lib/cannery_web/components/container_table_component.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,189 @@ | ||||
| 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 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(:containers) => [Container.t()], | ||||
|             optional(:range) => Rendered.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(:range, fn -> [] end) | ||||
|       |> 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, | ||||
|              range: range, | ||||
|              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} | ||||
|       ]) | ||||
|       |> Enum.concat( | ||||
|         [ | ||||
|           %{label: gettext("Tags"), key: :tags, type: :tags}, | ||||
|           %{label: gettext("Actions"), key: :actions, sortable: false, type: :actions} | ||||
|         ] | ||||
|         |> TableComponent.maybe_compose_columns( | ||||
|           %{label: gettext("Range"), key: :range}, | ||||
|           range != [] | ||||
|         ) | ||||
|       ) | ||||
|  | ||||
|     extra_data = %{ | ||||
|       current_user: current_user, | ||||
|       range: range, | ||||
|       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(:range, %{staged: staged} = container, %{range: range}) do | ||||
|     assigns = %{range: range, container: container} | ||||
|  | ||||
|     {staged, | ||||
|      ~H""" | ||||
|      {render_slot(@range, @container)} | ||||
|      """} | ||||
|   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 | ||||
							
								
								
									
										162
									
								
								lib/cannery_web/components/core_components.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										162
									
								
								lib/cannery_web/components/core_components.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,162 @@ | ||||
| defmodule CanneryWeb.CoreComponents do | ||||
|   @moduledoc """ | ||||
|   Provides core UI components. | ||||
|   """ | ||||
|   use Phoenix.Component | ||||
|   use CanneryWeb, :verified_routes | ||||
|   use Gettext, backend: CanneryWeb.Gettext | ||||
|   import CanneryWeb.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 DateTime in the | ||||
|   user's local timezone | ||||
|   """ | ||||
|   def datetime(assigns) | ||||
|  | ||||
|   attr :name, :string, required: true | ||||
|  | ||||
|   attr :start_date, :string, | ||||
|     default: Date.utc_today() |> Date.shift(year: -1) |> Date.to_iso8601() | ||||
|  | ||||
|   attr :end_date, :string, default: Date.utc_today() |> Date.to_iso8601() | ||||
|  | ||||
|   @doc """ | ||||
|   Phoenix.Component for an element that generates date fields for a range | ||||
|   """ | ||||
|   def date_range(assigns) | ||||
|  | ||||
|   @spec cast_datetime(DateTime.t() | nil) :: String.t() | ||||
|   defp cast_datetime(%DateTime{} = datetime) do | ||||
|     datetime |> DateTime.to_iso8601(:extended) | ||||
|   end | ||||
|  | ||||
|   defp cast_datetime(_datetime), do: "" | ||||
| end | ||||
| @@ -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-zinc-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> | ||||
| @@ -0,0 +1,3 @@ | ||||
| <time :if={@date} id={@id} datetime={Date.to_iso8601(@date, :extended)} phx-hook="Date"> | ||||
|   {Date.to_iso8601(@date, :extended)} | ||||
| </time> | ||||
| @@ -0,0 +1,15 @@ | ||||
| <div class="flex items-center mx-4 my-2 space-x-1"> | ||||
|   <input | ||||
|     class="w-40 text-center input input-primary" | ||||
|     name={"#{@name}_start"} | ||||
|     type="date" | ||||
|     value={@start_date} | ||||
|   /> | ||||
|   <span>—</span> | ||||
|   <input | ||||
|     class="w-40 text-center input input-primary" | ||||
|     name={"#{@name}_end"} | ||||
|     type="date" | ||||
|     value={@end_date} | ||||
|   /> | ||||
| </div> | ||||
| @@ -0,0 +1,3 @@ | ||||
| <time :if={@datetime} id={@id} datetime={cast_datetime(@datetime)} phx-hook="DateTime"> | ||||
|   {cast_datetime(@datetime)} | ||||
| </time> | ||||
| @@ -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-zinc-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-zinc-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> | ||||
							
								
								
									
										44
									
								
								lib/cannery_web/components/core_components/modal.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								lib/cannery_web/components/core_components/modal.html.heex
									
									
									
									
									
										Normal 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-zinc-500 hover:text-zinc-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> | ||||
| @@ -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-zinc-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> | ||||
| @@ -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> | ||||
| @@ -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> | ||||
| @@ -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-zinc-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> | ||||
| @@ -0,0 +1,28 @@ | ||||
| <label for={@id || @action} class="inline-flex relative 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-zinc-300 rounded-full peer | ||||
|     peer-checked:bg-zinc-600 peer-checked:after:translate-x-full peer-checked:after:border-white | ||||
|     after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-zinc-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 whitespace-nowrap text-zinc-900 dark:text-zinc-300" | ||||
|   > | ||||
|     {render_slot(@inner_block)} | ||||
|   </span> | ||||
| </label> | ||||
							
								
								
									
										100
									
								
								lib/cannery_web/components/core_components/topbar.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										100
									
								
								lib/cannery_web/components/core_components/topbar.html.heex
									
									
									
									
									
										Normal 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> | ||||
| @@ -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-zinc-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> | ||||
							
								
								
									
										10
									
								
								lib/cannery_web/components/email_html.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								lib/cannery_web/components/email_html.ex
									
									
									
									
									
										Normal 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 | ||||
| @@ -0,0 +1,23 @@ | ||||
| <div style="display: flex; flex-direction: column; justify-content: center; align-items: center;"> | ||||
|   <span style="margin-bottom: 0.75em; font-size: 1.5em;"> | ||||
|     {dgettext("emails", "Hi %{email},", email: @user.email)} | ||||
|   </span> | ||||
|  | ||||
|   <br /> | ||||
|  | ||||
|   <span style="margin-bottom: 1em; font-size: 1.25em;"> | ||||
|     {dgettext("emails", "Welcome to Cannery")} | ||||
|   </span> | ||||
|  | ||||
|   <br /> | ||||
|  | ||||
|   {dgettext("emails", "You can confirm your account by visiting the URL below:")} | ||||
|  | ||||
|   <br /> | ||||
|  | ||||
|   <a style="margin: 1em; color: rgb(31, 31, 31);" href={@url}>{@url}</a> | ||||
|  | ||||
|   <br /> | ||||
|  | ||||
|   {dgettext("emails", "If you didn't create an account at Cannery, please ignore this.")} | ||||
| </div> | ||||
| @@ -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"/") %> | ||||
| @@ -0,0 +1,17 @@ | ||||
| <div style="display: flex; flex-direction: column; justify-content: center; align-items: center;"> | ||||
|   <span style="margin-bottom: 0.5em; font-size: 1.5em;"> | ||||
|     {dgettext("emails", "Hi %{email},", email: @user.email)} | ||||
|   </span> | ||||
|  | ||||
|   <br /> | ||||
|  | ||||
|   {dgettext("emails", "You can reset your password by visiting the URL below:")} | ||||
|  | ||||
|   <br /> | ||||
|  | ||||
|   <a style="margin: 1em; color: rgb(31, 31, 31);" href={@url}>{@url}</a> | ||||
|  | ||||
|   <br /> | ||||
|  | ||||
|   {dgettext("emails", "If you didn't request this change from Cannery, please ignore this.")} | ||||
| </div> | ||||
| @@ -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"/") %> | ||||
							
								
								
									
										20
									
								
								lib/cannery_web/components/email_html/update_email.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								lib/cannery_web/components/email_html/update_email.html.heex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | ||||
| <div style="display: flex; flex-direction: column; justify-content: center; align-items: center;"> | ||||
|   <span style="margin-bottom: 0.5em; font-size: 1.5em;"> | ||||
|     {dgettext("emails", "Hi %{email},", email: @user.email)} | ||||
|   </span> | ||||
|  | ||||
|   <br /> | ||||
|  | ||||
|   {dgettext("emails", "You can change your email by visiting the URL below:")} | ||||
|  | ||||
|   <br /> | ||||
|  | ||||
|   <a style="margin: 1em; color: rgb(31, 31, 31);" href={@url}>{@url}</a> | ||||
|  | ||||
|   <br /> | ||||
|  | ||||
|   {dgettext( | ||||
|     "emails", | ||||
|     "If you didn't request this change from Cannery, please ignore this." | ||||
|   )} | ||||
| </div> | ||||
| @@ -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"/") %> | ||||
| @@ -1,52 +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 | ||||
							
								
								
									
										17
									
								
								lib/cannery_web/components/layouts.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								lib/cannery_web/components/layouts.ex
									
									
									
									
									
										Normal 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 | ||||
							
								
								
									
										45
									
								
								lib/cannery_web/components/layouts/app.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								lib/cannery_web/components/layouts/app.html.heex
									
									
									
									
									
										Normal 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" | ||||
|       > | ||||
|         {Phoenix.Flash.get(@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" | ||||
|       > | ||||
|         {Phoenix.Flash.get(@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> | ||||
							
								
								
									
										19
									
								
								lib/cannery_web/components/layouts/email_html.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								lib/cannery_web/components/layouts/email_html.html.heex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| <html> | ||||
|   <head> | ||||
|     <title> | ||||
|       {@email.subject} | ||||
|     </title> | ||||
|   </head> | ||||
|   <body style="padding: 2em; color: rgb(31, 31, 31); background-color: rgb(220, 220, 228); font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif; text-align: center;"> | ||||
|     {@inner_content} | ||||
|  | ||||
|     <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={~p"/"}> | ||||
|       {dgettext( | ||||
|         "emails", | ||||
|         "This email was sent from Cannery, the self-hosted firearm tracker website." | ||||
|       )} | ||||
|     </a> | ||||
|   </body> | ||||
| </html> | ||||
| @@ -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"/") %> | ||||
							
								
								
									
										1
									
								
								lib/cannery_web/components/layouts/empty.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								lib/cannery_web/components/layouts/empty.html.heex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| {@inner_block} | ||||
							
								
								
									
										19
									
								
								lib/cannery_web/components/layouts/root.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								lib/cannery_web/components/layouts/root.html.heex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| <!DOCTYPE html> | ||||
| <html lang="en" class="p-0 m-0 w-full h-full bg-white [scrollbar-gutter:stable]"> | ||||
|   <head> | ||||
|     <meta charset="utf-8" /> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1" /> | ||||
|     <meta name="csrf-token" content={get_csrf_token()} /> | ||||
|     <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"/assets/style.css"} /> | ||||
|     <script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}> | ||||
|     </script> | ||||
|   </head> | ||||
|  | ||||
|   <body class="p-0 m-0 w-full h-full subpixel-antialiased"> | ||||
|     {@inner_content} | ||||
|   </body> | ||||
| </html> | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user