Compare commits
1 commit
8429fb2b9c
...
24e1d101ce
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24e1d101ce |
86 changed files with 753 additions and 3948 deletions
|
|
@ -1,9 +0,0 @@
|
||||||
# Temporarily ignored security advisories
|
|
||||||
#
|
|
||||||
# Format: one GHSA ID per line.
|
|
||||||
# Remove an entry once a patched version is available and the dependency is updated.
|
|
||||||
|
|
||||||
# cowlib >= 2.9.0 <= 2.16.1 — Cookie Request Header Injection via cow_cookie:cookie/1
|
|
||||||
# Severity: low. No patched version available as of 2026-05-20.
|
|
||||||
# Tracked upstream: https://github.com/advisories/GHSA-g2wm-735q-3f56
|
|
||||||
GHSA-g2wm-735q-3f56
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
# Dialyzer ignore list.
|
|
||||||
#
|
|
||||||
# This file is for PROVEN false positives only. Each entry must carry a
|
|
||||||
# `# why:` comment explaining why Dialyzer is wrong about the call site.
|
|
||||||
# Real findings get fixed by adjusting @spec, return types, or pattern
|
|
||||||
# matches — never silenced here.
|
|
||||||
#
|
|
||||||
# Format: each entry is either a path string, a {path, warning} tuple,
|
|
||||||
# or a {path, warning, line} tuple. See:
|
|
||||||
# https://hexdocs.pm/dialyxir/readme.html#elixir-format
|
|
||||||
[]
|
|
||||||
184
.drone.jsonnet
184
.drone.jsonnet
|
|
@ -1,184 +0,0 @@
|
||||||
local elixir = 'docker.io/library/elixir:1.18.3-otp-27';
|
|
||||||
local postgres_image = 'docker.io/library/postgres:18.3';
|
|
||||||
|
|
||||||
local pg_service = {
|
|
||||||
name: 'postgres',
|
|
||||||
image: postgres_image,
|
|
||||||
environment: {
|
|
||||||
POSTGRES_USER: 'postgres',
|
|
||||||
POSTGRES_PASSWORD: 'postgres',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
local cache_volume = { name: 'cache', host: { path: '/tmp/drone_cache' } };
|
|
||||||
local cache_mount = [{ name: 'cache', path: '/cache' }];
|
|
||||||
|
|
||||||
local step_compute_cache = {
|
|
||||||
name: 'compute cache key',
|
|
||||||
image: elixir,
|
|
||||||
commands: [
|
|
||||||
"mix_lock_hash=$(sha256sum mix.lock | cut -d ' ' -f 1)",
|
|
||||||
'echo "$DRONE_REPO_OWNER/$DRONE_REPO_NAME/$mix_lock_hash" >> .cache_key',
|
|
||||||
// Print cache key for debugging
|
|
||||||
'cat .cache_key',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
local step_restore_cache = {
|
|
||||||
name: 'restore-cache',
|
|
||||||
image: 'drillster/drone-volume-cache',
|
|
||||||
settings: { restore: true, mount: ['./deps', './_build', './priv/plts'], ttl: 30 },
|
|
||||||
volumes: cache_mount,
|
|
||||||
};
|
|
||||||
|
|
||||||
local step_lint = {
|
|
||||||
name: 'lint',
|
|
||||||
image: elixir,
|
|
||||||
commands: [
|
|
||||||
'mix local.hex --force', // Install hex package manager
|
|
||||||
'mix deps.get', // Fetch dependencies
|
|
||||||
'mix compile --warnings-as-errors', // Check for compilation errors & warnings
|
|
||||||
'mix format --check-formatted', // Check formatting
|
|
||||||
'mix sobelow --config', // Security checks
|
|
||||||
'mix deps.audit --ignore-file .deps_audit_ignore', // Known vulnerabilities
|
|
||||||
'mix hex.audit', // Unmaintained dependencies
|
|
||||||
'mix credo --strict', // Code quality hints
|
|
||||||
'mix gettext.extract --check-up-to-date', // Translations up to date
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
local step_typecheck = {
|
|
||||||
name: 'typecheck',
|
|
||||||
image: elixir,
|
|
||||||
commands: [
|
|
||||||
'mix local.hex --force',
|
|
||||||
'mix deps.get',
|
|
||||||
'mkdir -p priv/plts',
|
|
||||||
// Build/refresh PLT — no-op on cache hit, full build (5-15 min) on cache miss.
|
|
||||||
'mix dialyzer --plt',
|
|
||||||
// Actual typecheck. --format short keeps log noise down on red builds.
|
|
||||||
'mix dialyzer --format short',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
local step_wait_postgres = {
|
|
||||||
name: 'wait_for_postgres',
|
|
||||||
image: postgres_image,
|
|
||||||
commands: [
|
|
||||||
|||
|
|
||||||
for i in {1..20}; do
|
|
||||||
if pg_isready -h postgres -U postgres; then
|
|
||||||
exit 0
|
|
||||||
else
|
|
||||||
true
|
|
||||||
fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
echo "Postgres did not become available, aborting."
|
|
||||||
exit 1
|
|
||||||
|||,
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
local step_rebuild_cache = {
|
|
||||||
name: 'rebuild-cache',
|
|
||||||
image: 'drillster/drone-volume-cache',
|
|
||||||
settings: { rebuild: true, mount: ['./deps', './_build', './priv/plts'] },
|
|
||||||
volumes: cache_mount,
|
|
||||||
};
|
|
||||||
|
|
||||||
// test_cmd is the only thing that differs between the fast and full suites.
|
|
||||||
local test_step(name, test_cmd) = {
|
|
||||||
name: name,
|
|
||||||
image: elixir,
|
|
||||||
environment: {
|
|
||||||
MIX_ENV: 'test',
|
|
||||||
TEST_POSTGRES_HOST: 'postgres',
|
|
||||||
TEST_POSTGRES_PORT: '5432',
|
|
||||||
},
|
|
||||||
commands: ['mix local.hex --force', 'mix deps.get', test_cmd],
|
|
||||||
};
|
|
||||||
|
|
||||||
local test_fast = test_step('test-fast', 'mix test --exclude slow --exclude ui --max-cases 2');
|
|
||||||
local test_all = test_step('test-all', 'mix test');
|
|
||||||
|
|
||||||
// A full check pipeline: identical steps, only name + trigger + test step vary.
|
|
||||||
local check_pipeline(name, trigger, test) = {
|
|
||||||
kind: 'pipeline',
|
|
||||||
type: 'docker',
|
|
||||||
name: name,
|
|
||||||
services: [pg_service],
|
|
||||||
trigger: trigger,
|
|
||||||
steps: [
|
|
||||||
step_compute_cache,
|
|
||||||
step_restore_cache,
|
|
||||||
step_lint,
|
|
||||||
] + (if test.name == 'test-all' then [step_typecheck] else []) + [
|
|
||||||
step_wait_postgres,
|
|
||||||
test,
|
|
||||||
step_rebuild_cache,
|
|
||||||
],
|
|
||||||
volumes: [cache_volume],
|
|
||||||
};
|
|
||||||
|
|
||||||
local docker_publish(name, extra_settings, trigger_event, deps) = {
|
|
||||||
kind: 'pipeline',
|
|
||||||
type: 'docker',
|
|
||||||
name: name,
|
|
||||||
trigger: trigger_event,
|
|
||||||
steps: [{
|
|
||||||
name: 'build-and-publish-container' + (if name == 'build-and-publish' then '-branch' else ''),
|
|
||||||
image: 'plugins/docker',
|
|
||||||
settings: {
|
|
||||||
registry: 'git.local-it.org',
|
|
||||||
repo: 'git.local-it.org/local-it/mitgliederverwaltung',
|
|
||||||
username: { from_secret: 'DRONE_REGISTRY_USERNAME' },
|
|
||||||
password: { from_secret: 'DRONE_REGISTRY_TOKEN' },
|
|
||||||
} + extra_settings,
|
|
||||||
when: trigger_event,
|
|
||||||
}],
|
|
||||||
depends_on: deps,
|
|
||||||
};
|
|
||||||
|
|
||||||
[
|
|
||||||
check_pipeline('check-fast', { branch: { exclude: ['main'] }, event: ['push'] }, test_fast),
|
|
||||||
check_pipeline('check-full', { branch: ['main'], event: ['push'] }, test_all),
|
|
||||||
check_pipeline('check-full-promote', { event: ['promote'], target: ['production'] }, test_all),
|
|
||||||
check_pipeline('check-full-tag', { event: ['tag'] }, test_all),
|
|
||||||
|
|
||||||
docker_publish(
|
|
||||||
'build-and-publish',
|
|
||||||
{ tags: ['latest', '${DRONE_COMMIT_SHA:0:8}'] },
|
|
||||||
{ branch: ['main'], event: ['push'] },
|
|
||||||
['check-full'],
|
|
||||||
),
|
|
||||||
docker_publish(
|
|
||||||
'build-and-release',
|
|
||||||
{ auto_tag: true },
|
|
||||||
{ event: ['tag'] },
|
|
||||||
['check-full-tag'],
|
|
||||||
),
|
|
||||||
|
|
||||||
{
|
|
||||||
kind: 'pipeline',
|
|
||||||
type: 'docker',
|
|
||||||
name: 'renovate',
|
|
||||||
trigger: { event: ['cron', 'custom'], branch: ['main'] },
|
|
||||||
environment: { LOG_LEVEL: 'debug' },
|
|
||||||
steps: [{
|
|
||||||
name: 'renovate',
|
|
||||||
image: 'renovate/renovate:43.165',
|
|
||||||
environment: {
|
|
||||||
RENOVATE_CONFIG_FILE: 'renovate_backend_config.js',
|
|
||||||
RENOVATE_TOKEN: { from_secret: 'RENOVATE_TOKEN' },
|
|
||||||
GITHUB_COM_TOKEN: { from_secret: 'GITHUB_COM_TOKEN' },
|
|
||||||
},
|
|
||||||
commands: [
|
|
||||||
// https://github.com/renovatebot/renovate/discussions/15049
|
|
||||||
'unset GIT_COMMITTER_NAME GIT_COMMITTER_EMAIL GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL',
|
|
||||||
'renovate-config-validator',
|
|
||||||
'renovate',
|
|
||||||
],
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
298
.drone.yml
Normal file
298
.drone.yml
Normal file
|
|
@ -0,0 +1,298 @@
|
||||||
|
kind: pipeline
|
||||||
|
type: docker
|
||||||
|
name: check-fast
|
||||||
|
|
||||||
|
services:
|
||||||
|
- name: postgres
|
||||||
|
image: docker.io/library/postgres:18.3
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
|
||||||
|
trigger:
|
||||||
|
event:
|
||||||
|
- push
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: compute cache key
|
||||||
|
image: docker.io/library/elixir:1.18.3-otp-27
|
||||||
|
commands:
|
||||||
|
- mix_lock_hash=$(sha256sum mix.lock | cut -d ' ' -f 1)
|
||||||
|
- echo "$DRONE_REPO_OWNER/$DRONE_REPO_NAME/$mix_lock_hash" >> .cache_key
|
||||||
|
# Print cache key for debugging
|
||||||
|
- cat .cache_key
|
||||||
|
|
||||||
|
- name: restore-cache
|
||||||
|
image: drillster/drone-volume-cache
|
||||||
|
settings:
|
||||||
|
restore: true
|
||||||
|
mount:
|
||||||
|
- ./deps
|
||||||
|
- ./_build
|
||||||
|
ttl: 30
|
||||||
|
volumes:
|
||||||
|
- name: cache
|
||||||
|
path: /cache
|
||||||
|
|
||||||
|
- name: lint
|
||||||
|
image: docker.io/library/elixir:1.18.3-otp-27
|
||||||
|
commands:
|
||||||
|
# Install hex package manager
|
||||||
|
- mix local.hex --force
|
||||||
|
# Fetch dependencies
|
||||||
|
- mix deps.get
|
||||||
|
# Check for compilation errors & warnings
|
||||||
|
- mix compile --warnings-as-errors
|
||||||
|
# Check formatting
|
||||||
|
- mix format --check-formatted
|
||||||
|
# Security checks
|
||||||
|
- mix sobelow --config
|
||||||
|
# Check dependencies for known vulnerabilities
|
||||||
|
- mix deps.audit
|
||||||
|
# Check for dependencies that are not maintained anymore
|
||||||
|
- mix hex.audit
|
||||||
|
# Provide hints for improving code quality
|
||||||
|
- mix credo --strict
|
||||||
|
# Check that translations are up to date
|
||||||
|
- mix gettext.extract --check-up-to-date
|
||||||
|
|
||||||
|
- name: wait_for_postgres
|
||||||
|
image: docker.io/library/postgres:18.3
|
||||||
|
commands:
|
||||||
|
# Wait for postgres to become available
|
||||||
|
- |
|
||||||
|
for i in {1..20}; do
|
||||||
|
if pg_isready -h postgres -U postgres; then
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
true
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo "Postgres did not become available, aborting."
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: test-fast
|
||||||
|
image: docker.io/library/elixir:1.18.3-otp-27
|
||||||
|
environment:
|
||||||
|
MIX_ENV: test
|
||||||
|
TEST_POSTGRES_HOST: postgres
|
||||||
|
TEST_POSTGRES_PORT: 5432
|
||||||
|
commands:
|
||||||
|
# Install hex package manager
|
||||||
|
- mix local.hex --force
|
||||||
|
# Fetch dependencies
|
||||||
|
- mix deps.get
|
||||||
|
# Run fast tests (excludes slow/performance and UI tests)
|
||||||
|
- mix test --exclude slow --exclude ui --max-cases 2
|
||||||
|
|
||||||
|
- name: rebuild-cache
|
||||||
|
image: drillster/drone-volume-cache
|
||||||
|
settings:
|
||||||
|
rebuild: true
|
||||||
|
mount:
|
||||||
|
- ./deps
|
||||||
|
- ./_build
|
||||||
|
volumes:
|
||||||
|
- name: cache
|
||||||
|
path: /cache
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- name: cache
|
||||||
|
host:
|
||||||
|
path: /tmp/drone_cache
|
||||||
|
|
||||||
|
---
|
||||||
|
kind: pipeline
|
||||||
|
type: docker
|
||||||
|
name: check-full
|
||||||
|
|
||||||
|
services:
|
||||||
|
- name: postgres
|
||||||
|
image: docker.io/library/postgres:18.3
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
|
||||||
|
trigger:
|
||||||
|
event:
|
||||||
|
- promote
|
||||||
|
target:
|
||||||
|
- production
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: compute cache key
|
||||||
|
image: docker.io/library/elixir:1.18.3-otp-27
|
||||||
|
commands:
|
||||||
|
- mix_lock_hash=$(sha256sum mix.lock | cut -d ' ' -f 1)
|
||||||
|
- echo "$DRONE_REPO_OWNER/$DRONE_REPO_NAME/$mix_lock_hash" >> .cache_key
|
||||||
|
# Print cache key for debugging
|
||||||
|
- cat .cache_key
|
||||||
|
|
||||||
|
- name: restore-cache
|
||||||
|
image: drillster/drone-volume-cache
|
||||||
|
settings:
|
||||||
|
restore: true
|
||||||
|
mount:
|
||||||
|
- ./deps
|
||||||
|
- ./_build
|
||||||
|
ttl: 30
|
||||||
|
volumes:
|
||||||
|
- name: cache
|
||||||
|
path: /cache
|
||||||
|
|
||||||
|
- name: lint
|
||||||
|
image: docker.io/library/elixir:1.18.3-otp-27
|
||||||
|
commands:
|
||||||
|
# Install hex package manager
|
||||||
|
- mix local.hex --force
|
||||||
|
# Fetch dependencies
|
||||||
|
- mix deps.get
|
||||||
|
# Check for compilation errors & warnings
|
||||||
|
- mix compile --warnings-as-errors
|
||||||
|
# Check formatting
|
||||||
|
- mix format --check-formatted
|
||||||
|
# Security checks
|
||||||
|
- mix sobelow --config
|
||||||
|
# Check dependencies for known vulnerabilities
|
||||||
|
- mix deps.audit
|
||||||
|
# Check for dependencies that are not maintained anymore
|
||||||
|
- mix hex.audit
|
||||||
|
# Provide hints for improving code quality
|
||||||
|
- mix credo --strict
|
||||||
|
# Check that translations are up to date
|
||||||
|
- mix gettext.extract --check-up-to-date
|
||||||
|
|
||||||
|
- name: wait_for_postgres
|
||||||
|
image: docker.io/library/postgres:18.3
|
||||||
|
commands:
|
||||||
|
# Wait for postgres to become available
|
||||||
|
- |
|
||||||
|
for i in {1..20}; do
|
||||||
|
if pg_isready -h postgres -U postgres; then
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
true
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo "Postgres did not become available, aborting."
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: test-all
|
||||||
|
image: docker.io/library/elixir:1.18.3-otp-27
|
||||||
|
environment:
|
||||||
|
MIX_ENV: test
|
||||||
|
TEST_POSTGRES_HOST: postgres
|
||||||
|
TEST_POSTGRES_PORT: 5432
|
||||||
|
commands:
|
||||||
|
# Install hex package manager
|
||||||
|
- mix local.hex --force
|
||||||
|
# Fetch dependencies
|
||||||
|
- mix deps.get
|
||||||
|
# Run all tests (including slow/performance and UI tests)
|
||||||
|
- mix test
|
||||||
|
|
||||||
|
- name: rebuild-cache
|
||||||
|
image: drillster/drone-volume-cache
|
||||||
|
settings:
|
||||||
|
rebuild: true
|
||||||
|
mount:
|
||||||
|
- ./deps
|
||||||
|
- ./_build
|
||||||
|
volumes:
|
||||||
|
- name: cache
|
||||||
|
path: /cache
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- name: cache
|
||||||
|
host:
|
||||||
|
path: /tmp/drone_cache
|
||||||
|
|
||||||
|
---
|
||||||
|
kind: pipeline
|
||||||
|
type: docker
|
||||||
|
name: build-and-publish
|
||||||
|
|
||||||
|
trigger:
|
||||||
|
branch:
|
||||||
|
- main
|
||||||
|
event:
|
||||||
|
- push
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: build-and-publish-container-branch
|
||||||
|
image: plugins/docker
|
||||||
|
settings:
|
||||||
|
registry: git.local-it.org
|
||||||
|
repo: git.local-it.org/local-it/mitgliederverwaltung
|
||||||
|
username:
|
||||||
|
from_secret: DRONE_REGISTRY_USERNAME
|
||||||
|
password:
|
||||||
|
from_secret: DRONE_REGISTRY_TOKEN
|
||||||
|
tags:
|
||||||
|
- latest
|
||||||
|
- ${DRONE_COMMIT_SHA:0:8}
|
||||||
|
when:
|
||||||
|
event:
|
||||||
|
- push
|
||||||
|
|
||||||
|
depends_on:
|
||||||
|
- check-fast
|
||||||
|
|
||||||
|
---
|
||||||
|
kind: pipeline
|
||||||
|
type: docker
|
||||||
|
name: build-and-release
|
||||||
|
|
||||||
|
trigger:
|
||||||
|
event:
|
||||||
|
- tag
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: build-and-publish-container
|
||||||
|
image: plugins/docker
|
||||||
|
settings:
|
||||||
|
registry: git.local-it.org
|
||||||
|
repo: git.local-it.org/local-it/mitgliederverwaltung
|
||||||
|
username:
|
||||||
|
from_secret: DRONE_REGISTRY_USERNAME
|
||||||
|
password:
|
||||||
|
from_secret: DRONE_REGISTRY_TOKEN
|
||||||
|
auto_tag: true
|
||||||
|
when:
|
||||||
|
event:
|
||||||
|
- tag
|
||||||
|
|
||||||
|
depends_on:
|
||||||
|
- check-fast
|
||||||
|
|
||||||
|
---
|
||||||
|
kind: pipeline
|
||||||
|
type: docker
|
||||||
|
name: renovate
|
||||||
|
|
||||||
|
trigger:
|
||||||
|
event:
|
||||||
|
- cron
|
||||||
|
- custom
|
||||||
|
branch:
|
||||||
|
- main
|
||||||
|
|
||||||
|
environment:
|
||||||
|
LOG_LEVEL: debug
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: renovate
|
||||||
|
image: renovate/renovate:43.165
|
||||||
|
environment:
|
||||||
|
RENOVATE_CONFIG_FILE: "renovate_backend_config.js"
|
||||||
|
RENOVATE_TOKEN:
|
||||||
|
from_secret: RENOVATE_TOKEN
|
||||||
|
GITHUB_COM_TOKEN:
|
||||||
|
from_secret: GITHUB_COM_TOKEN
|
||||||
|
commands:
|
||||||
|
# https://github.com/renovatebot/renovate/discussions/15049
|
||||||
|
- unset GIT_COMMITTER_NAME GIT_COMMITTER_EMAIL GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL
|
||||||
|
- renovate-config-validator
|
||||||
|
- renovate
|
||||||
|
|
@ -24,7 +24,7 @@ ASSOCIATION_NAME="Sportsclub XYZ"
|
||||||
# OIDC_CLIENT_ID=mv
|
# OIDC_CLIENT_ID=mv
|
||||||
# OIDC_BASE_URL=http://localhost:8080/auth/v1
|
# OIDC_BASE_URL=http://localhost:8080/auth/v1
|
||||||
# OIDC_REDIRECT_URI=http://localhost:4001/auth/user/oidc/callback
|
# OIDC_REDIRECT_URI=http://localhost:4001/auth/user/oidc/callback
|
||||||
# OIDC_CLIENT_SECRET=mv-dev-shared-secret-not-for-production-do-not-use-anywhere-else
|
# OIDC_CLIENT_SECRET=your-oidc-client-secret
|
||||||
|
|
||||||
# Optional: OIDC group → Admin role sync (e.g. Authentik groups from profile scope)
|
# Optional: OIDC group → Admin role sync (e.g. Authentik groups from profile scope)
|
||||||
# If OIDC_ADMIN_GROUP_NAME is set, users in that group get Admin role on registration/sign-in.
|
# If OIDC_ADMIN_GROUP_NAME is set, users in that group get Admin role on registration/sign-in.
|
||||||
|
|
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -49,7 +49,3 @@ notes.md
|
||||||
# Do NOT commit these — they are local to the dev machine
|
# Do NOT commit these — they are local to the dev machine
|
||||||
.pipeline/
|
.pipeline/
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
# Dialyzer PLT files — built locally and in CI cache, never tracked.
|
|
||||||
/priv/plts/*.plt
|
|
||||||
/priv/plts/*.plt.hash
|
|
||||||
|
|
|
||||||
43
Justfile
43
Justfile
|
|
@ -29,27 +29,7 @@ seed-database:
|
||||||
start-database:
|
start-database:
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
|
||||||
# Full check suite: lint + audit + the fast tests (slow/ui excluded). No Dialyzer.
|
ci-dev: lint audit test-fast
|
||||||
ci-dev: install-dependencies lint audit test-fast
|
|
||||||
|
|
||||||
# Fast pre-commit check: lint + sobelow + only the affected tests (mix test --stale)
|
|
||||||
# with reduced property runs. Run the full `ci-dev` before pushing.
|
|
||||||
check: install-dependencies lint sobelow test-stale
|
|
||||||
|
|
||||||
# Build the Dialyzer PLT. Idempotent — no-op once the PLT is up to date.
|
|
||||||
# First build takes 5–15 min; subsequent runs are seconds. PLT files live in
|
|
||||||
# priv/plts/ and are gitignored.
|
|
||||||
plt: install-dependencies
|
|
||||||
@mkdir -p priv/plts
|
|
||||||
mix dialyzer --plt
|
|
||||||
|
|
||||||
# Typecheck via Dialyzer. Slow stage, NOT part of ci-dev.
|
|
||||||
typecheck: plt
|
|
||||||
mix dialyzer --format short
|
|
||||||
|
|
||||||
# Full CI: inner loop plus typecheck. Use locally before pushing; Drone CI
|
|
||||||
# runs equivalent steps with PLT caching.
|
|
||||||
ci: ci-dev typecheck
|
|
||||||
|
|
||||||
gettext:
|
gettext:
|
||||||
mix gettext.extract
|
mix gettext.extract
|
||||||
|
|
@ -63,28 +43,19 @@ lint:
|
||||||
@bash -c 'for file in priv/gettext/de/LC_MESSAGES/*.po; do awk "/^msgid \"\"$/{header=1; next} /^msgid /{header=0} /^msgstr \"\"$/ && !header{print FILENAME\":\"NR\": \" \$0; exit 1}" "$file" || exit 1; done'
|
@bash -c 'for file in priv/gettext/de/LC_MESSAGES/*.po; do awk "/^msgid \"\"$/{header=1; next} /^msgid /{header=0} /^msgstr \"\"$/ && !header{print FILENAME\":\"NR\": \" \$0; exit 1}" "$file" || exit 1; done'
|
||||||
mix gettext.extract --check-up-to-date
|
mix gettext.extract --check-up-to-date
|
||||||
|
|
||||||
# Static security scan (Sobelow).
|
audit:
|
||||||
sobelow:
|
|
||||||
mix sobelow --config
|
mix sobelow --config
|
||||||
|
mix deps.audit
|
||||||
# Full security audit: Sobelow + dependency advisory scans.
|
|
||||||
audit: sobelow
|
|
||||||
mix deps.audit --ignore-file .deps_audit_ignore
|
|
||||||
mix hex.audit
|
mix hex.audit
|
||||||
|
|
||||||
# Run all tests. No install-dependencies prerequisite so single-file runs stay
|
# Run all tests
|
||||||
# fast; run `just install-dependencies` once on a fresh checkout.
|
test *args: install-dependencies
|
||||||
test *args:
|
|
||||||
mix test {{args}}
|
mix test {{args}}
|
||||||
|
|
||||||
# Fast tests only (excludes slow/performance and UI tests).
|
# Run only fast tests (excludes slow/performance and UI tests)
|
||||||
test-fast *args:
|
test-fast *args: install-dependencies
|
||||||
mix test --exclude slow --exclude ui {{args}}
|
mix test --exclude slow --exclude ui {{args}}
|
||||||
|
|
||||||
# Affected fast tests only (mix test --stale) with reduced property runs.
|
|
||||||
test-stale *args:
|
|
||||||
PROPERTY_RUNS=25 mix test --stale --exclude slow --exclude ui {{args}}
|
|
||||||
|
|
||||||
# Run only UI tests
|
# Run only UI tests
|
||||||
ui *args: install-dependencies
|
ui *args: install-dependencies
|
||||||
mix test --only ui {{args}}
|
mix test --only ui {{args}}
|
||||||
|
|
|
||||||
18
README.md
18
README.md
|
|
@ -124,8 +124,8 @@ mix archive.install hex phx_new
|
||||||
1. Copy env file:
|
1. Copy env file:
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
# Set OIDC_CLIENT_SECRET inside .env
|
||||||
```
|
```
|
||||||
The dev `OIDC_CLIENT_SECRET` is already preset — no manual GUI step needed.
|
|
||||||
|
|
||||||
2. Start everything (database, Mailcrab, Rauthy, app):
|
2. Start everything (database, Mailcrab, Rauthy, app):
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -139,9 +139,21 @@ mix archive.install hex phx_new
|
||||||
|
|
||||||
## 🔐 Testing SSO locally
|
## 🔐 Testing SSO locally
|
||||||
|
|
||||||
A local **Rauthy** instance is provided in dev. The `mv` client is auto-seeded from `rauthy-bootstrap/clients.json` on first start (and after `docker compose down -v`), so the secret in `.env.example` always matches.
|
Mila uses OIDC for Single Sign-On. In development, a local **Rauthy** instance is provided.
|
||||||
|
|
||||||
Rauthy admin UI: <http://localhost:8080> — login `admin@localhost`, password from `BOOTSTRAP_ADMIN_PASSWORD_PLAIN` in `docker-compose.yml`.
|
1. `just run`
|
||||||
|
2. go to [localhost:8080](http://localhost:8080), go to the Admin area
|
||||||
|
3. Login with "admin@localhost" and password from `BOOTSTRAP_ADMIN_PASSWORD_PLAIN` in docker-compose.yml
|
||||||
|
4. add client from the admin panel
|
||||||
|
- Client ID: mv
|
||||||
|
- redirect uris: http://localhost:4000/auth/user/oidc/callback
|
||||||
|
- Authorization Flows: authorization_code
|
||||||
|
- allowed origins: http://localhost:4000
|
||||||
|
- access/id token algortihm: RS256 (EDDSA did not work for me, found just few infos in the ashauthentication docs)
|
||||||
|
5. copy client secret to `.env` file
|
||||||
|
6. abort and run `just run` again
|
||||||
|
|
||||||
|
Now you can log in to Mila via OIDC!
|
||||||
|
|
||||||
### OIDC with other providers (Authentik, Keycloak, etc.)
|
### OIDC with other providers (Authentik, Keycloak, etc.)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,3 @@ config :mv, :join_rate_limit, scale_ms: 60_000, limit: 2
|
||||||
# Ash: silence "after_transaction hooks in surrounding transaction" warning when using
|
# Ash: silence "after_transaction hooks in surrounding transaction" warning when using
|
||||||
# Ecto sandbox (tests run in a transaction; create_member after_transaction is expected).
|
# Ecto sandbox (tests run in a transaction; create_member after_transaction is expected).
|
||||||
config :ash, warn_on_transaction_hooks?: false
|
config :ash, warn_on_transaction_hooks?: false
|
||||||
|
|
||||||
# StreamData property tests: generated cases per property, overridable via PROPERTY_RUNS
|
|
||||||
# (the `just check` recipe sets it low for speed; default 100 otherwise).
|
|
||||||
config :stream_data, max_runs: String.to_integer(System.get_env("PROPERTY_RUNS") || "100")
|
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,6 @@ services:
|
||||||
- BOOTSTRAP_ADMIN_PASSWORD_PLAIN=RauthyTest12345
|
- BOOTSTRAP_ADMIN_PASSWORD_PLAIN=RauthyTest12345
|
||||||
# Disable strict IP validation to allow access from multiple Docker networks
|
# Disable strict IP validation to allow access from multiple Docker networks
|
||||||
- SESSION_VALIDATE_IP=false
|
- SESSION_VALIDATE_IP=false
|
||||||
# Auto-seed the `mv` OIDC client (id + plain secret) on first DB init.
|
|
||||||
# Re-runs after `docker compose down -v` because the DB is empty again.
|
|
||||||
- BOOTSTRAP_DIR=/app/bootstrap
|
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|
@ -49,7 +46,6 @@ services:
|
||||||
- local
|
- local
|
||||||
volumes:
|
volumes:
|
||||||
- rauthy-data:/app/data
|
- rauthy-data:/app/data
|
||||||
- ./rauthy-bootstrap:/app/bootstrap:ro
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,16 @@ defmodule Mv.Membership.JoinRequest.Changes.FilterFormDataByAllowlist do
|
||||||
form_data = Ash.Changeset.get_attribute(changeset, :form_data) || %{}
|
form_data = Ash.Changeset.get_attribute(changeset, :form_data) || %{}
|
||||||
|
|
||||||
allowlist_ids =
|
allowlist_ids =
|
||||||
Membership.get_join_form_allowlist()
|
case Membership.get_join_form_allowlist() do
|
||||||
|> Enum.map(fn item -> item.id end)
|
list when is_list(list) ->
|
||||||
|> MapSet.new()
|
list
|
||||||
|> MapSet.difference(MapSet.new(@typed_fields))
|
|> Enum.map(fn item -> item.id end)
|
||||||
|
|> MapSet.new()
|
||||||
|
|> MapSet.difference(MapSet.new(@typed_fields))
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
MapSet.new()
|
||||||
|
end
|
||||||
|
|
||||||
filtered =
|
filtered =
|
||||||
form_data
|
form_data
|
||||||
|
|
|
||||||
|
|
@ -51,9 +51,6 @@ defmodule Mv.Membership.Member do
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
@typedoc "An `Mv.Membership.Member` resource record."
|
|
||||||
@type t :: %__MODULE__{}
|
|
||||||
|
|
||||||
# Module constants
|
# Module constants
|
||||||
@member_search_limit 10
|
@member_search_limit 10
|
||||||
|
|
||||||
|
|
@ -794,7 +791,7 @@ defmodule Mv.Membership.Member do
|
||||||
# nil/[] when membership_fee_type is missing.
|
# nil/[] when membership_fee_type is missing.
|
||||||
|
|
||||||
@doc false
|
@doc false
|
||||||
@spec get_current_cycle(t()) :: MembershipFeeCycle.t() | nil
|
@spec get_current_cycle(Member.t()) :: MembershipFeeCycle.t() | nil
|
||||||
def get_current_cycle(member) do
|
def get_current_cycle(member) do
|
||||||
today = Date.utc_today()
|
today = Date.utc_today()
|
||||||
|
|
||||||
|
|
@ -824,7 +821,7 @@ defmodule Mv.Membership.Member do
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc false
|
@doc false
|
||||||
@spec get_last_completed_cycle(t()) :: MembershipFeeCycle.t() | nil
|
@spec get_last_completed_cycle(Member.t()) :: MembershipFeeCycle.t() | nil
|
||||||
def get_last_completed_cycle(member) do
|
def get_last_completed_cycle(member) do
|
||||||
today = Date.utc_today()
|
today = Date.utc_today()
|
||||||
|
|
||||||
|
|
@ -870,7 +867,7 @@ defmodule Mv.Membership.Member do
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc false
|
@doc false
|
||||||
@spec get_overdue_cycles(t()) :: [MembershipFeeCycle.t()]
|
@spec get_overdue_cycles(Member.t()) :: [MembershipFeeCycle.t()]
|
||||||
def get_overdue_cycles(member) do
|
def get_overdue_cycles(member) do
|
||||||
today = Date.utc_today()
|
today = Date.utc_today()
|
||||||
|
|
||||||
|
|
@ -942,7 +939,7 @@ defmodule Mv.Membership.Member do
|
||||||
# Already in transaction: use advisory lock directly
|
# Already in transaction: use advisory lock directly
|
||||||
# Returns {:ok, notifications} - notifications should be returned to after_action hook
|
# Returns {:ok, notifications} - notifications should be returned to after_action hook
|
||||||
defp regenerate_cycles_in_transaction(member, today, lock_key) do
|
defp regenerate_cycles_in_transaction(member, today, lock_key) do
|
||||||
_ = EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
||||||
do_regenerate_cycles_on_type_change(member, today, skip_lock?: true)
|
do_regenerate_cycles_on_type_change(member, today, skip_lock?: true)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -950,7 +947,7 @@ defmodule Mv.Membership.Member do
|
||||||
# Returns {:ok, notifications} - notifications should be sent by caller (e.g., via after_action)
|
# Returns {:ok, notifications} - notifications should be sent by caller (e.g., via after_action)
|
||||||
defp regenerate_cycles_new_transaction(member, today, lock_key) do
|
defp regenerate_cycles_new_transaction(member, today, lock_key) do
|
||||||
Repo.transaction(fn ->
|
Repo.transaction(fn ->
|
||||||
_ = EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
||||||
|
|
||||||
case do_regenerate_cycles_on_type_change(member, today, skip_lock?: true) do
|
case do_regenerate_cycles_on_type_change(member, today, skip_lock?: true) do
|
||||||
{:ok, notifications} ->
|
{:ok, notifications} ->
|
||||||
|
|
@ -1096,7 +1093,7 @@ defmodule Mv.Membership.Member do
|
||||||
initiator: initiator
|
initiator: initiator
|
||||||
) do
|
) do
|
||||||
{:ok, cycles, notifications} ->
|
{:ok, cycles, notifications} ->
|
||||||
_ = send_notifications_if_any(notifications)
|
send_notifications_if_any(notifications)
|
||||||
|
|
||||||
log_cycle_generation_success(member, cycles, notifications,
|
log_cycle_generation_success(member, cycles, notifications,
|
||||||
sync: true,
|
sync: true,
|
||||||
|
|
@ -1115,7 +1112,7 @@ defmodule Mv.Membership.Member do
|
||||||
initiator: initiator
|
initiator: initiator
|
||||||
) do
|
) do
|
||||||
{:ok, cycles, notifications} ->
|
{:ok, cycles, notifications} ->
|
||||||
_ = send_notifications_if_any(notifications)
|
send_notifications_if_any(notifications)
|
||||||
|
|
||||||
log_cycle_generation_success(member, cycles, notifications,
|
log_cycle_generation_success(member, cycles, notifications,
|
||||||
sync: false,
|
sync: false,
|
||||||
|
|
@ -1234,6 +1231,8 @@ defmodule Mv.Membership.Member do
|
||||||
|> String.replace("_", "\\_")
|
|> String.replace("_", "\\_")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp sanitize_search_query(_), do: ""
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Search Filter Builders
|
# Search Filter Builders
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -37,10 +37,9 @@ defmodule Mv.Membership.Member.Changes.UnrelateUserWhenArgumentNil do
|
||||||
{:ok, %{user: user}} when not is_nil(user) ->
|
{:ok, %{user: user}} when not is_nil(user) ->
|
||||||
# User's :update action only accepts [:email]; use :update_user so
|
# User's :update action only accepts [:email]; use :update_user so
|
||||||
# manage_relationship(:member, ..., on_missing: :unrelate) runs and clears member_id.
|
# manage_relationship(:member, ..., on_missing: :unrelate) runs and clears member_id.
|
||||||
_ =
|
user
|
||||||
user
|
|> Ash.Changeset.for_update(:update_user, %{member: nil}, domain: Mv.Accounts)
|
||||||
|> Ash.Changeset.for_update(:update_user, %{member: nil}, domain: Mv.Accounts)
|
|> Ash.update(domain: Mv.Accounts, actor: actor, authorize?: false)
|
||||||
|> Ash.update(domain: Mv.Accounts, actor: actor, authorize?: false)
|
|
||||||
|
|
||||||
changeset
|
changeset
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -836,10 +836,7 @@ defmodule Mv.Membership do
|
||||||
- `{:ok, rejected_request}` - Rejected JoinRequest
|
- `{:ok, rejected_request}` - Rejected JoinRequest
|
||||||
- `{:error, error}` - Status error or authorization error
|
- `{:error, error}` - Status error or authorization error
|
||||||
"""
|
"""
|
||||||
@spec reject_join_request(String.t(), keyword()) ::
|
@spec reject_join_request(String.t(), keyword()) :: {:ok, JoinRequest.t()} | {:error, term()}
|
||||||
{:ok, JoinRequest.t()}
|
|
||||||
| {:ok, JoinRequest.t(), [Ash.Notifier.Notification.t()]}
|
|
||||||
| {:error, term()}
|
|
||||||
def reject_join_request(id, opts \\ []) do
|
def reject_join_request(id, opts \\ []) do
|
||||||
actor = Keyword.get(opts, :actor)
|
actor = Keyword.get(opts, :actor)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
|
||||||
"""
|
"""
|
||||||
use Ash.Resource.Change
|
use Ash.Resource.Change
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
alias Mv.MembershipFees.CalendarCycles
|
alias Mv.MembershipFees.CalendarCycles
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -81,6 +83,11 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
|
||||||
field: :membership_fee_type_id,
|
field: :membership_fee_type_id,
|
||||||
message: "not found"
|
message: "not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
# Log warning for other unexpected errors
|
||||||
|
Logger.warning("Could not auto-set membership_fee_start_date: #{inspect(reason)}")
|
||||||
|
changeset
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,6 @@ defmodule Mv.Authorization.PermissionSets do
|
||||||
pattern matches and map lookups with no database queries or external calls.
|
pattern matches and map lookups with no database queries or external calls.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@type permission_set_name :: :own_data | :read_only | :normal_user | :admin
|
|
||||||
@type scope :: :own | :linked | :all
|
@type scope :: :own | :linked | :all
|
||||||
@type action :: :read | :create | :update | :destroy
|
@type action :: :read | :create | :update | :destroy
|
||||||
|
|
||||||
|
|
@ -89,7 +88,7 @@ defmodule Mv.Authorization.PermissionSets do
|
||||||
iex> PermissionSets.all_permission_sets()
|
iex> PermissionSets.all_permission_sets()
|
||||||
[:own_data, :read_only, :normal_user, :admin]
|
[:own_data, :read_only, :normal_user, :admin]
|
||||||
"""
|
"""
|
||||||
@spec all_permission_sets() :: [permission_set_name(), ...]
|
@spec all_permission_sets() :: [atom()]
|
||||||
def all_permission_sets do
|
def all_permission_sets do
|
||||||
[:own_data, :read_only, :normal_user, :admin]
|
[:own_data, :read_only, :normal_user, :admin]
|
||||||
end
|
end
|
||||||
|
|
@ -108,7 +107,7 @@ defmodule Mv.Authorization.PermissionSets do
|
||||||
iex> PermissionSets.get_permissions(:invalid)
|
iex> PermissionSets.get_permissions(:invalid)
|
||||||
** (ArgumentError) invalid permission set: :invalid. Must be one of: [:own_data, :read_only, :normal_user, :admin]
|
** (ArgumentError) invalid permission set: :invalid. Must be one of: [:own_data, :read_only, :normal_user, :admin]
|
||||||
"""
|
"""
|
||||||
@spec get_permissions(permission_set_name()) :: permission_set()
|
@spec get_permissions(atom()) :: permission_set()
|
||||||
|
|
||||||
def get_permissions(set) when set not in [:own_data, :read_only, :normal_user, :admin] do
|
def get_permissions(set) when set not in [:own_data, :read_only, :normal_user, :admin] do
|
||||||
raise ArgumentError,
|
raise ArgumentError,
|
||||||
|
|
|
||||||
|
|
@ -207,6 +207,8 @@ defmodule Mv.Config do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp derive_app_url_from_api_url(_), do: nil
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns true if Vereinfacht is fully configured (URL, API key, and club ID all set).
|
Returns true if Vereinfacht is fully configured (URL, API key, and club ID all set).
|
||||||
"""
|
"""
|
||||||
|
|
@ -249,6 +251,7 @@ defmodule Mv.Config do
|
||||||
case System.get_env(key) do
|
case System.get_env(key) do
|
||||||
nil -> false
|
nil -> false
|
||||||
v when is_binary(v) -> String.trim(v) != ""
|
v when is_binary(v) -> String.trim(v) != ""
|
||||||
|
_ -> false
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -267,6 +270,9 @@ defmodule Mv.Config do
|
||||||
value when is_binary(value) ->
|
value when is_binary(value) ->
|
||||||
v = String.trim(value) |> String.downcase()
|
v = String.trim(value) |> String.downcase()
|
||||||
v in ["true", "1", "yes"]
|
v in ["true", "1", "yes"]
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
false
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -322,6 +328,7 @@ defmodule Mv.Config do
|
||||||
|
|
||||||
defp present?(nil), do: false
|
defp present?(nil), do: false
|
||||||
defp present?(s) when is_binary(s), do: String.trim(s) != ""
|
defp present?(s) when is_binary(s), do: String.trim(s) != ""
|
||||||
|
defp present?(_), do: false
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# OIDC authentication
|
# OIDC authentication
|
||||||
|
|
@ -402,7 +409,7 @@ defmodule Mv.Config do
|
||||||
@doc """
|
@doc """
|
||||||
Returns the OIDC groups claim name (default "groups"). ENV first, then Settings.
|
Returns the OIDC groups claim name (default "groups"). ENV first, then Settings.
|
||||||
"""
|
"""
|
||||||
@spec oidc_groups_claim() :: String.t()
|
@spec oidc_groups_claim() :: String.t() | nil
|
||||||
def oidc_groups_claim do
|
def oidc_groups_claim do
|
||||||
case env_or_setting("OIDC_GROUPS_CLAIM", :oidc_groups_claim) do
|
case env_or_setting("OIDC_GROUPS_CLAIM", :oidc_groups_claim) do
|
||||||
nil -> "groups"
|
nil -> "groups"
|
||||||
|
|
@ -485,7 +492,7 @@ defmodule Mv.Config do
|
||||||
- ENV-only mode (`SMTP_HOST` set): read from ENV `SMTP_PORT`
|
- ENV-only mode (`SMTP_HOST` set): read from ENV `SMTP_PORT`
|
||||||
- Settings mode: read from Settings only
|
- Settings mode: read from Settings only
|
||||||
"""
|
"""
|
||||||
@spec smtp_port() :: pos_integer() | nil
|
@spec smtp_port() :: non_neg_integer() | nil
|
||||||
def smtp_port do
|
def smtp_port do
|
||||||
if smtp_env_mode?() do
|
if smtp_env_mode?() do
|
||||||
parse_smtp_port_env(System.get_env("SMTP_PORT"))
|
parse_smtp_port_env(System.get_env("SMTP_PORT"))
|
||||||
|
|
@ -631,15 +638,9 @@ defmodule Mv.Config do
|
||||||
"""
|
"""
|
||||||
@spec mail_from_name() :: String.t()
|
@spec mail_from_name() :: String.t()
|
||||||
def mail_from_name do
|
def mail_from_name do
|
||||||
name =
|
case System.get_env("MAIL_FROM_NAME") do
|
||||||
case System.get_env("MAIL_FROM_NAME") do
|
nil -> get_from_settings(:smtp_from_name) || "Mila"
|
||||||
nil -> get_from_settings(:smtp_from_name)
|
value -> trim_nil(value) || "Mila"
|
||||||
value -> trim_nil(value)
|
|
||||||
end
|
|
||||||
|
|
||||||
case name do
|
|
||||||
nil -> "Mila"
|
|
||||||
name -> name
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,18 +26,6 @@ defmodule Mv.Constants do
|
||||||
|
|
||||||
@fee_type_filter_prefix "fee_type_"
|
@fee_type_filter_prefix "fee_type_"
|
||||||
|
|
||||||
@join_date_from_param "jd_from"
|
|
||||||
|
|
||||||
@join_date_to_param "jd_to"
|
|
||||||
|
|
||||||
@exit_date_mode_param "ed_mode"
|
|
||||||
|
|
||||||
@exit_date_from_param "ed_from"
|
|
||||||
|
|
||||||
@exit_date_to_param "ed_to"
|
|
||||||
|
|
||||||
@custom_date_filter_prefix "cdf_"
|
|
||||||
|
|
||||||
@max_boolean_filters 50
|
@max_boolean_filters 50
|
||||||
|
|
||||||
@max_uuid_length 36
|
@max_uuid_length 36
|
||||||
|
|
@ -96,70 +84,6 @@ defmodule Mv.Constants do
|
||||||
"""
|
"""
|
||||||
def fee_type_filter_prefix, do: @fee_type_filter_prefix
|
def fee_type_filter_prefix, do: @fee_type_filter_prefix
|
||||||
|
|
||||||
@doc """
|
|
||||||
Returns the URL parameter name for the join_date lower bound filter.
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
iex> Mv.Constants.join_date_from_param()
|
|
||||||
"jd_from"
|
|
||||||
"""
|
|
||||||
def join_date_from_param, do: @join_date_from_param
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Returns the URL parameter name for the join_date upper bound filter.
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
iex> Mv.Constants.join_date_to_param()
|
|
||||||
"jd_to"
|
|
||||||
"""
|
|
||||||
def join_date_to_param, do: @join_date_to_param
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Returns the URL parameter name for the exit_date filter mode
|
|
||||||
(`active_only` | `inactive_only` | `all` | `custom`).
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
iex> Mv.Constants.exit_date_mode_param()
|
|
||||||
"ed_mode"
|
|
||||||
"""
|
|
||||||
def exit_date_mode_param, do: @exit_date_mode_param
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Returns the URL parameter name for the exit_date lower bound filter
|
|
||||||
(only relevant when ed_mode=custom).
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
iex> Mv.Constants.exit_date_from_param()
|
|
||||||
"ed_from"
|
|
||||||
"""
|
|
||||||
def exit_date_from_param, do: @exit_date_from_param
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Returns the URL parameter name for the exit_date upper bound filter
|
|
||||||
(only relevant when ed_mode=custom).
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
iex> Mv.Constants.exit_date_to_param()
|
|
||||||
"ed_to"
|
|
||||||
"""
|
|
||||||
def exit_date_to_param, do: @exit_date_to_param
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Returns the prefix for custom date field filter URL parameters
|
|
||||||
(e.g. cdf_<uuid>_from / cdf_<uuid>_to).
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
iex> Mv.Constants.custom_date_filter_prefix()
|
|
||||||
"cdf_"
|
|
||||||
"""
|
|
||||||
def custom_date_filter_prefix, do: @custom_date_filter_prefix
|
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns the maximum number of boolean custom field filters allowed per request.
|
Returns the maximum number of boolean custom field filters allowed per request.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -225,10 +225,7 @@ defmodule Mv.Helpers.SystemActor do
|
||||||
# This allows configuration via SYSTEM_ACTOR_EMAIL env var
|
# This allows configuration via SYSTEM_ACTOR_EMAIL env var
|
||||||
@spec system_user_email_config() :: String.t()
|
@spec system_user_email_config() :: String.t()
|
||||||
defp system_user_email_config do
|
defp system_user_email_config do
|
||||||
case System.get_env("SYSTEM_ACTOR_EMAIL") do
|
System.get_env("SYSTEM_ACTOR_EMAIL") || "system@mila.local"
|
||||||
nil -> "system@mila.local"
|
|
||||||
email -> email
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Loads the system actor from the database
|
# Loads the system actor from the database
|
||||||
|
|
@ -260,7 +257,7 @@ defmodule Mv.Helpers.SystemActor do
|
||||||
end
|
end
|
||||||
|
|
||||||
# Handles database error when loading system user
|
# Handles database error when loading system user
|
||||||
@spec handle_system_user_error({:error, Ash.Error.t()}) :: Mv.Accounts.User.t() | no_return()
|
@spec handle_system_user_error(term()) :: Mv.Accounts.User.t() | no_return()
|
||||||
defp handle_system_user_error(error) do
|
defp handle_system_user_error(error) do
|
||||||
case load_admin_user_fallback() do
|
case load_admin_user_fallback() do
|
||||||
{:ok, admin_user} ->
|
{:ok, admin_user} ->
|
||||||
|
|
@ -396,18 +393,15 @@ defmodule Mv.Helpers.SystemActor do
|
||||||
# 1. Only creates system user with known email
|
# 1. Only creates system user with known email
|
||||||
# 2. Only called during system actor initialization (bootstrap)
|
# 2. Only called during system actor initialization (bootstrap)
|
||||||
# 3. Once created, all subsequent operations use proper authorization
|
# 3. Once created, all subsequent operations use proper authorization
|
||||||
user =
|
Accounts.create_user!(%{email: system_user_email_config()},
|
||||||
Accounts.create_user!(%{email: system_user_email_config()},
|
upsert?: true,
|
||||||
upsert?: true,
|
upsert_identity: :unique_email,
|
||||||
upsert_identity: :unique_email,
|
authorize?: false
|
||||||
authorize?: false
|
)
|
||||||
)
|
|> Ash.Changeset.for_update(:update_internal, %{})
|
||||||
|> Ash.Changeset.for_update(:update_internal, %{})
|
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
|> Ash.update!(authorize?: false)
|
||||||
|> Ash.update!(authorize?: false)
|
|> Ash.load!(:role, domain: Mv.Accounts, authorize?: false)
|
||||||
|> Ash.load!(:role, domain: Mv.Accounts, authorize?: false)
|
|
||||||
|
|
||||||
%Accounts.User{} = user
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Finds a user by email address
|
# Finds a user by email address
|
||||||
|
|
|
||||||
|
|
@ -190,4 +190,6 @@ defmodule Mv.Mailer do
|
||||||
defp valid_email?(email) when is_binary(email) do
|
defp valid_email?(email) when is_binary(email) do
|
||||||
Regex.match?(@email_regex, String.trim(email))
|
Regex.match?(@email_regex, String.trim(email))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp valid_email?(_), do: false
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -100,8 +100,7 @@ defmodule Mv.Membership.Import.CsvParser do
|
||||||
|> String.replace("\r", "\n")
|
|> String.replace("\r", "\n")
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec get_parser(String.t()) ::
|
@spec get_parser(String.t()) :: module()
|
||||||
Mv.Membership.Import.CsvParserSemicolon | Mv.Membership.Import.CsvParserComma
|
|
||||||
defp get_parser(";"), do: Mv.Membership.Import.CsvParserSemicolon
|
defp get_parser(";"), do: Mv.Membership.Import.CsvParserSemicolon
|
||||||
defp get_parser(","), do: Mv.Membership.Import.CsvParserComma
|
defp get_parser(","), do: Mv.Membership.Import.CsvParserComma
|
||||||
defp get_parser(_), do: Mv.Membership.Import.CsvParserSemicolon
|
defp get_parser(_), do: Mv.Membership.Import.CsvParserSemicolon
|
||||||
|
|
@ -117,10 +116,7 @@ defmodule Mv.Membership.Import.CsvParser do
|
||||||
if semicolon_score >= comma_score, do: ";", else: ","
|
if semicolon_score >= comma_score, do: ";", else: ","
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec header_field_count(
|
@spec header_field_count(module(), binary()) :: non_neg_integer()
|
||||||
Mv.Membership.Import.CsvParserSemicolon | Mv.Membership.Import.CsvParserComma,
|
|
||||||
binary()
|
|
||||||
) :: non_neg_integer()
|
|
||||||
defp header_field_count(parser, header_record) do
|
defp header_field_count(parser, header_record) do
|
||||||
case parse_single_record(parser, header_record, nil) do
|
case parse_single_record(parser, header_record, nil) do
|
||||||
{:ok, fields} -> Enum.count(fields, &(String.trim(&1) != ""))
|
{:ok, fields} -> Enum.count(fields, &(String.trim(&1) != ""))
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,14 @@ defmodule Mv.Membership.Import.ImportRunner do
|
||||||
{:ok, content} ->
|
{:ok, content} ->
|
||||||
{:ok, content}
|
{:ok, content}
|
||||||
|
|
||||||
|
{:error, reason} when is_atom(reason) ->
|
||||||
|
{:error, :file.format_error(reason)}
|
||||||
|
|
||||||
|
{:error, %File.Error{reason: reason}} ->
|
||||||
|
{:error, :file.format_error(reason)}
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
{:error, to_string(:file.format_error(reason))}
|
{:error, Exception.message(reason)}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -210,6 +210,8 @@ defmodule Mv.Membership.Import.MemberCSV do
|
||||||
MapSet.member?(HeaderMapper.known_member_fields(), normalized)
|
MapSet.member?(HeaderMapper.known_member_fields(), normalized)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp member_field?(_), do: false
|
||||||
|
|
||||||
# Validates that row count doesn't exceed limit
|
# Validates that row count doesn't exceed limit
|
||||||
defp validate_row_count(rows, max_rows) do
|
defp validate_row_count(rows, max_rows) do
|
||||||
if length(rows) > max_rows do
|
if length(rows) > max_rows do
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ defmodule Mv.Membership.Member.Validations.EmailChangePermission do
|
||||||
|
|
||||||
# Ash stores actor in changeset.context.private.actor; validation context has .actor; some callsites use context.actor
|
# Ash stores actor in changeset.context.private.actor; validation context has .actor; some callsites use context.actor
|
||||||
defp resolve_actor(changeset, context) do
|
defp resolve_actor(changeset, context) do
|
||||||
ctx = changeset.context
|
ctx = changeset.context || %{}
|
||||||
|
|
||||||
get_in(ctx, [:private, :actor]) ||
|
get_in(ctx, [:private, :actor]) ||
|
||||||
Map.get(ctx, :actor) ||
|
Map.get(ctx, :actor) ||
|
||||||
|
|
|
||||||
|
|
@ -16,21 +16,6 @@ defmodule Mv.Membership.MemberExport do
|
||||||
alias MvWeb.MemberLive.Index
|
alias MvWeb.MemberLive.Index
|
||||||
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
||||||
|
|
||||||
@typedoc "Validated export parameters produced by `parse_params/1`."
|
|
||||||
@type parsed_params :: %{
|
|
||||||
selected_ids: [String.t()],
|
|
||||||
member_fields: [String.t()],
|
|
||||||
selectable_member_fields: [String.t()],
|
|
||||||
computed_fields: [String.t()],
|
|
||||||
custom_field_ids: [String.t()],
|
|
||||||
query: String.t() | nil,
|
|
||||||
sort_field: String.t() | nil,
|
|
||||||
sort_order: String.t() | nil,
|
|
||||||
show_current_cycle: boolean(),
|
|
||||||
cycle_status_filter: :paid | :unpaid | nil,
|
|
||||||
boolean_filters: %{optional(String.t()) => boolean()}
|
|
||||||
}
|
|
||||||
|
|
||||||
@member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
|
@member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
|
||||||
["membership_fee_type", "membership_fee_status", "groups"]
|
["membership_fee_type", "membership_fee_status", "groups"]
|
||||||
@computed_export_fields ["membership_fee_status"]
|
@computed_export_fields ["membership_fee_status"]
|
||||||
|
|
@ -320,7 +305,7 @@ defmodule Mv.Membership.MemberExport do
|
||||||
:computed_fields, :custom_field_ids, :query, :sort_field, :sort_order,
|
:computed_fields, :custom_field_ids, :query, :sort_field, :sort_order,
|
||||||
:show_current_cycle, :cycle_status_filter, :boolean_filters.
|
:show_current_cycle, :cycle_status_filter, :boolean_filters.
|
||||||
"""
|
"""
|
||||||
@spec parse_params(map()) :: parsed_params()
|
@spec parse_params(map()) :: map()
|
||||||
def parse_params(params) do
|
def parse_params(params) do
|
||||||
# DB fields come from "member_fields"
|
# DB fields come from "member_fields"
|
||||||
raw_member_fields = extract_list(params, "member_fields")
|
raw_member_fields = extract_list(params, "member_fields")
|
||||||
|
|
@ -473,6 +458,9 @@ defmodule Mv.Membership.MemberExport do
|
||||||
computed_fields,
|
computed_fields,
|
||||||
member_fields
|
member_fields
|
||||||
) do
|
) do
|
||||||
|
computed_fields = computed_fields || []
|
||||||
|
member_fields = member_fields || []
|
||||||
|
|
||||||
db_with_insert =
|
db_with_insert =
|
||||||
Enum.flat_map(db_fields_ordered, fn f ->
|
Enum.flat_map(db_fields_ordered, fn f ->
|
||||||
expand_field_with_computed(f, member_fields, computed_fields)
|
expand_field_with_computed(f, member_fields, computed_fields)
|
||||||
|
|
@ -519,4 +507,6 @@ defmodule Mv.Membership.MemberExport do
|
||||||
other -> other
|
other -> other
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp normalize_computed_fields(_), do: []
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ defmodule Mv.Membership.MembersCSV do
|
||||||
Returns iodata suitable for `IO.iodata_to_binary/1` or sending as response body.
|
Returns iodata suitable for `IO.iodata_to_binary/1` or sending as response body.
|
||||||
RFC 4180 escaping and formula-injection safe_cell are applied.
|
RFC 4180 escaping and formula-injection safe_cell are applied.
|
||||||
"""
|
"""
|
||||||
@spec export([struct() | map()], [map()]) :: [iodata()] | Enumerable.t()
|
@spec export([struct() | map()], [map()]) :: iodata()
|
||||||
def export(members, columns) when is_list(members) do
|
def export(members, columns) when is_list(members) do
|
||||||
header = build_header(columns)
|
header = build_header(columns)
|
||||||
rows = Enum.map(members, fn member -> build_row(member, columns) end)
|
rows = Enum.map(members, fn member -> build_row(member, columns) end)
|
||||||
|
|
|
||||||
|
|
@ -143,7 +143,7 @@ defmodule Mv.Membership.MembersPDF do
|
||||||
|
|
||||||
defp convert_to_template_format(export_data, locale, club_name) do
|
defp convert_to_template_format(export_data, locale, club_name) do
|
||||||
# Set locale for translations
|
# Set locale for translations
|
||||||
_ = Gettext.put_locale(MvWeb.Gettext, locale)
|
Gettext.put_locale(MvWeb.Gettext, locale)
|
||||||
|
|
||||||
headers = Enum.map(export_data.columns, & &1.label)
|
headers = Enum.map(export_data.columns, & &1.label)
|
||||||
column_count = length(export_data.columns)
|
column_count = length(export_data.columns)
|
||||||
|
|
@ -211,6 +211,9 @@ defmodule Mv.Membership.MembersPDF do
|
||||||
{:ok, datetime, _offset} ->
|
{:ok, datetime, _offset} ->
|
||||||
format_datetime(datetime, locale)
|
format_datetime(datetime, locale)
|
||||||
|
|
||||||
|
{:ok, datetime} ->
|
||||||
|
format_datetime(datetime, locale)
|
||||||
|
|
||||||
{:error, _} ->
|
{:error, _} ->
|
||||||
# Try NaiveDateTime if DateTime parsing fails
|
# Try NaiveDateTime if DateTime parsing fails
|
||||||
case NaiveDateTime.from_iso8601(iso8601_string) do
|
case NaiveDateTime.from_iso8601(iso8601_string) do
|
||||||
|
|
@ -254,6 +257,8 @@ defmodule Mv.Membership.MembersPDF do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp format_date(_, _), do: ""
|
||||||
|
|
||||||
defp format_dates_in_rows(rows, columns, locale) do
|
defp format_dates_in_rows(rows, columns, locale) do
|
||||||
date_indices = find_date_column_indices(columns)
|
date_indices = find_date_column_indices(columns)
|
||||||
|
|
||||||
|
|
@ -316,7 +321,7 @@ defmodule Mv.Membership.MembersPDF do
|
||||||
|
|
||||||
defp format_cell_date_datetime(cell_value, locale) do
|
defp format_cell_date_datetime(cell_value, locale) do
|
||||||
case DateTime.from_iso8601(cell_value) do
|
case DateTime.from_iso8601(cell_value) do
|
||||||
{:ok, datetime, _offset} -> format_datetime(datetime, locale)
|
{:ok, datetime} -> format_datetime(datetime, locale)
|
||||||
_ -> format_cell_date_naive(cell_value, locale)
|
_ -> format_cell_date_naive(cell_value, locale)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ defmodule Mv.MembershipFees.CycleGenerationJob do
|
||||||
{:ok, %{success: 45, failed: 0, total: 45}}
|
{:ok, %{success: 45, failed: 0, total: 45}}
|
||||||
|
|
||||||
"""
|
"""
|
||||||
@spec run() :: {:ok, CycleGenerator.results_summary()} | {:error, Ash.Error.t()}
|
@spec run() :: {:ok, map()} | {:error, term()}
|
||||||
def run do
|
def run do
|
||||||
Logger.info("Starting membership fee cycle generation job")
|
Logger.info("Starting membership fee cycle generation job")
|
||||||
start_time = System.monotonic_time(:millisecond)
|
start_time = System.monotonic_time(:millisecond)
|
||||||
|
|
@ -98,7 +98,7 @@ defmodule Mv.MembershipFees.CycleGenerationJob do
|
||||||
Mv.MembershipFees.CycleGenerationJob.run(batch_size: 5)
|
Mv.MembershipFees.CycleGenerationJob.run(batch_size: 5)
|
||||||
|
|
||||||
"""
|
"""
|
||||||
@spec run(keyword()) :: {:ok, CycleGenerator.results_summary()} | {:error, Ash.Error.t()}
|
@spec run(keyword()) :: {:ok, map()} | {:error, term()}
|
||||||
def run(opts) when is_list(opts) do
|
def run(opts) when is_list(opts) do
|
||||||
Logger.info("Starting membership fee cycle generation job with opts: #{inspect(opts)}")
|
Logger.info("Starting membership fee cycle generation job with opts: #{inspect(opts)}")
|
||||||
start_time = System.monotonic_time(:millisecond)
|
start_time = System.monotonic_time(:millisecond)
|
||||||
|
|
@ -135,7 +135,7 @@ defmodule Mv.MembershipFees.CycleGenerationJob do
|
||||||
- `{:error, reason}` - Error with reason
|
- `{:error, reason}` - Error with reason
|
||||||
|
|
||||||
"""
|
"""
|
||||||
@spec pending_members_count() :: {:ok, non_neg_integer()} | {:error, Ash.Error.t()}
|
@spec pending_members_count() :: {:ok, non_neg_integer()} | {:error, term()}
|
||||||
def pending_members_count do
|
def pending_members_count do
|
||||||
today = Date.utc_today()
|
today = Date.utc_today()
|
||||||
|
|
||||||
|
|
@ -166,7 +166,7 @@ defmodule Mv.MembershipFees.CycleGenerationJob do
|
||||||
- `{:error, reason}` - Error with reason
|
- `{:error, reason}` - Error with reason
|
||||||
|
|
||||||
"""
|
"""
|
||||||
@spec run_for_member(String.t()) :: CycleGenerator.generate_result()
|
@spec run_for_member(String.t()) :: {:ok, [map()]} | {:error, term()}
|
||||||
def run_for_member(member_id) when is_binary(member_id) do
|
def run_for_member(member_id) when is_binary(member_id) do
|
||||||
Logger.info("Generating cycles for member #{member_id}")
|
Logger.info("Generating cycles for member #{member_id}")
|
||||||
CycleGenerator.generate_cycles_for_member(member_id)
|
CycleGenerator.generate_cycles_for_member(member_id)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,4 @@
|
||||||
defmodule Mv.MembershipFees.CycleGenerator do
|
defmodule Mv.MembershipFees.CycleGenerator do
|
||||||
@typedoc "Aggregate counts returned by a batch cycle-generation run."
|
|
||||||
@type results_summary :: %{
|
|
||||||
success: non_neg_integer(),
|
|
||||||
failed: non_neg_integer(),
|
|
||||||
total: non_neg_integer()
|
|
||||||
}
|
|
||||||
|
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Module for generating membership fee cycles for members.
|
Module for generating membership fee cycles for members.
|
||||||
|
|
||||||
|
|
@ -122,7 +115,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
||||||
lock_key = Member.advisory_lock_key_for_member_id(member.id)
|
lock_key = Member.advisory_lock_key_for_member_id(member.id)
|
||||||
|
|
||||||
Repo.transaction(fn ->
|
Repo.transaction(fn ->
|
||||||
_ = EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
||||||
|
|
||||||
case do_generate_cycles(member, today, opts) do
|
case do_generate_cycles(member, today, opts) do
|
||||||
{:ok, cycles, notifications} ->
|
{:ok, cycles, notifications} ->
|
||||||
|
|
@ -166,8 +159,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
||||||
- `{:error, reason}` - Error with reason
|
- `{:error, reason}` - Error with reason
|
||||||
|
|
||||||
"""
|
"""
|
||||||
@spec generate_cycles_for_all_members(keyword()) ::
|
@spec generate_cycles_for_all_members(keyword()) :: {:ok, map()} | {:error, term()}
|
||||||
{:ok, results_summary()} | {:error, Ash.Error.t()}
|
|
||||||
def generate_cycles_for_all_members(opts \\ []) do
|
def generate_cycles_for_all_members(opts \\ []) do
|
||||||
today = Keyword.get(opts, :today, Date.utc_today())
|
today = Keyword.get(opts, :today, Date.utc_today())
|
||||||
batch_size = Keyword.get(opts, :batch_size, 10)
|
batch_size = Keyword.get(opts, :batch_size, 10)
|
||||||
|
|
@ -220,7 +212,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
||||||
defp process_member_cycle_generation(member, today) do
|
defp process_member_cycle_generation(member, today) do
|
||||||
case generate_cycles_for_member(member, today: today) do
|
case generate_cycles_for_member(member, today: today) do
|
||||||
{:ok, _cycles, notifications} = ok ->
|
{:ok, _cycles, notifications} = ok ->
|
||||||
_ = send_notifications_for_batch_job(notifications)
|
send_notifications_for_batch_job(notifications)
|
||||||
{member.id, ok}
|
{member.id, ok}
|
||||||
|
|
||||||
{:error, _reason} = err ->
|
{:error, _reason} = err ->
|
||||||
|
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
defmodule Mv.Oidc.Discovery do
|
|
||||||
@moduledoc """
|
|
||||||
Fetches and caches the OIDC provider's discovery document
|
|
||||||
(`/.well-known/openid-configuration`).
|
|
||||||
|
|
||||||
Currently only `end_session_endpoint` is exposed — used by the logout flow to
|
|
||||||
trigger RP-initiated logout at the IdP so the user's SSO session is cleared
|
|
||||||
and they don't get auto-re-logged-in.
|
|
||||||
|
|
||||||
Cache lives in `:persistent_term`, keyed by base URL, for the lifetime of the
|
|
||||||
BEAM. Re-fetch on next call after `clear_cache/0`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
require Logger
|
|
||||||
|
|
||||||
@persistent_term_key {__MODULE__, :discovery}
|
|
||||||
@request_timeout 5_000
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Returns the IdP's `end_session_endpoint` URL.
|
|
||||||
|
|
||||||
- `{:ok, url}` if discovery succeeds (and is cached for future calls)
|
|
||||||
- `{:error, reason}` if the IdP is unreachable, the document is malformed,
|
|
||||||
or the field is missing
|
|
||||||
"""
|
|
||||||
@spec end_session_endpoint(String.t()) :: {:ok, String.t()} | {:error, term()}
|
|
||||||
def end_session_endpoint(base_url) when is_binary(base_url) do
|
|
||||||
case fetch_cached(base_url) do
|
|
||||||
{:ok, %{"end_session_endpoint" => url}} when is_binary(url) -> {:ok, url}
|
|
||||||
{:ok, _config} -> {:error, :no_end_session_endpoint}
|
|
||||||
{:error, _} = err -> err
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Clears the cached discovery documents. Intended for tests.
|
|
||||||
"""
|
|
||||||
@spec clear_cache() :: :ok
|
|
||||||
def clear_cache do
|
|
||||||
:persistent_term.erase(@persistent_term_key)
|
|
||||||
:ok
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Seeds the cache with a fixed result for a base URL. Intended for tests so the
|
|
||||||
HTTP fetch is skipped.
|
|
||||||
"""
|
|
||||||
@spec put_cache(String.t(), {:ok, map()} | {:error, term()}) :: :ok
|
|
||||||
def put_cache(base_url, result) when is_binary(base_url) do
|
|
||||||
cache = :persistent_term.get(@persistent_term_key, %{})
|
|
||||||
:persistent_term.put(@persistent_term_key, Map.put(cache, base_url, result))
|
|
||||||
:ok
|
|
||||||
end
|
|
||||||
|
|
||||||
defp fetch_cached(base_url) do
|
|
||||||
cache = :persistent_term.get(@persistent_term_key, %{})
|
|
||||||
|
|
||||||
case Map.fetch(cache, base_url) do
|
|
||||||
{:ok, result} ->
|
|
||||||
result
|
|
||||||
|
|
||||||
:error ->
|
|
||||||
result = fetch(base_url)
|
|
||||||
:persistent_term.put(@persistent_term_key, Map.put(cache, base_url, result))
|
|
||||||
result
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp fetch(base_url) do
|
|
||||||
url = String.trim_trailing(base_url, "/") <> "/.well-known/openid-configuration"
|
|
||||||
|
|
||||||
case Req.get(url,
|
|
||||||
receive_timeout: @request_timeout,
|
|
||||||
connect_options: [timeout: @request_timeout]
|
|
||||||
) do
|
|
||||||
{:ok, %Req.Response{status: 200, body: body}} when is_map(body) ->
|
|
||||||
{:ok, body}
|
|
||||||
|
|
||||||
{:ok, %Req.Response{status: status}} ->
|
|
||||||
Logger.warning("OIDC discovery returned HTTP #{status} for #{url}")
|
|
||||||
{:error, {:http_status, status}}
|
|
||||||
|
|
||||||
{:error, reason} ->
|
|
||||||
Logger.warning("OIDC discovery request failed for #{url}: #{inspect(reason)}")
|
|
||||||
{:error, reason}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -87,6 +87,8 @@ defmodule Mv.OidcRoleSync do
|
||||||
ArgumentError -> nil
|
ArgumentError -> nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp safe_get_atom(_map, _key), do: nil
|
||||||
|
|
||||||
defp peek_jwt_claims(token) do
|
defp peek_jwt_claims(token) do
|
||||||
parts = String.split(token, ".")
|
parts = String.split(token, ".")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,6 @@ defmodule Mv.OidcRoleSyncConfig do
|
||||||
|
|
||||||
@doc "Returns the JWT/user_info claim name for groups; defaults to \"groups\"."
|
@doc "Returns the JWT/user_info claim name for groups; defaults to \"groups\"."
|
||||||
def oidc_groups_claim do
|
def oidc_groups_claim do
|
||||||
Mv.Config.oidc_groups_claim()
|
Mv.Config.oidc_groups_claim() || "groups"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ defmodule Mv.Release do
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
def migrate do
|
def migrate do
|
||||||
_ = load_app()
|
load_app()
|
||||||
|
|
||||||
for repo <- repos() do
|
for repo <- repos() do
|
||||||
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
|
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
|
||||||
|
|
@ -75,14 +75,14 @@ defmodule Mv.Release do
|
||||||
dev_path = Path.join(priv, "repo/seeds_dev.exs")
|
dev_path = Path.join(priv, "repo/seeds_dev.exs")
|
||||||
|
|
||||||
prev = Code.compiler_options()
|
prev = Code.compiler_options()
|
||||||
_ = Code.compiler_options(ignore_module_conflict: true)
|
Code.compiler_options(ignore_module_conflict: true)
|
||||||
|
|
||||||
try do
|
try do
|
||||||
_ = Code.eval_file(bootstrap_path)
|
Code.eval_file(bootstrap_path)
|
||||||
IO.puts("✅ Bootstrap seeds completed.")
|
IO.puts("✅ Bootstrap seeds completed.")
|
||||||
|
|
||||||
if System.get_env("RUN_DEV_SEEDS") == "true" do
|
if System.get_env("RUN_DEV_SEEDS") == "true" do
|
||||||
_ = Code.eval_file(dev_path)
|
Code.eval_file(dev_path)
|
||||||
IO.puts("✅ Dev seeds completed.")
|
IO.puts("✅ Dev seeds completed.")
|
||||||
end
|
end
|
||||||
after
|
after
|
||||||
|
|
@ -92,7 +92,7 @@ defmodule Mv.Release do
|
||||||
end
|
end
|
||||||
|
|
||||||
def rollback(repo, version) do
|
def rollback(repo, version) do
|
||||||
_ = load_app()
|
load_app()
|
||||||
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
|
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -139,11 +139,10 @@ defmodule Mv.Release do
|
||||||
{:ok, %Role{} = admin_role} ->
|
{:ok, %Role{} = admin_role} ->
|
||||||
case get_user_by_email(email) do
|
case get_user_by_email(email) do
|
||||||
{:ok, %User{} = user} ->
|
{:ok, %User{} = user} ->
|
||||||
_ =
|
user
|
||||||
user
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|> Ash.Changeset.for_update(:update, %{})
|
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
|> Ash.update!(authorize?: false)
|
||||||
|> Ash.update!(authorize?: false)
|
|
||||||
|
|
||||||
:ok
|
:ok
|
||||||
|
|
||||||
|
|
@ -190,16 +189,15 @@ defmodule Mv.Release do
|
||||||
defp create_admin_user(email, password, admin_role) do
|
defp create_admin_user(email, password, admin_role) do
|
||||||
case Accounts.create_user(%{email: email}, authorize?: false) do
|
case Accounts.create_user(%{email: email}, authorize?: false) do
|
||||||
{:ok, user} ->
|
{:ok, user} ->
|
||||||
_ =
|
user
|
||||||
user
|
|> Ash.Changeset.for_update(:admin_set_password, %{password: password})
|
||||||
|> Ash.Changeset.for_update(:admin_set_password, %{password: password})
|
|> Ash.update!(authorize?: false)
|
||||||
|
|> then(fn u ->
|
||||||
|
u
|
||||||
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||||
|> Ash.update!(authorize?: false)
|
|> Ash.update!(authorize?: false)
|
||||||
|> then(fn u ->
|
end)
|
||||||
u
|
|
||||||
|> Ash.Changeset.for_update(:update, %{})
|
|
||||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
|
||||||
|> Ash.update!(authorize?: false)
|
|
||||||
end)
|
|
||||||
|
|
||||||
:ok
|
:ok
|
||||||
|
|
||||||
|
|
@ -209,16 +207,15 @@ defmodule Mv.Release do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp update_admin_user(user, password, admin_role) do
|
defp update_admin_user(user, password, admin_role) do
|
||||||
_ =
|
user
|
||||||
user
|
|> Ash.Changeset.for_update(:admin_set_password, %{password: password})
|
||||||
|> Ash.Changeset.for_update(:admin_set_password, %{password: password})
|
|> Ash.update!(authorize?: false)
|
||||||
|
|> then(fn u ->
|
||||||
|
u
|
||||||
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||||
|> Ash.update!(authorize?: false)
|
|> Ash.update!(authorize?: false)
|
||||||
|> then(fn u ->
|
end)
|
||||||
u
|
|
||||||
|> Ash.Changeset.for_update(:update, %{})
|
|
||||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
|
||||||
|> Ash.update!(authorize?: false)
|
|
||||||
end)
|
|
||||||
|
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -19,12 +19,4 @@ defmodule Mv.Repo do
|
||||||
def min_pg_version do
|
def min_pg_version do
|
||||||
%Version{major: 17, minor: 2, patch: 0}
|
%Version{major: 17, minor: 2, patch: 0}
|
||||||
end
|
end
|
||||||
|
|
||||||
# This app does not use schema-based multitenancy, so there are no tenant
|
|
||||||
# schemas to migrate. Returning [] keeps the AshPostgres callback total
|
|
||||||
# rather than raising the default "not defined" error.
|
|
||||||
@impl true
|
|
||||||
def all_tenants do
|
|
||||||
[]
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,6 @@ defmodule Mv.Vereinfacht.Client do
|
||||||
"""
|
"""
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
@typedoc "Error reasons returned by Vereinfacht API calls."
|
|
||||||
@type error_reason ::
|
|
||||||
:not_configured
|
|
||||||
| {:request_failed, map()}
|
|
||||||
| {:http, non_neg_integer(), :html_response | binary()}
|
|
||||||
|
|
||||||
@content_type "application/vnd.api+json"
|
@content_type "application/vnd.api+json"
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
@ -37,7 +31,7 @@ defmodule Mv.Vereinfacht.Client do
|
||||||
{:error, :not_configured}
|
{:error, :not_configured}
|
||||||
"""
|
"""
|
||||||
@spec test_connection(String.t() | nil, String.t() | nil, String.t() | nil) ::
|
@spec test_connection(String.t() | nil, String.t() | nil, String.t() | nil) ::
|
||||||
{:ok, :connected} | {:error, error_reason()}
|
{:ok, :connected} | {:error, term()}
|
||||||
def test_connection(api_url, api_key, club_id) do
|
def test_connection(api_url, api_key, club_id) do
|
||||||
if blank?(api_url) or blank?(api_key) or blank?(club_id) do
|
if blank?(api_url) or blank?(api_key) or blank?(club_id) do
|
||||||
{:error, :not_configured}
|
{:error, :not_configured}
|
||||||
|
|
@ -98,12 +92,13 @@ defmodule Mv.Vereinfacht.Client do
|
||||||
|
|
||||||
@sync_timeout_ms 5_000
|
@sync_timeout_ms 5_000
|
||||||
|
|
||||||
|
# Resolved at compile time so Mix is never called at runtime (Mix is not available in releases).
|
||||||
|
@env Mix.env()
|
||||||
|
|
||||||
# In test, skip retries so sync fails fast when no API is running (avoids log spam and long waits).
|
# In test, skip retries so sync fails fast when no API is running (avoids log spam and long waits).
|
||||||
# `sql_sandbox?/0` reads runtime config (true only in test) and avoids calling Mix at runtime,
|
|
||||||
# which is unavailable in releases.
|
|
||||||
defp req_http_options do
|
defp req_http_options do
|
||||||
opts = [receive_timeout: @sync_timeout_ms]
|
opts = [receive_timeout: @sync_timeout_ms]
|
||||||
if Mv.Config.sql_sandbox?(), do: [retry: false] ++ opts, else: opts
|
if @env == :test, do: [retry: false] ++ opts, else: opts
|
||||||
end
|
end
|
||||||
|
|
||||||
defp post_and_parse_contact(url, body, api_key) do
|
defp post_and_parse_contact(url, body, api_key) do
|
||||||
|
|
@ -235,7 +230,7 @@ defmodule Mv.Vereinfacht.Client do
|
||||||
|
|
||||||
Returns the full response body (decoded JSON) for debugging/display.
|
Returns the full response body (decoded JSON) for debugging/display.
|
||||||
"""
|
"""
|
||||||
@spec get_contact(String.t()) :: {:ok, map()} | {:error, error_reason()}
|
@spec get_contact(String.t()) :: {:ok, map()} | {:error, term()}
|
||||||
def get_contact(contact_id) when is_binary(contact_id) do
|
def get_contact(contact_id) when is_binary(contact_id) do
|
||||||
fetch_contact(contact_id, [])
|
fetch_contact(contact_id, [])
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -37,10 +37,9 @@ defmodule Mv.Vereinfacht.SyncFlash do
|
||||||
def create_table! do
|
def create_table! do
|
||||||
# :public so any process can write (SyncContact runs in LiveView/Ash transaction process,
|
# :public so any process can write (SyncContact runs in LiveView/Ash transaction process,
|
||||||
# not the process that created the table). :protected would restrict writes to the creating process.
|
# not the process that created the table). :protected would restrict writes to the creating process.
|
||||||
_ =
|
if :ets.whereis(@table) == :undefined do
|
||||||
if :ets.whereis(@table) == :undefined do
|
:ets.new(@table, [:set, :public, :named_table])
|
||||||
:ets.new(@table, [:set, :public, :named_table])
|
end
|
||||||
end
|
|
||||||
|
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ defmodule Mv.Vereinfacht do
|
||||||
- `{:error, {:http, status, message}}` – API returned an error (e.g. 401, 403)
|
- `{:error, {:http, status, message}}` – API returned an error (e.g. 401, 403)
|
||||||
- `{:error, {:request_failed, reason}}` – network/transport error
|
- `{:error, {:request_failed, reason}}` – network/transport error
|
||||||
"""
|
"""
|
||||||
@spec test_connection() :: {:ok, :connected} | {:error, Mv.Vereinfacht.Client.error_reason()}
|
@spec test_connection() :: {:ok, :connected} | {:error, term()}
|
||||||
def test_connection do
|
def test_connection do
|
||||||
Client.test_connection(
|
Client.test_connection(
|
||||||
Mv.Config.vereinfacht_api_url(),
|
Mv.Config.vereinfacht_api_url(),
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,8 @@ defmodule MvWeb.Authorization do
|
||||||
iex> can_access_page?(mitglied, "/members")
|
iex> can_access_page?(mitglied, "/members")
|
||||||
false
|
false
|
||||||
"""
|
"""
|
||||||
@spec can_access_page?(map() | nil, String.t()) :: boolean()
|
@spec can_access_page?(map() | nil, String.t() | Phoenix.VerifiedRoutes.unverified_path()) ::
|
||||||
|
boolean()
|
||||||
def can_access_page?(nil, _page_path), do: false
|
def can_access_page?(nil, _page_path), do: false
|
||||||
|
|
||||||
def can_access_page?(user, page_path) do
|
def can_access_page?(user, page_path) do
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ defmodule MvWeb.AuthController do
|
||||||
|
|
||||||
alias Mv.Accounts.User.Errors.PasswordVerificationRequired
|
alias Mv.Accounts.User.Errors.PasswordVerificationRequired
|
||||||
alias Mv.Config
|
alias Mv.Config
|
||||||
alias Mv.Oidc.Discovery
|
|
||||||
|
|
||||||
def success(conn, {:password, :sign_in} = _activity, user, token) do
|
def success(conn, {:password, :sign_in} = _activity, user, token) do
|
||||||
if Config.oidc_only?() do
|
if Config.oidc_only?() do
|
||||||
|
|
@ -335,29 +334,14 @@ defmodule MvWeb.AuthController do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp redact_url(_), do: "[redacted]"
|
||||||
|
|
||||||
def sign_out(conn, _params) do
|
def sign_out(conn, _params) do
|
||||||
conn = clear_session(conn, :mv) |> put_flash(:success, gettext("You are now signed out"))
|
return_to = get_session(conn, :return_to) || ~p"/"
|
||||||
|
|
||||||
case oidc_end_session_url() do
|
conn
|
||||||
{:ok, url} ->
|
|> clear_session(:mv)
|
||||||
redirect(conn, external: url)
|
|> put_flash(:success, gettext("You are now signed out"))
|
||||||
|
|> redirect(to: return_to)
|
||||||
:no_oidc ->
|
|
||||||
redirect(conn, to: get_session(conn, :return_to) || ~p"/")
|
|
||||||
|
|
||||||
{:error, _reason} ->
|
|
||||||
# IdP discovery failed — fall back to local logout. The user's IdP session
|
|
||||||
# is still active, so OIDC_ONLY setups may auto-re-login. Better than
|
|
||||||
# blocking logout entirely.
|
|
||||||
redirect(conn, to: ~p"/sign-in?oidc_failed=1")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp oidc_end_session_url do
|
|
||||||
if Config.oidc_configured?() do
|
|
||||||
Discovery.end_session_endpoint(Config.oidc_base_url())
|
|
||||||
else
|
|
||||||
:no_oidc
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -25,33 +25,31 @@ defmodule MvWeb.MemberExportController do
|
||||||
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
||||||
|
|
||||||
def export(conn, params) do
|
def export(conn, params) do
|
||||||
case current_actor(conn) do
|
actor = current_actor(conn)
|
||||||
nil -> return_forbidden(conn)
|
if is_nil(actor), do: return_forbidden(conn)
|
||||||
actor -> export_with_actor(conn, actor, params["payload"])
|
|
||||||
|
case params["payload"] do
|
||||||
|
nil ->
|
||||||
|
conn
|
||||||
|
|> put_status(400)
|
||||||
|
|> put_resp_content_type("application/json")
|
||||||
|
|> json(%{error: "payload required"})
|
||||||
|
|
||||||
|
payload when is_binary(payload) ->
|
||||||
|
case Jason.decode(payload) do
|
||||||
|
{:ok, decoded} when is_map(decoded) ->
|
||||||
|
parsed = parse_and_validate(decoded)
|
||||||
|
run_export(conn, actor, parsed)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
conn
|
||||||
|
|> put_status(400)
|
||||||
|
|> put_resp_content_type("application/json")
|
||||||
|
|> json(%{error: "invalid JSON"})
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp export_with_actor(conn, actor, payload) when is_binary(payload) do
|
|
||||||
case Jason.decode(payload) do
|
|
||||||
{:ok, decoded} when is_map(decoded) ->
|
|
||||||
run_export(conn, actor, parse_and_validate(decoded))
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
json_error(conn, "invalid JSON")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp export_with_actor(conn, _actor, _payload) do
|
|
||||||
json_error(conn, "payload required")
|
|
||||||
end
|
|
||||||
|
|
||||||
defp json_error(conn, message) do
|
|
||||||
conn
|
|
||||||
|> put_status(400)
|
|
||||||
|> put_resp_content_type("application/json")
|
|
||||||
|> json(%{error: message})
|
|
||||||
end
|
|
||||||
|
|
||||||
defp current_actor(conn) do
|
defp current_actor(conn) do
|
||||||
conn.assigns[:current_user]
|
conn.assigns[:current_user]
|
||||||
|> Actor.ensure_loaded()
|
|> Actor.ensure_loaded()
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,8 @@ defmodule MvWeb.SignInLive do
|
||||||
# Set both backend-specific and global locale so Gettext.get_locale/0 and
|
# Set both backend-specific and global locale so Gettext.get_locale/0 and
|
||||||
# Gettext.get_locale/1 both return the correct value (important for the
|
# Gettext.get_locale/1 both return the correct value (important for the
|
||||||
# language-selector `selected` attribute in Layouts.public_page).
|
# language-selector `selected` attribute in Layouts.public_page).
|
||||||
_ = Gettext.put_locale(MvWeb.Gettext, locale)
|
Gettext.put_locale(MvWeb.Gettext, locale)
|
||||||
_ = Gettext.put_locale(locale)
|
Gettext.put_locale(locale)
|
||||||
|
|
||||||
# Prepend DE-specific overrides when locale is German so that components
|
# Prepend DE-specific overrides when locale is German so that components
|
||||||
# without _gettext support (e.g. HorizontalRule) still render in German.
|
# without _gettext support (e.g. HorizontalRule) still render in German.
|
||||||
|
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
defmodule MvWeb.SignOutLive do
|
|
||||||
@moduledoc """
|
|
||||||
Custom sign-out confirmation page.
|
|
||||||
|
|
||||||
Replaces AshAuthentication.Phoenix.SignOutLive so the page meets accessibility
|
|
||||||
requirements (main landmark via Layouts.public_page, level-one heading) and
|
|
||||||
uses the project's DaisyUI button styles. Submits DELETE /sign-out for CSRF
|
|
||||||
protection, same contract as the library default.
|
|
||||||
"""
|
|
||||||
use Phoenix.LiveView
|
|
||||||
use Gettext, backend: MvWeb.Gettext
|
|
||||||
|
|
||||||
alias Mv.Membership
|
|
||||||
alias MvWeb.Layouts
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def mount(_params, session, socket) do
|
|
||||||
locale = session["locale"] || Application.get_env(:mv, :default_locale, "de")
|
|
||||||
_ = Gettext.put_locale(MvWeb.Gettext, locale)
|
|
||||||
_ = Gettext.put_locale(locale)
|
|
||||||
|
|
||||||
club_name =
|
|
||||||
case Membership.get_settings() do
|
|
||||||
{:ok, settings} when is_binary(settings.club_name) -> settings.club_name
|
|
||||||
_ -> nil
|
|
||||||
end
|
|
||||||
|
|
||||||
socket =
|
|
||||||
socket
|
|
||||||
|> assign(:sign_out_path, session["sign_out_path"] || "/sign-out")
|
|
||||||
|> assign(:locale, locale)
|
|
||||||
|> assign(:club_name, club_name)
|
|
||||||
|> Layouts.assign_page_title(dgettext("auth", "Sign out"))
|
|
||||||
|
|
||||||
{:ok, socket}
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def render(assigns) do
|
|
||||||
~H"""
|
|
||||||
<Layouts.public_page flash={@flash} club_name={@club_name}>
|
|
||||||
<div class="hero min-h-[40vh] bg-base-200 rounded-lg">
|
|
||||||
<div class="hero-content flex-col items-start text-left">
|
|
||||||
<div class="w-full max-w-md">
|
|
||||||
<h1 class="text-xl font-semibold leading-8 mb-4">
|
|
||||||
{dgettext("auth", "Sign out")}
|
|
||||||
</h1>
|
|
||||||
<p class="text-base-content/70 mb-4">
|
|
||||||
{dgettext("auth", "Are you sure you want to sign out?")}
|
|
||||||
</p>
|
|
||||||
<.form for={%{}} action={@sign_out_path} method="delete">
|
|
||||||
<button type="submit" class="btn btn-primary w-full">
|
|
||||||
{dgettext("auth", "Sign out")}
|
|
||||||
</button>
|
|
||||||
</.form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Layouts.public_page>
|
|
||||||
"""
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -23,11 +23,6 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
||||||
- `:fee_type_filters` - Map of active fee type filters: `%{fee_type_id => :in | :not_in}` (nil = All).
|
- `:fee_type_filters` - Map of active fee type filters: `%{fee_type_id => :in | :not_in}` (nil = All).
|
||||||
- `:boolean_custom_fields` - List of boolean custom fields to display
|
- `:boolean_custom_fields` - List of boolean custom fields to display
|
||||||
- `:boolean_filters` - Map of active boolean filters: `%{custom_field_id => true | false}`
|
- `:boolean_filters` - Map of active boolean filters: `%{custom_field_id => true | false}`
|
||||||
- `:date_custom_fields` - List of date-typed custom fields rendered in the
|
|
||||||
"Custom date fields" section (each with `:id`, `:name`, `:value_type`).
|
|
||||||
- `:date_filters` - Date filter state map (see `MvWeb.MemberLive.Index.DateFilter`):
|
|
||||||
built-in `:join_date` / `:exit_date` bounds and mode, plus optional
|
|
||||||
UUID-keyed custom date field bound entries.
|
|
||||||
- `:id` - Component ID (required)
|
- `:id` - Component ID (required)
|
||||||
- `:member_count` - Number of filtered members to display in badge (optional, default: 0)
|
- `:member_count` - Number of filtered members to display in badge (optional, default: 0)
|
||||||
|
|
||||||
|
|
@ -36,18 +31,13 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
||||||
- Sends `{:group_filter_changed, group_id_str, value}` to parent when a group filter changes (value: nil | :in | :not_in)
|
- Sends `{:group_filter_changed, group_id_str, value}` to parent when a group filter changes (value: nil | :in | :not_in)
|
||||||
- Sends `{:fee_type_filter_changed, fee_type_id_str, value}` to parent when a fee type filter changes (value: nil | :in | :not_in)
|
- Sends `{:fee_type_filter_changed, fee_type_id_str, value}` to parent when a fee type filter changes (value: nil | :in | :not_in)
|
||||||
- Sends `{:boolean_filter_changed, custom_field_id, filter_value}` to parent when boolean filter changes
|
- Sends `{:boolean_filter_changed, custom_field_id, filter_value}` to parent when boolean filter changes
|
||||||
- Sends `{:date_filters_changed, new_filters}` to parent when any date
|
|
||||||
filter input changes (built-in date bounds, exit_date mode, or custom
|
|
||||||
date field bounds).
|
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_component
|
use MvWeb, :live_component
|
||||||
|
|
||||||
alias MvWeb.MemberLive.Index.DateFilter
|
|
||||||
alias MvWeb.MemberLive.Index.FilterParams
|
alias MvWeb.MemberLive.Index.FilterParams
|
||||||
|
|
||||||
@group_filter_prefix Mv.Constants.group_filter_prefix()
|
@group_filter_prefix Mv.Constants.group_filter_prefix()
|
||||||
@fee_type_filter_prefix Mv.Constants.fee_type_filter_prefix()
|
@fee_type_filter_prefix Mv.Constants.fee_type_filter_prefix()
|
||||||
@custom_date_filter_prefix Mv.Constants.custom_date_filter_prefix()
|
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(socket) do
|
def mount(socket) do
|
||||||
|
|
@ -60,42 +50,19 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
||||||
socket
|
socket
|
||||||
|> assign(:id, assigns.id)
|
|> assign(:id, assigns.id)
|
||||||
|> assign(:cycle_status_filter, assigns[:cycle_status_filter])
|
|> assign(:cycle_status_filter, assigns[:cycle_status_filter])
|
||||||
|> assign_group_assigns(assigns)
|
|> assign(:groups, assigns[:groups] || [])
|
||||||
|> assign_fee_type_assigns(assigns)
|
|> assign(:group_filters, assigns[:group_filters] || %{})
|
||||||
|> assign_boolean_assigns(assigns)
|
|> assign(:group_filter_prefix, @group_filter_prefix)
|
||||||
|> assign_date_assigns(assigns)
|
|> assign(:fee_types, assigns[:fee_types] || [])
|
||||||
|
|> assign(:fee_type_filters, assigns[:fee_type_filters] || %{})
|
||||||
|
|> assign(:fee_type_filter_prefix, @fee_type_filter_prefix)
|
||||||
|
|> assign(:boolean_custom_fields, assigns[:boolean_custom_fields] || [])
|
||||||
|
|> assign(:boolean_filters, assigns[:boolean_filters] || %{})
|
||||||
|> assign(:member_count, assigns[:member_count] || 0)
|
|> assign(:member_count, assigns[:member_count] || 0)
|
||||||
|
|
||||||
{:ok, socket}
|
{:ok, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp assign_group_assigns(socket, assigns) do
|
|
||||||
socket
|
|
||||||
|> assign(:groups, assigns[:groups] || [])
|
|
||||||
|> assign(:group_filters, assigns[:group_filters] || %{})
|
|
||||||
|> assign(:group_filter_prefix, @group_filter_prefix)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp assign_fee_type_assigns(socket, assigns) do
|
|
||||||
socket
|
|
||||||
|> assign(:fee_types, assigns[:fee_types] || [])
|
|
||||||
|> assign(:fee_type_filters, assigns[:fee_type_filters] || %{})
|
|
||||||
|> assign(:fee_type_filter_prefix, @fee_type_filter_prefix)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp assign_boolean_assigns(socket, assigns) do
|
|
||||||
socket
|
|
||||||
|> assign(:boolean_custom_fields, assigns[:boolean_custom_fields] || [])
|
|
||||||
|> assign(:boolean_filters, assigns[:boolean_filters] || %{})
|
|
||||||
end
|
|
||||||
|
|
||||||
defp assign_date_assigns(socket, assigns) do
|
|
||||||
socket
|
|
||||||
|> assign(:date_custom_fields, assigns[:date_custom_fields] || [])
|
|
||||||
|> assign(:date_filters, assigns[:date_filters] || DateFilter.default())
|
|
||||||
|> assign(:custom_date_filter_prefix, @custom_date_filter_prefix)
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
|
|
@ -114,8 +81,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
||||||
"gap-2",
|
"gap-2",
|
||||||
(@cycle_status_filter || map_size(@group_filters) > 0 ||
|
(@cycle_status_filter || map_size(@group_filters) > 0 ||
|
||||||
map_size(@fee_type_filters) > 0 ||
|
map_size(@fee_type_filters) > 0 ||
|
||||||
active_boolean_filters_count(@boolean_filters) > 0 ||
|
active_boolean_filters_count(@boolean_filters) > 0) &&
|
||||||
date_filters_active?(@date_filters)) &&
|
|
||||||
"btn-active"
|
"btn-active"
|
||||||
]}
|
]}
|
||||||
phx-click="toggle_dropdown"
|
phx-click="toggle_dropdown"
|
||||||
|
|
@ -133,8 +99,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
||||||
@fee_types,
|
@fee_types,
|
||||||
@fee_type_filters,
|
@fee_type_filters,
|
||||||
@boolean_custom_fields,
|
@boolean_custom_fields,
|
||||||
@boolean_filters,
|
@boolean_filters
|
||||||
@date_filters
|
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<.badge
|
<.badge
|
||||||
|
|
@ -146,9 +111,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
||||||
</.badge>
|
</.badge>
|
||||||
<.badge
|
<.badge
|
||||||
:if={
|
:if={
|
||||||
(@cycle_status_filter || map_size(@group_filters) > 0 ||
|
(@cycle_status_filter || map_size(@group_filters) > 0 || map_size(@fee_type_filters) > 0) &&
|
||||||
map_size(@fee_type_filters) > 0 ||
|
|
||||||
date_filters_active?(@date_filters)) &&
|
|
||||||
active_boolean_filters_count(@boolean_filters) == 0
|
active_boolean_filters_count(@boolean_filters) == 0
|
||||||
}
|
}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
|
|
@ -366,163 +329,6 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Dates Group (built-in fields: exit_date with mode selector, join_date range) -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
|
|
||||||
{gettext("Dates")}
|
|
||||||
</div>
|
|
||||||
<fieldset class="border-0 p-0 m-0 min-w-0 mb-3">
|
|
||||||
<legend class="text-sm font-medium mb-1">
|
|
||||||
{gettext("Exit date")}
|
|
||||||
</legend>
|
|
||||||
<div class="join w-full">
|
|
||||||
<label
|
|
||||||
class={"#{exit_mode_label_class(@date_filters, :active_only)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
|
|
||||||
for="ed-mode-active-only"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
id="ed-mode-active-only"
|
|
||||||
name="ed_mode"
|
|
||||||
value="active_only"
|
|
||||||
class="absolute opacity-0 w-0 h-0 pointer-events-none"
|
|
||||||
checked={exit_mode(@date_filters) == :active_only}
|
|
||||||
/>
|
|
||||||
<span class="text-xs">{gettext("Active only")}</span>
|
|
||||||
</label>
|
|
||||||
<label
|
|
||||||
class={"#{exit_mode_label_class(@date_filters, :all)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
|
|
||||||
for="ed-mode-all"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
id="ed-mode-all"
|
|
||||||
name="ed_mode"
|
|
||||||
value="all"
|
|
||||||
class="absolute opacity-0 w-0 h-0 pointer-events-none"
|
|
||||||
checked={exit_mode(@date_filters) == :all}
|
|
||||||
/>
|
|
||||||
<span class="text-xs">{gettext("All")}</span>
|
|
||||||
</label>
|
|
||||||
<label
|
|
||||||
class={"#{exit_mode_label_class(@date_filters, :inactive_only)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
|
|
||||||
for="ed-mode-inactive-only"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
id="ed-mode-inactive-only"
|
|
||||||
name="ed_mode"
|
|
||||||
value="inactive_only"
|
|
||||||
class="absolute opacity-0 w-0 h-0 pointer-events-none"
|
|
||||||
checked={exit_mode(@date_filters) == :inactive_only}
|
|
||||||
/>
|
|
||||||
<span class="text-xs">{gettext("Inactive only")}</span>
|
|
||||||
</label>
|
|
||||||
<label
|
|
||||||
class={"#{exit_mode_label_class(@date_filters, :custom)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
|
|
||||||
for="ed-mode-custom"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
id="ed-mode-custom"
|
|
||||||
name="ed_mode"
|
|
||||||
value="custom"
|
|
||||||
class="absolute opacity-0 w-0 h-0 pointer-events-none"
|
|
||||||
checked={exit_mode(@date_filters) == :custom}
|
|
||||||
/>
|
|
||||||
<span class="text-xs">{gettext("Range")}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
:if={exit_mode(@date_filters) == :custom}
|
|
||||||
class="mt-2 flex gap-3 items-end flex-wrap"
|
|
||||||
>
|
|
||||||
<.input
|
|
||||||
type="date"
|
|
||||||
id="ed-from"
|
|
||||||
name="ed_from"
|
|
||||||
label={gettext("From")}
|
|
||||||
class="input input-sm input-bordered"
|
|
||||||
aria-label={gettext("Exit date from")}
|
|
||||||
value={date_value_for_input(@date_filters, :exit_date, :from)}
|
|
||||||
/>
|
|
||||||
<.input
|
|
||||||
type="date"
|
|
||||||
id="ed-to"
|
|
||||||
name="ed_to"
|
|
||||||
label={gettext("To")}
|
|
||||||
class="input input-sm input-bordered"
|
|
||||||
aria-label={gettext("Exit date to")}
|
|
||||||
value={date_value_for_input(@date_filters, :exit_date, :to)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
<fieldset class="border-0 p-0 m-0 min-w-0">
|
|
||||||
<legend class="text-sm font-medium mb-1">
|
|
||||||
{gettext("Join date")}
|
|
||||||
</legend>
|
|
||||||
<div class="flex gap-3 items-end flex-wrap">
|
|
||||||
<.input
|
|
||||||
type="date"
|
|
||||||
id="jd-from"
|
|
||||||
name="jd_from"
|
|
||||||
label={gettext("From")}
|
|
||||||
class="input input-sm input-bordered"
|
|
||||||
aria-label={gettext("Join date from")}
|
|
||||||
value={date_value_for_input(@date_filters, :join_date, :from)}
|
|
||||||
/>
|
|
||||||
<.input
|
|
||||||
type="date"
|
|
||||||
id="jd-to"
|
|
||||||
name="jd_to"
|
|
||||||
label={gettext("To")}
|
|
||||||
class="input input-sm input-bordered"
|
|
||||||
aria-label={gettext("Join date to")}
|
|
||||||
value={date_value_for_input(@date_filters, :join_date, :to)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Custom Date Fields Group (in-memory filter; one row per :date custom field) -->
|
|
||||||
<div :if={length(@date_custom_fields) > 0} class="mb-4">
|
|
||||||
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
|
|
||||||
{gettext("Custom date fields")}
|
|
||||||
</div>
|
|
||||||
<div class={
|
|
||||||
if length(@date_custom_fields) > 5, do: "max-h-60 overflow-y-auto pr-2", else: ""
|
|
||||||
}>
|
|
||||||
<fieldset
|
|
||||||
:for={field <- @date_custom_fields}
|
|
||||||
class="border-0 border-b border-base-200 last:border-b-0 p-0 m-0 min-w-0 py-2"
|
|
||||||
>
|
|
||||||
<legend class="text-sm font-medium mb-1">
|
|
||||||
{field.name}
|
|
||||||
</legend>
|
|
||||||
<div class="flex gap-3 items-end flex-wrap">
|
|
||||||
<.input
|
|
||||||
type="date"
|
|
||||||
id={"cdf-#{field.id}-from"}
|
|
||||||
name={"#{@custom_date_filter_prefix}#{field.id}_from"}
|
|
||||||
label={gettext("From")}
|
|
||||||
class="input input-sm input-bordered"
|
|
||||||
aria-label={gettext("%{field} from", field: field.name)}
|
|
||||||
value={custom_date_value_for_input(@date_filters, field.id, :from)}
|
|
||||||
/>
|
|
||||||
<.input
|
|
||||||
type="date"
|
|
||||||
id={"cdf-#{field.id}-to"}
|
|
||||||
name={"#{@custom_date_filter_prefix}#{field.id}_to"}
|
|
||||||
label={gettext("To")}
|
|
||||||
class="input input-sm input-bordered"
|
|
||||||
aria-label={gettext("%{field} to", field: field.name)}
|
|
||||||
value={custom_date_value_for_input(@date_filters, field.id, :to)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Custom Fields Group -->
|
<!-- Custom Fields Group -->
|
||||||
<div :if={length(@boolean_custom_fields) > 0} class="mb-2">
|
<div :if={length(@boolean_custom_fields) > 0} class="mb-2">
|
||||||
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
|
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
|
||||||
|
|
@ -632,27 +438,17 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
||||||
payment_filter = parse_payment_filter(params)
|
payment_filter = parse_payment_filter(params)
|
||||||
|
|
||||||
group_filters_parsed =
|
group_filters_parsed =
|
||||||
FilterParams.parse_prefix_filters(
|
parse_prefix_filters(params, @group_filter_prefix, &FilterParams.parse_in_not_in_value/1)
|
||||||
params,
|
|
||||||
@group_filter_prefix,
|
|
||||||
&FilterParams.parse_in_not_in_value/1
|
|
||||||
)
|
|
||||||
|
|
||||||
fee_type_filters_parsed =
|
fee_type_filters_parsed =
|
||||||
FilterParams.parse_prefix_filters(
|
parse_prefix_filters(params, @fee_type_filter_prefix, &FilterParams.parse_in_not_in_value/1)
|
||||||
params,
|
|
||||||
@fee_type_filter_prefix,
|
|
||||||
&FilterParams.parse_in_not_in_value/1
|
|
||||||
)
|
|
||||||
|
|
||||||
custom_boolean_filters_parsed = parse_custom_boolean_filters(params)
|
custom_boolean_filters_parsed = parse_custom_boolean_filters(params)
|
||||||
new_date_filters = DateFilter.from_params(params, socket.assigns.date_custom_fields)
|
|
||||||
|
|
||||||
dispatch_payment_filter_change(socket, payment_filter)
|
dispatch_payment_filter_change(socket, payment_filter)
|
||||||
dispatch_group_filter_changes(socket, group_filters_parsed)
|
dispatch_group_filter_changes(socket, group_filters_parsed)
|
||||||
dispatch_fee_type_filter_changes(socket, fee_type_filters_parsed)
|
dispatch_fee_type_filter_changes(socket, fee_type_filters_parsed)
|
||||||
dispatch_boolean_filter_changes(socket, custom_boolean_filters_parsed)
|
dispatch_boolean_filter_changes(socket, custom_boolean_filters_parsed)
|
||||||
dispatch_date_filters_change(socket, new_date_filters)
|
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
@ -690,6 +486,17 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp parse_prefix_filters(params, prefix, parse_value_fn) do
|
||||||
|
prefix_len = String.length(prefix)
|
||||||
|
|
||||||
|
params
|
||||||
|
|> Enum.filter(fn {key, _} -> String.starts_with?(key, prefix) end)
|
||||||
|
|> Enum.reduce(%{}, fn {key, value_str}, acc ->
|
||||||
|
id_str = String.slice(key, prefix_len, String.length(key) - prefix_len)
|
||||||
|
Map.put(acc, id_str, parse_value_fn.(value_str))
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
defp parse_custom_boolean_filters(params) do
|
defp parse_custom_boolean_filters(params) do
|
||||||
params
|
params
|
||||||
|> Map.get("custom_boolean", %{})
|
|> Map.get("custom_boolean", %{})
|
||||||
|
|
@ -736,12 +543,6 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp dispatch_date_filters_change(socket, new_date_filters) do
|
|
||||||
if new_date_filters != socket.assigns.date_filters do
|
|
||||||
send(self(), {:date_filters_changed, new_date_filters})
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Get display label for button
|
# Get display label for button
|
||||||
defp button_label(
|
defp button_label(
|
||||||
cycle_status_filter,
|
cycle_status_filter,
|
||||||
|
|
@ -750,16 +551,14 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
||||||
fee_types,
|
fee_types,
|
||||||
fee_type_filters,
|
fee_type_filters,
|
||||||
boolean_custom_fields,
|
boolean_custom_fields,
|
||||||
boolean_filters,
|
boolean_filters
|
||||||
date_filters
|
|
||||||
) do
|
) do
|
||||||
active_count =
|
active_count =
|
||||||
count_active_filter_categories(
|
count_active_filter_categories(
|
||||||
cycle_status_filter,
|
cycle_status_filter,
|
||||||
group_filters,
|
group_filters,
|
||||||
fee_type_filters,
|
fee_type_filters,
|
||||||
boolean_filters,
|
boolean_filters
|
||||||
date_filters
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if active_count >= 2 do
|
if active_count >= 2 do
|
||||||
|
|
@ -780,9 +579,6 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
||||||
map_size(boolean_filters) > 0 ->
|
map_size(boolean_filters) > 0 ->
|
||||||
boolean_filter_label(boolean_custom_fields, boolean_filters)
|
boolean_filter_label(boolean_custom_fields, boolean_filters)
|
||||||
|
|
||||||
date_filters_active?(date_filters) ->
|
|
||||||
gettext("Dates")
|
|
||||||
|
|
||||||
true ->
|
true ->
|
||||||
gettext("Apply filters")
|
gettext("Apply filters")
|
||||||
end
|
end
|
||||||
|
|
@ -793,27 +589,17 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
||||||
cycle_status_filter,
|
cycle_status_filter,
|
||||||
group_filters,
|
group_filters,
|
||||||
fee_type_filters,
|
fee_type_filters,
|
||||||
boolean_filters,
|
boolean_filters
|
||||||
date_filters
|
|
||||||
) do
|
) do
|
||||||
[
|
[
|
||||||
cycle_status_filter,
|
cycle_status_filter,
|
||||||
map_size(group_filters) > 0,
|
map_size(group_filters) > 0,
|
||||||
map_size(fee_type_filters) > 0,
|
map_size(fee_type_filters) > 0,
|
||||||
map_size(boolean_filters) > 0,
|
map_size(boolean_filters) > 0
|
||||||
date_filters_active?(date_filters)
|
|
||||||
]
|
]
|
||||||
|> Enum.count(& &1)
|
|> Enum.count(& &1)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Date filter is "active" when its state differs from the default — i.e. the
|
|
||||||
# user selected something other than active-only with no custom date bounds.
|
|
||||||
defp date_filters_active?(date_filters) when is_map(date_filters) do
|
|
||||||
date_filters != DateFilter.default()
|
|
||||||
end
|
|
||||||
|
|
||||||
defp date_filters_active?(_), do: false
|
|
||||||
|
|
||||||
defp group_filters_label(_groups, group_filters) when map_size(group_filters) == 0,
|
defp group_filters_label(_groups, group_filters) when map_size(group_filters) == 0,
|
||||||
do: gettext("All")
|
do: gettext("All")
|
||||||
|
|
||||||
|
|
@ -935,6 +721,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
||||||
{nil, true} -> "#{base_classes} btn-active"
|
{nil, true} -> "#{base_classes} btn-active"
|
||||||
{:in, true} -> "#{base_classes} btn-success btn-active"
|
{:in, true} -> "#{base_classes} btn-success btn-active"
|
||||||
{:not_in, true} -> "#{base_classes} btn-error btn-active"
|
{:not_in, true} -> "#{base_classes} btn-error btn-active"
|
||||||
|
_ -> "#{base_classes} btn-outline"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -981,35 +768,4 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
||||||
"#{base_classes} btn-outline"
|
"#{base_classes} btn-outline"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# --- Date filter helpers ----------------------------------------------
|
|
||||||
|
|
||||||
defp exit_mode(%{exit_date: %{mode: mode}}), do: mode
|
|
||||||
defp exit_mode(_), do: :active_only
|
|
||||||
|
|
||||||
defp exit_mode_label_class(date_filters, expected) do
|
|
||||||
base_classes = "join-item btn btn-sm"
|
|
||||||
|
|
||||||
if exit_mode(date_filters) == expected do
|
|
||||||
"#{base_classes} btn-active"
|
|
||||||
else
|
|
||||||
"#{base_classes} btn"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp date_value_for_input(date_filters, field, bound) do
|
|
||||||
case date_filters do
|
|
||||||
%{^field => %{^bound => %Date{} = d}} -> Date.to_iso8601(d)
|
|
||||||
_ -> ""
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp custom_date_value_for_input(date_filters, field_id, bound) do
|
|
||||||
key = to_string(field_id)
|
|
||||||
|
|
||||||
case Map.get(date_filters, key) do
|
|
||||||
%{^bound => %Date{} = d} -> Date.to_iso8601(d)
|
|
||||||
_ -> ""
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
|
|
||||||
# Get locale from session; same fallback as router/LiveUserAuth (respects config :default_locale in test)
|
# Get locale from session; same fallback as router/LiveUserAuth (respects config :default_locale in test)
|
||||||
locale = session["locale"] || Application.get_env(:mv, :default_locale, "de")
|
locale = session["locale"] || Application.get_env(:mv, :default_locale, "de")
|
||||||
_ = Gettext.put_locale(MvWeb.Gettext, locale)
|
Gettext.put_locale(MvWeb.Gettext, locale)
|
||||||
|
|
||||||
actor = MvWeb.LiveHelpers.current_actor(socket)
|
actor = MvWeb.LiveHelpers.current_actor(socket)
|
||||||
custom_fields = load_custom_fields(actor)
|
custom_fields = load_custom_fields(actor)
|
||||||
|
|
|
||||||
|
|
@ -836,6 +836,12 @@ defmodule MvWeb.GroupLive.Show do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp perform_add_members(socket, _group, _member_ids, _actor) do
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:error, gettext("No members selected."))}
|
||||||
|
end
|
||||||
|
|
||||||
defp handle_successful_add_members(socket, group, actor) do
|
defp handle_successful_add_members(socket, group, actor) do
|
||||||
socket = reload_group(socket, group.slug, actor)
|
socket = reload_group(socket, group.slug, actor)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,11 +47,14 @@ defmodule MvWeb.ImportLive do
|
||||||
# after this limit is reached.
|
# after this limit is reached.
|
||||||
@max_errors 50
|
@max_errors 50
|
||||||
|
|
||||||
|
# Maximum length for error messages before truncation
|
||||||
|
@max_error_message_length 200
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, session, socket) do
|
def mount(_params, session, socket) do
|
||||||
# Get locale from session for translations
|
# Get locale from session for translations
|
||||||
locale = session["locale"] || "de"
|
locale = session["locale"] || "de"
|
||||||
_ = Gettext.put_locale(MvWeb.Gettext, locale)
|
Gettext.put_locale(MvWeb.Gettext, locale)
|
||||||
|
|
||||||
# Get club name from settings
|
# Get club name from settings
|
||||||
club_name =
|
club_name =
|
||||||
|
|
@ -190,6 +193,16 @@ defmodule MvWeb.ImportLive do
|
||||||
:error,
|
:error,
|
||||||
gettext("Failed to prepare CSV import: %{reason}", reason: reason)
|
gettext("Failed to prepare CSV import: %{reason}", reason: reason)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{:error, error} ->
|
||||||
|
error_message = format_error_message(error)
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
put_flash(
|
||||||
|
socket,
|
||||||
|
:error,
|
||||||
|
gettext("Failed to prepare CSV import: %{reason}", reason: error_message)
|
||||||
|
)}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -210,6 +223,64 @@ defmodule MvWeb.ImportLive do
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Formats error messages for user-friendly display.
|
||||||
|
#
|
||||||
|
# Handles various error types including Ash errors, maps with message fields,
|
||||||
|
# lists of errors, and fallback formatting for unknown types.
|
||||||
|
@spec format_error_message(any()) :: String.t()
|
||||||
|
defp format_error_message(error) do
|
||||||
|
case error do
|
||||||
|
%Ash.Error.Invalid{} = ash_error ->
|
||||||
|
format_ash_error(ash_error)
|
||||||
|
|
||||||
|
%{message: msg} when is_binary(msg) ->
|
||||||
|
msg
|
||||||
|
|
||||||
|
%{errors: errors} when is_list(errors) ->
|
||||||
|
format_error_list(errors)
|
||||||
|
|
||||||
|
reason when is_binary(reason) ->
|
||||||
|
reason
|
||||||
|
|
||||||
|
other ->
|
||||||
|
format_unknown_error(other)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Formats Ash validation errors for display
|
||||||
|
defp format_ash_error(%Ash.Error.Invalid{errors: errors}) when is_list(errors) do
|
||||||
|
Enum.map_join(errors, ", ", &format_single_error/1)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_ash_error(error) do
|
||||||
|
format_unknown_error(error)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Formats a list of errors into a readable string
|
||||||
|
defp format_error_list(errors) do
|
||||||
|
Enum.map_join(errors, ", ", &format_single_error/1)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Formats a single error item
|
||||||
|
defp format_single_error(error) when is_map(error) do
|
||||||
|
Map.get(error, :message) || Map.get(error, :field) || inspect(error, limit: :infinity)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_single_error(error) do
|
||||||
|
to_string(error)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Formats unknown error types with truncation for very long messages
|
||||||
|
defp format_unknown_error(other) do
|
||||||
|
error_str = inspect(other, limit: :infinity, pretty: true)
|
||||||
|
|
||||||
|
if String.length(error_str) > @max_error_message_length do
|
||||||
|
String.slice(error_str, 0, @max_error_message_length - 3) <> "..."
|
||||||
|
else
|
||||||
|
error_str
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info({:process_chunk, idx}, socket) do
|
def handle_info({:process_chunk, idx}, socket) do
|
||||||
case socket.assigns do
|
case socket.assigns do
|
||||||
|
|
@ -266,33 +337,32 @@ defmodule MvWeb.ImportLive do
|
||||||
actor: actor
|
actor: actor
|
||||||
]
|
]
|
||||||
|
|
||||||
_ =
|
if Config.sql_sandbox?() do
|
||||||
if Config.sql_sandbox?() do
|
run_chunk_with_locale(
|
||||||
run_chunk_with_locale(
|
locale,
|
||||||
locale,
|
chunk,
|
||||||
chunk,
|
import_state.column_map,
|
||||||
import_state.column_map,
|
import_state.custom_field_map,
|
||||||
import_state.custom_field_map,
|
opts,
|
||||||
opts,
|
live_view_pid,
|
||||||
live_view_pid,
|
idx
|
||||||
idx
|
)
|
||||||
)
|
else
|
||||||
else
|
Task.Supervisor.start_child(
|
||||||
Task.Supervisor.start_child(
|
Mv.TaskSupervisor,
|
||||||
Mv.TaskSupervisor,
|
fn ->
|
||||||
fn ->
|
run_chunk_with_locale(
|
||||||
run_chunk_with_locale(
|
locale,
|
||||||
locale,
|
chunk,
|
||||||
chunk,
|
import_state.column_map,
|
||||||
import_state.column_map,
|
import_state.custom_field_map,
|
||||||
import_state.custom_field_map,
|
opts,
|
||||||
opts,
|
live_view_pid,
|
||||||
live_view_pid,
|
idx
|
||||||
idx
|
)
|
||||||
)
|
end
|
||||||
end
|
)
|
||||||
)
|
end
|
||||||
end
|
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
@ -308,7 +378,7 @@ defmodule MvWeb.ImportLive do
|
||||||
live_view_pid,
|
live_view_pid,
|
||||||
idx
|
idx
|
||||||
) do
|
) do
|
||||||
_ = Gettext.put_locale(MvWeb.Gettext, locale)
|
Gettext.put_locale(MvWeb.Gettext, locale)
|
||||||
ImportRunner.process_chunk(chunk, column_map, custom_field_map, opts, live_view_pid, idx)
|
ImportRunner.process_chunk(chunk, column_map, custom_field_map, opts, live_view_pid, idx)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -287,6 +287,8 @@ defmodule MvWeb.JoinLive do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp member_field_input_type(_), do: "text"
|
||||||
|
|
||||||
defp member_field_atom(field_id) when is_binary(field_id) do
|
defp member_field_atom(field_id) when is_binary(field_id) do
|
||||||
Mv.Constants.member_fields()
|
Mv.Constants.member_fields()
|
||||||
|> Enum.find(&(Atom.to_string(&1) == field_id))
|
|> Enum.find(&(Atom.to_string(&1) == field_id))
|
||||||
|
|
|
||||||
|
|
@ -36,8 +36,6 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
alias Mv.MembershipFees
|
alias Mv.MembershipFees
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
alias MvWeb.Helpers.DateFormatter
|
alias MvWeb.Helpers.DateFormatter
|
||||||
alias MvWeb.MemberLive.Index.CustomFieldValueLookup
|
|
||||||
alias MvWeb.MemberLive.Index.DateFilter
|
|
||||||
alias MvWeb.MemberLive.Index.FieldSelection
|
alias MvWeb.MemberLive.Index.FieldSelection
|
||||||
alias MvWeb.MemberLive.Index.FieldVisibility
|
alias MvWeb.MemberLive.Index.FieldVisibility
|
||||||
alias MvWeb.MemberLive.Index.FilterParams
|
alias MvWeb.MemberLive.Index.FilterParams
|
||||||
|
|
@ -89,13 +87,6 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|> Enum.filter(&(&1.value_type == :boolean))
|
|> Enum.filter(&(&1.value_type == :boolean))
|
||||||
|> Enum.sort_by(& &1.name, :asc)
|
|> Enum.sort_by(& &1.name, :asc)
|
||||||
|
|
||||||
# Date-typed custom fields surface in the new "Custom date fields" filter
|
|
||||||
# section and are needed by DateFilter.from_params/2 to validate UUIDs.
|
|
||||||
date_custom_fields =
|
|
||||||
all_custom_fields
|
|
||||||
|> Enum.filter(&(&1.value_type == :date))
|
|
||||||
|> Enum.sort_by(& &1.name, :asc)
|
|
||||||
|
|
||||||
# Load groups for filter dropdown (sorted by name)
|
# Load groups for filter dropdown (sorted by name)
|
||||||
groups =
|
groups =
|
||||||
Mv.Membership.Group
|
Mv.Membership.Group
|
||||||
|
|
@ -152,8 +143,6 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|> assign(:custom_fields_visible, custom_fields_visible)
|
|> assign(:custom_fields_visible, custom_fields_visible)
|
||||||
|> assign(:all_custom_fields, all_custom_fields)
|
|> assign(:all_custom_fields, all_custom_fields)
|
||||||
|> assign(:boolean_custom_fields, boolean_custom_fields)
|
|> assign(:boolean_custom_fields, boolean_custom_fields)
|
||||||
|> assign(:date_custom_fields, date_custom_fields)
|
|
||||||
|> assign(:date_filters, DateFilter.default())
|
|
||||||
|> assign(:all_available_fields, all_available_fields)
|
|> assign(:all_available_fields, all_available_fields)
|
||||||
|> assign(:user_field_selection, initial_selection)
|
|> assign(:user_field_selection, initial_selection)
|
||||||
|> assign(:fields_in_url?, false)
|
|> assign(:fields_in_url?, false)
|
||||||
|
|
@ -459,25 +448,6 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
{:noreply, push_patch(socket, to: new_path, replace: true)}
|
{:noreply, push_patch(socket, to: new_path, replace: true)}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_info({:date_filters_changed, new_date_filters}, socket) do
|
|
||||||
socket =
|
|
||||||
socket
|
|
||||||
|> assign(:date_filters, new_date_filters)
|
|
||||||
|> load_members()
|
|
||||||
|> update_selection_assigns()
|
|
||||||
|
|
||||||
query_params =
|
|
||||||
build_query_params(opts_for_query_params(socket, %{date_filters: new_date_filters}))
|
|
||||||
|> maybe_add_field_selection(
|
|
||||||
socket.assigns[:user_field_selection],
|
|
||||||
socket.assigns[:fields_in_url?] || false
|
|
||||||
)
|
|
||||||
|
|
||||||
new_path = ~p"/members?#{query_params}"
|
|
||||||
{:noreply, push_patch(socket, to: new_path, replace: true)}
|
|
||||||
end
|
|
||||||
|
|
||||||
# Backward compatibility: tuple form delegates to map form
|
# Backward compatibility: tuple form delegates to map form
|
||||||
def handle_info({:reset_all_filters, cycle_status_filter, boolean_filters}, socket) do
|
def handle_info({:reset_all_filters, cycle_status_filter, boolean_filters}, socket) do
|
||||||
handle_info(
|
handle_info(
|
||||||
|
|
@ -532,7 +502,6 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|> assign(:group_filters, Map.get(opts, :group_filters, %{}))
|
|> assign(:group_filters, Map.get(opts, :group_filters, %{}))
|
||||||
|> assign(:fee_type_filters, Map.get(opts, :fee_type_filters, %{}))
|
|> assign(:fee_type_filters, Map.get(opts, :fee_type_filters, %{}))
|
||||||
|> assign(:boolean_custom_field_filters, Map.get(opts, :boolean_filters, %{}))
|
|> assign(:boolean_custom_field_filters, Map.get(opts, :boolean_filters, %{}))
|
||||||
|> assign(:date_filters, Map.get(opts, :date_filters, DateFilter.default()))
|
|
||||||
|> load_members()
|
|> load_members()
|
||||||
|> update_selection_assigns()
|
|> update_selection_assigns()
|
||||||
|
|
||||||
|
|
@ -663,7 +632,6 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|> maybe_update_group_filters(params)
|
|> maybe_update_group_filters(params)
|
||||||
|> maybe_update_fee_type_filters(params)
|
|> maybe_update_fee_type_filters(params)
|
||||||
|> maybe_update_boolean_filters(params)
|
|> maybe_update_boolean_filters(params)
|
||||||
|> maybe_update_date_filters(params)
|
|
||||||
|> maybe_update_show_current_cycle(params)
|
|> maybe_update_show_current_cycle(params)
|
||||||
|> assign(:fields_in_url?, fields_in_url?)
|
|> assign(:fields_in_url?, fields_in_url?)
|
||||||
|> assign(:query, params["query"])
|
|> assign(:query, params["query"])
|
||||||
|
|
@ -715,8 +683,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
socket.assigns.show_current_cycle,
|
socket.assigns.show_current_cycle,
|
||||||
socket.assigns.boolean_custom_field_filters,
|
socket.assigns.boolean_custom_field_filters,
|
||||||
socket.assigns.user_field_selection,
|
socket.assigns.user_field_selection,
|
||||||
socket.assigns[:visible_custom_field_ids] || [],
|
socket.assigns[:visible_custom_field_ids] || []
|
||||||
socket.assigns[:date_filters]
|
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -816,12 +783,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
base_params = add_group_filters(base_params, opts.group_filters || %{})
|
base_params = add_group_filters(base_params, opts.group_filters || %{})
|
||||||
base_params = add_fee_type_filters(base_params, opts.fee_type_filters || %{})
|
base_params = add_fee_type_filters(base_params, opts.fee_type_filters || %{})
|
||||||
base_params = add_show_current_cycle(base_params, opts.show_current_cycle)
|
base_params = add_show_current_cycle(base_params, opts.show_current_cycle)
|
||||||
base_params = add_boolean_filters(base_params, opts.boolean_filters || %{})
|
add_boolean_filters(base_params, opts.boolean_filters || %{})
|
||||||
add_date_filters(base_params, opts.date_filters)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp add_date_filters(params, date_filters) do
|
|
||||||
Map.merge(params, DateFilter.to_params(date_filters))
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp opts_for_query_params(socket, overrides \\ %{}) do
|
defp opts_for_query_params(socket, overrides \\ %{}) do
|
||||||
|
|
@ -833,8 +795,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
group_filters: socket.assigns[:group_filters] || %{},
|
group_filters: socket.assigns[:group_filters] || %{},
|
||||||
show_current_cycle: socket.assigns.show_current_cycle,
|
show_current_cycle: socket.assigns.show_current_cycle,
|
||||||
boolean_filters: socket.assigns.boolean_custom_field_filters || %{},
|
boolean_filters: socket.assigns.boolean_custom_field_filters || %{},
|
||||||
fee_type_filters: socket.assigns[:fee_type_filters] || %{},
|
fee_type_filters: socket.assigns[:fee_type_filters] || %{}
|
||||||
date_filters: socket.assigns.date_filters
|
|
||||||
}
|
}
|
||||||
|> Map.merge(overrides)
|
|> Map.merge(overrides)
|
||||||
end
|
end
|
||||||
|
|
@ -980,7 +941,26 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|> Ash.Query.new()
|
|> Ash.Query.new()
|
||||||
|> Ash.Query.select(@overview_fields)
|
|> Ash.Query.select(@overview_fields)
|
||||||
|
|
||||||
query = load_custom_field_values(query, compute_ids_to_load(socket))
|
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
|
||||||
|
|
||||||
|
boolean_custom_fields_map =
|
||||||
|
socket.assigns.boolean_custom_fields
|
||||||
|
|> Map.new(fn cf -> {to_string(cf.id), cf} end)
|
||||||
|
|
||||||
|
active_boolean_filter_ids =
|
||||||
|
socket.assigns.boolean_custom_field_filters
|
||||||
|
|> Map.keys()
|
||||||
|
|> Enum.filter(fn id_str ->
|
||||||
|
String.length(id_str) <= @max_uuid_length &&
|
||||||
|
match?({:ok, _}, Ecto.UUID.cast(id_str)) &&
|
||||||
|
Map.has_key?(boolean_custom_fields_map, id_str)
|
||||||
|
end)
|
||||||
|
|
||||||
|
ids_to_load =
|
||||||
|
(visible_custom_field_ids ++ active_boolean_filter_ids)
|
||||||
|
|> Enum.uniq()
|
||||||
|
|
||||||
|
query = load_custom_field_values(query, ids_to_load)
|
||||||
|
|
||||||
query = MembershipFeeStatus.load_cycles_for_members(query, socket.assigns.show_current_cycle)
|
query = MembershipFeeStatus.load_cycles_for_members(query, socket.assigns.show_current_cycle)
|
||||||
|
|
||||||
|
|
@ -1004,13 +984,6 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
query =
|
query =
|
||||||
apply_fee_type_filters(query, socket.assigns[:fee_type_filters], socket.assigns[:fee_types])
|
apply_fee_type_filters(query, socket.assigns[:fee_type_filters], socket.assigns[:fee_types])
|
||||||
|
|
||||||
# Built-in date filters (join_date, exit_date) are pushed to the DB so
|
|
||||||
# excluded rows never reach the BEAM. The active_only default is part of
|
|
||||||
# this — fresh load returns only members without an exit_date or with an
|
|
||||||
# exit_date strictly in the future.
|
|
||||||
query =
|
|
||||||
DateFilter.apply_ash_filter(query, socket.assigns.date_filters)
|
|
||||||
|
|
||||||
# Use ALL custom fields for sorting (not just show_in_overview subset)
|
# Use ALL custom fields for sorting (not just show_in_overview subset)
|
||||||
custom_fields_for_sort = socket.assigns.all_custom_fields
|
custom_fields_for_sort = socket.assigns.all_custom_fields
|
||||||
|
|
||||||
|
|
@ -1030,7 +1003,21 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
# Custom field values are already filtered at the database level in load_custom_field_values/2
|
# Custom field values are already filtered at the database level in load_custom_field_values/2
|
||||||
# No need for in-memory filtering anymore
|
# No need for in-memory filtering anymore
|
||||||
|
|
||||||
members = apply_in_memory_filters(members, socket)
|
# Apply cycle status filter if set
|
||||||
|
members =
|
||||||
|
apply_cycle_status_filter(
|
||||||
|
members,
|
||||||
|
socket.assigns.cycle_status_filter,
|
||||||
|
socket.assigns.show_current_cycle
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply boolean custom field filters if set
|
||||||
|
members =
|
||||||
|
apply_boolean_custom_field_filters(
|
||||||
|
members,
|
||||||
|
socket.assigns.boolean_custom_field_filters,
|
||||||
|
socket.assigns.all_custom_fields
|
||||||
|
)
|
||||||
|
|
||||||
# Sort in memory if needed (custom fields, groups, group_count; computed fields are blocked)
|
# Sort in memory if needed (custom fields, groups, group_count; computed fields are blocked)
|
||||||
# Note: :groups is in computed_member_fields() but can be sorted in-memory, so we only block :membership_fee_status
|
# Note: :groups is in computed_member_fields() but can be sorted in-memory, so we only block :membership_fee_status
|
||||||
|
|
@ -1050,55 +1037,6 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
assign(socket, :members, members)
|
assign(socket, :members, members)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Collects every custom field UUID whose values must be loaded for a given
|
|
||||||
# render — visible columns plus any active boolean or date filter. Kept as a
|
|
||||||
# standalone helper so load_members/1 stays under the credo complexity bar.
|
|
||||||
defp compute_ids_to_load(socket) do
|
|
||||||
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
|
|
||||||
|
|
||||||
boolean_custom_fields_map =
|
|
||||||
socket.assigns.boolean_custom_fields
|
|
||||||
|> Map.new(fn cf -> {to_string(cf.id), cf} end)
|
|
||||||
|
|
||||||
active_boolean_filter_ids =
|
|
||||||
socket.assigns.boolean_custom_field_filters
|
|
||||||
|> Map.keys()
|
|
||||||
|> Enum.filter(fn id_str ->
|
|
||||||
String.length(id_str) <= @max_uuid_length &&
|
|
||||||
match?({:ok, _}, Ecto.UUID.cast(id_str)) &&
|
|
||||||
Map.has_key?(boolean_custom_fields_map, id_str)
|
|
||||||
end)
|
|
||||||
|
|
||||||
date_custom_fields = socket.assigns[:date_custom_fields] || []
|
|
||||||
|
|
||||||
active_date_filter_ids =
|
|
||||||
DateFilter.active_custom_field_ids(
|
|
||||||
socket.assigns.date_filters,
|
|
||||||
date_custom_fields
|
|
||||||
)
|
|
||||||
|
|
||||||
(visible_custom_field_ids ++ active_boolean_filter_ids ++ active_date_filter_ids)
|
|
||||||
|> Enum.uniq()
|
|
||||||
end
|
|
||||||
|
|
||||||
# Post-DB filtering: cycle status, boolean custom fields, and custom date
|
|
||||||
# fields. Date custom fields are last so they see the already-narrowed list.
|
|
||||||
defp apply_in_memory_filters(members, socket) do
|
|
||||||
members
|
|
||||||
|> apply_cycle_status_filter(
|
|
||||||
socket.assigns.cycle_status_filter,
|
|
||||||
socket.assigns.show_current_cycle
|
|
||||||
)
|
|
||||||
|> apply_boolean_custom_field_filters(
|
|
||||||
socket.assigns.boolean_custom_field_filters,
|
|
||||||
socket.assigns.all_custom_fields
|
|
||||||
)
|
|
||||||
|> DateFilter.apply_in_memory(
|
|
||||||
socket.assigns.date_filters,
|
|
||||||
socket.assigns[:date_custom_fields] || []
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp load_custom_field_values(query, []), do: query
|
defp load_custom_field_values(query, []), do: query
|
||||||
|
|
||||||
defp load_custom_field_values(query, custom_field_ids) do
|
defp load_custom_field_values(query, custom_field_ids) do
|
||||||
|
|
@ -1218,6 +1156,8 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp apply_one_fee_type_filter(query, _, _), do: query
|
||||||
|
|
||||||
defp apply_cycle_status_filter(members, nil, _show_current), do: members
|
defp apply_cycle_status_filter(members, nil, _show_current), do: members
|
||||||
|
|
||||||
defp apply_cycle_status_filter(members, status, show_current)
|
defp apply_cycle_status_filter(members, status, show_current)
|
||||||
|
|
@ -1295,6 +1235,8 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp valid_sort_field?(_), do: false
|
||||||
|
|
||||||
defp valid_sort_field_db_or_custom?(field) when is_atom(field) do
|
defp valid_sort_field_db_or_custom?(field) when is_atom(field) do
|
||||||
non_sortable_fields = [:notes]
|
non_sortable_fields = [:notes]
|
||||||
valid_fields = Mv.Constants.member_fields() -- non_sortable_fields
|
valid_fields = Mv.Constants.member_fields() -- non_sortable_fields
|
||||||
|
|
@ -1554,6 +1496,8 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
assign(socket, :group_filters, Map.take(filters, valid_group_ids))
|
assign(socket, :group_filters, Map.take(filters, valid_group_ids))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp maybe_update_group_filters(socket, _), do: socket
|
||||||
|
|
||||||
defp maybe_update_fee_type_filters(socket, params) when is_map(params) do
|
defp maybe_update_fee_type_filters(socket, params) when is_map(params) do
|
||||||
prefix = @fee_type_filter_prefix
|
prefix = @fee_type_filter_prefix
|
||||||
prefix_len = String.length(prefix)
|
prefix_len = String.length(prefix)
|
||||||
|
|
@ -1580,6 +1524,8 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
assign(socket, :fee_type_filters, Map.take(filters, valid_fee_type_ids))
|
assign(socket, :fee_type_filters, Map.take(filters, valid_fee_type_ids))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp maybe_update_fee_type_filters(socket, _), do: socket
|
||||||
|
|
||||||
defp add_fee_type_filter_entry(acc, key, value_str, prefix_len) do
|
defp add_fee_type_filter_entry(acc, key, value_str, prefix_len) do
|
||||||
key_str = to_string(key)
|
key_str = to_string(key)
|
||||||
raw_id = String.slice(key_str, prefix_len, String.length(key_str) - prefix_len)
|
raw_id = String.slice(key_str, prefix_len, String.length(key_str) - prefix_len)
|
||||||
|
|
@ -1703,20 +1649,24 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|
|
||||||
defp maybe_update_show_current_cycle(socket, _params), do: socket
|
defp maybe_update_show_current_cycle(socket, _params), do: socket
|
||||||
|
|
||||||
# URL params are the source of truth for filter state on every navigation.
|
|
||||||
# When no date filter params are present, this falls through to the
|
|
||||||
# active_only default — exactly the spec behavior for fresh load (§1.1).
|
|
||||||
defp maybe_update_date_filters(socket, params) when is_map(params) do
|
|
||||||
date_custom_fields = socket.assigns[:date_custom_fields] || []
|
|
||||||
assign(socket, :date_filters, DateFilter.from_params(params, date_custom_fields))
|
|
||||||
end
|
|
||||||
|
|
||||||
# -------------------------------------------------------------
|
# -------------------------------------------------------------
|
||||||
# Custom Field Value Helpers
|
# Custom Field Value Helpers
|
||||||
# -------------------------------------------------------------
|
# -------------------------------------------------------------
|
||||||
|
|
||||||
def get_custom_field_value(member, custom_field) do
|
def get_custom_field_value(member, custom_field) do
|
||||||
CustomFieldValueLookup.find_by_field(member, custom_field)
|
case member.custom_field_values do
|
||||||
|
nil ->
|
||||||
|
nil
|
||||||
|
|
||||||
|
values when is_list(values) ->
|
||||||
|
Enum.find(values, fn cfv ->
|
||||||
|
cfv.custom_field_id == custom_field.id or
|
||||||
|
(match?(%{custom_field: %{id: _}}, cfv) && cfv.custom_field.id == custom_field.id)
|
||||||
|
end)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
nil
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def get_boolean_custom_field_value(member, custom_field) do
|
def get_boolean_custom_field_value(member, custom_field) do
|
||||||
|
|
@ -1775,12 +1725,29 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp matches_filter?(member, custom_field_id_str, filter_value) do
|
defp matches_filter?(member, custom_field_id_str, filter_value) do
|
||||||
case CustomFieldValueLookup.find_by_id(member, custom_field_id_str) do
|
case find_custom_field_value_by_id(member, custom_field_id_str) do
|
||||||
nil -> false
|
nil -> false
|
||||||
cfv -> extract_boolean_value(cfv.value) == filter_value
|
cfv -> extract_boolean_value(cfv.value) == filter_value
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp find_custom_field_value_by_id(member, custom_field_id_str) do
|
||||||
|
case member.custom_field_values do
|
||||||
|
nil ->
|
||||||
|
nil
|
||||||
|
|
||||||
|
values when is_list(values) ->
|
||||||
|
Enum.find(values, fn cfv ->
|
||||||
|
to_string(cfv.custom_field_id) == custom_field_id_str or
|
||||||
|
(match?(%{custom_field: %{id: _}}, cfv) &&
|
||||||
|
to_string(cfv.custom_field.id) == custom_field_id_str)
|
||||||
|
end)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def format_selected_member_emails(members, selected_members) do
|
def format_selected_member_emails(members, selected_members) do
|
||||||
members
|
members
|
||||||
|> Enum.filter(fn member ->
|
|> Enum.filter(fn member ->
|
||||||
|
|
|
||||||
|
|
@ -54,8 +54,6 @@
|
||||||
fee_type_filters={@fee_type_filters}
|
fee_type_filters={@fee_type_filters}
|
||||||
boolean_custom_fields={@boolean_custom_fields}
|
boolean_custom_fields={@boolean_custom_fields}
|
||||||
boolean_filters={@boolean_custom_field_filters}
|
boolean_filters={@boolean_custom_field_filters}
|
||||||
date_custom_fields={@date_custom_fields}
|
|
||||||
date_filters={@date_filters}
|
|
||||||
member_count={length(@members)}
|
member_count={length(@members)}
|
||||||
/>
|
/>
|
||||||
<.tooltip
|
<.tooltip
|
||||||
|
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
defmodule MvWeb.MemberLive.Index.CustomFieldValueLookup do
|
|
||||||
@moduledoc """
|
|
||||||
Centralized lookup for a member's `custom_field_values` entry that matches
|
|
||||||
a given custom field.
|
|
||||||
|
|
||||||
Two callable shapes:
|
|
||||||
|
|
||||||
* `find_by_id/2` — match against a stringified UUID (used by the URL-param
|
|
||||||
driven date and boolean filter pipelines).
|
|
||||||
* `find_by_field/2` — match against a loaded `%CustomField{}` struct
|
|
||||||
(used by the table rendering / display path that already has the
|
|
||||||
field record at hand).
|
|
||||||
|
|
||||||
Both forms handle the two CFV layouts that appear on a loaded member:
|
|
||||||
|
|
||||||
* the direct foreign key — `%{custom_field_id: id, value: ...}`
|
|
||||||
* the nested loaded relation — `%{custom_field: %{id: id, ...}, value: ...}`
|
|
||||||
|
|
||||||
All non-loaded or empty containers (`nil`, `%Ash.NotLoaded{}`, empty list)
|
|
||||||
return `nil`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Returns the CFV entry whose custom field id, compared as a string, equals
|
|
||||||
`custom_field_id_str`. Returns `nil` when no entry matches or the
|
|
||||||
`custom_field_values` association is not a list.
|
|
||||||
"""
|
|
||||||
@spec find_by_id(map(), String.t()) :: map() | nil
|
|
||||||
def find_by_id(member, custom_field_id_str) when is_binary(custom_field_id_str) do
|
|
||||||
member
|
|
||||||
|> Map.get(:custom_field_values)
|
|
||||||
|> find_in(fn cfv -> cfv_id_string(cfv) == custom_field_id_str end)
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Returns the CFV entry whose custom field id matches the given
|
|
||||||
`custom_field` struct's `:id`. The comparison is identity-based (not
|
|
||||||
stringified) because both sides are typically `Ash.UUID` binaries; falls
|
|
||||||
back to string comparison so atom-id callers still work.
|
|
||||||
"""
|
|
||||||
@spec find_by_field(map(), map()) :: map() | nil
|
|
||||||
def find_by_field(member, %{id: field_id}) do
|
|
||||||
member
|
|
||||||
|> Map.get(:custom_field_values)
|
|
||||||
|> find_in(fn cfv -> cfv_id(cfv) == field_id end)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp find_in(values, predicate) when is_list(values), do: Enum.find(values, predicate)
|
|
||||||
defp find_in(_other, _predicate), do: nil
|
|
||||||
|
|
||||||
defp cfv_id(%{custom_field_id: id}) when not is_nil(id), do: id
|
|
||||||
defp cfv_id(%{custom_field: %{id: id}}) when not is_nil(id), do: id
|
|
||||||
defp cfv_id(_), do: nil
|
|
||||||
|
|
||||||
defp cfv_id_string(cfv) do
|
|
||||||
case cfv_id(cfv) do
|
|
||||||
nil -> nil
|
|
||||||
id -> to_string(id)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,454 +0,0 @@
|
||||||
defmodule MvWeb.MemberLive.Index.DateFilter do
|
|
||||||
@moduledoc """
|
|
||||||
Encapsulates the complete lifecycle of date-range filters used on the
|
|
||||||
member overview page.
|
|
||||||
|
|
||||||
Owns:
|
|
||||||
|
|
||||||
- the default filter state (active members only)
|
|
||||||
- URL encoding / decoding of filter state
|
|
||||||
- DB-level Ash expression construction for built-in date fields
|
|
||||||
(`join_date`, `exit_date`)
|
|
||||||
- in-memory predicates for custom date-typed custom fields
|
|
||||||
|
|
||||||
## Filter state shape
|
|
||||||
|
|
||||||
%{
|
|
||||||
join_date: %{from: nil | %Date{}, to: nil | %Date{}},
|
|
||||||
exit_date: %{
|
|
||||||
mode: :active_only | :inactive_only | :all | :custom,
|
|
||||||
from: nil | %Date{},
|
|
||||||
to: nil | %Date{}
|
|
||||||
},
|
|
||||||
# optional custom date field entries (UUID string keys):
|
|
||||||
"<uuid>" => %{from: nil | %Date{}, to: nil | %Date{}}
|
|
||||||
}
|
|
||||||
|
|
||||||
The default mode for `exit_date` is `:active_only`, which means
|
|
||||||
`exit_date IS NULL OR exit_date > today` — a member who left today is hidden.
|
|
||||||
"""
|
|
||||||
|
|
||||||
require Ash.Query
|
|
||||||
import Ash.Expr
|
|
||||||
|
|
||||||
alias MvWeb.MemberLive.Index.CustomFieldValueLookup
|
|
||||||
alias MvWeb.MemberLive.Index.FilterParams
|
|
||||||
|
|
||||||
@join_date_from_param Mv.Constants.join_date_from_param()
|
|
||||||
@join_date_to_param Mv.Constants.join_date_to_param()
|
|
||||||
@exit_date_mode_param Mv.Constants.exit_date_mode_param()
|
|
||||||
@exit_date_from_param Mv.Constants.exit_date_from_param()
|
|
||||||
@exit_date_to_param Mv.Constants.exit_date_to_param()
|
|
||||||
@custom_date_filter_prefix Mv.Constants.custom_date_filter_prefix()
|
|
||||||
@max_uuid_length Mv.Constants.max_uuid_length()
|
|
||||||
|
|
||||||
# An id stripped from a cdf_-prefixed param still has its `_from` / `_to`
|
|
||||||
# bound suffix attached when we first see it. The longest legal suffix is
|
|
||||||
# `_from` (5 chars), so the upper bound on a valid suffixed_id is
|
|
||||||
# @max_uuid_length + 5. Anything longer cannot map to a known custom date
|
|
||||||
# field and is rejected before further string work — matching the same
|
|
||||||
# DoS-protection contract enforced by the boolean / group / fee_type
|
|
||||||
# filter parsers in `MvWeb.MemberLive.Index`.
|
|
||||||
@max_suffixed_id_length @max_uuid_length + 5
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Returns the default date filter state used on fresh page load and after
|
|
||||||
"Clear filters". `exit_date` is set to `:active_only`; all other bounds are nil.
|
|
||||||
"""
|
|
||||||
@spec default() :: %{
|
|
||||||
join_date: %{from: nil, to: nil},
|
|
||||||
exit_date: %{mode: :active_only, from: nil, to: nil}
|
|
||||||
}
|
|
||||||
def default do
|
|
||||||
%{
|
|
||||||
join_date: %{from: nil, to: nil},
|
|
||||||
exit_date: %{mode: :active_only, from: nil, to: nil}
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Decodes URL params into a date filter state map.
|
|
||||||
|
|
||||||
Recognized keys:
|
|
||||||
|
|
||||||
* `"jd_from"` / `"jd_to"` — join_date bounds (ISO-8601 dates)
|
|
||||||
* `"ed_mode"` — exit_date mode (`"active_only"` | `"inactive_only"` |
|
|
||||||
`"all"` | `"custom"`); absent or unknown values fall back to
|
|
||||||
`:active_only`
|
|
||||||
* `"ed_from"` / `"ed_to"` — exit_date bounds (ISO-8601 dates, used when
|
|
||||||
`ed_mode=custom`)
|
|
||||||
* `"cdf_<uuid>_from"` / `"cdf_<uuid>_to"` — custom date field bounds;
|
|
||||||
the UUID must appear (by `to_string/1` on its `:id`) in
|
|
||||||
`date_custom_fields`, otherwise the entry is dropped
|
|
||||||
|
|
||||||
Malformed ISO-8601 strings are silently discarded; the corresponding bound
|
|
||||||
stays `nil`. No exception is raised for any malformed input.
|
|
||||||
"""
|
|
||||||
@spec from_params(map(), list()) :: map()
|
|
||||||
def from_params(params, date_custom_fields)
|
|
||||||
when is_map(params) and is_list(date_custom_fields) do
|
|
||||||
base = %{
|
|
||||||
join_date: %{
|
|
||||||
from: parse_date(Map.get(params, @join_date_from_param)),
|
|
||||||
to: parse_date(Map.get(params, @join_date_to_param))
|
|
||||||
},
|
|
||||||
exit_date: %{
|
|
||||||
mode: parse_exit_date_mode(Map.get(params, @exit_date_mode_param)),
|
|
||||||
from: parse_date(Map.get(params, @exit_date_from_param)),
|
|
||||||
to: parse_date(Map.get(params, @exit_date_to_param))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
parse_custom_date_filters(params, date_custom_fields, base)
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Encodes a date filter state map into a URL params map (string keys, string
|
|
||||||
values).
|
|
||||||
|
|
||||||
Encoding rules:
|
|
||||||
|
|
||||||
* `join_date` from/to → `"jd_from"` / `"jd_to"` (omitted when nil)
|
|
||||||
* `exit_date` mode →
|
|
||||||
- `:active_only` is the default and is omitted entirely (no `ed_mode`,
|
|
||||||
no bounds — a fresh URL is the canonical representation of the default
|
|
||||||
state)
|
|
||||||
- `:all` / `:inactive_only` → `"ed_mode"` only; bounds are omitted
|
|
||||||
- `:custom` → `"ed_mode" => "custom"` plus `"ed_from"` / `"ed_to"`
|
|
||||||
when those bounds are set
|
|
||||||
* custom date field entries (UUID string keys) → `"cdf_<uuid>_from"` /
|
|
||||||
`"cdf_<uuid>_to"`; each bound is included only when non-nil; an entry
|
|
||||||
with both bounds nil produces no params
|
|
||||||
|
|
||||||
All dates are serialized via `Date.to_iso8601/1`.
|
|
||||||
"""
|
|
||||||
@spec to_params(map()) :: %{optional(String.t()) => String.t()}
|
|
||||||
def to_params(filters) when is_map(filters) do
|
|
||||||
%{}
|
|
||||||
|> put_join_date_params(Map.get(filters, :join_date, %{}))
|
|
||||||
|> put_exit_date_params(Map.get(filters, :exit_date, %{}))
|
|
||||||
|> put_custom_date_params(filters)
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Applies the DB-level portion of the date filter — `join_date` and
|
|
||||||
`exit_date` constraints — to the given Ash query.
|
|
||||||
|
|
||||||
Exit_date semantics by mode:
|
|
||||||
|
|
||||||
* `:active_only` → `is_nil(exit_date) or exit_date > today`
|
|
||||||
* `:inactive_only` → `not is_nil(exit_date) and exit_date <= today`
|
|
||||||
* `:all` → no filter added for exit_date
|
|
||||||
* `:custom` → `not is_nil(exit_date)` plus the active bounds; if both
|
|
||||||
bounds are nil, no filter is added (the user picked "custom" but
|
|
||||||
entered nothing)
|
|
||||||
|
|
||||||
Join_date is purely a range filter — nil join_date is always excluded when
|
|
||||||
any bound is set:
|
|
||||||
|
|
||||||
* `from` set → `not is_nil(join_date) and join_date >= from`
|
|
||||||
* `to` set → `not is_nil(join_date) and join_date <= to`
|
|
||||||
* neither set → no filter
|
|
||||||
|
|
||||||
Today's date is captured via `Date.utc_today/0`; callers needing a frozen
|
|
||||||
clock should wrap the call site, not this function.
|
|
||||||
|
|
||||||
The caller is expected to pass an `%Ash.Query{}` (typically built with
|
|
||||||
`Ash.Query.new/1` or via earlier filter chaining), matching the convention
|
|
||||||
used by the sibling `apply_search_filter/2`, `apply_group_filters/3`, and
|
|
||||||
`apply_fee_type_filters/3` helpers in `MvWeb.MemberLive.Index`.
|
|
||||||
"""
|
|
||||||
@spec apply_ash_filter(Ash.Query.t(), map()) :: Ash.Query.t()
|
|
||||||
def apply_ash_filter(%Ash.Query{} = query, filters) when is_map(filters) do
|
|
||||||
exit_bounds = normalize_exit_bounds(Map.get(filters, :exit_date, %{}))
|
|
||||||
join_bounds = normalize_join_bounds(Map.get(filters, :join_date, %{}))
|
|
||||||
|
|
||||||
query
|
|
||||||
|> apply_exit_date_filter(exit_bounds)
|
|
||||||
|> apply_join_date_filter(join_bounds)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Defensive shape normalization: callers may supply maps where one bound key
|
|
||||||
# is absent entirely (not just nil). Pattern-match heads require both keys
|
|
||||||
# present, so we backfill nil here.
|
|
||||||
defp normalize_exit_bounds(bounds) when is_map(bounds) do
|
|
||||||
%{
|
|
||||||
mode: Map.get(bounds, :mode, :active_only),
|
|
||||||
from: Map.get(bounds, :from),
|
|
||||||
to: Map.get(bounds, :to)
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp normalize_exit_bounds(_), do: %{mode: :active_only, from: nil, to: nil}
|
|
||||||
|
|
||||||
defp normalize_join_bounds(bounds) when is_map(bounds) do
|
|
||||||
%{from: Map.get(bounds, :from), to: Map.get(bounds, :to)}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp normalize_join_bounds(_), do: %{from: nil, to: nil}
|
|
||||||
|
|
||||||
defp apply_exit_date_filter(query, %{mode: :all}), do: query
|
|
||||||
|
|
||||||
defp apply_exit_date_filter(query, %{mode: :active_only}) do
|
|
||||||
today = Date.utc_today()
|
|
||||||
Ash.Query.filter(query, expr(is_nil(exit_date) or exit_date > ^today))
|
|
||||||
end
|
|
||||||
|
|
||||||
defp apply_exit_date_filter(query, %{mode: :inactive_only}) do
|
|
||||||
today = Date.utc_today()
|
|
||||||
Ash.Query.filter(query, expr(not is_nil(exit_date) and exit_date <= ^today))
|
|
||||||
end
|
|
||||||
|
|
||||||
defp apply_exit_date_filter(query, %{mode: :custom, from: nil, to: nil}), do: query
|
|
||||||
|
|
||||||
defp apply_exit_date_filter(query, %{mode: :custom, from: from, to: nil}) do
|
|
||||||
Ash.Query.filter(query, expr(not is_nil(exit_date) and exit_date >= ^from))
|
|
||||||
end
|
|
||||||
|
|
||||||
defp apply_exit_date_filter(query, %{mode: :custom, from: nil, to: to}) do
|
|
||||||
Ash.Query.filter(query, expr(not is_nil(exit_date) and exit_date <= ^to))
|
|
||||||
end
|
|
||||||
|
|
||||||
defp apply_exit_date_filter(query, %{mode: :custom, from: from, to: to}) do
|
|
||||||
Ash.Query.filter(
|
|
||||||
query,
|
|
||||||
expr(not is_nil(exit_date) and exit_date >= ^from and exit_date <= ^to)
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp apply_exit_date_filter(query, _), do: query
|
|
||||||
|
|
||||||
defp apply_join_date_filter(query, %{from: nil, to: nil}), do: query
|
|
||||||
|
|
||||||
defp apply_join_date_filter(query, %{from: from, to: nil}) when not is_nil(from) do
|
|
||||||
Ash.Query.filter(query, expr(not is_nil(join_date) and join_date >= ^from))
|
|
||||||
end
|
|
||||||
|
|
||||||
defp apply_join_date_filter(query, %{from: nil, to: to}) when not is_nil(to) do
|
|
||||||
Ash.Query.filter(query, expr(not is_nil(join_date) and join_date <= ^to))
|
|
||||||
end
|
|
||||||
|
|
||||||
defp apply_join_date_filter(query, %{from: from, to: to})
|
|
||||||
when not is_nil(from) and not is_nil(to) do
|
|
||||||
Ash.Query.filter(
|
|
||||||
query,
|
|
||||||
expr(not is_nil(join_date) and join_date >= ^from and join_date <= ^to)
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp apply_join_date_filter(query, _), do: query
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Applies the in-memory portion of the date filter — custom date fields
|
|
||||||
whose values live in JSONB-backed `custom_field_values`.
|
|
||||||
|
|
||||||
Behavior:
|
|
||||||
|
|
||||||
* Only entries whose UUID key matches a `date_custom_fields` entry
|
|
||||||
(by `to_string(field.id)` and `value_type == :date`) are considered.
|
|
||||||
* Entries with both bounds nil add no constraint.
|
|
||||||
* For an active entry, a member is kept iff its custom field value is
|
|
||||||
present AND the value (unwrapped from `%Ash.Union{type: :date}`)
|
|
||||||
satisfies `value >= from` (when from set) AND `value <= to`
|
|
||||||
(when to set).
|
|
||||||
* Members with `custom_field_values` nil, `%Ash.NotLoaded{}`, an empty
|
|
||||||
list, or no entry for the active field — are excluded.
|
|
||||||
* Non-date `Ash.Union` types are treated as "no value" and exclude the
|
|
||||||
member.
|
|
||||||
|
|
||||||
Returns the filtered list of members (order preserved).
|
|
||||||
"""
|
|
||||||
@spec apply_in_memory([map()], map(), [map()]) :: [map()]
|
|
||||||
def apply_in_memory(members, filters, date_custom_fields)
|
|
||||||
when is_list(members) and is_map(filters) and is_list(date_custom_fields) do
|
|
||||||
active_filters = active_custom_date_filters(filters, date_custom_fields)
|
|
||||||
|
|
||||||
if active_filters == [] do
|
|
||||||
members
|
|
||||||
else
|
|
||||||
Enum.filter(members, &matches_all_custom_dates?(&1, active_filters))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Returns the UUID string keys of `filters` that name an active (at-least-one-
|
|
||||||
bound-set) custom date field. The UUID must appear in `date_custom_fields`
|
|
||||||
(matched by `to_string(field.id)` and `value_type == :date`); other entries
|
|
||||||
are dropped.
|
|
||||||
|
|
||||||
Use this to compute which custom field values must be loaded so the
|
|
||||||
in-memory predicate (`apply_in_memory/3`) has the data it needs.
|
|
||||||
"""
|
|
||||||
@spec active_custom_field_ids(map(), [map()]) :: [String.t()]
|
|
||||||
def active_custom_field_ids(filters, date_custom_fields)
|
|
||||||
when is_map(filters) and is_list(date_custom_fields) do
|
|
||||||
filters
|
|
||||||
|> active_custom_date_filters(date_custom_fields)
|
|
||||||
|> Enum.map(fn {id, _bounds} -> id end)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp matches_all_custom_dates?(member, active_filters) do
|
|
||||||
Enum.all?(active_filters, fn {id, bounds} ->
|
|
||||||
member_matches_custom_date?(member, id, bounds)
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp active_custom_date_filters(filters, date_custom_fields) do
|
|
||||||
valid_ids = valid_custom_date_field_ids(date_custom_fields)
|
|
||||||
|
|
||||||
filters
|
|
||||||
|> Enum.filter(fn
|
|
||||||
{key, %{from: from, to: to}} when is_binary(key) ->
|
|
||||||
MapSet.member?(valid_ids, key) and (not is_nil(from) or not is_nil(to))
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
false
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp member_matches_custom_date?(member, custom_field_id, %{from: from, to: to}) do
|
|
||||||
case extract_member_date(member, custom_field_id) do
|
|
||||||
%Date{} = date -> within_bounds?(date, from, to)
|
|
||||||
_ -> false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp extract_member_date(member, custom_field_id) do
|
|
||||||
member
|
|
||||||
|> CustomFieldValueLookup.find_by_id(custom_field_id)
|
|
||||||
|> extract_date_from_cfv()
|
|
||||||
end
|
|
||||||
|
|
||||||
defp extract_date_from_cfv(nil), do: nil
|
|
||||||
|
|
||||||
defp extract_date_from_cfv(%{value: value}), do: extract_date_value(value)
|
|
||||||
|
|
||||||
defp extract_date_from_cfv(_), do: nil
|
|
||||||
|
|
||||||
defp extract_date_value(%Ash.Union{value: %Date{} = date, type: :date}), do: date
|
|
||||||
defp extract_date_value(_), do: nil
|
|
||||||
|
|
||||||
defp within_bounds?(%Date{} = date, from, to) do
|
|
||||||
from_ok? = is_nil(from) or Date.compare(date, from) != :lt
|
|
||||||
to_ok? = is_nil(to) or Date.compare(date, to) != :gt
|
|
||||||
from_ok? and to_ok?
|
|
||||||
end
|
|
||||||
|
|
||||||
defp put_join_date_params(params, %{from: from, to: to}) do
|
|
||||||
params
|
|
||||||
|> maybe_put_date(@join_date_from_param, from)
|
|
||||||
|> maybe_put_date(@join_date_to_param, to)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp put_join_date_params(params, _), do: params
|
|
||||||
|
|
||||||
defp put_exit_date_params(params, %{mode: :active_only}), do: params
|
|
||||||
|
|
||||||
defp put_exit_date_params(params, %{mode: mode})
|
|
||||||
when mode in [:all, :inactive_only] do
|
|
||||||
Map.put(params, @exit_date_mode_param, Atom.to_string(mode))
|
|
||||||
end
|
|
||||||
|
|
||||||
defp put_exit_date_params(params, %{mode: :custom, from: from, to: to}) do
|
|
||||||
params
|
|
||||||
|> Map.put(@exit_date_mode_param, "custom")
|
|
||||||
|> maybe_put_date(@exit_date_from_param, from)
|
|
||||||
|> maybe_put_date(@exit_date_to_param, to)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp put_exit_date_params(params, _), do: params
|
|
||||||
|
|
||||||
defp put_custom_date_params(params, filters) do
|
|
||||||
prefix = @custom_date_filter_prefix
|
|
||||||
|
|
||||||
filters
|
|
||||||
|> Enum.filter(fn {key, _value} -> is_binary(key) end)
|
|
||||||
|> Enum.reduce(params, fn {id, %{from: from, to: to}}, acc ->
|
|
||||||
acc
|
|
||||||
|> maybe_put_date("#{prefix}#{id}_from", from)
|
|
||||||
|> maybe_put_date("#{prefix}#{id}_to", to)
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp maybe_put_date(params, _key, nil), do: params
|
|
||||||
|
|
||||||
defp maybe_put_date(params, key, %Date{} = date),
|
|
||||||
do: Map.put(params, key, Date.to_iso8601(date))
|
|
||||||
|
|
||||||
defp parse_date(nil), do: nil
|
|
||||||
|
|
||||||
defp parse_date(value) when is_binary(value) do
|
|
||||||
case Date.from_iso8601(String.trim(value)) do
|
|
||||||
{:ok, date} -> date
|
|
||||||
_ -> nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp parse_date(_), do: nil
|
|
||||||
|
|
||||||
defp parse_exit_date_mode("all"), do: :all
|
|
||||||
defp parse_exit_date_mode("inactive_only"), do: :inactive_only
|
|
||||||
defp parse_exit_date_mode("custom"), do: :custom
|
|
||||||
defp parse_exit_date_mode("active_only"), do: :active_only
|
|
||||||
defp parse_exit_date_mode(_), do: :active_only
|
|
||||||
|
|
||||||
defp parse_custom_date_filters(params, date_custom_fields, base) do
|
|
||||||
valid_ids = valid_custom_date_field_ids(date_custom_fields)
|
|
||||||
|
|
||||||
# FilterParams.parse_prefix_filters narrows the params map to the
|
|
||||||
# cdf_-prefixed subset once; the per-entry work below scales with the
|
|
||||||
# date filter count, not the full form-param map size.
|
|
||||||
params
|
|
||||||
|> FilterParams.parse_prefix_filters(@custom_date_filter_prefix, & &1)
|
|
||||||
|> Enum.reduce(base, fn {suffixed_id, value}, acc ->
|
|
||||||
with true <- bounded_id?(suffixed_id),
|
|
||||||
{id, bound} <- split_suffix(suffixed_id),
|
|
||||||
true <- MapSet.member?(valid_ids, id),
|
|
||||||
%Date{} = date <- parse_date(value) do
|
|
||||||
update_custom_date_entry(acc, id, bound, date)
|
|
||||||
else
|
|
||||||
_ -> acc
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Reject any suffixed_id that could not possibly fit a UUID + bound suffix
|
|
||||||
# before doing further string work. This is the DoS-protection contract
|
|
||||||
# used by the boolean / group / fee_type filter parsers in
|
|
||||||
# `MvWeb.MemberLive.Index` (see `process_boolean_filter_param/5`,
|
|
||||||
# `add_group_filter_entry/4`, `add_fee_type_filter_entry/4`).
|
|
||||||
defp bounded_id?(suffixed_id) when is_binary(suffixed_id),
|
|
||||||
do: String.length(suffixed_id) <= @max_suffixed_id_length
|
|
||||||
|
|
||||||
defp bounded_id?(_), do: false
|
|
||||||
|
|
||||||
defp date_field?(%{value_type: :date}), do: true
|
|
||||||
defp date_field?(_), do: false
|
|
||||||
|
|
||||||
# Single source of truth for the set of valid custom-date-field UUID strings.
|
|
||||||
# Used both when parsing URL params (to drop bogus UUIDs) and when computing
|
|
||||||
# which active filter entries actually correspond to a known date field.
|
|
||||||
defp valid_custom_date_field_ids(date_custom_fields) do
|
|
||||||
date_custom_fields
|
|
||||||
|> Enum.filter(&date_field?/1)
|
|
||||||
|> MapSet.new(&to_string(&1.id))
|
|
||||||
end
|
|
||||||
|
|
||||||
defp split_suffix(suffixed_id) do
|
|
||||||
cond do
|
|
||||||
String.ends_with?(suffixed_id, "_from") ->
|
|
||||||
{String.replace_suffix(suffixed_id, "_from", ""), :from}
|
|
||||||
|
|
||||||
String.ends_with?(suffixed_id, "_to") ->
|
|
||||||
{String.replace_suffix(suffixed_id, "_to", ""), :to}
|
|
||||||
|
|
||||||
true ->
|
|
||||||
:error
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp update_custom_date_entry(acc, id, bound, date) do
|
|
||||||
current = Map.get(acc, id, %{from: nil, to: nil})
|
|
||||||
Map.put(acc, id, Map.put(current, bound, date))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -103,6 +103,8 @@ defmodule MvWeb.MemberLive.Index.FieldSelection do
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp parse_cookie_header(_), do: %{}
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Saves field selection to cookie.
|
Saves field selection to cookie.
|
||||||
|
|
||||||
|
|
@ -216,6 +218,8 @@ defmodule MvWeb.MemberLive.Index.FieldSelection do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp parse_json(_), do: %{}
|
||||||
|
|
||||||
# Parses a comma-separated string of field names
|
# Parses a comma-separated string of field names
|
||||||
defp parse_fields_string(fields_string) do
|
defp parse_fields_string(fields_string) do
|
||||||
fields_string
|
fields_string
|
||||||
|
|
|
||||||
|
|
@ -190,7 +190,7 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
||||||
These fields are not in the database; they must not be used for Ash query
|
These fields are not in the database; they must not be used for Ash query
|
||||||
select/sort. Use this to filter sort options and validate sort_field.
|
select/sort. Use this to filter sort options and validate sort_field.
|
||||||
"""
|
"""
|
||||||
@spec computed_member_fields() :: [:membership_fee_status | :membership_fee_type | :groups, ...]
|
@spec computed_member_fields() :: [atom()]
|
||||||
def computed_member_fields, do: @pseudo_member_fields
|
def computed_member_fields, do: @pseudo_member_fields
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,8 @@
|
||||||
defmodule MvWeb.MemberLive.Index.FilterParams do
|
defmodule MvWeb.MemberLive.Index.FilterParams do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Shared parsing helpers for member list filter URL/params.
|
Shared parsing helpers for member list filter URL/params (in/not_in style).
|
||||||
|
Used by MemberLive.Index and MemberFilterComponent to avoid duplication and recursion bugs.
|
||||||
Used by `MvWeb.MemberLive.Index`, `MvWeb.Components.MemberFilterComponent`,
|
|
||||||
and `MvWeb.MemberLive.Index.DateFilter` to avoid duplication and to keep
|
|
||||||
param-extraction logic in one place.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Parses a value for group or fee-type filter params.
|
Parses a value for group or fee-type filter params.
|
||||||
Returns `:in`, `:not_in`, or `nil`. Handles trimmed strings; no recursion.
|
Returns `:in`, `:not_in`, or `nil`. Handles trimmed strings; no recursion.
|
||||||
|
|
@ -23,29 +19,4 @@ defmodule MvWeb.MemberLive.Index.FilterParams do
|
||||||
end
|
end
|
||||||
|
|
||||||
def parse_in_not_in_value(_), do: nil
|
def parse_in_not_in_value(_), do: nil
|
||||||
|
|
||||||
@doc """
|
|
||||||
Selects every `{key, value}` pair in `params` whose `key` is a binary that
|
|
||||||
starts with `prefix`, strips the prefix from the key, runs `parse_value_fn`
|
|
||||||
on the value, and accumulates the results into a map.
|
|
||||||
|
|
||||||
Non-binary keys are ignored. Exactly one occurrence of the prefix is
|
|
||||||
stripped (so a key like `"p_p_abc"` with prefix `"p_"` yields id `"p_abc"`).
|
|
||||||
|
|
||||||
The prefix-match filter is applied before the reduce so unrelated params
|
|
||||||
(e.g. `query`, `sort_field`, other-prefix filters) do not enter the
|
|
||||||
per-entry work — keeping the cost proportional to the matched subset on
|
|
||||||
every `phx-change` keystroke.
|
|
||||||
"""
|
|
||||||
@spec parse_prefix_filters(map(), String.t(), (String.t() -> term())) ::
|
|
||||||
%{optional(String.t()) => term()}
|
|
||||||
def parse_prefix_filters(params, prefix, parse_value_fn)
|
|
||||||
when is_map(params) and is_binary(prefix) and is_function(parse_value_fn, 1) do
|
|
||||||
params
|
|
||||||
|> Enum.filter(fn {key, _} -> is_binary(key) and String.starts_with?(key, prefix) end)
|
|
||||||
|> Enum.reduce(%{}, fn {key, value}, acc ->
|
|
||||||
id = String.replace_prefix(key, prefix, "")
|
|
||||||
Map.put(acc, id, parse_value_fn.(value))
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1027,7 +1027,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
|> assign(:create_cycle_error, format_error(error))}
|
|> assign(:create_cycle_error, format_error(error))}
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
{:error, reason} when reason in [:invalid_format, :invalid_date, :incompatible_calendars] ->
|
:error ->
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(:create_cycle_error, gettext("Invalid date format"))}
|
|> assign(:create_cycle_error, gettext("Invalid date format"))}
|
||||||
|
|
|
||||||
|
|
@ -464,6 +464,7 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
||||||
Enum.map_join(error.errors, ", ", fn e -> e.message end)
|
Enum.map_join(error.errors, ", ", fn e -> e.message end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp format_error(error) when is_binary(error), do: error
|
||||||
defp format_error(_error), do: gettext("An error occurred")
|
defp format_error(_error), do: gettext("An error occurred")
|
||||||
|
|
||||||
defp assign_form(%{assigns: %{settings: settings}} = socket) do
|
defp assign_form(%{assigns: %{settings: settings}} = socket) do
|
||||||
|
|
|
||||||
|
|
@ -326,7 +326,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
|
||||||
|
|
||||||
case submit_form(socket.assigns.form, params, actor) do
|
case submit_form(socket.assigns.form, params, actor) do
|
||||||
{:ok, membership_fee_type} ->
|
{:ok, membership_fee_type} ->
|
||||||
_ = notify_parent({:saved, membership_fee_type})
|
notify_parent({:saved, membership_fee_type})
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|
|
@ -341,7 +341,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec notify_parent(any()) :: {module(), any()}
|
@spec notify_parent(any()) :: any()
|
||||||
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
||||||
|
|
||||||
@spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
|
@spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
|
||||||
|
|
|
||||||
|
|
@ -214,6 +214,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
|
||||||
Enum.map_join(error.errors, ", ", fn e -> e.message end)
|
Enum.map_join(error.errors, ", ", fn e -> e.message end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp format_error(error) when is_binary(error), do: error
|
||||||
defp format_error(_error), do: gettext("An error occurred")
|
defp format_error(_error), do: gettext("An error occurred")
|
||||||
|
|
||||||
# Info card explaining the membership fee type concept
|
# Info card explaining the membership fee type concept
|
||||||
|
|
|
||||||
|
|
@ -165,7 +165,7 @@ defmodule MvWeb.RoleLive.Form do
|
||||||
|
|
||||||
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, role_params, actor) do
|
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, role_params, actor) do
|
||||||
{:ok, role} ->
|
{:ok, role} ->
|
||||||
_ = notify_parent({:saved, role})
|
notify_parent({:saved, role})
|
||||||
|
|
||||||
redirect_path =
|
redirect_path =
|
||||||
if socket.assigns.return_to == "show" do
|
if socket.assigns.return_to == "show" do
|
||||||
|
|
@ -186,7 +186,7 @@ defmodule MvWeb.RoleLive.Form do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec notify_parent(any()) :: {module(), any()}
|
@spec notify_parent(any()) :: any()
|
||||||
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
||||||
|
|
||||||
@spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
|
@spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
|
||||||
|
|
|
||||||
|
|
@ -734,7 +734,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp handle_save_success(socket, updated_user) do
|
defp handle_save_success(socket, updated_user) do
|
||||||
_ = notify_parent({:saved, updated_user})
|
notify_parent({:saved, updated_user})
|
||||||
|
|
||||||
action = get_action_name(socket.assigns.form.source.type)
|
action = get_action_name(socket.assigns.form.source.type)
|
||||||
|
|
||||||
|
|
@ -775,7 +775,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
)}
|
)}
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec notify_parent(any()) :: {module(), any()}
|
@spec notify_parent(any()) :: any()
|
||||||
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
||||||
|
|
||||||
# Helper to ignore keyboard events when dropdown is closed
|
# Helper to ignore keyboard events when dropdown is closed
|
||||||
|
|
@ -913,7 +913,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
MemberResource.filter_by_email_match(members, user_email_str)
|
MemberResource.filter_by_email_match(members, user_email_str)
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec load_roles(any()) :: [Mv.Authorization.Role.t()] | Ash.Page.page()
|
@spec load_roles(any()) :: [Mv.Authorization.Role.t()]
|
||||||
defp load_roles(actor) do
|
defp load_roles(actor) do
|
||||||
case Authorization.list_roles(actor: actor) do
|
case Authorization.list_roles(actor: actor) do
|
||||||
{:ok, roles} -> roles
|
{:ok, roles} -> roles
|
||||||
|
|
@ -922,7 +922,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
end
|
end
|
||||||
|
|
||||||
# Extract user-friendly error message from Ash.Error
|
# Extract user-friendly error message from Ash.Error
|
||||||
@spec extract_error_message(Ash.Error.t()) :: String.t()
|
@spec extract_error_message(any()) :: String.t()
|
||||||
defp extract_error_message(%Ash.Error.Invalid{errors: errors}) when is_list(errors) do
|
defp extract_error_message(%Ash.Error.Invalid{errors: errors}) when is_list(errors) do
|
||||||
# Take first error and extract message
|
# Take first error and extract message
|
||||||
case List.first(errors) do
|
case List.first(errors) do
|
||||||
|
|
@ -932,5 +932,6 @@ defmodule MvWeb.UserLive.Form do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp extract_error_message(error) when is_binary(error), do: error
|
||||||
defp extract_error_message(_), do: gettext("Unknown error")
|
defp extract_error_message(_), do: gettext("Unknown error")
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ defmodule MvWeb.LiveHelpers do
|
||||||
|
|
||||||
def on_mount(:default, _params, session, socket) do
|
def on_mount(:default, _params, session, socket) do
|
||||||
locale = session["locale"] || "de"
|
locale = session["locale"] || "de"
|
||||||
_ = Gettext.put_locale(locale)
|
Gettext.put_locale(locale)
|
||||||
|
|
||||||
# Browser timezone from LiveSocket connect params (set in app.js via Intl API)
|
# Browser timezone from LiveSocket connect params (set in app.js via Intl API)
|
||||||
connect_params = socket.private[:connect_params] || %{}
|
connect_params = socket.private[:connect_params] || %{}
|
||||||
|
|
@ -145,10 +145,7 @@ defmodule MvWeb.LiveHelpers do
|
||||||
end
|
end
|
||||||
"""
|
"""
|
||||||
@spec submit_form(AshPhoenix.Form.t(), map(), Mv.Accounts.User.t() | nil) ::
|
@spec submit_form(AshPhoenix.Form.t(), map(), Mv.Accounts.User.t() | nil) ::
|
||||||
{:ok, Ash.Resource.record() | nil | [Ash.Notifier.Notification.t()]}
|
{:ok, Ash.Resource.t()} | {:error, AshPhoenix.Form.t()}
|
||||||
| {:ok, Ash.Resource.record(), [Ash.Notifier.Notification.t()]}
|
|
||||||
| :ok
|
|
||||||
| {:error, AshPhoenix.Form.t()}
|
|
||||||
def submit_form(form, params, actor) do
|
def submit_form(form, params, actor) do
|
||||||
AshPhoenix.Form.submit(form, params: params, action_opts: ash_actor_opts(actor))
|
AshPhoenix.Form.submit(form, params: params, action_opts: ash_actor_opts(actor))
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -31,24 +31,27 @@ defmodule MvWeb.LiveUserAuth do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def on_mount(:live_user_required, _params, _session, socket) do
|
def on_mount(:live_user_required, _params, session, socket) do
|
||||||
|
socket = LiveSession.assign_new_resources(socket, session)
|
||||||
|
|
||||||
case socket.assigns do
|
case socket.assigns do
|
||||||
%{current_user: %{} = user} ->
|
%{current_user: %{} = user} ->
|
||||||
{:cont, assign(socket, :current_user, user)}
|
{:cont, assign(socket, :current_user, user)}
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
{:halt, LiveView.redirect(socket, to: ~p"/sign-in")}
|
socket = LiveView.redirect(socket, to: ~p"/sign-in")
|
||||||
|
{:halt, socket}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def on_mount(:live_no_user, _params, session, socket) do
|
def on_mount(:live_no_user, _params, session, socket) do
|
||||||
# Set the locale for not logged in user (default from config, "de" in dev/prod).
|
# Set the locale for not logged in user (default from config, "de" in dev/prod).
|
||||||
locale = session["locale"] || Application.get_env(:mv, :default_locale, "de")
|
locale = session["locale"] || Application.get_env(:mv, :default_locale, "de")
|
||||||
_ = Gettext.put_locale(MvWeb.Gettext, locale)
|
Gettext.put_locale(MvWeb.Gettext, locale)
|
||||||
socket = assign(socket, :locale, locale)
|
{:cont, assign(socket, :locale, locale)}
|
||||||
|
|
||||||
if socket.assigns[:current_user] do
|
if socket.assigns[:current_user] do
|
||||||
{:halt, LiveView.redirect(socket, to: ~p"/")}
|
{:halt, Phoenix.LiveView.redirect(socket, to: ~p"/")}
|
||||||
else
|
else
|
||||||
{:cont, assign(socket, :current_user, nil)}
|
{:cont, assign(socket, :current_user, nil)}
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,7 @@ defmodule MvWeb.Router do
|
||||||
|
|
||||||
# ASHAUTHENTICATION GENERATED AUTH ROUTES
|
# ASHAUTHENTICATION GENERATED AUTH ROUTES
|
||||||
auth_routes AuthController, Mv.Accounts.User, path: "/auth"
|
auth_routes AuthController, Mv.Accounts.User, path: "/auth"
|
||||||
sign_out_route AuthController, "/sign-out", live_view: MvWeb.SignOutLive
|
sign_out_route AuthController
|
||||||
|
|
||||||
# Remove these if you'd like to use your own authentication views
|
# Remove these if you'd like to use your own authentication views
|
||||||
sign_in_route register_path: "/register",
|
sign_in_route register_path: "/register",
|
||||||
|
|
@ -188,7 +188,7 @@ defmodule MvWeb.Router do
|
||||||
get_locale_from_cookie(conn) ||
|
get_locale_from_cookie(conn) ||
|
||||||
extract_locale_from_headers(conn.req_headers)
|
extract_locale_from_headers(conn.req_headers)
|
||||||
|
|
||||||
_ = Gettext.put_locale(MvWeb.Gettext, locale)
|
Gettext.put_locale(MvWeb.Gettext, locale)
|
||||||
|
|
||||||
conn
|
conn
|
||||||
|> put_session(:locale, locale)
|
|> put_session(:locale, locale)
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,7 @@ defmodule MvWeb.Translations.FieldTypes do
|
||||||
"""
|
"""
|
||||||
use Gettext, backend: MvWeb.Gettext
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
@type field_type :: :string | :integer | :boolean | :date | :email
|
@spec label(atom()) :: String.t()
|
||||||
|
|
||||||
@spec label(field_type()) :: String.t()
|
|
||||||
def label(:string), do: gettext("Text")
|
def label(:string), do: gettext("Text")
|
||||||
def label(:integer), do: gettext("Number")
|
def label(:integer), do: gettext("Number")
|
||||||
def label(:boolean), do: gettext("Yes/No-Selection")
|
def label(:boolean), do: gettext("Yes/No-Selection")
|
||||||
|
|
|
||||||
19
mix.exs
19
mix.exs
|
|
@ -12,7 +12,6 @@ defmodule Mv.MixProject do
|
||||||
compilers: [:phoenix_live_view] ++ Mix.compilers(),
|
compilers: [:phoenix_live_view] ++ Mix.compilers(),
|
||||||
aliases: aliases(),
|
aliases: aliases(),
|
||||||
deps: deps(),
|
deps: deps(),
|
||||||
dialyzer: dialyzer(),
|
|
||||||
listeners: [Phoenix.CodeReloader],
|
listeners: [Phoenix.CodeReloader],
|
||||||
gettext: [write_reference_line_numbers: false]
|
gettext: [write_reference_line_numbers: false]
|
||||||
]
|
]
|
||||||
|
|
@ -81,7 +80,6 @@ defmodule Mv.MixProject do
|
||||||
{:sobelow, "~> 0.14", only: [:dev, :test], runtime: false},
|
{:sobelow, "~> 0.14", only: [:dev, :test], runtime: false},
|
||||||
{:bypass, "~> 2.1", only: [:dev, :test]},
|
{:bypass, "~> 2.1", only: [:dev, :test]},
|
||||||
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
|
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
|
||||||
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false},
|
|
||||||
{:picosat_elixir, "~> 0.1"},
|
{:picosat_elixir, "~> 0.1"},
|
||||||
{:ecto_commons, "~> 0.3"},
|
{:ecto_commons, "~> 0.3"},
|
||||||
{:slugify, "~> 1.3"},
|
{:slugify, "~> 1.3"},
|
||||||
|
|
@ -114,21 +112,4 @@ defmodule Mv.MixProject do
|
||||||
"phx.routes": ["phx.routes", "ash_authentication.phoenix.routes"]
|
"phx.routes": ["phx.routes", "ash_authentication.phoenix.routes"]
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
defp dialyzer do
|
|
||||||
[
|
|
||||||
plt_file: {:no_warn, "priv/plts/dialyzer.plt"},
|
|
||||||
plt_core_path: "priv/plts/core.plt",
|
|
||||||
plt_add_apps: [:mix, :ex_unit],
|
|
||||||
flags: [
|
|
||||||
:error_handling,
|
|
||||||
:unmatched_returns,
|
|
||||||
:extra_return,
|
|
||||||
:missing_return,
|
|
||||||
:underspecs
|
|
||||||
],
|
|
||||||
ignore_warnings: ".dialyzer_ignore.exs",
|
|
||||||
list_unused_filters: true
|
|
||||||
]
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
22
mix.lock
22
mix.lock
|
|
@ -1,5 +1,5 @@
|
||||||
%{
|
%{
|
||||||
"ash": {:hex, :ash, "3.27.7", "349e47b9fc293c8de56866f900f6e1a3a5deea1e110d205749f94a9833431811", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "eb8a1a74090d7f1753a63fe422cd493b7f50736e2d95d280ccfb508956dccc1d"},
|
"ash": {:hex, :ash, "3.25.2", "d23c52a9f823e98895d0cf1dc8bbf5d22943ffa45ba087e583d94bb05d205b2e", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c4e3fb9252719dd3fec84610a5a19e309f298265076da23c0bef21de237e98bb"},
|
||||||
"ash_admin": {:hex, :ash_admin, "1.1.0", "df7c8075347ca9229f132648534a33319f29ae5aceed6c1015d138bba1a4811f", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.20 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:cinder, "~> 0.9", [hex: :cinder, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "f8bd6c08b584a315a9574c7bbe9c1c914bc5c51838045994b0e5369871f9b3d8"},
|
"ash_admin": {:hex, :ash_admin, "1.1.0", "df7c8075347ca9229f132648534a33319f29ae5aceed6c1015d138bba1a4811f", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.20 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:cinder, "~> 0.9", [hex: :cinder, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "f8bd6c08b584a315a9574c7bbe9c1c914bc5c51838045994b0e5369871f9b3d8"},
|
||||||
"ash_authentication": {:hex, :ash_authentication, "4.13.7", "421b5ddb516026f6794435980a632109ec116af2afa68a45e15fb48b41c92cfa", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "0d45ac3fdcca6902dabbe161ce63e9cea8f90583863c2e14261c9309e5837121"},
|
"ash_authentication": {:hex, :ash_authentication, "4.13.7", "421b5ddb516026f6794435980a632109ec116af2afa68a45e15fb48b41c92cfa", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "0d45ac3fdcca6902dabbe161ce63e9cea8f90583863c2e14261c9309e5837121"},
|
||||||
"ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.16.0", "02045ecde9eeb30ab1bfffbdf693c64426af24902bcd533765eba725b9b9f46f", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "1107a45af771ee7c02ebe82abcaf9a778096e66b3e6cb2b6e614d22d1fe385f7"},
|
"ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.16.0", "02045ecde9eeb30ab1bfffbdf693c64426af24902bcd533765eba725b9b9f46f", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "1107a45af771ee7c02ebe82abcaf9a778096e66b3e6cb2b6e614d22d1fe385f7"},
|
||||||
|
|
@ -16,27 +16,25 @@
|
||||||
"cinder": {:hex, :cinder, "0.14.0", "ae0866aaa3c166cc882de04f1c9d9906d5be0e5cfb8d7e9f7d62c097d97013e7", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 2.3", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 1.0.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "e05d9c6c75dc839faaaa2063e46cd69dda3907718af71964231c93d2f602bc49"},
|
"cinder": {:hex, :cinder, "0.14.0", "ae0866aaa3c166cc882de04f1c9d9906d5be0e5cfb8d7e9f7d62c097d97013e7", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 2.3", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 1.0.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "e05d9c6c75dc839faaaa2063e46cd69dda3907718af71964231c93d2f602bc49"},
|
||||||
"circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"},
|
"circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"},
|
||||||
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
|
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
|
||||||
"cowboy": {:hex, :cowboy, "2.15.0", "9cfe86ed7117bf045e10adbedb0170af7be57f2a3637e7be143433d8dd267396", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "179fb65140fb440a17b767ad53b755081506f9596c4db5c49c0396d8c8643668"},
|
"cowboy": {:hex, :cowboy, "2.15.0", "9cfe86ed7117bf045e10adbedb0170af7be57f2a3637e7be143433d8dd267396", [], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "179fb65140fb440a17b767ad53b755081506f9596c4db5c49c0396d8c8643668"},
|
||||||
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
|
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
|
||||||
"cowlib": {:hex, :cowlib, "2.16.1", "318d385d55f657e9a5005838c4e426e13dcd724a691438384b6165a69687e531", [:make, :rebar3], [], "hexpm", "58f1e425a9e04176f1d30e20116f57c4e90ef0e187552e9741c465bdf4044f70"},
|
"cowlib": {:hex, :cowlib, "2.16.1", "318d385d55f657e9a5005838c4e426e13dcd724a691438384b6165a69687e531", [:make, :rebar3], [], "hexpm", "58f1e425a9e04176f1d30e20116f57c4e90ef0e187552e9741c465bdf4044f70"},
|
||||||
"credo": {:hex, :credo, "1.7.18", "5c5596bf7aedf9c8c227f13272ac499fe8eae6237bd326f2f07dfc173786f042", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a189d164685fd945809e862fe76a7420c4398fa288d76257662aecb909d6b3e5"},
|
"credo": {:hex, :credo, "1.7.18", "5c5596bf7aedf9c8c227f13272ac499fe8eae6237bd326f2f07dfc173786f042", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a189d164685fd945809e862fe76a7420c4398fa288d76257662aecb909d6b3e5"},
|
||||||
"crux": {:hex, :crux, "0.1.3", "c698dee09d811678dcddad11a02a832c6bff100f1a7aee49ac44c87485bdbac8", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "04188ea9c1cee13e3ef132417200765857402dcc581f45a8a7862eec3b0530ff"},
|
"crux": {:hex, :crux, "0.1.3", "c698dee09d811678dcddad11a02a832c6bff100f1a7aee49ac44c87485bdbac8", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "04188ea9c1cee13e3ef132417200765857402dcc581f45a8a7862eec3b0530ff"},
|
||||||
"db_connection": {:hex, :db_connection, "2.10.1", "d5465f6bcc125c1b8981c1dbf23c193ca16f446ec0b25832dc174f74f18be510", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "18ed94c6e627b4bf452dbd4df61b69a35a1e768525140bc1917b7a685026a6a3"},
|
"db_connection": {:hex, :db_connection, "2.10.1", "d5465f6bcc125c1b8981c1dbf23c193ca16f446ec0b25832dc174f74f18be510", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "18ed94c6e627b4bf452dbd4df61b69a35a1e768525140bc1917b7a685026a6a3"},
|
||||||
"decimal": {:hex, :decimal, "3.1.1", "430d87b04011ce6cbd4fd205be758311a81f87d552d40904abd00f015935b1d0", [:mix], [], "hexpm", "c5f25f2ced74a0587d03e6023f595db8e924c9d3922c8c8ffd9edfc4498cf1f6"},
|
"decimal": {:hex, :decimal, "3.1.0", "9ede268cff827e6f0c4fb1b34747c82630dce5d7b877dfb22ec8f0cb25855fce", [:mix], [], "hexpm", "e8b3efb3bb3a13cb5e4268ffe128569067b1972e9dee013537c71a5b073168f9"},
|
||||||
"dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"},
|
|
||||||
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
|
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
|
||||||
"ecto": {:hex, :ecto, "3.13.6", "352135b474f91d1ab99a1b502171d207e9db60421c9e3d0ecab4c7ab96b24d14", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8afa059bc16cd2c94739ec0a11e3e5df69d828125119109bef35f20a21a76af2"},
|
"ecto": {:hex, :ecto, "3.13.6", "352135b474f91d1ab99a1b502171d207e9db60421c9e3d0ecab4c7ab96b24d14", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8afa059bc16cd2c94739ec0a11e3e5df69d828125119109bef35f20a21a76af2"},
|
||||||
"ecto_commons": {:hex, :ecto_commons, "0.3.7", "f33c162a6f63695d5939af02c65a0e76aa6e7278b82c7bfc357ffbfea353bf0f", [:mix], [{:burnex, "~> 3.0", [hex: :burnex, repo: "hexpm", optional: true]}, {:ecto, "~> 3.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:ex_phone_number, "~> 0.4", [hex: :ex_phone_number, repo: "hexpm", optional: false]}, {:luhn, "~> 0.3.0", [hex: :luhn, repo: "hexpm", optional: false]}], "hexpm", "9c33771ebd38cd83d3f90fab6069826ba9d4f7580f1481b3c0913f8b9795c5fd"},
|
"ecto_commons": {:hex, :ecto_commons, "0.3.7", "f33c162a6f63695d5939af02c65a0e76aa6e7278b82c7bfc357ffbfea353bf0f", [:mix], [{:burnex, "~> 3.0", [hex: :burnex, repo: "hexpm", optional: true]}, {:ecto, "~> 3.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:ex_phone_number, "~> 0.4", [hex: :ex_phone_number, repo: "hexpm", optional: false]}, {:luhn, "~> 0.3.0", [hex: :luhn, repo: "hexpm", optional: false]}], "hexpm", "9c33771ebd38cd83d3f90fab6069826ba9d4f7580f1481b3c0913f8b9795c5fd"},
|
||||||
"ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"},
|
"ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"},
|
||||||
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
|
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
|
||||||
"erlex": {:hex, :erlex, "0.2.9", "7debbbaa9f4f368b8cd648983e0f1d7963028508e9c59e9d4ed504e94ef52a55", [:mix], [], "hexpm", "8cfffc0ec7159e6d73de2ab28a588064de80f88b2798d5cbe4482cbbc200178b"},
|
|
||||||
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
|
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
|
||||||
"ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"},
|
"ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"},
|
||||||
"ex_ast": {:hex, :ex_ast, "0.12.0", "052ad63711da41b7efbfb3490dbf3d757bb67caec17d02f6deb0db4a0363e5f6", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.7", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "66b4797f157d32f0a63c6da227515f78816c0ac8f621f6d7a2b22108e7b4dd85"},
|
"ex_ast": {:hex, :ex_ast, "0.12.0", "052ad63711da41b7efbfb3490dbf3d757bb67caec17d02f6deb0db4a0363e5f6", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.7", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "66b4797f157d32f0a63c6da227515f78816c0ac8f621f6d7a2b22108e7b4dd85"},
|
||||||
"ex_phone_number": {:hex, :ex_phone_number, "0.4.10", "11809f6600b2ecb0a2e75d496c2ec2f273d49d1e2f58b2be2667decb0aabfb43", [:mix], [{:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}], "hexpm", "eefccf58d8149d64af658721bff0edcb9e9b8943f74000ede151948ef03046c1"},
|
"ex_phone_number": {:hex, :ex_phone_number, "0.4.10", "11809f6600b2ecb0a2e75d496c2ec2f273d49d1e2f58b2be2667decb0aabfb43", [:mix], [{:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}], "hexpm", "eefccf58d8149d64af658721bff0edcb9e9b8943f74000ede151948ef03046c1"},
|
||||||
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
|
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
|
||||||
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
|
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
|
||||||
"finch": {:hex, :finch, "0.22.0", "5c48fa6f9706a78eb9036cacb67b8b996b4e66d111c543f4c29bb0f879a6806b", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b94e83c47780fc6813f746a1f1a34ee65cda42da4c5ea26a68f0acc4498e23dc"},
|
"finch": {:hex, :finch, "0.22.0", "5c48fa6f9706a78eb9036cacb67b8b996b4e66d111c543f4c29bb0f879a6806b", [], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b94e83c47780fc6813f746a1f1a34ee65cda42da4c5ea26a68f0acc4498e23dc"},
|
||||||
"fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"},
|
"fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"},
|
||||||
"gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"},
|
"gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"},
|
||||||
"gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
|
"gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
|
||||||
|
|
@ -45,7 +43,7 @@
|
||||||
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
|
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
|
||||||
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
|
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
|
||||||
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
|
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
|
||||||
"igniter": {:hex, :igniter, "0.8.1", "3c6ea47f3a6031015e29da8b4ba5c685f0a2e409facf63041fd83e982ca3aa89", [:mix], [{:ex_ast, "~> 0.5", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4.5", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "d99472e6daf3bfc3675d699c6c7ace9196f377207aab83e09d7b95e9d90e8ae8"},
|
"igniter": {:hex, :igniter, "0.8.0", "c7cab589440e5f20ff68e00f60eb094378114dab3105c0784ce8140f8dfdd2c0", [:mix], [{:ex_ast, "~> 0.5", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "fcd99096fde4797f7b48bebddcfc58785569acd696346a3eb385bf813f47a7cc"},
|
||||||
"imprintor": {:hex, :imprintor, "0.6.0", "c6dfeb3c47d15cfb7e8491cf0a40548b3b9e37d0fc33940ca6dd283d36c4bada", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "9c26b0e665c0ab183860e77c450e0b0a4e390249e8322328dbe1be70d0fbca36"},
|
"imprintor": {:hex, :imprintor, "0.6.0", "c6dfeb3c47d15cfb7e8491cf0a40548b3b9e37d0fc33940ca6dd283d36c4bada", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "9c26b0e665c0ab183860e77c450e0b0a4e390249e8322328dbe1be70d0fbca36"},
|
||||||
"iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"},
|
"iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"},
|
||||||
"jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
|
"jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
|
||||||
|
|
@ -56,7 +54,7 @@
|
||||||
"live_debugger": {:hex, :live_debugger, "1.0.0", "0bbcbdcb3b40b6862b6dfbb76579e7832e2787fee643031e5958f597def6fb32", [:mix], [{:file_system, "~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, ">= 1.1.7 and < 2.0.0-0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "b80f5d9db874d3270eb534738a10982b2b4ce55d58f94544e43b4a36111585fc"},
|
"live_debugger": {:hex, :live_debugger, "1.0.0", "0bbcbdcb3b40b6862b6dfbb76579e7832e2787fee643031e5958f597def6fb32", [:mix], [{:file_system, "~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, ">= 1.1.7 and < 2.0.0-0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "b80f5d9db874d3270eb534738a10982b2b4ce55d58f94544e43b4a36111585fc"},
|
||||||
"luhn": {:hex, :luhn, "0.3.3", "5aa0c6a32c2db4b9db9f9b883ba8301c1ae169d57199b9e6cb1ba2707bc51d96", [:mix], [], "hexpm", "3e823a913a25aab51352c727f135278d22954874d5f0835be81ed4fec3daf78d"},
|
"luhn": {:hex, :luhn, "0.3.3", "5aa0c6a32c2db4b9db9f9b883ba8301c1ae169d57199b9e6cb1ba2707bc51d96", [:mix], [], "hexpm", "3e823a913a25aab51352c727f135278d22954874d5f0835be81ed4fec3daf78d"},
|
||||||
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
|
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
|
||||||
"mint": {:hex, :mint, "1.9.0", "d6f534c2a3e98b2a8cc749b4796eb77e9e3af79a76f96e4c74035a827de0d318", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "007154c7d8c43916aed3c93afd1f11aebbaa9c5ff4b7ba55ebe0d17ee0296042"},
|
"mint": {:hex, :mint, "1.8.0", "b964eaf4416f2dee2ba88968d52239fca5621b0402b9c95f55a08eb9d74803e9", [], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "f3c572c11355eccf00f22275e9b42463bc17bd28db13be1e28f8e0bb4adbc849"},
|
||||||
"mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"},
|
"mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"},
|
||||||
"multigraph": {:hex, :multigraph, "0.16.1-mg.4", "2bbe149f5411b0e3bf0624c7bf2e3da2738efeac2f9a67bbbcb807ab171f0a76", [:mix], [], "hexpm", "b9f3e2577cef4658eeedf97c76d22a86d33a7aab702a93c1da9c122e849e9037"},
|
"multigraph": {:hex, :multigraph, "0.16.1-mg.4", "2bbe149f5411b0e3bf0624c7bf2e3da2738efeac2f9a67bbbcb807ab171f0a76", [:mix], [], "hexpm", "b9f3e2577cef4658eeedf97c76d22a86d33a7aab702a93c1da9c122e849e9037"},
|
||||||
"nimble_csv": {:hex, :nimble_csv, "1.3.0", "b7f998dc62b222bce9596e46f028c7a5af04cb5dde6df2ea197c583227c54971", [:mix], [], "hexpm", "41ccdc18f7c8f8bb06e84164fc51635321e80d5a3b450761c4997d620925d619"},
|
"nimble_csv": {:hex, :nimble_csv, "1.3.0", "b7f998dc62b222bce9596e46f028c7a5af04cb5dde6df2ea197c583227c54971", [:mix], [], "hexpm", "41ccdc18f7c8f8bb06e84164fc51635321e80d5a3b450761c4997d620925d619"},
|
||||||
|
|
@ -69,7 +67,7 @@
|
||||||
"phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"},
|
"phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"},
|
||||||
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
|
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
|
||||||
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"},
|
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"},
|
||||||
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.31", "c45c85df509dd79c917bc530e26c71299e3920850f65ea52ab6a19ccee66875a", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2f53cc6a9e149f30449341c2775990819d97e3b22338fe719c4d30342e6f9638"},
|
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.30", "a84af1610755dc208da35d4d45564485edbf18c3f3c77373c4a650dc994cdcdb", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a353c51ac1e3190910f01a6100c7d5cc02c5e22e7374fd817bd3aedd21149039"},
|
||||||
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
|
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
|
||||||
"phoenix_swoosh": {:hex, :phoenix_swoosh, "1.2.1", "b74ccaa8046fbc388a62134360ee7d9742d5a8ae74063f34eb050279de7a99e1", [:mix], [{:finch, "~> 0.8", [hex: :finch, repo: "hexpm", optional: true]}, {:hackney, "~> 1.10", [hex: :hackney, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_view, "~> 1.0 or ~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:swoosh, "~> 1.5", [hex: :swoosh, repo: "hexpm", optional: false]}], "hexpm", "4000eeba3f9d7d1a6bf56d2bd56733d5cadf41a7f0d8ffe5bb67e7d667e204a2"},
|
"phoenix_swoosh": {:hex, :phoenix_swoosh, "1.2.1", "b74ccaa8046fbc388a62134360ee7d9742d5a8ae74063f34eb050279de7a99e1", [:mix], [{:finch, "~> 0.8", [hex: :finch, repo: "hexpm", optional: true]}, {:hackney, "~> 1.10", [hex: :hackney, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_view, "~> 1.0 or ~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:swoosh, "~> 1.5", [hex: :swoosh, repo: "hexpm", optional: false]}], "hexpm", "4000eeba3f9d7d1a6bf56d2bd56733d5cadf41a7f0d8ffe5bb67e7d667e204a2"},
|
||||||
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
|
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
|
||||||
|
|
@ -81,7 +79,7 @@
|
||||||
"postgrex": {:hex, :postgrex, "0.22.2", "4aec14df2a72722aee92492566edbeeb44e233ecb86b1915d03136297ef1385d", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8946382ddb06294f56026ac4278b3cc212bac8a2c82ed68b4087819ed1abc53b"},
|
"postgrex": {:hex, :postgrex, "0.22.2", "4aec14df2a72722aee92492566edbeeb44e233ecb86b1915d03136297ef1385d", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8946382ddb06294f56026ac4278b3cc212bac8a2c82ed68b4087819ed1abc53b"},
|
||||||
"ranch": {:hex, :ranch, "1.8.1", "208169e65292ac5d333d6cdbad49388c1ae198136e4697ae2f474697140f201c", [:make, :rebar3], [], "hexpm", "aed58910f4e21deea992a67bf51632b6d60114895eb03bb392bb733064594dd0"},
|
"ranch": {:hex, :ranch, "1.8.1", "208169e65292ac5d333d6cdbad49388c1ae198136e4697ae2f474697140f201c", [:make, :rebar3], [], "hexpm", "aed58910f4e21deea992a67bf51632b6d60114895eb03bb392bb733064594dd0"},
|
||||||
"reactor": {:hex, :reactor, "1.0.2", "79e4e81d016ab0016afd10bb4c18cb3a574f08f10f8e53be5f08ce27f8eed541", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:multigraph, "~> 0.16.1-mg.2", [hex: :multigraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "19fd55aaaadaae28f55133351051c25d4ac217f99e3e5a67940cc4a321e3948e"},
|
"reactor": {:hex, :reactor, "1.0.2", "79e4e81d016ab0016afd10bb4c18cb3a574f08f10f8e53be5f08ce27f8eed541", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:multigraph, "~> 0.16.1-mg.2", [hex: :multigraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "19fd55aaaadaae28f55133351051c25d4ac217f99e3e5a67940cc4a321e3948e"},
|
||||||
"req": {:hex, :req, "0.5.18", "48e6431cb4135e8a7815e745177485369a9b4a9924d5fe68ca00eb09ceaed1ef", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21.0 or ~> 0.22.0", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "fa03812c440a9754bf34355e0c5d4f3ed316458db62e3284b7a352ef8dc0b996"},
|
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
|
||||||
"rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"},
|
"rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"},
|
||||||
"rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"},
|
"rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"},
|
||||||
"slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"},
|
"slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"},
|
||||||
|
|
@ -98,13 +96,13 @@
|
||||||
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
|
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
|
||||||
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
|
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
|
||||||
"text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
|
"text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
|
||||||
"thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"},
|
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
|
||||||
"tidewave": {:hex, :tidewave, "0.5.6", "91f35540b5599640443f1d3a1c6166bf506e202840261a6344e384e8813c1f64", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "dc82d52b8b6ffc04680544b17cd340c7d4166bb0d63999eb960850526866b533"},
|
"tidewave": {:hex, :tidewave, "0.5.6", "91f35540b5599640443f1d3a1c6166bf506e202840261a6344e384e8813c1f64", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "dc82d52b8b6ffc04680544b17cd340c7d4166bb0d63999eb960850526866b533"},
|
||||||
"tz": {:hex, :tz, "0.28.1", "717f5ffddfd1e475e2a233e221dc0b4b76c35c4b3650b060c8e3ba29dd6632e9", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:mint, "~> 1.6", [hex: :mint, repo: "hexpm", optional: true]}], "hexpm", "bfdca1aa1902643c6c43b77c1fb0cb3d744fd2f09a8a98405468afdee0848c8a"},
|
"tz": {:hex, :tz, "0.28.1", "717f5ffddfd1e475e2a233e221dc0b4b76c35c4b3650b060c8e3ba29dd6632e9", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:mint, "~> 1.6", [hex: :mint, repo: "hexpm", optional: true]}], "hexpm", "bfdca1aa1902643c6c43b77c1fb0cb3d744fd2f09a8a98405468afdee0848c8a"},
|
||||||
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
|
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
|
||||||
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
||||||
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
|
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
|
||||||
"yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"},
|
"yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"},
|
||||||
"yaml_elixir": {:hex, :yaml_elixir, "2.12.2", "9dd1330fb4cd9a36a7b0f502e5b12486eff632792ee4a5f0eba52a4d4ec32c9c", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "e7c1b10122f973e6558462d51c39026ba0e14afbc6745318e990ea82cfe9e159"},
|
"yaml_elixir": {:hex, :yaml_elixir, "2.12.1", "d74f2d82294651b58dac849c45a82aaea639766797359baff834b64439f6b3f4", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "d9ac16563c737d55f9bfeed7627489156b91268a3a21cd55c54eb2e335207fed"},
|
||||||
"ymlr": {:hex, :ymlr, "5.1.5", "0b9207c7940be3f2bc29b77cd55109d5aa2f4dcde6575942017335769e6f5628", [:mix], [], "hexpm", "7030cb240c46850caeb3b01be745307632be319b15f03083136f6251f49b516d"},
|
"ymlr": {:hex, :ymlr, "5.1.5", "0b9207c7940be3f2bc29b77cd55109d5aa2f4dcde6575942017335769e6f5628", [:mix], [], "hexpm", "7030cb240c46850caeb3b01be745307632be319b15f03083136f6251f49b516d"},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -152,13 +152,3 @@ msgstr ""
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Register"
|
msgid "Register"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/auth/sign_out_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Are you sure you want to sign out?"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/auth/sign_out_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Sign out"
|
|
||||||
msgstr ""
|
|
||||||
|
|
|
||||||
|
|
@ -148,13 +148,3 @@ msgstr "Sprache auswählen"
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Register"
|
msgid "Register"
|
||||||
msgstr "Registrieren"
|
msgstr "Registrieren"
|
||||||
|
|
||||||
#: lib/mv_web/live/auth/sign_out_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Are you sure you want to sign out?"
|
|
||||||
msgstr "Möchtest du dich wirklich abmelden?"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/auth/sign_out_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Sign out"
|
|
||||||
msgstr "Abmelden"
|
|
||||||
|
|
|
||||||
|
|
@ -2208,6 +2208,11 @@ msgstr "Keine Mitglieder in dieser Gruppe"
|
||||||
msgid "No members selected"
|
msgid "No members selected"
|
||||||
msgstr "Keine Mitglieder ausgewählt"
|
msgstr "Keine Mitglieder ausgewählt"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "No members selected."
|
||||||
|
msgstr "Keine Mitglieder ausgewählt."
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned."
|
msgid "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned."
|
||||||
|
|
@ -3892,83 +3897,3 @@ msgstr "Die SMTP-Umgebungs-Konfiguration ist unvollständig. Fehlend: %{keys}"
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only."
|
msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only."
|
||||||
msgstr "SMTP wird vollständig über Umgebungsvariablen verwaltet. Alle SMTP-Felder sind schreibgeschützt."
|
msgstr "SMTP wird vollständig über Umgebungsvariablen verwaltet. Alle SMTP-Felder sind schreibgeschützt."
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "%{field} from"
|
|
||||||
msgstr "%{field} von"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "%{field} to"
|
|
||||||
msgstr "%{field} bis"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Active only"
|
|
||||||
msgstr "Nur aktive"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Custom date fields"
|
|
||||||
msgstr "Benutzerdefinierte Datumsfelder"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Dates"
|
|
||||||
msgstr "Daten"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Exit date"
|
|
||||||
msgstr "Austrittsdatum"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Exit date from"
|
|
||||||
msgstr "Austrittsdatum von"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Exit date to"
|
|
||||||
msgstr "Austrittsdatum bis"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "From"
|
|
||||||
msgstr "Von"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Inactive only"
|
|
||||||
msgstr "Nur ehemalige"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Join date"
|
|
||||||
msgstr "Beitrittsdatum"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Join date from"
|
|
||||||
msgstr "Beitrittsdatum von"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Join date to"
|
|
||||||
msgstr "Beitrittsdatum bis"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Range"
|
|
||||||
msgstr "Zeitraum"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "To"
|
|
||||||
msgstr "Bis"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/group_live/show.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "No members selected."
|
|
||||||
#~ msgstr "Keine Mitglieder ausgewählt."
|
|
||||||
|
|
|
||||||
|
|
@ -2209,6 +2209,11 @@ msgstr ""
|
||||||
msgid "No members selected"
|
msgid "No members selected"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "No members selected."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned."
|
msgid "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned."
|
||||||
|
|
@ -3892,78 +3897,3 @@ msgstr ""
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only."
|
msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "%{field} from"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "%{field} to"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Active only"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Custom date fields"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Dates"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Exit date"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Exit date from"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Exit date to"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "From"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Inactive only"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Join date"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Join date from"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Join date to"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Range"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "To"
|
|
||||||
msgstr ""
|
|
||||||
|
|
|
||||||
|
|
@ -145,13 +145,3 @@ msgstr ""
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Register"
|
msgid "Register"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/auth/sign_out_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Are you sure you want to sign out?"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/auth/sign_out_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Sign out"
|
|
||||||
msgstr ""
|
|
||||||
|
|
|
||||||
|
|
@ -2209,6 +2209,11 @@ msgstr ""
|
||||||
msgid "No members selected"
|
msgid "No members selected"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "No members selected."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned."
|
msgid "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned."
|
||||||
|
|
@ -3892,83 +3897,3 @@ msgstr ""
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only."
|
msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only."
|
||||||
msgstr "SMTP is fully managed via environment variables. All SMTP fields are read-only."
|
msgstr "SMTP is fully managed via environment variables. All SMTP fields are read-only."
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "%{field} from"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "%{field} to"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
|
||||||
msgid "Active only"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
|
||||||
msgid "Custom date fields"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
|
||||||
msgid "Dates"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
|
||||||
msgid "Exit date"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
|
||||||
msgid "Exit date from"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
|
||||||
msgid "Exit date to"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "From"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Inactive only"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
|
||||||
msgid "Join date"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
|
||||||
msgid "Join date from"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
|
||||||
msgid "Join date to"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Range"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "To"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/group_live/show.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
|
||||||
#~ msgid "No members selected."
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
[
|
|
||||||
{
|
|
||||||
"id": "mv",
|
|
||||||
"name": "Mila dev",
|
|
||||||
"secret": { "Plain": "mv-dev-shared-secret-not-for-production-do-not-use-anywhere-else" },
|
|
||||||
"redirect_uris": ["http://localhost:4000/auth/user/oidc/callback"],
|
|
||||||
"allowed_origins": ["http://localhost:4000"],
|
|
||||||
"enabled": true,
|
|
||||||
"flows_enabled": ["authorization_code", "refresh_token"],
|
|
||||||
"access_token_alg": "RS256",
|
|
||||||
"id_token_alg": "RS256",
|
|
||||||
"auth_code_lifetime": 60,
|
|
||||||
"access_token_lifetime": 1800,
|
|
||||||
"scopes": ["openid", "profile", "email", "groups"],
|
|
||||||
"default_scopes": ["openid", "profile", "email", "groups"],
|
|
||||||
"challenges": ["S256"],
|
|
||||||
"force_mfa": false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
defmodule Mv.ConstantsTest do
|
|
||||||
@moduledoc """
|
|
||||||
Tests for Mv.Constants accessor functions. Focus is on the date filter
|
|
||||||
URL parameter prefixes that drive the bookmarkable filter state.
|
|
||||||
"""
|
|
||||||
use ExUnit.Case, async: true
|
|
||||||
|
|
||||||
describe "date filter URL param prefixes" do
|
|
||||||
test "join_date_from_param/0 returns jd_from" do
|
|
||||||
assert Mv.Constants.join_date_from_param() == "jd_from"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "join_date_to_param/0 returns jd_to" do
|
|
||||||
assert Mv.Constants.join_date_to_param() == "jd_to"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "exit_date_mode_param/0 returns ed_mode" do
|
|
||||||
assert Mv.Constants.exit_date_mode_param() == "ed_mode"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "exit_date_from_param/0 returns ed_from" do
|
|
||||||
assert Mv.Constants.exit_date_from_param() == "ed_from"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "exit_date_to_param/0 returns ed_to" do
|
|
||||||
assert Mv.Constants.exit_date_to_param() == "ed_to"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "custom_date_filter_prefix/0 returns cdf_" do
|
|
||||||
assert Mv.Constants.custom_date_filter_prefix() == "cdf_"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
defmodule Mv.Membership.Import.ImportRunnerTest do
|
|
||||||
use ExUnit.Case, async: true
|
|
||||||
|
|
||||||
alias Mv.Membership.Import.ImportRunner
|
|
||||||
|
|
||||||
describe "read_file_entry/2" do
|
|
||||||
test "returns {:ok, content} for a readable file" do
|
|
||||||
path =
|
|
||||||
Path.join(
|
|
||||||
System.tmp_dir!(),
|
|
||||||
"import_runner_read_#{System.unique_integer([:positive])}.csv"
|
|
||||||
)
|
|
||||||
|
|
||||||
File.write!(path, "email;first_name\njohn@example.com;John")
|
|
||||||
on_exit(fn -> File.rm_rf(path) end)
|
|
||||||
|
|
||||||
assert {:ok, "email;first_name\njohn@example.com;John"} =
|
|
||||||
ImportRunner.read_file_entry(%{path: path}, %{})
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns {:error, message} with a binary message when the file cannot be read" do
|
|
||||||
missing_path =
|
|
||||||
Path.join(
|
|
||||||
System.tmp_dir!(),
|
|
||||||
"import_runner_missing_#{System.unique_integer([:positive])}.csv"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert {:error, message} = ImportRunner.read_file_entry(%{path: missing_path}, %{})
|
|
||||||
assert is_binary(message)
|
|
||||||
assert message != ""
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -101,29 +101,6 @@ defmodule Mv.Membership.MembersPDFTest do
|
||||||
assert byte_size(pdf_binary) > 1000
|
assert byte_size(pdf_binary) > 1000
|
||||||
end
|
end
|
||||||
|
|
||||||
test "renders date column holding an ISO8601 datetime value" do
|
|
||||||
# Regression: a date column whose value is a full datetime string must be
|
|
||||||
# parsed via DateTime.from_iso8601/1 (which returns a 3-tuple) and rendered,
|
|
||||||
# not silently dropped.
|
|
||||||
export_data = %{
|
|
||||||
columns: [
|
|
||||||
%{key: "first_name", kind: :member_field, label: "Vorname"},
|
|
||||||
%{key: "join_date", kind: :member_field, label: "Eintritt"}
|
|
||||||
],
|
|
||||||
rows: [
|
|
||||||
["Max", "2024-01-15T14:30:00Z"]
|
|
||||||
],
|
|
||||||
meta: %{
|
|
||||||
generated_at: "2024-01-15T14:30:00Z",
|
|
||||||
member_count: 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
assert {:ok, pdf_binary} = MembersPDF.render(export_data)
|
|
||||||
assert String.starts_with?(pdf_binary, "%PDF")
|
|
||||||
assert byte_size(pdf_binary) > 1000
|
|
||||||
end
|
|
||||||
|
|
||||||
test "generates valid PDF with custom fields and computed fields" do
|
test "generates valid PDF with custom fields and computed fields" do
|
||||||
export_data = %{
|
export_data = %{
|
||||||
columns: [
|
columns: [
|
||||||
|
|
|
||||||
|
|
@ -209,57 +209,6 @@ defmodule MvWeb.Components.MemberFilterComponentTest do
|
||||||
# Button should still contain some text (truncated version or indicator)
|
# Button should still contain some text (truncated version or indicator)
|
||||||
assert String.length(button_html) > 0
|
assert String.length(button_html) > 0
|
||||||
end
|
end
|
||||||
|
|
||||||
test "date-only activation (ed_mode=all) replaces the idle label", %{conn: conn} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, view, _html} = live(conn, "/members?ed_mode=all")
|
|
||||||
|
|
||||||
button_html =
|
|
||||||
view
|
|
||||||
|> element("#member-filter button[aria-haspopup='true']")
|
|
||||||
|> render()
|
|
||||||
|
|
||||||
# The idle label must not appear; some non-idle label is shown. This is
|
|
||||||
# the same observable contract as the other filter categories — the
|
|
||||||
# button visually communicates "a filter is active". The `btn-active`
|
|
||||||
# CSS class is set by the parent class= attribute but the `<.button>`
|
|
||||||
# core component currently composes its own class string and drops the
|
|
||||||
# caller-supplied one — that is a pre-existing component constraint, not
|
|
||||||
# specific to date filters.
|
|
||||||
refute button_html =~ gettext("Apply filters")
|
|
||||||
end
|
|
||||||
|
|
||||||
test "date-only activation (jd_from) replaces the idle label", %{conn: conn} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, view, _html} = live(conn, "/members?jd_from=2024-01-15")
|
|
||||||
|
|
||||||
button_html =
|
|
||||||
view
|
|
||||||
|> element("#member-filter button[aria-haspopup='true']")
|
|
||||||
|> render()
|
|
||||||
|
|
||||||
refute button_html =~ gettext("Apply filters")
|
|
||||||
end
|
|
||||||
|
|
||||||
test "date filter combined with one other filter shows '2 filters active'", %{conn: conn} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
boolean_field = create_boolean_custom_field(%{name: "Newsletter"})
|
|
||||||
|
|
||||||
{:ok, view, _html} =
|
|
||||||
live(conn, "/members?ed_mode=all&bf_#{boolean_field.id}=true")
|
|
||||||
|
|
||||||
button_html =
|
|
||||||
view
|
|
||||||
|> element("#member-filter button[aria-haspopup='true']")
|
|
||||||
|> render()
|
|
||||||
|
|
||||||
# With two distinct filter categories active, the label switches to the
|
|
||||||
# pluralized "N filters active" form. Without counting date filters as
|
|
||||||
# a category, this would show only "1 filter active" or the boolean
|
|
||||||
# field name.
|
|
||||||
assert button_html =~ "2"
|
|
||||||
assert button_html =~ gettext("filters active")
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "badge" do
|
describe "badge" do
|
||||||
|
|
@ -319,293 +268,6 @@ defmodule MvWeb.Components.MemberFilterComponentTest do
|
||||||
refute dropdown_html =~ "String Field"
|
refute dropdown_html =~ "String Field"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "renders the Dates section with exit_date and join_date controls", %{conn: conn} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
|
||||||
|
|
||||||
view
|
|
||||||
|> element("#member-filter button[aria-haspopup='true']")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
dropdown_html =
|
|
||||||
view
|
|
||||||
|> element("#member-filter div[role='dialog']")
|
|
||||||
|> render()
|
|
||||||
|
|
||||||
assert dropdown_html =~ gettext("Dates")
|
|
||||||
assert dropdown_html =~ gettext("Join date")
|
|
||||||
assert dropdown_html =~ gettext("Exit date")
|
|
||||||
# Exit-date segmented control modes.
|
|
||||||
assert dropdown_html =~ gettext("Active only")
|
|
||||||
assert dropdown_html =~ gettext("Inactive only")
|
|
||||||
# Built-in date inputs (always present for join_date and the ed_mode selector).
|
|
||||||
assert dropdown_html =~ ~s(name="jd_from")
|
|
||||||
assert dropdown_html =~ ~s(name="jd_to")
|
|
||||||
assert dropdown_html =~ ~s(name="ed_mode")
|
|
||||||
end
|
|
||||||
|
|
||||||
test "exit_date custom mode reveals ed_from and ed_to inputs", %{conn: conn} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, view, _html} = live(conn, "/members?ed_mode=custom")
|
|
||||||
|
|
||||||
view
|
|
||||||
|> element("#member-filter button[aria-haspopup='true']")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
dropdown_html =
|
|
||||||
view
|
|
||||||
|> element("#member-filter div[role='dialog']")
|
|
||||||
|> render()
|
|
||||||
|
|
||||||
assert dropdown_html =~ ~s(name="ed_from")
|
|
||||||
assert dropdown_html =~ ~s(name="ed_to")
|
|
||||||
end
|
|
||||||
|
|
||||||
test "date inputs render via MvWeb.CoreComponents.input (no raw DaisyUI input markup)",
|
|
||||||
%{conn: conn} do
|
|
||||||
# DESIGN_GUIDELINES §1.1 mandates that LiveViews/HEEX use the project's
|
|
||||||
# `<.input>` wrapper rather than emitting raw `<input>` tags carrying
|
|
||||||
# DaisyUI component classes (e.g. `input input-sm input-bordered`)
|
|
||||||
# directly in HEEX. `<.input>` is the project's single source of truth
|
|
||||||
# for input styling; bypassing it splits styling across many call sites.
|
|
||||||
#
|
|
||||||
# The recognizable structural fingerprint of `<.input>` is a wrapping
|
|
||||||
# `<fieldset class="mb-2 fieldset">` `<label>` chain immediately
|
|
||||||
# preceding the `<input>`. The raw inline form has no such wrapper —
|
|
||||||
# the input sits directly inside a sibling `<label>`/`<input>` flex row.
|
|
||||||
# We assert that fingerprint on each of the date inputs.
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, view, _html} = live(conn, "/members?ed_mode=custom")
|
|
||||||
|
|
||||||
view
|
|
||||||
|> element("#member-filter button[aria-haspopup='true']")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
dropdown_html =
|
|
||||||
view
|
|
||||||
|> element("#member-filter div[role='dialog']")
|
|
||||||
|> render()
|
|
||||||
|
|
||||||
for name <- ["jd_from", "jd_to", "ed_from", "ed_to"] do
|
|
||||||
# Match `<fieldset class="mb-2 fieldset">` followed (within a short
|
|
||||||
# window of HTML) by an `<input>` carrying the expected `name`. The
|
|
||||||
# window prevents the regex from spanning unrelated `mb-2` /
|
|
||||||
# `fieldset` occurrences scattered across the dropdown. The wrapper
|
|
||||||
# is the canonical fingerprint of `MvWeb.CoreComponents.input/1`
|
|
||||||
# (see `lib/mv_web/components/core_components.ex` — every input
|
|
||||||
# branch starts with `<fieldset class="mb-2 fieldset">`).
|
|
||||||
assert Regex.match?(
|
|
||||||
~r/<fieldset[^>]*class="mb-2 fieldset"[^>]*>\s*<label[^>]*>(?:\s*<span[^>]*>.*?<\/span>)?\s*<input[^>]*name="#{name}"/s,
|
|
||||||
dropdown_html
|
|
||||||
),
|
|
||||||
"expected date input #{name} to be wrapped by MvWeb.CoreComponents.input " <>
|
|
||||||
"(class=\"mb-2 fieldset\" fieldset wrapper), not a raw inline " <>
|
|
||||||
"<input type=\"date\"> element"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
test "exit_date defaults to :active_only in the rendered radio", %{conn: conn} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
|
||||||
|
|
||||||
view
|
|
||||||
|> element("#member-filter button[aria-haspopup='true']")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
dropdown_html =
|
|
||||||
view
|
|
||||||
|> element("#member-filter div[role='dialog']")
|
|
||||||
|> render()
|
|
||||||
|
|
||||||
assert dropdown_html =~
|
|
||||||
~r/name="ed_mode"[^>]*value="active_only"[^>]*checked|checked[^>]*name="ed_mode"[^>]*value="active_only"/
|
|
||||||
end
|
|
||||||
|
|
||||||
test "Custom date fields section is non-scrollable with 5 or fewer fields (§3.4)", %{
|
|
||||||
conn: conn
|
|
||||||
} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
|
||||||
|
|
||||||
for i <- 1..5 do
|
|
||||||
CustomField
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
name: "DateField-#{i}-#{System.unique_integer([:positive])}",
|
|
||||||
value_type: :date
|
|
||||||
})
|
|
||||||
|> Ash.create!(actor: system_actor)
|
|
||||||
end
|
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
|
||||||
|
|
||||||
view
|
|
||||||
|> element("#member-filter button[aria-haspopup='true']")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
section_html = custom_date_section_html(view)
|
|
||||||
|
|
||||||
# With ≤ 5 fields the section must NOT carry the scrollable wrapper.
|
|
||||||
refute section_html =~ "max-h-60"
|
|
||||||
refute section_html =~ "overflow-y-auto"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "Custom date fields section becomes scrollable with more than 5 fields (§3.4)", %{
|
|
||||||
conn: conn
|
|
||||||
} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
|
||||||
|
|
||||||
for i <- 1..6 do
|
|
||||||
CustomField
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
name: "DateField-#{i}-#{System.unique_integer([:positive])}",
|
|
||||||
value_type: :date
|
|
||||||
})
|
|
||||||
|> Ash.create!(actor: system_actor)
|
|
||||||
end
|
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
|
||||||
|
|
||||||
view
|
|
||||||
|> element("#member-filter button[aria-haspopup='true']")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
section_html = custom_date_section_html(view)
|
|
||||||
|
|
||||||
# With more than 5 fields the section is wrapped in the scrollable container.
|
|
||||||
assert section_html =~ "max-h-60"
|
|
||||||
assert section_html =~ "overflow-y-auto"
|
|
||||||
end
|
|
||||||
|
|
||||||
# Extract the HTML of the rendered "Custom date fields" section. Returns
|
|
||||||
# "" if the section is not rendered. Used by the threshold tests to avoid
|
|
||||||
# picking up scrollable classes from sibling sections.
|
|
||||||
defp custom_date_section_html(view) do
|
|
||||||
dropdown_html =
|
|
||||||
view
|
|
||||||
|> element("#member-filter div[role='dialog']")
|
|
||||||
|> render()
|
|
||||||
|
|
||||||
label = gettext("Custom date fields")
|
|
||||||
|
|
||||||
case String.split(dropdown_html, label, parts: 2) do
|
|
||||||
[_before, after_label] ->
|
|
||||||
# Up to the next group header label, or the footer.
|
|
||||||
after_label
|
|
||||||
|> String.split(["text-xs font-semibold opacity-70 mb-2 uppercase"], parts: 2)
|
|
||||||
|> List.first()
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
""
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
test "Custom date fields section appears only when date custom fields exist", %{conn: conn} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, view_no_field, _} = live(conn, "/members")
|
|
||||||
|
|
||||||
view_no_field
|
|
||||||
|> element("#member-filter button[aria-haspopup='true']")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
dropdown_html =
|
|
||||||
view_no_field
|
|
||||||
|> element("#member-filter div[role='dialog']")
|
|
||||||
|> render()
|
|
||||||
|
|
||||||
refute dropdown_html =~ gettext("Custom date fields")
|
|
||||||
|
|
||||||
# Add a date-typed custom field and re-load: the section appears.
|
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
|
||||||
|
|
||||||
{:ok, field} =
|
|
||||||
CustomField
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
name: "Birthday-#{System.unique_integer([:positive])}",
|
|
||||||
value_type: :date
|
|
||||||
})
|
|
||||||
|> Ash.create(actor: system_actor)
|
|
||||||
|
|
||||||
{:ok, view, _} = live(conn, "/members")
|
|
||||||
|
|
||||||
view
|
|
||||||
|> element("#member-filter button[aria-haspopup='true']")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
dropdown_html =
|
|
||||||
view
|
|
||||||
|> element("#member-filter div[role='dialog']")
|
|
||||||
|> render()
|
|
||||||
|
|
||||||
assert dropdown_html =~ gettext("Custom date fields")
|
|
||||||
assert dropdown_html =~ field.name
|
|
||||||
assert dropdown_html =~ "cdf_#{field.id}_from"
|
|
||||||
assert dropdown_html =~ "cdf_#{field.id}_to"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "update_filters event dispatches a date_filters_changed patch with the new jd_from", %{
|
|
||||||
conn: conn
|
|
||||||
} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
|
||||||
|
|
||||||
view
|
|
||||||
|> element("#member-filter button[aria-haspopup='true']")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
view
|
|
||||||
|> form("#member-filter form", %{
|
|
||||||
"jd_from" => "2024-01-15",
|
|
||||||
"payment_filter" => "all"
|
|
||||||
})
|
|
||||||
|> render_change()
|
|
||||||
|
|
||||||
# Parent LiveView receives {:date_filters_changed, ...} and patches the URL.
|
|
||||||
path = assert_patch(view)
|
|
||||||
assert path =~ "jd_from=2024-01-15"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "selecting ed_mode=all updates the URL and reveals former members", %{conn: conn} do
|
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
|
||||||
today = Date.utc_today()
|
|
||||||
unique_name = "Zarquon-#{System.unique_integer([:positive])}"
|
|
||||||
|
|
||||||
{:ok, former} =
|
|
||||||
Mv.Membership.create_member(
|
|
||||||
%{
|
|
||||||
first_name: unique_name,
|
|
||||||
last_name: "Exited",
|
|
||||||
email: "ex-#{System.unique_integer([:positive])}@example.com",
|
|
||||||
join_date: Date.add(today, -1000),
|
|
||||||
exit_date: Date.add(today, -30)
|
|
||||||
},
|
|
||||||
actor: system_actor
|
|
||||||
)
|
|
||||||
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, view, html} = live(conn, "/members")
|
|
||||||
|
|
||||||
# Fresh load hides the former member.
|
|
||||||
refute html =~ former.first_name
|
|
||||||
|
|
||||||
view
|
|
||||||
|> element("#member-filter button[aria-haspopup='true']")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
view
|
|
||||||
|> form("#member-filter form", %{
|
|
||||||
"ed_mode" => "all",
|
|
||||||
"payment_filter" => "all"
|
|
||||||
})
|
|
||||||
|> render_change()
|
|
||||||
|
|
||||||
path = assert_patch(view)
|
|
||||||
assert path =~ "ed_mode=all"
|
|
||||||
|
|
||||||
# Now Eve appears in the rendered list.
|
|
||||||
assert render(view) =~ former.first_name
|
|
||||||
end
|
|
||||||
|
|
||||||
test "dropdown shows scrollbar when many boolean custom fields exist", %{conn: conn} do
|
test "dropdown shows scrollbar when many boolean custom fields exist", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,87 +62,6 @@ defmodule MvWeb.AuthControllerTest do
|
||||||
assert redirected_to(conn) == ~p"/"
|
assert redirected_to(conn) == ~p"/"
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "DELETE /sign-out with OIDC configured" do
|
|
||||||
@base_url "https://idp.example.com"
|
|
||||||
|
|
||||||
defp with_oidc_settings(fun) do
|
|
||||||
{:ok, settings} = Membership.get_settings()
|
|
||||||
|
|
||||||
prev = %{
|
|
||||||
oidc_client_id: settings.oidc_client_id,
|
|
||||||
oidc_base_url: settings.oidc_base_url,
|
|
||||||
oidc_redirect_uri: settings.oidc_redirect_uri,
|
|
||||||
oidc_client_secret: settings.oidc_client_secret
|
|
||||||
}
|
|
||||||
|
|
||||||
{:ok, _} =
|
|
||||||
Membership.update_settings(settings, %{
|
|
||||||
oidc_client_id: "test-client",
|
|
||||||
oidc_base_url: @base_url,
|
|
||||||
oidc_redirect_uri: "http://localhost:4000/auth/user/oidc/callback",
|
|
||||||
oidc_client_secret: "test-secret"
|
|
||||||
})
|
|
||||||
|
|
||||||
try do
|
|
||||||
fun.()
|
|
||||||
after
|
|
||||||
Mv.Oidc.Discovery.clear_cache()
|
|
||||||
{:ok, s} = Membership.get_settings()
|
|
||||||
Membership.update_settings(s, prev)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
test "redirects to end_session_endpoint when discovery succeeds", %{
|
|
||||||
conn: authenticated_conn
|
|
||||||
} do
|
|
||||||
with_oidc_settings(fn ->
|
|
||||||
end_session_url = "https://idp.example.com/end-session"
|
|
||||||
|
|
||||||
Mv.Oidc.Discovery.put_cache(
|
|
||||||
@base_url,
|
|
||||||
{:ok, %{"end_session_endpoint" => end_session_url}}
|
|
||||||
)
|
|
||||||
|
|
||||||
conn =
|
|
||||||
authenticated_conn
|
|
||||||
|> conn_with_oidc_user()
|
|
||||||
|> delete(~p"/sign-out")
|
|
||||||
|
|
||||||
assert redirected_to(conn, 302) == end_session_url
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "falls back to /sign-in?oidc_failed=1 when discovery fails", %{
|
|
||||||
conn: authenticated_conn
|
|
||||||
} do
|
|
||||||
with_oidc_settings(fn ->
|
|
||||||
Mv.Oidc.Discovery.put_cache(@base_url, {:error, :test_failure})
|
|
||||||
|
|
||||||
conn =
|
|
||||||
authenticated_conn
|
|
||||||
|> conn_with_oidc_user()
|
|
||||||
|> delete(~p"/sign-out")
|
|
||||||
|
|
||||||
assert redirected_to(conn) == "/sign-in?oidc_failed=1"
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "falls back to /sign-in?oidc_failed=1 when end_session_endpoint is missing", %{
|
|
||||||
conn: authenticated_conn
|
|
||||||
} do
|
|
||||||
with_oidc_settings(fn ->
|
|
||||||
Mv.Oidc.Discovery.put_cache(@base_url, {:ok, %{"issuer" => @base_url}})
|
|
||||||
|
|
||||||
conn =
|
|
||||||
authenticated_conn
|
|
||||||
|> conn_with_oidc_user()
|
|
||||||
|> delete(~p"/sign-out")
|
|
||||||
|
|
||||||
assert redirected_to(conn) == "/sign-in?oidc_failed=1"
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp csrf_token_from_sign_out_form(html) when is_binary(html) do
|
defp csrf_token_from_sign_out_form(html) when is_binary(html) do
|
||||||
case Regex.run(~r/name="_csrf_token"[^>]*value="([^"]+)"/, html) do
|
case Regex.run(~r/name="_csrf_token"[^>]*value="([^"]+)"/, html) do
|
||||||
[_, token] ->
|
[_, token] ->
|
||||||
|
|
|
||||||
|
|
@ -1,319 +0,0 @@
|
||||||
defmodule MvWeb.MemberLive.Index.DateFilterCustomFieldTest do
|
|
||||||
@moduledoc """
|
|
||||||
Unit tests for `DateFilter.apply_in_memory/3` — the post-`Ash.read!`
|
|
||||||
predicate that filters members by custom date field values stored as
|
|
||||||
JSONB `Ash.Union` types in `custom_field_values`.
|
|
||||||
|
|
||||||
Integration coverage against a real database lives in the second module
|
|
||||||
in this file (DateFilterCustomFieldIntegrationTest).
|
|
||||||
"""
|
|
||||||
use ExUnit.Case, async: true
|
|
||||||
|
|
||||||
alias MvWeb.MemberLive.Index.DateFilter
|
|
||||||
|
|
||||||
# ---- helpers ---------------------------------------------------------
|
|
||||||
|
|
||||||
defp date_custom_field(id, name \\ "Birthday") do
|
|
||||||
%{id: id, value_type: :date, name: name}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp date_cfv(custom_field_id, %Date{} = date) do
|
|
||||||
%{
|
|
||||||
custom_field_id: custom_field_id,
|
|
||||||
value: %Ash.Union{value: date, type: :date}
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp member_with_dates(id, custom_field_values) do
|
|
||||||
%{id: id, custom_field_values: custom_field_values}
|
|
||||||
end
|
|
||||||
|
|
||||||
# ---- no-op cases -----------------------------------------------------
|
|
||||||
|
|
||||||
describe "apply_in_memory/3 — no-op cases" do
|
|
||||||
test "returns members unchanged when filters has no custom date entries" do
|
|
||||||
filters = DateFilter.default()
|
|
||||||
members = [member_with_dates("m1", []), member_with_dates("m2", [])]
|
|
||||||
assert DateFilter.apply_in_memory(members, filters, []) == members
|
|
||||||
end
|
|
||||||
|
|
||||||
test "ignores custom date entries whose UUID is not in the date_custom_fields list" do
|
|
||||||
id = "11111111-2222-3333-4444-555555555555"
|
|
||||||
other_id = "99999999-8888-7777-6666-555555555555"
|
|
||||||
|
|
||||||
filters =
|
|
||||||
DateFilter.default()
|
|
||||||
|> Map.put(other_id, %{from: ~D[2024-01-01], to: nil})
|
|
||||||
|
|
||||||
m = member_with_dates("m1", [date_cfv(id, ~D[2023-01-01])])
|
|
||||||
assert DateFilter.apply_in_memory([m], filters, [date_custom_field(id)]) == [m]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "entry with both bounds nil is treated as inactive" do
|
|
||||||
id = "11111111-2222-3333-4444-555555555555"
|
|
||||||
|
|
||||||
filters =
|
|
||||||
DateFilter.default()
|
|
||||||
|> Map.put(id, %{from: nil, to: nil})
|
|
||||||
|
|
||||||
m = member_with_dates("m1", [date_cfv(id, ~D[2023-01-01])])
|
|
||||||
assert DateFilter.apply_in_memory([m], filters, [date_custom_field(id)]) == [m]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# ---- inclusive range semantics --------------------------------------
|
|
||||||
|
|
||||||
describe "apply_in_memory/3 — inclusive range semantics" do
|
|
||||||
setup do
|
|
||||||
id = "11111111-2222-3333-4444-555555555555"
|
|
||||||
|
|
||||||
members = [
|
|
||||||
member_with_dates("before", [date_cfv(id, ~D[2024-05-31])]),
|
|
||||||
member_with_dates("from_boundary", [date_cfv(id, ~D[2024-06-01])]),
|
|
||||||
member_with_dates("inside", [date_cfv(id, ~D[2024-06-15])]),
|
|
||||||
member_with_dates("to_boundary", [date_cfv(id, ~D[2024-06-30])]),
|
|
||||||
member_with_dates("after", [date_cfv(id, ~D[2024-07-01])])
|
|
||||||
]
|
|
||||||
|
|
||||||
%{id: id, members: members, fields: [date_custom_field(id)]}
|
|
||||||
end
|
|
||||||
|
|
||||||
test "from-only includes member when value >= from (boundary inclusive)", ctx do
|
|
||||||
filters =
|
|
||||||
DateFilter.default()
|
|
||||||
|> Map.put(ctx.id, %{from: ~D[2024-06-01], to: nil})
|
|
||||||
|
|
||||||
ids =
|
|
||||||
ctx.members
|
|
||||||
|> DateFilter.apply_in_memory(filters, ctx.fields)
|
|
||||||
|> Enum.map(& &1.id)
|
|
||||||
|
|
||||||
assert ids == ["from_boundary", "inside", "to_boundary", "after"]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "from-only excludes member when value < from", ctx do
|
|
||||||
filters =
|
|
||||||
DateFilter.default()
|
|
||||||
|> Map.put(ctx.id, %{from: ~D[2024-06-01], to: nil})
|
|
||||||
|
|
||||||
refute Enum.any?(
|
|
||||||
DateFilter.apply_in_memory(ctx.members, filters, ctx.fields),
|
|
||||||
&(&1.id == "before")
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "to-only includes member when value <= to (boundary inclusive)", ctx do
|
|
||||||
filters =
|
|
||||||
DateFilter.default()
|
|
||||||
|> Map.put(ctx.id, %{from: nil, to: ~D[2024-06-30]})
|
|
||||||
|
|
||||||
ids =
|
|
||||||
ctx.members
|
|
||||||
|> DateFilter.apply_in_memory(filters, ctx.fields)
|
|
||||||
|> Enum.map(& &1.id)
|
|
||||||
|
|
||||||
assert ids == ["before", "from_boundary", "inside", "to_boundary"]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "from+to applies an inclusive range", ctx do
|
|
||||||
filters =
|
|
||||||
DateFilter.default()
|
|
||||||
|> Map.put(ctx.id, %{from: ~D[2024-06-01], to: ~D[2024-06-30]})
|
|
||||||
|
|
||||||
ids =
|
|
||||||
ctx.members
|
|
||||||
|> DateFilter.apply_in_memory(filters, ctx.fields)
|
|
||||||
|> Enum.map(& &1.id)
|
|
||||||
|
|
||||||
assert ids == ["from_boundary", "inside", "to_boundary"]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# ---- exclusion of members without a value ---------------------------
|
|
||||||
|
|
||||||
describe "apply_in_memory/3 — members without a value" do
|
|
||||||
test "excludes member with no custom_field_values when bound is active" do
|
|
||||||
id = "11111111-2222-3333-4444-555555555555"
|
|
||||||
|
|
||||||
filters =
|
|
||||||
DateFilter.default()
|
|
||||||
|> Map.put(id, %{from: ~D[2024-01-01], to: nil})
|
|
||||||
|
|
||||||
members = [
|
|
||||||
member_with_dates("present", [date_cfv(id, ~D[2024-06-01])]),
|
|
||||||
member_with_dates("nil_list", nil),
|
|
||||||
member_with_dates("empty_list", []),
|
|
||||||
# member missing the specific field but having other CFVs:
|
|
||||||
member_with_dates("other_field", [
|
|
||||||
date_cfv("other-aaaa-bbbb-cccc-dddddddddddd", ~D[2024-06-01])
|
|
||||||
])
|
|
||||||
]
|
|
||||||
|
|
||||||
ids =
|
|
||||||
members
|
|
||||||
|> DateFilter.apply_in_memory(filters, [date_custom_field(id)])
|
|
||||||
|> Enum.map(& &1.id)
|
|
||||||
|
|
||||||
assert ids == ["present"]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "treats Ash.NotLoaded custom_field_values as no value (excluded)" do
|
|
||||||
id = "11111111-2222-3333-4444-555555555555"
|
|
||||||
|
|
||||||
filters =
|
|
||||||
DateFilter.default()
|
|
||||||
|> Map.put(id, %{from: ~D[2024-01-01], to: nil})
|
|
||||||
|
|
||||||
m = %{id: "m1", custom_field_values: %Ash.NotLoaded{}}
|
|
||||||
assert DateFilter.apply_in_memory([m], filters, [date_custom_field(id)]) == []
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# ---- Ash.Union unwrapping -------------------------------------------
|
|
||||||
|
|
||||||
describe "apply_in_memory/3 — Ash.Union unwrapping" do
|
|
||||||
test "unwraps %Ash.Union{value: %Date{}, type: :date}" do
|
|
||||||
id = "11111111-2222-3333-4444-555555555555"
|
|
||||||
|
|
||||||
filters =
|
|
||||||
DateFilter.default()
|
|
||||||
|> Map.put(id, %{from: ~D[2024-06-01], to: nil})
|
|
||||||
|
|
||||||
m =
|
|
||||||
member_with_dates("m1", [
|
|
||||||
%{
|
|
||||||
custom_field_id: id,
|
|
||||||
value: %Ash.Union{value: ~D[2024-06-15], type: :date}
|
|
||||||
}
|
|
||||||
])
|
|
||||||
|
|
||||||
assert DateFilter.apply_in_memory([m], filters, [date_custom_field(id)]) == [m]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "rejects values whose Ash.Union type is not :date" do
|
|
||||||
id = "11111111-2222-3333-4444-555555555555"
|
|
||||||
|
|
||||||
filters =
|
|
||||||
DateFilter.default()
|
|
||||||
|> Map.put(id, %{from: ~D[2024-06-01], to: nil})
|
|
||||||
|
|
||||||
# A boolean-typed value for what the filter believes is a date field —
|
|
||||||
# treat as no value, exclude the member.
|
|
||||||
m =
|
|
||||||
member_with_dates("m1", [
|
|
||||||
%{
|
|
||||||
custom_field_id: id,
|
|
||||||
value: %Ash.Union{value: true, type: :boolean}
|
|
||||||
}
|
|
||||||
])
|
|
||||||
|
|
||||||
assert DateFilter.apply_in_memory([m], filters, [date_custom_field(id)]) == []
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defmodule MvWeb.MemberLive.Index.DateFilterCustomFieldIntegrationTest do
|
|
||||||
@moduledoc """
|
|
||||||
Integration tests for custom date field filtering on /members (§1.13, §1.14).
|
|
||||||
|
|
||||||
Creates a real `:date`-typed CustomField plus members with corresponding
|
|
||||||
CustomFieldValue rows, then asserts visibility through the LiveView with
|
|
||||||
`cdf_<uuid>_from` and `cdf_<uuid>_to` URL params.
|
|
||||||
"""
|
|
||||||
# async: false because we mutate global custom_fields and custom_field_values tables.
|
|
||||||
use MvWeb.ConnCase, async: false
|
|
||||||
import Phoenix.LiveViewTest
|
|
||||||
|
|
||||||
alias Mv.Membership.{CustomField, CustomFieldValue}
|
|
||||||
|
|
||||||
setup do
|
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
|
||||||
|
|
||||||
{:ok, field} =
|
|
||||||
CustomField
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
name: "Birthday-#{System.unique_integer([:positive])}",
|
|
||||||
value_type: :date,
|
|
||||||
show_in_overview: true
|
|
||||||
})
|
|
||||||
|> Ash.create(actor: system_actor)
|
|
||||||
|
|
||||||
{:ok, alice} =
|
|
||||||
Mv.Membership.create_member(
|
|
||||||
%{first_name: "Alice", last_name: "Anderson", email: "alice@example.com"},
|
|
||||||
actor: system_actor
|
|
||||||
)
|
|
||||||
|
|
||||||
{:ok, bob} =
|
|
||||||
Mv.Membership.create_member(
|
|
||||||
%{first_name: "Bob", last_name: "Brown", email: "bob@example.com"},
|
|
||||||
actor: system_actor
|
|
||||||
)
|
|
||||||
|
|
||||||
{:ok, carla} =
|
|
||||||
Mv.Membership.create_member(
|
|
||||||
%{first_name: "Carla", last_name: "Carter", email: "carla@example.com"},
|
|
||||||
actor: system_actor
|
|
||||||
)
|
|
||||||
|
|
||||||
{:ok, dan_no_value} =
|
|
||||||
Mv.Membership.create_member(
|
|
||||||
%{first_name: "Dan", last_name: "Dixon", email: "dan@example.com"},
|
|
||||||
actor: system_actor
|
|
||||||
)
|
|
||||||
|
|
||||||
create_cfv(system_actor, alice.id, field.id, ~D[2020-05-15])
|
|
||||||
create_cfv(system_actor, bob.id, field.id, ~D[2022-08-01])
|
|
||||||
create_cfv(system_actor, carla.id, field.id, ~D[2024-02-20])
|
|
||||||
|
|
||||||
%{
|
|
||||||
field: field,
|
|
||||||
alice: alice,
|
|
||||||
bob: bob,
|
|
||||||
carla: carla,
|
|
||||||
dan_no_value: dan_no_value
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp create_cfv(actor, member_id, custom_field_id, %Date{} = date) do
|
|
||||||
{:ok, cfv} =
|
|
||||||
CustomFieldValue
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
member_id: member_id,
|
|
||||||
custom_field_id: custom_field_id,
|
|
||||||
value: %{"_union_type" => "date", "_union_value" => Date.to_iso8601(date)}
|
|
||||||
})
|
|
||||||
|> Ash.create(actor: actor, domain: Mv.Membership)
|
|
||||||
|
|
||||||
cfv
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "custom date field URL filter" do
|
|
||||||
test "from-only includes members with value >= bound (§1.13)",
|
|
||||||
%{conn: conn, field: field, alice: alice, bob: bob, carla: carla} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, _view, html} = live(conn, "/members?cdf_#{field.id}_from=2022-01-01")
|
|
||||||
refute html =~ alice.first_name
|
|
||||||
assert html =~ bob.first_name
|
|
||||||
assert html =~ carla.first_name
|
|
||||||
end
|
|
||||||
|
|
||||||
test "from+to applies inclusive range (§1.14)",
|
|
||||||
%{conn: conn, field: field, alice: alice, bob: bob, carla: carla} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
|
|
||||||
url = "/members?cdf_#{field.id}_from=2022-01-01&cdf_#{field.id}_to=2023-12-31"
|
|
||||||
{:ok, _view, html} = live(conn, url)
|
|
||||||
refute html =~ alice.first_name
|
|
||||||
assert html =~ bob.first_name
|
|
||||||
refute html =~ carla.first_name
|
|
||||||
end
|
|
||||||
|
|
||||||
test "excludes member with no value for the active custom date field (§1.13)",
|
|
||||||
%{conn: conn, field: field, dan_no_value: dan} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, _view, html} = live(conn, "/members?cdf_#{field.id}_from=2000-01-01")
|
|
||||||
refute html =~ dan.first_name
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,144 +0,0 @@
|
||||||
defmodule MvWeb.MemberLive.Index.DateFilterDefaultTest do
|
|
||||||
@moduledoc """
|
|
||||||
Unit tests for DateFilter.default/0 — the initial filter map used when
|
|
||||||
no URL params are present (fresh load) and after "Clear filters".
|
|
||||||
"""
|
|
||||||
use ExUnit.Case, async: true
|
|
||||||
|
|
||||||
alias MvWeb.MemberLive.Index.DateFilter
|
|
||||||
|
|
||||||
describe "default/0" do
|
|
||||||
test "returns :active_only mode for exit_date with nil bounds" do
|
|
||||||
assert %{exit_date: %{mode: :active_only, from: nil, to: nil}} = DateFilter.default()
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns nil bounds for join_date" do
|
|
||||||
assert %{join_date: %{from: nil, to: nil}} = DateFilter.default()
|
|
||||||
end
|
|
||||||
|
|
||||||
test "contains only :join_date and :exit_date top-level keys" do
|
|
||||||
defaults = DateFilter.default()
|
|
||||||
assert Map.keys(defaults) |> Enum.sort() == [:exit_date, :join_date]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defmodule MvWeb.MemberLive.Index.DateFilterDefaultIntegrationTest do
|
|
||||||
@moduledoc """
|
|
||||||
Integration tests for the default exit_date filter behavior on the member
|
|
||||||
overview page (§1.1, §1.2, §1.3, §1.4, §1.6 in the issue specs).
|
|
||||||
|
|
||||||
These exercise the full `mount/3` → `handle_params/3` → `load_members/1`
|
|
||||||
pipeline against a real database, asserting that the active-only default
|
|
||||||
is applied to a fresh page load and overridden when the URL says so.
|
|
||||||
"""
|
|
||||||
# async: false because we mutate the global member table.
|
|
||||||
use MvWeb.ConnCase, async: false
|
|
||||||
import Phoenix.LiveViewTest
|
|
||||||
|
|
||||||
setup do
|
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
|
||||||
today = Date.utc_today()
|
|
||||||
|
|
||||||
{:ok, active_no_exit} =
|
|
||||||
Mv.Membership.create_member(
|
|
||||||
%{first_name: "Anna", last_name: "Active", email: "anna@example.com"},
|
|
||||||
actor: system_actor
|
|
||||||
)
|
|
||||||
|
|
||||||
{:ok, future_exit} =
|
|
||||||
Mv.Membership.create_member(
|
|
||||||
%{
|
|
||||||
first_name: "Felix",
|
|
||||||
last_name: "Future",
|
|
||||||
email: "felix@example.com",
|
|
||||||
join_date: Date.add(today, -365),
|
|
||||||
exit_date: Date.add(today, 30)
|
|
||||||
},
|
|
||||||
actor: system_actor
|
|
||||||
)
|
|
||||||
|
|
||||||
{:ok, exit_today} =
|
|
||||||
Mv.Membership.create_member(
|
|
||||||
%{
|
|
||||||
first_name: "Tina",
|
|
||||||
last_name: "Today",
|
|
||||||
email: "tina@example.com",
|
|
||||||
join_date: Date.add(today, -365),
|
|
||||||
exit_date: today
|
|
||||||
},
|
|
||||||
actor: system_actor
|
|
||||||
)
|
|
||||||
|
|
||||||
{:ok, past_exit} =
|
|
||||||
Mv.Membership.create_member(
|
|
||||||
%{
|
|
||||||
first_name: "Paula",
|
|
||||||
last_name: "Past",
|
|
||||||
email: "paula@example.com",
|
|
||||||
join_date: Date.add(today, -365),
|
|
||||||
exit_date: Date.add(today, -1)
|
|
||||||
},
|
|
||||||
actor: system_actor
|
|
||||||
)
|
|
||||||
|
|
||||||
%{
|
|
||||||
active_no_exit: active_no_exit,
|
|
||||||
future_exit: future_exit,
|
|
||||||
exit_today: exit_today,
|
|
||||||
past_exit: past_exit
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "fresh load — no URL params" do
|
|
||||||
test "hides member with exit_date strictly before today (§1.1)", %{
|
|
||||||
conn: conn,
|
|
||||||
past_exit: past
|
|
||||||
} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, _view, html} = live(conn, "/members")
|
|
||||||
refute html =~ past.first_name
|
|
||||||
end
|
|
||||||
|
|
||||||
test "hides member whose exit_date equals today (§1.4)", %{
|
|
||||||
conn: conn,
|
|
||||||
exit_today: exit_today
|
|
||||||
} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, _view, html} = live(conn, "/members")
|
|
||||||
refute html =~ exit_today.first_name
|
|
||||||
end
|
|
||||||
|
|
||||||
test "shows member with no exit_date (§1.2)", %{conn: conn, active_no_exit: anna} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, _view, html} = live(conn, "/members")
|
|
||||||
assert html =~ anna.first_name
|
|
||||||
end
|
|
||||||
|
|
||||||
test "shows member with exit_date strictly in the future (§1.3)", %{
|
|
||||||
conn: conn,
|
|
||||||
future_exit: felix
|
|
||||||
} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, _view, html} = live(conn, "/members")
|
|
||||||
assert html =~ felix.first_name
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "URL with ed_mode=all overrides the default (§1.6)" do
|
|
||||||
test "shows former members when URL contains ed_mode=all", %{
|
|
||||||
conn: conn,
|
|
||||||
past_exit: past,
|
|
||||||
exit_today: today,
|
|
||||||
active_no_exit: anna,
|
|
||||||
future_exit: felix
|
|
||||||
} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, _view, html} = live(conn, "/members?ed_mode=all")
|
|
||||||
assert html =~ past.first_name
|
|
||||||
assert html =~ today.first_name
|
|
||||||
assert html =~ anna.first_name
|
|
||||||
assert html =~ felix.first_name
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,137 +0,0 @@
|
||||||
defmodule MvWeb.MemberLive.Index.DateFilterPropertyTest do
|
|
||||||
@moduledoc """
|
|
||||||
Property tests for the pure functions on `DateFilter`:
|
|
||||||
|
|
||||||
* `to_params/1` ∘ `from_params/2` must be the identity for all valid
|
|
||||||
built-in date filter states (§2.3).
|
|
||||||
* `apply_in_memory/3` matches the inclusive range predicate
|
|
||||||
`(from == nil or value >= from) and (to == nil or value <= to)`
|
|
||||||
for any custom date field value and bound pair (§2.4).
|
|
||||||
|
|
||||||
Custom date field round-trip is not part of the URL codec property because
|
|
||||||
`from_params/2` needs the caller-supplied `date_custom_fields` list to
|
|
||||||
validate UUIDs; that interaction is covered by example tests in
|
|
||||||
`MvWeb.MemberLive.Index.DateFilterTest`.
|
|
||||||
"""
|
|
||||||
use ExUnit.Case, async: true
|
|
||||||
use ExUnitProperties
|
|
||||||
|
|
||||||
alias MvWeb.MemberLive.Index.DateFilter
|
|
||||||
|
|
||||||
# Generators -----------------------------------------------------------
|
|
||||||
|
|
||||||
defp optional_date_gen do
|
|
||||||
one_of([
|
|
||||||
constant(nil),
|
|
||||||
map(integer(-3650..3650), &Date.add(~D[2000-01-01], &1))
|
|
||||||
])
|
|
||||||
end
|
|
||||||
|
|
||||||
defp exit_date_mode_gen do
|
|
||||||
one_of([
|
|
||||||
constant(:active_only),
|
|
||||||
constant(:all),
|
|
||||||
constant(:inactive_only),
|
|
||||||
constant(:custom)
|
|
||||||
])
|
|
||||||
end
|
|
||||||
|
|
||||||
defp exit_date_state_gen do
|
|
||||||
gen all(
|
|
||||||
mode <- exit_date_mode_gen(),
|
|
||||||
from <- optional_date_gen(),
|
|
||||||
to <- optional_date_gen()
|
|
||||||
) do
|
|
||||||
%{mode: mode, from: from, to: to}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp join_date_state_gen do
|
|
||||||
gen all(
|
|
||||||
from <- optional_date_gen(),
|
|
||||||
to <- optional_date_gen()
|
|
||||||
) do
|
|
||||||
%{from: from, to: to}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Property -------------------------------------------------------------
|
|
||||||
|
|
||||||
defp bound_pair_with_at_least_one_set_gen do
|
|
||||||
gen all(
|
|
||||||
from <- optional_date_gen(),
|
|
||||||
to <- optional_date_gen(),
|
|
||||||
from != nil or to != nil
|
|
||||||
) do
|
|
||||||
{from, to}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp value_date_gen do
|
|
||||||
map(integer(-3650..3650), &Date.add(~D[2000-01-01], &1))
|
|
||||||
end
|
|
||||||
|
|
||||||
# Property -------------------------------------------------------------
|
|
||||||
|
|
||||||
property "in-memory date filter matches the inclusive range predicate" do
|
|
||||||
id = "11111111-2222-3333-4444-555555555555"
|
|
||||||
field = %{id: id, value_type: :date, name: "Property field"}
|
|
||||||
|
|
||||||
check all(
|
|
||||||
value <- value_date_gen(),
|
|
||||||
{from, to} <- bound_pair_with_at_least_one_set_gen()
|
|
||||||
) do
|
|
||||||
filters =
|
|
||||||
DateFilter.default()
|
|
||||||
|> Map.put(id, %{from: from, to: to})
|
|
||||||
|
|
||||||
member = %{
|
|
||||||
id: "m1",
|
|
||||||
custom_field_values: [
|
|
||||||
%{
|
|
||||||
custom_field_id: id,
|
|
||||||
value: %Ash.Union{value: value, type: :date}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
result = DateFilter.apply_in_memory([member], filters, [field])
|
|
||||||
|
|
||||||
from_ok? = is_nil(from) or Date.compare(value, from) != :lt
|
|
||||||
to_ok? = is_nil(to) or Date.compare(value, to) != :gt
|
|
||||||
expected_included? = from_ok? and to_ok?
|
|
||||||
actually_included? = result == [member]
|
|
||||||
|
|
||||||
assert actually_included? == expected_included?
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
property "encoding then decoding built-in date filter state is identity" do
|
|
||||||
check all(
|
|
||||||
join_date <- join_date_state_gen(),
|
|
||||||
exit_date <- exit_date_state_gen()
|
|
||||||
) do
|
|
||||||
filters = %{join_date: join_date, exit_date: exit_date}
|
|
||||||
|
|
||||||
decoded = DateFilter.from_params(DateFilter.to_params(filters), [])
|
|
||||||
|
|
||||||
# join_date round-trips verbatim.
|
|
||||||
assert decoded.join_date == join_date
|
|
||||||
|
|
||||||
# exit_date semantics:
|
|
||||||
# * :active_only is the default and discards bounds — the canonical
|
|
||||||
# URL omits them, so decoding restores nil bounds.
|
|
||||||
# * :all and :inactive_only also drop bounds in the URL — same reason.
|
|
||||||
# * :custom preserves bounds.
|
|
||||||
expected_exit_date =
|
|
||||||
case exit_date.mode do
|
|
||||||
:active_only -> %{mode: :active_only, from: nil, to: nil}
|
|
||||||
:all -> %{mode: :all, from: nil, to: nil}
|
|
||||||
:inactive_only -> %{mode: :inactive_only, from: nil, to: nil}
|
|
||||||
:custom -> exit_date
|
|
||||||
end
|
|
||||||
|
|
||||||
assert decoded.exit_date == expected_exit_date
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,628 +0,0 @@
|
||||||
defmodule MvWeb.MemberLive.Index.DateFilterTest do
|
|
||||||
@moduledoc """
|
|
||||||
Unit tests for DateFilter URL codec and pure helpers.
|
|
||||||
DB-level filtering against real members is covered by the integration
|
|
||||||
module below in this file.
|
|
||||||
"""
|
|
||||||
use ExUnit.Case, async: true
|
|
||||||
|
|
||||||
alias MvWeb.MemberLive.Index.DateFilter
|
|
||||||
|
|
||||||
# Synthesize the minimal shape of a date-typed custom field expected by
|
|
||||||
# from_params/2. Only :id and :value_type are inspected by the decoder.
|
|
||||||
defp date_custom_field(id) do
|
|
||||||
%{id: id, value_type: :date, name: "Birthday-#{id}"}
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "from_params/2 — built-in date fields" do
|
|
||||||
test "parses jd_from as a Date" do
|
|
||||||
params = %{"jd_from" => "2024-05-01"}
|
|
||||||
filters = DateFilter.from_params(params, [])
|
|
||||||
assert filters.join_date.from == ~D[2024-05-01]
|
|
||||||
assert filters.join_date.to == nil
|
|
||||||
end
|
|
||||||
|
|
||||||
test "parses jd_to as a Date" do
|
|
||||||
params = %{"jd_to" => "2024-08-31"}
|
|
||||||
filters = DateFilter.from_params(params, [])
|
|
||||||
assert filters.join_date.to == ~D[2024-08-31]
|
|
||||||
assert filters.join_date.from == nil
|
|
||||||
end
|
|
||||||
|
|
||||||
test "ignores malformed jd_from string" do
|
|
||||||
params = %{"jd_from" => "notadate"}
|
|
||||||
filters = DateFilter.from_params(params, [])
|
|
||||||
assert filters.join_date.from == nil
|
|
||||||
assert filters.join_date.to == nil
|
|
||||||
end
|
|
||||||
|
|
||||||
test "ignores malformed jd_to string" do
|
|
||||||
params = %{"jd_to" => "2024-13-45"}
|
|
||||||
filters = DateFilter.from_params(params, [])
|
|
||||||
assert filters.join_date.to == nil
|
|
||||||
end
|
|
||||||
|
|
||||||
test "parses ed_mode=all" do
|
|
||||||
params = %{"ed_mode" => "all"}
|
|
||||||
filters = DateFilter.from_params(params, [])
|
|
||||||
assert filters.exit_date.mode == :all
|
|
||||||
end
|
|
||||||
|
|
||||||
test "parses ed_mode=inactive_only" do
|
|
||||||
params = %{"ed_mode" => "inactive_only"}
|
|
||||||
filters = DateFilter.from_params(params, [])
|
|
||||||
assert filters.exit_date.mode == :inactive_only
|
|
||||||
end
|
|
||||||
|
|
||||||
test "parses ed_mode=custom with bounds" do
|
|
||||||
params = %{"ed_mode" => "custom", "ed_from" => "2024-01-01", "ed_to" => "2024-12-31"}
|
|
||||||
filters = DateFilter.from_params(params, [])
|
|
||||||
assert filters.exit_date.mode == :custom
|
|
||||||
assert filters.exit_date.from == ~D[2024-01-01]
|
|
||||||
assert filters.exit_date.to == ~D[2024-12-31]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns :active_only mode when ed_mode is absent" do
|
|
||||||
filters = DateFilter.from_params(%{}, [])
|
|
||||||
assert filters.exit_date.mode == :active_only
|
|
||||||
end
|
|
||||||
|
|
||||||
test "treats unknown ed_mode value as :active_only" do
|
|
||||||
params = %{"ed_mode" => "gibberish"}
|
|
||||||
filters = DateFilter.from_params(params, [])
|
|
||||||
assert filters.exit_date.mode == :active_only
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "to_params/1 — built-in date fields" do
|
|
||||||
test "omits ed_mode when mode is :active_only (default)" do
|
|
||||||
params = DateFilter.to_params(DateFilter.default())
|
|
||||||
refute Map.has_key?(params, "ed_mode")
|
|
||||||
refute Map.has_key?(params, "ed_from")
|
|
||||||
refute Map.has_key?(params, "ed_to")
|
|
||||||
refute Map.has_key?(params, "jd_from")
|
|
||||||
refute Map.has_key?(params, "jd_to")
|
|
||||||
end
|
|
||||||
|
|
||||||
test "encodes ed_mode=all" do
|
|
||||||
filters = %{
|
|
||||||
join_date: %{from: nil, to: nil},
|
|
||||||
exit_date: %{mode: :all, from: nil, to: nil}
|
|
||||||
}
|
|
||||||
|
|
||||||
params = DateFilter.to_params(filters)
|
|
||||||
assert params["ed_mode"] == "all"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "encodes ed_mode=inactive_only" do
|
|
||||||
filters = %{
|
|
||||||
join_date: %{from: nil, to: nil},
|
|
||||||
exit_date: %{mode: :inactive_only, from: nil, to: nil}
|
|
||||||
}
|
|
||||||
|
|
||||||
params = DateFilter.to_params(filters)
|
|
||||||
assert params["ed_mode"] == "inactive_only"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "encodes ed_mode=custom with bounds" do
|
|
||||||
filters = %{
|
|
||||||
join_date: %{from: nil, to: nil},
|
|
||||||
exit_date: %{mode: :custom, from: ~D[2024-01-01], to: ~D[2024-12-31]}
|
|
||||||
}
|
|
||||||
|
|
||||||
params = DateFilter.to_params(filters)
|
|
||||||
assert params["ed_mode"] == "custom"
|
|
||||||
assert params["ed_from"] == "2024-01-01"
|
|
||||||
assert params["ed_to"] == "2024-12-31"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "encodes jd_from as ISO-8601 string" do
|
|
||||||
filters = %{
|
|
||||||
join_date: %{from: ~D[2024-05-01], to: nil},
|
|
||||||
exit_date: %{mode: :active_only, from: nil, to: nil}
|
|
||||||
}
|
|
||||||
|
|
||||||
params = DateFilter.to_params(filters)
|
|
||||||
assert params["jd_from"] == "2024-05-01"
|
|
||||||
refute Map.has_key?(params, "jd_to")
|
|
||||||
end
|
|
||||||
|
|
||||||
test "encodes jd_to as ISO-8601 string" do
|
|
||||||
filters = %{
|
|
||||||
join_date: %{from: nil, to: ~D[2024-08-31]},
|
|
||||||
exit_date: %{mode: :active_only, from: nil, to: nil}
|
|
||||||
}
|
|
||||||
|
|
||||||
params = DateFilter.to_params(filters)
|
|
||||||
assert params["jd_to"] == "2024-08-31"
|
|
||||||
refute Map.has_key?(params, "jd_from")
|
|
||||||
end
|
|
||||||
|
|
||||||
test "omits exit_date bounds when mode is not :custom" do
|
|
||||||
# Bounds may linger in state for UX (preserve user's last input) but the
|
|
||||||
# URL should not advertise them while a non-custom mode is active.
|
|
||||||
filters = %{
|
|
||||||
join_date: %{from: nil, to: nil},
|
|
||||||
exit_date: %{mode: :all, from: ~D[2024-01-01], to: ~D[2024-12-31]}
|
|
||||||
}
|
|
||||||
|
|
||||||
params = DateFilter.to_params(filters)
|
|
||||||
assert params["ed_mode"] == "all"
|
|
||||||
refute Map.has_key?(params, "ed_from")
|
|
||||||
refute Map.has_key?(params, "ed_to")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "to_params/1 — custom date field entries" do
|
|
||||||
test "encodes from/to bounds with cdf_<uuid>_ prefix" do
|
|
||||||
id = "11111111-2222-3333-4444-555555555555"
|
|
||||||
|
|
||||||
filters =
|
|
||||||
DateFilter.default()
|
|
||||||
|> Map.put(id, %{from: ~D[2024-06-01], to: ~D[2024-06-30]})
|
|
||||||
|
|
||||||
params = DateFilter.to_params(filters)
|
|
||||||
assert params["cdf_#{id}_from"] == "2024-06-01"
|
|
||||||
assert params["cdf_#{id}_to"] == "2024-06-30"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "omits nil bounds for custom date field entries" do
|
|
||||||
id = "11111111-2222-3333-4444-555555555555"
|
|
||||||
|
|
||||||
filters =
|
|
||||||
DateFilter.default()
|
|
||||||
|> Map.put(id, %{from: ~D[2024-06-01], to: nil})
|
|
||||||
|
|
||||||
params = DateFilter.to_params(filters)
|
|
||||||
assert params["cdf_#{id}_from"] == "2024-06-01"
|
|
||||||
refute Map.has_key?(params, "cdf_#{id}_to")
|
|
||||||
end
|
|
||||||
|
|
||||||
test "omits custom date field entry entirely when both bounds are nil" do
|
|
||||||
id = "11111111-2222-3333-4444-555555555555"
|
|
||||||
|
|
||||||
filters =
|
|
||||||
DateFilter.default()
|
|
||||||
|> Map.put(id, %{from: nil, to: nil})
|
|
||||||
|
|
||||||
params = DateFilter.to_params(filters)
|
|
||||||
refute Map.has_key?(params, "cdf_#{id}_from")
|
|
||||||
refute Map.has_key?(params, "cdf_#{id}_to")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "apply_ash_filter/2 — shape contract" do
|
|
||||||
# The behavioral correctness of apply_ash_filter/2 is verified in the
|
|
||||||
# integration tests (date_filter_default_test, date_filter_test). Here we
|
|
||||||
# only assert the shape contract: which inputs leave the query untouched,
|
|
||||||
# and which add a filter expression. Inspecting Ash internals is brittle —
|
|
||||||
# we only check `query.filter` is or is not nil.
|
|
||||||
|
|
||||||
alias Mv.Membership.Member, as: MemberResource
|
|
||||||
|
|
||||||
# The production caller (`MvWeb.MemberLive.Index.load_members/1`) hands
|
|
||||||
# `apply_ash_filter/2` an already-built `%Ash.Query{}`, matching the
|
|
||||||
# convention of the sibling `apply_*_filters` helpers. The shape contract
|
|
||||||
# tests mirror that convention so they exercise the exact call shape used
|
|
||||||
# in production.
|
|
||||||
defp base_query, do: Ash.Query.new(MemberResource)
|
|
||||||
|
|
||||||
test ":all mode and empty join_date is a no-op" do
|
|
||||||
filters = %{
|
|
||||||
join_date: %{from: nil, to: nil},
|
|
||||||
exit_date: %{mode: :all, from: nil, to: nil}
|
|
||||||
}
|
|
||||||
|
|
||||||
query = DateFilter.apply_ash_filter(base_query(), filters)
|
|
||||||
# No filter was applied — Ash leaves filter as nil when nothing is added.
|
|
||||||
assert is_nil(query.filter)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "default (:active_only) adds an exit_date filter" do
|
|
||||||
query = DateFilter.apply_ash_filter(base_query(), DateFilter.default())
|
|
||||||
refute is_nil(query.filter)
|
|
||||||
end
|
|
||||||
|
|
||||||
test ":inactive_only adds an exit_date filter" do
|
|
||||||
filters = %{
|
|
||||||
join_date: %{from: nil, to: nil},
|
|
||||||
exit_date: %{mode: :inactive_only, from: nil, to: nil}
|
|
||||||
}
|
|
||||||
|
|
||||||
query = DateFilter.apply_ash_filter(base_query(), filters)
|
|
||||||
refute is_nil(query.filter)
|
|
||||||
end
|
|
||||||
|
|
||||||
test ":custom mode with bounds adds an exit_date filter" do
|
|
||||||
filters = %{
|
|
||||||
join_date: %{from: nil, to: nil},
|
|
||||||
exit_date: %{mode: :custom, from: ~D[2024-01-01], to: ~D[2024-12-31]}
|
|
||||||
}
|
|
||||||
|
|
||||||
query = DateFilter.apply_ash_filter(base_query(), filters)
|
|
||||||
refute is_nil(query.filter)
|
|
||||||
end
|
|
||||||
|
|
||||||
test ":custom mode with both bounds nil adds no filter" do
|
|
||||||
filters = %{
|
|
||||||
join_date: %{from: nil, to: nil},
|
|
||||||
exit_date: %{mode: :custom, from: nil, to: nil}
|
|
||||||
}
|
|
||||||
|
|
||||||
query = DateFilter.apply_ash_filter(base_query(), filters)
|
|
||||||
assert is_nil(query.filter)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "join_date from adds a filter" do
|
|
||||||
filters = %{
|
|
||||||
join_date: %{from: ~D[2024-01-01], to: nil},
|
|
||||||
exit_date: %{mode: :all, from: nil, to: nil}
|
|
||||||
}
|
|
||||||
|
|
||||||
query = DateFilter.apply_ash_filter(base_query(), filters)
|
|
||||||
refute is_nil(query.filter)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "join_date to adds a filter" do
|
|
||||||
filters = %{
|
|
||||||
join_date: %{from: nil, to: ~D[2024-12-31]},
|
|
||||||
exit_date: %{mode: :all, from: nil, to: nil}
|
|
||||||
}
|
|
||||||
|
|
||||||
query = DateFilter.apply_ash_filter(base_query(), filters)
|
|
||||||
refute is_nil(query.filter)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "raises FunctionClauseError when caller passes a bare resource module" do
|
|
||||||
# The function now requires `%Ash.Query{}` — the production convention
|
|
||||||
# used by every sibling `apply_*_filters` helper in
|
|
||||||
# `MvWeb.MemberLive.Index`. A bare resource module is no longer accepted.
|
|
||||||
filters = %{
|
|
||||||
join_date: %{from: nil, to: nil},
|
|
||||||
exit_date: %{mode: :all, from: nil, to: nil}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Indirect through a variable so the compiler's static type analysis
|
|
||||||
# does not flag the deliberately invalid call shape we want to assert on.
|
|
||||||
bare_resource = Function.identity(MemberResource)
|
|
||||||
|
|
||||||
assert_raise FunctionClauseError, fn ->
|
|
||||||
DateFilter.apply_ash_filter(bare_resource, filters)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
test "join_date map missing :to key still applies the from bound" do
|
|
||||||
# A caller-supplied map can omit one bound key entirely (not just set it
|
|
||||||
# to nil). The Ash filter must still be applied for the bound that is
|
|
||||||
# present.
|
|
||||||
filters = %{
|
|
||||||
join_date: %{from: ~D[2024-01-01]},
|
|
||||||
exit_date: %{mode: :all, from: nil, to: nil}
|
|
||||||
}
|
|
||||||
|
|
||||||
query = DateFilter.apply_ash_filter(base_query(), filters)
|
|
||||||
refute is_nil(query.filter)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "join_date map missing :from key still applies the to bound" do
|
|
||||||
filters = %{
|
|
||||||
join_date: %{to: ~D[2024-12-31]},
|
|
||||||
exit_date: %{mode: :all, from: nil, to: nil}
|
|
||||||
}
|
|
||||||
|
|
||||||
query = DateFilter.apply_ash_filter(base_query(), filters)
|
|
||||||
refute is_nil(query.filter)
|
|
||||||
end
|
|
||||||
|
|
||||||
test ":custom exit_date with only :from key still applies the bound" do
|
|
||||||
filters = %{
|
|
||||||
join_date: %{from: nil, to: nil},
|
|
||||||
exit_date: %{mode: :custom, from: ~D[2024-01-01]}
|
|
||||||
}
|
|
||||||
|
|
||||||
query = DateFilter.apply_ash_filter(base_query(), filters)
|
|
||||||
refute is_nil(query.filter)
|
|
||||||
end
|
|
||||||
|
|
||||||
test ":custom exit_date with only :to key still applies the bound" do
|
|
||||||
filters = %{
|
|
||||||
join_date: %{from: nil, to: nil},
|
|
||||||
exit_date: %{mode: :custom, to: ~D[2024-12-31]}
|
|
||||||
}
|
|
||||||
|
|
||||||
query = DateFilter.apply_ash_filter(base_query(), filters)
|
|
||||||
refute is_nil(query.filter)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "active_custom_field_ids/2" do
|
|
||||||
test "returns UUID keys with at least one bound set whose UUID matches a date field" do
|
|
||||||
id_a = "11111111-1111-1111-1111-111111111111"
|
|
||||||
id_b = "22222222-2222-2222-2222-222222222222"
|
|
||||||
id_unknown = "33333333-3333-3333-3333-333333333333"
|
|
||||||
|
|
||||||
filters =
|
|
||||||
DateFilter.default()
|
|
||||||
|> Map.put(id_a, %{from: ~D[2024-01-01], to: nil})
|
|
||||||
|> Map.put(id_b, %{from: nil, to: nil})
|
|
||||||
|> Map.put(id_unknown, %{from: ~D[2024-06-01], to: nil})
|
|
||||||
|
|
||||||
ids =
|
|
||||||
DateFilter.active_custom_field_ids(filters, [
|
|
||||||
date_custom_field(id_a),
|
|
||||||
date_custom_field(id_b)
|
|
||||||
])
|
|
||||||
|
|
||||||
assert ids == [id_a]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "ignores non-binary keys (built-in atoms)" do
|
|
||||||
assert DateFilter.active_custom_field_ids(DateFilter.default(), []) == []
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns [] when no date custom fields are supplied" do
|
|
||||||
id_a = "11111111-1111-1111-1111-111111111111"
|
|
||||||
|
|
||||||
filters =
|
|
||||||
DateFilter.default()
|
|
||||||
|> Map.put(id_a, %{from: ~D[2024-01-01], to: nil})
|
|
||||||
|
|
||||||
assert DateFilter.active_custom_field_ids(filters, []) == []
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "from_params/2 — custom date field entries" do
|
|
||||||
test "includes entry for known custom date field UUID" do
|
|
||||||
id = "11111111-2222-3333-4444-555555555555"
|
|
||||||
params = %{"cdf_#{id}_from" => "2024-06-01", "cdf_#{id}_to" => "2024-06-30"}
|
|
||||||
filters = DateFilter.from_params(params, [date_custom_field(id)])
|
|
||||||
assert filters[id] == %{from: ~D[2024-06-01], to: ~D[2024-06-30]}
|
|
||||||
end
|
|
||||||
|
|
||||||
test "ignores UUID not in date_custom_fields list" do
|
|
||||||
id = "11111111-2222-3333-4444-555555555555"
|
|
||||||
other = "99999999-8888-7777-6666-555555555555"
|
|
||||||
params = %{"cdf_#{other}_from" => "2024-06-01"}
|
|
||||||
filters = DateFilter.from_params(params, [date_custom_field(id)])
|
|
||||||
refute Map.has_key?(filters, other)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "ignores malformed custom date field bound" do
|
|
||||||
id = "11111111-2222-3333-4444-555555555555"
|
|
||||||
params = %{"cdf_#{id}_from" => "notadate"}
|
|
||||||
filters = DateFilter.from_params(params, [date_custom_field(id)])
|
|
||||||
# Either no entry, or entry with nil bounds — both satisfy "silently ignored"
|
|
||||||
case Map.get(filters, id) do
|
|
||||||
nil -> :ok
|
|
||||||
%{from: nil, to: nil} -> :ok
|
|
||||||
other -> flunk("expected nil bound for malformed input, got #{inspect(other)}")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
test "strips only the trailing _from / _to suffix, not internal substrings" do
|
|
||||||
# Construct an id that itself contains "_from" / "_to" — a quirky but
|
|
||||||
# legal MapSet key. Trailing-only suffix stripping must leave the
|
|
||||||
# internal substrings intact and recover the original id.
|
|
||||||
id_with_internal = "abc_from_xyz_to_def"
|
|
||||||
|
|
||||||
params = %{
|
|
||||||
"cdf_#{id_with_internal}_from" => "2024-06-01",
|
|
||||||
"cdf_#{id_with_internal}_to" => "2024-06-30"
|
|
||||||
}
|
|
||||||
|
|
||||||
filters =
|
|
||||||
DateFilter.from_params(params, [date_custom_field(id_with_internal)])
|
|
||||||
|
|
||||||
assert filters[id_with_internal] ==
|
|
||||||
%{from: ~D[2024-06-01], to: ~D[2024-06-30]}
|
|
||||||
end
|
|
||||||
|
|
||||||
test "drops cdf_-prefixed keys whose id exceeds the UUID length cap" do
|
|
||||||
# Matches the DoS-protection contract enforced by the sibling boolean,
|
|
||||||
# group, and fee_type filter parsers: an over-long id-portion (post
|
|
||||||
# prefix-strip, pre suffix-strip) is rejected without invoking the
|
|
||||||
# heavier String.replace_suffix / MapSet.member? path. The id we
|
|
||||||
# construct is well past `Mv.Constants.max_uuid_length()` (36).
|
|
||||||
known_id = "11111111-2222-3333-4444-555555555555"
|
|
||||||
over_long_id = String.duplicate("a", 200)
|
|
||||||
|
|
||||||
params = %{
|
|
||||||
"cdf_#{over_long_id}_from" => "2024-06-01",
|
|
||||||
"cdf_#{over_long_id}_to" => "2024-06-30"
|
|
||||||
}
|
|
||||||
|
|
||||||
filters = DateFilter.from_params(params, [date_custom_field(known_id)])
|
|
||||||
|
|
||||||
refute Map.has_key?(filters, over_long_id)
|
|
||||||
refute Map.has_key?(filters, "#{over_long_id}_from")
|
|
||||||
refute Map.has_key?(filters, "#{over_long_id}_to")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defmodule MvWeb.MemberLive.Index.DateFilterIntegrationTest do
|
|
||||||
@moduledoc """
|
|
||||||
Integration tests for the date filter URL → query → result-set pipeline.
|
|
||||||
|
|
||||||
Covers §1.7, §1.8, §1.9, §1.10, §1.11, §1.12, §1.15, §1.16, §1.17, §1.18,
|
|
||||||
§1.19. Custom date field filters are covered in the dedicated custom-field
|
|
||||||
integration test.
|
|
||||||
"""
|
|
||||||
# async: false: mutates the global member table.
|
|
||||||
use MvWeb.ConnCase, async: false
|
|
||||||
import Phoenix.LiveViewTest
|
|
||||||
|
|
||||||
setup do
|
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
|
||||||
today = Date.utc_today()
|
|
||||||
|
|
||||||
{:ok, alice} =
|
|
||||||
Mv.Membership.create_member(
|
|
||||||
%{
|
|
||||||
first_name: "Alice",
|
|
||||||
last_name: "Anderson",
|
|
||||||
email: "alice@example.com",
|
|
||||||
join_date: ~D[2020-01-01]
|
|
||||||
},
|
|
||||||
actor: system_actor
|
|
||||||
)
|
|
||||||
|
|
||||||
{:ok, bob} =
|
|
||||||
Mv.Membership.create_member(
|
|
||||||
%{
|
|
||||||
first_name: "Bob",
|
|
||||||
last_name: "Brown",
|
|
||||||
email: "bob@example.com",
|
|
||||||
join_date: ~D[2022-06-15]
|
|
||||||
},
|
|
||||||
actor: system_actor
|
|
||||||
)
|
|
||||||
|
|
||||||
{:ok, carla} =
|
|
||||||
Mv.Membership.create_member(
|
|
||||||
%{
|
|
||||||
first_name: "Carla",
|
|
||||||
last_name: "Carter",
|
|
||||||
email: "carla@example.com",
|
|
||||||
join_date: ~D[2024-03-20]
|
|
||||||
},
|
|
||||||
actor: system_actor
|
|
||||||
)
|
|
||||||
|
|
||||||
{:ok, dan} =
|
|
||||||
Mv.Membership.create_member(
|
|
||||||
%{
|
|
||||||
first_name: "Dan",
|
|
||||||
last_name: "Dixon",
|
|
||||||
email: "dan@example.com"
|
|
||||||
# no join_date — should be excluded by any join_date range filter
|
|
||||||
},
|
|
||||||
actor: system_actor
|
|
||||||
)
|
|
||||||
|
|
||||||
{:ok, former} =
|
|
||||||
Mv.Membership.create_member(
|
|
||||||
%{
|
|
||||||
first_name: "Frida",
|
|
||||||
last_name: "Former",
|
|
||||||
email: "frida@example.com",
|
|
||||||
join_date: Date.add(today, -1000),
|
|
||||||
exit_date: Date.add(today, -10)
|
|
||||||
},
|
|
||||||
actor: system_actor
|
|
||||||
)
|
|
||||||
|
|
||||||
%{alice: alice, bob: bob, carla: carla, dan: dan, former: former, today: today}
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "join_date filters" do
|
|
||||||
test "jd_from includes members with join_date >= bound; excludes nil (§1.7, §1.17)",
|
|
||||||
%{conn: conn, alice: alice, bob: bob, carla: carla, dan: dan} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, _view, html} = live(conn, "/members?jd_from=2022-01-01")
|
|
||||||
refute html =~ alice.first_name
|
|
||||||
assert html =~ bob.first_name
|
|
||||||
assert html =~ carla.first_name
|
|
||||||
refute html =~ dan.first_name
|
|
||||||
end
|
|
||||||
|
|
||||||
test "jd_to excludes members with join_date > bound (§1.8)",
|
|
||||||
%{conn: conn, alice: alice, bob: bob, carla: carla, dan: dan} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, _view, html} = live(conn, "/members?jd_to=2022-12-31")
|
|
||||||
assert html =~ alice.first_name
|
|
||||||
assert html =~ bob.first_name
|
|
||||||
refute html =~ carla.first_name
|
|
||||||
refute html =~ dan.first_name
|
|
||||||
end
|
|
||||||
|
|
||||||
test "jd_from and jd_to combine into an inclusive range (§1.9)",
|
|
||||||
%{conn: conn, alice: alice, bob: bob, carla: carla} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, _view, html} = live(conn, "/members?jd_from=2022-01-01&jd_to=2023-12-31")
|
|
||||||
refute html =~ alice.first_name
|
|
||||||
assert html =~ bob.first_name
|
|
||||||
refute html =~ carla.first_name
|
|
||||||
end
|
|
||||||
|
|
||||||
test "no active filter imposes no constraint on the join_date field (§1.18)",
|
|
||||||
%{conn: conn, alice: alice, bob: bob, carla: carla, dan: dan} do
|
|
||||||
# Nil join_date members are still visible when no join_date filter is active.
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, _view, html} = live(conn, "/members")
|
|
||||||
assert html =~ alice.first_name
|
|
||||||
assert html =~ bob.first_name
|
|
||||||
assert html =~ carla.first_name
|
|
||||||
assert html =~ dan.first_name
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "exit_date filters" do
|
|
||||||
test "ed_mode=inactive_only shows only former members (§1.10)",
|
|
||||||
%{conn: conn, alice: alice, former: former} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, _view, html} = live(conn, "/members?ed_mode=inactive_only")
|
|
||||||
refute html =~ alice.first_name
|
|
||||||
assert html =~ former.first_name
|
|
||||||
end
|
|
||||||
|
|
||||||
test "ed_mode=all shows all members regardless of exit_date (§1.11)",
|
|
||||||
%{conn: conn, alice: alice, former: former} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, _view, html} = live(conn, "/members?ed_mode=all")
|
|
||||||
assert html =~ alice.first_name
|
|
||||||
assert html =~ former.first_name
|
|
||||||
end
|
|
||||||
|
|
||||||
test "ed_mode=custom range hides members outside the range (§1.12)",
|
|
||||||
%{conn: conn, alice: alice, former: former, today: today} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
from = Date.add(today, -30) |> Date.to_iso8601()
|
|
||||||
to = Date.to_iso8601(today)
|
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, "/members?ed_mode=custom&ed_from=#{from}&ed_to=#{to}")
|
|
||||||
refute html =~ alice.first_name
|
|
||||||
assert html =~ former.first_name
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "filter combination and URL persistence" do
|
|
||||||
test "join_date filter combined with the default (active-only) shows only active members in range (§1.15)",
|
|
||||||
%{conn: conn, alice: alice, bob: bob, former: former} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, _view, html} = live(conn, "/members?jd_from=2020-01-01")
|
|
||||||
# Default active-only hides the former member even though they match join_date range.
|
|
||||||
refute html =~ former.first_name
|
|
||||||
assert html =~ alice.first_name
|
|
||||||
assert html =~ bob.first_name
|
|
||||||
end
|
|
||||||
|
|
||||||
test "date filter survives reload via URL params (§1.16)",
|
|
||||||
%{conn: conn, alice: alice, bob: bob, carla: carla} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
url = "/members?jd_from=2022-01-01&jd_to=2023-12-31"
|
|
||||||
{:ok, _view, html1} = live(conn, url)
|
|
||||||
{:ok, _view, html2} = live(conn, url)
|
|
||||||
# Same URL → same visible result set.
|
|
||||||
for member <- [alice, carla] do
|
|
||||||
refute html1 =~ member.first_name
|
|
||||||
refute html2 =~ member.first_name
|
|
||||||
end
|
|
||||||
|
|
||||||
assert html1 =~ bob.first_name
|
|
||||||
assert html2 =~ bob.first_name
|
|
||||||
end
|
|
||||||
|
|
||||||
test "malformed jd_from is silently ignored (§1.19)",
|
|
||||||
%{conn: conn, alice: alice, bob: bob, carla: carla, dan: dan} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, _view, html} = live(conn, "/members?jd_from=notadate")
|
|
||||||
# The filter is dropped, so default behavior (no join_date filter) applies
|
|
||||||
# and every member shows up.
|
|
||||||
assert html =~ alice.first_name
|
|
||||||
assert html =~ bob.first_name
|
|
||||||
assert html =~ carla.first_name
|
|
||||||
assert html =~ dan.first_name
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
defmodule MvWeb.MemberLive.Index.CustomFieldValueLookupTest do
|
|
||||||
@moduledoc """
|
|
||||||
Unit tests for the shared custom-field-value lookup helper.
|
|
||||||
|
|
||||||
The lookup must handle both shapes a CFV entry can take on a loaded member:
|
|
||||||
|
|
||||||
* `%{custom_field_id: id, value: ...}` — id present directly
|
|
||||||
* `%{custom_field: %{id: id, ...}, value: ...}` — id nested under loaded relation
|
|
||||||
"""
|
|
||||||
use ExUnit.Case, async: true
|
|
||||||
|
|
||||||
alias MvWeb.MemberLive.Index.CustomFieldValueLookup
|
|
||||||
|
|
||||||
defp uuid, do: "11111111-2222-3333-4444-555555555555"
|
|
||||||
|
|
||||||
describe "find_by_id/2" do
|
|
||||||
test "matches when custom_field_id key is present" do
|
|
||||||
id = uuid()
|
|
||||||
cfv = %{custom_field_id: id, value: :anything}
|
|
||||||
member = %{custom_field_values: [cfv]}
|
|
||||||
|
|
||||||
assert CustomFieldValueLookup.find_by_id(member, id) == cfv
|
|
||||||
end
|
|
||||||
|
|
||||||
test "matches when nested custom_field relation is loaded" do
|
|
||||||
id = uuid()
|
|
||||||
cfv = %{custom_field: %{id: id, value_type: :date}, value: :anything}
|
|
||||||
member = %{custom_field_values: [cfv]}
|
|
||||||
|
|
||||||
assert CustomFieldValueLookup.find_by_id(member, id) == cfv
|
|
||||||
end
|
|
||||||
|
|
||||||
test "compares stringified ids — accepts atom or binary ids on the cfv side" do
|
|
||||||
id = uuid()
|
|
||||||
cfv = %{custom_field_id: id, value: :v}
|
|
||||||
member = %{custom_field_values: [cfv]}
|
|
||||||
|
|
||||||
# Same id, passed as binary
|
|
||||||
assert CustomFieldValueLookup.find_by_id(member, id) == cfv
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns nil when no entry has a matching id" do
|
|
||||||
member = %{
|
|
||||||
custom_field_values: [
|
|
||||||
%{custom_field_id: "11111111-1111-1111-1111-111111111111", value: 1}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
assert CustomFieldValueLookup.find_by_id(member, uuid()) == nil
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns nil when custom_field_values is nil" do
|
|
||||||
assert CustomFieldValueLookup.find_by_id(%{custom_field_values: nil}, uuid()) == nil
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns nil when custom_field_values is not loaded (Ash.NotLoaded)" do
|
|
||||||
member = %{custom_field_values: %Ash.NotLoaded{type: :relationship}}
|
|
||||||
assert CustomFieldValueLookup.find_by_id(member, uuid()) == nil
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns nil when custom_field_values is empty" do
|
|
||||||
assert CustomFieldValueLookup.find_by_id(%{custom_field_values: []}, uuid()) == nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "find_by_field/2" do
|
|
||||||
test "matches a custom_field struct via its :id" do
|
|
||||||
id = uuid()
|
|
||||||
cfv = %{custom_field_id: id, value: :v}
|
|
||||||
member = %{custom_field_values: [cfv]}
|
|
||||||
custom_field = %{id: id, value_type: :boolean}
|
|
||||||
|
|
||||||
assert CustomFieldValueLookup.find_by_field(member, custom_field) == cfv
|
|
||||||
end
|
|
||||||
|
|
||||||
test "matches when only the nested custom_field is present" do
|
|
||||||
id = uuid()
|
|
||||||
cfv = %{custom_field: %{id: id}, value: :v}
|
|
||||||
member = %{custom_field_values: [cfv]}
|
|
||||||
|
|
||||||
assert CustomFieldValueLookup.find_by_field(member, %{id: id}) == cfv
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns nil when no entry matches" do
|
|
||||||
member = %{custom_field_values: [%{custom_field_id: "other", value: :v}]}
|
|
||||||
assert CustomFieldValueLookup.find_by_field(member, %{id: uuid()}) == nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
defmodule MvWeb.MemberLive.Index.FilterParamsTest do
|
|
||||||
@moduledoc """
|
|
||||||
Unit tests for the shared filter-param parsers.
|
|
||||||
"""
|
|
||||||
use ExUnit.Case, async: true
|
|
||||||
|
|
||||||
alias MvWeb.MemberLive.Index.FilterParams
|
|
||||||
|
|
||||||
describe "parse_prefix_filters/3" do
|
|
||||||
test "extracts only entries whose key starts with the prefix" do
|
|
||||||
params = %{
|
|
||||||
"group_abc" => "in",
|
|
||||||
"group_def" => "not_in",
|
|
||||||
"fee_type_xyz" => "in",
|
|
||||||
"unrelated" => "in",
|
|
||||||
"query" => "alice"
|
|
||||||
}
|
|
||||||
|
|
||||||
result = FilterParams.parse_prefix_filters(params, "group_", & &1)
|
|
||||||
|
|
||||||
assert result == %{"abc" => "in", "def" => "not_in"}
|
|
||||||
end
|
|
||||||
|
|
||||||
test "strips exactly one occurrence of the prefix, even when the rest starts with the prefix again" do
|
|
||||||
# Quirky but legal: a key like "p_p_abc" with prefix "p_" must produce id "p_abc".
|
|
||||||
params = %{"p_p_abc" => "v"}
|
|
||||||
result = FilterParams.parse_prefix_filters(params, "p_", & &1)
|
|
||||||
assert result == %{"p_abc" => "v"}
|
|
||||||
end
|
|
||||||
|
|
||||||
test "applies parse_value_fn to every value" do
|
|
||||||
params = %{"x_one" => "in", "x_two" => "not_in", "x_three" => "garbage"}
|
|
||||||
|
|
||||||
result =
|
|
||||||
FilterParams.parse_prefix_filters(
|
|
||||||
params,
|
|
||||||
"x_",
|
|
||||||
&FilterParams.parse_in_not_in_value/1
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result == %{"one" => :in, "two" => :not_in, "three" => nil}
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns empty map when no key matches the prefix" do
|
|
||||||
params = %{"a" => "1", "b" => "2"}
|
|
||||||
assert FilterParams.parse_prefix_filters(params, "z_", & &1) == %{}
|
|
||||||
end
|
|
||||||
|
|
||||||
test "ignores non-binary keys" do
|
|
||||||
params = %{"x_a" => "1", :atom_key => "2", 123 => "3"}
|
|
||||||
result = FilterParams.parse_prefix_filters(params, "x_", & &1)
|
|
||||||
assert result == %{"a" => "1"}
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns empty map for empty input" do
|
|
||||||
assert FilterParams.parse_prefix_filters(%{}, "x_", & &1) == %{}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "parse_in_not_in_value/1" do
|
|
||||||
test "maps 'in' to :in" do
|
|
||||||
assert FilterParams.parse_in_not_in_value("in") == :in
|
|
||||||
end
|
|
||||||
|
|
||||||
test "maps 'not_in' to :not_in" do
|
|
||||||
assert FilterParams.parse_in_not_in_value("not_in") == :not_in
|
|
||||||
end
|
|
||||||
|
|
||||||
test "trims whitespace around recognized values" do
|
|
||||||
assert FilterParams.parse_in_not_in_value(" in ") == :in
|
|
||||||
assert FilterParams.parse_in_not_in_value("\tnot_in\n") == :not_in
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns nil for unrecognized strings" do
|
|
||||||
assert FilterParams.parse_in_not_in_value("yes") == nil
|
|
||||||
assert FilterParams.parse_in_not_in_value("") == nil
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns nil for non-binary input" do
|
|
||||||
assert FilterParams.parse_in_not_in_value(nil) == nil
|
|
||||||
assert FilterParams.parse_in_not_in_value(:in) == nil
|
|
||||||
assert FilterParams.parse_in_not_in_value(123) == nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
defmodule MvWeb.LiveUserAuthTest do
|
|
||||||
@moduledoc """
|
|
||||||
Regression tests for the `MvWeb.LiveUserAuth` on_mount guards:
|
|
||||||
the unauthenticated `:live_user_required` redirect to the sign-in page and
|
|
||||||
the authenticated `:live_no_user` redirect away from the sign-in page.
|
|
||||||
"""
|
|
||||||
use MvWeb.ConnCase, async: false
|
|
||||||
|
|
||||||
import Phoenix.LiveViewTest
|
|
||||||
|
|
||||||
describe ":live_user_required" do
|
|
||||||
@tag role: :unauthenticated
|
|
||||||
test "unauthenticated request to a protected route is redirected to sign-in", %{conn: conn} do
|
|
||||||
assert {:error, {:redirect, %{to: to}}} = live(conn, "/members")
|
|
||||||
assert to == "/sign-in"
|
|
||||||
end
|
|
||||||
|
|
||||||
@tag role: :admin
|
|
||||||
test "authenticated user can mount a protected route", %{conn: conn} do
|
|
||||||
assert {:ok, _view, _html} = live(conn, "/members")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe ":live_no_user" do
|
|
||||||
@tag role: :admin
|
|
||||||
test "authenticated user visiting the sign-in page is redirected to root", %{conn: conn} do
|
|
||||||
assert {:error, {:redirect, %{to: "/"}}} = live(conn, "/sign-in")
|
|
||||||
end
|
|
||||||
|
|
||||||
@tag role: :unauthenticated
|
|
||||||
test "unauthenticated user can reach the sign-in page", %{conn: conn} do
|
|
||||||
assert {:ok, _view, _html} = live(conn, "/sign-in")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -268,28 +268,6 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
||||||
# Should not crash
|
# Should not crash
|
||||||
assert html =~ member.first_name
|
assert html =~ member.first_name
|
||||||
end
|
end
|
||||||
|
|
||||||
test "create_cycle with an unparseable date shows an error instead of crashing", %{conn: conn} do
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
|
||||||
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
|
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
|
||||||
|
|
||||||
view
|
|
||||||
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
view
|
|
||||||
|> element("button[phx-click='open_create_cycle_modal']")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
html =
|
|
||||||
view
|
|
||||||
|> element("form[phx-submit='create_cycle']")
|
|
||||||
|> render_submit(%{"date" => "not-a-date", "amount" => "10"})
|
|
||||||
|
|
||||||
assert html =~ "Invalid date format"
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "read_only user (Vorstand/Buchhaltung) - no cycle action buttons" do
|
describe "read_only user (Vorstand/Buchhaltung) - no cycle action buttons" do
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue