Compare commits

..

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

270 changed files with 23982 additions and 28594 deletions

View File

@ -13,39 +13,32 @@ steps:
mount:
- _build
- deps
- .npm
- .mix
- assets/node_modules/
- name: test
image: elixir:1.17.3-otp-27-alpine
image: elixir:1.14.1-alpine
environment:
TEST_DATABASE_URL: ecto://postgres:postgres@database/cannery_test
HOST: testing.example.tld
MIX_HOME: /drone/src/.mix
MIX_ARCHIVES: /drone/src/.mix/archives
MIX_ENV: test
commands:
- apk add --no-cache build-base npm git
- apk add --no-cache build-base npm git python3
- mix local.rebar --force
- mix local.hex --force
- mix deps.get
- npm set cache .npm
- npm --prefix ./assets ci --no-audit --prefer-offline
- mix deps.compile
- npm --prefix ./assets ci --progress=false --no-audit --loglevel=error
- npm run --prefix ./assets deploy
- mix do phx.digest, gettext.extract
- mix test.all
- name: build and publish stable
image: plugins/docker
image: thegeeklab/drone-docker-buildx
privileged: true
settings:
repo: shibaobun/cannery
purge: true
compress: true
platforms:
- linux/amd64
- linux/arm64
- linux/arm/v7
platforms: linux/amd64,linux/arm/v7
username:
from_secret: docker_username
password:
@ -56,16 +49,13 @@ steps:
- stable
- name: build and publish tagged version
image: plugins/docker
image: thegeeklab/drone-docker-buildx
privileged: true
settings:
repo: shibaobun/cannery
purge: true
compress: true
platforms:
- linux/amd64
- linux/arm64
- linux/arm/v7
platforms: linux/amd64,linux/arm/v7
username:
from_secret: docker_username
password:
@ -86,8 +76,7 @@ steps:
mount:
- _build
- deps
- .npm
- .mix
- assets/node_modules/
services:
- name: database

View File

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

2
.gitignore vendored
View File

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

View File

@ -1,3 +1,3 @@
elixir 1.17.3-otp-27
erlang 27.1.2
nodejs 23.0.0
elixir 1.14.1-otp-25
erlang 25.1.2
nodejs 18.12.1

View File

@ -1,111 +1,3 @@
# 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
@ -115,7 +7,7 @@
# 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
- Fix bug with shot group 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!)
@ -127,7 +19,7 @@
- Fix toggle button styling
- Miscellanous code improvements
- Improve container index table
- Fix bug with ammo not updating after deleting shot record
- Fix bug with ammo not updating after deleting shot group
- Replace ammo "added on" with "purchased on"
- Miscellaneous wording improvements
- Update translations
@ -136,8 +28,8 @@
- 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
- Make ammo type show page also display ammo groups as table
- Make container show page also display ammo groups as table
- Display CPR for ammo packs
- Add original count for ammo packs
- Add ammo pack CPR and original count to json export
@ -161,7 +53,7 @@
- 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
- Add button to set rounds left to 0 when creating a shot group
- Update project dependencies
# v0.5.4
@ -213,8 +105,8 @@
# v0.3.0
- Fix ammo type counts not showing when count is 0
- Add prompt to create first container before first ammo group
- Edit and delete shot records from ammo group show page
- Use today's date when adding new shot records
- Edit and delete shot groups from ammo group show page
- Use today's date when adding new shot groups
- Create multiple ammo groups at one time
# v0.2.3

View File

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

View File

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

View File

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

View File

@ -8,8 +8,6 @@ $fa-font-path: "@fortawesome/fontawesome-free/webfonts";
@import "@fortawesome/fontawesome-free/scss/solid";
@import "@fortawesome/fontawesome-free/scss/brands";
@import "slim-select/styles";
@import "components";
/* fix firefox scrollbars */
@ -154,57 +152,3 @@ $fa-font-path: "@fortawesome/fontawesome-free/webfonts";
0% { opacity: 1; }
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

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

View File

@ -24,18 +24,26 @@ import 'phoenix_html'
// Establish Phoenix Socket and LiveView configuration.
import { Socket } from 'phoenix'
import { LiveSocket } from 'phoenix_live_view'
import Date from './date'
import DateTime from './datetime'
import ShotLogChart from './shot_log_chart'
import SlimSelect from './slim_select'
import topbar from 'topbar'
import MaintainAttrs from './maintain_attrs'
import ShotLogChart from './shot_log_chart'
import Alpine from 'alpinejs'
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute('content')
const liveSocket = new LiveSocket('/live', Socket, {
dom: {
onBeforeElUpdated (from, to) {
if (from._x_dataStack) { window.Alpine.clone(from, to) }
}
},
params: { _csrf_token: csrfToken },
hooks: { Date, DateTime, ShotLogChart, SlimSelect }
hooks: { MaintainAttrs, ShotLogChart }
})
// alpine.js
window.Alpine = Alpine
Alpine.start()
// Show progress bar on live navigation and form submits
topbar.config({ barColors: { 0: '#29d' }, shadowColor: 'rgba(0, 0, 0, .3)' })
window.addEventListener('phx:page-loading-start', info => topbar.show())

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

View File

