Compare commits

..

No commits in common. "stable" and "0.1.0" have entirely different histories.

293 changed files with 24399 additions and 38530 deletions

View File

@ -157,17 +157,17 @@
# #
# Controversial and experimental checks (opt-in, just replace `false` with `[]`) # Controversial and experimental checks (opt-in, just replace `false` with `[]`)
# #
{Credo.Check.Consistency.MultiAliasImportRequireUse, []}, {Credo.Check.Consistency.MultiAliasImportRequireUse, false},
{Credo.Check.Consistency.UnusedVariableNames, [force: :meaningful]}, {Credo.Check.Consistency.UnusedVariableNames, false},
{Credo.Check.Design.DuplicatedCode, false}, {Credo.Check.Design.DuplicatedCode, false},
{Credo.Check.Readability.AliasAs, false}, {Credo.Check.Readability.AliasAs, false},
{Credo.Check.Readability.BlockPipe, false}, {Credo.Check.Readability.BlockPipe, false},
{Credo.Check.Readability.ImplTrue, false}, {Credo.Check.Readability.ImplTrue, false},
{Credo.Check.Readability.MultiAlias, false}, {Credo.Check.Readability.MultiAlias, false},
{Credo.Check.Readability.SeparateAliasRequire, []}, {Credo.Check.Readability.SeparateAliasRequire, false},
{Credo.Check.Readability.SinglePipe, false}, {Credo.Check.Readability.SinglePipe, false},
{Credo.Check.Readability.Specs, false}, {Credo.Check.Readability.Specs, false},
{Credo.Check.Readability.StrictModuleLayout, []}, {Credo.Check.Readability.StrictModuleLayout, false},
{Credo.Check.Readability.WithCustomTaggedTuple, false}, {Credo.Check.Readability.WithCustomTaggedTuple, false},
{Credo.Check.Refactor.ABCSize, false}, {Credo.Check.Refactor.ABCSize, false},
{Credo.Check.Refactor.AppendSingleItem, false}, {Credo.Check.Refactor.AppendSingleItem, false},
@ -176,9 +176,9 @@
{Credo.Check.Refactor.NegatedIsNil, false}, {Credo.Check.Refactor.NegatedIsNil, false},
{Credo.Check.Refactor.PipeChainStart, false}, {Credo.Check.Refactor.PipeChainStart, false},
{Credo.Check.Refactor.VariableRebinding, false}, {Credo.Check.Refactor.VariableRebinding, false},
{Credo.Check.Warning.LeakyEnvironment, []}, {Credo.Check.Warning.LeakyEnvironment, false},
{Credo.Check.Warning.MapGetUnsafePass, []}, {Credo.Check.Warning.MapGetUnsafePass, false},
{Credo.Check.Warning.UnsafeToAtom, []} {Credo.Check.Warning.UnsafeToAtom, false}
# #
# Custom checks can be created using `mix credo.gen.check`. # Custom checks can be created using `mix credo.gen.check`.

View File

@ -13,69 +13,33 @@ steps:
mount: mount:
- _build - _build
- deps - deps
- .npm - assets/node_modules/
- .mix
- name: test - name: test
image: elixir:1.17.3-otp-27-alpine image: bitwalker/alpine-elixir-phoenix:1.13
environment: environment:
TEST_DATABASE_URL: ecto://postgres:postgres@database/cannery_test TEST_DATABASE_URL: ecto://postgres:postgres@database/cannery_test
HOST: testing.example.tld HOST: testing.example.tld
MIX_HOME: /drone/src/.mix
MIX_ARCHIVES: /drone/src/.mix/archives
MIX_ENV: test
commands: commands:
- apk add --no-cache build-base npm git
- mix local.rebar --force - mix local.rebar --force
- mix local.hex --force - mix local.hex --force
- mix deps.get - mix deps.get
- npm set cache .npm - npm install --prefix assets
- npm --prefix ./assets ci --no-audit --prefer-offline - mix test
- npm run --prefix ./assets deploy
- mix do phx.digest, gettext.extract
- mix test.all
- name: build and publish stable - name: build and publish
image: plugins/docker image: plugins/docker
privileged: true
settings: settings:
repo: shibaobun/cannery repo: shibaobun/cannery
purge: true tags: latest
compress: true
platforms:
- linux/amd64
- linux/arm64
- linux/arm/v7
username: username:
from_secret: docker_username from_secret: docker_username
password: password:
from_secret: docker_password from_secret: docker_password
tags: latest
when: when:
branch: branch:
- stable - stable
- 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:
from_secret: docker_password
tags:
- ${DRONE_TAG}
when:
event:
- tag
- name: rebuild-cache - name: rebuild-cache
image: drillster/drone-volume-cache image: drillster/drone-volume-cache
volumes: volumes:
@ -86,8 +50,7 @@ steps:
mount: mount:
- _build - _build
- deps - deps
- .npm - assets/node_modules/
- .mix
services: services:
- name: database - name: database
@ -100,7 +63,7 @@ services:
volumes: volumes:
- name: cache - name: cache
host: host:
path: /run/media/default/ssdsrv/gitea/drone-cache path: /tmp/drone-cache
- name: docker_sock - name: docker_sock
host: host:
path: /var/run/docker.sock path: /var/run/docker.sock

View File

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

2
.gitignore vendored
View File

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

View File

@ -1,3 +1,3 @@
elixir 1.17.3-otp-27 elixir 1.13.2-otp-24
erlang 27.1.2 erlang 24.2
nodejs 23.0.0 nodejs 16.13.2

View File

@ -1,246 +0,0 @@
# 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
- Fix loading and reconnecting pages not being fixed
- Fix closing modal in some cases not triggering a page reload
- Fix error when display container and tag edit routes from a fresh reload
# v0.2.1
- Fix checkbox spacing for mobile view
- Fix spacing with form elements in mobile view
- Fix user card spacing
# v0.2.0
- Add or remove tags from containers list and details page
- Show tags on containers
- Add "Cannery" to page titles
- Don't show true/false column for ammo types if all values are false
- Fix ammo type firing type display
- Show original count, current value, and percentage remaining for ammo groups
- Show shot history for an ammo group
- Show ammo round totals and total shot for ammo types
# v0.1.0
- Initial release!

View File

@ -17,8 +17,8 @@ If you're multilingual, this project can use your translations! Visit
functions as short as possible while keeping variable names descriptive! For functions as short as possible while keeping variable names descriptive! For
instance, use inline `do:` blocks for short functions and make your aliases as instance, use inline `do:` blocks for short functions and make your aliases as
short as possible without introducing ambiguity. short as possible without introducing ambiguity.
- I.e. since there's only one `Pack` in the app, please alias - I.e. since there's only one `Changeset` in the app, please alias
`Pack.t()` instead of using `Cannery.Ammo.Pack.t()` `Changeset.t(Type.t())` instead of using `Ecto.Changeset.t(Long.Type.t())`
- Use pipelines when possible. If only calling a single method, a pipeline isn't - Use pipelines when possible. If only calling a single method, a pipeline isn't
strictly necessary but still encouraged for future modification. strictly necessary but still encouraged for future modification.
- Please add typespecs to your functions! Even your private functions may be - Please add typespecs to your functions! Even your private functions may be
@ -109,8 +109,7 @@ In `dev` mode, Cannery will listen for these environment variables at runtime.
Defaults to `false`. Defaults to `false`.
- `POOL_SIZE`: Controls the pool size to use with PostgreSQL. Defaults to `10`. - `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`. - `REGISTRATION`: Controls if user sign-up should be invite only or set to public. Set to `public` to enable public registration. Defaults to `invite`.
- `LOCALE`: Sets a custom default locale. Defaults to `en_US`. - `LOCALE`: Sets a custom locale. Defaults to `en_US`.
- Available options: `en_US`, `de`, `fr`, and `es`
## `MIX_ENV=test` ## `MIX_ENV=test`
@ -142,7 +141,3 @@ In `prod` mode (or in the Docker container), Cannery will listen for the same en
Thank you so much for your contributions! Thank you so much for your contributions!
- shibao (https://misskey.bubbletea.dev/@shibao) - 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)

View File

@ -1,4 +1,4 @@
FROM elixir:1.17.3-otp-27-alpine AS build FROM elixir:1.13-alpine AS build
# install build dependencies # install build dependencies
RUN apk add --no-cache build-base npm git python3 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 WORKDIR /app
# install hex + rebar # install hex + rebar
RUN mix local.rebar --force && \ RUN mix local.hex --force && \
mix local.hex --force mix local.rebar --force
# set build ENV # set build ENV
ENV MIX_ENV=prod ENV MIX_ENV=prod
@ -37,7 +37,7 @@ RUN mix do compile, release
FROM alpine:latest AS app FROM alpine:latest AS app
RUN apk upgrade --no-cache && \ RUN apk upgrade --no-cache && \
apk add --no-cache bash openssl libssl3 libcrypto3 libgcc libstdc++ ncurses-libs apk add --no-cache bash openssl libgcc libstdc++ ncurses-libs
WORKDIR /app WORKDIR /app

View File

@ -1,7 +1,5 @@
# Cannery # Cannery
![old screenshot](https://gitea.bubbletea.dev/shibao/cannery/raw/branch/stable/home.png)
The self-hosted firearm tracker website. The self-hosted firearm tracker website.
* Easy to Use: Cannery lets you easily keep an eye on your ammo levels before * Easy to Use: Cannery lets you easily keep an eye on your ammo levels before
@ -13,8 +11,8 @@ The self-hosted firearm tracker website.
# Features # Features
- Create containers to store your ammunition, and tag them with custom tags - Create containers to store your ammunition, and tag them with custom tags
- Add ammunition types to Cannery, and then ammo packs to your containers - Add ammunition types to Cannery, and then ammunition groups to your containers
- Stage ammo packs for range day and track your usage with shot records - Stage groups of ammo for range day and record your ammo usage
- Invitations via invite tokens or public registration - Invitations via invite tokens or public registration
# Installation # Installation
@ -63,8 +61,7 @@ You can use the following environment variables to configure Cannery in
with `docker run -it shibaobun/cannery mix phx.gen.secret` and set for server to start. with `docker run -it shibaobun/cannery mix phx.gen.secret` and set for server to start.
- `REGISTRATION`: Controls if user sign-up should be invite only or set to - `REGISTRATION`: Controls if user sign-up should be invite only or set to
public. Set to `public` to enable public registration. Defaults to `invite`. public. Set to `public` to enable public registration. Defaults to `invite`.
- `LOCALE`: Sets a custom default locale. Defaults to `en_US` - `LOCALE`: Sets a custom 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_HOST`: The url for your SMTP email provider. Must be set
- `SMTP_PORT`: The port for your SMTP relay. Defaults to `587`. - `SMTP_PORT`: The port for your SMTP relay. Defaults to `587`.
- `SMTP_USERNAME`: The username for your SMTP relay. Must be set! - `SMTP_USERNAME`: The username for your SMTP relay. Must be set!
@ -92,16 +89,6 @@ Cannery is licensed under AGPLv3 or later. A copy of the latest version of the
license can be found at license can be found at
[LICENSE.md](https://gitea.bubbletea.dev/shibao/cannery/src/branch/stable/LICENSE.md). [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 [![Build

View File

@ -8,8 +8,6 @@ $fa-font-path: "@fortawesome/fontawesome-free/webfonts";
@import "@fortawesome/fontawesome-free/scss/solid"; @import "@fortawesome/fontawesome-free/scss/solid";
@import "@fortawesome/fontawesome-free/scss/brands"; @import "@fortawesome/fontawesome-free/scss/brands";
@import "slim-select/styles";
@import "components"; @import "components";
/* fix firefox scrollbars */ /* fix firefox scrollbars */
@ -27,13 +25,12 @@ $fa-font-path: "@fortawesome/fontawesome-free/webfonts";
100% { scale: 1.0; opacity: 1; } 100% { scale: 1.0; opacity: 1; }
} }
// disconnect toast .phx-connected > #disconnect, #loading {
.phx-connected > #disconnect {
opacity: 0 !important; opacity: 0 !important;
pointer-events: none; pointer-events: none;
} }
.phx-error > #disconnect { .phx-loading:not(.phx-error) > #loading, .phx-error > #disconnect {
opacity: 0.95 !important; opacity: 0.95 !important;
} }
@ -154,57 +151,3 @@ $fa-font-path: "@fortawesome/fontawesome-free/webfonts";
0% { opacity: 1; } 0% { opacity: 1; }
100% { opacity: 0; } 100% { opacity: 0; }
} }
.ss-main {
@apply input;
}
.ss-main.input-primary {
@apply border-primary-500 hover:border-primary-600 active:border-primary-600;
}
.ss-content {
@apply input;
}
.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-search input[type="search"] {
@apply input;
}
.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);
}

View File

@ -17,7 +17,8 @@
-o-transform: scale(1.5); -o-transform: scale(1.5);
transform: scale(1.5); transform: scale(1.5);
padding: 10px; padding: 10px;
margin: 1em auto; margin-left: auto;
margin-right: auto;
} }
.title { .title {

View File

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

View File

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

View File

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

View File

@ -0,0 +1,8 @@
// 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)) }
}

View File

@ -1,109 +0,0 @@
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) }
}

View File

@ -1,23 +0,0 @@
import SlimSelect from 'slim-select'
export default {
initalizeSlimSelect (el) {
// eslint-disable-next-line no-new
el.slimselect = new SlimSelect({ select: el })
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)
}
}

24328
assets/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -2,10 +2,6 @@
"repository": {}, "repository": {},
"description": " ", "description": " ",
"license": "MIT", "license": "MIT",
"engines": {
"node": "v23.0.0",
"npm": "10.9.0"
},
"scripts": { "scripts": {
"deploy": "NODE_ENV=production webpack --mode production", "deploy": "NODE_ENV=production webpack --mode production",
"watch": "webpack --mode development --watch --watch-options-stdin", "watch": "webpack --mode development --watch --watch-options-stdin",
@ -13,39 +9,36 @@
"test": "standard" "test": "standard"
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^6.6.0", "@fortawesome/fontawesome-free": "^5.15.4",
"chart.js": "^4.4.5", "alpinejs": "^3.9.0",
"chartjs-adapter-date-fns": "^3.0.0",
"date-fns": "^4.1.0",
"phoenix": "file:../deps/phoenix", "phoenix": "file:../deps/phoenix",
"phoenix_html": "file:../deps/phoenix_html", "phoenix_html": "file:../deps/phoenix_html",
"phoenix_live_view": "file:../deps/phoenix_live_view", "phoenix_live_view": "file:../deps/phoenix_live_view",
"slim-select": "^2.9.2", "topbar": "^0.1.4"
"topbar": "^3.0.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.26.0", "@babel/core": "^7.15.0",
"@babel/preset-env": "^7.26.0", "@babel/preset-env": "^7.15.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.2.6",
"babel-loader": "^9.2.1", "babel-loader": "^8.2.2",
"copy-webpack-plugin": "^12.0.2", "copy-webpack-plugin": "^9.0.0",
"css-loader": "^7.1.2", "css-loader": "^5.2.7",
"css-minimizer-webpack-plugin": "^7.0.0", "css-minimizer-webpack-plugin": "^3.0.1",
"file-loader": "^6.2.0", "file-loader": "^6.2.0",
"glob": "^11.0.0", "hard-source-webpack-plugin": "^0.13.1",
"mini-css-extract-plugin": "^2.9.1", "mini-css-extract-plugin": "^1.6.0",
"npm-check-updates": "^17.1.6", "node-sass": "^7.0.1",
"postcss": "^8.4.47", "postcss": "^8.3.6",
"postcss-import": "^16.1.0", "postcss-import": "^14.0.2",
"postcss-loader": "^8.1.1", "postcss-loader": "^6.1.1",
"postcss-preset-env": "^10.0.8", "postcss-preset-env": "^7.3.1",
"sass": "^1.80.4", "sass-loader": "^12.1.0",
"sass-loader": "^16.0.2", "standard": "^16.0.4",
"standard": "^17.1.2", "style-loader": "^3.2.1",
"tailwindcss": "^3.4.14", "tailwindcss": "^2.2.7",
"terser-webpack-plugin": "^5.3.10", "terser-webpack-plugin": "^5.1.3",
"webpack": "^5.95.0", "webpack": "^5.67.0",
"webpack-cli": "^5.1.4", "webpack-cli": "^4.8.0",
"webpack-dev-server": "^5.1.0" "webpack-dev-server": "^4.7.4"
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 516 KiB

View File

@ -1,18 +1,24 @@
const colors = require('tailwindcss/colors') const colors = require('tailwindcss/colors')
module.exports = { module.exports = {
content: [ purge: [
'../lib/**/*.{ex,heex,leex,eex}', '../lib/**/*.ex',
'../lib/**/*.heex',
'../lib/**/*.leex',
'../lib/**/*.eex',
'./js/**/*.js' './js/**/*.js'
], ],
darkMode: 'media',
theme: { theme: {
colors: { colors: {
transparent: 'transparent', transparent: 'transparent',
current: 'currentColor', current: 'currentColor',
primary: colors.gray, primary: colors.gray,
black: colors.black, black: colors.black,
white: colors.white, white: colors.white,
gray: colors.neutral, gray: colors.trueGray,
indigo: colors.indigo, indigo: colors.indigo,
red: colors.rose, red: colors.rose,
yellow: colors.amber yellow: colors.amber
@ -22,22 +28,14 @@ module.exports = {
128: '32rem', 128: '32rem',
192: '48rem', 192: '48rem',
256: '64rem' 256: '64rem'
},
minWidth: {
4: '1rem',
8: '2rem',
12: '3rem',
16: '4rem',
20: '8rem'
},
maxWidth: {
4: '1rem',
8: '2rem',
12: '3rem',
16: '4rem',
20: '8rem'
} }
} }
}, },
variants: {
extend: {
backgroundColor: ['active'],
borderColor: ['active']
}
},
plugins: [] plugins: []
} }

157
assets/vendor/topbar.js vendored Normal file
View File

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

View File

@ -44,14 +44,19 @@ module.exports = (env, options) => {
}, },
{ {
test: /\.(woff(2)?|ttf|eot|svg|otf)(\?v=[0-9]\.[0-9]\.[0-9])?$/, test: /\.(woff(2)?|ttf|eot|svg|otf)(\?v=[0-9]\.[0-9]\.[0-9])?$/,
type: 'asset/resource', loader: 'file-loader',
generator: { filename: 'fonts/[name].[ext]' } options: {
name: '[name].[ext]?[hash]',
outputPath: '../fonts'
}
} }
] ]
}, },
plugins: [ plugins: [
new MiniCssExtractPlugin({ filename: '../css/app.css' }), new MiniCssExtractPlugin({ filename: '../css/app.css' }),
new CopyWebpackPlugin({ patterns: [{ from: 'static/', to: '../' }] }) new CopyWebpackPlugin({
patterns: [{ from: 'static/', to: '../' }]
})
] ]
} }
} }

View File

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

View File

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

View File

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

View File

@ -9,9 +9,8 @@ config :bcrypt_elixir, :log_rounds, 1
# to provide built-in test partitioning in CI environment. # to provide built-in test partitioning in CI environment.
# Run `mix help test` for more information. # Run `mix help test` for more information.
config :cannery, Cannery.Repo, config :cannery, Cannery.Repo,
pool_size: 10,
pool: Ecto.Adapters.SQL.Sandbox, pool: Ecto.Adapters.SQL.Sandbox,
timeout: 60000 pool_size: 10
# We don't run a server during test. If one is required, # We don't run a server during test. If one is required,
# you can enable the server option below. # you can enable the server option below.
@ -23,14 +22,11 @@ config :cannery, CanneryWeb.Endpoint,
# In test we don't send emails. # In test we don't send emails.
config :cannery, Cannery.Mailer, adapter: Swoosh.Adapters.Test 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 # Print only warnings and errors during test
config :logger, level: :warning config :logger, level: :warn
# Initialize plugs at runtime for faster test compilation # Initialize plugs at runtime for faster test compilation
config :phoenix, :plug_init_mode, :runtime config :phoenix, :plug_init_mode, :runtime
# Disable Oban # Disable Oban
config :cannery, Oban, queues: false, plugins: false, testing: :manual config :cannery, Oban, queues: false, plugins: false

BIN
home.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

View File

@ -6,34 +6,4 @@ defmodule Cannery do
Contexts are also responsible for managing your data, regardless Contexts are also responsible for managing your data, regardless
if it comes from the database, an external API or others. 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
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 end

View File

@ -3,9 +3,10 @@ defmodule Cannery.Accounts do
The Accounts context. The Accounts context.
""" """
use Cannery, :context import Ecto.Query, warn: false
alias Cannery.Mailer alias Cannery.{Mailer, Repo}
alias Cannery.Accounts.{Invite, Invites, UserToken} alias Cannery.Accounts.{User, UserToken}
alias Ecto.{Changeset, Multi}
alias Oban.Job alias Oban.Job
## Database getters ## Database getters
@ -22,24 +23,22 @@ defmodule Cannery.Accounts do
nil nil
""" """
@spec get_user_by_email(email :: String.t()) :: User.t() | nil @spec get_user_by_email(String.t()) :: User.t() | nil
def get_user_by_email(email) when is_binary(email) do def get_user_by_email(email) when is_binary(email), do: Repo.get_by(User, email: email)
Repo.get_by(User, email: email)
end
@doc """ @doc """
Gets a user by email and password. Gets a user by email and password.
## Examples ## Examples
iex> get_user_by_email_and_password("foo@example.com", "valid_password") iex> get_user_by_email_and_password("foo@example.com", "correct_password")
%User{} %User{}
iex> get_user_by_email_and_password("foo@example.com", "invalid_password") iex> get_user_by_email_and_password("foo@example.com", "invalid_password")
nil nil
""" """
@spec get_user_by_email_and_password(email :: String.t(), password :: String.t()) :: @spec get_user_by_email_and_password(String.t(), String.t()) ::
User.t() | nil User.t() | nil
def get_user_by_email_and_password(email, password) def get_user_by_email_and_password(email, password)
when is_binary(email) and is_binary(password) do when is_binary(email) and is_binary(password) do
@ -54,30 +53,28 @@ defmodule Cannery.Accounts do
## Examples ## Examples
iex> get_user!(user_id) iex> get_user!(123)
user %User{}
iex> get_user!() iex> get_user!(456)
** (Ecto.NoResultsError) ** (Ecto.NoResultsError)
""" """
@spec get_user!(User.id()) :: User.t() @spec get_user!(User.t()) :: User.t()
def get_user!(id) do def get_user!(id), do: Repo.get!(User, id)
Repo.get!(User, id)
end
@doc """ @doc """
Returns all users grouped by role. Returns all users grouped by role.
## Examples ## Examples
iex> list_all_users_by_role(user1) iex> list_users_by_role(%User{id: 123, role: :admin})
%{admin: [%User{role: :admin}], user: [%User{role: :user}]} [admin: [%User{}], user: [%User{}, %User{}]]
""" """
@spec list_all_users_by_role(User.t()) :: %{User.role() => [User.t()]} @spec list_all_users_by_role(User.t()) :: %{String.t() => [User.t()]}
def list_all_users_by_role(%User{role: :admin}) do def list_all_users_by_role(%User{role: :admin}) do
Repo.all(from u in User, order_by: u.email) |> Enum.group_by(fn %{role: role} -> role end) Repo.all(from u in User, order_by: u.email) |> Enum.group_by(fn user -> user.role end)
end end
@doc """ @doc """
@ -85,12 +82,13 @@ defmodule Cannery.Accounts do
## Examples ## Examples
iex> list_users_by_role(:admin) iex> list_users_by_role(%User{id: 123, role: :admin})
[%User{role: :admin}] [%User{}]
""" """
@spec list_users_by_role(:admin) :: [User.t()] @spec list_users_by_role(:admin | :user) :: [User.t()]
def list_users_by_role(:admin = role) do def list_users_by_role(role) do
role = role |> to_string()
Repo.all(from u in User, where: u.role == ^role, order_by: u.email) Repo.all(from u in User, where: u.role == ^role, order_by: u.email)
end end
@ -101,38 +99,22 @@ defmodule Cannery.Accounts do
## Examples ## Examples
iex> register_user(%{email: "foo@example.com", password: "valid_password"}) iex> register_user(%{field: value})
{:ok, %User{email: "foo@example.com"}} {:ok, %User{}}
iex> register_user(%{email: "foo@example"}) iex> register_user(%{field: bad_value})
{:error, %Changeset{}} {:error, %Changeset{}}
""" """
@spec register_user(attrs :: map()) :: @spec register_user(map()) :: {:ok, User.t()} | {:error, Changeset.t(User.new_user())}
{:ok, User.t()} | {:error, :invalid_token | User.changeset()} def register_user(attrs) do
@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 # if no registered users, make first user an admin
role = if count == 0, do: :admin, else: :user role =
User.registration_changeset(attrs, invite) |> User.role_changeset(role) if Repo.one!(from u in User, select: count(u.id), distinct: true) == 0,
end) do: "admin",
|> Repo.transaction() else: "user"
|> case do
{:ok, %{add_user: user}} -> {:ok, user} %User{} |> User.registration_changeset(attrs |> Map.put("role", role)) |> Repo.insert()
{:error, :use_invite, :invalid_token, _changes_so_far} -> {:error, :invalid_token}
{:error, :add_user, changeset, _changes_so_far} -> {:error, changeset}
end
end end
@doc """ @doc """
@ -140,18 +122,16 @@ defmodule Cannery.Accounts do
## Examples ## Examples
iex> change_user_registration() iex> change_user_registration(user)
%Changeset{} %Changeset{data: %User{}}
iex> change_user_registration(%{password: "hi"}
%Changeset{}
""" """
@spec change_user_registration() :: User.changeset() @spec change_user_registration(User.t() | User.new_user()) ::
@spec change_user_registration(attrs :: map()) :: User.changeset() Changeset.t(User.t() | User.new_user())
def change_user_registration(attrs \\ %{}) do @spec change_user_registration(User.t() | User.new_user(), map()) ::
User.registration_changeset(attrs, nil, hash_password: false) Changeset.t(User.t() | User.new_user())
end def change_user_registration(user, attrs \\ %{}),
do: User.registration_changeset(user, attrs, hash_password: false)
## Settings ## Settings
@ -160,29 +140,24 @@ defmodule Cannery.Accounts do
## Examples ## Examples
iex> change_user_email(%User{email: "foo@example.com"}) iex> change_user_email(user)
%Changeset{} %Changeset{data: %User{}}
""" """
@spec change_user_email(User.t()) :: User.changeset() @spec change_user_email(User.t(), map()) :: Changeset.t(User.t())
@spec change_user_email(User.t(), attrs :: map()) :: User.changeset() def change_user_email(user, attrs \\ %{}), do: User.email_changeset(user, attrs)
def change_user_email(user, attrs \\ %{}) do
User.email_changeset(user, attrs)
end
@doc """ @doc """
Returns an `%Changeset{}` for changing the user role. Returns an `%Changeset{}` for changing the user role.
## Examples ## Examples
iex> change_user_role(%User{}, :user) iex> change_user_role(user)
%Changeset{} %Changeset{data: %User{}}
""" """
@spec change_user_role(User.t(), User.role()) :: User.changeset() @spec change_user_role(User.t(), atom()) :: Changeset.t(User.t())
def change_user_role(user, role) do def change_user_role(user, role), do: User.role_changeset(user, role)
User.role_changeset(user, role)
end
@doc """ @doc """
Emulates that the email will change without actually changing Emulates that the email will change without actually changing
@ -190,15 +165,15 @@ defmodule Cannery.Accounts do
## Examples ## Examples
iex> apply_user_email(user, "valid_password", %{email: "new_email@account.com"}) iex> apply_user_email(user, "valid password", %{email: ...})
{:ok, %User{}} {:ok, %User{}}
iex> apply_user_email(user, "invalid password", %{email: "new_email@account"}) iex> apply_user_email(user, "invalid password", %{email: ...})
{:error, %Changeset{}} {:error, %Changeset{}}
""" """
@spec apply_user_email(User.t(), email :: String.t(), attrs :: map()) :: @spec apply_user_email(User.t(), String.t(), map()) ::
{:ok, User.t()} | {:error, User.changeset()} {:ok, User.t()} | {:error, Changeset.t(User.t())}
def apply_user_email(user, password, attrs) do def apply_user_email(user, password, attrs) do
user user
|> User.email_changeset(attrs) |> User.email_changeset(attrs)
@ -212,7 +187,7 @@ defmodule Cannery.Accounts do
If the token matches, the user email is updated and the token is deleted. 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. The confirmed_at date is also updated to the current time.
""" """
@spec update_user_email(User.t(), token :: String.t()) :: :ok | :error @spec update_user_email(User.t(), String.t()) :: :ok | :error
def update_user_email(user, token) do def update_user_email(user, token) do
context = "change:#{user.email}" context = "change:#{user.email}"
@ -221,11 +196,11 @@ defmodule Cannery.Accounts do
{:ok, _} <- Repo.transaction(user_email_multi(user, email, context)) do {:ok, _} <- Repo.transaction(user_email_multi(user, email, context)) do
:ok :ok
else else
_error_tuple -> :error _ -> :error
end end
end end
@spec user_email_multi(User.t(), email :: String.t(), context :: String.t()) :: Multi.t() @spec user_email_multi(User.t(), String.t(), String.t()) :: Multi.t()
defp user_email_multi(user, email, context) do defp user_email_multi(user, email, context) do
changeset = user |> User.email_changeset(%{email: email}) |> User.confirm_changeset() changeset = user |> User.email_changeset(%{email: email}) |> User.confirm_changeset()
@ -239,12 +214,11 @@ defmodule Cannery.Accounts do
## Examples ## Examples
iex> deliver_update_email_instructions(user, "new_foo@example.com", fn _token -> "example url" end) iex> deliver_update_email_instructions(user, current_email, &Routes.user_update_email_url(conn, :edit, &1))
%Oban.Job{args: %{email: :update_email, user_id: ^user_id, attrs: %{url: "example url"}}} {:ok, %{to: ..., body: ...}}
""" """
@spec deliver_update_email_instructions(User.t(), current_email :: String.t(), function) :: @spec deliver_update_email_instructions(User.t(), String.t(), function) :: Job.t()
Job.t()
def deliver_update_email_instructions(user, current_email, update_email_url_fun) def deliver_update_email_instructions(user, current_email, update_email_url_fun)
when is_function(update_email_url_fun, 1) do when is_function(update_email_url_fun, 1) do
{encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}") {encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}")
@ -257,32 +231,28 @@ defmodule Cannery.Accounts do
## Examples ## Examples
iex> change_user_password(%User{}) iex> change_user_password(user)
%Changeset{} %Changeset{data: %User{}}
""" """
@spec change_user_password(User.t(), attrs :: map()) :: User.changeset() @spec change_user_password(User.t(), map()) :: Changeset.t(User.t())
def change_user_password(user, attrs \\ %{}) do def change_user_password(user, attrs \\ %{}),
User.password_changeset(user, attrs, hash_password: false) do: User.password_changeset(user, attrs, hash_password: false)
end
@doc """ @doc """
Updates the user password. Updates the user password.
## Examples ## Examples
iex> reset_user_password(user, %{ iex> update_user_password(user, "valid password", %{password: ...})
...> password: "new password",
...> password_confirmation: "new password"
...> })
{:ok, %User{}} {:ok, %User{}}
iex> update_user_password(user, "invalid password", %{password: "123"}) iex> update_user_password(user, "invalid password", %{password: ...})
{:error, %Changeset{}} {:error, %Changeset{}}
""" """
@spec update_user_password(User.t(), String.t(), attrs :: map()) :: @spec update_user_password(User.t(), String.t(), map()) ::
{:ok, User.t()} | {:error, User.changeset()} {:ok, User.t()} | {:error, Changeset.t(User.t())}
def update_user_password(user, password, attrs) do def update_user_password(user, password, attrs) do
changeset = changeset =
user user
@ -295,59 +265,25 @@ defmodule Cannery.Accounts do
|> Repo.transaction() |> Repo.transaction()
|> case do |> case do
{:ok, %{user: user}} -> {:ok, user} {:ok, %{user: user}} -> {:ok, user}
{:error, :user, changeset, _changes_so_far} -> {:error, changeset} {:error, :user, changeset, _} -> {:error, changeset}
end end
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 """ @doc """
Deletes a user. must be performed by an admin or the same user! Deletes a user. must be performed by an admin or the same user!
## Examples ## Examples
iex> delete_user!(user, %User{id: 123, role: :admin}) iex> delete_user!(user_to_delete, %User{id: 123, role: :admin})
%User{} %User{}
iex> delete_user!(user, user) iex> delete_user!(%User{id: 123}, %User{id: 123})
%User{} %User{}
""" """
@spec delete_user!(user_to_delete :: User.t(), User.t()) :: User.t() @spec delete_user!(User.t(), User.t()) :: User.t()
def delete_user!(user, %User{role: :admin}) do def delete_user!(user, %User{role: :admin}), do: user |> Repo.delete!()
user |> Repo.delete!() def delete_user!(%User{id: user_id} = user, %User{id: user_id}), do: user |> Repo.delete!()
end
def delete_user!(%User{id: user_id} = user, %User{id: user_id}) do
user |> Repo.delete!()
end
## Session ## Session
@ -364,7 +300,7 @@ defmodule Cannery.Accounts do
@doc """ @doc """
Gets the user with the given signed token. Gets the user with the given signed token.
""" """
@spec get_user_by_session_token(token :: String.t()) :: User.t() @spec get_user_by_session_token(String.t()) :: User.t()
def get_user_by_session_token(token) do def get_user_by_session_token(token) do
{:ok, query} = UserToken.verify_session_token_query(token) {:ok, query} = UserToken.verify_session_token_query(token)
Repo.one(query) Repo.one(query)
@ -373,9 +309,9 @@ defmodule Cannery.Accounts do
@doc """ @doc """
Deletes the signed token with the given context. Deletes the signed token with the given context.
""" """
@spec delete_user_session_token(token :: String.t()) :: :ok @spec delete_session_token(String.t()) :: :ok
def delete_user_session_token(token) do def delete_session_token(token) do
UserToken.token_and_context_query(token, "session") |> Repo.delete_all() Repo.delete_all(UserToken.token_and_context_query(token, "session"))
:ok :ok
end end
@ -384,53 +320,19 @@ defmodule Cannery.Accounts do
""" """
@spec allow_registration?() :: boolean() @spec allow_registration?() :: boolean()
def allow_registration? do def allow_registration? do
registration_mode() == :public or list_users_by_role(:admin) |> Enum.empty?() Application.get_env(:cannery, CanneryWeb.Endpoint)[:registration] == "public" or
end list_users_by_role(:admin) |> Enum.empty?()
@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 end
@doc """ @doc """
Checks if user is an admin Checks if user is an admin
## Examples
iex> admin?(%User{role: :admin})
true
iex> admin?(%User{})
false
""" """
@spec admin?(User.t()) :: boolean() @spec is_admin?(User.t()) :: boolean()
def admin?(%User{id: user_id}) do def is_admin?(%User{id: user_id}) do
Repo.exists?(from u in User, where: u.id == ^user_id, where: u.role == :admin) Repo.one(from u in User, where: u.id == ^user_id and u.role == :admin)
|> is_nil()
end 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 ## Confirmation
@doc """ @doc """
@ -438,10 +340,10 @@ defmodule Cannery.Accounts do
## Examples ## Examples
iex> deliver_user_confirmation_instructions(user, fn _token -> "example url" end) iex> deliver_user_confirmation_instructions(user, &Routes.user_confirmation_url(conn, :confirm, &1))
%Oban.Job{args: %{email: :welcome, user_id: ^user_id, attrs: %{url: "example url"}}} {:ok, %{to: ..., body: ...}}
iex> deliver_user_confirmation_instructions(user, fn _token -> "example url" end) iex> deliver_user_confirmation_instructions(confirmed_user, &Routes.user_confirmation_url(conn, :confirm, &1))
{:error, :already_confirmed} {:error, :already_confirmed}
""" """
@ -463,14 +365,14 @@ defmodule Cannery.Accounts do
If the token matches, the user account is marked as confirmed If the token matches, the user account is marked as confirmed
and the token is deleted. and the token is deleted.
""" """
@spec confirm_user(token :: String.t()) :: {:ok, User.t()} | :error @spec confirm_user(String.t()) :: {:ok, User.t()} | atom()
def confirm_user(token) do def confirm_user(token) do
with {:ok, query} <- UserToken.verify_email_token_query(token, "confirm"), with {:ok, query} <- UserToken.verify_email_token_query(token, "confirm"),
%User{} = user <- Repo.one(query), %User{} = user <- Repo.one(query),
{:ok, %{user: user}} <- Repo.transaction(confirm_user_multi(user)) do {:ok, %{user: user}} <- Repo.transaction(confirm_user_multi(user)) do
{:ok, user} {:ok, user}
else else
_error_tuple -> :error _ -> :error
end end
end end
@ -488,8 +390,8 @@ defmodule Cannery.Accounts do
## Examples ## Examples
iex> deliver_user_reset_password_instructions(user, fn _token -> "example url" end) iex> deliver_user_reset_password_instructions(user, &Routes.user_reset_password_url(conn, :edit, &1))
%Oban.Job{args: %{email: :reset_password, user_id: ^user_id, attrs: %{url: "example url"}}} {:ok, %{to: ..., body: ...}}
""" """
@spec deliver_user_reset_password_instructions(User.t(), function()) :: Job.t() @spec deliver_user_reset_password_instructions(User.t(), function()) :: Job.t()
@ -505,20 +407,20 @@ defmodule Cannery.Accounts do
## Examples ## Examples
iex> get_user_by_reset_password_token(encoded_token) iex> get_user_by_reset_password_token("validtoken")
%User{} %User{}
iex> get_user_by_reset_password_token("invalidtoken") iex> get_user_by_reset_password_token("invalidtoken")
nil nil
""" """
@spec get_user_by_reset_password_token(token :: String.t()) :: User.t() | nil @spec get_user_by_reset_password_token(String.t()) :: User.t() | nil
def get_user_by_reset_password_token(token) do def get_user_by_reset_password_token(token) do
with {:ok, query} <- UserToken.verify_email_token_query(token, "reset_password"), with {:ok, query} <- UserToken.verify_email_token_query(token, "reset_password"),
%User{} = user <- Repo.one(query) do %User{} = user <- Repo.one(query) do
user user
else else
_error_tuple -> nil _ -> nil
end end
end end
@ -527,18 +429,14 @@ defmodule Cannery.Accounts do
## Examples ## Examples
iex> reset_user_password(user, %{ iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"})
...> password: "new password",
...> password_confirmation: "new password"
...> })
{:ok, %User{}} {:ok, %User{}}
iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"}) iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"})
{:error, %Changeset{}} {:error, %Changeset{}}
""" """
@spec reset_user_password(User.t(), attrs :: map()) :: @spec reset_user_password(User.t(), map()) :: {:ok, User.t()} | {:error, Changeset.t(User.t())}
{:ok, User.t()} | {:error, User.changeset()}
def reset_user_password(user, attrs) do def reset_user_password(user, attrs) do
Multi.new() Multi.new()
|> Multi.update(:user, User.password_changeset(user, attrs)) |> Multi.update(:user, User.password_changeset(user, attrs))
@ -546,7 +444,7 @@ defmodule Cannery.Accounts do
|> Repo.transaction() |> Repo.transaction()
|> case do |> case do
{:ok, %{user: user}} -> {:ok, user} {:ok, %{user: user}} -> {:ok, user}
{:error, :user, changeset, _changes_so_far} -> {:error, changeset} {:error, :user, changeset, _} -> {:error, changeset}
end end
end end
end end

View File

@ -0,0 +1,48 @@
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

View File

@ -1,206 +0,0 @@
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 = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
invite |> Invite.update_changeset(%{uses_left: 0, disabled_at: now})
end
defp decrement_invite_changeset(%Invite{uses_left: uses_left} = invite) do
invite |> Invite.update_changeset(%{uses_left: uses_left - 1})
end
@doc """
Creates a invite.
## Examples
iex> create_invite(%User{id: 123, role: :admin}, %{field: value})
{:ok, %Invite{}}
iex> create_invite(%User{id: 123, role: :admin}, %{field: bad_value})
{:error, %Changeset{}}
"""
@spec create_invite(User.t(), attrs :: map()) ::
{:ok, Invite.t()} | {:error, Invite.changeset()}
def create_invite(%User{role: :admin} = user, attrs) do
token =
:crypto.strong_rand_bytes(@invite_token_length)
|> Base.url_encode64()
|> binary_part(0, @invite_token_length)
Invite.create_changeset(user, token, attrs) |> Repo.insert()
end
@doc """
Updates a invite.
## Examples
iex> update_invite(invite, %{field: new_value}, %User{id: 123, role: :admin})
{:ok, %Invite{}}
iex> update_invite(invite, %{field: bad_value}, %User{id: 123, role: :admin})
{:error, %Changeset{}}
"""
@spec update_invite(Invite.t(), attrs :: map(), User.t()) ::
{:ok, Invite.t()} | {:error, Invite.changeset()}
def update_invite(invite, attrs, %User{role: :admin}) do
invite |> Invite.update_changeset(attrs) |> Repo.update()
end
@doc """
Deletes a invite.
## Examples
iex> delete_invite(invite, %User{id: 123, role: :admin})
{:ok, %Invite{}}
iex> delete_invite(invite, %User{id: 123, role: :admin})
{:error, %Changeset{}}
"""
@spec delete_invite(Invite.t(), User.t()) ::
{:ok, Invite.t()} | {:error, Invite.changeset()}
def delete_invite(invite, %User{role: :admin}) do
invite |> Repo.delete()
end
@doc """
Deletes a invite.
## Examples
iex> delete_invite(invite, %User{id: 123, role: :admin})
%Invite{}
"""
@spec delete_invite!(Invite.t(), User.t()) :: Invite.t()
def delete_invite!(invite, %User{role: :admin}) do
invite |> Repo.delete!()
end
end

View File

@ -1,33 +1,25 @@
defmodule Cannery.Accounts.User do defmodule Cannery.Accounts.User do
@moduledoc """ @moduledoc """
A Cannery user A cannery user
""" """
use Cannery, :schema use Ecto.Schema
alias Cannery.Accounts.Invite import Ecto.Changeset
import CanneryWeb.Gettext
alias Ecto.{Changeset, UUID}
alias Cannery.{Accounts.User, Invites.Invite}
@derive {Jason.Encoder,
only: [
:id,
:email,
:confirmed_at,
:role,
:locale,
:inserted_at,
:updated_at
]}
@derive {Inspect, except: [:password]} @derive {Inspect, except: [:password]}
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "users" do schema "users" do
field :email, :string field :email, :string
field :password, :string, virtual: true field :password, :string, virtual: true
field :hashed_password, :string field :hashed_password, :string
field :confirmed_at, :naive_datetime field :confirmed_at, :naive_datetime
field :role, Ecto.Enum, values: [:admin, :user], default: :user field :role, Ecto.Enum, values: [:admin, :user], default: :user
field :locale, :string
has_many :created_invites, Invite, foreign_key: :created_by_id has_many :invites, Invite, on_delete: :delete_all
belongs_to :invite, Invite
timestamps() timestamps()
end end
@ -38,18 +30,13 @@ defmodule Cannery.Accounts.User do
password: String.t(), password: String.t(),
hashed_password: String.t(), hashed_password: String.t(),
confirmed_at: NaiveDateTime.t(), confirmed_at: NaiveDateTime.t(),
role: role(), role: atom(),
locale: String.t() | nil, invites: [Invite.t()],
created_invites: [Invite.t()] | Association.NotLoaded.t(),
invite: Invite.t() | nil | Association.NotLoaded.t(),
invite_id: Invite.id() | nil,
inserted_at: NaiveDateTime.t(), inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t() updated_at: NaiveDateTime.t()
} }
@type new_user :: %User{} @type new_user :: %User{}
@type id :: UUID.t() @type id :: UUID.t()
@type changeset :: Changeset.t(t() | new_user())
@type role :: :admin | :user
@doc """ @doc """
A user changeset for registration. A user changeset for registration.
@ -68,26 +55,26 @@ defmodule Cannery.Accounts.User do
validations on a LiveView form), this option can be set to `false`. validations on a LiveView form), this option can be set to `false`.
Defaults to `true`. Defaults to `true`.
""" """
@spec registration_changeset(attrs :: map(), Invite.t() | nil) :: changeset() @spec registration_changeset(t() | new_user(), attrs :: map()) :: Changeset.t(t() | new_user())
@spec registration_changeset(attrs :: map(), Invite.t() | nil, opts :: keyword()) :: changeset() @spec registration_changeset(t() | new_user(), attrs :: map(), opts :: keyword()) ::
def registration_changeset(attrs, invite, opts \\ []) do Changeset.t(t() | new_user())
%User{} def registration_changeset(user, attrs, opts \\ []) do
|> cast(attrs, [:email, :password, :locale]) user
|> put_change(:invite_id, if(invite, do: invite.id)) |> cast(attrs, [:email, :password, :role])
|> validate_length(:locale, max: 255)
|> validate_email() |> validate_email()
|> validate_password(opts) |> validate_password(opts)
end end
@doc """ @doc """
A user changeset for role. A user changeset for role.
""" """
@spec role_changeset(t() | new_user() | changeset(), role()) :: changeset() @spec role_changeset(t(), role :: atom()) :: Changeset.t(t())
def role_changeset(user, role) do def role_changeset(user, role) do
user |> change(role: role) user |> cast(%{"role" => role}, [:role])
end end
@spec validate_email(changeset()) :: changeset() @spec validate_email(Changeset.t(t() | new_user())) :: Changeset.t(t() | new_user())
defp validate_email(changeset) do defp validate_email(changeset) do
changeset changeset
|> validate_required([:email]) |> validate_required([:email])
@ -99,8 +86,8 @@ defmodule Cannery.Accounts.User do
|> unique_constraint(:email) |> unique_constraint(:email)
end end
@spec validate_password(changeset(), opts :: keyword()) :: @spec validate_password(Changeset.t(t() | new_user()), opts :: keyword()) ::
changeset() Changeset.t(t() | new_user())
defp validate_password(changeset, opts) do defp validate_password(changeset, opts) do
changeset changeset
|> validate_required([:password]) |> validate_required([:password])
@ -111,7 +98,8 @@ defmodule Cannery.Accounts.User do
|> maybe_hash_password(opts) |> maybe_hash_password(opts)
end end
@spec maybe_hash_password(changeset(), opts :: keyword()) :: changeset() @spec maybe_hash_password(Changeset.t(t() | new_user()), opts :: keyword()) ::
Changeset.t(t() | new_user())
defp maybe_hash_password(changeset, opts) do defp maybe_hash_password(changeset, opts) do
hash_password? = Keyword.get(opts, :hash_password, true) hash_password? = Keyword.get(opts, :hash_password, true)
password = get_change(changeset, :password) password = get_change(changeset, :password)
@ -130,7 +118,7 @@ defmodule Cannery.Accounts.User do
It requires the email to change otherwise an error is added. It requires the email to change otherwise an error is added.
""" """
@spec email_changeset(t(), attrs :: map()) :: changeset() @spec email_changeset(t(), attrs :: map()) :: Changeset.t(t())
def email_changeset(user, attrs) do def email_changeset(user, attrs) do
user user
|> cast(attrs, [:email]) |> cast(attrs, [:email])
@ -153,8 +141,8 @@ defmodule Cannery.Accounts.User do
validations on a LiveView form), this option can be set to `false`. validations on a LiveView form), this option can be set to `false`.
Defaults to `true`. Defaults to `true`.
""" """
@spec password_changeset(t(), attrs :: map()) :: changeset() @spec password_changeset(t(), attrs :: map()) :: Changeset.t(t())
@spec password_changeset(t(), attrs :: map(), opts :: keyword()) :: changeset() @spec password_changeset(t(), attrs :: map(), opts :: keyword()) :: Changeset.t(t())
def password_changeset(user, attrs, opts \\ []) do def password_changeset(user, attrs, opts \\ []) do
user user
|> cast(attrs, [:password]) |> cast(attrs, [:password])
@ -165,7 +153,7 @@ defmodule Cannery.Accounts.User do
@doc """ @doc """
Confirms the account by setting `confirmed_at`. Confirms the account by setting `confirmed_at`.
""" """
@spec confirm_changeset(t() | changeset()) :: changeset() @spec confirm_changeset(t() | Changeset.t(t())) :: Changeset.t(t())
def confirm_changeset(user_or_changeset) do def confirm_changeset(user_or_changeset) do
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
user_or_changeset |> change(confirmed_at: now) user_or_changeset |> change(confirmed_at: now)
@ -183,7 +171,7 @@ defmodule Cannery.Accounts.User do
Bcrypt.verify_pass(password, hashed_password) Bcrypt.verify_pass(password, hashed_password)
end end
def valid_password?(_invalid_user, _invalid_password) do def valid_password?(_, _) do
Bcrypt.no_user_verify() Bcrypt.no_user_verify()
false false
end end
@ -191,21 +179,10 @@ defmodule Cannery.Accounts.User do
@doc """ @doc """
Validates the current password otherwise adds an error to the changeset. Validates the current password otherwise adds an error to the changeset.
""" """
@spec validate_current_password(changeset(), String.t()) :: changeset() @spec validate_current_password(Changeset.t(t()), String.t()) :: Changeset.t(t())
def validate_current_password(changeset, password) do def validate_current_password(changeset, password) do
if valid_password?(changeset.data, password), if valid_password?(changeset.data, password),
do: changeset, do: changeset,
else: changeset |> add_error(:current_password, dgettext("errors", "is not valid")) else: changeset |> add_error(:current_password, dgettext("errors", "is not valid"))
end 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 end

View File

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

View File

@ -3,435 +3,196 @@ defmodule Cannery.ActivityLog do
The ActivityLog context. The ActivityLog context.
""" """
use Cannery, :context import Ecto.Query, warn: false
alias Cannery.{ActivityLog.ShotRecord, Ammo.Pack, Ammo.Type} import CanneryWeb.Gettext
alias Cannery.{Accounts.User, ActivityLog.ShotGroup, Ammo, Ammo.AmmoGroup, Repo}
@type list_shot_records_option :: alias Ecto.{Changeset, Multi}
{:search, String.t() | nil}
| {:class, Type.class() | :all | nil}
| {:pack_id, Pack.id() | nil}
@type list_shot_records_options :: [list_shot_records_option()]
@doc """ @doc """
Returns the list of shot_records. Returns the list of shot_groups.
## Examples ## Examples
iex> list_shot_records(%User{id: 123}) iex> list_shot_groups(%User{id: 123})
[%ShotRecord{}, ...] [%ShotGroup{}, ...]
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_records(User.t()) :: [ShotRecord.t()] @spec list_shot_groups(User.t()) :: [ShotGroup.t()]
@spec list_shot_records(User.t(), list_shot_records_options()) :: [ShotRecord.t()] def list_shot_groups(%User{id: user_id}) do
def list_shot_records(%User{id: user_id}, opts \\ []) do Repo.all(from(sg in ShotGroup, where: sg.user_id == ^user_id))
from(sr in ShotRecord,
as: :sr,
left_join: p in Pack,
as: :p,
on: sr.pack_id == p.id,
on: p.user_id == ^user_id,
left_join: t in Type,
as: :t,
on: p.type_id == t.id,
on: t.user_id == ^user_id,
where: sr.user_id == ^user_id,
distinct: sr.id
)
|> list_shot_records_search(Keyword.get(opts, :search))
|> list_shot_records_class(Keyword.get(opts, :class))
|> list_shot_records_pack_id(Keyword.get(opts, :pack_id))
|> Repo.all()
end
@spec list_shot_records_search(Queryable.t(), search :: String.t() | nil) ::
Queryable.t()
defp list_shot_records_search(query, search) when search in ["", nil], do: query
defp list_shot_records_search(query, search) when search |> is_binary() do
trimmed_search = String.trim(search)
query
|> where(
[sr: sr, p: p, t: t],
fragment(
"? @@ websearch_to_tsquery('english', ?)",
sr.search,
^trimmed_search
) or
fragment(
"? @@ websearch_to_tsquery('english', ?)",
p.search,
^trimmed_search
) or
fragment(
"? @@ websearch_to_tsquery('english', ?)",
t.search,
^trimmed_search
)
)
|> order_by([sr: sr], {
:desc,
fragment(
"ts_rank_cd(?, websearch_to_tsquery('english', ?), 4)",
sr.search,
^trimmed_search
)
})
end
@spec list_shot_records_class(Queryable.t(), Type.class() | :all | nil) :: Queryable.t()
defp list_shot_records_class(query, class) when class in [:rifle, :pistol, :shotgun],
do: query |> where([t: t], t.class == ^class)
defp list_shot_records_class(query, _all), do: query
@spec list_shot_records_pack_id(Queryable.t(), Pack.id() | nil) :: Queryable.t()
defp list_shot_records_pack_id(query, pack_id) when pack_id |> is_binary(),
do: query |> where([sr: sr], sr.pack_id == ^pack_id)
defp list_shot_records_pack_id(query, _all), do: query
@doc """
Returns a count of shot records.
## Examples
iex> get_shot_record_count!(%User{id: 123})
3
"""
@spec get_shot_record_count!(User.t()) :: integer()
def get_shot_record_count!(%User{id: user_id}) do
Repo.one(
from sr in ShotRecord,
where: sr.user_id == ^user_id,
select: count(sr.id),
distinct: true
) || 0
end end
@doc """ @doc """
Gets a single shot_record. Gets a single shot_group.
Raises `Ecto.NoResultsError` if the shot record does not exist. Raises `Ecto.NoResultsError` if the Shot group does not exist.
## Examples ## Examples
iex> get_shot_record!(123, %User{id: 123}) iex> get_shot_group!(123, %User{id: 123})
%ShotRecord{} %ShotGroup{}
iex> get_shot_record!(456, %User{id: 123}) iex> get_shot_group!(456, %User{id: 123})
** (Ecto.NoResultsError) ** (Ecto.NoResultsError)
""" """
@spec get_shot_record!(ShotRecord.id(), User.t()) :: ShotRecord.t() @spec get_shot_group!(ShotGroup.id(), User.t()) :: ShotGroup.t()
def get_shot_record!(id, %User{id: user_id}) do def get_shot_group!(id, %User{id: user_id}) do
Repo.one!( Repo.one!(
from sr in ShotRecord, from sg in ShotGroup,
where: sr.id == ^id, where: sg.id == ^id,
where: sr.user_id == ^user_id, where: sg.user_id == ^user_id,
order_by: sr.date order_by: sg.date
) )
end end
@doc """ @doc """
Creates a shot_record. Creates a shot_group.
## Examples ## Examples
iex> create_shot_record(%{field: value}, %User{id: 123}) iex> create_shot_group(%{field: value}, %User{id: 123})
{:ok, %ShotRecord{}} {:ok, %ShotGroup{}}
iex> create_shot_record(%{field: bad_value}, %User{id: 123}) iex> create_shot_group(%{field: bad_value}, %User{id: 123})
{:error, %Ecto.Changeset{}} {:error, %Ecto.Changeset{}}
""" """
@spec create_shot_record(attrs :: map(), User.t(), Pack.t()) :: @spec create_shot_group(attrs :: map(), User.t(), AmmoGroup.t()) ::
{:ok, ShotRecord.t()} | {:error, ShotRecord.changeset() | nil} {:ok, ShotGroup.t()} | {:error, Changeset.t(ShotGroup.t()) | nil}
def create_shot_record(attrs, user, pack) do def create_shot_group(
Multi.new() attrs,
|> Multi.insert( %User{id: user_id},
:create_shot_record, %AmmoGroup{id: ammo_group_id, count: ammo_group_count, user_id: user_id} = ammo_group
%ShotRecord{} |> ShotRecord.create_changeset(user, pack, attrs) ) do
) attrs = attrs |> Map.merge(%{"user_id" => user_id, "ammo_group_id" => ammo_group_id})
|> Multi.run( changeset = %ShotGroup{} |> ShotGroup.create_changeset(attrs)
:pack, shot_group_count = changeset |> Changeset.get_field(:count)
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} if shot_group_count > ammo_group_count do
end 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( |> Multi.update(
:update_pack, :update_ammo_group,
fn %{create_shot_record: %{count: shot_record_count}, pack: %{count: pack_count}} -> ammo_group |> AmmoGroup.range_changeset(%{"count" => ammo_group_count - shot_group_count})
pack |> Pack.range_changeset(%{"count" => pack_count - shot_record_count})
end
) )
|> Repo.transaction() |> Repo.transaction()
|> case do |> case do
{:ok, %{create_shot_record: shot_record}} -> {:ok, shot_record} {:ok, %{create_shot_group: shot_group}} -> {:ok, shot_group}
{:error, :create_shot_record, changeset, _changes_so_far} -> {:error, changeset} {:error, :create_shot_group, changeset, _changes_so_far} -> {:error, changeset}
{:error, _other_transaction, _value, _changes_so_far} -> {:error, nil} {:error, _other_transaction, _value, _changes_so_far} -> {:error, nil}
end end
end end
end
@doc """ @doc """
Updates a shot_record. Updates a shot_group.
## Examples ## Examples
iex> update_shot_record(shot_record, %{field: new_value}, %User{id: 123}) iex> update_shot_group(shot_group, %{field: new_value}, %User{id: 123})
{:ok, %ShotRecord{}} {:ok, %ShotGroup{}}
iex> update_shot_record(shot_record, %{field: bad_value}, %User{id: 123}) iex> update_shot_group(shot_group, %{field: bad_value}, %User{id: 123})
{:error, %Ecto.Changeset{}} {:error, %Ecto.Changeset{}}
""" """
@spec update_shot_record(ShotRecord.t(), attrs :: map(), User.t()) :: @spec update_shot_group(ShotGroup.t(), attrs :: map(), User.t()) ::
{:ok, ShotRecord.t()} | {:error, ShotRecord.changeset() | nil} {:ok, ShotGroup.t()} | {:error, Changeset.t(ShotGroup.t()) | nil}
def update_shot_record( def update_shot_group(
%ShotRecord{count: count, user_id: user_id} = shot_record, %ShotGroup{count: count, user_id: user_id, ammo_group_id: ammo_group_id} = shot_group,
attrs, attrs,
%User{id: user_id} = user %User{id: user_id} = user
) do ) 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.new()
|> Multi.update(:update_shot_group, changeset)
|> Multi.update( |> Multi.update(
:update_shot_record, :update_ammo_group,
shot_record |> ShotRecord.update_changeset(user, attrs) ammo_group
) |> AmmoGroup.range_changeset(%{"count" => ammo_group_count - shot_diff_to_add})
|> 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() |> Repo.transaction()
|> case do |> case do
{:ok, %{update_shot_record: shot_record}} -> {:ok, shot_record} {:ok, %{update_shot_group: shot_group}} -> {:ok, shot_group}
{:error, :update_shot_record, changeset, _changes_so_far} -> {:error, changeset} {:error, :update_shot_group, changeset, _changes_so_far} -> {:error, changeset}
{:error, _other_transaction, _value, _changes_so_far} -> {:error, nil} {:error, _other_transaction, _value, _changes_so_far} -> {:error, nil}
end end
end end
end
@doc """ @doc """
Deletes a shot_record. Deletes a shot_group.
## Examples ## Examples
iex> delete_shot_record(shot_record, %User{id: 123}) iex> delete_shot_group(shot_group, %User{id: 123})
{:ok, %ShotRecord{}} {:ok, %ShotGroup{}}
iex> delete_shot_record(shot_record, %User{id: 123}) iex> delete_shot_group(shot_group, %User{id: 123})
{:error, %Ecto.Changeset{}} {:error, %Ecto.Changeset{}}
""" """
@spec delete_shot_record(ShotRecord.t(), User.t()) :: @spec delete_shot_group(ShotGroup.t(), User.t()) ::
{:ok, ShotRecord.t()} | {:error, ShotRecord.changeset()} {:ok, ShotGroup.t()} | {:error, Changeset.t(ShotGroup.t())}
def delete_shot_record( def delete_shot_group(
%ShotRecord{user_id: user_id} = shot_record, %ShotGroup{count: count, user_id: user_id, ammo_group_id: ammo_group_id} = shot_group,
%User{id: user_id} %User{id: user_id} = user
) do ) do
%{count: ammo_group_count, user_id: ^user_id} =
ammo_group = ammo_group_id |> Ammo.get_ammo_group!(user)
Multi.new() Multi.new()
|> Multi.delete(:delete_shot_record, shot_record) |> Multi.delete(:delete_shot_group, shot_group)
|> 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( |> Multi.update(
:update_pack, :update_ammo_group,
fn %{ ammo_group
delete_shot_record: %{count: count}, |> AmmoGroup.range_changeset(%{"count" => ammo_group_count + count})
pack: %{count: pack_count} = pack
} ->
new_pack_count = pack_count + count
pack |> Pack.range_changeset(%{"count" => new_pack_count})
end
) )
|> Repo.transaction() |> Repo.transaction()
|> case do |> case do
{:ok, %{delete_shot_record: shot_record}} -> {:ok, shot_record} {:ok, %{delete_shot_group: shot_group}} -> {:ok, shot_group}
{:error, :delete_shot_record, changeset, _changes_so_far} -> {:error, changeset} {:error, :delete_shot_group, changeset, _changes_so_far} -> {:error, changeset}
{:error, _other_transaction, _value, _changes_so_far} -> {:error, nil} {:error, _other_transaction, _value, _changes_so_far} -> {:error, nil}
end end
end end
@doc """ @doc """
Returns the last entered shot record date for a pack Returns an `%Ecto.Changeset{}` for tracking shot_group changes.
"""
@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 ## Examples
iex> get_used_count(%User{id: 123}, type_id: 123) iex> change_shot_group(shot_group)
35 %Ecto.Changeset{data: %ShotGroup{}}
iex> get_used_count(%User{id: 123}, pack_id: 456)
50
""" """
@spec get_used_count(User.t(), get_used_count_options()) :: non_neg_integer() @spec change_shot_group(ShotGroup.t() | ShotGroup.new_shot_group()) ::
def get_used_count(%User{id: user_id}, opts) do Changeset.t(ShotGroup.t() | ShotGroup.new_shot_group())
from(sr in ShotRecord, @spec change_shot_group(ShotGroup.t() | ShotGroup.new_shot_group(), attrs :: map()) ::
as: :sr, Changeset.t(ShotGroup.t() | ShotGroup.new_shot_group())
left_join: p in Pack, def change_shot_group(%ShotGroup{} = shot_group, attrs \\ %{}) do
on: sr.pack_id == p.id, shot_group |> ShotGroup.update_changeset(attrs)
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 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 end

View File

@ -0,0 +1,57 @@
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

View File

@ -1,123 +0,0 @@
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()
end
@type t :: %__MODULE__{
id: id(),
count: integer,
notes: String.t() | nil,
date: Date.t() | nil,
pack_id: Pack.id(),
user_id: User.id(),
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
@type new_shot_record :: %__MODULE__{}
@type id :: UUID.t()
@type changeset :: Changeset.t(t() | new_shot_record())
@doc false
@spec create_changeset(
new_shot_record(),
User.t() | any(),
Pack.t() | any(),
attrs :: map()
) :: changeset()
def create_changeset(
shot_record,
%User{id: user_id},
%Pack{id: pack_id, user_id: user_id} = pack,
attrs
) do
shot_record
|> change(user_id: user_id)
|> change(pack_id: pack_id)
|> cast(attrs, [:count, :notes, :date])
|> validate_length(:notes, max: 255)
|> validate_create_shot_record_count(pack)
|> validate_required([:date, :pack_id, :user_id])
end
def create_changeset(shot_record, _invalid_user, _invalid_pack, attrs) do
shot_record
|> cast(attrs, [:count, :notes, :date])
|> validate_length(:notes, max: 255)
|> validate_required([:pack_id, :user_id])
|> add_error(:invalid, dgettext("errors", "Please select a valid user and ammo pack"))
end
defp validate_create_shot_record_count(changeset, %Pack{count: pack_count}) do
case changeset |> Changeset.get_field(:count) do
nil ->
changeset |> Changeset.add_error(:ammo_left, dgettext("errors", "can't be blank"))
count when count > pack_count ->
changeset
|> Changeset.add_error(:ammo_left, dgettext("errors", "Ammo left must be at least 0"))
count when count <= 0 ->
error =
dgettext("errors", "Ammo left can be at most %{count} rounds", count: pack_count - 1)
changeset |> Changeset.add_error(:ammo_left, error)
_valid_count ->
changeset
end
end
@doc false
@spec update_changeset(t() | new_shot_record(), User.t(), attrs :: map()) :: changeset()
def update_changeset(%__MODULE__{} = shot_record, user, attrs) do
shot_record
|> cast(attrs, [:count, :notes, :date])
|> validate_length(:notes, max: 255)
|> validate_number(:count, greater_than: 0)
|> validate_required([:count, :date])
|> validate_update_shot_record_count(shot_record, user)
end
defp validate_update_shot_record_count(
changeset,
%__MODULE__{pack_id: pack_id, count: count},
user
) do
%{count: pack_count} = Ammo.get_pack!(pack_id, user)
new_shot_record_count = changeset |> Changeset.get_field(:count)
shot_diff_to_add = new_shot_record_count - count
if shot_diff_to_add > pack_count do
error = dgettext("errors", "Count can be at most %{count} shots", count: pack_count + count)
changeset |> Changeset.add_error(:count, error)
else
changeset
end
end
end

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,80 @@
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

View File

@ -0,0 +1,123 @@
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

View File

@ -1,146 +0,0 @@
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: [
:id,
:count,
:notes,
:price_paid,
:lot_number,
:staged,
:type_id,
:container_id
]}
schema "packs" do
field :count, :integer
field :notes, :string
field :price_paid, :float
field :staged, :boolean, default: false
field :lot_number, :string
field :purchased_on, :date
belongs_to :type, Type
field :container_id, :binary_id
field :user_id, :binary_id
timestamps()
end
@type t :: %__MODULE__{
id: id(),
count: integer,
notes: String.t() | nil,
price_paid: float() | nil,
staged: boolean(),
lot_number: String.t() | nil,
purchased_on: Date.t(),
type: Type.t() | nil,
type_id: Type.id(),
container_id: Container.id(),
user_id: User.id(),
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
@type new_pack :: %__MODULE__{}
@type id :: UUID.t()
@type changeset :: Changeset.t(t() | new_pack())
@doc false
@spec create_changeset(
new_pack(),
Type.t() | nil,
Container.t() | nil,
User.t(),
attrs :: map()
) :: changeset()
def create_changeset(
pack,
type,
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, :staged])
|> 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([:count, :staged, :purchased_on, :type_id, :container_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, [
:count,
:price_paid,
:notes,
:staged,
:purchased_on,
:lot_number,
:container_id
])
|> validate_number(:count, greater_than_or_equal_to: 0)
|> validate_number(:price_paid, greater_than_or_equal_to: 0)
|> validate_container_id(user)
|> validate_length(:lot_number, max: 255)
|> validate_required([:count, :staged, :purchased_on, :container_id])
end
defp validate_container_id(changeset, user) do
container_id = changeset |> Changeset.get_field(:container_id)
if container_id do
Containers.get_container!(container_id, user)
end
changeset
end
@doc """
This range changeset is used when "using up" packs, and allows for
updating the count to 0
"""
@spec range_changeset(t() | new_pack(), attrs :: map()) :: changeset()
def range_changeset(pack, attrs) do
pack
|> cast(attrs, [:count, :staged])
|> validate_required([:count, :staged])
end
end

View File

@ -1,229 +0,0 @@
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()
end
@type t :: %__MODULE__{
id: id(),
name: String.t(),
desc: String.t() | nil,
class: class(),
bullet_type: String.t() | nil,
bullet_core: String.t() | nil,
caliber: String.t() | nil,
case_material: String.t() | nil,
powder_type: String.t() | nil,
grains: integer() | nil,
pressure: String.t() | nil,
primer_type: String.t() | nil,
firing_type: String.t() | nil,
manufacturer: String.t() | nil,
upc: String.t() | nil,
tracer: boolean(),
incendiary: boolean(),
blank: boolean(),
corrosive: boolean(),
cartridge: String.t() | nil,
jacket_type: String.t() | nil,
powder_grains_per_charge: integer() | nil,
muzzle_velocity: integer() | nil,
wadding: String.t() | nil,
shot_type: String.t() | nil,
shot_material: String.t() | nil,
shot_size: String.t() | nil,
unfired_length: String.t() | nil,
brass_height: String.t() | nil,
chamber_size: String.t() | nil,
load_grains: integer() | nil,
shot_charge_weight: String.t() | nil,
dram_equivalent: String.t() | nil,
user_id: User.id(),
packs: [Pack.t()] | nil,
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
@type new_type :: %__MODULE__{}
@type id :: UUID.t()
@type changeset :: Changeset.t(t() | new_type())
@type class :: :rifle | :shotgun | :pistol | nil
@spec changeset_fields() :: [atom()]
defp changeset_fields,
do: [
:name,
:desc,
:class,
:bullet_type,
:bullet_core,
:caliber,
:case_material,
:powder_type,
:grains,
:pressure,
:primer_type,
:firing_type,
:manufacturer,
:upc,
:tracer,
:incendiary,
:blank,
:corrosive,
:cartridge,
:jacket_type,
:powder_grains_per_charge,
:muzzle_velocity,
:wadding,
:shot_type,
:shot_material,
:shot_size,
:unfired_length,
:brass_height,
:chamber_size,
:load_grains,
:shot_charge_weight,
:dram_equivalent
]
@spec string_fields() :: [atom()]
defp string_fields,
do: [
:name,
:desc,
:bullet_type,
:bullet_core,
:caliber,
:case_material,
:powder_type,
:pressure,
:primer_type,
:firing_type,
:manufacturer,
:upc,
:cartridge,
:jacket_type,
:wadding,
:shot_type,
:shot_material,
:shot_size,
:unfired_length,
:brass_height,
:chamber_size,
:shot_charge_weight,
:dram_equivalent
]
@doc false
@spec create_changeset(new_type(), User.t(), attrs :: map()) :: changeset()
def create_changeset(type, %User{id: user_id}, attrs) do
changeset =
type
|> change(user_id: user_id)
|> cast(attrs, changeset_fields())
string_fields()
|> Enum.reduce(changeset, fn field, acc -> acc |> validate_length(field, max: 255) end)
|> validate_required([:name, :user_id])
end
@doc false
@spec update_changeset(t() | new_type(), attrs :: map()) :: changeset()
def update_changeset(type, attrs) do
changeset =
type
|> cast(attrs, changeset_fields())
string_fields()
|> Enum.reduce(changeset, fn field, acc -> acc |> validate_length(field, max: 255) end)
|> validate_required(:name)
end
end

View File

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

View File

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

View File

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

View File

@ -3,14 +3,11 @@ defmodule Cannery.Containers do
The Containers context. The Containers context.
""" """
use Cannery, :context import CanneryWeb.Gettext
alias Cannery.Ammo.Pack import Ecto.Query, warn: false
alias Cannery.Containers.{Container, ContainerTag, Tag} alias Cannery.{Accounts.User, Ammo.AmmoGroup, Repo, Tags.Tag}
alias Cannery.Containers.{Container, ContainerTag}
@container_preloads [:tags] alias Ecto.Changeset
@type list_containers_option :: {:search, String.t() | nil}
@type list_containers_options :: [list_containers_option()]
@doc """ @doc """
Returns the list of containers. Returns the list of containers.
@ -20,81 +17,15 @@ defmodule Cannery.Containers do
iex> list_containers(%User{id: 123}) iex> list_containers(%User{id: 123})
[%Container{}, ...] [%Container{}, ...]
iex> list_containers(%User{id: 123}, search: "cool")
[%Container{name: "my cool container"}, ...]
""" """
@spec list_containers(User.t()) :: [Container.t()] @spec list_containers(User.t()) :: [Container.t()]
@spec list_containers(User.t(), list_containers_options()) :: [Container.t()] def list_containers(%User{id: user_id}),
def list_containers(%User{id: user_id}, opts \\ []) do do: Repo.all(from c in Container, where: c.user_id == ^user_id, order_by: c.name)
from(c in Container,
as: :c,
left_join: t in assoc(c, :tags),
on: c.user_id == t.user_id,
as: :t,
where: c.user_id == ^user_id,
distinct: c.id,
preload: ^@container_preloads
)
|> list_containers_search(Keyword.get(opts, :search))
|> Repo.all()
end
@spec list_containers_search(Queryable.t(), search :: String.t() | nil) :: Queryable.t()
defp list_containers_search(query, search) when search in ["", nil],
do: query |> order_by([c: c], c.name)
defp list_containers_search(query, search) when search |> is_binary() do
trimmed_search = String.trim(search)
query
|> where(
[c: c, t: t],
fragment(
"? @@ websearch_to_tsquery('english', ?)",
c.search,
^trimmed_search
) or
fragment(
"? @@ websearch_to_tsquery('english', ?)",
t.search,
^trimmed_search
)
)
|> order_by(
[c: c],
desc:
fragment(
"ts_rank_cd(?, websearch_to_tsquery('english', ?), 4)",
c.search,
^trimmed_search
)
)
end
@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,
where: c.user_id == ^user_id,
select: count(c.id),
distinct: true
)
end
@doc """ @doc """
Gets a single container. Gets a single container.
Raises `KeyError` if the Container does not exist. Raises `Ecto.NoResultsError` if the Container does not exist.
## Examples ## Examples
@ -102,38 +33,12 @@ defmodule Cannery.Containers do
%Container{} %Container{}
iex> get_container!(456, %User{id: 123}) iex> get_container!(456, %User{id: 123})
** (KeyError) ** (Ecto.NoResultsError)
""" """
@spec get_container!(Container.id(), User.t()) :: Container.t() @spec get_container!(Container.id(), User.t()) :: Container.t()
def get_container!(id, user) do def get_container!(id, %User{id: user_id}),
[id] do: Repo.one!(from c in Container, where: c.id == ^id and c.user_id == ^user_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,
where: c.user_id == ^user_id,
where: c.id in ^ids,
order_by: c.name,
preload: ^@container_preloads,
select: {c.id, c}
)
|> Map.new()
end
@doc """ @doc """
Creates a container. Creates a container.
@ -148,21 +53,10 @@ defmodule Cannery.Containers do
""" """
@spec create_container(attrs :: map(), User.t()) :: @spec create_container(attrs :: map(), User.t()) ::
{:ok, Container.t()} | {:error, Container.changeset()} {:ok, Container.t()} | {:error, Changeset.t(Container.new_container())}
def create_container(attrs, %User{} = user) do def create_container(attrs, %User{id: user_id}) do
%Container{} attrs = attrs |> Map.put("user_id", user_id)
|> Container.create_changeset(user, attrs) %Container{} |> Container.create_changeset(attrs) |> Repo.insert()
|> 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 end
@doc """ @doc """
@ -178,15 +72,9 @@ defmodule Cannery.Containers do
""" """
@spec update_container(Container.t(), User.t(), attrs :: map()) :: @spec update_container(Container.t(), User.t(), attrs :: map()) ::
{:ok, Container.t()} | {:error, Container.changeset()} {:ok, Container.t()} | {:error, Changeset.t(Container.t())}
def update_container(%Container{user_id: user_id} = container, %User{id: user_id}, attrs) do def update_container(%Container{user_id: user_id} = container, %User{id: user_id}, attrs) do
container container |> Container.update_changeset(attrs) |> Repo.update()
|> Container.update_changeset(attrs)
|> Repo.update()
|> case do
{:ok, container} -> {:ok, container |> preload_container()}
{:error, changeset} -> {:error, changeset}
end
end end
@doc """ @doc """
@ -202,28 +90,23 @@ defmodule Cannery.Containers do
""" """
@spec delete_container(Container.t(), User.t()) :: @spec delete_container(Container.t(), User.t()) ::
{:ok, Container.t()} | {:error, Container.changeset()} {:ok, Container.t()} | {:error, Changeset.t(Container.t())}
def delete_container(%Container{user_id: user_id} = container, %User{id: user_id}) do def delete_container(%Container{user_id: user_id} = container, %User{id: user_id}) do
Repo.one( Repo.one(
from p in Pack, from ag in AmmoGroup,
where: p.container_id == ^container.id, where: ag.container_id == ^container.id,
select: count(p.id) select: count(ag.id)
) )
|> case do |> case do
0 -> 0 ->
container container |> Repo.delete()
|> Repo.delete()
|> case do
{:ok, container} -> {:ok, container |> preload_container()}
{:error, changeset} -> {:error, changeset}
end
_amount -> _amount ->
error = dgettext("errors", "Container must be empty before deleting") error = dgettext("errors", "Container must be empty before deleting")
container container
|> Container.update_changeset(%{}) |> change_container()
|> Changeset.add_error(:packs, error) |> Changeset.add_error(:ammo_groups, error)
|> Changeset.apply_action(:delete) |> Changeset.apply_action(:delete)
end end
end end
@ -243,6 +126,25 @@ defmodule Cannery.Containers do
container container
end 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 """ @doc """
Adds a tag to a container Adds a tag to a container
@ -254,12 +156,12 @@ defmodule Cannery.Containers do
""" """
@spec add_tag!(Container.t(), Tag.t(), User.t()) :: ContainerTag.t() @spec add_tag!(Container.t(), Tag.t(), User.t()) :: ContainerTag.t()
def add_tag!( def add_tag!(
%Container{user_id: user_id} = container, %Container{id: container_id, user_id: user_id},
%Tag{user_id: user_id} = tag, %Tag{id: tag_id, user_id: user_id},
%User{id: user_id} %User{id: user_id}
) do ) do
%ContainerTag{} %ContainerTag{}
|> ContainerTag.create_changeset(tag, container) |> ContainerTag.changeset(%{"container_id" => container_id, "tag_id" => tag_id})
|> Repo.insert!() |> Repo.insert!()
end end
@ -272,182 +174,19 @@ defmodule Cannery.Containers do
%Container{} %Container{}
""" """
@spec remove_tag!(Container.t(), Tag.t(), User.t()) :: {non_neg_integer(), [ContainerTag.t()]} @spec remove_tag!(Container.t(), Tag.t(), User.t()) :: non_neg_integer()
def remove_tag!( def remove_tag!(
%Container{id: container_id, user_id: user_id}, %Container{id: container_id, user_id: user_id},
%Tag{id: tag_id, user_id: user_id}, %Tag{id: tag_id, user_id: user_id},
%User{id: user_id} %User{id: user_id}
) do ) do
{count, results} = {count, _} =
Repo.delete_all( Repo.delete_all(
from ct in ContainerTag, from ct in ContainerTag,
where: ct.container_id == ^container_id, 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, results} if count == 0, do: raise("could not delete container tag"), else: count
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 """
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_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
end end

View File

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

View File

@ -1,12 +1,16 @@
defmodule Cannery.Containers.ContainerTag do defmodule Cannery.Containers.ContainerTag do
@moduledoc """ @moduledoc """
Thru-table struct for associating Cannery.Containers.Container and Thru-table struct for associating Cannery.Containers.Container and
Cannery.Containers.Tag. Cannery.Tags.Tag.
""" """
use Cannery, :schema use Ecto.Schema
alias Cannery.Containers.{Container, Tag} import Ecto.Changeset
alias Cannery.{Containers.Container, Containers.ContainerTag, Tags.Tag}
alias Ecto.{Changeset, UUID}
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "container_tags" do schema "container_tags" do
belongs_to :container, Container belongs_to :container, Container
belongs_to :tag, Tag belongs_to :tag, Tag
@ -14,7 +18,7 @@ defmodule Cannery.Containers.ContainerTag do
timestamps() timestamps()
end end
@type t :: %__MODULE__{ @type t :: %ContainerTag{
id: id(), id: id(),
container: Container.t(), container: Container.t(),
container_id: Container.id(), container_id: Container.id(),
@ -23,20 +27,14 @@ defmodule Cannery.Containers.ContainerTag do
inserted_at: NaiveDateTime.t(), inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t() updated_at: NaiveDateTime.t()
} }
@type new_container_tag :: %__MODULE__{} @type new_container_tag :: %ContainerTag{}
@type id :: UUID.t() @type id :: UUID.t()
@type changeset :: Changeset.t(t() | new_container_tag())
@doc false @doc false
@spec create_changeset(new_container_tag(), Tag.t(), Container.t()) :: changeset() @spec changeset(new_container_tag(), attrs :: map()) :: Changeset.t(new_container_tag())
def create_changeset( def changeset(container_tag, attrs) do
container_tag,
%Tag{id: tag_id, user_id: user_id},
%Container{id: container_id, user_id: user_id}
) do
container_tag container_tag
|> change(tag_id: tag_id) |> cast(attrs, [:tag_id, :container_id])
|> change(container_id: container_id)
|> validate_required([:tag_id, :container_id]) |> validate_required([:tag_id, :container_id])
end end
end end

View File

@ -1,61 +0,0 @@
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()
end
@type t :: %__MODULE__{
id: id(),
name: String.t(),
bg_color: String.t(),
text_color: String.t(),
user_id: User.id(),
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.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

View File

@ -1,61 +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/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

173
lib/cannery/invites.ex Normal file
View File

@ -0,0 +1,173 @@
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

View File

@ -1,60 +1,57 @@
defmodule Cannery.Accounts.Invite do defmodule Cannery.Invites.Invite do
@moduledoc """ @moduledoc """
An invite, created by an admin to allow someone to join their instance. An 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 invite can be enabled or disabled, and can have an optional number of uses if
`:uses_left` is defined. `:uses_left` is defined.
""" """
use Cannery, :schema 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 schema "invites" do
field :name, :string field :name, :string
field :token, :string field :token, :string
field :uses_left, :integer, default: nil field :uses_left, :integer, default: nil
field :disabled_at, :naive_datetime field :disabled_at, :naive_datetime
belongs_to :created_by, User belongs_to :user, User
has_many :users, User
timestamps() timestamps()
end end
@type t :: %__MODULE__{ @type t :: %Invite{
id: id(), id: id(),
name: String.t(), name: String.t(),
token: token(), token: String.t(),
uses_left: integer() | nil, uses_left: integer() | nil,
disabled_at: NaiveDateTime.t(), disabled_at: NaiveDateTime.t(),
created_by: User.t() | nil | Association.NotLoaded.t(), user: User.t(),
created_by_id: User.id() | nil, user_id: User.id(),
users: [User.t()] | Association.NotLoaded.t(),
inserted_at: NaiveDateTime.t(), inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t() updated_at: NaiveDateTime.t()
} }
@type new_invite :: %__MODULE__{} @type new_invite :: %Invite{}
@type id :: UUID.t() @type id :: UUID.t()
@type changeset :: Changeset.t(t() | new_invite())
@type token :: String.t()
@doc false @doc false
@spec create_changeset(User.t(), token(), attrs :: map()) :: changeset() @spec create_changeset(new_invite(), attrs :: map()) :: Changeset.t(new_invite())
def create_changeset(%User{id: user_id}, token, attrs) do def create_changeset(invite, attrs) do
%__MODULE__{} invite
|> change(token: token, created_by_id: user_id) |> cast(attrs, [:name, :token, :uses_left, :disabled_at, :user_id])
|> cast(attrs, [:name, :uses_left, :disabled_at]) |> validate_required([:name, :token, :user_id])
|> validate_length(:name, max: 255)
|> validate_number(:uses_left, greater_than_or_equal_to: 0) |> validate_number(:uses_left, greater_than_or_equal_to: 0)
|> validate_required([:name, :token, :created_by_id])
end end
@doc false @doc false
@spec update_changeset(t() | new_invite(), attrs :: map()) :: changeset() @spec update_changeset(t() | new_invite(), attrs :: map()) :: Changeset.t(t() | new_invite())
def update_changeset(invite, attrs) do def update_changeset(invite, attrs) do
invite invite
|> cast(attrs, [:name, :uses_left, :disabled_at]) |> cast(attrs, [:name, :uses_left, :disabled_at])
|> validate_length(:name, max: 255) |> validate_required([:name, :token, :user_id])
|> validate_number(:uses_left, greater_than_or_equal_to: 0) |> validate_number(:uses_left, greater_than_or_equal_to: 0)
|> validate_required([:name])
end end
end end

View File

@ -1,63 +0,0 @@
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

View File

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

View File

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

151
lib/cannery/tags.ex Normal file
View File

@ -0,0 +1,151 @@
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

52
lib/cannery/tags/tag.ex Normal file
View File

@ -0,0 +1,52 @@
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

View File

@ -1,29 +1,76 @@
defmodule CanneryWeb do defmodule CanneryWeb do
@moduledoc """ @moduledoc """
The entrypoint for defining your web interface, such The entrypoint for defining your web interface, such
as controllers, components, channels, and so on. as controllers, views, channels and so on.
This can be used in your application as: This can be used in your application as:
use CanneryWeb, :controller use CanneryWeb, :controller
use CanneryWeb, :html use CanneryWeb, :view
The definitions below will be executed for every controller, The definitions below will be executed for every view,
component, etc, so keep them short and clean, focused controller, etc, so keep them short and clean, focused
on imports, uses and aliases. on imports, uses and aliases.
Do NOT define functions inside the quoted expressions Do NOT define functions inside the quoted expressions
below. Instead, define additional modules and import below. Instead, define any helper function in modules
those modules here. and import those modules here.
""" """
def static_paths, do: ~w(css js fonts images favicon.ico robots.txt) 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 router do def router do
quote do quote do
use Phoenix.Router, helpers: false use Phoenix.Router
# Import common connection and controller functions to use in pipelines
import Plug.Conn import Plug.Conn
import Phoenix.Controller import Phoenix.Controller
import Phoenix.LiveView.Router import Phoenix.LiveView.Router
@ -33,76 +80,23 @@ defmodule CanneryWeb do
def channel do def channel do
quote do quote do
use Phoenix.Channel use Phoenix.Channel
import CanneryWeb.Gettext
end end
end end
def controller do defp view_helpers do
quote do quote do
use Phoenix.Controller, # Use all HTML functionality (forms, tags, etc)
formats: [:html, :json], use Phoenix.HTML
layouts: [html: CanneryWeb.Layouts]
use Gettext, backend: CanneryWeb.Gettext # Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc)
import Phoenix.LiveView.Helpers
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse # Import basic rendering functionality (render, render_layout, etc)
import Plug.Conn import Phoenix.View
unquote(verified_routes()) import CanneryWeb.{ErrorHelpers, Gettext, LiveHelpers, ViewHelpers}
end alias CanneryWeb.Router.Helpers, as: Routes
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
end end

View File

@ -0,0 +1,90 @@
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

View File

@ -0,0 +1,47 @@
<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 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>

View File

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

View File

@ -1,62 +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-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>

View File

@ -0,0 +1,58 @@
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

View File

@ -0,0 +1,53 @@
defmodule CanneryWeb.Components.ContainerCard do
@moduledoc """
Display card for a container
"""
use CanneryWeb, :component
alias CanneryWeb.Endpoint
def container_card(assigns) do
~H"""
<div
id={"container-#{@container.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"
>
<div class="mb-4 flex flex-col justify-center items-center">
<%= 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 %>
</div>
<%= if assigns |> Map.has_key?(:inner_block) do %>
<div class="flex space-x-4 justify-center items-center">
<%= render_slot(@inner_block) %>
</div>
<% end %>
</div>
"""
end
end

View File

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

View File

@ -1,150 +0,0 @@
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 naivedatetime in the
user's local timezone
"""
def datetime(assigns)
@spec cast_datetime(NaiveDateTime.t() | nil) :: String.t()
defp cast_datetime(%NaiveDateTime{} = datetime) do
datetime |> DateTime.from_naive!("Etc/UTC") |> DateTime.to_iso8601(:extended)
end
defp cast_datetime(_datetime), do: ""
end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,45 +0,0 @@
<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>

View File

@ -1,19 +0,0 @@
<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>

View File

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

View File

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

View File

@ -0,0 +1,57 @@
defmodule CanneryWeb.Components.MoveAmmoGroupComponent do
@moduledoc """
Livecomponent that can move an ammo group to another container
"""
use CanneryWeb, :live_component
alias Cannery.{Accounts.User, Ammo, Ammo.AmmoGroup, Containers}
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: %{container_id: container_id} = ammo_group, current_user: current_user} =
assigns,
socket
) do
changeset = Ammo.change_ammo_group(ammo_group)
containers =
Containers.list_containers(current_user)
|> Enum.reject(fn %{id: id} -> id == container_id end)
{:ok, socket |> assign(assigns) |> assign(changeset: changeset, containers: containers)}
end
@impl true
def handle_event(
"move",
%{"container_id" => container_id},
%{assigns: %{ammo_group: ammo_group, current_user: current_user, return_to: return_to}} =
socket
) do
%{name: container_name} = Containers.get_container!(container_id, current_user)
socket =
ammo_group
|> Ammo.update_ammo_group(%{"container_id" => container_id}, current_user)
|> case do
{:ok, _ammo_group} ->
prompt = dgettext("prompts", "Ammo moved to %{name} successfully", name: container_name)
socket |> put_flash(:info, prompt) |> push_redirect(to: return_to)
{:error, %Ecto.Changeset{} = changeset} ->
socket |> assign(changeset: changeset)
end
{:noreply, socket}
end
end

View File

@ -0,0 +1,70 @@
<div class="w-full flex flex-col space-y-8 justify-center items-center">
<h2 class="mb-8 text-center title text-xl text-primary-600">
<%= gettext("Move ammo") %>
</h2>
<%= if @containers |> Enum.empty?() do %>
<h2 class="title text-xl text-primary-600">
<%= gettext("No other containers") %>
<%= display_emoji("😔") %>
</h2>
<%= live_patch(dgettext("actions", "Add another container!"),
to: Routes.container_index_path(Endpoint, :new),
class: "btn btn-primary"
) %>
<% else %>
<div class="w-full overflow-x-auto border border-gray-600 rounded-lg shadow-lg bg-black">
<table class="min-w-full table-auto text-center bg-white">
<thead class="border-b border-primary-600">
<tr>
<th class="p-2">
<%= gettext("Container") %>
</th>
<th class="p-2">
<%= gettext("Type") %>
</th>
<th class="p-2">
<%= gettext("Location") %>
</th>
<th class="p-2"></th>
</tr>
</thead>
<tbody id="containers">
<%= for container <- @containers do %>
<tr id={"container-#{container.id}"}>
<td class="p-2">
<%= container.name %>
</td>
<td class="p-2">
<%= container.type %>
</td>
<td class="p-2">
<%= container.location %>
</td>
<td class="p-2">
<div class="px-4 py-2 space-x-4 flex justify-center items-center">
<button
type="button"
class="btn btn-primary"
phx-click="move"
phx-target={@myself}
phx-value-container_id={container.id}
>
<%= dgettext("actions", "Select") %>
</button>
</div>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
</div>

View File

@ -1,130 +0,0 @@
defmodule CanneryWeb.Components.MovePackComponent do
@moduledoc """
Livecomponent that can move a pack to another container
"""
use CanneryWeb, :live_component
alias Cannery.{Accounts.User, Ammo, Ammo.Pack, Containers, Containers.Container}
alias Ecto.Changeset
alias Phoenix.LiveView.Socket
@impl true
@spec update(
%{
required(:current_user) => User.t(),
required(:pack) => Pack.t(),
optional(any()) => any()
},
Socket.t()
) :: {:ok, Socket.t()}
def update(
%{pack: %{container_id: container_id} = pack, current_user: current_user} = assigns,
socket
) do
changeset = pack |> Pack.update_changeset(%{}, current_user)
containers =
Containers.list_containers(current_user)
|> Enum.reject(fn %{id: id} -> id == container_id end)
socket =
socket
|> assign(assigns)
|> assign(changeset: changeset, containers: containers)
{:ok, socket}
end
@impl true
def handle_event(
"move",
%{"container_id" => container_id},
%{assigns: %{pack: pack, current_user: current_user, return_to: return_to}} = socket
) do
%{name: container_name} = Containers.get_container!(container_id, current_user)
socket =
pack
|> Ammo.update_pack(%{"container_id" => container_id}, current_user)
|> case do
{:ok, _pack} ->
prompt = dgettext("prompts", "Ammo moved to %{name} successfully", name: container_name)
socket |> put_flash(:info, prompt) |> push_navigate(to: return_to)
{:error, %Changeset{} = changeset} ->
socket |> assign(changeset: changeset)
end
{:noreply, socket}
end
@impl true
def render(%{containers: containers} = assigns) do
columns = [
%{label: gettext("Container"), key: :name},
%{label: gettext("Type"), key: :type},
%{label: gettext("Location"), key: :location},
%{label: gettext("Actions"), key: :actions, sortable: false}
]
rows = containers |> get_rows_for_containers(assigns, columns)
assigns = assigns |> Map.merge(%{columns: columns, rows: rows})
~H"""
<div class="w-full flex flex-col space-y-8 justify-center items-center">
<h2 class="mb-8 text-center title text-xl text-primary-600">
<%= dgettext("actions", "Move ammo") %>
</h2>
<%= if @containers |> Enum.empty?() do %>
<h2 class="title text-xl text-primary-600">
<%= gettext("No other containers") %>
<%= display_emoji("😔") %>
</h2>
<.link navigate={~p"/containers/new"} class="btn btn-primary">
<%= dgettext("actions", "Add another container!") %>
</.link>
<% else %>
<.live_component
module={CanneryWeb.Components.TableComponent}
id="move-pack-table"
columns={@columns}
rows={@rows}
/>
<% end %>
</div>
"""
end
@spec get_rows_for_containers([Container.t()], map(), [map()]) :: [map()]
defp get_rows_for_containers(containers, assigns, columns) do
containers
|> Enum.map(fn container ->
columns
|> Map.new(fn %{key: key} -> {key, get_row_value_by_key(key, container, assigns)} end)
end)
end
@spec get_row_value_by_key(atom(), Container.t(), map()) :: any()
defp get_row_value_by_key(:actions, container, assigns) do
assigns = assigns |> Map.put(:container, container)
~H"""
<div class="px-4 py-2 space-x-4 flex justify-center items-center">
<button
type="button"
class="btn btn-primary"
phx-click="move"
phx-target={@myself}
phx-value-container_id={@container.id}
>
<%= dgettext("actions", "Select") %>
</button>
</div>
"""
end
defp get_row_value_by_key(key, container, _assigns), do: container |> Map.get(key)
end

View File

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

View File

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

View File

@ -1,159 +0,0 @@
defmodule CanneryWeb.Components.TableComponent do
@moduledoc """
Livecomponent that presents a resortable table
It takes the following required assigns:
- `:columns`: An array of maps containing the following keys
- `:label`: A gettext'd or otherwise user-facing string label for the
column. Can be nil
- `:key`: An atom key used for sorting
- `:class`: Extra classes to be applied to the column element, if desired.
Optional
- `:sortable`: If false, will prevent the user from sorting with it.
Optional
- `:values`: An array of maps containing data for each row. Each map is
string-keyed with the associated column key to the following values:
- A single element, like string, integer or Phoenix.LiveView.Rendered
object, like returned from the ~H sigil
- A tuple, containing a custom value used for sorting, and the displayed
content.
"""
use CanneryWeb, :live_component
alias Phoenix.LiveView.Socket
require Integer
@impl true
@spec update(
%{
required(:columns) =>
list(%{
required(:label) => String.t() | nil,
required(:key) => atom() | nil,
optional(:class) => String.t(),
optional(:row_class) => String.t(),
optional(:alternate_row_class) => String.t(),
optional(:sortable) => false,
optional(:type) => module()
}),
required(:rows) =>
list(%{
(key :: atom()) => any() | {custom_sort_value :: String.t(), value :: any()}
}),
optional(:inital_key) => atom(),
optional(:initial_sort_mode) => atom(),
optional(any()) => any()
},
Socket.t()
) :: {:ok, Socket.t()}
def update(%{columns: columns, rows: rows} = assigns, socket) do
initial_key =
if assigns |> Map.has_key?(:initial_key) do
assigns.initial_key
else
columns |> List.first(%{}) |> Map.get(:key)
end
initial_sort_mode =
if assigns |> Map.has_key?(:initial_sort_mode) do
assigns.initial_sort_mode
else
:asc
end
type = columns |> Enum.find(%{}, fn %{key: key} -> key == initial_key end) |> Map.get(:type)
rows = rows |> sort_by_custom_sort_value_or_value(initial_key, initial_sort_mode, type)
socket =
socket
|> assign(assigns)
|> assign(
columns: columns,
rows: rows,
key: initial_key,
last_sort_key: initial_key,
sort_mode: initial_sort_mode
)
|> assign_new(:row_class, fn -> "bg-white" end)
|> assign_new(:alternate_row_class, fn -> "bg-gray-200" end)
{:ok, socket}
end
@impl true
def handle_event(
"sort_by",
%{"sort-key" => key},
%{
assigns: %{
columns: columns,
rows: rows,
last_sort_key: last_sort_key,
sort_mode: sort_mode
}
} = socket
) do
key = key |> String.to_existing_atom()
sort_mode =
case {key, sort_mode} do
{^last_sort_key, :asc} -> :desc
{^last_sort_key, :desc} -> :asc
{_new_sort_key, _last_sort_mode} -> :asc
end
type =
columns |> Enum.find(%{}, fn %{key: column_key} -> column_key == key end) |> Map.get(:type)
rows = rows |> sort_by_custom_sort_value_or_value(key, sort_mode, type)
{:noreply, socket |> assign(last_sort_key: key, sort_mode: sort_mode, rows: rows)}
end
defp sort_by_custom_sort_value_or_value(rows, key, sort_mode, type)
when type in [Date, DateTime] do
rows
|> Enum.sort_by(
fn row ->
case row |> Map.get(key) do
{custom_sort_key, _value} -> custom_sort_key
value -> value
end
end,
{sort_mode, type}
)
end
defp sort_by_custom_sort_value_or_value(rows, key, sort_mode, _type) do
rows
|> Enum.sort_by(
fn row ->
case row |> Map.get(key) do
{custom_sort_key, _value} -> custom_sort_key
value -> value
end
end,
sort_mode
)
end
@doc """
Conditionally composes elements into the columns list, supports maps and
lists. Works tail to front in order for efficiency
iex> []
...> |> maybe_compose_columns(%{label: "Column 3"}, true)
...> |> maybe_compose_columns(%{label: "Column 2"}, false)
...> |> maybe_compose_columns(%{label: "Column 1"})
[%{label: "Column 1"}, %{label: "Column 3"}]
"""
@spec maybe_compose_columns(list(), element_to_add :: list() | map()) :: list()
@spec maybe_compose_columns(list(), element_to_add :: list() | map(), boolean()) :: list()
def maybe_compose_columns(columns, element_or_elements, add? \\ true)
def maybe_compose_columns(columns, elements, true) when is_list(elements),
do: Enum.concat(elements, columns)
def maybe_compose_columns(columns, element, true) when is_map(element), do: [element | columns]
def maybe_compose_columns(columns, _element_or_elements, false), do: columns
end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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