@ -1,12 +1,13 @@
import Chart from 'chart.js/auto'
import { Chart, Title, Tooltip, Legend, LineController, LineElement, PointElement, TimeScale, LinearScale } from 'chart.js'
import 'chartjs-adapter-date-fns'
Chart.register(Title, Tooltip, Legend, LineController, LineElement, PointElement, TimeScale, LinearScale)
export default {
initalizeChart (el) {
const data = JSON.parse(el.dataset.chartData)
this.el.chart = new Chart(el, {
type: 'bar',
type: 'line',
data: {
datasets: [{
label: el.dataset.label,
@ -50,17 +51,13 @@ export default {
stacked: true,
grace: '15%',
ticks: {
padding: 15,
precision: 0
padding: 15
}
},
x: {
type: 'timeseries',
type: 'time',
time: {
unit: 'day'
},
ticks: {
source: 'data'
}
}
},

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)
}
}

18068
assets/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,8 +3,8 @@
"description": " ",
"license": "MIT",
"engines": {
"node": "v23.0.0",
"npm": "10.9.0"
"node": "18.12.1",
"npm": "8.19.2"
},
"scripts": {
"deploy": "NODE_ENV=production webpack --mode production",
@ -13,39 +13,37 @@
"test": "standard"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^6.6.0",
"chart.js": "^4.4.5",
"chartjs-adapter-date-fns": "^3.0.0",
"date-fns": "^4.1.0",
"@fortawesome/fontawesome-free": "^6.1.1",
"alpinejs": "^3.10.2",
"chart.js": "^3.9.1",
"chartjs-adapter-date-fns": "^2.0.0",
"date-fns": "^2.29.3",
"phoenix": "file:../deps/phoenix",
"phoenix_html": "file:../deps/phoenix_html",
"phoenix_live_view": "file:../deps/phoenix_live_view",
"slim-select": "^2.9.2",
"topbar": "^3.0.0"
"topbar": "^1.0.1"
},
"devDependencies": {
"@babel/core": "^7.26.0",
"@babel/preset-env": "^7.26.0",
"autoprefixer": "^10.4.20",
"babel-loader": "^9.2.1",
"copy-webpack-plugin": "^12.0.2",
"css-loader": "^7.1.2",
"css-minimizer-webpack-plugin": "^7.0.0",
"@babel/core": "^7.17.10",
"@babel/preset-env": "^7.17.10",
"autoprefixer": "^10.4.7",
"babel-loader": "^8.2.5",
"copy-webpack-plugin": "^10.2.4",
"css-loader": "^6.7.1",
"css-minimizer-webpack-plugin": "^3.4.1",
"file-loader": "^6.2.0",
"glob": "^11.0.0",
"mini-css-extract-plugin": "^2.9.1",
"npm-check-updates": "^17.1.6",
"postcss": "^8.4.47",
"postcss-import": "^16.1.0",
"postcss-loader": "^8.1.1",
"postcss-preset-env": "^10.0.8",
"sass": "^1.80.4",
"sass-loader": "^16.0.2",
"standard": "^17.1.2",
"tailwindcss": "^3.4.14",
"terser-webpack-plugin": "^5.3.10",
"webpack": "^5.95.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^5.1.0"
"mini-css-extract-plugin": "^2.6.0",
"postcss": "^8.4.13",
"postcss-import": "^14.1.0",
"postcss-loader": "^6.2.1",
"postcss-preset-env": "^7.5.0",
"sass": "^1.56.0",
"sass-loader": "^12.6.0",
"standard": "^17.0.0",
"tailwindcss": "^3.0.24",
"terser-webpack-plugin": "^5.3.1",
"webpack": "^5.72.0",
"webpack-cli": "^4.9.2",
"webpack-dev-server": "^4.9.0"
}
}

View File

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

View File

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

View File

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

View File

@ -12,7 +12,7 @@ if System.get_env("PHX_SERVER") && System.get_env("RELEASE_NAME") do
config :cannery, CanneryWeb.Endpoint, server: true
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
config :gettext, :default_locale, System.get_env("LOCALE", "en_US")
@ -52,11 +52,8 @@ config :cannery, CanneryWeb.Endpoint,
ip: interface,
port: String.to_integer(System.get_env("PORT", "4000"))
],
server: true
if config_env() in [:dev, :prod] do
config :cannery, Cannery.Accounts, registration: System.get_env("REGISTRATION", "invite")
end
server: true,
registration: System.get_env("REGISTRATION", "invite")
if config_env() == :prod do
# The secret key base is used to sign/encrypt cookies and other secrets.

View File

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

View File

@ -6,34 +6,4 @@ defmodule Cannery do
Contexts are also responsible for managing your data, regardless
if it comes from the database, an external API or others.
"""
def context do
quote do
use Gettext, backend: CanneryWeb.Gettext
import Ecto.Query
alias Cannery.Accounts.User
alias Cannery.Repo
alias Ecto.{Changeset, Multi, Queryable, UUID}
end
end
def schema do
quote do
use Ecto.Schema
use Gettext, backend: CanneryWeb.Gettext
import Ecto.{Changeset, Query}
alias Cannery.Accounts.User
alias Ecto.{Association, Changeset, Queryable, UUID}
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
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

View File

@ -3,9 +3,10 @@ defmodule Cannery.Accounts do
The Accounts context.
"""
use Cannery, :context
alias Cannery.Mailer
alias Cannery.Accounts.{Invite, Invites, UserToken}
import Ecto.Query, warn: false
alias Cannery.{Mailer, Repo}
alias Cannery.Accounts.{User, UserToken}
alias Ecto.{Changeset, Multi}
alias Oban.Job
## Database getters
@ -23,16 +24,14 @@ defmodule Cannery.Accounts do
"""
@spec get_user_by_email(email :: String.t()) :: User.t() | nil
def get_user_by_email(email) when is_binary(email) do
Repo.get_by(User, email: email)
end
def get_user_by_email(email) when is_binary(email), do: Repo.get_by(User, email: email)
@doc """
Gets a user by email and password.
## 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{}
iex> get_user_by_email_and_password("foo@example.com", "invalid_password")
@ -54,30 +53,28 @@ defmodule Cannery.Accounts do
## Examples
iex> get_user!(user_id)
user
iex> get_user!(123)
%User{}
iex> get_user!()
iex> get_user!(456)
** (Ecto.NoResultsError)
"""
@spec get_user!(User.id()) :: User.t()
def get_user!(id) do
Repo.get!(User, id)
end
@spec get_user!(User.t()) :: User.t()
def get_user!(id), do: Repo.get!(User, id)
@doc """
Returns all users grouped by role.
## Examples
iex> list_all_users_by_role(user1)
%{admin: [%User{role: :admin}], user: [%User{role: :user}]}
iex> list_users_by_role(%User{id: 123, role: :admin})
[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
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
@doc """
@ -85,12 +82,13 @@ defmodule Cannery.Accounts do
## Examples
iex> list_users_by_role(:admin)
[%User{role: :admin}]
iex> list_users_by_role(%User{id: 123, role: :admin})
[%User{}]
"""
@spec list_users_by_role(:admin) :: [User.t()]
def list_users_by_role(:admin = role) do
@spec list_users_by_role(User.role()) :: [User.t()]
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)
end
@ -101,36 +99,26 @@ defmodule Cannery.Accounts do
## Examples
iex> register_user(%{email: "foo@example.com", password: "valid_password"})
{:ok, %User{email: "foo@example.com"}}
iex> register_user(%{field: value})
{:ok, %User{}}
iex> register_user(%{email: "foo@example"})
iex> register_user(%{field: bad_value})
{:error, %Changeset{}}
"""
@spec register_user(attrs :: map()) ::
{:ok, User.t()} | {:error, :invalid_token | User.changeset()}
@spec register_user(attrs :: map(), Invite.token() | nil) ::
{:ok, User.t()} | {:error, :invalid_token | User.changeset()}
def register_user(attrs, invite_token \\ nil) do
@spec register_user(attrs :: map()) :: {:ok, User.t()} | {:error, User.changeset()}
def register_user(attrs) 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} ->
|> Multi.insert(:add_user, fn %{users_count: count} ->
# if no registered users, make first user an admin
role = if count == 0, do: :admin, else: :user
User.registration_changeset(attrs, invite) |> User.role_changeset(role)
role = if count == 0, do: "admin", else: "user"
User.registration_changeset(attrs) |> User.role_changeset(role)
end)
|> Repo.transaction()
|> case do
{:ok, %{add_user: user}} -> {:ok, user}
{:error, :use_invite, :invalid_token, _changes_so_far} -> {:error, :invalid_token}
{:error, :add_user, changeset, _changes_so_far} -> {:error, changeset}
end
end
@ -140,18 +128,14 @@ defmodule Cannery.Accounts do
## Examples
iex> change_user_registration()
%Changeset{}
iex> change_user_registration(%{password: "hi"}
%Changeset{}
iex> change_user_registration(user)
%Changeset{data: %User{}}
"""
@spec change_user_registration() :: User.changeset()
@spec change_user_registration(attrs :: map()) :: User.changeset()
def change_user_registration(attrs \\ %{}) do
User.registration_changeset(attrs, nil, hash_password: false)
end
def change_user_registration(attrs \\ %{}),
do: User.registration_changeset(attrs, hash_password: false)
## Settings
@ -160,29 +144,25 @@ defmodule Cannery.Accounts do
## Examples
iex> change_user_email(%User{email: "foo@example.com"})
%Changeset{}
iex> change_user_email(user)
%Changeset{data: %User{}}
"""
@spec change_user_email(User.t()) :: User.changeset()
@spec change_user_email(User.t(), attrs :: map()) :: User.changeset()
def change_user_email(user, attrs \\ %{}) do
User.email_changeset(user, attrs)
end
def change_user_email(user, attrs \\ %{}), do: User.email_changeset(user, attrs)
@doc """
Returns an `%Changeset{}` for changing the user role.
## Examples
iex> change_user_role(%User{}, :user)
%Changeset{}
iex> change_user_role(user)
%Changeset{data: %User{}}
"""
@spec change_user_role(User.t(), User.role()) :: User.changeset()
def change_user_role(user, role) do
User.role_changeset(user, role)
end
def change_user_role(user, role), do: User.role_changeset(user, role)
@doc """
Emulates that the email will change without actually changing
@ -190,10 +170,10 @@ defmodule Cannery.Accounts do
## Examples
iex> apply_user_email(user, "valid_password", %{email: "new_email@account.com"})
iex> apply_user_email(user, "valid password", %{email: ...})
{:ok, %User{}}
iex> apply_user_email(user, "invalid password", %{email: "new_email@account"})
iex> apply_user_email(user, "invalid password", %{email: ...})
{:error, %Changeset{}}
"""
@ -239,8 +219,8 @@ defmodule Cannery.Accounts do
## Examples
iex> deliver_update_email_instructions(user, "new_foo@example.com", fn _token -> "example url" end)
%Oban.Job{args: %{email: :update_email, user_id: ^user_id, attrs: %{url: "example url"}}}
iex> deliver_update_email_instructions(user, current_email, &Routes.user_update_email_url(conn, :edit, &1))
{:ok, %{to: ..., body: ...}}
"""
@spec deliver_update_email_instructions(User.t(), current_email :: String.t(), function) ::
@ -257,27 +237,23 @@ defmodule Cannery.Accounts do
## Examples
iex> change_user_password(%User{})
%Changeset{}
iex> change_user_password(user)
%Changeset{data: %User{}}
"""
@spec change_user_password(User.t(), attrs :: map()) :: User.changeset()
def change_user_password(user, attrs \\ %{}) do
User.password_changeset(user, attrs, hash_password: false)
end
def change_user_password(user, attrs \\ %{}),
do: User.password_changeset(user, attrs, hash_password: false)
@doc """
Updates the user password.
## Examples
iex> reset_user_password(user, %{
...> password: "new password",
...> password_confirmation: "new password"
...> })
iex> update_user_password(user, "valid password", %{password: ...})
{:ok, %User{}}
iex> update_user_password(user, "invalid password", %{password: "123"})
iex> update_user_password(user, "invalid password", %{password: ...})
{:error, %Changeset{}}
"""
@ -300,54 +276,49 @@ defmodule Cannery.Accounts do
end
@doc """
Returns an `Ecto.Changeset.t()` for changing the user locale.
Returns an `%Changeset{}` for changing the user locale.
## Examples
iex> change_user_locale(%User{})
%Changeset{}
iex> change_user_locale(user)
%Changeset{data: %User{}}
"""
@spec change_user_locale(User.t()) :: User.changeset()
def change_user_locale(%{locale: locale} = user) do
User.locale_changeset(user, locale)
end
def change_user_locale(%{locale: locale} = user), do: User.locale_changeset(user, locale)
@doc """
Updates the user locale.
## Examples
iex> update_user_locale(user, "en_US")
iex> update_user_locale(user, "valid locale")
{:ok, %User{}}
iex> update_user_password(user, "invalid locale")
{:error, %Changeset{}}
"""
@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
def update_user_locale(user, locale),
do: user |> User.locale_changeset(locale) |> Repo.update()
@doc """
Deletes a user. must be performed by an admin or the same user!
## Examples
iex> delete_user!(user, %User{id: 123, role: :admin})
iex> delete_user!(user_to_delete, %User{id: 123, role: :admin})
%User{}
iex> delete_user!(user, user)
iex> delete_user!(%User{id: 123}, %User{id: 123})
%User{}
"""
@spec delete_user!(user_to_delete :: User.t(), User.t()) :: User.t()
def delete_user!(user, %User{role: :admin}) do
user |> Repo.delete!()
end
def delete_user!(%User{id: user_id} = user, %User{id: user_id}) do
user |> Repo.delete!()
end
def delete_user!(user, %User{role: :admin}), do: user |> Repo.delete!()
def delete_user!(%User{id: user_id} = user, %User{id: user_id}), do: user |> Repo.delete!()
## Session
@ -373,9 +344,9 @@ defmodule Cannery.Accounts do
@doc """
Deletes the signed token with the given context.
"""
@spec delete_user_session_token(token :: String.t()) :: :ok
def delete_user_session_token(token) do
UserToken.token_and_context_query(token, "session") |> Repo.delete_all()
@spec delete_session_token(token :: String.t()) :: :ok
def delete_session_token(token) do
Repo.delete_all(UserToken.token_and_context_query(token, "session"))
:ok
end
@ -384,53 +355,19 @@ defmodule Cannery.Accounts do
"""
@spec allow_registration?() :: boolean()
def allow_registration? do
registration_mode() == :public or list_users_by_role(:admin) |> Enum.empty?()
end
@doc """
Returns an atom representing the current configured registration mode
"""
@spec registration_mode() :: :public | :invite_only
def registration_mode do
case Application.get_env(:cannery, Cannery.Accounts)[:registration] do
"public" -> :public
_other -> :invite_only
end
Application.get_env(:cannery, CanneryWeb.Endpoint)[:registration] == "public" or
list_users_by_role(:admin) |> Enum.empty?()
end
@doc """
Checks if user is an admin
## Examples
iex> admin?(%User{role: :admin})
true
iex> admin?(%User{})
false
"""
@spec admin?(User.t()) :: boolean()
def admin?(%User{id: user_id}) do
Repo.exists?(from u in User, where: u.id == ^user_id, where: u.role == :admin)
@spec is_admin?(User.t()) :: boolean()
def is_admin?(%User{id: user_id}) do
Repo.one(from u in User, where: u.id == ^user_id and u.role == :admin)
|> is_nil()
end
@doc """
Checks to see if user has the admin role
## Examples
iex> already_admin?(%User{role: :admin})
true
iex> already_admin?(%User{})
false
"""
@spec already_admin?(User.t() | nil) :: boolean()
def already_admin?(%User{role: :admin}), do: true
def already_admin?(_invalid_user), do: false
## Confirmation
@doc """
@ -438,10 +375,10 @@ defmodule Cannery.Accounts do
## Examples
iex> deliver_user_confirmation_instructions(user, fn _token -> "example url" end)
%Oban.Job{args: %{email: :welcome, user_id: ^user_id, attrs: %{url: "example url"}}}
iex> deliver_user_confirmation_instructions(user, &Routes.user_confirmation_url(conn, :confirm, &1))
{: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}
"""
@ -488,8 +425,8 @@ defmodule Cannery.Accounts do
## Examples
iex> deliver_user_reset_password_instructions(user, fn _token -> "example url" end)
%Oban.Job{args: %{email: :reset_password, user_id: ^user_id, attrs: %{url: "example url"}}}
iex> deliver_user_reset_password_instructions(user, &Routes.user_reset_password_url(conn, :edit, &1))
{:ok, %{to: ..., body: ...}}
"""
@spec deliver_user_reset_password_instructions(User.t(), function()) :: Job.t()
@ -505,7 +442,7 @@ defmodule Cannery.Accounts do
## Examples
iex> get_user_by_reset_password_token(encoded_token)
iex> get_user_by_reset_password_token("validtoken")
%User{}
iex> get_user_by_reset_password_token("invalidtoken")
@ -527,10 +464,7 @@ defmodule Cannery.Accounts do
## Examples
iex> reset_user_password(user, %{
...> password: "new password",
...> password_confirmation: "new password"
...> })
iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"})
{:ok, %User{}}
iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"})

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,10 +1,13 @@
defmodule Cannery.Accounts.User do
@moduledoc """
A Cannery user
A cannery user
"""
use Cannery, :schema
alias Cannery.Accounts.Invite
use Ecto.Schema
import Ecto.Changeset
import CanneryWeb.Gettext
alias Ecto.{Changeset, UUID}
alias Cannery.{Accounts.User, Invites.Invite}
@derive {Jason.Encoder,
only: [
@ -12,11 +15,11 @@ defmodule Cannery.Accounts.User do
:email,
:confirmed_at,
:role,
:locale,
:inserted_at,
:updated_at
:locale
]}
@derive {Inspect, except: [:password]}
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "users" do
field :email, :string
field :password, :string, virtual: true
@ -25,9 +28,7 @@ defmodule Cannery.Accounts.User do
field :role, Ecto.Enum, values: [:admin, :user], default: :user
field :locale, :string
has_many :created_invites, Invite, foreign_key: :created_by_id
belongs_to :invite, Invite
has_many :invites, Invite, on_delete: :delete_all
timestamps()
end
@ -40,16 +41,14 @@ defmodule Cannery.Accounts.User do
confirmed_at: NaiveDateTime.t(),
role: role(),
locale: String.t() | nil,
created_invites: [Invite.t()] | Association.NotLoaded.t(),
invite: Invite.t() | nil | Association.NotLoaded.t(),
invite_id: Invite.id() | nil,
invites: [Invite.t()],
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
@type new_user :: %User{}
@type id :: UUID.t()
@type changeset :: Changeset.t(t() | new_user())
@type role :: :admin | :user
@type role :: :admin | :user | String.t()
@doc """
A user changeset for registration.
@ -68,13 +67,11 @@ defmodule Cannery.Accounts.User do
validations on a LiveView form), this option can be set to `false`.
Defaults to `true`.
"""
@spec registration_changeset(attrs :: map(), Invite.t() | nil) :: changeset()
@spec registration_changeset(attrs :: map(), Invite.t() | nil, opts :: keyword()) :: changeset()
def registration_changeset(attrs, invite, opts \\ []) do
@spec registration_changeset(attrs :: map()) :: changeset()
@spec registration_changeset(attrs :: map(), opts :: keyword()) :: changeset()
def registration_changeset(attrs, opts \\ []) do
%User{}
|> cast(attrs, [:email, :password, :locale])
|> put_change(:invite_id, if(invite, do: invite.id))
|> validate_length(:locale, max: 255)
|> validate_email()
|> validate_password(opts)
end
@ -84,7 +81,7 @@ defmodule Cannery.Accounts.User do
"""
@spec role_changeset(t() | new_user() | changeset(), role()) :: changeset()
def role_changeset(user, role) do
user |> change(role: role)
user |> cast(%{"role" => role}, [:role])
end
@spec validate_email(changeset()) :: changeset()
@ -205,7 +202,6 @@ defmodule Cannery.Accounts.User do
def locale_changeset(user_or_changeset, locale) do
user_or_changeset
|> cast(%{"locale" => locale}, [:locale])
|> validate_length(:locale, max: 255)
|> validate_required(:locale)
end
end

View File

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

View File

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

View File

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

View File

@ -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,130 @@
defmodule Cannery.Ammo.AmmoGroup do
@moduledoc """
A group of a certain ammunition type.
Can be placed in a container, and contains auxiliary information such as the
amount paid for that ammunition, or what condition it is in
"""
use Ecto.Schema
import CanneryWeb.Gettext
import Ecto.Changeset
alias Cannery.Ammo.{AmmoGroup, AmmoType}
alias Cannery.{Accounts.User, ActivityLog.ShotGroup, Containers, Containers.Container}
alias Ecto.{Changeset, UUID}
@derive {Jason.Encoder,
only: [
:id,
:count,
:notes,
:price_paid,
:staged,
:ammo_type_id,
:container_id
]}
@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
field :purchased_on, :date
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(),
purchased_on: Date.t(),
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()
@type changeset :: Changeset.t(t() | new_ammo_group())
@doc false
@spec create_changeset(
new_ammo_group(),
AmmoType.t() | nil,
Container.t() | nil,
User.t(),
attrs :: map()
) :: changeset()
def create_changeset(
ammo_group,
%AmmoType{id: ammo_type_id},
%Container{id: container_id, user_id: user_id},
%User{id: user_id},
attrs
)
when not (ammo_type_id |> is_nil()) and not (container_id |> is_nil()) and
not (user_id |> is_nil()) do
ammo_group
|> change(ammo_type_id: ammo_type_id)
|> change(user_id: user_id)
|> change(container_id: container_id)
|> cast(attrs, [:count, :price_paid, :notes, :staged, :purchased_on])
|> validate_number(:count, greater_than: 0)
|> validate_required([:count, :staged, :purchased_on, :ammo_type_id, :container_id, :user_id])
end
@doc """
Invalid changeset, used to prompt user to select ammo type and container
"""
def create_changeset(ammo_group, _invalid_ammo_type, _invalid_container, _invalid_user, attrs) do
ammo_group
|> cast(attrs, [:ammo_type_id, :container_id])
|> validate_required([:ammo_type_id, :container_id])
|> add_error(:invalid, dgettext("errors", "Please select an ammo type and container"))
end
@doc false
@spec update_changeset(t() | new_ammo_group(), attrs :: map(), User.t()) :: changeset()
def update_changeset(ammo_group, attrs, user) do
ammo_group
|> cast(attrs, [:count, :price_paid, :notes, :staged, :purchased_on, :container_id])
|> validate_number(:count, greater_than_or_equal_to: 0)
|> validate_container_id(user)
|> 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" ammo groups, and allows for
updating the count to 0
"""
@spec range_changeset(t() | new_ammo_group(), attrs :: map()) :: changeset()
def range_changeset(ammo_group, attrs) do
ammo_group
|> cast(attrs, [:count, :staged])
|> validate_required([:count, :staged])
end
end

View File

@ -0,0 +1,149 @@
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}
@derive {Jason.Encoder,
only: [
:id,
: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
]}
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "ammo_types" do
field :name, :string
field :desc, :string
# https://en.wikipedia.org/wiki/Bullet#Abbreviations
field :bullet_type, :string
field :bullet_core, :string
field :cartridge, :string
field :caliber, :string
field :case_material, :string
field :jacket_type, :string
field :muzzle_velocity, :integer
field :powder_type, :string
field :powder_grains_per_charge, :integer
field :grains, :integer
field :pressure, :string
field :primer_type, :string
field :firing_type, :string
field :tracer, :boolean, default: false
field :incendiary, :boolean, default: false
field :blank, :boolean, default: false
field :corrosive, :boolean, default: false
field :manufacturer, :string
field :upc, :string
belongs_to :user, User
has_many :ammo_groups, AmmoGroup
timestamps()
end
@type t :: %AmmoType{
id: id(),
name: String.t(),
desc: String.t() | nil,
bullet_type: String.t() | nil,
bullet_core: String.t() | nil,
cartridge: String.t() | nil,
caliber: String.t() | nil,
case_material: String.t() | nil,
jacket_type: String.t() | nil,
muzzle_velocity: integer() | nil,
powder_type: String.t() | nil,
powder_grains_per_charge: integer() | nil,
grains: integer() | nil,
pressure: String.t() | nil,
primer_type: String.t() | nil,
firing_type: String.t() | nil,
tracer: boolean(),
incendiary: boolean(),
blank: boolean(),
corrosive: boolean(),
manufacturer: String.t() | nil,
upc: String.t() | nil,
user_id: User.id(),
user: User.t() | nil,
ammo_groups: [AmmoGroup.t()] | nil,
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
@type new_ammo_type :: %AmmoType{}
@type id :: UUID.t()
@type changeset :: Changeset.t(t() | new_ammo_type())
@spec changeset_fields() :: [atom()]
defp changeset_fields,
do: [
:name,
:desc,
:bullet_type,
:bullet_core,
:cartridge,
:caliber,
:case_material,
:jacket_type,
:muzzle_velocity,
:powder_type,
:powder_grains_per_charge,
:grains,
:pressure,
:primer_type,
:firing_type,
:tracer,
:incendiary,
:blank,
:corrosive,
:manufacturer,
:upc
]
@doc false
@spec create_changeset(new_ammo_type(), User.t(), attrs :: map()) :: changeset()
def create_changeset(ammo_type, %User{id: user_id}, attrs) do
ammo_type
|> change(user_id: user_id)
|> cast(attrs, changeset_fields())
|> validate_required([:name, :user_id])
end
@doc false
@spec update_changeset(t() | new_ammo_type(), attrs :: map()) :: changeset()
def update_changeset(ammo_type, attrs) do
ammo_type
|> cast(attrs, changeset_fields())
|> validate_required(:name)
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
use Application
alias Cannery.Logger
@impl true
def start(_type, _args) do
@ -18,24 +17,16 @@ defmodule Cannery.Application do
# Start the Endpoint (http/https)
CanneryWeb.Endpoint,
# Add Oban
{Oban, oban_config()},
Cannery.Repo.Migrator
{Oban, oban_config()}
# Start a worker by calling: Cannery.Worker.start_link(arg)
# {Cannery.Worker, arg}
]
# Oban events logging https://hexdocs.pm/oban/Oban.html#module-reporting-errors
:ok =
:telemetry.attach_many(
"oban-logger",
[
[:oban, :job, :exception],
[:oban, :job, :start],
[:oban, :job, :stop]
],
&Logger.handle_event/4,
[]
)
# Automatically migrate on start in prod
children =
if Application.get_env(:cannery, Cannery.Application, automigrate: false)[:automigrate],
do: children ++ [Cannery.Repo.Migrator],
else: children
# See https://hexdocs.pm/elixir/Supervisor.html
# 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.
"""
use Cannery, :context
alias Cannery.Ammo.Pack
alias Cannery.Containers.{Container, ContainerTag, Tag}
@container_preloads [:tags]
@type list_containers_option :: {:search, String.t() | nil}
@type list_containers_options :: [list_containers_option()]
import CanneryWeb.Gettext
import Ecto.Query, warn: false
alias Cannery.{Accounts.User, Ammo.AmmoGroup, Repo, Tags.Tag}
alias Cannery.Containers.{Container, ContainerTag}
alias Ecto.Changeset
@doc """
Returns the list of containers.
@ -20,31 +17,31 @@ defmodule Cannery.Containers do
iex> list_containers(%User{id: 123})
[%Container{}, ...]
iex> list_containers(%User{id: 123}, search: "cool")
iex> list_containers("cool", %User{id: 123})
[%Container{name: "my cool container"}, ...]
"""
@spec list_containers(User.t()) :: [Container.t()]
@spec list_containers(User.t(), list_containers_options()) :: [Container.t()]
def list_containers(%User{id: user_id}, opts \\ []) do
@spec list_containers(search :: nil | String.t(), User.t()) :: [Container.t()]
def list_containers(search \\ nil, %User{id: user_id}) do
from(c in Container,
as: :c,
left_join: t in assoc(c, :tags),
on: c.user_id == t.user_id,
as: :t,
left_join: ag in assoc(c, :ammo_groups),
as: :ag,
where: c.user_id == ^user_id,
distinct: c.id,
preload: ^@container_preloads
order_by: c.name,
preload: [tags: t, ammo_groups: ag]
)
|> list_containers_search(Keyword.get(opts, :search))
|> list_containers_search(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, nil), do: query
defp list_containers_search(query, ""), do: query
defp list_containers_search(query, search) when search |> is_binary() do
defp list_containers_search(query, search) do
trimmed_search = String.trim(search)
query
@ -94,7 +91,7 @@ defmodule Cannery.Containers do
@doc """
Gets a single container.
Raises `KeyError` if the Container does not exist.
Raises `Ecto.NoResultsError` if the Container does not exist.
## Examples
@ -102,37 +99,20 @@ defmodule Cannery.Containers do
%Container{}
iex> get_container!(456, %User{id: 123})
** (KeyError)
** (Ecto.NoResultsError)
"""
@spec get_container!(Container.id(), User.t()) :: Container.t()
def get_container!(id, user) do
[id]
|> get_containers(user)
|> Map.fetch!(id)
end
@doc """
Gets multiple containers.
## Examples
iex> get_containers([123], %User{id: 123})
%{123 => %Container{}}
"""
@spec get_containers([Container.id()], User.t()) :: %{optional(Container.id()) => Container.t()}
def get_containers(ids, %User{id: user_id}) do
Repo.all(
def get_container!(id, %User{id: user_id}) do
Repo.one!(
from c in Container,
left_join: t in assoc(c, :tags),
left_join: ag in assoc(c, :ammo_groups),
where: c.user_id == ^user_id,
where: c.id in ^ids,
where: c.id == ^id,
order_by: c.name,
preload: ^@container_preloads,
select: {c.id, c}
preload: [tags: t, ammo_groups: ag]
)
|> Map.new()
end
@doc """
@ -150,19 +130,7 @@ defmodule Cannery.Containers do
@spec create_container(attrs :: map(), User.t()) ::
{:ok, Container.t()} | {:error, Container.changeset()}
def create_container(attrs, %User{} = user) do
%Container{}
|> Container.create_changeset(user, attrs)
|> Repo.insert()
|> case do
{:ok, container} -> {:ok, container |> preload_container()}
{:error, changeset} -> {:error, changeset}
end
end
@spec preload_container(Container.t()) :: Container.t()
@spec preload_container([Container.t()]) :: [Container.t()]
def preload_container(container) do
container |> Repo.preload(@container_preloads)
%Container{} |> Container.create_changeset(user, attrs) |> Repo.insert()
end
@doc """
@ -180,13 +148,7 @@ defmodule Cannery.Containers do
@spec update_container(Container.t(), User.t(), attrs :: map()) ::
{:ok, Container.t()} | {:error, Container.changeset()}
def update_container(%Container{user_id: user_id} = container, %User{id: user_id}, attrs) do
container
|> Container.update_changeset(attrs)
|> Repo.update()
|> case do
{:ok, container} -> {:ok, container |> preload_container()}
{:error, changeset} -> {:error, changeset}
end
container |> Container.update_changeset(attrs) |> Repo.update()
end
@doc """
@ -205,25 +167,20 @@ defmodule Cannery.Containers do
{:ok, Container.t()} | {:error, Container.changeset()}
def delete_container(%Container{user_id: user_id} = container, %User{id: user_id}) do
Repo.one(
from p in Pack,
where: p.container_id == ^container.id,
select: count(p.id)
from ag in AmmoGroup,
where: ag.container_id == ^container.id,
select: count(ag.id)
)
|> case do
0 ->
container
|> Repo.delete()
|> case do
{:ok, container} -> {:ok, container |> preload_container()}
{:error, changeset} -> {:error, changeset}
end
container |> Repo.delete()
_amount ->
error = dgettext("errors", "Container must be empty before deleting")
container
|> Container.update_changeset(%{})
|> Changeset.add_error(:packs, error)
|> Changeset.add_error(:ammo_groups, error)
|> Changeset.apply_action(:delete)
end
end
@ -257,11 +214,8 @@ defmodule Cannery.Containers do
%Container{user_id: user_id} = container,
%Tag{user_id: user_id} = tag,
%User{id: user_id}
) do
%ContainerTag{}
|> ContainerTag.create_changeset(tag, container)
|> Repo.insert!()
end
),
do: %ContainerTag{} |> ContainerTag.create_changeset(tag, container) |> Repo.insert!()
@doc """
Removes a tag from a container
@ -272,182 +226,45 @@ defmodule Cannery.Containers do
%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!(
%Container{id: container_id, user_id: user_id},
%Tag{id: tag_id, user_id: user_id},
%User{id: user_id}
) do
{count, results} =
{count, _} =
Repo.delete_all(
from ct in ContainerTag,
where: ct.container_id == ^container_id,
where: ct.tag_id == ^tag_id,
select: ct
where: ct.tag_id == ^tag_id
)
if count == 0, do: raise("could not delete container tag"), else: {count, results}
end
# Container Tags
@type list_tags_option :: {:search, String.t() | nil}
@type list_tags_options :: [list_tags_option()]
@doc """
Returns the list of tags.
## Examples
iex> list_tags(%User{id: 123})
[%Tag{}, ...]
iex> list_tags(%User{id: 123}, search: "cool")
[%Tag{name: "my cool tag"}, ...]
"""
@spec list_tags(User.t()) :: [Tag.t()]
@spec list_tags(User.t(), list_tags_options()) :: [Tag.t()]
def list_tags(%User{id: user_id}, opts \\ []) do
from(t in Tag, as: :t, where: t.user_id == ^user_id)
|> list_tags_search(Keyword.get(opts, :search))
|> Repo.all()
end
@spec list_tags_search(Queryable.t(), search :: String.t() | nil) :: Queryable.t()
defp list_tags_search(query, search) when search in ["", nil],
do: query |> order_by([t: t], t.name)
defp list_tags_search(query, search) when search |> is_binary() do
trimmed_search = String.trim(search)
query
|> where(
[t: t],
fragment(
"? @@ websearch_to_tsquery('english', ?)",
t.search,
^trimmed_search
)
)
|> order_by([t: t], {
:desc,
fragment(
"ts_rank_cd(?, websearch_to_tsquery('english', ?), 4)",
t.search,
^trimmed_search
)
})
if count == 0, do: raise("could not delete container tag"), else: count
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}
Returns number of rounds in container. If data is already preloaded, then
there will be no db hit.
"""
@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
@spec get_container_ammo_group_count!(Container.t()) :: non_neg_integer()
def get_container_ammo_group_count!(%Container{} = container) do
container
|> Repo.preload(:ammo_groups)
|> Map.fetch!(:ammo_groups)
|> Enum.reject(fn %{count: count} -> count == 0 end)
|> Enum.count()
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)
Returns number of rounds in container. If data is already preloaded, then
there will be no db hit.
"""
@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!()
@spec get_container_rounds!(Container.t()) :: non_neg_integer()
def get_container_rounds!(%Container{} = container) do
container
|> Repo.preload(:ammo_groups)
|> Map.fetch!(:ammo_groups)
|> Enum.map(fn %{count: count} -> count end)
|> Enum.sum()
end
end

View File

@ -3,8 +3,11 @@ defmodule Cannery.Containers.Container do
A container that holds ammunition and belongs to a user.
"""
use Cannery, :schema
alias Cannery.{Containers.ContainerTag, Containers.Tag}
use Ecto.Schema
import Ecto.Changeset
alias Ecto.{Changeset, UUID}
alias Cannery.Containers.{Container, ContainerTag}
alias Cannery.{Accounts.User, Ammo.AmmoGroup, Tags.Tag}
@derive {Jason.Encoder,
only: [
@ -15,31 +18,36 @@ defmodule Cannery.Containers.Container do
:type,
:tags
]}
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "containers" do
field :name, :string
field :desc, :string
field :location, :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
timestamps()
end
@type t :: %__MODULE__{
@type t :: %Container{
id: id(),
name: String.t(),
desc: String.t(),
location: String.t(),
type: String.t(),
user: User.t(),
user_id: User.id(),
ammo_groups: [AmmoGroup.t()] | nil,
tags: [Tag.t()] | nil,
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
@type new_container :: %__MODULE__{}
@type new_container :: %Container{}
@type id :: UUID.t()
@type changeset :: Changeset.t(t() | new_container())
@ -49,8 +57,6 @@ defmodule Cannery.Containers.Container do
container
|> change(user_id: user_id)
|> cast(attrs, [:name, :desc, :type, :location])
|> validate_length(:name, max: 255)
|> validate_length(:type, max: 255)
|> validate_required([:name, :type, :user_id])
end
@ -59,8 +65,6 @@ defmodule Cannery.Containers.Container do
def update_changeset(container, attrs) do
container
|> cast(attrs, [:name, :desc, :type, :location])
|> validate_length(:name, max: 255)
|> validate_length(:type, max: 255)
|> validate_required([:name, :type])
end
end

View File

@ -1,12 +1,16 @@
defmodule Cannery.Containers.ContainerTag do
@moduledoc """
Thru-table struct for associating Cannery.Containers.Container and
Cannery.Containers.Tag.
Cannery.Tags.Tag.
"""
use Cannery, :schema
alias Cannery.Containers.{Container, Tag}
use Ecto.Schema
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
belongs_to :container, Container
belongs_to :tag, Tag
@ -14,7 +18,7 @@ defmodule Cannery.Containers.ContainerTag do
timestamps()
end
@type t :: %__MODULE__{
@type t :: %ContainerTag{
id: id(),
container: Container.t(),
container_id: Container.id(),
@ -23,7 +27,7 @@ defmodule Cannery.Containers.ContainerTag do
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
@type new_container_tag :: %__MODULE__{}
@type new_container_tag :: %ContainerTag{}
@type id :: UUID.t()
@type changeset :: Changeset.t(t() | new_container_tag())

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

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

@ -0,0 +1,155 @@
defmodule Cannery.Invites do
@moduledoc """
The Invites context.
"""
import Ecto.Query, warn: false
alias Cannery.{Accounts.User, Invites.Invite, Repo}
@invite_token_length 20
@doc """
Returns the list of invites.
## Examples
iex> list_invites(%User{id: 123, role: :admin})
[%Invite{}, ...]
"""
@spec list_invites(User.t()) :: [Invite.t()]
def list_invites(%User{role: :admin}) do
Repo.all(from i in Invite, order_by: i.name)
end
@doc """
Gets a single invite.
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, 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()
@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()
@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

View File

@ -1,51 +1,51 @@
defmodule Cannery.Accounts.Invite do
defmodule Cannery.Invites.Invite do
@moduledoc """
An invite, created by an admin to allow someone to join their instance. An
invite can be enabled or disabled, and can have an optional number of uses if
`:uses_left` is defined.
"""
use 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
field :name, :string
field :token, :string
field :uses_left, :integer, default: nil
field :disabled_at, :naive_datetime
belongs_to :created_by, User
has_many :users, User
belongs_to :user, User
timestamps()
end
@type t :: %__MODULE__{
@type t :: %Invite{
id: id(),
name: String.t(),
token: token(),
token: String.t(),
uses_left: integer() | nil,
disabled_at: NaiveDateTime.t(),
created_by: User.t() | nil | Association.NotLoaded.t(),
created_by_id: User.id() | nil,
users: [User.t()] | Association.NotLoaded.t(),
user: User.t(),
user_id: User.id(),
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
@type new_invite :: %__MODULE__{}
@type new_invite :: %Invite{}
@type id :: UUID.t()
@type changeset :: Changeset.t(t() | new_invite())
@type token :: String.t()
@doc false
@spec create_changeset(User.t(), token(), attrs :: map()) :: changeset()
@spec create_changeset(User.t(), token :: binary(), attrs :: map()) :: changeset()
def create_changeset(%User{id: user_id}, token, attrs) do
%__MODULE__{}
|> change(token: token, created_by_id: user_id)
%Invite{}
|> change(token: token, user_id: user_id)
|> 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_required([:name, :token, :created_by_id])
end
@doc false
@ -53,8 +53,7 @@ defmodule Cannery.Accounts.Invite do
def update_changeset(invite, attrs) do
invite
|> cast(attrs, [:name, :uses_left, :disabled_at])
|> validate_length(:name, max: 255)
|> validate_number(:uses_left, greater_than_or_equal_to: 0)
|> validate_required([:name])
|> validate_number(:uses_left, greater_than_or_equal_to: 0)
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
load_app()
{:ok, _fun_return, _apps} =
Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
{:ok, _fun, _opts} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
end
defp load_app do
@ -22,8 +20,7 @@ defmodule Cannery.Release do
load_app()
for repo <- Application.fetch_env!(@app, :ecto_repos) do
{:ok, _fun_return, _apps} =
Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
{:ok, _fun, _opts} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
end
end
end

View File

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

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

@ -0,0 +1,149 @@
defmodule Cannery.Tags do
@moduledoc """
The Tags context.
"""
import Ecto.Query, warn: false
import CanneryWeb.Gettext
alias Cannery.{Accounts.User, Repo, Tags.Tag}
@doc """
Returns the list of tags.
## Examples
iex> list_tags(%User{id: 123})
[%Tag{}, ...]
iex> list_tags("cool", %User{id: 123})
[%Tag{name: "my cool tag"}, ...]
"""
@spec list_tags(User.t()) :: [Tag.t()]
@spec list_tags(search :: nil | String.t(), User.t()) :: [Tag.t()]
def list_tags(search \\ nil, user)
def list_tags(search, %{id: user_id}) when search |> is_nil() or search == "",
do: Repo.all(from t in Tag, where: t.user_id == ^user_id, order_by: t.name)
def list_tags(search, %{id: user_id}) when search |> is_binary() do
trimmed_search = String.trim(search)
Repo.all(
from t in Tag,
where: t.user_id == ^user_id,
where:
fragment(
"search @@ websearch_to_tsquery('english', ?)",
^trimmed_search
),
order_by: {
:desc,
fragment(
"ts_rank_cd(search, websearch_to_tsquery('english', ?), 4)",
^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, "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, Tag.changeset()}
def create_tag(attrs, %User{} = user),
do: %Tag{} |> Tag.create_changeset(user, attrs) |> Repo.insert()
@doc """
Updates a tag.
## Examples
iex> update_tag(tag, %{field: new_value}, %User{id: 123})
{:ok, %Tag{}}
iex> update_tag(tag, %{field: bad_value}, %User{id: 123})
{:error, %Changeset{}}
"""
@spec update_tag(Tag.t(), attrs :: map(), User.t()) ::
{:ok, Tag.t()} | {:error, Tag.changeset()}
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, Tag.changeset()}
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!()
end

View File

@ -1,10 +1,13 @@
defmodule Cannery.Containers.Tag do
defmodule Cannery.Tags.Tag do
@moduledoc """
Tags are added to containers to help organize, and can include custom-defined
text and bg colors.
"""
use Cannery, :schema
use Ecto.Schema
import Ecto.Changeset
alias Ecto.{Changeset, UUID}
alias Cannery.{Accounts.User, Tags.Tag}
@derive {Jason.Encoder,
only: [
@ -13,26 +16,29 @@ defmodule Cannery.Containers.Tag do
:bg_color,
:text_color
]}
@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
field :user_id, :binary_id
belongs_to :user, User
timestamps()
end
@type t :: %__MODULE__{
@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() :: %__MODULE__{}
@type new_tag() :: %Tag{}
@type id() :: UUID.t()
@type changeset() :: Changeset.t(t() | new_tag())
@ -42,9 +48,6 @@ defmodule Cannery.Containers.Tag 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
@ -53,9 +56,6 @@ defmodule Cannery.Containers.Tag do
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,29 +1,78 @@
defmodule CanneryWeb do
@moduledoc """
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:
use CanneryWeb, :controller
use CanneryWeb, :html
use CanneryWeb, :view
The definitions below will be executed for every controller,
component, etc, so keep them short and clean, focused
The definitions below will be executed for every view,
controller, etc, so keep them short and clean, focused
on imports, uses and aliases.
Do NOT define functions inside the quoted expressions
below. Instead, define additional modules and import
those modules here.
below. Instead, define any helper function in modules
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"}
on_mount CanneryWeb.InitAssigns
unquote(view_helpers())
end
end
def live_component do
quote do
use Phoenix.LiveComponent
unquote(view_helpers())
end
end
def component do
quote do
use Phoenix.Component
unquote(view_helpers())
end
end
def router do
quote do
use Phoenix.Router, helpers: false
use Phoenix.Router
# Import common connection and controller functions to use in pipelines
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
import Plug.Conn
import Phoenix.Controller
import Phoenix.LiveView.Router
@ -32,77 +81,31 @@ defmodule CanneryWeb do
def channel do
quote do
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
use Phoenix.Channel
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
import CanneryWeb.Gettext
end
end
def controller do
defp view_helpers do
quote do
use Phoenix.Controller,
formats: [:html, :json],
layouts: [html: CanneryWeb.Layouts]
# Use all HTML functionality (forms, tags, etc)
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
use Phoenix.HTML
use Gettext, backend: CanneryWeb.Gettext
# Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc)
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
import Phoenix.Component
# Import basic rendering functionality (render, render_layout, etc)
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
import Phoenix.View
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
import Plug.Conn
unquote(verified_routes())
end
end
def live_view do
quote do
use Phoenix.LiveView,
layout: {CanneryWeb.Layouts, :app}
unquote(html_helpers())
end
end
def live_component do
quote do
use Phoenix.LiveComponent
unquote(html_helpers())
end
end
def html do
quote do
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
use Phoenix.Component
# Import convenience functions from controllers
import Phoenix.Controller,
only: [get_csrf_token: 0, view_module: 1, view_template: 1]
# Include general helpers for rendering HTML
unquote(html_helpers())
end
end
defp html_helpers do
quote do
use PhoenixHTMLHelpers
use Gettext, backend: CanneryWeb.Gettext
import Phoenix.{Component, HTML, HTML.Form}
import CanneryWeb.{ErrorHelpers, CoreComponents, HTMLHelpers}
# Shortcut for generating JS commands
alias Phoenix.LiveView.JS
# Routes generation with the ~p sigil
unquote(verified_routes())
end
end
def verified_routes do
quote do
use Phoenix.VerifiedRoutes,
endpoint: CanneryWeb.Endpoint,
router: CanneryWeb.Router,
statics: CanneryWeb.static_paths()
import CanneryWeb.{ErrorHelpers, Gettext, LiveHelpers, ViewHelpers}
alias CanneryWeb.Endpoint
alias CanneryWeb.Router.Helpers, as: Routes
end
end

View File

@ -0,0 +1,78 @@
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.{JS, Socket}
@impl true
@spec update(
%{
required(:current_user) => User.t(),
required(:ammo_group) => AmmoGroup.t(),
optional(any()) => any()
},
Socket.t()
) :: {:ok, Socket.t()}
def update(%{ammo_group: ammo_group, current_user: current_user} = assigns, socket) do
changeset =
%ShotGroup{date: NaiveDateTime.utc_now(), count: 1}
|> ShotGroup.create_changeset(current_user, ammo_group, %{})
{:ok, socket |> assign(assigns) |> assign(:changeset, changeset)}
end
@impl true
def handle_event(
"validate",
%{"shot_group" => shot_group_params},
%{assigns: %{ammo_group: ammo_group, current_user: current_user}} = socket
) do
params = shot_group_params |> process_params(ammo_group)
changeset =
%ShotGroup{}
|> ShotGroup.create_changeset(current_user, ammo_group, params)
|> Map.put(:action, :validate)
{:noreply, socket |> assign(:changeset, changeset)}
end
def handle_event(
"save",
%{"shot_group" => shot_group_params},
%{
assigns: %{ammo_group: ammo_group, current_user: current_user, return_to: return_to}
} = socket
) do
socket =
shot_group_params
|> process_params(ammo_group)
|> ActivityLog.create_shot_group(current_user, ammo_group)
|> case do
{:ok, _shot_group} ->
prompt = dgettext("prompts", "Shots recorded successfully")
socket |> put_flash(:info, prompt) |> push_navigate(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

@ -6,30 +6,29 @@
<.form
:let={f}
for={@changeset}
id="shot-record-form"
id="shot-group-form"
class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center"
phx-target={@myself}
phx-change="validate"
phx-submit="save"
>
<div
:if={@changeset.action && not @changeset.valid?}
class="invalid-feedback col-span-3 text-center"
>
<%= changeset_errors(@changeset) %>
</div>
<%= 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: @pack.count - 1,
max: @ammo_group.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")}
phx-click={JS.dispatch("cannery:set-zero", to: "#shot-group-form_ammo_left")}
>
<%= gettext("Used up!") %>
</button>
@ -38,11 +37,8 @@
<%= 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")
placeholder: "Really great weather",
phx_hook: "MaintainAttrs"
) %>
<%= error_tag(f, :notes, "col-span-3") %>

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

@ -0,0 +1,106 @@
defmodule CanneryWeb.Components.AmmoGroupCard do
@moduledoc """
Display card for an ammo group
"""
use CanneryWeb, :component
alias Cannery.{Ammo, Ammo.AmmoGroup, Repo}
alias CanneryWeb.Endpoint
attr :ammo_group, AmmoGroup, required: true
attr :show_container, :boolean, default: false
slot(:inner_block)
def ammo_group_card(%{ammo_group: ammo_group} = assigns) do
assigns =
%{show_container: show_container} = assigns |> assign_new(:show_container, fn -> false end)
preloads = if show_container, do: [:ammo_type, :container], else: [:ammo_type]
ammo_group = ammo_group |> Repo.preload(preloads)
assigns = assigns |> assign(:ammo_group, ammo_group)
~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"
>
<.link navigate={Routes.ammo_group_show_path(Endpoint, :show, @ammo_group)} class="mb-2 link">
<h1 class="title text-xl title-primary-500">
<%= @ammo_group.ammo_type.name %>
</h1>
</.link>
<div class="flex flex-col justify-center items-center">
<span class="rounded-lg title text-lg">
<%= gettext("Count:") %>
<%= if @ammo_group.count == 0, do: gettext("Empty"), else: @ammo_group.count %>
</span>
<%= if @ammo_group |> Ammo.get_original_count() != @ammo_group.count do %>
<span class="rounded-lg title text-lg">
<%= gettext("Original Count:") %>
<%= @ammo_group |> Ammo.get_original_count() %>
</span>
<% end %>
<%= if @ammo_group.notes do %>
<span class="rounded-lg title text-lg">
<%= gettext("Notes:") %>
<%= @ammo_group.notes %>
</span>
<% end %>
<span class="rounded-lg title text-lg">
<%= gettext("Purchased on:") %>
<.date date={@ammo_group.purchased_on} />
</span>
<%= if @ammo_group |> Ammo.get_last_used_shot_group() do %>
<span class="rounded-lg title text-lg">
<%= gettext("Last used on:") %>
<.date date={@ammo_group |> Ammo.get_last_used_shot_group() |> Map.get(:date)} />
</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>
<span class="rounded-lg title text-lg">
<%= gettext("CPR:") %>
<%= gettext("$%{amount}",
amount: @ammo_group |> Ammo.get_cpr() |> :erlang.float_to_binary(decimals: 2)
) %>
</span>
<% end %>
<%= if @show_container and @ammo_group.container do %>
<span class="rounded-lg title text-lg">
<%= gettext("Container:") %>
<.link
navigate={Routes.container_show_path(Endpoint, :show, @ammo_group.container)}
class="link"
>
<%= @ammo_group.container.name %>
</.link>
</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,234 @@
defmodule CanneryWeb.Components.AmmoGroupTableComponent do
@moduledoc """
A component that displays a list of ammo groups
"""
use CanneryWeb, :live_component
alias Cannery.{Accounts.User, Ammo, Ammo.AmmoGroup, Repo}
alias Ecto.UUID
alias Phoenix.LiveView.{Rendered, Socket}
@impl true
@spec update(
%{
required(:id) => UUID.t(),
required(:current_user) => User.t(),
required(:ammo_groups) => [AmmoGroup.t()],
optional(:ammo_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, ammo_groups: _ammo_group, current_user: _current_user} = assigns, socket) do
socket =
socket
|> assign(assigns)
|> assign_new(:ammo_type, fn -> [] end)
|> assign_new(:range, fn -> [] end)
|> assign_new(:container, fn -> [] end)
|> assign_new(:actions, fn -> [] end)
|> display_ammo_groups()
{:ok, socket}
end
defp display_ammo_groups(
%{
assigns: %{
ammo_groups: ammo_groups,
current_user: current_user,
ammo_type: ammo_type,
range: range,
container: container,
actions: actions
}
} = socket
) do
columns =
if actions == [] do
[]
else
[%{label: nil, key: :actions, sortable: false}]
end
columns = [
%{label: gettext("Purchased on"), key: :purchased_on},
%{label: gettext("Last used on"), key: :used_up_on} | columns
]
columns =
if container == [] do
columns
else
[%{label: gettext("Container"), key: :container} | columns]
end
columns =
if range == [] do
columns
else
[%{label: gettext("Range"), key: :range} | columns]
end
columns = [
%{label: gettext("Count"), key: :count},
%{label: gettext("Original Count"), key: :original_count},
%{label: gettext("Price paid"), key: :price_paid},
%{label: gettext("CPR"), key: :cpr},
%{label: gettext("% left"), key: :remaining},
%{label: gettext("Notes"), key: :notes}
| columns
]
columns =
if ammo_type == [] do
columns
else
[%{label: gettext("Ammo type"), key: :ammo_type} | columns]
end
extra_data = %{
current_user: current_user,
ammo_type: ammo_type,
columns: columns,
container: container,
actions: actions,
range: range
}
rows =
ammo_groups
|> Repo.preload([:ammo_type, :container])
|> Enum.map(fn ammo_group ->
ammo_group |> get_row_data_for_ammo_group(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_ammo_group(AmmoGroup.t(), additional_data :: map()) :: map()
defp get_row_data_for_ammo_group(ammo_group, %{columns: columns} = additional_data) do
ammo_group = ammo_group |> Repo.preload([:ammo_type, :container])
columns
|> Map.new(fn %{key: key} ->
{key, get_value_for_key(key, ammo_group, additional_data)}
end)
end
@spec get_value_for_key(atom(), AmmoGroup.t(), additional_data :: map()) ::
any() | {any(), Rendered.t()}
defp get_value_for_key(
:ammo_type,
%{ammo_type: %{name: ammo_type_name} = ammo_type},
%{ammo_type: ammo_type_block}
) do
assigns = %{ammo_type: ammo_type, ammo_type_block: ammo_type_block}
{ammo_type_name,
~H"""
<%= render_slot(@ammo_type_block, @ammo_type) %>
"""}
end
defp get_value_for_key(:price_paid, %{price_paid: nil}, _additional_data), do: {"", nil}
defp get_value_for_key(:price_paid, %{price_paid: price_paid}, _additional_data),
do: gettext("$%{amount}", amount: price_paid |> :erlang.float_to_binary(decimals: 2))
defp get_value_for_key(:purchased_on, %{purchased_on: purchased_on}, _additional_data) do
assigns = %{purchased_on: purchased_on}
{purchased_on,
~H"""
<.date date={@purchased_on} />
"""}
end
defp get_value_for_key(:used_up_on, ammo_group, _additional_data) do
last_shot_group_date =
case ammo_group |> Ammo.get_last_used_shot_group() do
%{date: last_shot_group_date} -> last_shot_group_date
_no_shot_groups -> nil
end
assigns = %{last_shot_group_date: last_shot_group_date}
{last_shot_group_date,
~H"""
<%= if @last_shot_group_date do %>
<.date date={@last_shot_group_date} />
<% else %>
<%= gettext("Never used") %>
<% end %>
"""}
end
defp get_value_for_key(:range, %{staged: staged} = ammo_group, %{range: range}) do
assigns = %{range: range, ammo_group: ammo_group}
{staged,
~H"""
<%= render_slot(@range, @ammo_group) %>
"""}
end
defp get_value_for_key(:remaining, ammo_group, _additional_data),
do: gettext("%{percentage}%", percentage: ammo_group |> Ammo.get_percentage_remaining())
defp get_value_for_key(:actions, ammo_group, %{actions: actions}) do
assigns = %{actions: actions, ammo_group: ammo_group}
~H"""
<%= render_slot(@actions, @ammo_group) %>
"""
end
defp get_value_for_key(:container, %{container: nil}, _additional_data), do: {nil, nil}
defp get_value_for_key(
:container,
%{container: %{name: container_name}} = ammo_group,
%{container: container}
) do
assigns = %{container: container, ammo_group: ammo_group}
{container_name,
~H"""
<%= render_slot(@container, @ammo_group) %>
"""}
end
defp get_value_for_key(:original_count, ammo_group, _additional_data),
do: ammo_group |> Ammo.get_original_count()
defp get_value_for_key(:cpr, %{price_paid: nil}, _additional_data),
do: gettext("No cost information")
defp get_value_for_key(:cpr, ammo_group, _additional_data) do
gettext("$%{amount}",
amount: ammo_group |> Ammo.get_cpr() |> :erlang.float_to_binary(decimals: 2)
)
end
defp get_value_for_key(:count, %{count: count}, _additional_data),
do: if(count == 0, do: gettext("Empty"), else: count)
defp get_value_for_key(key, ammo_group, _additional_data), do: ammo_group |> Map.get(key)
end

View File

@ -0,0 +1,209 @@
defmodule CanneryWeb.Components.AmmoTypeTableComponent do
@moduledoc """
A component that displays a list of ammo type
"""
use CanneryWeb, :live_component
alias Cannery.{Accounts.User, Ammo, Ammo.AmmoType}
alias Ecto.UUID
alias Phoenix.LiveView.{Rendered, Socket}
@impl true
@spec update(
%{
required(:id) => UUID.t(),
required(:current_user) => User.t(),
optional(:show_used) => boolean(),
optional(:ammo_types) => [AmmoType.t()],
optional(:actions) => Rendered.t(),
optional(any()) => any()
},
Socket.t()
) :: {:ok, Socket.t()}
def update(%{id: _id, ammo_types: _ammo_types, current_user: _current_user} = assigns, socket) do
socket =
socket
|> assign(assigns)
|> assign_new(:show_used, fn -> false end)
|> assign_new(:actions, fn -> [] end)
|> display_ammo_types()
{:ok, socket}
end
defp display_ammo_types(
%{
assigns: %{
ammo_types: ammo_types,
current_user: current_user,
show_used: show_used,
actions: actions
}
} = socket
) do
columns =
[
%{label: gettext("Name"), key: :name, type: :name},
%{label: gettext("Bullet type"), key: :bullet_type, type: :string},
%{label: gettext("Bullet core"), key: :bullet_core, type: :string},
%{label: gettext("Cartridge"), key: :cartridge, type: :string},
%{label: gettext("Caliber"), key: :caliber, type: :string},
%{label: gettext("Case material"), key: :case_material, type: :string},
%{label: gettext("Jacket type"), key: :jacket_type, type: :string},
%{label: gettext("Muzzle velocity"), key: :muzzle_velocity, 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("Grains"), key: :grains, type: :string},
%{label: gettext("Pressure"), key: :pressure, 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: :boolean},
%{label: gettext("Incendiary"), key: :incendiary, type: :boolean},
%{label: gettext("Blank"), key: :blank, type: :boolean},
%{label: gettext("Corrosive"), key: :corrosive, type: :boolean},
%{label: gettext("Manufacturer"), key: :manufacturer, type: :string},
%{label: gettext("UPC"), key: "upc", type: :string}
]
|> Enum.filter(fn %{key: key, type: type} ->
# remove columns if all values match defaults
default_value = if type == :boolean, do: false, else: nil
ammo_types
|> Enum.any?(fn ammo_type ->
not (ammo_type |> Map.get(key) == default_value)
end)
end)
|> Kernel.++([
%{label: gettext("Rounds"), key: :round_count, type: :round_count}
])
|> Kernel.++(
if show_used do
[
%{
label: gettext("Used rounds"),
key: :used_round_count,
type: :used_round_count
},
%{
label: gettext("Total ever rounds"),
key: :historical_round_count,
type: :historical_round_count
}
]
else
[]
end
)
|> Kernel.++([%{label: gettext("Packs"), key: :ammo_count, type: :ammo_count}])
|> Kernel.++(
if show_used do
[
%{
label: gettext("Used packs"),
key: :used_ammo_count,
type: :used_ammo_count
},
%{
label: gettext("Total ever packs"),
key: :historical_ammo_count,
type: :historical_ammo_count
}
]
else
[]
end
)
|> Kernel.++([
%{label: gettext("Average CPR"), key: :avg_price_paid, type: :avg_price_paid},
%{label: nil, key: "actions", type: :actions, sortable: false}
])
extra_data = %{actions: actions, current_user: current_user}
rows =
ammo_types
|> Enum.map(fn ammo_type ->
ammo_type |> get_ammo_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={CanneryWeb.Components.TableComponent}
id={"table-#{@id}"}
columns={@columns}
rows={@rows}
/>
</div>
"""
end
defp get_ammo_type_values(ammo_type, columns, extra_data) do
columns
|> Map.new(fn %{key: key, type: type} ->
{key, get_ammo_type_value(type, key, ammo_type, extra_data)}
end)
end
defp get_ammo_type_value(:boolean, key, ammo_type, _other_data),
do: ammo_type |> Map.get(key) |> humanize()
defp get_ammo_type_value(:round_count, _key, ammo_type, %{current_user: current_user}),
do: ammo_type |> Ammo.get_round_count_for_ammo_type(current_user)
defp get_ammo_type_value(:historical_round_count, _key, ammo_type, %{current_user: current_user}),
do: ammo_type |> Ammo.get_historical_count_for_ammo_type(current_user)
defp get_ammo_type_value(:used_round_count, _key, ammo_type, %{current_user: current_user}),
do: ammo_type |> Ammo.get_used_count_for_ammo_type(current_user)
defp get_ammo_type_value(:historical_ammo_count, _key, ammo_type, %{current_user: current_user}),
do: ammo_type |> Ammo.get_ammo_groups_count_for_type(current_user, true)
defp get_ammo_type_value(:used_ammo_count, _key, ammo_type, %{current_user: current_user}),
do: ammo_type |> Ammo.get_used_ammo_groups_count_for_type(current_user)
defp get_ammo_type_value(:ammo_count, _key, ammo_type, %{current_user: current_user}),
do: ammo_type |> Ammo.get_ammo_groups_count_for_type(current_user)
defp get_ammo_type_value(:avg_price_paid, _key, ammo_type, %{current_user: current_user}) do
case ammo_type |> Ammo.get_average_cost_for_ammo_type!(current_user) do
nil -> gettext("No cost information")
count -> gettext("$%{amount}", amount: count |> :erlang.float_to_binary(decimals: 2))
end
end
defp get_ammo_type_value(:name, _key, ammo_type, _other_data) do
assigns = %{ammo_type: ammo_type}
~H"""
<.link
navigate={Routes.ammo_type_show_path(Endpoint, :show, @ammo_type)}
class="link"
data-qa={"view-name-#{@ammo_type.id}"}
>
<%= @ammo_type.name %>
</.link>
"""
end
defp get_ammo_type_value(:actions, _key, ammo_type, %{actions: actions}) do
assigns = %{actions: actions, ammo_type: ammo_type}
~H"""
<%= render_slot(@actions, @ammo_type) %>
"""
end
defp get_ammo_type_value(nil, _key, _ammo_type, _other_data), do: nil
defp get_ammo_type_value(_other, key, ammo_type, _other_data), do: ammo_type |> Map.get(key)
end

View File

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

View File

@ -3,7 +3,8 @@ defmodule CanneryWeb.Components.ContainerTableComponent do
A component that displays a list of containers
"""
use CanneryWeb, :live_component
alias Cannery.{Accounts.User, Ammo, Containers.Container}
alias Cannery.{Accounts.User, Containers, Containers.Container, Repo}
alias CanneryWeb.Components.TagCard
alias Ecto.UUID
alias Phoenix.LiveView.{Rendered, Socket}
@ -45,7 +46,11 @@ defmodule CanneryWeb.Components.ContainerTableComponent do
%{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}
%{label: gettext("Type"), key: :type, type: :string},
%{label: gettext("Packs"), key: :packs, type: :integer},
%{label: gettext("Rounds"), key: :rounds, type: :string},
%{label: gettext("Tags"), key: :tags, type: :tags},
%{label: nil, key: :actions, sortable: false, type: :actions}
]
|> Enum.filter(fn %{key: key, type: type} ->
# remove columns if all values match defaults
@ -60,27 +65,11 @@ defmodule CanneryWeb.Components.ContainerTableComponent do
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
)
actions: actions
}
rows =
@ -112,43 +101,45 @@ defmodule CanneryWeb.Components.ContainerTableComponent do
@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
container = container |> Repo.preload([:ammo_groups, :tags])
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
defp get_value_for_key(:name, %{id: id, name: container_name}, _extra_data) do
assigns = %{id: id, container_name: container_name}
{container_name,
~H"""
<div class="flex flex-wrap justify-center items-center">
<.link navigate={~p"/container/#{@id}"} class="link">
<%= @name %>
<.link navigate={Routes.container_show_path(Endpoint, :show, @id)} class="link">
<%= @container_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)
defp get_value_for_key(:packs, container, _extra_data) do
container |> Containers.get_container_ammo_group_count!()
end
defp get_value_for_key(:rounds, %{id: container_id}, %{round_count: round_count}) do
round_count |> Map.get(container_id, 0)
defp get_value_for_key(:rounds, container, _extra_data) do
container |> Containers.get_container_rounds!()
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,
{container.tags |> Enum.map(fn %{name: name} -> name end),
~H"""
<div class="flex flex-wrap justify-center items-center">
<.simple_tag_card :for={tag <- @container.tags} :if={@container.tags} tag={tag} />
<%= unless @container.tags |> Enum.empty?() do %>
<%= for tag <- @container.tags do %>
<TagCard.simple_tag_card tag={tag} />
<% end %>
<% end %>
<%= render_slot(@tag_actions, @container) %>
</div>

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,67 @@
defmodule CanneryWeb.Components.InviteCard do
@moduledoc """
Display card for an invite
"""
use CanneryWeb, :component
alias Cannery.Invites.Invite
alias CanneryWeb.Endpoint
attr :invite, Invite, required: true
slot(:inner_block)
slot(:code_actions)
def invite_card(assigns) do
assigns = assigns |> assign_new(:code_actions, fn -> [] end)
~H"""
<div
id={"invite-#{@invite.id}"}
class="mx-4 my-2 px-8 py-4 flex flex-col justify-center items-center space-y-4
border border-gray-400 rounded-lg shadow-lg hover:shadow-md
transition-all duration-300 ease-in-out"
>
<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}",
uses_left: @invite.uses_left
) %>
<% else %>
<%= gettext("Uses Left: Unlimited") %>
<% end %>
</h2>
<% else %>
<h2 class="title text-md">
<%= gettext("Invite Disabled") %>
</h2>
<% end %>
<.qr_code
content={Routes.user_registration_url(Endpoint, :new, invite: @invite.token)}
filename={@invite.name}
/>
<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
><%= Routes.user_registration_url(Endpoint, :new, invite: @invite.token) %></code>
<%= render_slot(@code_actions) %>
</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 +0,0 @@
<%= @inner_block %>

View File

@ -1,27 +1,28 @@
defmodule CanneryWeb.Components.MovePackComponent do
defmodule CanneryWeb.Components.MoveAmmoGroupComponent do
@moduledoc """
Livecomponent that can move a pack to another container
Livecomponent that can move an ammo group to another container
"""
use CanneryWeb, :live_component
alias Cannery.{Accounts.User, Ammo, Ammo.Pack, Containers, Containers.Container}
alias Ecto.Changeset
alias Cannery.{Accounts.User, Ammo, Ammo.AmmoGroup, Containers, Containers.Container}
alias CanneryWeb.Endpoint
alias Phoenix.LiveView.Socket
@impl true
@spec update(
%{
required(:current_user) => User.t(),
required(:pack) => Pack.t(),
required(:ammo_group) => AmmoGroup.t(),
optional(any()) => any()
},
Socket.t()
) :: {:ok, Socket.t()}
def update(
%{pack: %{container_id: container_id} = pack, current_user: current_user} = assigns,
%{ammo_group: %{container_id: container_id} = ammo_group, current_user: current_user} =
assigns,
socket
) do
changeset = pack |> Pack.update_changeset(%{}, current_user)
changeset = ammo_group |> AmmoGroup.update_changeset(%{}, current_user)
containers =
Containers.list_containers(current_user)
@ -39,19 +40,21 @@ defmodule CanneryWeb.Components.MovePackComponent do
def handle_event(
"move",
%{"container_id" => container_id},
%{assigns: %{pack: pack, current_user: current_user, return_to: return_to}} = socket
%{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 =
pack
|> Ammo.update_pack(%{"container_id" => container_id}, current_user)
ammo_group
|> Ammo.update_ammo_group(%{"container_id" => container_id}, current_user)
|> case do
{:ok, _pack} ->
{:ok, _ammo_group} ->
prompt = dgettext("prompts", "Ammo moved to %{name} successfully", name: container_name)
socket |> put_flash(:info, prompt) |> push_navigate(to: return_to)
{:error, %Changeset{} = changeset} ->
{:error, %Ecto.Changeset{} = changeset} ->
socket |> assign(changeset: changeset)
end
@ -61,10 +64,10 @@ defmodule CanneryWeb.Components.MovePackComponent do
@impl true
def render(%{containers: containers} = assigns) do
columns = [
%{label: gettext("Container"), key: :name},
%{label: gettext("Type"), key: :type},
%{label: gettext("Location"), key: :location},
%{label: gettext("Actions"), key: :actions, sortable: false}
%{label: gettext("Container"), key: "name"},
%{label: gettext("Type"), key: "type"},
%{label: gettext("Location"), key: "location"},
%{label: nil, key: "actions", sortable: false}
]
rows = containers |> get_rows_for_containers(assigns, columns)
@ -83,13 +86,13 @@ defmodule CanneryWeb.Components.MovePackComponent do
<%= display_emoji("😔") %>
</h2>
<.link navigate={~p"/containers/new"} class="btn btn-primary">
<.link navigate={Routes.container_index_path(Endpoint, :new)} class="btn btn-primary">
<%= dgettext("actions", "Add another container!") %>
</.link>
<% else %>
<.live_component
module={CanneryWeb.Components.TableComponent}
id="move-pack-table"
id="move_ammo_group_table"
columns={@columns}
rows={@rows}
/>
@ -107,8 +110,8 @@ defmodule CanneryWeb.Components.MovePackComponent do
end)
end
@spec get_row_value_by_key(atom(), Container.t(), map()) :: any()
defp get_row_value_by_key(:actions, container, assigns) do
@spec get_row_value_by_key(String.t(), Container.t(), map()) :: any()
defp get_row_value_by_key("actions", container, assigns) do
assigns = assigns |> Map.put(:container, container)
~H"""
@ -126,5 +129,6 @@ defmodule CanneryWeb.Components.MovePackComponent do
"""
end
defp get_row_value_by_key(key, container, _assigns), do: container |> Map.get(key)
defp get_row_value_by_key(key, container, _assigns),
do: container |> Map.get(key |> String.to_existing_atom())
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

@ -0,0 +1,121 @@
defmodule CanneryWeb.Components.ShotGroupTableComponent do
@moduledoc """
A component that displays a list of shot groups
"""
use CanneryWeb, :live_component
alias Cannery.{Accounts.User, ActivityLog.ShotGroup, Repo}
alias Ecto.UUID
alias Phoenix.LiveView.{Rendered, Socket}
@impl true
@spec update(
%{
required(:id) => UUID.t(),
required(:current_user) => User.t(),
optional(:shot_groups) => [ShotGroup.t()],
optional(:actions) => Rendered.t(),
optional(any()) => any()
},
Socket.t()
) :: {:ok, Socket.t()}
def update(%{id: _id, shot_groups: _shot_groups, current_user: _current_user} = assigns, socket) do
socket =
socket
|> assign(assigns)
|> assign_new(:actions, fn -> [] end)
|> display_shot_groups()
{:ok, socket}
end
defp display_shot_groups(
%{
assigns: %{
shot_groups: shot_groups,
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},
%{label: nil, key: :actions, sortable: false}
]
extra_data = %{current_user: current_user, actions: actions}
rows =
shot_groups
|> Enum.map(fn shot_group ->
shot_group |> get_row_data_for_shot_group(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}
initial_key={:date}
initial_sort_mode={:desc}
/>
</div>
"""
end
@spec get_row_data_for_shot_group(ShotGroup.t(), columns :: [map()], extra_data :: map()) ::
map()
defp get_row_data_for_shot_group(shot_group, columns, extra_data) do
shot_group = shot_group |> Repo.preload(ammo_group: :ammo_type)
columns
|> Map.new(fn %{key: key} ->
{key, get_row_value(key, shot_group, extra_data)}
end)
end
defp get_row_value(
:name,
%{ammo_group: %{ammo_type: %{name: ammo_type_name} = ammo_group}},
_extra_data
) do
assigns = %{ammo_group: ammo_group, ammo_type_name: ammo_type_name}
name_block = ~H"""
<.link navigate={Routes.ammo_group_show_path(Endpoint, :show, @ammo_group)} class="link">
<%= @ammo_type_name %>
</.link>
"""
{ammo_type_name, name_block}
end
defp get_row_value(:date, %{date: _date} = assigns, _extra_data) do
~H"""
<.date date={@date} />
"""
end
defp get_row_value(:actions, shot_group, %{actions: actions}) do
assigns = %{actions: actions, shot_group: shot_group}
~H"""
<%= render_slot(@actions, @shot_group) %>
"""
end
defp get_row_value(key, shot_group, _extra_data), do: shot_group |> Map.get(key)
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

@ -33,8 +33,7 @@ defmodule CanneryWeb.Components.TableComponent do
optional(:class) => String.t(),
optional(:row_class) => String.t(),
optional(:alternate_row_class) => String.t(),
optional(:sortable) => false,
optional(:type) => module()
optional(:sortable) => false
}),
required(:rows) =>
list(%{
@ -61,8 +60,7 @@ defmodule CanneryWeb.Components.TableComponent do
: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)
rows = rows |> sort_by_custom_sort_value_or_value(initial_key, initial_sort_mode)
socket =
socket
@ -70,7 +68,6 @@ defmodule CanneryWeb.Components.TableComponent do
|> assign(
columns: columns,
rows: rows,
key: initial_key,
last_sort_key: initial_key,
sort_mode: initial_sort_mode
)
@ -84,14 +81,7 @@ defmodule CanneryWeb.Components.TableComponent do
def handle_event(
"sort_by",
%{"sort-key" => key},
%{
assigns: %{
columns: columns,
rows: rows,
last_sort_key: last_sort_key,
sort_mode: sort_mode
}
} = socket
%{assigns: %{rows: rows, last_sort_key: last_sort_key, sort_mode: sort_mode}} = socket
) do
key = key |> String.to_existing_atom()
@ -102,28 +92,11 @@ defmodule CanneryWeb.Components.TableComponent do
{_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)
rows = rows |> sort_by_custom_sort_value_or_value(key, sort_mode)
{: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
defp sort_by_custom_sort_value_or_value(rows, key, sort_mode) do
rows
|> Enum.sort_by(
fn row ->
@ -135,25 +108,4 @@ defmodule CanneryWeb.Components.TableComponent do
sort_mode
)
end
@doc """
Conditionally composes elements into the columns list, supports maps and
lists. Works tail to front in order for efficiency
iex> []
...> |> maybe_compose_columns(%{label: "Column 3"}, true)
...> |> maybe_compose_columns(%{label: "Column 2"}, false)
...> |> maybe_compose_columns(%{label: "Column 1"})
[%{label: "Column 1"}, %{label: "Column 3"}]
"""
@spec maybe_compose_columns(list(), element_to_add :: list() | map()) :: list()
@spec maybe_compose_columns(list(), element_to_add :: list() | map(), boolean()) :: list()
def maybe_compose_columns(columns, element_or_elements, add? \\ true)
def maybe_compose_columns(columns, elements, true) when is_list(elements),
do: Enum.concat(elements, columns)
def maybe_compose_columns(columns, element, true) when is_map(element), do: [element | columns]
def maybe_compose_columns(columns, _element_or_elements, false), do: columns
end

View File

@ -34,19 +34,20 @@
</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 %>
<%= for {values, i} <- @rows |> Enum.with_index() do %>
<tr class={if i |> Integer.is_even(), do: @row_class, else: @alternate_row_class}>
<%= for %{key: key} = value <- @columns do %>
<td class={["p-2", value[:class]]}>
<%= case values |> Map.get(key) do %>
<% {_custom_sort_value, value} -> %>
<%= value %>
<% value -> %>
<%= value %>
<% end %>
</td>
<% end %>
</td>
</tr>
</tr>
<% end %>
</tbody>
</table>
</div>

View File

@ -0,0 +1,38 @@
defmodule CanneryWeb.Components.TagCard do
@moduledoc """
Display card for a tag
"""
use CanneryWeb, :component
alias Cannery.Tags.Tag
attr :tag, Tag, required: true
slot(:inner_block, required: true)
def tag_card(assigns) do
~H"""
<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>
"""
end
attr :tag, Tag, required: true
def simple_tag_card(assigns) do
~H"""
<h1
class="inline-block break-all mx-2 my-1 px-4 py-2 rounded-lg title text-xl"
style={"color: #{@tag.text_color}; background-color: #{@tag.bg_color}"}
>
<%= @tag.name %>
</h1>
"""
end
end

View File

@ -0,0 +1,146 @@
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">
<.link
navigate={Routes.live_path(Endpoint, HomeLive)}
class="inline mx-2 my-1 leading-5 text-xl text-white"
>
<img
src={Routes.static_path(Endpoint, "/images/cannery.svg")}
alt={gettext("Cannery logo")}
class="inline-block h-8 mx-1"
/>
<h1 class="inline hover:underline">Cannery</h1>
</.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={Routes.tag_index_path(Endpoint, :index)}
class="text-primary-600 text-white hover:underline"
>
<%= gettext("Tags") %>
</.link>
</li>
<li class="mx-2 my-1">
<.link
navigate={Routes.container_index_path(Endpoint, :index)}
class="text-primary-600 text-white hover:underline"
>
<%= gettext("Containers") %>
</.link>
</li>
<li class="mx-2 my-1">
<.link
navigate={Routes.ammo_type_index_path(Endpoint, :index)}
class="text-primary-600 text-white hover:underline"
>
<%= gettext("Catalog") %>
</.link>
</li>
<li class="mx-2 my-1">
<.link
navigate={Routes.ammo_group_index_path(Endpoint, :index)}
class="text-primary-600 text-white hover:underline"
>
<%= gettext("Ammo") %>
</.link>
</li>
<li class="mx-2 my-1">
<.link
navigate={Routes.range_index_path(Endpoint, :index)}
class="text-primary-600 text-white hover:underline"
>
<%= gettext("Range") %>
</.link>
</li>
<%= if @current_user.role == :admin do %>
<li class="mx-2 my-1">
<.link
navigate={Routes.invite_index_path(Endpoint, :index)}
class="text-primary-600 text-white hover:underline"
>
<%= gettext("Invites") %>
</.link>
</li>
<% end %>
<li class="mx-2 my-1">
<.link
navigate={Routes.user_settings_path(Endpoint, :edit)}
class="text-primary-600 text-white hover:underline truncate"
>
<%= @current_user.email %>
</.link>
</li>
<li class="mx-2 my-1">
<.link
href={Routes.user_session_path(Endpoint, :delete)}
method="delete"
data-confirm={dgettext("prompts", "Are you sure you want to log out?")}
>
<i class="fas fa-sign-out-alt"></i>
</.link>
</li>
<%= if @current_user.role == :admin and function_exported?(Routes, :live_dashboard_path, 2) do %>
<li class="mx-2 my-1">
<.link
navigate={Routes.live_dashboard_path(Endpoint, :home)}
class="text-primary-600 text-white hover:underline"
>
<i class="fas fa-gauge"></i>
</.link>
</li>
<% end %>
<% else %>
<%= if Accounts.allow_registration?() do %>
<li class="mx-2 my-1">
<.link
navigate={Routes.user_registration_path(Endpoint, :new)}
class="text-primary-600 text-white hover:underline truncate"
>
<%= dgettext("actions", "Register") %>
</.link>
</li>
<% end %>
<li class="mx-2 my-1">
<.link
navigate={Routes.user_session_path(Endpoint, :new)}
class="text-primary-600 text-white hover:underline truncate"
>
<%= dgettext("actions", "Log in") %>
</.link>
</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,54 @@
defmodule CanneryWeb.Components.UserCard do
@moduledoc """
Display card for a user
"""
use CanneryWeb, :component
alias Cannery.Accounts.User
attr :user, User, required: true
slot(:inner_block, required: true)
def user_card(assigns) do
~H"""
<div
id={"user-#{@user.id}"}
class="mx-4 my-2 px-8 py-4 flex flex-col justify-center items-center text-center
border border-gray-400 rounded-lg shadow-lg hover:shadow-md
transition-all duration-300 ease-in-out"
>
<h1 class="px-4 py-2 rounded-lg title text-xl break-all">
<%= @user.email %>
</h1>
<h3 class="px-4 py-2 rounded-lg title text-lg">
<p>
<%= if @user.confirmed_at do %>
<%= gettext(
"User was confirmed at%{confirmed_datetime}",
confirmed_datetime: ""
) %>
<.datetime datetime={@user.confirmed_at} />
<% else %>
<%= gettext("Email unconfirmed") %>
<% end %>
</p>
<p>
<%= gettext(
"User registered on%{registered_datetime}",
registered_datetime: ""
) %>
<.datetime datetime={@user.inserted_at} />
</p>
</h3>
<%= if @inner_block do %>
<div class="px-4 py-2 flex space-x-4 justify-center items-center">
<%= render_slot(@inner_block) %>
</div>
<% end %>
</div>
"""
end
end

View File

@ -6,8 +6,7 @@ defmodule CanneryWeb.EmailController do
use CanneryWeb, :controller
alias Cannery.Accounts.User
plug :put_root_layout, html: {CanneryWeb.Layouts, :email_html}
plug :put_layout, false
plug :put_layout, {CanneryWeb.LayoutView, :email}
@sample_assigns %{
email: %{subject: "Example subject"},
@ -19,6 +18,6 @@ defmodule CanneryWeb.EmailController do
Debug route used to preview emails
"""
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

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

View File

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

View File

@ -3,80 +3,66 @@ defmodule CanneryWeb.ExportController do
alias Cannery.{ActivityLog, Ammo, Containers}
def export(%{assigns: %{current_user: current_user}} = conn, %{"mode" => "json"}) do
types = Ammo.list_types(current_user)
ammo_types =
Ammo.list_ammo_types(current_user)
|> Enum.map(fn ammo_type ->
average_cost = ammo_type |> Ammo.get_average_cost_for_ammo_type!(current_user)
round_count = ammo_type |> Ammo.get_round_count_for_ammo_type(current_user)
used_count = ammo_type |> Ammo.get_used_count_for_ammo_type(current_user)
ammo_group_count = ammo_type |> Ammo.get_ammo_groups_count_for_type(current_user, true)
used_counts =
ActivityLog.get_grouped_used_counts(current_user, types: types, group_by: :type_id)
round_counts = Ammo.get_grouped_round_count(current_user, types: types, group_by: :type_id)
pack_counts = Ammo.get_grouped_packs_count(current_user, types: types, group_by: :type_id)
total_pack_counts =
Ammo.get_grouped_packs_count(current_user,
types: types,
group_by: :type_id,
show_used: true
)
average_costs = Ammo.get_average_costs(types, current_user)
types =
types
|> Enum.map(fn %{id: type_id} = type ->
type
ammo_type
|> Jason.encode!()
|> Jason.decode!()
|> Map.merge(%{
"average_cost" => Map.get(average_costs, type_id),
"round_count" => Map.get(round_counts, type_id, 0),
"used_count" => Map.get(used_counts, type_id, 0),
"pack_count" => Map.get(pack_counts, type_id, 0),
"total_pack_count" => Map.get(total_pack_counts, type_id, 0)
"average_cost" => average_cost,
"round_count" => round_count,
"used_count" => used_count,
"ammo_group_count" => ammo_group_count
})
end)
packs = Ammo.list_packs(current_user, show_used: true)
ammo_groups =
Ammo.list_ammo_groups(nil, true, current_user)
|> Enum.map(fn ammo_group ->
cpr = ammo_group |> Ammo.get_cpr()
used_count = ammo_group |> Ammo.get_used_count()
original_count = ammo_group |> Ammo.get_original_count()
percentage_remaining = ammo_group |> Ammo.get_percentage_remaining()
used_counts =
ActivityLog.get_grouped_used_counts(current_user, packs: packs, group_by: :pack_id)
original_counts = packs |> Ammo.get_original_counts(current_user)
cprs = packs |> Ammo.get_cprs(current_user)
percentages_remaining = packs |> Ammo.get_percentages_remaining(current_user)
packs =
packs
|> Enum.map(fn %{id: pack_id} = pack ->
pack
ammo_group
|> Jason.encode!()
|> Jason.decode!()
|> Map.merge(%{
"used_count" => Map.get(used_counts, pack_id),
"percentage_remaining" => Map.fetch!(percentages_remaining, pack_id),
"original_count" => Map.get(original_counts, pack_id),
"cpr" => Map.get(cprs, pack_id)
"used_count" => used_count,
"percentage_remaining" => percentage_remaining,
"original_count" => original_count,
"cpr" => cpr
})
end)
shot_records = ActivityLog.list_shot_records(current_user)
shot_groups = ActivityLog.list_shot_groups(current_user)
containers =
Containers.list_containers(current_user)
|> Enum.map(fn container ->
ammo_group_count = container |> Containers.get_container_ammo_group_count!()
round_count = container |> Containers.get_container_rounds!()
container
|> Jason.encode!()
|> Jason.decode!()
|> Map.merge(%{
"pack_count" => Ammo.get_packs_count(current_user, container_id: container.id),
"round_count" => Ammo.get_round_count(current_user, container_id: container.id)
"ammo_group_count" => ammo_group_count,
"round_count" => round_count
})
end)
json(conn, %{
user: current_user,
types: types,
packs: packs,
shot_records: shot_records,
ammo_types: ammo_types,
ammo_groups: ammo_groups,
shot_groups: shot_groups,
containers: containers
})
end

View File

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

View File

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

View File

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

View File

@ -1,16 +1,18 @@
defmodule CanneryWeb.UserConfirmationController do
use CanneryWeb, :controller
import CanneryWeb.Gettext
alias Cannery.Accounts
def new(conn, _params) do
render(conn, :new, page_title: gettext("Confirm your account"))
render(conn, "new.html", page_title: gettext("Confirm your account"))
end
def create(conn, %{"user" => %{"email" => email}}) do
if user = Accounts.get_user_by_email(email) do
Accounts.deliver_user_confirmation_instructions(
user,
fn token -> url(CanneryWeb.Endpoint, ~p"/users/confirm/#{token}") end
&Routes.user_confirmation_url(conn, :confirm, &1)
)
end
@ -20,10 +22,11 @@ defmodule CanneryWeb.UserConfirmationController do
:info,
dgettext(
"prompts",
"If your email is in our system and it has not been confirmed yet, you will receive an email with instructions shortly."
"If your email is in our system and it has not been confirmed yet, " <>
"you will receive an email with instructions shortly."
)
)
|> redirect(to: ~p"/")
|> redirect(to: "/")
end
# Do not log in the user after confirmation to avoid a
@ -33,7 +36,7 @@ defmodule CanneryWeb.UserConfirmationController do
{:ok, %{email: email}} ->
conn
|> put_flash(:info, dgettext("prompts", "%{email} confirmed successfully.", email: email))
|> redirect(to: ~p"/")
|> redirect(to: "/")
:error ->
# If there is a current user and the account was already confirmed,
@ -42,7 +45,7 @@ defmodule CanneryWeb.UserConfirmationController do
# a warning message.
case conn.assigns do
%{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) ->
redirect(conn, to: ~p"/")
redirect(conn, to: "/")
%{} ->
conn
@ -50,7 +53,7 @@ defmodule CanneryWeb.UserConfirmationController do
:error,
dgettext("errors", "User confirmation link is invalid or it has expired.")
)
|> redirect(to: ~p"/")
|> redirect(to: "/")
end
end
end

View File

@ -1,6 +0,0 @@
defmodule CanneryWeb.UserConfirmationHTML do
use CanneryWeb, :html
alias Cannery.Accounts
embed_templates "user_confirmation_html/*"
end

View File

@ -1,31 +0,0 @@
<div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4">
<h1 class="title text-primary-600 text-xl">
<%= dgettext("actions", "Resend confirmation instructions") %>
</h1>
<.form
:let={f}
for={%{}}
as={:user}
action={~p"/users/confirm"}
class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center"
>
<%= label(f, :email, gettext("Email"), class: "title text-lg text-primary-600") %>
<%= email_input(f, :email, required: true, class: "input input-primary col-span-2") %>
<%= submit(dgettext("actions", "Resend confirmation instructions"),
class: "mx-auto btn btn-primary col-span-3"
) %>
</.form>
<hr class="hr" />
<div class="flex flex-row justify-center items-center space-x-4">
<.link :if={Accounts.allow_registration?()} href={~p"/users/register"} class="btn btn-primary">
<%= dgettext("actions", "Register") %>
</.link>
<.link href={~p"/users/log_in"} class="btn btn-primary">
<%= dgettext("actions", "Log in") %>
</.link>
</div>
</div>

View File

@ -1,15 +1,18 @@
defmodule CanneryWeb.UserRegistrationController do
use CanneryWeb, :controller
alias Cannery.{Accounts, Accounts.Invites}
alias Ecto.Changeset
import CanneryWeb.Gettext
alias Cannery.{Accounts, Invites}
alias CanneryWeb.{Endpoint, HomeLive}
def new(conn, %{"invite" => invite_token}) do
if Invites.valid_invite_token?(invite_token) do
conn |> render_new(invite_token)
invite = Invites.get_invite_by_token(invite_token)
if invite do
conn |> render_new(invite)
else
conn
|> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired"))
|> redirect(to: ~p"/")
|> redirect(to: Routes.live_path(Endpoint, HomeLive))
end
end
@ -19,26 +22,28 @@ defmodule CanneryWeb.UserRegistrationController do
else
conn
|> put_flash(:error, dgettext("errors", "Sorry, public registration is disabled"))
|> redirect(to: ~p"/")
|> redirect(to: Routes.live_path(Endpoint, HomeLive))
end
end
# renders new user registration page
defp render_new(conn, invite_token \\ nil) do
render(conn, :new,
defp render_new(conn, invite \\ nil) do
render(conn, "new.html",
changeset: Accounts.change_user_registration(),
invite_token: invite_token,
invite: invite,
page_title: gettext("Register")
)
end
def create(conn, %{"user" => %{"invite_token" => invite_token}} = attrs) do
if Invites.valid_invite_token?(invite_token) do
conn |> create_user(attrs, invite_token)
invite = Invites.get_invite_by_token(invite_token)
if invite do
conn |> create_user(attrs, invite)
else
conn
|> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired"))
|> redirect(to: ~p"/")
|> redirect(to: Routes.live_path(Endpoint, HomeLive))
end
end
@ -48,29 +53,28 @@ defmodule CanneryWeb.UserRegistrationController do
else
conn
|> put_flash(:error, dgettext("errors", "Sorry, public registration is disabled"))
|> redirect(to: ~p"/")
|> redirect(to: Routes.live_path(Endpoint, HomeLive))
end
end
defp create_user(conn, %{"user" => user_params}, invite_token \\ nil) do
case Accounts.register_user(user_params, invite_token) do
defp create_user(conn, %{"user" => user_params}, invite \\ nil) do
case Accounts.register_user(user_params) do
{:ok, user} ->
unless invite |> is_nil() do
invite |> Invites.use_invite!()
end
Accounts.deliver_user_confirmation_instructions(
user,
fn token -> url(CanneryWeb.Endpoint, ~p"/users/confirm/#{token}") end
&Routes.user_confirmation_url(conn, :confirm, &1)
)
conn
|> put_flash(:info, dgettext("prompts", "Please check your email to verify your account"))
|> redirect(to: ~p"/users/log_in")
|> redirect(to: Routes.user_session_path(Endpoint, :new))
{:error, :invalid_token} ->
conn
|> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired"))
|> redirect(to: ~p"/")
{:error, %Changeset{} = changeset} ->
conn |> render(:new, changeset: changeset, invite_token: invite_token)
{:error, %Ecto.Changeset{} = changeset} ->
conn |> render("new.html", changeset: changeset, invite: invite)
end
end
end

View File

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

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