Compare commits
16 commits
main
...
bugfix/480
| Author | SHA1 | Date | |
|---|---|---|---|
| 2443bc62ac | |||
| 605a897045 | |||
| a244b1b07e | |||
| 679f1404e9 | |||
| a62fceaf28 | |||
| f8e1d39964 | |||
| cc1df449c6 | |||
| 104d945dd1 | |||
| 3cc35d0293 | |||
| d0d7d38c03 | |||
| 0159d5352a | |||
| 15e9a52bc9 | |||
| 6327ea00eb | |||
| 95b666f04f | |||
| 0a7bbc7fa6 | |||
| a6f6f402af |
141 changed files with 1654 additions and 8913 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_BASE_URL=http://localhost:8080/auth/v1
|
||||
# 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)
|
||||
# If OIDC_ADMIN_GROUP_NAME is set, users in that group get Admin role on registration/sign-in.
|
||||
|
|
|
|||
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -34,7 +34,6 @@ mv-*.tar
|
|||
# In case you use Node.js/npm, you want to ignore these.
|
||||
npm-debug.log
|
||||
/assets/node_modules/
|
||||
/node_modules/
|
||||
|
||||
.cursor
|
||||
|
||||
|
|
@ -46,11 +45,3 @@ npm-debug.log
|
|||
# Docker secrets directory (generated by `just init-secrets`)
|
||||
/secrets/
|
||||
notes.md
|
||||
|
||||
# Do NOT commit these — they are local to the dev machine
|
||||
.pipeline/
|
||||
.claude/
|
||||
|
||||
# Dialyzer PLT files — built locally and in CI cache, never tracked.
|
||||
/priv/plts/*.plt
|
||||
/priv/plts/*.plt.hash
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 233 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 122 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 265 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 136 KiB |
|
|
@ -1,4 +1,3 @@
|
|||
elixir 1.18.3-otp-27
|
||||
erlang 27.3.4
|
||||
just 1.51.0
|
||||
nodejs 26.2.0
|
||||
just 1.50.0
|
||||
|
|
|
|||
26
CHANGELOG.md
26
CHANGELOG.md
|
|
@ -7,29 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **GDPR/DSGVO join-form description** – Custom fields can carry a "join form description" that is shown as the field's label on the public join form, with clickable external links (whole URLs and Markdown `[text](url)`). Useful for presenting a GDPR confirmation with a link to an externally hosted privacy declaration before sign-up.
|
||||
- **Join-form description tooltip in member details** – Custom fields that have a join-form description show an info tooltip (prefixed "Beitrittsformular:") on their label in the member detail view.
|
||||
- **Editable join-form description** – Admins can set a field's join-form description in the custom-field settings, with an inline hint about the supported link syntax.
|
||||
- **CSV import – groups column** – Members can be assigned to groups during CSV import via a `Groups`/`Gruppen` column; group names that do not exist yet are created automatically, and re-importing the same file does not create duplicate groups.
|
||||
- **CSV import – membership fee type column** – A `Fee Type`/`Beitragsart` column assigns each member's membership fee type; an unknown name falls back to the default fee type and is flagged in the preview with a link to create it.
|
||||
- **CSV import – mapping preview** – After uploading a file, a preview shows how every column maps (with sample rows and warnings for ignored or unknown columns) and the import only starts once you confirm.
|
||||
- **Dynamic CSV import templates** – The EN and DE import-template downloads now include the association's current custom fields instead of a fixed column set.
|
||||
|
||||
### Changed
|
||||
- **Member bulk actions in one menu** – The actions above the member overview (open in email program, copy email addresses, export to CSV, export to PDF) are now collected in a single "Aktionen" dropdown instead of separate buttons. Without a selection they apply to all members, or to the currently filtered members; the trigger shows the active scope. Opening the email program is disabled when too many recipients are selected, with a hint to copy the addresses or use the export instead.
|
||||
- **Dropdown buttons** – Dropdown buttons (actions, filter, column visibility) now show a chevron so they are recognizable as menus.
|
||||
- **Default GDPR custom field** – The seeded GDPR field was shortened from "Datenschutzerklärung akzeptiert" to "DSGVO" and now ships with a default join-form description (with a placeholder link to replace).
|
||||
|
||||
### Fixed
|
||||
- **CSV date round-trip** – Date custom-field values are now exported as ISO-8601 (`YYYY-MM-DD`), so an exported CSV can be re-imported without date-parsing errors.
|
||||
- **CSV import – fee-status columns ignored** – Columns such as `Bezahlstatus` / `Membership Fee Status` are always ignored on import and never stored as a custom-field value, even when a custom field of the same name exists.
|
||||
|
||||
## [1.2.0] - 2026-05-08
|
||||
|
||||
### Changed
|
||||
- **Clickable table row highlights** – The new hover/focus-visible row highlight behavior is now the CoreComponents default across clickable tables. Sticky-first-column tables keep zebra striping and show selection through the sticky-column accent stripe (checkboxes keep their default style).
|
||||
- **Members overview scrolling** – The members table scrollbar now scrolls inside the table container instead of moving with the full page.
|
||||
- **Join request display and settings workflow** – Improved join request rendering and related settings behavior in one cohesive update:
|
||||
- Join request fields now respect their configured field types in the details view.
|
||||
- Custom field labels in join request views were standardized.
|
||||
|
|
@ -39,10 +17,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
### Fixed
|
||||
- **Runtime ENV handling** – Empty or invalid environment variables (e.g. `SMTP_PORT=`, `PORT=`, `POOL_SIZE=`, `DATABASE_PORT=`) no longer cause `ArgumentError` at boot. Instead raises clear errors for required vars set but empty (e.g. DATABASE_HOST, PHX_HOST/DOMAIN, SECRET_KEY_BASE).
|
||||
- **PostgreSQL 18 Docker volume path** – Corrected the database volume path to match PostgreSQL 18 expectations.
|
||||
- **Association name ENV handling** – `ASSOCIATION_NAME` is now treated as source of truth; the field is read-only in Global Settings when managed via ENV.
|
||||
- **Association name consistency after updates** – Layout now prefers explicitly assigned `club_name` values to avoid stale cached values right after settings changes.
|
||||
- **SMTP ENV/UI source selection** – SMTP now follows a strict single-source policy: ENV-only when `SMTP_HOST` is set, otherwise Settings-only.
|
||||
- **SMTP settings UI in ENV mode** – SMTP fields are read-only, save action is hidden, and missing required ENV keys are shown as a warning.
|
||||
|
||||
### Dependency updates
|
||||
- Mix dependencies were updated.
|
||||
|
|
|
|||
|
|
@ -1278,7 +1278,7 @@ mix hex.outdated
|
|||
**SMTP configuration:**
|
||||
|
||||
- SMTP can be configured via **ENV variables** (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_PASSWORD_FILE`, `SMTP_SSL`) or via **Admin Settings** (database: `smtp_host`, `smtp_port`, `smtp_username`, `smtp_password`, `smtp_ssl`).
|
||||
- **ENV-only policy:** If `SMTP_HOST` is set, SMTP is treated as environment-managed only. All SMTP fields in Settings are read-only, SMTP save action is hidden, and the UI shows a warning when required ENV values are missing (`SMTP_USERNAME`, and `SMTP_PASSWORD` or `SMTP_PASSWORD_FILE`). This keeps one source of truth for transport credentials and avoids mixed ENV/DB SMTP states.
|
||||
- **ENV-only policy:** If `SMTP_HOST` is set, SMTP is treated as environment-managed only. All SMTP fields in Settings are read-only, SMTP save action is hidden, and the UI shows a warning when required ENV values are missing (`SMTP_USERNAME`, and `SMTP_PASSWORD` or `SMTP_PASSWORD_FILE`).
|
||||
- **Sensitive settings in DB:** `smtp_password` and `oidc_client_secret` are excluded from the default read of the Setting resource; they are loaded only via explicit select when needed (e.g. `Mv.Config.smtp_password/0`, `Mv.Config.oidc_client_secret/0`). This avoids exposing secrets through `get_settings()`.
|
||||
- **Settings cache:** `Mv.Membership.get_settings/0` uses `Mv.Membership.SettingsCache` when the cache process is running (not in test). Cache has a short TTL and is invalidated on every settings update. This avoids repeated DB reads on hot paths (e.g. `RegistrationEnabled` validation, `Layouts.public_page`). In test, the cache is not started so all callers use `get_settings_uncached/0` in the test process (Ecto Sandbox).
|
||||
- **Join emails (domain → web):** The domain calls `Mv.Membership.JoinNotifier` (config `:join_notifier`, default `MvWeb.JoinNotifierImpl`) for sending join confirmation, already-member, and already-pending emails. This keeps the domain independent of the web layer; tests can override the notifier.
|
||||
|
|
@ -1363,8 +1363,6 @@ mix gettext.merge priv/gettext --on-obsolete=mark_as_obsolete
|
|||
|
||||
### 3.13 Task Runner: Just
|
||||
|
||||
The `Justfile` prepends `~/.asdf/shims`, `~/.asdf/bin`, and `~/.asdf` to `PATH` for all recipes (`set export := true`), so `mix` / `elixir` resolve from `.tool-versions` without shell init. The caller's `PATH` is kept (e.g. Homebrew `asdf`, Docker). Run `asdf install` once per machine; no extra `source` is required for `just run`.
|
||||
|
||||
**Common Commands:**
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -247,13 +247,11 @@ If these cannot be met, use `secondary`/`outline` instead of `ghost`.
|
|||
### 8.1 Default behavior: row click opens details
|
||||
- **DEFAULT:** Clicking a row navigates to the details page.
|
||||
- **EXCEPTIONS:** Highly interactive rows may disable row-click (document why).
|
||||
- **Row highlight (CoreComponents):** When `row_click` is set, rows use a neutral background highlight on `hover` and `tr:has(:focus-visible)` (see `assets/css/app.css`), so keyboard focus is visible while mouse-only focus does not appear "stuck". For non-sticky tables, `selected_row_id` can still add a stronger selected ring. For sticky-first-column tables, selection emphasis is handled by the sticky-column accent stripe.
|
||||
- **Row outline (CoreComponents):** When `row_click` is set, rows get a subtle hover and focus-within ring (theme-friendly). Use `selected_row_id` to show a stronger selected outline (e.g. from URL `?highlight=id` or last selection); the Back link from detail can use `?highlight=id` so the row is visually selected when returning to the index.
|
||||
|
||||
**IMPORTANT (correctness with our `<.table>` CoreComponent):**
|
||||
Our table implementation attaches the `phx-click` to the **`<td>`** when `row_click` is set. That means click events bubble from inner elements up to the cell unless we stop propagation.
|
||||
|
||||
**LiveStream rows:** Do not enumerate `@rows` with `Enum.with_index` in the table template; streams must be consumed only through `:for`. Sticky-first-column zebra striping for those tables is handled in CSS (`nth-child` under `data-sticky-first-col-rows`), not by assigning odd/even classes from an index.
|
||||
|
||||
So, for interactive elements inside a clickable row, you must **stop propagation using `Phoenix.LiveView.JS.stop_propagation/1`**, not a custom attribute.
|
||||
|
||||
✅ Correct pattern (one click handler that both stops propagation and triggers an event):
|
||||
|
|
|
|||
53
Justfile
53
Justfile
|
|
@ -1,12 +1,6 @@
|
|||
set dotenv-load := true
|
||||
set export := true
|
||||
|
||||
# Prepend asdf paths so recipes work without sourcing ~/.asdf/asdf.sh in the shell.
|
||||
# Caller PATH is preserved (Homebrew asdf, docker CLI, etc.). See CODE_GUIDELINES §3.13.
|
||||
home := env_var("HOME")
|
||||
asdf_paths := home + "/.asdf/shims:" + home + "/.asdf/bin:" + home + "/.asdf:"
|
||||
PATH := asdf_paths + env_var("PATH")
|
||||
|
||||
MIX_QUIET := "1"
|
||||
|
||||
run: install-dependencies start-database migrate-database seed-database
|
||||
|
|
@ -29,27 +23,7 @@ seed-database:
|
|||
start-database:
|
||||
docker compose up -d
|
||||
|
||||
# Full check suite: lint + audit + the fast tests (slow/ui excluded). No Dialyzer.
|
||||
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
|
||||
ci-dev: lint audit test-fast
|
||||
|
||||
gettext:
|
||||
mix gettext.extract
|
||||
|
|
@ -63,28 +37,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'
|
||||
mix gettext.extract --check-up-to-date
|
||||
|
||||
# Static security scan (Sobelow).
|
||||
sobelow:
|
||||
audit:
|
||||
mix sobelow --config
|
||||
|
||||
# Full security audit: Sobelow + dependency advisory scans.
|
||||
audit: sobelow
|
||||
mix deps.audit --ignore-file .deps_audit_ignore
|
||||
mix deps.audit
|
||||
mix hex.audit
|
||||
|
||||
# Run all tests. No install-dependencies prerequisite so single-file runs stay
|
||||
# fast; run `just install-dependencies` once on a fresh checkout.
|
||||
test *args:
|
||||
# Run all tests
|
||||
test *args: install-dependencies
|
||||
mix test {{args}}
|
||||
|
||||
# Fast tests only (excludes slow/performance and UI tests).
|
||||
test-fast *args:
|
||||
# Run only fast tests (excludes slow/performance and UI tests)
|
||||
test-fast *args: install-dependencies
|
||||
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
|
||||
ui *args: install-dependencies
|
||||
mix test --only ui {{args}}
|
||||
|
|
@ -104,10 +69,6 @@ test-all *args: install-dependencies
|
|||
format:
|
||||
mix format
|
||||
|
||||
# Catch-all wrapper for arbitrary mix commands not exposed as their own recipe.
|
||||
mix *args:
|
||||
mix {{args}}
|
||||
|
||||
build-docker-container:
|
||||
docker build --tag mitgliederverwaltung .
|
||||
|
||||
|
|
|
|||
18
README.md
18
README.md
|
|
@ -124,8 +124,8 @@ mix archive.install hex phx_new
|
|||
1. Copy env file:
|
||||
```bash
|
||||
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):
|
||||
```bash
|
||||
|
|
@ -139,9 +139,21 @@ mix archive.install hex phx_new
|
|||
|
||||
## 🔐 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.)
|
||||
|
||||
|
|
|
|||
|
|
@ -708,68 +708,3 @@
|
|||
background-color: transparent !important;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/*
|
||||
* Default interactive table rows: neutral hover/focus-visible fill for clickable rows.
|
||||
* Uses :has(:focus-visible) so keyboard navigation highlights the row without sticky mouse-focus artifacts.
|
||||
*/
|
||||
.table.table-zebra tbody tr[data-row-interactive="true"]:is(:hover, :has(:focus-visible)) > td {
|
||||
background-color: var(--color-base-300);
|
||||
}
|
||||
|
||||
/*
|
||||
* Sticky first column in zebra tables: opaque backgrounds per row.
|
||||
* Use nth-child (not HEEx row index) so LiveStream rows stay iterable only via :for (Phoenix LV requirement).
|
||||
*/
|
||||
[data-sticky-first-col-rows="true"] .table.table-zebra tbody tr:nth-child(odd) > td.sticky-first-col-cell {
|
||||
background-color: var(--color-base-100);
|
||||
}
|
||||
|
||||
[data-sticky-first-col-rows="true"] .table.table-zebra tbody tr:nth-child(even) > td.sticky-first-col-cell {
|
||||
background-color: var(--color-base-200);
|
||||
}
|
||||
|
||||
/*
|
||||
* Checkbox-selected rows: keep zebra backgrounds; only accent the sticky checkbox column.
|
||||
*/
|
||||
[data-sticky-first-col-rows="true"]
|
||||
.table.table-zebra
|
||||
tbody
|
||||
tr[data-selected="true"]
|
||||
> td.sticky-first-col-cell {
|
||||
box-shadow: inset 2px 0 0 var(--color-primary);
|
||||
}
|
||||
|
||||
[data-sticky-first-col-rows="true"]
|
||||
.table.table-zebra
|
||||
tbody
|
||||
tr[data-row-interactive="true"]:is(:hover, :has(:focus-visible))
|
||||
> td.sticky-first-col-cell {
|
||||
background-color: var(--color-base-300);
|
||||
/* Left accent only; keep the familiar orange primary accent. */
|
||||
box-shadow: inset 2px 0 0 var(--color-primary);
|
||||
}
|
||||
|
||||
/*
|
||||
* Sticky member selection table: drop mouse-only focus outlines that read like a thin frame around the row;
|
||||
* keyboard :focus-visible keeps DaisyUI control outlines (checkbox / tabindex cell).
|
||||
*/
|
||||
[data-sticky-first-col-rows="true"] .table.table-zebra tbody tr {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
[data-sticky-first-col-rows="true"]
|
||||
.table.table-zebra
|
||||
tbody
|
||||
tr[data-row-interactive="true"]:is(:hover, :has(:focus-visible)):not(:last-child) {
|
||||
/* DaisyUI draws a bottom border on each row; hiding it while highlighted avoids a boxy “frame”. */
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
[data-sticky-first-col-rows="true"] .table.table-zebra tbody tr td:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
[data-sticky-first-col-rows="true"] .table.table-zebra tbody tr input.checkbox:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# Ecto sandbox (tests run in a transaction; create_member after_transaction is expected).
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ services:
|
|||
restart: unless-stopped
|
||||
|
||||
db-prod:
|
||||
image: postgres:18.4-alpine
|
||||
image: postgres:18.3-alpine
|
||||
container_name: mv-prod-db
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ networks:
|
|||
|
||||
services:
|
||||
db:
|
||||
image: postgres:18.4-alpine
|
||||
image: postgres:18.3-alpine
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
|
|
@ -25,7 +25,7 @@ services:
|
|||
|
||||
rauthy:
|
||||
container_name: rauthy-dev
|
||||
image: ghcr.io/sebadob/rauthy:0.35.2
|
||||
image: ghcr.io/sebadob/rauthy:0.35.1
|
||||
environment:
|
||||
- LOCAL_TEST=true
|
||||
- SMTP_URL=mailcrab
|
||||
|
|
@ -36,9 +36,6 @@ services:
|
|||
- BOOTSTRAP_ADMIN_PASSWORD_PLAIN=RauthyTest12345
|
||||
# Disable strict IP validation to allow access from multiple Docker networks
|
||||
- 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:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
|
|
@ -49,7 +46,6 @@ services:
|
|||
- local
|
||||
volumes:
|
||||
- rauthy-data:/app/data
|
||||
- ./rauthy-bootstrap:/app/bootstrap:ro
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
|
|
|
|||
|
|
@ -188,6 +188,7 @@ Settings (1) → MembershipFeeType (0..1)
|
|||
### Member Constraints
|
||||
- First name and last name required (min 1 char)
|
||||
- Email unique, validated format (5-254 chars)
|
||||
- Join date cannot be in future
|
||||
- Exit date must be after join date
|
||||
- Phone: `+?[0-9\- ]{6,20}`
|
||||
- Postal code: optional (no format validation)
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ Table members {
|
|||
first_name text [null, note: 'Member first name (min length: 1 if present)']
|
||||
last_name text [null, note: 'Member last name (min length: 1 if present)']
|
||||
email text [not null, unique, note: 'Member email address (5-254 chars, validated)']
|
||||
join_date date [null, note: 'Date when member joined club']
|
||||
join_date date [null, note: 'Date when member joined club (cannot be in future)']
|
||||
exit_date date [null, note: 'Date when member left club (must be after join_date)']
|
||||
notes text [null, note: 'Additional notes about member']
|
||||
city text [null, note: 'City of residence']
|
||||
|
|
@ -187,6 +187,7 @@ Table members {
|
|||
**Validation Rules:**
|
||||
- first_name, last_name: optional, but if present min 1 character
|
||||
- email: 5-254 characters, valid email format (required)
|
||||
- join_date: cannot be in future
|
||||
- exit_date: must be after join_date (if both present)
|
||||
- postal_code: optional (no format validation)
|
||||
- country: optional
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ When `SMTP_HOST` is set, SMTP runs in **ENV-only mode**:
|
|||
- all SMTP fields in Settings are read-only,
|
||||
- saving SMTP settings in the UI is disabled,
|
||||
- and the UI shows a warning block if required SMTP ENV values are missing.
|
||||
- the UI displays the effective ENV-driven SMTP values in disabled fields so admins can verify what is active.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -12,8 +12,6 @@ defmodule Mv.Membership.CustomField do
|
|||
- `slug` - URL-friendly, immutable identifier automatically generated from name (e.g., "phone-mobile")
|
||||
- `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`). Immutable after creation.
|
||||
- `description` - Optional human-readable description
|
||||
- `join_description` - Optional label shown for this field on the public join form
|
||||
(e.g., a GDPR confirmation text); supports inline external links. Falls back to `name` when nil.
|
||||
- `required` - If true, all members must have this custom field (future feature)
|
||||
- `show_in_overview` - If true, this custom field will be displayed in the member overview table and can be sorted
|
||||
|
||||
|
|
@ -63,14 +61,7 @@ defmodule Mv.Membership.CustomField do
|
|||
end
|
||||
|
||||
actions do
|
||||
default_accept [
|
||||
:name,
|
||||
:value_type,
|
||||
:description,
|
||||
:join_description,
|
||||
:required,
|
||||
:show_in_overview
|
||||
]
|
||||
default_accept [:name, :value_type, :description, :required, :show_in_overview]
|
||||
|
||||
read :read do
|
||||
primary? true
|
||||
|
|
@ -78,13 +69,13 @@ defmodule Mv.Membership.CustomField do
|
|||
end
|
||||
|
||||
create :create do
|
||||
accept [:name, :value_type, :description, :join_description, :required, :show_in_overview]
|
||||
accept [:name, :value_type, :description, :required, :show_in_overview]
|
||||
change Mv.Membership.Changes.GenerateSlug
|
||||
validate string_length(:slug, min: 1)
|
||||
end
|
||||
|
||||
update :update do
|
||||
accept [:name, :description, :join_description, :required, :show_in_overview]
|
||||
accept [:name, :description, :required, :show_in_overview]
|
||||
require_atomic? false
|
||||
|
||||
validate fn changeset, _context ->
|
||||
|
|
@ -148,15 +139,6 @@ defmodule Mv.Membership.CustomField do
|
|||
trim?: true
|
||||
]
|
||||
|
||||
attribute :join_description, :string,
|
||||
allow_nil?: true,
|
||||
public?: true,
|
||||
description: "Label shown for this field on the public join form; supports external links",
|
||||
constraints: [
|
||||
max_length: 1000,
|
||||
trim?: true
|
||||
]
|
||||
|
||||
attribute :required, :boolean,
|
||||
default: false,
|
||||
allow_nil?: false
|
||||
|
|
|
|||
|
|
@ -17,10 +17,16 @@ defmodule Mv.Membership.JoinRequest.Changes.FilterFormDataByAllowlist do
|
|||
form_data = Ash.Changeset.get_attribute(changeset, :form_data) || %{}
|
||||
|
||||
allowlist_ids =
|
||||
Membership.get_join_form_allowlist()
|
||||
|> Enum.map(fn item -> item.id end)
|
||||
|> MapSet.new()
|
||||
|> MapSet.difference(MapSet.new(@typed_fields))
|
||||
case Membership.get_join_form_allowlist() do
|
||||
list when is_list(list) ->
|
||||
list
|
||||
|> Enum.map(fn item -> item.id end)
|
||||
|> MapSet.new()
|
||||
|> MapSet.difference(MapSet.new(@typed_fields))
|
||||
|
||||
_ ->
|
||||
MapSet.new()
|
||||
end
|
||||
|
||||
filtered =
|
||||
form_data
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ defmodule Mv.Membership.Member do
|
|||
## Validations
|
||||
- Required: email (all other fields are optional)
|
||||
- Email format validation (using EctoCommons.EmailValidator)
|
||||
- Date validations: exit_date after join_date
|
||||
- Date validations: join_date not in future, exit_date after join_date
|
||||
- Email uniqueness: prevents conflicts with unlinked users
|
||||
- Linked member email change: only admins or the linked user may change a linked member's email (see `Mv.Membership.Member.Validations.EmailChangePermission`)
|
||||
|
||||
|
|
@ -51,9 +51,6 @@ defmodule Mv.Membership.Member do
|
|||
|
||||
require Logger
|
||||
|
||||
@typedoc "An `Mv.Membership.Member` resource record."
|
||||
@type t :: %__MODULE__{}
|
||||
|
||||
# Module constants
|
||||
@member_search_limit 10
|
||||
|
||||
|
|
@ -476,6 +473,11 @@ defmodule Mv.Membership.Member do
|
|||
end
|
||||
end
|
||||
|
||||
# Join date not in future
|
||||
validate compare(:join_date, less_than_or_equal_to: &Date.utc_today/0),
|
||||
where: [present(:join_date)],
|
||||
message: "cannot be in the future"
|
||||
|
||||
# Exit date not before join date
|
||||
validate compare(:exit_date, greater_than: :join_date),
|
||||
where: [present([:join_date, :exit_date])],
|
||||
|
|
@ -794,7 +796,7 @@ defmodule Mv.Membership.Member do
|
|||
# nil/[] when membership_fee_type is missing.
|
||||
|
||||
@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
|
||||
today = Date.utc_today()
|
||||
|
||||
|
|
@ -824,7 +826,7 @@ defmodule Mv.Membership.Member do
|
|||
end
|
||||
|
||||
@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
|
||||
today = Date.utc_today()
|
||||
|
||||
|
|
@ -870,7 +872,7 @@ defmodule Mv.Membership.Member do
|
|||
end
|
||||
|
||||
@doc false
|
||||
@spec get_overdue_cycles(t()) :: [MembershipFeeCycle.t()]
|
||||
@spec get_overdue_cycles(Member.t()) :: [MembershipFeeCycle.t()]
|
||||
def get_overdue_cycles(member) do
|
||||
today = Date.utc_today()
|
||||
|
||||
|
|
@ -942,7 +944,7 @@ defmodule Mv.Membership.Member do
|
|||
# Already in transaction: use advisory lock directly
|
||||
# Returns {:ok, notifications} - notifications should be returned to after_action hook
|
||||
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)
|
||||
end
|
||||
|
||||
|
|
@ -950,7 +952,7 @@ defmodule Mv.Membership.Member do
|
|||
# Returns {:ok, notifications} - notifications should be sent by caller (e.g., via after_action)
|
||||
defp regenerate_cycles_new_transaction(member, today, lock_key) do
|
||||
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
|
||||
{:ok, notifications} ->
|
||||
|
|
@ -1096,7 +1098,7 @@ defmodule Mv.Membership.Member do
|
|||
initiator: initiator
|
||||
) do
|
||||
{:ok, cycles, notifications} ->
|
||||
_ = send_notifications_if_any(notifications)
|
||||
send_notifications_if_any(notifications)
|
||||
|
||||
log_cycle_generation_success(member, cycles, notifications,
|
||||
sync: true,
|
||||
|
|
@ -1115,7 +1117,7 @@ defmodule Mv.Membership.Member do
|
|||
initiator: initiator
|
||||
) do
|
||||
{:ok, cycles, notifications} ->
|
||||
_ = send_notifications_if_any(notifications)
|
||||
send_notifications_if_any(notifications)
|
||||
|
||||
log_cycle_generation_success(member, cycles, notifications,
|
||||
sync: false,
|
||||
|
|
@ -1234,6 +1236,8 @@ defmodule Mv.Membership.Member do
|
|||
|> String.replace("_", "\\_")
|
||||
end
|
||||
|
||||
defp sanitize_search_query(_), do: ""
|
||||
|
||||
# ============================================================================
|
||||
# Search Filter Builders
|
||||
# ============================================================================
|
||||
|
|
|
|||
|
|
@ -37,10 +37,9 @@ defmodule Mv.Membership.Member.Changes.UnrelateUserWhenArgumentNil do
|
|||
{:ok, %{user: user}} when not is_nil(user) ->
|
||||
# User's :update action only accepts [:email]; use :update_user so
|
||||
# manage_relationship(:member, ..., on_missing: :unrelate) runs and clears member_id.
|
||||
_ =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update_user, %{member: nil}, domain: Mv.Accounts)
|
||||
|> Ash.update(domain: Mv.Accounts, actor: actor, authorize?: false)
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update_user, %{member: nil}, domain: Mv.Accounts)
|
||||
|> Ash.update(domain: Mv.Accounts, actor: actor, authorize?: false)
|
||||
|
||||
changeset
|
||||
|
||||
|
|
|
|||
|
|
@ -178,6 +178,18 @@ defmodule Mv.Membership do
|
|||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Invalidates the global settings cache.
|
||||
|
||||
This should be used by callers that update settings through paths outside of
|
||||
`update_settings/2` (for example, custom form submit flows) to keep reads via
|
||||
`get_settings/0` consistent across views.
|
||||
"""
|
||||
@spec invalidate_settings_cache() :: :ok
|
||||
def invalidate_settings_cache do
|
||||
SettingsCache.invalidate()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Lists only required custom fields.
|
||||
|
||||
|
|
@ -836,10 +848,7 @@ defmodule Mv.Membership do
|
|||
- `{:ok, rejected_request}` - Rejected JoinRequest
|
||||
- `{:error, error}` - Status error or authorization error
|
||||
"""
|
||||
@spec reject_join_request(String.t(), keyword()) ::
|
||||
{:ok, JoinRequest.t()}
|
||||
| {:ok, JoinRequest.t(), [Ash.Notifier.Notification.t()]}
|
||||
| {:error, term()}
|
||||
@spec reject_join_request(String.t(), keyword()) :: {:ok, JoinRequest.t()} | {:error, term()}
|
||||
def reject_join_request(id, opts \\ []) do
|
||||
actor = Keyword.get(opts, :actor)
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
|
|||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
require Logger
|
||||
|
||||
alias Mv.MembershipFees.CalendarCycles
|
||||
|
||||
@impl true
|
||||
|
|
@ -81,6 +83,11 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
|
|||
field: :membership_fee_type_id,
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@ defmodule Mv.Authorization.PermissionSets do
|
|||
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 action :: :read | :create | :update | :destroy
|
||||
|
||||
|
|
@ -89,7 +88,7 @@ defmodule Mv.Authorization.PermissionSets do
|
|||
iex> PermissionSets.all_permission_sets()
|
||||
[:own_data, :read_only, :normal_user, :admin]
|
||||
"""
|
||||
@spec all_permission_sets() :: [permission_set_name(), ...]
|
||||
@spec all_permission_sets() :: [atom()]
|
||||
def all_permission_sets do
|
||||
[:own_data, :read_only, :normal_user, :admin]
|
||||
end
|
||||
|
|
@ -108,7 +107,7 @@ defmodule Mv.Authorization.PermissionSets do
|
|||
iex> PermissionSets.get_permissions(:invalid)
|
||||
** (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
|
||||
raise ArgumentError,
|
||||
|
|
|
|||
|
|
@ -143,6 +143,27 @@ defmodule Mv.Config do
|
|||
|> parse_and_validate_integer(default)
|
||||
end
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Association name
|
||||
# ENV variable takes priority; fallback to Settings from database.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@doc """
|
||||
Returns the association name.
|
||||
|
||||
Reads from `ASSOCIATION_NAME` env first, then from Settings.
|
||||
"""
|
||||
@spec association_name() :: String.t() | nil
|
||||
def association_name do
|
||||
env_or_setting("ASSOCIATION_NAME", :club_name)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns true if ASSOCIATION_NAME is set (field is read-only in Settings).
|
||||
"""
|
||||
@spec association_name_env_set?() :: boolean()
|
||||
def association_name_env_set?, do: env_set?("ASSOCIATION_NAME")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vereinfacht accounting software integration
|
||||
# ENV variables take priority; fallback to Settings from database.
|
||||
|
|
@ -207,6 +228,8 @@ defmodule Mv.Config do
|
|||
end
|
||||
end
|
||||
|
||||
defp derive_app_url_from_api_url(_), do: nil
|
||||
|
||||
@doc """
|
||||
Returns true if Vereinfacht is fully configured (URL, API key, and club ID all set).
|
||||
"""
|
||||
|
|
@ -249,6 +272,7 @@ defmodule Mv.Config do
|
|||
case System.get_env(key) do
|
||||
nil -> false
|
||||
v when is_binary(v) -> String.trim(v) != ""
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -267,6 +291,9 @@ defmodule Mv.Config do
|
|||
value when is_binary(value) ->
|
||||
v = String.trim(value) |> String.downcase()
|
||||
v in ["true", "1", "yes"]
|
||||
|
||||
_ ->
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -322,6 +349,7 @@ defmodule Mv.Config do
|
|||
|
||||
defp present?(nil), do: false
|
||||
defp present?(s) when is_binary(s), do: String.trim(s) != ""
|
||||
defp present?(_), do: false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OIDC authentication
|
||||
|
|
@ -402,7 +430,7 @@ defmodule Mv.Config do
|
|||
@doc """
|
||||
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
|
||||
case env_or_setting("OIDC_GROUPS_CLAIM", :oidc_groups_claim) do
|
||||
nil -> "groups"
|
||||
|
|
@ -463,19 +491,11 @@ defmodule Mv.Config do
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
@doc """
|
||||
Returns SMTP host.
|
||||
|
||||
Policy:
|
||||
- ENV-only mode (`SMTP_HOST` set): read from ENV `SMTP_HOST`
|
||||
- Settings mode: read from Settings only
|
||||
Returns SMTP host. ENV `SMTP_HOST` overrides Settings.
|
||||
"""
|
||||
@spec smtp_host() :: String.t() | nil
|
||||
def smtp_host do
|
||||
if smtp_env_mode?() do
|
||||
System.get_env("SMTP_HOST") |> trim_nil()
|
||||
else
|
||||
get_from_settings(:smtp_host)
|
||||
end
|
||||
smtp_env_or_setting("SMTP_HOST", :smtp_host)
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
@ -485,7 +505,7 @@ defmodule Mv.Config do
|
|||
- ENV-only mode (`SMTP_HOST` set): read from ENV `SMTP_PORT`
|
||||
- Settings mode: read from Settings only
|
||||
"""
|
||||
@spec smtp_port() :: pos_integer() | nil
|
||||
@spec smtp_port() :: non_neg_integer() | nil
|
||||
def smtp_port do
|
||||
if smtp_env_mode?() do
|
||||
parse_smtp_port_env(System.get_env("SMTP_PORT"))
|
||||
|
|
@ -523,7 +543,7 @@ defmodule Mv.Config do
|
|||
def smtp_password do
|
||||
if smtp_env_mode?() do
|
||||
case System.get_env("SMTP_PASSWORD") do
|
||||
nil -> smtp_password_from_file()
|
||||
nil -> smtp_password_from_file_or_settings()
|
||||
value -> trim_nil(value)
|
||||
end
|
||||
else
|
||||
|
|
@ -531,7 +551,7 @@ defmodule Mv.Config do
|
|||
end
|
||||
end
|
||||
|
||||
defp smtp_password_from_file do
|
||||
defp smtp_password_from_file_or_settings do
|
||||
case System.get_env("SMTP_PASSWORD_FILE") do
|
||||
nil -> nil
|
||||
path -> read_smtp_password_file(path)
|
||||
|
|
@ -569,6 +589,14 @@ defmodule Mv.Config do
|
|||
present?(smtp_host())
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns true when SMTP ENV mode is active.
|
||||
"""
|
||||
@spec smtp_env_configured?() :: boolean()
|
||||
def smtp_env_configured? do
|
||||
smtp_env_mode?()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns true when SMTP is managed by environment variables.
|
||||
|
||||
|
|
@ -592,7 +620,6 @@ defmodule Mv.Config do
|
|||
[]
|
||||
|> maybe_add_missing("SMTP_USERNAME", smtp_username_env_set?())
|
||||
|> maybe_add_missing("SMTP_PASSWORD/SMTP_PASSWORD_FILE", smtp_password_env_set?())
|
||||
|> Enum.reverse()
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
|
@ -631,15 +658,9 @@ defmodule Mv.Config do
|
|||
"""
|
||||
@spec mail_from_name() :: String.t()
|
||||
def mail_from_name do
|
||||
name =
|
||||
case System.get_env("MAIL_FROM_NAME") do
|
||||
nil -> get_from_settings(:smtp_from_name)
|
||||
value -> trim_nil(value)
|
||||
end
|
||||
|
||||
case name do
|
||||
nil -> "Mila"
|
||||
name -> name
|
||||
case System.get_env("MAIL_FROM_NAME") do
|
||||
nil -> get_from_settings(:smtp_from_name) || "Mila"
|
||||
value -> trim_nil(value) || "Mila"
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -665,6 +686,8 @@ defmodule Mv.Config do
|
|||
@spec mail_from_email_env_set?() :: boolean()
|
||||
def mail_from_email_env_set?, do: env_set?("MAIL_FROM_EMAIL")
|
||||
|
||||
defp parse_smtp_port_env(nil), do: nil
|
||||
|
||||
defp parse_smtp_port_env(value) when is_binary(value) do
|
||||
case Integer.parse(String.trim(value)) do
|
||||
{port, _} when port > 0 -> port
|
||||
|
|
@ -674,8 +697,16 @@ defmodule Mv.Config do
|
|||
|
||||
defp parse_smtp_port_env(_), do: nil
|
||||
|
||||
# Reads a plain string SMTP setting: ENV first, then Settings.
|
||||
defp smtp_env_or_setting(env_key, setting_key) do
|
||||
case System.get_env(env_key) do
|
||||
nil -> get_from_settings(setting_key)
|
||||
value -> trim_nil(value)
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_add_missing(acc, _label, true), do: acc
|
||||
defp maybe_add_missing(acc, label, false), do: [label | acc]
|
||||
defp maybe_add_missing(acc, label, false), do: acc ++ [label]
|
||||
|
||||
# Reads an integer setting attribute from Settings.
|
||||
defp get_from_settings_integer(key) do
|
||||
|
|
|
|||
|
|
@ -26,22 +26,8 @@ defmodule Mv.Constants do
|
|||
|
||||
@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_mailto_bulk_recipients 50
|
||||
|
||||
@max_uuid_length 36
|
||||
|
||||
@email_validator_checks [:html_input, :pow]
|
||||
|
|
@ -98,70 +84,6 @@ defmodule Mv.Constants do
|
|||
"""
|
||||
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 """
|
||||
Returns the maximum number of boolean custom field filters allowed per request.
|
||||
|
||||
|
|
@ -175,21 +97,6 @@ defmodule Mv.Constants do
|
|||
"""
|
||||
def max_boolean_filters, do: @max_boolean_filters
|
||||
|
||||
@doc """
|
||||
Returns the maximum number of mailto recipients before the bulk "open in email
|
||||
program" action is disabled.
|
||||
|
||||
The mailto link carries every recipient in its BCC; browsers cannot reliably
|
||||
hand a too-long mailto URI to the mail program. At or above this count the
|
||||
action is disabled in the UI (Copy and Export have no such limit).
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Mv.Constants.max_mailto_bulk_recipients()
|
||||
50
|
||||
"""
|
||||
def max_mailto_bulk_recipients, do: @max_mailto_bulk_recipients
|
||||
|
||||
@doc """
|
||||
Returns the maximum length of a UUID string (36 characters including hyphens).
|
||||
|
||||
|
|
|
|||
|
|
@ -225,10 +225,7 @@ defmodule Mv.Helpers.SystemActor do
|
|||
# This allows configuration via SYSTEM_ACTOR_EMAIL env var
|
||||
@spec system_user_email_config() :: String.t()
|
||||
defp system_user_email_config do
|
||||
case System.get_env("SYSTEM_ACTOR_EMAIL") do
|
||||
nil -> "system@mila.local"
|
||||
email -> email
|
||||
end
|
||||
System.get_env("SYSTEM_ACTOR_EMAIL") || "system@mila.local"
|
||||
end
|
||||
|
||||
# Loads the system actor from the database
|
||||
|
|
@ -260,7 +257,7 @@ defmodule Mv.Helpers.SystemActor do
|
|||
end
|
||||
|
||||
# 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
|
||||
case load_admin_user_fallback() do
|
||||
{:ok, admin_user} ->
|
||||
|
|
@ -396,18 +393,15 @@ defmodule Mv.Helpers.SystemActor do
|
|||
# 1. Only creates system user with known email
|
||||
# 2. Only called during system actor initialization (bootstrap)
|
||||
# 3. Once created, all subsequent operations use proper authorization
|
||||
user =
|
||||
Accounts.create_user!(%{email: system_user_email_config()},
|
||||
upsert?: true,
|
||||
upsert_identity: :unique_email,
|
||||
authorize?: false
|
||||
)
|
||||
|> Ash.Changeset.for_update(:update_internal, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||
|> Ash.update!(authorize?: false)
|
||||
|> Ash.load!(:role, domain: Mv.Accounts, authorize?: false)
|
||||
|
||||
%Accounts.User{} = user
|
||||
Accounts.create_user!(%{email: system_user_email_config()},
|
||||
upsert?: true,
|
||||
upsert_identity: :unique_email,
|
||||
authorize?: false
|
||||
)
|
||||
|> Ash.Changeset.for_update(:update_internal, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||
|> Ash.update!(authorize?: false)
|
||||
|> Ash.load!(:role, domain: Mv.Accounts, authorize?: false)
|
||||
end
|
||||
|
||||
# Finds a user by email address
|
||||
|
|
|
|||
|
|
@ -190,4 +190,6 @@ defmodule Mv.Mailer do
|
|||
defp valid_email?(email) when is_binary(email) do
|
||||
Regex.match?(@email_regex, String.trim(email))
|
||||
end
|
||||
|
||||
defp valid_email?(_), do: false
|
||||
end
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@ defmodule Mv.Membership.CustomFieldValueFormatter do
|
|||
|
||||
Same logic as the member overview Formatter but without Gettext or web helpers,
|
||||
so it can be used from the Membership context. For boolean: "Yes"/"No";
|
||||
for date: ISO-8601 (YYYY-MM-DD) so exported values can be re-imported.
|
||||
for date: European format (dd.mm.yyyy).
|
||||
"""
|
||||
@doc """
|
||||
Formats a custom field value for plain text (e.g. CSV).
|
||||
|
||||
Handles nil, Ash.Union, JSONB map, and direct values. Uses custom_field.value_type
|
||||
for typing. Boolean -> "Yes"/"No", Date -> ISO-8601 (YYYY-MM-DD).
|
||||
for typing. Boolean -> "Yes"/"No", Date -> dd.mm.yyyy.
|
||||
"""
|
||||
def format_custom_field_value(nil, _custom_field), do: ""
|
||||
|
||||
|
|
@ -18,10 +18,6 @@ defmodule Mv.Membership.CustomFieldValueFormatter do
|
|||
format_value_by_type(value, type, custom_field)
|
||||
end
|
||||
|
||||
def format_custom_field_value(%Date{} = value, custom_field) do
|
||||
format_value_by_type(value, :date, custom_field)
|
||||
end
|
||||
|
||||
def format_custom_field_value(value, custom_field) when is_map(value) do
|
||||
type = Map.get(value, "type") || Map.get(value, "_union_type")
|
||||
val = Map.get(value, "value") || Map.get(value, "_union_value")
|
||||
|
|
@ -45,12 +41,12 @@ defmodule Mv.Membership.CustomFieldValueFormatter do
|
|||
defp format_value_by_type(value, :boolean, _), do: to_string(value)
|
||||
|
||||
defp format_value_by_type(%Date{} = date, :date, _) do
|
||||
Date.to_iso8601(date)
|
||||
Calendar.strftime(date, "%d.%m.%Y")
|
||||
end
|
||||
|
||||
defp format_value_by_type(value, :date, _) when is_binary(value) do
|
||||
case Date.from_iso8601(value) do
|
||||
{:ok, date} -> Date.to_iso8601(date)
|
||||
{:ok, date} -> Calendar.strftime(date, "%d.%m.%Y")
|
||||
_ -> value
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,258 +0,0 @@
|
|||
defmodule Mv.Membership.Import.ColumnResolver do
|
||||
@moduledoc """
|
||||
Read-only resolution of CSV import columns against the database.
|
||||
|
||||
Given the `HeaderMapper.build_maps/2` result, the raw numbered rows, and an
|
||||
actor, `resolve/3` determines:
|
||||
|
||||
- which group names in the groups column already exist (`groups_found`) and
|
||||
which would have to be created (`groups_to_create`);
|
||||
- a small set of preview rows for the mapping preview UI.
|
||||
|
||||
No database writes happen here; the resolver only reads. Group creation and
|
||||
member-group assignment happen during processing via `create_or_find_group/3`.
|
||||
|
||||
This module has no Phoenix or web dependencies.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias Mv.Membership.Import.HeaderMapper
|
||||
|
||||
@preview_row_limit 3
|
||||
|
||||
@type numbered_row :: {pos_integer(), [String.t()]}
|
||||
|
||||
@type resolution :: %{
|
||||
groups_found: [%{id: String.t(), name: String.t()}],
|
||||
groups_to_create: [String.t()],
|
||||
fee_type_map: %{String.t() => String.t()},
|
||||
fee_type_warnings: [String.t()],
|
||||
has_empty_fee_type_cells?: boolean(),
|
||||
preview_rows: [[String.t()]]
|
||||
}
|
||||
|
||||
@doc """
|
||||
Resolves the group and fee-type columns of an import against the database and
|
||||
extracts preview rows.
|
||||
|
||||
Returns a map with `:groups_found`, `:groups_to_create`, `:fee_type_map`,
|
||||
`:fee_type_warnings`, `:has_empty_fee_type_cells?`, and `:preview_rows`.
|
||||
"""
|
||||
@spec resolve(map(), [numbered_row()], term()) :: resolution()
|
||||
def resolve(header_maps, rows, actor) do
|
||||
%{
|
||||
groups_found: groups_found,
|
||||
groups_to_create: groups_to_create
|
||||
} = resolve_groups(header_maps, rows, actor)
|
||||
|
||||
%{
|
||||
fee_type_map: fee_type_map,
|
||||
fee_type_warnings: fee_type_warnings,
|
||||
has_empty_fee_type_cells?: has_empty_fee_type_cells?
|
||||
} = resolve_fee_types(header_maps, rows, actor)
|
||||
|
||||
%{
|
||||
groups_found: groups_found,
|
||||
groups_to_create: groups_to_create,
|
||||
fee_type_map: fee_type_map,
|
||||
fee_type_warnings: fee_type_warnings,
|
||||
has_empty_fee_type_cells?: has_empty_fee_type_cells?,
|
||||
preview_rows: preview_rows(rows)
|
||||
}
|
||||
end
|
||||
|
||||
defp resolve_groups(%{groups_column_index: nil}, _rows, _actor) do
|
||||
%{groups_found: [], groups_to_create: []}
|
||||
end
|
||||
|
||||
defp resolve_groups(%{groups_column_index: index}, rows, actor) do
|
||||
existing_groups = list_groups(actor)
|
||||
lookup = build_group_lookup(existing_groups)
|
||||
|
||||
names = unique_group_names(rows, index)
|
||||
|
||||
{found, to_create} =
|
||||
Enum.reduce(names, {[], []}, fn name, {found, to_create} ->
|
||||
case Map.get(lookup, normalize_name(name)) do
|
||||
nil -> {found, [name | to_create]}
|
||||
group -> {[%{id: group.id, name: group.name} | found], to_create}
|
||||
end
|
||||
end)
|
||||
|
||||
%{groups_found: Enum.reverse(found), groups_to_create: Enum.reverse(to_create)}
|
||||
end
|
||||
|
||||
defp resolve_fee_types(%{fee_type_column_index: nil}, _rows, _actor) do
|
||||
%{fee_type_map: %{}, fee_type_warnings: [], has_empty_fee_type_cells?: false}
|
||||
end
|
||||
|
||||
defp resolve_fee_types(%{fee_type_column_index: index}, rows, actor) do
|
||||
lookup = build_fee_type_lookup(actor)
|
||||
|
||||
cells = Enum.map(rows, fn {_line, values} -> Enum.at(values, index) end)
|
||||
|
||||
has_empty? = Enum.any?(cells, &blank?/1)
|
||||
|
||||
{fee_type_map, warnings} =
|
||||
cells
|
||||
|> Enum.reject(&blank?/1)
|
||||
|> Enum.uniq_by(&normalize_fee_type_name/1)
|
||||
|> Enum.reduce({%{}, []}, fn name, {map, warnings} ->
|
||||
case Map.get(lookup, normalize_fee_type_name(name)) do
|
||||
nil -> {map, [String.trim(name) | warnings]}
|
||||
id -> {Map.put(map, normalize_fee_type_name(name), id), warnings}
|
||||
end
|
||||
end)
|
||||
|
||||
%{
|
||||
fee_type_map: fee_type_map,
|
||||
fee_type_warnings: Enum.reverse(warnings),
|
||||
has_empty_fee_type_cells?: has_empty?
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Normalizes a fee-type name using the same rules as CSV header normalization
|
||||
(trim, lowercase, transliterate, drop hyphens and whitespace).
|
||||
"""
|
||||
@spec normalize_fee_type_name(String.t() | nil) :: String.t()
|
||||
def normalize_fee_type_name(name) when is_binary(name), do: HeaderMapper.normalize_header(name)
|
||||
def normalize_fee_type_name(_), do: ""
|
||||
|
||||
defp build_fee_type_lookup(actor) do
|
||||
actor
|
||||
|> list_fee_types()
|
||||
|> Enum.reduce(%{}, fn fee_type, acc ->
|
||||
normalized = normalize_fee_type_name(fee_type.name)
|
||||
|
||||
if Map.has_key?(acc, normalized) do
|
||||
Logger.warning(
|
||||
"Multiple membership fee types normalize to #{inspect(normalized)}; using the first match for CSV import."
|
||||
)
|
||||
|
||||
acc
|
||||
else
|
||||
Map.put(acc, normalized, fee_type.id)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp list_fee_types(actor) do
|
||||
Mv.MembershipFees.list_membership_fee_types!(actor: actor)
|
||||
end
|
||||
|
||||
defp blank?(nil), do: true
|
||||
defp blank?(value) when is_binary(value), do: String.trim(value) == ""
|
||||
defp blank?(_), do: false
|
||||
|
||||
@doc """
|
||||
Finds an existing group by name (case-insensitive) or creates it.
|
||||
|
||||
Looks first in the pre-fetched `groups` list, then in the database (to catch
|
||||
groups created earlier in the same import), and only creates a new group when
|
||||
none is found. This keeps group resolution idempotent across re-imports.
|
||||
"""
|
||||
@spec create_or_find_group(String.t(), [Mv.Membership.Group.t()], term()) ::
|
||||
{:ok, Mv.Membership.Group.t()} | {:error, term()}
|
||||
def create_or_find_group(name, groups, actor) when is_binary(name) do
|
||||
trimmed = String.trim(name)
|
||||
normalized = normalize_name(trimmed)
|
||||
|
||||
case find_group_in_list(groups, normalized) do
|
||||
nil -> find_or_create_group(trimmed, normalized, actor)
|
||||
group -> {:ok, group}
|
||||
end
|
||||
end
|
||||
|
||||
defp find_group_in_list(groups, normalized) do
|
||||
Enum.find(groups, fn group -> normalize_name(group.name) == normalized end)
|
||||
end
|
||||
|
||||
defp find_or_create_group(trimmed, normalized, actor) do
|
||||
case fetch_group_by_normalized_name(normalized, actor) do
|
||||
nil -> create_group(trimmed, normalized, actor)
|
||||
group -> {:ok, group}
|
||||
end
|
||||
end
|
||||
|
||||
# Normalizes the Ash code-interface return to a two-shape result.
|
||||
#
|
||||
# On a create failure the group may have been created concurrently by another
|
||||
# import session between our read and our write (the DB unique index is the
|
||||
# final arbiter, and the name validation is fail-open). Re-fetch by normalized
|
||||
# name and link to the existing group rather than failing the row.
|
||||
defp create_group(name, normalized, actor) do
|
||||
case Mv.Membership.create_group(%{name: name}, actor: actor) do
|
||||
{:ok, %Mv.Membership.Group{} = group} ->
|
||||
{:ok, group}
|
||||
|
||||
{:error, reason} ->
|
||||
case fetch_group_by_normalized_name(normalized, actor) do
|
||||
nil -> {:error, reason}
|
||||
group -> {:ok, group}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Fetches a single group by case-insensitive name using a name-filtered query
|
||||
# rather than reading the whole groups table. `normalized` is the trimmed,
|
||||
# lower-cased name; the DB comparison uses LOWER(name) consistent with the
|
||||
# Group resource's case-insensitive uniqueness constraint.
|
||||
defp fetch_group_by_normalized_name(normalized, actor) do
|
||||
require Ash.Query
|
||||
|
||||
Mv.Membership.Group
|
||||
|> Ash.Query.filter(fragment("LOWER(?) = ?", name, ^normalized))
|
||||
|> Ash.read(actor: actor, domain: Mv.Membership)
|
||||
|> case do
|
||||
{:ok, [group | _]} -> group
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Splits a raw groups-cell value into trimmed, non-empty group names.
|
||||
"""
|
||||
@spec split_group_names(String.t() | nil) :: [String.t()]
|
||||
def split_group_names(nil), do: []
|
||||
|
||||
def split_group_names(cell) when is_binary(cell) do
|
||||
cell
|
||||
|> String.split(",")
|
||||
|> Enum.map(&String.trim/1)
|
||||
|> Enum.reject(&(&1 == ""))
|
||||
end
|
||||
|
||||
defp unique_group_names(rows, index) do
|
||||
rows
|
||||
|> Enum.flat_map(fn {_line, values} ->
|
||||
values
|
||||
|> Enum.at(index)
|
||||
|> split_group_names()
|
||||
end)
|
||||
|> Enum.uniq_by(&normalize_name/1)
|
||||
end
|
||||
|
||||
defp preview_rows(rows) do
|
||||
rows
|
||||
|> Enum.take(@preview_row_limit)
|
||||
|> Enum.map(fn {_line, values} -> values end)
|
||||
end
|
||||
|
||||
defp list_groups(actor) do
|
||||
Mv.Membership.list_groups!(actor: actor)
|
||||
end
|
||||
|
||||
defp build_group_lookup(groups) do
|
||||
Enum.reduce(groups, %{}, fn group, acc ->
|
||||
Map.put(acc, normalize_name(group.name), group)
|
||||
end)
|
||||
end
|
||||
|
||||
# Case-insensitive comparison consistent with the Group resource's
|
||||
# case-insensitive name uniqueness.
|
||||
defp normalize_name(name) when is_binary(name) do
|
||||
name |> String.trim() |> String.downcase()
|
||||
end
|
||||
end
|
||||
|
|
@ -100,8 +100,7 @@ defmodule Mv.Membership.Import.CsvParser do
|
|||
|> String.replace("\r", "\n")
|
||||
end
|
||||
|
||||
@spec get_parser(String.t()) ::
|
||||
Mv.Membership.Import.CsvParserSemicolon | Mv.Membership.Import.CsvParserComma
|
||||
@spec get_parser(String.t()) :: module()
|
||||
defp get_parser(";"), do: Mv.Membership.Import.CsvParserSemicolon
|
||||
defp get_parser(","), do: Mv.Membership.Import.CsvParserComma
|
||||
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: ","
|
||||
end
|
||||
|
||||
@spec header_field_count(
|
||||
Mv.Membership.Import.CsvParserSemicolon | Mv.Membership.Import.CsvParserComma,
|
||||
binary()
|
||||
) :: non_neg_integer()
|
||||
@spec header_field_count(module(), binary()) :: non_neg_integer()
|
||||
defp header_field_count(parser, header_record) do
|
||||
case parse_single_record(parser, header_record, nil) do
|
||||
{:ok, fields} -> Enum.count(fields, &(String.trim(&1) != ""))
|
||||
|
|
|
|||
|
|
@ -29,21 +29,12 @@ defmodule Mv.Membership.Import.HeaderMapper do
|
|||
|
||||
Supports English and German header variants (e.g. "Email" / "E-Mail", "Join Date" / "Beitrittsdatum").
|
||||
|
||||
## Special columns
|
||||
|
||||
- **groups** – Many-to-many relationship (through member_groups). Recognized via the
|
||||
`groups_column_index` key (headers `Groups`, `Gruppen`, `Gruppe`). Comma-separated
|
||||
names are resolved during processing; missing groups are auto-created.
|
||||
- **membership_fee_type** – Recognized via the `fee_type_column_index` key (headers
|
||||
`Fee Type`, `fee_type`, `membership_fee_type`, `Beitragsart`). Names are matched to
|
||||
existing fee types; unknown names fall back to the default fee type.
|
||||
|
||||
## Fields not supported for import
|
||||
|
||||
- **membership_fee_status** – Computed (calculation from membership fee cycles). Not stored;
|
||||
cannot be set via CSV. Export can include it. Fee-status header variants
|
||||
(`Membership Fee Status`, `Bezahlstatus`, `Mitgliedsbeitragsstatus`) are explicitly
|
||||
placed in the `ignored` list and never mapped.
|
||||
cannot be set via CSV. Export can include it.
|
||||
- **groups** – Many-to-many relationship (through member_groups). Import would require
|
||||
resolving group names/slugs to IDs and creating associations; not in current import scope.
|
||||
|
||||
## Custom Field Detection
|
||||
|
||||
|
|
@ -56,10 +47,10 @@ defmodule Mv.Membership.Import.HeaderMapper do
|
|||
"e-mail"
|
||||
|
||||
iex> HeaderMapper.build_maps(["Email", "First Name"], [])
|
||||
{:ok, %{member: %{email: 0, first_name: 1}, custom: %{}, unknown: [], ignored: [], groups_column_index: nil, fee_type_column_index: nil}}
|
||||
{:ok, %{member: %{email: 0, first_name: 1}, custom: %{}, unknown: []}}
|
||||
|
||||
iex> HeaderMapper.build_maps(["Email", "CustomField"], [%{id: "cf1", name: "CustomField"}])
|
||||
{:ok, %{member: %{email: 0}, custom: %{"cf1" => 1}, unknown: [], ignored: [], groups_column_index: nil, fee_type_column_index: nil}}
|
||||
{:ok, %{member: %{email: 0}, custom: %{"cf1" => 1}, unknown: []}}
|
||||
"""
|
||||
|
||||
@type column_map :: %{atom() => non_neg_integer()}
|
||||
|
|
@ -69,33 +60,6 @@ defmodule Mv.Membership.Import.HeaderMapper do
|
|||
# Required member fields
|
||||
@required_member_fields [:email]
|
||||
|
||||
# Fee-status header variants that must never be imported (computed/read-only field).
|
||||
# Stored already-normalized; checked before member, custom, groups, and fee-type mapping.
|
||||
# Maintain this list when new locale translations for fee-status are added.
|
||||
@ignored_normalized [
|
||||
"membershipfeestatus",
|
||||
"mitgliedsbeitragsstatus",
|
||||
"bezahlstatus",
|
||||
# DE export label for membership_fee_start_date — system-managed, not importable
|
||||
"startdatummitgliedsbeitrag"
|
||||
]
|
||||
|
||||
# Normalized header variants for the groups column. The column is resolved to
|
||||
# group associations during import; it is never a member or custom field.
|
||||
@groups_column_normalized [
|
||||
"groups",
|
||||
"gruppen",
|
||||
"gruppe"
|
||||
]
|
||||
|
||||
# Normalized header variants for the membership fee-type column. The column is
|
||||
# resolved to a MembershipFeeType during import; it is never a member or custom field.
|
||||
@fee_type_column_normalized [
|
||||
"membershipfeetype",
|
||||
"feetype",
|
||||
"beitragsart"
|
||||
]
|
||||
|
||||
# Canonical member fields with their raw variants
|
||||
# These will be normalized at runtime when building the lookup map
|
||||
@member_field_variants_raw %{
|
||||
|
|
@ -275,79 +239,30 @@ defmodule Mv.Membership.Import.HeaderMapper do
|
|||
|
||||
## Returns
|
||||
|
||||
- `{:ok, %{member: column_map, custom: custom_field_map, unknown: unknown_headers,
|
||||
ignored: [non_neg_integer], groups_column_index: non_neg_integer | nil,
|
||||
fee_type_column_index: non_neg_integer | nil}}` on success
|
||||
- `{:ok, %{member: column_map, custom: custom_field_map, unknown: unknown_headers}}` on success
|
||||
- `{:error, reason}` on error (missing required field, duplicate headers)
|
||||
|
||||
The `ignored` list holds the indices of fee-status columns (computed/read-only),
|
||||
which are never mapped to member or custom fields.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> build_maps(["Email", "First Name"], [])
|
||||
{:ok, %{member: %{email: 0, first_name: 1}, custom: %{}, unknown: [], ignored: [], groups_column_index: nil, fee_type_column_index: nil}}
|
||||
{:ok, %{member: %{email: 0, first_name: 1}, custom: %{}, unknown: []}}
|
||||
|
||||
iex> build_maps(["Email", "CustomField"], [%{id: "cf1", name: "CustomField"}])
|
||||
{:ok, %{member: %{email: 0}, custom: %{"cf1" => 1}, unknown: [], ignored: [], groups_column_index: nil, fee_type_column_index: nil}}
|
||||
{:ok, %{member: %{email: 0}, custom: %{"cf1" => 1}, unknown: []}}
|
||||
|
||||
"""
|
||||
@spec build_maps([String.t()], [map()]) ::
|
||||
{:ok,
|
||||
%{
|
||||
member: column_map(),
|
||||
custom: custom_field_map(),
|
||||
unknown: unknown_headers(),
|
||||
ignored: [non_neg_integer()],
|
||||
groups_column_index: non_neg_integer() | nil,
|
||||
fee_type_column_index: non_neg_integer() | nil
|
||||
}}
|
||||
{:ok, %{member: column_map(), custom: custom_field_map(), unknown: unknown_headers()}}
|
||||
| {:error, String.t()}
|
||||
def build_maps(headers, custom_fields) when is_list(headers) and is_list(custom_fields) do
|
||||
ignored = ignored_indices(headers)
|
||||
groups_column_index = first_matching_index(headers, @groups_column_normalized)
|
||||
fee_type_column_index = first_matching_index(headers, @fee_type_column_normalized)
|
||||
|
||||
reserved =
|
||||
[groups_column_index, fee_type_column_index | ignored]
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> MapSet.new()
|
||||
|
||||
with {:ok, member_map, unknown_after_member} <- build_member_map(headers, reserved),
|
||||
with {:ok, member_map, unknown_after_member} <- build_member_map(headers),
|
||||
{:ok, custom_map, unknown_after_custom} <-
|
||||
build_custom_field_map(headers, unknown_after_member, custom_fields, member_map) do
|
||||
unknown = Enum.map(unknown_after_custom, &Enum.at(headers, &1))
|
||||
|
||||
{:ok,
|
||||
%{
|
||||
member: member_map,
|
||||
custom: custom_map,
|
||||
unknown: unknown,
|
||||
ignored: ignored,
|
||||
groups_column_index: groups_column_index,
|
||||
fee_type_column_index: fee_type_column_index
|
||||
}}
|
||||
{:ok, %{member: member_map, custom: custom_map, unknown: unknown}}
|
||||
end
|
||||
end
|
||||
|
||||
# Returns the index of the first header whose normalized form is in `variants`,
|
||||
# or nil if none match.
|
||||
defp first_matching_index(headers, variants) do
|
||||
headers
|
||||
|> Enum.with_index()
|
||||
|> Enum.find_value(fn {header, index} ->
|
||||
if normalize_header(header) in variants, do: index
|
||||
end)
|
||||
end
|
||||
|
||||
# Returns the column indices whose normalized header is in the fee-status ignore list.
|
||||
defp ignored_indices(headers) do
|
||||
headers
|
||||
|> Enum.with_index()
|
||||
|> Enum.filter(fn {header, _index} -> normalize_header(header) in @ignored_normalized end)
|
||||
|> Enum.map(fn {_header, index} -> index end)
|
||||
end
|
||||
|
||||
# --- Private Functions ---
|
||||
|
||||
# Transliterates German umlauts and special characters
|
||||
|
|
@ -389,14 +304,13 @@ defmodule Mv.Membership.Import.HeaderMapper do
|
|||
|> String.replace(" ", "")
|
||||
end
|
||||
|
||||
# Builds member field column map, skipping reserved (e.g. ignored) indices.
|
||||
defp build_member_map(headers, reserved) do
|
||||
# Builds member field column map
|
||||
defp build_member_map(headers) do
|
||||
result =
|
||||
headers
|
||||
|> Enum.with_index()
|
||||
|> Enum.reduce_while({%{}, []}, fn {header, index}, {acc_map, acc_unknown} ->
|
||||
normalized =
|
||||
if MapSet.member?(reserved, index), do: "", else: normalize_header(header)
|
||||
normalized = normalize_header(header)
|
||||
|
||||
case process_member_header(header, index, normalized, acc_map, %{}) do
|
||||
{:error, reason} ->
|
||||
|
|
|
|||
|
|
@ -26,8 +26,14 @@ defmodule Mv.Membership.Import.ImportRunner do
|
|||
{: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, to_string(:file.format_error(reason))}
|
||||
{:error, Exception.message(reason)}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -80,7 +86,7 @@ defmodule Mv.Membership.Import.ImportRunner do
|
|||
all_errors = progress.errors ++ chunk_result.errors
|
||||
new_errors = Enum.take(all_errors, max_errors)
|
||||
errors_truncated? = length(all_errors) > max_errors
|
||||
new_warnings = Enum.uniq(progress.warnings ++ Map.get(chunk_result, :warnings, []))
|
||||
new_warnings = progress.warnings ++ Map.get(chunk_result, :warnings, [])
|
||||
|
||||
chunks_processed = current_chunk_idx + 1
|
||||
new_status = if chunks_processed >= progress.total_chunks, do: :done, else: :running
|
||||
|
|
@ -97,20 +103,6 @@ defmodule Mv.Membership.Import.ImportRunner do
|
|||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Carries the in-memory group snapshot grown by a chunk back into `import_state`
|
||||
so the next chunk reuses groups created earlier instead of re-reading the
|
||||
Group table. When the chunk result omits `groups_found`, the state is returned
|
||||
unchanged.
|
||||
"""
|
||||
@spec carry_groups_forward(map(), map()) :: map()
|
||||
def carry_groups_forward(import_state, chunk_result) do
|
||||
case Map.fetch(chunk_result, :groups_found) do
|
||||
{:ok, groups_found} -> Map.put(import_state, :groups_found, groups_found)
|
||||
:error -> import_state
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the next action after processing a chunk: send the next chunk index or done.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
|
||||
This module provides the core API for CSV member import functionality:
|
||||
- `prepare/2` - Parses and validates CSV content, returns import state
|
||||
- `process_chunk/4` - Processes a chunk of rows and creates members
|
||||
- `process_chunk/3` - Processes a chunk of rows and creates members
|
||||
|
||||
## Error Handling
|
||||
|
||||
|
|
@ -22,24 +22,13 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
- `column_map` - Map of canonical field names to column indices
|
||||
- `custom_field_map` - Map of custom field names to column indices
|
||||
- `warnings` - List of warning messages (e.g., unknown custom field columns)
|
||||
- `headers` - The raw CSV header row
|
||||
- `ignored` - Header names of ignored (fee-status) columns
|
||||
- `groups_column_index` / `fee_type_column_index` - Indices for resolved columns (or nil)
|
||||
- `groups_found` / `groups_to_create` - Existing and to-be-created groups from the preview
|
||||
- `fee_type_map` - Normalized fee-type name to id, for matched fee types
|
||||
- `fee_type_warnings` - Unmatched fee-type names surfaced in the preview
|
||||
- `has_empty_fee_type_cells?` - Whether any fee-type cell is blank (default applies)
|
||||
- `preview_rows` - Up to 3 sample data rows for the mapping preview
|
||||
|
||||
## Chunk Results
|
||||
|
||||
The `chunk_result` returned by `process_chunk/4` contains:
|
||||
The `chunk_result` returned by `process_chunk/3` contains:
|
||||
- `inserted` - Number of successfully created members
|
||||
- `failed` - Number of failed member creations
|
||||
- `errors` - List of `%MemberCSV.Error{}` structs (capped at 50 per import)
|
||||
- `groups_found` - The in-memory group snapshot grown while processing this
|
||||
chunk; thread it into the next chunk's `:groups_found` opt so groups created
|
||||
in an earlier chunk are reused without re-reading the Group table
|
||||
|
||||
## Examples
|
||||
|
||||
|
|
@ -48,9 +37,7 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
|
||||
# Process first chunk
|
||||
chunk = Enum.at(import_state.chunks, 0)
|
||||
|
||||
{:ok, result} =
|
||||
MemberCSV.process_chunk(chunk, import_state.column_map, import_state.custom_field_map, [])
|
||||
{:ok, result} = MemberCSV.process_chunk(chunk, import_state.column_map)
|
||||
"""
|
||||
|
||||
defmodule Error do
|
||||
|
|
@ -79,29 +66,16 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
custom_field_lookup: %{
|
||||
String.t() => %{id: String.t(), value_type: atom(), name: String.t()}
|
||||
},
|
||||
warnings: list(String.t()),
|
||||
headers: list(String.t()),
|
||||
ignored: list(String.t()),
|
||||
groups_column_index: non_neg_integer() | nil,
|
||||
fee_type_column_index: non_neg_integer() | nil,
|
||||
groups_found: list(%{id: String.t(), name: String.t()}),
|
||||
groups_to_create: list(String.t()),
|
||||
fee_type_map: %{String.t() => String.t()},
|
||||
fee_type_warnings: list(String.t()),
|
||||
has_empty_fee_type_cells?: boolean(),
|
||||
preview_rows: list(list(String.t()))
|
||||
warnings: list(String.t())
|
||||
}
|
||||
|
||||
@type chunk_result :: %{
|
||||
inserted: non_neg_integer(),
|
||||
failed: non_neg_integer(),
|
||||
errors: list(Error.t()),
|
||||
errors_truncated?: boolean(),
|
||||
warnings: list(String.t()),
|
||||
groups_found: list(Mv.Membership.Group.t() | %{id: String.t(), name: String.t()})
|
||||
errors_truncated?: boolean()
|
||||
}
|
||||
|
||||
alias Mv.Membership.Import.ColumnResolver
|
||||
alias Mv.Membership.Import.CsvParser
|
||||
alias Mv.Membership.Import.HeaderMapper
|
||||
|
||||
|
|
@ -165,27 +139,13 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
# Build custom field lookup for efficient value processing
|
||||
custom_field_lookup = build_custom_field_lookup(custom_fields)
|
||||
|
||||
# Resolve DB-backed columns (groups, fee types) read-only for the preview.
|
||||
resolution = ColumnResolver.resolve(maps, rows, actor)
|
||||
ignored_headers = Enum.map(maps.ignored, &Enum.at(headers, &1))
|
||||
|
||||
{:ok,
|
||||
%{
|
||||
chunks: chunks,
|
||||
column_map: maps.member,
|
||||
custom_field_map: maps.custom,
|
||||
custom_field_lookup: custom_field_lookup,
|
||||
warnings: warnings,
|
||||
headers: headers,
|
||||
ignored: ignored_headers,
|
||||
groups_column_index: maps.groups_column_index,
|
||||
fee_type_column_index: maps.fee_type_column_index,
|
||||
groups_found: resolution.groups_found,
|
||||
groups_to_create: resolution.groups_to_create,
|
||||
fee_type_map: resolution.fee_type_map,
|
||||
fee_type_warnings: resolution.fee_type_warnings,
|
||||
has_empty_fee_type_cells?: resolution.has_empty_fee_type_cells?,
|
||||
preview_rows: resolution.preview_rows
|
||||
warnings: warnings
|
||||
}}
|
||||
end
|
||||
end
|
||||
|
|
@ -220,7 +180,7 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
end)
|
||||
|
||||
case HeaderMapper.build_maps(headers, custom_field_maps) do
|
||||
{:ok, %{unknown: unknown} = maps} ->
|
||||
{:ok, %{member: member_map, custom: custom_map, unknown: unknown}} ->
|
||||
# Build warnings for unknown custom field columns
|
||||
warnings =
|
||||
unknown
|
||||
|
|
@ -237,7 +197,7 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
)
|
||||
end)
|
||||
|
||||
{:ok, maps, warnings}
|
||||
{:ok, %{member: member_map, custom: custom_map}, warnings}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
|
|
@ -250,6 +210,8 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
MapSet.member?(HeaderMapper.known_member_fields(), normalized)
|
||||
end
|
||||
|
||||
defp member_field?(_), do: false
|
||||
|
||||
# Validates that row count doesn't exceed limit
|
||||
defp validate_row_count(rows, max_rows) do
|
||||
if length(rows) > max_rows do
|
||||
|
|
@ -290,20 +252,9 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
Map.put(acc, custom_field_id, value)
|
||||
end)
|
||||
|
||||
%{
|
||||
member: member_map,
|
||||
custom: custom_map,
|
||||
fee_type: cell_at(row_tuple, tuple_size, maps.fee_type_column_index),
|
||||
groups: cell_at(row_tuple, tuple_size, maps.groups_column_index)
|
||||
}
|
||||
%{member: member_map, custom: custom_map}
|
||||
end
|
||||
|
||||
# Returns the raw cell at the given index, or nil if the column is absent.
|
||||
defp cell_at(_row_tuple, _size, nil), do: nil
|
||||
|
||||
defp cell_at(row_tuple, size, index) when index < size, do: elem(row_tuple, index)
|
||||
defp cell_at(_row_tuple, _size, _index), do: ""
|
||||
|
||||
@doc """
|
||||
Processes a chunk of CSV rows and creates members.
|
||||
|
||||
|
|
@ -319,18 +270,12 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
- `chunk_rows_with_lines` - List of tuples `{csv_line_number, row_map}` where:
|
||||
- `csv_line_number` - Physical line number in CSV (1-based)
|
||||
- `row_map` - Map with `:member` and `:custom` keys containing field values
|
||||
- `column_map` - Unused; kept for backward-compatible call sites. Field values are
|
||||
read from each row's pre-built `:member`/`:custom` maps, not from this argument.
|
||||
- `custom_field_map` - Unused; kept for backward-compatible call sites (see above).
|
||||
- `column_map` - Map of canonical field names (atoms) to column indices (for reference)
|
||||
- `custom_field_map` - Map of custom field IDs (strings) to column indices (for reference)
|
||||
- `opts` - Optional keyword list for processing options:
|
||||
- `:custom_field_lookup` - Map of custom field IDs to metadata (default: `%{}`)
|
||||
- `:existing_error_count` - Number of errors already collected in previous chunks (default: `0`)
|
||||
- `:max_errors` - Maximum number of errors to collect per import overall (default: `50`)
|
||||
- `:actor` - Actor used for all writes (default: the system actor)
|
||||
- `:fee_type_map` - Map of normalized fee-type name to fee-type id, used to resolve
|
||||
each row's fee-type cell (default: `%{}`)
|
||||
- `:groups_found` - List of pre-fetched `Group` structs seeding in-memory group
|
||||
resolution; the snapshot grows as groups are auto-created (default: `[]`)
|
||||
|
||||
## Error Capping
|
||||
|
||||
|
|
@ -369,49 +314,27 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
existing_error_count = Keyword.get(opts, :existing_error_count, 0)
|
||||
max_errors = Keyword.get(opts, :max_errors, @default_max_errors)
|
||||
actor = Keyword.get(opts, :actor, SystemActor.get_system_actor())
|
||||
fee_type_map = Keyword.get(opts, :fee_type_map, %{})
|
||||
groups_found = Keyword.get(opts, :groups_found, [])
|
||||
|
||||
base_row_opts = %{
|
||||
custom_field_lookup: custom_field_lookup,
|
||||
fee_type_map: fee_type_map,
|
||||
actor: actor
|
||||
}
|
||||
|
||||
{inserted, failed, errors, _collected_error_count, truncated?, warnings, groups_acc} =
|
||||
Enum.reduce(chunk_rows_with_lines, {0, 0, [], 0, false, [], groups_found}, fn {line_number,
|
||||
row_map},
|
||||
{acc_inserted,
|
||||
acc_failed,
|
||||
acc_errors,
|
||||
acc_error_count,
|
||||
acc_truncated?,
|
||||
acc_warnings,
|
||||
acc_groups} ->
|
||||
{inserted, failed, errors, _collected_error_count, truncated?} =
|
||||
Enum.reduce(chunk_rows_with_lines, {0, 0, [], 0, false}, fn {line_number, row_map},
|
||||
{acc_inserted, acc_failed,
|
||||
acc_errors, acc_error_count,
|
||||
acc_truncated?} ->
|
||||
current_error_count = existing_error_count + acc_error_count
|
||||
row_opts = Map.put(base_row_opts, :groups_found, acc_groups)
|
||||
|
||||
case process_row(row_map, line_number, row_opts) do
|
||||
{:ok, _member, row_warnings, new_groups} ->
|
||||
{new_inserted, new_failed, new_errors, new_error_count, new_truncated?} =
|
||||
update_inserted(
|
||||
{acc_inserted, acc_failed, acc_errors, acc_error_count, acc_truncated?}
|
||||
)
|
||||
case process_row(row_map, line_number, custom_field_lookup, actor) do
|
||||
{:ok, _member} ->
|
||||
update_inserted(
|
||||
{acc_inserted, acc_failed, acc_errors, acc_error_count, acc_truncated?}
|
||||
)
|
||||
|
||||
{new_inserted, new_failed, new_errors, new_error_count, new_truncated?,
|
||||
acc_warnings ++ row_warnings, new_groups}
|
||||
|
||||
{:error, error, new_groups} ->
|
||||
{new_inserted, new_failed, new_errors, new_error_count, new_truncated?} =
|
||||
handle_row_error(
|
||||
{acc_inserted, acc_failed, acc_errors, acc_error_count, acc_truncated?},
|
||||
error,
|
||||
current_error_count,
|
||||
max_errors
|
||||
)
|
||||
|
||||
{new_inserted, new_failed, new_errors, new_error_count, new_truncated?, acc_warnings,
|
||||
new_groups}
|
||||
{:error, error} ->
|
||||
handle_row_error(
|
||||
{acc_inserted, acc_failed, acc_errors, acc_error_count, acc_truncated?},
|
||||
error,
|
||||
current_error_count,
|
||||
max_errors
|
||||
)
|
||||
end
|
||||
end)
|
||||
|
||||
|
|
@ -420,9 +343,7 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
inserted: inserted,
|
||||
failed: failed,
|
||||
errors: Enum.reverse(errors),
|
||||
errors_truncated?: truncated?,
|
||||
warnings: warnings,
|
||||
groups_found: groups_acc
|
||||
errors_truncated?: truncated?
|
||||
}}
|
||||
end
|
||||
|
||||
|
|
@ -586,27 +507,18 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
|
||||
defp gettext_error_message(_), do: gettext("Email is invalid.")
|
||||
|
||||
# Processes a single row and creates member with custom field values.
|
||||
# On success returns {:ok, member, warnings, groups}; warnings carry non-fatal
|
||||
# notices such as an unresolved fee-type name. The returned groups list is the
|
||||
# accumulated in-memory group snapshot (seeded from the chunk, grown with any
|
||||
# group created while linking this row) so later rows reuse it instead of
|
||||
# re-reading the whole Group table per row.
|
||||
# Processes a single row and creates member with custom field values
|
||||
defp process_row(
|
||||
row_map,
|
||||
line_number,
|
||||
%{
|
||||
custom_field_lookup: custom_field_lookup,
|
||||
fee_type_map: fee_type_map,
|
||||
groups_found: groups_found,
|
||||
actor: actor
|
||||
} = _row_opts
|
||||
custom_field_lookup,
|
||||
actor
|
||||
) do
|
||||
# Validate row before database insertion
|
||||
case validate_row(row_map, line_number, []) do
|
||||
{:error, error} ->
|
||||
# Return validation error immediately, no DB insert attempted
|
||||
{:error, error, groups_found}
|
||||
{:error, error}
|
||||
|
||||
{:ok, %{member: trimmed_member_attrs, custom: custom_attrs}} ->
|
||||
# Prepare custom field values for Ash
|
||||
|
|
@ -614,119 +526,20 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
{:error, validation_errors} ->
|
||||
# Custom field validation errors - return first error
|
||||
first_error = List.first(validation_errors)
|
||||
|
||||
{:error, %Error{csv_line_number: line_number, field: nil, message: first_error},
|
||||
groups_found}
|
||||
{:error, %Error{csv_line_number: line_number, field: nil, message: first_error}}
|
||||
|
||||
{:ok, custom_field_values} ->
|
||||
{fee_attrs, warnings} =
|
||||
resolve_fee_type_attrs(Map.get(row_map, :fee_type), fee_type_map)
|
||||
|
||||
create_member_and_assign_groups(
|
||||
Map.merge(trimmed_member_attrs, fee_attrs),
|
||||
create_member_with_custom_fields(
|
||||
trimmed_member_attrs,
|
||||
custom_field_values,
|
||||
Map.get(row_map, :groups),
|
||||
groups_found,
|
||||
line_number,
|
||||
actor,
|
||||
warnings
|
||||
actor
|
||||
)
|
||||
end
|
||||
end
|
||||
rescue
|
||||
e ->
|
||||
{:error, %Error{csv_line_number: line_number, field: nil, message: Exception.message(e)},
|
||||
groups_found}
|
||||
end
|
||||
|
||||
# Creates the member, then assigns groups as a post-creation step. A group
|
||||
# assignment failure fails the row (the member was already created, but the
|
||||
# row is reported as failed so the operator can act on it).
|
||||
defp create_member_and_assign_groups(
|
||||
member_attrs,
|
||||
custom_field_values,
|
||||
groups_cell,
|
||||
groups_found,
|
||||
line_number,
|
||||
actor,
|
||||
warnings
|
||||
) do
|
||||
case create_member_with_custom_fields(
|
||||
member_attrs,
|
||||
custom_field_values,
|
||||
line_number,
|
||||
actor,
|
||||
warnings
|
||||
) do
|
||||
{:ok, member, member_warnings} ->
|
||||
assign_groups(member, groups_cell, groups_found, line_number, actor, member_warnings)
|
||||
|
||||
{:error, error} ->
|
||||
{:error, error, groups_found}
|
||||
end
|
||||
end
|
||||
|
||||
# Assigns the member to all groups listed in the cell, creating missing groups.
|
||||
# Returns the (possibly grown) group snapshot so the caller can reuse it.
|
||||
defp assign_groups(member, groups_cell, groups_found, line_number, actor, warnings) do
|
||||
names = ColumnResolver.split_group_names(groups_cell)
|
||||
|
||||
Enum.reduce_while(names, {:ok, member, warnings, groups_found}, fn name,
|
||||
{:ok, _m, _w, acc_groups} ->
|
||||
case link_member_to_group(member, name, acc_groups, actor) do
|
||||
{:ok, group} ->
|
||||
{:cont, {:ok, member, warnings, add_group(acc_groups, group)}}
|
||||
|
||||
{:error, reason} ->
|
||||
{:halt,
|
||||
{:error,
|
||||
%Error{
|
||||
csv_line_number: line_number,
|
||||
field: nil,
|
||||
message: gettext("Group assignment failed: %{reason}", reason: inspect(reason))
|
||||
}, acc_groups}}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp add_group(groups, group) do
|
||||
if Enum.any?(groups, &(&1.id == group.id)), do: groups, else: [group | groups]
|
||||
end
|
||||
|
||||
defp link_member_to_group(member, name, groups_found, actor) do
|
||||
with {:ok, group} <- ColumnResolver.create_or_find_group(name, groups_found, actor),
|
||||
{:ok, _member_group} <-
|
||||
Mv.Membership.create_member_group(
|
||||
%{member_id: member.id, group_id: group.id},
|
||||
actor: actor
|
||||
) do
|
||||
{:ok, group}
|
||||
end
|
||||
end
|
||||
|
||||
# Resolves the fee-type cell into member attrs plus optional warnings.
|
||||
# Empty cell -> default fee type (SetDefaultMembershipFeeType), no warning.
|
||||
# Matched name -> membership_fee_type_id attr.
|
||||
# Unmatched name -> no attr (default applies), warning naming the value.
|
||||
defp resolve_fee_type_attrs(nil, _fee_type_map), do: {%{}, []}
|
||||
|
||||
defp resolve_fee_type_attrs(cell, fee_type_map) when is_binary(cell) do
|
||||
trimmed = String.trim(cell)
|
||||
|
||||
if trimmed == "" do
|
||||
{%{}, []}
|
||||
else
|
||||
case Map.get(fee_type_map, ColumnResolver.normalize_fee_type_name(trimmed)) do
|
||||
nil ->
|
||||
{%{},
|
||||
[
|
||||
gettext("Fee type '%{name}' not found; using the default fee type.", name: trimmed)
|
||||
]}
|
||||
|
||||
fee_type_id ->
|
||||
{%{membership_fee_type_id: fee_type_id}, []}
|
||||
end
|
||||
end
|
||||
{:error, %Error{csv_line_number: line_number, field: nil, message: Exception.message(e)}}
|
||||
end
|
||||
|
||||
# Creates a member with custom field values, handling errors appropriately
|
||||
|
|
@ -734,8 +547,7 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
trimmed_member_attrs,
|
||||
custom_field_values,
|
||||
line_number,
|
||||
actor,
|
||||
warnings
|
||||
actor
|
||||
) do
|
||||
# Convert empty strings to nil for date fields so Ash accepts them
|
||||
member_attrs = sanitize_date_fields(trimmed_member_attrs)
|
||||
|
|
@ -755,7 +567,7 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
|
||||
case Mv.Membership.create_member(final_attrs, actor: actor) do
|
||||
{:ok, member} ->
|
||||
{:ok, member, warnings}
|
||||
{:ok, member}
|
||||
|
||||
{:error, %Ash.Error.Invalid{} = error} ->
|
||||
# Extract email from final_attrs for better error messages
|
||||
|
|
|
|||
|
|
@ -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
|
||||
defp resolve_actor(changeset, context) do
|
||||
ctx = changeset.context
|
||||
ctx = changeset.context || %{}
|
||||
|
||||
get_in(ctx, [:private, :actor]) ||
|
||||
Map.get(ctx, :actor) ||
|
||||
|
|
|
|||
|
|
@ -16,21 +16,6 @@ defmodule Mv.Membership.MemberExport do
|
|||
alias MvWeb.MemberLive.Index
|
||||
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)) ++
|
||||
["membership_fee_type", "membership_fee_status", "groups"]
|
||||
@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,
|
||||
:show_current_cycle, :cycle_status_filter, :boolean_filters.
|
||||
"""
|
||||
@spec parse_params(map()) :: parsed_params()
|
||||
@spec parse_params(map()) :: map()
|
||||
def parse_params(params) do
|
||||
# DB fields come from "member_fields"
|
||||
raw_member_fields = extract_list(params, "member_fields")
|
||||
|
|
@ -473,6 +458,9 @@ defmodule Mv.Membership.MemberExport do
|
|||
computed_fields,
|
||||
member_fields
|
||||
) do
|
||||
computed_fields = computed_fields || []
|
||||
member_fields = member_fields || []
|
||||
|
||||
db_with_insert =
|
||||
Enum.flat_map(db_fields_ordered, fn f ->
|
||||
expand_field_with_computed(f, member_fields, computed_fields)
|
||||
|
|
@ -519,4 +507,6 @@ defmodule Mv.Membership.MemberExport do
|
|||
other -> other
|
||||
end)
|
||||
end
|
||||
|
||||
defp normalize_computed_fields(_), do: []
|
||||
end
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ defmodule Mv.Membership.MembersCSV do
|
|||
Returns iodata suitable for `IO.iodata_to_binary/1` or sending as response body.
|
||||
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
|
||||
header = build_header(columns)
|
||||
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
|
||||
# Set locale for translations
|
||||
_ = Gettext.put_locale(MvWeb.Gettext, locale)
|
||||
Gettext.put_locale(MvWeb.Gettext, locale)
|
||||
|
||||
headers = Enum.map(export_data.columns, & &1.label)
|
||||
column_count = length(export_data.columns)
|
||||
|
|
@ -211,6 +211,9 @@ defmodule Mv.Membership.MembersPDF do
|
|||
{:ok, datetime, _offset} ->
|
||||
format_datetime(datetime, locale)
|
||||
|
||||
{:ok, datetime} ->
|
||||
format_datetime(datetime, locale)
|
||||
|
||||
{:error, _} ->
|
||||
# Try NaiveDateTime if DateTime parsing fails
|
||||
case NaiveDateTime.from_iso8601(iso8601_string) do
|
||||
|
|
@ -254,6 +257,8 @@ defmodule Mv.Membership.MembersPDF do
|
|||
end
|
||||
end
|
||||
|
||||
defp format_date(_, _), do: ""
|
||||
|
||||
defp format_dates_in_rows(rows, columns, locale) do
|
||||
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
|
||||
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)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ defmodule Mv.MembershipFees.CycleGenerationJob do
|
|||
{: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
|
||||
Logger.info("Starting membership fee cycle generation job")
|
||||
start_time = System.monotonic_time(:millisecond)
|
||||
|
|
@ -98,7 +98,7 @@ defmodule Mv.MembershipFees.CycleGenerationJob do
|
|||
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
|
||||
Logger.info("Starting membership fee cycle generation job with opts: #{inspect(opts)}")
|
||||
start_time = System.monotonic_time(:millisecond)
|
||||
|
|
@ -135,7 +135,7 @@ defmodule Mv.MembershipFees.CycleGenerationJob do
|
|||
- `{: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
|
||||
today = Date.utc_today()
|
||||
|
||||
|
|
@ -166,7 +166,7 @@ defmodule Mv.MembershipFees.CycleGenerationJob do
|
|||
- `{: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
|
||||
Logger.info("Generating cycles for member #{member_id}")
|
||||
CycleGenerator.generate_cycles_for_member(member_id)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,4 @@
|
|||
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 """
|
||||
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)
|
||||
|
||||
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
|
||||
{:ok, cycles, notifications} ->
|
||||
|
|
@ -166,8 +159,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
|||
- `{:error, reason}` - Error with reason
|
||||
|
||||
"""
|
||||
@spec generate_cycles_for_all_members(keyword()) ::
|
||||
{:ok, results_summary()} | {:error, Ash.Error.t()}
|
||||
@spec generate_cycles_for_all_members(keyword()) :: {:ok, map()} | {:error, term()}
|
||||
def generate_cycles_for_all_members(opts \\ []) do
|
||||
today = Keyword.get(opts, :today, Date.utc_today())
|
||||
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
|
||||
case generate_cycles_for_member(member, today: today) do
|
||||
{:ok, _cycles, notifications} = ok ->
|
||||
_ = send_notifications_for_batch_job(notifications)
|
||||
send_notifications_for_batch_job(notifications)
|
||||
{member.id, ok}
|
||||
|
||||
{: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
|
||||
end
|
||||
|
||||
defp safe_get_atom(_map, _key), do: nil
|
||||
|
||||
defp peek_jwt_claims(token) do
|
||||
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\"."
|
||||
def oidc_groups_claim do
|
||||
Mv.Config.oidc_groups_claim()
|
||||
Mv.Config.oidc_groups_claim() || "groups"
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ defmodule Mv.Release do
|
|||
require Logger
|
||||
|
||||
def migrate do
|
||||
_ = load_app()
|
||||
load_app()
|
||||
|
||||
for repo <- repos() do
|
||||
{: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")
|
||||
|
||||
prev = Code.compiler_options()
|
||||
_ = Code.compiler_options(ignore_module_conflict: true)
|
||||
Code.compiler_options(ignore_module_conflict: true)
|
||||
|
||||
try do
|
||||
_ = Code.eval_file(bootstrap_path)
|
||||
Code.eval_file(bootstrap_path)
|
||||
IO.puts("✅ Bootstrap seeds completed.")
|
||||
|
||||
if System.get_env("RUN_DEV_SEEDS") == "true" do
|
||||
_ = Code.eval_file(dev_path)
|
||||
Code.eval_file(dev_path)
|
||||
IO.puts("✅ Dev seeds completed.")
|
||||
end
|
||||
after
|
||||
|
|
@ -92,7 +92,7 @@ defmodule Mv.Release do
|
|||
end
|
||||
|
||||
def rollback(repo, version) do
|
||||
_ = load_app()
|
||||
load_app()
|
||||
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
|
||||
end
|
||||
|
||||
|
|
@ -139,11 +139,10 @@ defmodule Mv.Release do
|
|||
{:ok, %Role{} = admin_role} ->
|
||||
case get_user_by_email(email) do
|
||||
{:ok, %User{} = user} ->
|
||||
_ =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||
|> Ash.update!(authorize?: false)
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||
|> Ash.update!(authorize?: false)
|
||||
|
||||
:ok
|
||||
|
||||
|
|
@ -190,16 +189,15 @@ defmodule Mv.Release do
|
|||
defp create_admin_user(email, password, admin_role) do
|
||||
case Accounts.create_user(%{email: email}, authorize?: false) do
|
||||
{:ok, user} ->
|
||||
_ =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:admin_set_password, %{password: password})
|
||||
user
|
||||
|> 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)
|
||||
|> then(fn u ->
|
||||
u
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||
|> Ash.update!(authorize?: false)
|
||||
end)
|
||||
end)
|
||||
|
||||
:ok
|
||||
|
||||
|
|
@ -209,16 +207,15 @@ defmodule Mv.Release do
|
|||
end
|
||||
|
||||
defp update_admin_user(user, password, admin_role) do
|
||||
_ =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:admin_set_password, %{password: password})
|
||||
user
|
||||
|> 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)
|
||||
|> then(fn u ->
|
||||
u
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||
|> Ash.update!(authorize?: false)
|
||||
end)
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
|
|
|||
|
|
@ -19,12 +19,4 @@ defmodule Mv.Repo do
|
|||
def min_pg_version do
|
||||
%Version{major: 17, minor: 2, patch: 0}
|
||||
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
|
||||
|
|
|
|||
|
|
@ -8,12 +8,6 @@ defmodule Mv.Vereinfacht.Client do
|
|||
"""
|
||||
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"
|
||||
|
||||
@doc """
|
||||
|
|
@ -37,7 +31,7 @@ defmodule Mv.Vereinfacht.Client do
|
|||
{:error, :not_configured}
|
||||
"""
|
||||
@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
|
||||
if blank?(api_url) or blank?(api_key) or blank?(club_id) do
|
||||
{:error, :not_configured}
|
||||
|
|
@ -98,12 +92,13 @@ defmodule Mv.Vereinfacht.Client do
|
|||
|
||||
@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).
|
||||
# `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
|
||||
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
|
||||
|
||||
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.
|
||||
"""
|
||||
@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
|
||||
fetch_contact(contact_id, [])
|
||||
end
|
||||
|
|
|
|||
|
|
@ -37,10 +37,9 @@ defmodule Mv.Vereinfacht.SyncFlash do
|
|||
def create_table! do
|
||||
# :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.
|
||||
_ =
|
||||
if :ets.whereis(@table) == :undefined do
|
||||
:ets.new(@table, [:set, :public, :named_table])
|
||||
end
|
||||
if :ets.whereis(@table) == :undefined do
|
||||
:ets.new(@table, [:set, :public, :named_table])
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ defmodule Mv.Vereinfacht do
|
|||
- `{:error, {:http, status, message}}` – API returned an error (e.g. 401, 403)
|
||||
- `{: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
|
||||
Client.test_connection(
|
||||
Mv.Config.vereinfacht_api_url(),
|
||||
|
|
|
|||
|
|
@ -113,7 +113,8 @@ defmodule MvWeb.Authorization do
|
|||
iex> can_access_page?(mitglied, "/members")
|
||||
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?(user, page_path) do
|
||||
|
|
|
|||
|
|
@ -1,243 +0,0 @@
|
|||
defmodule MvWeb.Components.BulkActionsDropdown do
|
||||
@moduledoc """
|
||||
Single "Aktionen" dropdown bundling the four member bulk actions, flattened to
|
||||
one level: open in email program (mailto), copy email addresses, export to CSV,
|
||||
export to PDF.
|
||||
|
||||
It keeps the CSRF-protected `<form>` POST export items unchanged (CSV/PDF) and
|
||||
adds the mailto and copy items that previously lived as standalone header
|
||||
buttons next to a separate export dropdown.
|
||||
|
||||
## Scope and trigger badge
|
||||
|
||||
The trigger reads `Aktionen` followed by a scope badge: an emphasized
|
||||
(`primary`) count `N` when `N` members are selected, and a muted (`neutral`)
|
||||
badge otherwise — `gefiltert` when a search term or filter narrows the list,
|
||||
`alle` when nothing is selected and no search/filter is active. Only an actual
|
||||
selection is emphasized. The badge sits inside the shared `dropdown_menu/1`
|
||||
trigger via its `trigger_badge` slot, matching the member-filter dropdown's
|
||||
count badge. The `scope`, `selected_count`, `mailto_bcc`, `recipient_count`
|
||||
and `mailto_disabled?` are computed by the parent LiveView and passed in.
|
||||
|
||||
## Recipient handling (mailto / copy)
|
||||
|
||||
The parent already excludes members without an email when building
|
||||
`mailto_bcc` and `recipient_count` (defensive filter preserved verbatim from
|
||||
the previous behaviour). Export, by contrast, still includes every member in
|
||||
scope regardless of email — its payload is unchanged.
|
||||
|
||||
## Mailto recipient cap
|
||||
|
||||
A mailto URI carries every recipient in its BCC; browsers cannot reliably hand
|
||||
a very long mailto over to the mail program. When `mailto_disabled?` is true
|
||||
(recipient count at or above `Mv.Constants.max_mailto_bulk_recipients/0`) the
|
||||
mailto item is rendered disabled (`aria-disabled`, `tabindex="-1"`, href
|
||||
dropped) with an explanatory tooltip. Copy and Export have no such cap.
|
||||
|
||||
## Event routing
|
||||
|
||||
`dropdown_menu/1` sends `toggle_dropdown`/`close_dropdown` to `@myself`, so the
|
||||
component owns its own `:open` state. The copy item carries an *un-targeted*
|
||||
`phx-click="copy_emails"`, which therefore reaches the parent LiveView's
|
||||
`handle_event/3` (which keeps access to `@members`), plus the
|
||||
`CopyToClipboard` hook.
|
||||
"""
|
||||
use MvWeb, :live_component
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
# Same focus ring as CoreComponents button/dropdown (WCAG 2.4.7)
|
||||
defp dropdown_item_class do
|
||||
focus =
|
||||
MvWeb.CoreComponents.button_focus_classes()
|
||||
|> Kernel.++(["focus-visible:ring-inset"])
|
||||
|> Enum.join(" ")
|
||||
|
||||
"flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left whitespace-nowrap #{focus}"
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(socket) do
|
||||
{:ok, assign(socket, :open, false)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:id, assigns.id)
|
||||
|> assign(:export_payload_json, assigns[:export_payload_json] || "")
|
||||
|> assign(:selected_count, assigns[:selected_count] || 0)
|
||||
|> assign(:scope, assigns[:scope] || :all)
|
||||
|> assign(:mailto_bcc, assigns[:mailto_bcc] || "")
|
||||
|> assign(:recipient_count, assigns[:recipient_count] || 0)
|
||||
|> assign(:mailto_disabled?, assigns[:mailto_disabled?] || false)
|
||||
|
||||
# The parent never sets :open (the component owns it via toggle/close).
|
||||
# Honouring an explicit :open assign keeps the component renderable in
|
||||
# isolation (render_component/2) for structural tests.
|
||||
socket =
|
||||
case Map.fetch(assigns, :open) do
|
||||
{:ok, open} -> assign(socket, :open, open)
|
||||
:error -> socket
|
||||
end
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:scope_label, scope_label(assigns))
|
||||
|> assign(:scope_variant, scope_variant(assigns))
|
||||
|
||||
~H"""
|
||||
<div id={@id} data-testid="bulk-actions-dropdown" class="flex-auto flex-wrap">
|
||||
<.dropdown_menu
|
||||
id={"#{@id}-menu"}
|
||||
button_label={gettext("Actions")}
|
||||
icon="hero-bolt"
|
||||
open={@open}
|
||||
phx_target={@myself}
|
||||
menu_width="w-70"
|
||||
menu_align="left"
|
||||
button_class="btn-secondary gap-2"
|
||||
testid="bulk-actions-dropdown"
|
||||
button_testid="bulk-actions-button"
|
||||
menu_testid="bulk-actions-menu"
|
||||
>
|
||||
<:trigger_badge>
|
||||
<.badge variant={@scope_variant} size="sm" data-testid="bulk-actions-scope-badge">
|
||||
{@scope_label}
|
||||
</.badge>
|
||||
</:trigger_badge>
|
||||
<li role="none">
|
||||
<.mailto_item mailto_bcc={@mailto_bcc} disabled={@mailto_disabled?} />
|
||||
</li>
|
||||
<li role="none">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
id="bulk-actions-copy"
|
||||
phx-hook="CopyToClipboard"
|
||||
phx-click="copy_emails"
|
||||
class={dropdown_item_class()}
|
||||
aria-label={gettext("Copy email addresses")}
|
||||
data-testid="bulk-actions-copy"
|
||||
>
|
||||
<.icon name="hero-clipboard-document" class="h-4 w-4" />
|
||||
<span>{gettext("Copy email addresses")}</span>
|
||||
</button>
|
||||
</li>
|
||||
<li role="none">
|
||||
<form method="post" action={~p"/members/export.csv"} target="_blank" class="w-full">
|
||||
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
|
||||
<input type="hidden" name="payload" value={@export_payload_json} />
|
||||
<button
|
||||
type="submit"
|
||||
role="menuitem"
|
||||
class={dropdown_item_class()}
|
||||
aria-label={gettext("Export members to CSV")}
|
||||
data-testid="export-csv-link"
|
||||
>
|
||||
<.icon name="hero-document-arrow-down" class="h-4 w-4" />
|
||||
<span>{gettext("Export to CSV")}</span>
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
<li role="none">
|
||||
<form method="post" action={~p"/members/export.pdf"} target="_blank" class="w-full">
|
||||
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
|
||||
<input type="hidden" name="payload" value={@export_payload_json} />
|
||||
<button
|
||||
type="submit"
|
||||
role="menuitem"
|
||||
class={dropdown_item_class()}
|
||||
aria-label={gettext("Export members to PDF")}
|
||||
data-testid="export-pdf-link"
|
||||
>
|
||||
<.icon name="hero-document-text" class="h-4 w-4" />
|
||||
<span>{gettext("Export to PDF")}</span>
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</.dropdown_menu>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# The mailto item is an anchor menu item. When over the recipient cap it is
|
||||
# rendered disabled following the same a11y pattern as a disabled CoreComponents
|
||||
# link button (href dropped, tabindex=-1, aria-disabled=true) and exposes the
|
||||
# explanatory tooltip via title.
|
||||
attr :mailto_bcc, :string, required: true
|
||||
attr :disabled, :boolean, required: true
|
||||
|
||||
defp mailto_item(%{disabled: true} = assigns) do
|
||||
assigns = assign(assigns, :item_class, dropdown_item_class())
|
||||
|
||||
~H"""
|
||||
<a
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
aria-disabled="true"
|
||||
title={over_threshold_tooltip()}
|
||||
class={[@item_class, "opacity-50 pointer-events-none"]}
|
||||
aria-label={gettext("Open in email program")}
|
||||
data-testid="bulk-actions-mailto"
|
||||
>
|
||||
<.icon name="hero-envelope" class="h-4 w-4" />
|
||||
<span>{gettext("Open in email program")}</span>
|
||||
</a>
|
||||
"""
|
||||
end
|
||||
|
||||
defp mailto_item(%{disabled: false} = assigns) do
|
||||
assigns = assign(assigns, :item_class, dropdown_item_class())
|
||||
|
||||
~H"""
|
||||
<a
|
||||
role="menuitem"
|
||||
tabindex="0"
|
||||
href={"mailto:?bcc=" <> @mailto_bcc}
|
||||
class={@item_class}
|
||||
aria-label={gettext("Open in email program")}
|
||||
data-testid="bulk-actions-mailto"
|
||||
>
|
||||
<.icon name="hero-envelope" class="h-4 w-4" />
|
||||
<span>{gettext("Open in email program")}</span>
|
||||
</a>
|
||||
"""
|
||||
end
|
||||
|
||||
defp over_threshold_tooltip do
|
||||
gettext("Too many recipients for this function. Copy the addresses or export the list.")
|
||||
end
|
||||
|
||||
# The trigger scope is shown as a badge after the "Aktionen" label. Only an
|
||||
# actual selection is emphasized (primary); both the "filtered" and "all"
|
||||
# scopes are muted (neutral), since neither means members are selected.
|
||||
defp scope_label(assigns) do
|
||||
case assigns.scope do
|
||||
:selection -> to_string(assigns.selected_count)
|
||||
:filtered -> gettext("filtered")
|
||||
_ -> gettext("all")
|
||||
end
|
||||
end
|
||||
|
||||
defp scope_variant(assigns) do
|
||||
case assigns.scope do
|
||||
:selection -> "primary"
|
||||
_ -> "neutral"
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("toggle_dropdown", _params, socket) do
|
||||
{:noreply, assign(socket, :open, !socket.assigns.open)}
|
||||
end
|
||||
|
||||
def handle_event("close_dropdown", _params, socket) do
|
||||
{:noreply, assign(socket, :open, false)}
|
||||
end
|
||||
end
|
||||
|
|
@ -464,9 +464,6 @@ defmodule MvWeb.CoreComponents do
|
|||
|
||||
slot :inner_block, doc: "Custom content for the dropdown menu (e.g., forms)"
|
||||
|
||||
slot :trigger_badge,
|
||||
doc: "Optional badge rendered in the trigger after the label (e.g. a scope badge)"
|
||||
|
||||
def dropdown_menu(assigns) do
|
||||
menu_testid = assigns.menu_testid || "#{assigns.testid}-menu"
|
||||
|
||||
|
|
@ -501,8 +498,6 @@ defmodule MvWeb.CoreComponents do
|
|||
<.icon name={@icon} />
|
||||
<% end %>
|
||||
<span>{@button_label}</span>
|
||||
{render_slot(@trigger_badge)}
|
||||
<.icon name="hero-chevron-down" class="size-4" />
|
||||
</button>
|
||||
|
||||
<ul
|
||||
|
|
@ -943,16 +938,6 @@ defmodule MvWeb.CoreComponents do
|
|||
doc:
|
||||
"when true, thead th get lg:sticky lg:top-0 bg-base-100 z-10 for use inside a scroll container on desktop"
|
||||
|
||||
attr :wrapper_overflow_class, :string,
|
||||
default: "overflow-x-auto",
|
||||
doc:
|
||||
"overflow class for the table wrapper; set to overflow-visible when outer container owns scrolling"
|
||||
|
||||
attr :sticky_first_col, :boolean,
|
||||
default: false,
|
||||
doc:
|
||||
"when true, first header/body column gets sticky left positioning to keep selection controls visible"
|
||||
|
||||
slot :col, required: true do
|
||||
attr :label, :string
|
||||
attr :class, :string
|
||||
|
|
@ -989,19 +974,15 @@ defmodule MvWeb.CoreComponents do
|
|||
~H"""
|
||||
<div
|
||||
id={@row_click && "#{@id}-keyboard"}
|
||||
class={@wrapper_overflow_class}
|
||||
data-sticky-first-col-rows={@sticky_first_col && "true"}
|
||||
class="overflow-auto"
|
||||
phx-hook={@row_click && "TableRowKeydown"}
|
||||
>
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
:for={{col, col_idx} <- Enum.with_index(@col)}
|
||||
class={[
|
||||
table_th_class(col, @sticky_header),
|
||||
@sticky_first_col && col_idx == 0 && "sticky left-0 z-30 bg-base-100"
|
||||
]}
|
||||
:for={col <- @col}
|
||||
class={table_th_class(col, @sticky_header)}
|
||||
aria-sort={table_th_aria_sort(col, @sort_field, @sort_order)}
|
||||
>
|
||||
{col[:label]}
|
||||
|
|
@ -1025,13 +1006,7 @@ defmodule MvWeb.CoreComponents do
|
|||
<tr
|
||||
:for={row <- @rows}
|
||||
id={@row_id && @row_id.(row)}
|
||||
class={[
|
||||
table_row_tr_class(
|
||||
table_row_selected?(assigns, row),
|
||||
@sticky_first_col
|
||||
)
|
||||
]}
|
||||
data-row-interactive={@row_click && "true"}
|
||||
class={table_row_tr_class(@row_click, table_row_selected?(assigns, row))}
|
||||
data-selected={table_row_selected?(assigns, row) && "true"}
|
||||
title={@row_click && @row_tooltip}
|
||||
>
|
||||
|
|
@ -1051,13 +1026,6 @@ defmodule MvWeb.CoreComponents do
|
|||
has_click = col[:col_click] || @row_click
|
||||
classes = ["max-w-xs"]
|
||||
|
||||
classes =
|
||||
if @sticky_first_col && col_idx == 0 do
|
||||
["sticky-first-col-cell sticky left-0 z-20" | classes]
|
||||
else
|
||||
classes
|
||||
end
|
||||
|
||||
classes =
|
||||
if col_class == nil || (col_class && !String.contains?(col_class, "text-center")) do
|
||||
["truncate" | classes]
|
||||
|
|
@ -1072,7 +1040,7 @@ defmodule MvWeb.CoreComponents do
|
|||
classes
|
||||
end
|
||||
|
||||
# WCAG: no focus ring on the cell itself; sticky zebra rows show keyboard focus via CSS :has(:focus-visible)
|
||||
# WCAG: no focus ring on the cell itself; row shows focus via focus-within
|
||||
classes =
|
||||
if @row_click && @first_row_click_col_idx == col_idx do
|
||||
[
|
||||
|
|
@ -1143,11 +1111,30 @@ defmodule MvWeb.CoreComponents do
|
|||
end
|
||||
end
|
||||
|
||||
# Returns CSS classes for table row selection styles.
|
||||
# Hover/focus row highlighting is CSS-driven via [data-row-interactive] selectors in app.css.
|
||||
# Sticky-first-column zebra tables use CSS accents and omit selected row ring classes.
|
||||
defp table_row_tr_class(true, false), do: "ring-2 ring-inset ring-primary"
|
||||
defp table_row_tr_class(_, _), do: ""
|
||||
# Returns CSS classes for table row: hover/focus-within outline when row_click is set,
|
||||
# and stronger selected outline when selected (WCAG: not color-only).
|
||||
# Hover/focus-within are omitted for the selected row so the selected ring stays visible.
|
||||
defp table_row_tr_class(row_click, selected?) do
|
||||
has_row_click? = not is_nil(row_click)
|
||||
base = []
|
||||
|
||||
base =
|
||||
if has_row_click? and not selected?,
|
||||
do:
|
||||
base ++
|
||||
[
|
||||
"hover:ring-2",
|
||||
"hover:ring-inset",
|
||||
"hover:ring-base-content/10",
|
||||
"focus-within:ring-2",
|
||||
"focus-within:ring-inset",
|
||||
"focus-within:ring-base-content/10"
|
||||
],
|
||||
else: base
|
||||
|
||||
base = if selected?, do: base ++ ["ring-2", "ring-inset", "ring-primary"], else: base
|
||||
Enum.join(base, " ")
|
||||
end
|
||||
|
||||
defp table_th_aria_sort(col, sort_field, sort_order) do
|
||||
col_sort = Map.get(col, :sort_field)
|
||||
|
|
|
|||
110
lib/mv_web/components/export_dropdown.ex
Normal file
110
lib/mv_web/components/export_dropdown.ex
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
defmodule MvWeb.Components.ExportDropdown do
|
||||
@moduledoc """
|
||||
Export dropdown component for member export (CSV/PDF).
|
||||
|
||||
Provides an accessible dropdown menu with CSV and PDF export options.
|
||||
Uses the same export payload as the previous single-button export.
|
||||
"""
|
||||
use MvWeb, :live_component
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
# Same focus ring as CoreComponents button/dropdown (WCAG 2.4.7)
|
||||
defp dropdown_item_class do
|
||||
focus =
|
||||
MvWeb.CoreComponents.button_focus_classes()
|
||||
|> Kernel.++(["focus-visible:ring-inset"])
|
||||
|> Enum.join(" ")
|
||||
|
||||
"flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left #{focus}"
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(socket) do
|
||||
{:ok, assign(socket, :open, false)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:id, assigns.id)
|
||||
|> assign(:export_payload_json, assigns[:export_payload_json] || "")
|
||||
|> assign(:selected_count, assigns[:selected_count] || 0)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
button_label =
|
||||
gettext("Export") <>
|
||||
" (" <>
|
||||
if(assigns.selected_count == 0,
|
||||
do: gettext("all"),
|
||||
else: to_string(assigns.selected_count)
|
||||
) <>
|
||||
")"
|
||||
|
||||
assigns = assign(assigns, :button_label, button_label)
|
||||
|
||||
~H"""
|
||||
<div id={@id} data-testid="export-dropdown" class="flex-auto flex-wrap">
|
||||
<.dropdown_menu
|
||||
id={"#{@id}-menu"}
|
||||
button_label={@button_label}
|
||||
icon="hero-arrow-down-tray"
|
||||
open={@open}
|
||||
phx_target={@myself}
|
||||
menu_width="w-48"
|
||||
menu_align="left"
|
||||
button_class="btn-secondary gap-2"
|
||||
testid="export-dropdown"
|
||||
button_testid="export-dropdown-button"
|
||||
menu_testid="export-dropdown-menu"
|
||||
>
|
||||
<li role="none">
|
||||
<form method="post" action={~p"/members/export.csv"} target="_blank" class="w-full">
|
||||
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
|
||||
<input type="hidden" name="payload" value={@export_payload_json} />
|
||||
<button
|
||||
type="submit"
|
||||
role="menuitem"
|
||||
class={dropdown_item_class()}
|
||||
aria-label={gettext("Export members to CSV")}
|
||||
data-testid="export-csv-link"
|
||||
>
|
||||
<.icon name="hero-document-arrow-down" class="h-4 w-4" />
|
||||
<span>{gettext("CSV")}</span>
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
<li role="none">
|
||||
<form method="post" action={~p"/members/export.pdf"} target="_blank" class="w-full">
|
||||
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
|
||||
<input type="hidden" name="payload" value={@export_payload_json} />
|
||||
<button
|
||||
type="submit"
|
||||
role="menuitem"
|
||||
class={dropdown_item_class()}
|
||||
aria-label={gettext("Export members to PDF")}
|
||||
data-testid="export-pdf-link"
|
||||
>
|
||||
<.icon name="hero-document-text" class="h-4 w-4" />
|
||||
<span>{gettext("PDF")}</span>
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</.dropdown_menu>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("toggle_dropdown", _params, socket) do
|
||||
{:noreply, assign(socket, :open, !socket.assigns.open)}
|
||||
end
|
||||
|
||||
def handle_event("close_dropdown", _params, socket) do
|
||||
{:noreply, assign(socket, :open, false)}
|
||||
end
|
||||
end
|
||||
|
|
@ -135,8 +135,11 @@ defmodule MvWeb.Layouts do
|
|||
slot :inner_block, required: true
|
||||
|
||||
def app(assigns) do
|
||||
# Single get_settings() for layout; derive club_name and join_form_enabled to avoid duplicate query.
|
||||
%{club_name: club_name, join_form_enabled: join_form_enabled} = get_layout_settings()
|
||||
# Single settings read for layout defaults.
|
||||
# Use an explicitly provided club_name as source of truth to avoid stale
|
||||
# values from cache reads immediately after a settings update in LiveViews.
|
||||
%{club_name: fallback_club_name, join_form_enabled: join_form_enabled} = get_layout_settings()
|
||||
club_name = assigns[:club_name] || fallback_club_name
|
||||
|
||||
# NOTE: Unprocessed count runs on every page load when join form is enabled; consider
|
||||
# loading only on navigation or caching briefly if performance becomes an issue.
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ defmodule MvWeb.AuthController do
|
|||
|
||||
alias Mv.Accounts.User.Errors.PasswordVerificationRequired
|
||||
alias Mv.Config
|
||||
alias Mv.Oidc.Discovery
|
||||
|
||||
def success(conn, {:password, :sign_in} = _activity, user, token) do
|
||||
if Config.oidc_only?() do
|
||||
|
|
@ -335,29 +334,14 @@ defmodule MvWeb.AuthController do
|
|||
end
|
||||
end
|
||||
|
||||
defp redact_url(_), do: "[redacted]"
|
||||
|
||||
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
|
||||
{:ok, url} ->
|
||||
redirect(conn, external: url)
|
||||
|
||||
: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
|
||||
conn
|
||||
|> clear_session(:mv)
|
||||
|> put_flash(:success, gettext("You are now signed out"))
|
||||
|> redirect(to: return_to)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,120 +0,0 @@
|
|||
defmodule MvWeb.ImportTemplateController do
|
||||
@moduledoc """
|
||||
Serves CSV import templates generated on the fly from the current custom fields.
|
||||
|
||||
Two actions provide an English (`en/2`) and a German (`de/2`) template. Each
|
||||
template has a single header row listing the standard member columns followed
|
||||
by every existing custom field name (exact match, as the import expects), plus
|
||||
the importable groups and fee-type columns. A single placeholder example row is
|
||||
included to illustrate the format.
|
||||
|
||||
Both actions require the same authorization as the import page
|
||||
(`can?(:create, Member)`); unauthorized requests are rejected.
|
||||
"""
|
||||
use MvWeb, :controller
|
||||
|
||||
alias Mv.Authorization.Actor
|
||||
alias Mv.Membership.Member
|
||||
alias Mv.Membership.MembersCSV
|
||||
alias MvWeb.Authorization
|
||||
|
||||
# Standard member columns in template order, with their English and German headers
|
||||
# and a placeholder example value. Groups and fee type are importable extras.
|
||||
@columns [
|
||||
{"first name", "Vorname", "John", "Max"},
|
||||
{"last name", "Nachname", "Doe", "Mustermann"},
|
||||
{"email", "E-Mail", "john.doe@example.com", "max.mustermann@example.com"},
|
||||
{"country", "Land", "Germany", "Deutschland"},
|
||||
{"city", "Stadt", "Berlin", "Berlin"},
|
||||
{"street", "Straße", "Main Street", "Hauptstraße"},
|
||||
{"house number", "Hausnummer", "1a", "12"},
|
||||
{"postal_code", "PLZ", "12345", "10115"},
|
||||
{"join_date", "Beitrittsdatum", "2020-01-15", "2020-01-15"},
|
||||
{"exit_date", "Austrittsdatum", "", ""},
|
||||
{"notes", "Notizen", "", ""},
|
||||
{"membership_fee_start_date", "Beitragsbeginn", "", ""},
|
||||
{"Groups", "Gruppen", "", ""},
|
||||
{"Fee Type", "Beitragsart", "", ""}
|
||||
]
|
||||
|
||||
@spec en(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def en(conn, _params) do
|
||||
serve_template(conn, :en, "member_import_en.csv")
|
||||
end
|
||||
|
||||
@spec de(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def de(conn, _params) do
|
||||
serve_template(conn, :de, "member_import_de.csv")
|
||||
end
|
||||
|
||||
defp serve_template(conn, locale, filename) do
|
||||
actor = current_actor(conn)
|
||||
|
||||
if Authorization.can?(actor, :create, Member) do
|
||||
csv = build_csv(locale, actor)
|
||||
|
||||
send_download(conn, {:binary, csv},
|
||||
filename: filename,
|
||||
content_type: "text/csv; charset=utf-8"
|
||||
)
|
||||
else
|
||||
return_forbidden(conn)
|
||||
end
|
||||
end
|
||||
|
||||
defp build_csv(locale, actor) do
|
||||
custom_field_names = custom_field_names(actor)
|
||||
|
||||
header =
|
||||
Enum.map(@columns, &header_for(&1, locale)) ++ custom_field_names
|
||||
|
||||
example =
|
||||
Enum.map(@columns, &example_for(&1, locale)) ++ Enum.map(custom_field_names, fn _ -> "" end)
|
||||
|
||||
[csv_row(header), csv_row(example)]
|
||||
|> Enum.join("\n")
|
||||
end
|
||||
|
||||
defp header_for({en, _de, _ex_en, _ex_de}, :en), do: en
|
||||
defp header_for({_en, de, _ex_en, _ex_de}, :de), do: de
|
||||
|
||||
defp example_for({_en, _de, ex_en, _ex_de}, :en), do: ex_en
|
||||
defp example_for({_en, _de, _ex_en, ex_de}, :de), do: ex_de
|
||||
|
||||
defp custom_field_names(actor) do
|
||||
Mv.Membership.list_custom_fields!(actor: actor)
|
||||
|> Enum.map(& &1.name)
|
||||
end
|
||||
|
||||
# Serializes a row using the semicolon delimiter (the import auto-detects it),
|
||||
# quoting any field that contains a delimiter, quote, or newline.
|
||||
defp csv_row(fields) do
|
||||
Enum.map_join(fields, ";", &escape_field/1)
|
||||
end
|
||||
|
||||
# Neutralizes spreadsheet formula triggers (the same guard the export writer
|
||||
# applies) before RFC 4180 quoting, so a custom-field name like
|
||||
# `=HYPERLINK(...)` is not evaluated when the template is opened.
|
||||
defp escape_field(field) do
|
||||
field = field |> to_string() |> MembersCSV.safe_cell()
|
||||
|
||||
if String.contains?(field, [";", "\"", "\n", "\r"]) do
|
||||
"\"" <> String.replace(field, "\"", "\"\"") <> "\""
|
||||
else
|
||||
field
|
||||
end
|
||||
end
|
||||
|
||||
defp current_actor(conn) do
|
||||
conn.assigns[:current_user]
|
||||
|> Actor.ensure_loaded()
|
||||
end
|
||||
|
||||
defp return_forbidden(conn) do
|
||||
conn
|
||||
|> put_status(403)
|
||||
|> put_resp_content_type("application/json")
|
||||
|> json(%{error: "Forbidden"})
|
||||
|> halt()
|
||||
end
|
||||
end
|
||||
|
|
@ -25,33 +25,31 @@ defmodule MvWeb.MemberExportController do
|
|||
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
||||
|
||||
def export(conn, params) do
|
||||
case current_actor(conn) do
|
||||
nil -> return_forbidden(conn)
|
||||
actor -> export_with_actor(conn, actor, params["payload"])
|
||||
actor = current_actor(conn)
|
||||
if is_nil(actor), do: return_forbidden(conn)
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
conn.assigns[:current_user]
|
||||
|> Actor.ensure_loaded()
|
||||
|
|
|
|||
|
|
@ -1,70 +0,0 @@
|
|||
defmodule MvWeb.Helpers.JoinDescriptionRenderer do
|
||||
@moduledoc """
|
||||
Renders a custom field's `join_description` into Phoenix-safe HTML for the
|
||||
public join form.
|
||||
|
||||
The renderer auto-links two patterns into `<a href="...">` tags:
|
||||
|
||||
- Markdown links of the form `[text](url)` (processed first)
|
||||
- bare `http(s)://` URLs in the remaining text
|
||||
|
||||
All other content is HTML-escaped: only `<a href="...">` tags are ever
|
||||
emitted, so arbitrary HTML in the input is rendered as inert text. This is a
|
||||
defense-in-depth measure — `join_description` is admin-set content, never
|
||||
end-user input — but the renderer must not become a vector for injecting
|
||||
arbitrary markup.
|
||||
|
||||
Markdown links are matched before bare URLs and their matched region is
|
||||
consumed, so a Markdown link whose URL also looks like a bare URL is linked
|
||||
exactly once (no nested anchors).
|
||||
"""
|
||||
|
||||
@markdown_link ~r/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/
|
||||
@bare_url ~r/(https?:\/\/[^\s<]+)/
|
||||
@bare_url_anchored ~r/\A(https?:\/\/[^\s<]+)\z/
|
||||
|
||||
@doc """
|
||||
Converts `value` to a Phoenix-safe HTML iolist.
|
||||
|
||||
Returns `{:safe, ""}` for `nil`. For a string, returns `{:safe, iolist}` with
|
||||
links rendered and all other text HTML-escaped.
|
||||
"""
|
||||
@spec render(String.t() | nil) :: Phoenix.HTML.safe()
|
||||
def render(nil), do: {:safe, ""}
|
||||
|
||||
def render(value) when is_binary(value) do
|
||||
{:safe, render_segments(value)}
|
||||
end
|
||||
|
||||
# Split on Markdown links first; for each non-Markdown segment, link bare URLs;
|
||||
# everything that is not a link is HTML-escaped.
|
||||
defp render_segments(text) do
|
||||
Regex.split(@markdown_link, text, include_captures: true)
|
||||
|> Enum.map(&render_markdown_or_plain/1)
|
||||
end
|
||||
|
||||
defp render_markdown_or_plain(segment) do
|
||||
case Regex.run(@markdown_link, segment) do
|
||||
[^segment, label, url] -> anchor(url, label)
|
||||
_ -> render_plain(segment)
|
||||
end
|
||||
end
|
||||
|
||||
# Auto-link bare URLs in a plain-text segment, escaping all surrounding text.
|
||||
defp render_plain(segment) do
|
||||
Regex.split(@bare_url, segment, include_captures: true)
|
||||
|> Enum.map(fn part ->
|
||||
if Regex.match?(@bare_url_anchored, part) do
|
||||
anchor(part, part)
|
||||
else
|
||||
escape(part)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp anchor(url, label) do
|
||||
["<a href=\"", escape(url), "\" class=\"link link-primary\">", escape(label), "</a>"]
|
||||
end
|
||||
|
||||
defp escape(text), do: Phoenix.HTML.html_escape(text) |> Phoenix.HTML.safe_to_string()
|
||||
end
|
||||
|
|
@ -30,8 +30,8 @@ defmodule MvWeb.SignInLive do
|
|||
# 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
|
||||
# language-selector `selected` attribute in Layouts.public_page).
|
||||
_ = Gettext.put_locale(MvWeb.Gettext, locale)
|
||||
_ = Gettext.put_locale(locale)
|
||||
Gettext.put_locale(MvWeb.Gettext, locale)
|
||||
Gettext.put_locale(locale)
|
||||
|
||||
# Prepend DE-specific overrides when locale is German so that components
|
||||
# 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).
|
||||
- `:boolean_custom_fields` - List of boolean custom fields to display
|
||||
- `: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)
|
||||
- `: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 `{: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 `{: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
|
||||
|
||||
alias MvWeb.MemberLive.Index.DateFilter
|
||||
alias MvWeb.MemberLive.Index.FilterParams
|
||||
|
||||
@group_filter_prefix Mv.Constants.group_filter_prefix()
|
||||
@fee_type_filter_prefix Mv.Constants.fee_type_filter_prefix()
|
||||
@custom_date_filter_prefix Mv.Constants.custom_date_filter_prefix()
|
||||
|
||||
@impl true
|
||||
def mount(socket) do
|
||||
|
|
@ -60,42 +50,19 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
socket
|
||||
|> assign(:id, assigns.id)
|
||||
|> assign(:cycle_status_filter, assigns[:cycle_status_filter])
|
||||
|> assign_group_assigns(assigns)
|
||||
|> assign_fee_type_assigns(assigns)
|
||||
|> assign_boolean_assigns(assigns)
|
||||
|> assign_date_assigns(assigns)
|
||||
|> assign(:groups, assigns[:groups] || [])
|
||||
|> assign(:group_filters, assigns[:group_filters] || %{})
|
||||
|> assign(:group_filter_prefix, @group_filter_prefix)
|
||||
|> 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)
|
||||
|
||||
{:ok, socket}
|
||||
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
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
|
|
@ -114,8 +81,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
"gap-2",
|
||||
(@cycle_status_filter || map_size(@group_filters) > 0 ||
|
||||
map_size(@fee_type_filters) > 0 ||
|
||||
active_boolean_filters_count(@boolean_filters) > 0 ||
|
||||
date_filters_active?(@date_filters)) &&
|
||||
active_boolean_filters_count(@boolean_filters) > 0) &&
|
||||
"btn-active"
|
||||
]}
|
||||
phx-click="toggle_dropdown"
|
||||
|
|
@ -133,8 +99,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
@fee_types,
|
||||
@fee_type_filters,
|
||||
@boolean_custom_fields,
|
||||
@boolean_filters,
|
||||
@date_filters
|
||||
@boolean_filters
|
||||
)}
|
||||
</span>
|
||||
<.badge
|
||||
|
|
@ -146,9 +111,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
</.badge>
|
||||
<.badge
|
||||
:if={
|
||||
(@cycle_status_filter || map_size(@group_filters) > 0 ||
|
||||
map_size(@fee_type_filters) > 0 ||
|
||||
date_filters_active?(@date_filters)) &&
|
||||
(@cycle_status_filter || map_size(@group_filters) > 0 || map_size(@fee_type_filters) > 0) &&
|
||||
active_boolean_filters_count(@boolean_filters) == 0
|
||||
}
|
||||
variant="primary"
|
||||
|
|
@ -156,7 +119,6 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
>
|
||||
{@member_count}
|
||||
</.badge>
|
||||
<.icon name="hero-chevron-down" class="size-4" />
|
||||
</.button>
|
||||
|
||||
<!--
|
||||
|
|
@ -367,163 +329,6 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
</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 -->
|
||||
<div :if={length(@boolean_custom_fields) > 0} class="mb-2">
|
||||
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
|
||||
|
|
@ -633,27 +438,17 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
payment_filter = parse_payment_filter(params)
|
||||
|
||||
group_filters_parsed =
|
||||
FilterParams.parse_prefix_filters(
|
||||
params,
|
||||
@group_filter_prefix,
|
||||
&FilterParams.parse_in_not_in_value/1
|
||||
)
|
||||
parse_prefix_filters(params, @group_filter_prefix, &FilterParams.parse_in_not_in_value/1)
|
||||
|
||||
fee_type_filters_parsed =
|
||||
FilterParams.parse_prefix_filters(
|
||||
params,
|
||||
@fee_type_filter_prefix,
|
||||
&FilterParams.parse_in_not_in_value/1
|
||||
)
|
||||
parse_prefix_filters(params, @fee_type_filter_prefix, &FilterParams.parse_in_not_in_value/1)
|
||||
|
||||
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_group_filter_changes(socket, group_filters_parsed)
|
||||
dispatch_fee_type_filter_changes(socket, fee_type_filters_parsed)
|
||||
dispatch_boolean_filter_changes(socket, custom_boolean_filters_parsed)
|
||||
dispatch_date_filters_change(socket, new_date_filters)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
|
@ -691,6 +486,17 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
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
|
||||
params
|
||||
|> Map.get("custom_boolean", %{})
|
||||
|
|
@ -737,12 +543,6 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
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
|
||||
defp button_label(
|
||||
cycle_status_filter,
|
||||
|
|
@ -751,16 +551,14 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
fee_types,
|
||||
fee_type_filters,
|
||||
boolean_custom_fields,
|
||||
boolean_filters,
|
||||
date_filters
|
||||
boolean_filters
|
||||
) do
|
||||
active_count =
|
||||
count_active_filter_categories(
|
||||
cycle_status_filter,
|
||||
group_filters,
|
||||
fee_type_filters,
|
||||
boolean_filters,
|
||||
date_filters
|
||||
boolean_filters
|
||||
)
|
||||
|
||||
if active_count >= 2 do
|
||||
|
|
@ -781,9 +579,6 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
map_size(boolean_filters) > 0 ->
|
||||
boolean_filter_label(boolean_custom_fields, boolean_filters)
|
||||
|
||||
date_filters_active?(date_filters) ->
|
||||
gettext("Dates")
|
||||
|
||||
true ->
|
||||
gettext("Apply filters")
|
||||
end
|
||||
|
|
@ -794,27 +589,17 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
cycle_status_filter,
|
||||
group_filters,
|
||||
fee_type_filters,
|
||||
boolean_filters,
|
||||
date_filters
|
||||
boolean_filters
|
||||
) do
|
||||
[
|
||||
cycle_status_filter,
|
||||
map_size(group_filters) > 0,
|
||||
map_size(fee_type_filters) > 0,
|
||||
map_size(boolean_filters) > 0,
|
||||
date_filters_active?(date_filters)
|
||||
map_size(boolean_filters) > 0
|
||||
]
|
||||
|> Enum.count(& &1)
|
||||
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,
|
||||
do: gettext("All")
|
||||
|
||||
|
|
@ -936,6 +721,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
{nil, true} -> "#{base_classes} btn-active"
|
||||
{:in, true} -> "#{base_classes} btn-success btn-active"
|
||||
{:not_in, true} -> "#{base_classes} btn-error btn-active"
|
||||
_ -> "#{base_classes} btn-outline"
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -982,35 +768,4 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
"#{base_classes} btn-outline"
|
||||
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
|
||||
|
|
|
|||
|
|
@ -91,45 +91,6 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
|
|||
<% end %>
|
||||
|
||||
<.input field={@form[:description]} type="text" label={gettext("Description")} />
|
||||
|
||||
<fieldset class="mb-2 fieldset">
|
||||
<label>
|
||||
<span class="mb-1 label flex items-center gap-2">
|
||||
{gettext("Description for join form")}
|
||||
<.tooltip
|
||||
content={
|
||||
gettext(
|
||||
"You can add links: full addresses (https://…) or as [link text](https://…)."
|
||||
)
|
||||
}
|
||||
position="right"
|
||||
>
|
||||
<span
|
||||
data-testid="join-description-link-hint"
|
||||
aria-label={
|
||||
gettext(
|
||||
"You can add links: full addresses (https://…) or as [link text](https://…)."
|
||||
)
|
||||
}
|
||||
>
|
||||
<.icon
|
||||
name="hero-information-circle"
|
||||
class="w-4 h-4 text-base-content/60 cursor-help"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</.tooltip>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
name={@form[:join_description].name}
|
||||
id={@form[:join_description].id}
|
||||
value={Phoenix.HTML.Form.normalize_value("text", @form[:join_description].value)}
|
||||
class="w-full input"
|
||||
/>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<.input field={@form[:required]} type="checkbox" label={gettext("Required")} />
|
||||
<.input
|
||||
field={@form[:show_in_overview]}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
|
||||
# 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")
|
||||
_ = Gettext.put_locale(MvWeb.Gettext, locale)
|
||||
Gettext.put_locale(MvWeb.Gettext, locale)
|
||||
|
||||
actor = MvWeb.LiveHelpers.current_actor(socket)
|
||||
custom_fields = load_custom_fields(actor)
|
||||
|
|
@ -65,6 +65,7 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
|> assign(:settings, settings)
|
||||
|> assign(:locale, locale)
|
||||
|> assign(:environment, environment)
|
||||
|> assign(:association_name_env_set, Mv.Config.association_name_env_set?())
|
||||
|> assign(:vereinfacht_env_configured, Mv.Config.vereinfacht_env_configured?())
|
||||
|> assign(:vereinfacht_api_url_env_set, Mv.Config.vereinfacht_api_url_env_set?())
|
||||
|> assign(:vereinfacht_api_key_env_set, Mv.Config.vereinfacht_api_key_env_set?())
|
||||
|
|
@ -85,8 +86,16 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
|> assign(:oidc_configured, Mv.Config.oidc_configured?())
|
||||
|> assign(:oidc_client_secret_set, Mv.Config.oidc_client_secret_set?())
|
||||
|> assign(:registration_enabled, settings.registration_enabled != false)
|
||||
|> assign(:smtp_env_configured, Mv.Config.smtp_env_configured?())
|
||||
|> assign(:smtp_env_mode, Mv.Config.smtp_env_mode?())
|
||||
|> assign(:smtp_missing_required_env_keys, Mv.Config.smtp_missing_required_env_keys())
|
||||
|> assign(:smtp_host_env_set, Mv.Config.smtp_host_env_set?())
|
||||
|> assign(:smtp_port_env_set, Mv.Config.smtp_port_env_set?())
|
||||
|> assign(:smtp_username_env_set, Mv.Config.smtp_username_env_set?())
|
||||
|> assign(:smtp_password_env_set, Mv.Config.smtp_password_env_set?())
|
||||
|> assign(:smtp_ssl_env_set, Mv.Config.smtp_ssl_env_set?())
|
||||
|> assign(:smtp_from_name_env_set, Mv.Config.mail_from_name_env_set?())
|
||||
|> assign(:smtp_from_email_env_set, Mv.Config.mail_from_email_env_set?())
|
||||
|> assign(:smtp_password_set, present?(Mv.Config.smtp_password()))
|
||||
|> assign(:smtp_configured, Mv.Config.smtp_configured?())
|
||||
|> assign(:smtp_test_result, nil)
|
||||
|
|
@ -117,6 +126,13 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
<div class="mt-6 space-y-6 max-w-4xl px-4">
|
||||
<%!-- Club Settings Section --%>
|
||||
<.form_section title={gettext("Club Settings")}>
|
||||
<%= if @association_name_env_set do %>
|
||||
<p class="text-sm text-base-content/70 mb-4">
|
||||
{gettext(
|
||||
"Association name is set via environment variable ASSOCIATION_NAME. This field is read-only."
|
||||
)}
|
||||
</p>
|
||||
<% end %>
|
||||
<.form for={@form} id="settings-form" phx-change="validate" phx-submit="save">
|
||||
<div class="w-100">
|
||||
<.input
|
||||
|
|
@ -124,10 +140,18 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
type="text"
|
||||
label={gettext("Association Name")}
|
||||
required
|
||||
disabled={@association_name_env_set}
|
||||
placeholder={
|
||||
if(@association_name_env_set, do: gettext("From ASSOCIATION_NAME"), else: nil)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
||||
<.button
|
||||
:if={not @association_name_env_set}
|
||||
phx-disable-with={gettext("Saving...")}
|
||||
variant="primary"
|
||||
>
|
||||
{gettext("Save Name")}
|
||||
</.button>
|
||||
</.form>
|
||||
|
|
@ -353,14 +377,19 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
type="text"
|
||||
label={gettext("Host")}
|
||||
disabled={@smtp_env_mode}
|
||||
placeholder="smtp.example.com"
|
||||
placeholder={
|
||||
if(@smtp_host_env_set,
|
||||
do: gettext("From SMTP_HOST"),
|
||||
else: "smtp.example.com"
|
||||
)
|
||||
}
|
||||
/>
|
||||
<.input
|
||||
field={@form[:smtp_port]}
|
||||
type="number"
|
||||
label={gettext("Port")}
|
||||
disabled={@smtp_env_mode}
|
||||
placeholder="587"
|
||||
placeholder={if(@smtp_port_env_set, do: gettext("From SMTP_PORT"), else: "587")}
|
||||
/>
|
||||
<.input
|
||||
field={@form[:smtp_ssl]}
|
||||
|
|
@ -372,6 +401,7 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
{gettext("SSL (port 465)"), "ssl"},
|
||||
{gettext("None (port 25, insecure)"), "none"}
|
||||
]}
|
||||
placeholder={if(@smtp_ssl_env_set, do: gettext("From SMTP_SSL"), else: nil)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -381,7 +411,12 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
type="text"
|
||||
label={gettext("Username")}
|
||||
disabled={@smtp_env_mode}
|
||||
placeholder="user@example.com"
|
||||
placeholder={
|
||||
if(@smtp_username_env_set,
|
||||
do: gettext("From SMTP_USERNAME"),
|
||||
else: "user@example.com"
|
||||
)
|
||||
}
|
||||
/>
|
||||
<.input
|
||||
field={@form[:smtp_password]}
|
||||
|
|
@ -389,11 +424,14 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
label={gettext("Password")}
|
||||
disabled={@smtp_env_mode}
|
||||
placeholder={
|
||||
if @smtp_env_mode do
|
||||
gettext("From SMTP_PASSWORD")
|
||||
else
|
||||
if @smtp_password_set, do: gettext("Leave blank to keep current"), else: nil
|
||||
end
|
||||
if(@smtp_password_env_set,
|
||||
do: gettext("From SMTP_PASSWORD"),
|
||||
else:
|
||||
if(@smtp_password_set,
|
||||
do: gettext("Leave blank to keep current"),
|
||||
else: nil
|
||||
)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -404,14 +442,21 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
type="email"
|
||||
label={gettext("Sender email (From)")}
|
||||
disabled={@smtp_env_mode}
|
||||
placeholder="noreply@example.com"
|
||||
placeholder={
|
||||
if(@smtp_from_email_env_set,
|
||||
do: gettext("From MAIL_FROM_EMAIL"),
|
||||
else: "noreply@example.com"
|
||||
)
|
||||
}
|
||||
/>
|
||||
<.input
|
||||
field={@form[:smtp_from_name]}
|
||||
type="text"
|
||||
label={gettext("Sender name (From)")}
|
||||
disabled={@smtp_env_mode}
|
||||
placeholder="Mila"
|
||||
placeholder={
|
||||
if(@smtp_from_name_env_set, do: gettext("From MAIL_FROM_NAME"), else: "Mila")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -421,7 +466,12 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
)}
|
||||
</p>
|
||||
<.button
|
||||
:if={not @smtp_env_mode}
|
||||
:if={
|
||||
not @smtp_env_mode and
|
||||
not (@smtp_host_env_set and @smtp_port_env_set and @smtp_username_env_set and
|
||||
@smtp_password_env_set and @smtp_ssl_env_set and @smtp_from_email_env_set and
|
||||
@smtp_from_name_env_set)
|
||||
}
|
||||
phx-disable-with={gettext("Saving...")}
|
||||
variant="primary"
|
||||
class="mt-2"
|
||||
|
|
@ -885,6 +935,7 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
# Never send blank API key / client secret / smtp password so we do not overwrite stored secrets
|
||||
setting_params_clean =
|
||||
setting_params
|
||||
|> drop_env_managed_association_name()
|
||||
|> drop_blank_vereinfacht_api_key()
|
||||
|> drop_blank_oidc_client_secret()
|
||||
|> drop_blank_smtp_password()
|
||||
|
|
@ -893,6 +944,10 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
|
||||
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, setting_params_clean, actor) do
|
||||
{:ok, updated_settings} ->
|
||||
# Keep cross-view reads consistent after settings updates (layouts/sidebar
|
||||
# read via Membership.get_settings/0).
|
||||
Membership.invalidate_settings_cache()
|
||||
|
||||
# Use the returned record for the form so saved values show immediately;
|
||||
# get_settings() can return cached data without the new attribute until reload.
|
||||
test_result =
|
||||
|
|
@ -907,9 +962,9 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
|> assign(:oidc_only, Mv.Config.oidc_only?())
|
||||
|> assign(:oidc_configured, Mv.Config.oidc_configured?())
|
||||
|> assign(:smtp_configured, Mv.Config.smtp_configured?())
|
||||
|> assign(:smtp_env_mode, Mv.Config.smtp_env_mode?())
|
||||
|> assign(:smtp_missing_required_env_keys, Mv.Config.smtp_missing_required_env_keys())
|
||||
|> assign(:smtp_password_set, present?(Mv.Config.smtp_password()))
|
||||
|> assign(:smtp_from_name_env_set, Mv.Config.mail_from_name_env_set?())
|
||||
|> assign(:smtp_from_email_env_set, Mv.Config.mail_from_email_env_set?())
|
||||
|> assign(:vereinfacht_test_result, test_result)
|
||||
|> put_flash(:success, gettext("Settings updated successfully"))
|
||||
|> assign_form()
|
||||
|
|
@ -1161,10 +1216,19 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
end
|
||||
end
|
||||
|
||||
defp drop_env_managed_association_name(params) when is_map(params) do
|
||||
if Mv.Config.association_name_env_set?() do
|
||||
Map.delete(params, "club_name")
|
||||
else
|
||||
params
|
||||
end
|
||||
end
|
||||
|
||||
defp assign_form(%{assigns: %{settings: settings}} = socket) do
|
||||
# Show ENV values in disabled fields (Vereinfacht, OIDC, SMTP); never expose secrets in form
|
||||
# Show ENV values in disabled fields (Association Name, Vereinfacht, OIDC, SMTP); never expose secrets in form
|
||||
settings_display =
|
||||
settings
|
||||
|> merge_association_env_values()
|
||||
|> merge_vereinfacht_env_values()
|
||||
|> merge_oidc_env_values()
|
||||
|> merge_smtp_env_values()
|
||||
|
|
@ -1191,6 +1255,15 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
defp put_if_env_set(map, _key, false, _value), do: map
|
||||
defp put_if_env_set(map, key, true, value), do: Map.put(map, key, value)
|
||||
|
||||
defp merge_association_env_values(s) do
|
||||
put_if_env_set(
|
||||
s,
|
||||
:club_name,
|
||||
Mv.Config.association_name_env_set?(),
|
||||
Mv.Config.association_name()
|
||||
)
|
||||
end
|
||||
|
||||
defp merge_vereinfacht_env_values(s) do
|
||||
s
|
||||
|> put_if_env_set(
|
||||
|
|
@ -1249,17 +1322,25 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
end
|
||||
|
||||
defp merge_smtp_env_values(s) do
|
||||
if Mv.Config.smtp_env_mode?() do
|
||||
s
|
||||
|> Map.put(:smtp_host, Mv.Config.smtp_host())
|
||||
|> Map.put(:smtp_port, Mv.Config.smtp_port())
|
||||
|> Map.put(:smtp_username, Mv.Config.smtp_username())
|
||||
|> Map.put(:smtp_ssl, Mv.Config.smtp_ssl())
|
||||
|> Map.put(:smtp_from_email, Mv.Config.mail_from_email())
|
||||
|> Map.put(:smtp_from_name, Mv.Config.mail_from_name())
|
||||
else
|
||||
s
|
||||
end
|
||||
s
|
||||
|> put_if_env_set(:smtp_host, Mv.Config.smtp_host_env_set?(), Mv.Config.smtp_host())
|
||||
|> put_if_env_set(:smtp_port, Mv.Config.smtp_port_env_set?(), Mv.Config.smtp_port())
|
||||
|> put_if_env_set(
|
||||
:smtp_username,
|
||||
Mv.Config.smtp_username_env_set?(),
|
||||
Mv.Config.smtp_username()
|
||||
)
|
||||
|> put_if_env_set(:smtp_ssl, Mv.Config.smtp_ssl_env_set?(), Mv.Config.smtp_ssl())
|
||||
|> put_if_env_set(
|
||||
:smtp_from_email,
|
||||
Mv.Config.mail_from_email_env_set?(),
|
||||
Mv.Config.mail_from_email()
|
||||
)
|
||||
|> put_if_env_set(
|
||||
:smtp_from_name,
|
||||
Mv.Config.mail_from_name_env_set?(),
|
||||
Mv.Config.mail_from_name()
|
||||
)
|
||||
end
|
||||
|
||||
defp enrich_sync_errors([]), do: []
|
||||
|
|
|
|||
|
|
@ -836,6 +836,12 @@ defmodule MvWeb.GroupLive.Show do
|
|||
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
|
||||
socket = reload_group(socket, group.slug, actor)
|
||||
|
||||
|
|
|
|||
|
|
@ -47,11 +47,14 @@ defmodule MvWeb.ImportLive do
|
|||
# after this limit is reached.
|
||||
@max_errors 50
|
||||
|
||||
# Maximum length for error messages before truncation
|
||||
@max_error_message_length 200
|
||||
|
||||
@impl true
|
||||
def mount(_params, session, socket) do
|
||||
# Get locale from session for translations
|
||||
locale = session["locale"] || "de"
|
||||
_ = Gettext.put_locale(MvWeb.Gettext, locale)
|
||||
Gettext.put_locale(MvWeb.Gettext, locale)
|
||||
|
||||
# Get club name from settings
|
||||
club_name =
|
||||
|
|
@ -99,12 +102,7 @@ defmodule MvWeb.ImportLive do
|
|||
<.form_section title={gettext("Choose CSV file")}>
|
||||
<Components.custom_fields_notice {assigns} />
|
||||
<Components.template_links {assigns} />
|
||||
<%= if @import_status != :preview do %>
|
||||
<Components.import_form {assigns} />
|
||||
<% end %>
|
||||
<%= if @import_status == :preview do %>
|
||||
<Components.preview {assigns} />
|
||||
<% end %>
|
||||
<Components.import_form {assigns} />
|
||||
<%= if @import_status == :running or @import_status == :done or @import_status == :error do %>
|
||||
<Components.import_progress {assigns} />
|
||||
<% end %>
|
||||
|
|
@ -138,29 +136,6 @@ defmodule MvWeb.ImportLive do
|
|||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("confirm_import", _params, socket) do
|
||||
case socket.assigns do
|
||||
%{import_state: import_state} when is_map(import_state) ->
|
||||
start_import(socket, import_state)
|
||||
|
||||
_ ->
|
||||
{:noreply,
|
||||
put_flash(socket, :error, gettext("No prepared import to confirm. Please upload again."))}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("cancel_import", _params, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:import_state, nil)
|
||||
|> assign(:import_progress, nil)
|
||||
|> assign(:import_status, :idle)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
# Checks if all prerequisites for starting an import are met.
|
||||
#
|
||||
# Validates:
|
||||
|
|
@ -197,10 +172,10 @@ defmodule MvWeb.ImportLive do
|
|||
end
|
||||
end
|
||||
|
||||
# Processes CSV upload and enters the mapping preview.
|
||||
# Processes CSV upload and starts import process.
|
||||
#
|
||||
# Reads the uploaded CSV file and prepares it (read-only at the DB level), then
|
||||
# shows the mapping preview. No member is created until the user confirms.
|
||||
# Reads the uploaded CSV file, prepares it for import, and initiates
|
||||
# the chunked processing workflow.
|
||||
@spec process_csv_upload(Phoenix.LiveView.Socket.t()) ::
|
||||
{:noreply, Phoenix.LiveView.Socket.t()}
|
||||
defp process_csv_upload(socket) do
|
||||
|
|
@ -209,7 +184,7 @@ defmodule MvWeb.ImportLive do
|
|||
with {:ok, content} <- consume_and_read_csv(socket),
|
||||
{:ok, import_state} <-
|
||||
MemberCSV.prepare(content, max_rows: Config.csv_import_max_rows(), actor: actor) do
|
||||
enter_preview(socket, import_state)
|
||||
start_import(socket, import_state)
|
||||
else
|
||||
{:error, reason} when is_binary(reason) ->
|
||||
{:noreply,
|
||||
|
|
@ -218,22 +193,19 @@ defmodule MvWeb.ImportLive do
|
|||
:error,
|
||||
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
|
||||
|
||||
# Shows the mapping preview without starting any processing.
|
||||
@spec enter_preview(Phoenix.LiveView.Socket.t(), map()) ::
|
||||
{:noreply, Phoenix.LiveView.Socket.t()}
|
||||
defp enter_preview(socket, import_state) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:import_state, import_state)
|
||||
|> assign(:import_progress, nil)
|
||||
|> assign(:import_status, :preview)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
# Starts the import process by initializing progress tracking and scheduling the first chunk.
|
||||
@spec start_import(Phoenix.LiveView.Socket.t(), map()) ::
|
||||
{:noreply, Phoenix.LiveView.Socket.t()}
|
||||
|
|
@ -251,6 +223,64 @@ defmodule MvWeb.ImportLive do
|
|||
{:noreply, socket}
|
||||
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
|
||||
def handle_info({:process_chunk, idx}, socket) do
|
||||
case socket.assigns do
|
||||
|
|
@ -304,38 +334,35 @@ defmodule MvWeb.ImportLive do
|
|||
custom_field_lookup: import_state.custom_field_lookup,
|
||||
existing_error_count: length(progress.errors),
|
||||
max_errors: @max_errors,
|
||||
actor: actor,
|
||||
fee_type_map: import_state.fee_type_map,
|
||||
groups_found: import_state.groups_found
|
||||
actor: actor
|
||||
]
|
||||
|
||||
_ =
|
||||
if Config.sql_sandbox?() do
|
||||
run_chunk_with_locale(
|
||||
locale,
|
||||
chunk,
|
||||
import_state.column_map,
|
||||
import_state.custom_field_map,
|
||||
opts,
|
||||
live_view_pid,
|
||||
idx
|
||||
)
|
||||
else
|
||||
Task.Supervisor.start_child(
|
||||
Mv.TaskSupervisor,
|
||||
fn ->
|
||||
run_chunk_with_locale(
|
||||
locale,
|
||||
chunk,
|
||||
import_state.column_map,
|
||||
import_state.custom_field_map,
|
||||
opts,
|
||||
live_view_pid,
|
||||
idx
|
||||
)
|
||||
end
|
||||
)
|
||||
end
|
||||
if Config.sql_sandbox?() do
|
||||
run_chunk_with_locale(
|
||||
locale,
|
||||
chunk,
|
||||
import_state.column_map,
|
||||
import_state.custom_field_map,
|
||||
opts,
|
||||
live_view_pid,
|
||||
idx
|
||||
)
|
||||
else
|
||||
Task.Supervisor.start_child(
|
||||
Mv.TaskSupervisor,
|
||||
fn ->
|
||||
run_chunk_with_locale(
|
||||
locale,
|
||||
chunk,
|
||||
import_state.column_map,
|
||||
import_state.custom_field_map,
|
||||
opts,
|
||||
live_view_pid,
|
||||
idx
|
||||
)
|
||||
end
|
||||
)
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
|
@ -351,7 +378,7 @@ defmodule MvWeb.ImportLive do
|
|||
live_view_pid,
|
||||
idx
|
||||
) 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)
|
||||
end
|
||||
|
||||
|
|
@ -367,11 +394,8 @@ defmodule MvWeb.ImportLive do
|
|||
new_progress =
|
||||
ImportRunner.merge_progress(progress, chunk_result, idx, max_errors: @max_errors)
|
||||
|
||||
new_import_state = ImportRunner.carry_groups_forward(import_state, chunk_result)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:import_state, new_import_state)
|
||||
|> assign(:import_progress, new_progress)
|
||||
|> assign(:import_status, new_progress.status)
|
||||
|> maybe_send_next_chunk(idx, length(import_state.chunks))
|
||||
|
|
|
|||
|
|
@ -25,22 +25,7 @@ defmodule MvWeb.ImportLive.Components do
|
|||
<div>
|
||||
<p class="text-sm mb-2">
|
||||
{gettext(
|
||||
"Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored."
|
||||
)}
|
||||
</p>
|
||||
<p class="text-sm mb-2">
|
||||
{gettext(
|
||||
"Groups column (recognized headers): Groups, Gruppen, Gruppe. Comma-separated group names are supported and missing groups are created automatically."
|
||||
)}
|
||||
</p>
|
||||
<p class="text-sm mb-2">
|
||||
{gettext(
|
||||
"Fee type column (recognized headers): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unknown fee types fall back to the default."
|
||||
)}
|
||||
</p>
|
||||
<p class="text-sm">
|
||||
{gettext(
|
||||
"Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitragsstatus) are always ignored and cannot be imported."
|
||||
"Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored. Groups and membership fees are not supported for import."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -59,12 +44,20 @@ defmodule MvWeb.ImportLive.Components do
|
|||
</p>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li>
|
||||
<.link href={~p"/admin/import/template/en"} class="link link-primary">
|
||||
<.link
|
||||
href={~p"/templates/member_import_en.csv"}
|
||||
download="member_import_en.csv"
|
||||
class="link link-primary"
|
||||
>
|
||||
{gettext("English Template")}
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link href={~p"/admin/import/template/de"} class="link link-primary">
|
||||
<.link
|
||||
href={~p"/templates/member_import_de.csv"}
|
||||
download="member_import_de.csv"
|
||||
class="link link-primary"
|
||||
>
|
||||
{gettext("German Template")}
|
||||
</.link>
|
||||
</li>
|
||||
|
|
@ -115,194 +108,6 @@ defmodule MvWeb.ImportLive.Components do
|
|||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders the mapping preview shown between upload and processing.
|
||||
|
||||
Shows the column-to-role mapping, up to 3 sample rows, and notices for
|
||||
auto-created groups, unresolved fee types, empty fee-type cells, and unknown
|
||||
columns. Nothing is written until the user confirms.
|
||||
"""
|
||||
def preview(assigns) do
|
||||
state = assigns.import_state
|
||||
column_roles = column_roles(state)
|
||||
column_samples = column_samples(state.preview_rows, length(state.headers))
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:column_roles, column_roles)
|
||||
|> assign(:column_samples, column_samples)
|
||||
|
||||
~H"""
|
||||
<section
|
||||
class="mt-4 space-y-4"
|
||||
data-testid="import-preview"
|
||||
aria-labelledby="import-preview-heading"
|
||||
>
|
||||
<h2 id="import-preview-heading" class="text-lg font-semibold">
|
||||
{gettext("Preview import")}
|
||||
</h2>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm w-full" data-testid="preview-mapping-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{gettext("Role")}</th>
|
||||
<th>{gettext("Column")}</th>
|
||||
<th class="text-base-content/60">{gettext("Row 1")}</th>
|
||||
<th class="text-base-content/60">{gettext("Row 2")}</th>
|
||||
<th class="text-base-content/60">{gettext("Row 3")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<%= for {{header, role}, samples} <- Enum.zip(@column_roles, @column_samples) do %>
|
||||
<tr class={role_row_class(role)} data-testid="preview-column-row">
|
||||
<td>
|
||||
<span class={"badge badge-sm #{role_badge_class(role)}"}>
|
||||
{role_label(role)}
|
||||
</span>
|
||||
</td>
|
||||
<td class="font-medium">{header}</td>
|
||||
<%= for sample <- samples do %>
|
||||
<td class="text-base-content/70 max-w-32 truncate">{sample}</td>
|
||||
<% end %>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<%= if @import_state.groups_to_create != [] do %>
|
||||
<div class="alert alert-info" role="note" data-testid="preview-groups-notice">
|
||||
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
|
||||
<div>
|
||||
<p class="text-sm">
|
||||
{gettext("These groups will be created automatically: %{names}",
|
||||
names: Enum.join(@import_state.groups_to_create, ", ")
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @import_state.fee_type_warnings != [] do %>
|
||||
<div class="alert alert-warning" role="alert" data-testid="preview-fee-type-warning">
|
||||
<.icon name="hero-exclamation-circle" class="size-5" aria-hidden="true" />
|
||||
<div>
|
||||
<p class="text-sm">
|
||||
{gettext("Unknown fee types (members get the default): %{names}",
|
||||
names: Enum.join(@import_state.fee_type_warnings, ", ")
|
||||
)}
|
||||
</p>
|
||||
<.link
|
||||
navigate={~p"/membership_fee_settings/new_fee_type"}
|
||||
class="link link-primary text-sm"
|
||||
>
|
||||
{gettext("Create fee type")}
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @import_state.has_empty_fee_type_cells? do %>
|
||||
<div class="alert alert-info" role="note" data-testid="preview-fee-type-info">
|
||||
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
|
||||
<div>
|
||||
<p class="text-sm">
|
||||
{gettext("Rows with an empty fee type will get the default fee type.")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @import_state.warnings != [] do %>
|
||||
<div class="alert alert-warning" role="alert" data-testid="preview-unknown-warning">
|
||||
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
|
||||
<div>
|
||||
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||
<%= for warning <- @import_state.warnings do %>
|
||||
<li>{warning}</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<.link navigate={~p"/admin/datafields"} class="link link-primary text-sm">
|
||||
{gettext("Create custom field")}
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<.button
|
||||
type="button"
|
||||
phx-click="confirm_import"
|
||||
variant="primary"
|
||||
data-testid="confirm-import-button"
|
||||
>
|
||||
{gettext("Confirm and Import")}
|
||||
</.button>
|
||||
<.button type="button" phx-click="cancel_import" data-testid="cancel-import-button">
|
||||
{gettext("Cancel")}
|
||||
</.button>
|
||||
</div>
|
||||
</section>
|
||||
"""
|
||||
end
|
||||
|
||||
# Pairs each CSV header with its resolved role for the preview mapping table.
|
||||
defp column_roles(state) do
|
||||
member_indices = MapSet.new(Map.values(state.column_map))
|
||||
custom_indices = MapSet.new(Map.values(state.custom_field_map))
|
||||
ignored_headers = MapSet.new(state.ignored)
|
||||
|
||||
state.headers
|
||||
|> Enum.with_index()
|
||||
|> Enum.map(fn {header, index} ->
|
||||
{header, role_for(index, header, state, member_indices, custom_indices, ignored_headers)}
|
||||
end)
|
||||
end
|
||||
|
||||
defp role_for(index, header, state, member_indices, custom_indices, ignored_headers) do
|
||||
cond do
|
||||
index == state.groups_column_index -> :groups
|
||||
index == state.fee_type_column_index -> :fee_type
|
||||
MapSet.member?(ignored_headers, header) -> :ignored
|
||||
MapSet.member?(member_indices, index) -> :member_field
|
||||
MapSet.member?(custom_indices, index) -> :custom_field
|
||||
true -> :unknown
|
||||
end
|
||||
end
|
||||
|
||||
defp role_label(:member_field), do: gettext("Member field")
|
||||
defp role_label(:custom_field), do: gettext("Custom field")
|
||||
defp role_label(:groups), do: gettext("Groups")
|
||||
defp role_label(:fee_type), do: gettext("Fee type")
|
||||
defp role_label(:ignored), do: gettext("Ignored (system-computed field)")
|
||||
defp role_label(:unknown), do: gettext("Unknown (ignored)")
|
||||
|
||||
defp role_badge_class(:member_field), do: "badge-primary"
|
||||
defp role_badge_class(:custom_field), do: "badge-secondary"
|
||||
defp role_badge_class(:groups), do: "badge-success"
|
||||
defp role_badge_class(:fee_type), do: "badge-warning"
|
||||
defp role_badge_class(:ignored), do: "badge-ghost"
|
||||
defp role_badge_class(:unknown), do: "badge-error"
|
||||
|
||||
defp role_row_class(:ignored), do: "opacity-50"
|
||||
defp role_row_class(:unknown), do: "opacity-50"
|
||||
defp role_row_class(_), do: nil
|
||||
|
||||
defp column_samples([], col_count), do: List.duplicate([], col_count)
|
||||
|
||||
defp column_samples(rows, col_count) do
|
||||
Enum.map(0..(col_count - 1), fn col_idx ->
|
||||
rows
|
||||
|> Enum.map(fn row -> Enum.at(row, col_idx, "") end)
|
||||
|> pad_to(3, "")
|
||||
end)
|
||||
end
|
||||
|
||||
defp pad_to(list, target, fill) do
|
||||
list ++ List.duplicate(fill, max(0, target - length(list)))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders import progress text and, when done or aborted, the import results section.
|
||||
"""
|
||||
|
|
@ -449,10 +254,8 @@ defmodule MvWeb.ImportLive.Components do
|
|||
@doc """
|
||||
Returns whether the Start Import button should be disabled.
|
||||
"""
|
||||
@spec import_button_disabled?(:idle | :preview | :running | :done | :error, [map()]) ::
|
||||
boolean()
|
||||
@spec import_button_disabled?(:idle | :running | :done | :error, [map()]) :: boolean()
|
||||
def import_button_disabled?(:running, _entries), do: true
|
||||
def import_button_disabled?(:preview, _entries), do: true
|
||||
def import_button_disabled?(_status, []), do: true
|
||||
def import_button_disabled?(_status, [entry | _]) when not entry.done?, do: true
|
||||
def import_button_disabled?(_status, _entries), do: false
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ defmodule MvWeb.JoinLive do
|
|||
alias Ash.Resource.Info
|
||||
alias Mv.Membership
|
||||
alias Mv.Membership.CustomFieldLookup
|
||||
alias MvWeb.Helpers.JoinDescriptionRenderer
|
||||
alias MvWeb.JoinRateLimit
|
||||
alias MvWeb.Translations.MemberFields
|
||||
|
||||
|
|
@ -97,20 +96,14 @@ defmodule MvWeb.JoinLive do
|
|||
class="checkbox checkbox-sm"
|
||||
/>
|
||||
<span class="label-text">
|
||||
{render_field_label(field)}<span
|
||||
:if={field.required}
|
||||
aria-hidden="true"
|
||||
> *</span>
|
||||
{field.label}<span :if={field.required} aria-hidden="true"> *</span>
|
||||
</span>
|
||||
</label>
|
||||
<% else %>
|
||||
<div>
|
||||
<label for={"join-field-#{field.id}"} class="label">
|
||||
<span class="label-text">
|
||||
{render_field_label(field)}<span
|
||||
:if={field.required}
|
||||
aria-hidden="true"
|
||||
> *</span>
|
||||
{field.label}<span :if={field.required} aria-hidden="true"> *</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
|
|
@ -244,17 +237,6 @@ defmodule MvWeb.JoinLive do
|
|||
|> assign(:form, to_form(params, as: "join"))}
|
||||
end
|
||||
|
||||
# Renders a join field's label. When a custom field has a join_description it is
|
||||
# rendered with auto-linked URLs/Markdown; otherwise the plain field label is used.
|
||||
# Safe: join_description is admin-set settings content, never end-user input, and
|
||||
# JoinDescriptionRenderer escapes all non-link text (only emits <a href> tags).
|
||||
defp render_field_label(%{join_description: join_description})
|
||||
when is_binary(join_description) do
|
||||
JoinDescriptionRenderer.render(join_description)
|
||||
end
|
||||
|
||||
defp render_field_label(%{label: label}), do: label
|
||||
|
||||
defp build_join_fields_with_labels(allowlist) do
|
||||
member_field_strings = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||
custom_field_by_id = custom_field_map(allowlist, member_field_strings)
|
||||
|
|
@ -267,36 +249,20 @@ defmodule MvWeb.JoinLive do
|
|||
defp build_join_field(id, required, member_field_strings, custom_field_by_id) do
|
||||
if id in member_field_strings do
|
||||
label = MemberFields.label(String.to_existing_atom(id))
|
||||
|
||||
%{
|
||||
id: id,
|
||||
label: label,
|
||||
required: required,
|
||||
input_type: member_field_input_type(id),
|
||||
join_description: nil
|
||||
}
|
||||
%{id: id, label: label, required: required, input_type: member_field_input_type(id)}
|
||||
else
|
||||
custom_field = Map.get(custom_field_by_id, id)
|
||||
label = if custom_field, do: custom_field.name, else: gettext("Field")
|
||||
input_type = custom_field_input_type(custom_field && custom_field.value_type)
|
||||
|
||||
%{
|
||||
id: id,
|
||||
label: label,
|
||||
required: required,
|
||||
input_type: input_type,
|
||||
join_description: custom_field && custom_field.join_description
|
||||
}
|
||||
%{id: id, label: label, required: required, input_type: input_type}
|
||||
end
|
||||
end
|
||||
|
||||
defp custom_field_map(allowlist, _member_field_strings) do
|
||||
allowlist
|
||||
|> Enum.map(& &1.id)
|
||||
|> CustomFieldLookup.fetch_map_by_ids(
|
||||
authorize?: false,
|
||||
select: [:id, :name, :value_type, :join_description]
|
||||
)
|
||||
|> CustomFieldLookup.fetch_map_by_ids(authorize?: false, select: [:id, :name, :value_type])
|
||||
end
|
||||
|
||||
defp initial_form_params(join_fields) do
|
||||
|
|
@ -321,6 +287,8 @@ defmodule MvWeb.JoinLive do
|
|||
end
|
||||
end
|
||||
|
||||
defp member_field_input_type(_), do: "text"
|
||||
|
||||
defp member_field_atom(field_id) when is_binary(field_id) do
|
||||
Mv.Constants.member_fields()
|
||||
|> Enum.find(&(Atom.to_string(&1) == field_id))
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
## Events
|
||||
- `select_member` - Toggle individual member selection
|
||||
- `select_all` - Toggle selection of all visible members
|
||||
- `copy_emails` - Copy email addresses of the selected members, or of all/filtered members when nothing is selected
|
||||
- `copy_emails` - Copy email addresses of selected members to clipboard
|
||||
|
||||
## Implementation Notes
|
||||
- Search uses PostgreSQL full-text search (plainto_tsquery)
|
||||
|
|
@ -36,8 +36,6 @@ defmodule MvWeb.MemberLive.Index do
|
|||
alias Mv.MembershipFees
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias MvWeb.Helpers.DateFormatter
|
||||
alias MvWeb.MemberLive.Index.CustomFieldValueLookup
|
||||
alias MvWeb.MemberLive.Index.DateFilter
|
||||
alias MvWeb.MemberLive.Index.FieldSelection
|
||||
alias MvWeb.MemberLive.Index.FieldVisibility
|
||||
alias MvWeb.MemberLive.Index.FilterParams
|
||||
|
|
@ -89,13 +87,6 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|> Enum.filter(&(&1.value_type == :boolean))
|
||||
|> 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)
|
||||
groups =
|
||||
Mv.Membership.Group
|
||||
|
|
@ -152,8 +143,6 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|> assign(:custom_fields_visible, custom_fields_visible)
|
||||
|> assign(:all_custom_fields, all_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(:user_field_selection, initial_selection)
|
||||
|> assign(:fields_in_url?, false)
|
||||
|
|
@ -250,42 +239,41 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|
||||
@impl true
|
||||
def handle_event("copy_emails", _params, socket) do
|
||||
members = socket.assigns.members
|
||||
selected_ids = socket.assigns.selected_members
|
||||
any_selected? = Enum.any?(members, &MapSet.member?(selected_ids, &1.id))
|
||||
|
||||
# Recipients follow the current scope: the selection when present, otherwise
|
||||
# every member in the (filtered) list. Members without an email are excluded
|
||||
# in both cases (unchanged missing-email handling). With no selection we no
|
||||
# longer hard-stop with "No members selected" — we act on the scope; the
|
||||
# empty-recipient feedback below is preserved.
|
||||
formatted_emails = scope_member_emails(members, selected_ids, any_selected?)
|
||||
# Filter members that are in the selection and have email addresses
|
||||
formatted_emails = format_selected_member_emails(socket.assigns.members, selected_ids)
|
||||
email_count = length(formatted_emails)
|
||||
|
||||
if email_count == 0 do
|
||||
{:noreply, put_flash(socket, :error, gettext("No email addresses found"))}
|
||||
else
|
||||
# RFC 5322 uses comma as separator for email address lists
|
||||
email_string = Enum.join(formatted_emails, ", ")
|
||||
cond do
|
||||
MapSet.size(selected_ids) == 0 ->
|
||||
{:noreply, put_flash(socket, :error, gettext("No members selected"))}
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> push_event("copy_to_clipboard", %{text: email_string})
|
||||
|> put_flash(
|
||||
:success,
|
||||
ngettext(
|
||||
"Copied %{count} email address to clipboard",
|
||||
"Copied %{count} email addresses to clipboard",
|
||||
email_count,
|
||||
count: email_count
|
||||
email_count == 0 ->
|
||||
{:noreply, put_flash(socket, :error, gettext("No email addresses found"))}
|
||||
|
||||
true ->
|
||||
# RFC 5322 uses comma as separator for email address lists
|
||||
email_string = Enum.join(formatted_emails, ", ")
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> push_event("copy_to_clipboard", %{text: email_string})
|
||||
|> put_flash(
|
||||
:success,
|
||||
ngettext(
|
||||
"Copied %{count} email address to clipboard",
|
||||
"Copied %{count} email addresses to clipboard",
|
||||
email_count,
|
||||
count: email_count
|
||||
)
|
||||
)
|
||||
|> put_flash(
|
||||
:warning,
|
||||
gettext("Tip: Paste email addresses into the BCC field for privacy compliance")
|
||||
)
|
||||
)
|
||||
|> put_flash(
|
||||
:warning,
|
||||
gettext("Tip: Paste email addresses into the BCC field for privacy compliance")
|
||||
)
|
||||
|
||||
{:noreply, socket}
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -460,25 +448,6 @@ defmodule MvWeb.MemberLive.Index do
|
|||
{:noreply, push_patch(socket, to: new_path, replace: true)}
|
||||
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
|
||||
def handle_info({:reset_all_filters, cycle_status_filter, boolean_filters}, socket) do
|
||||
handle_info(
|
||||
|
|
@ -533,7 +502,6 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|> assign(:group_filters, Map.get(opts, :group_filters, %{}))
|
||||
|> assign(:fee_type_filters, Map.get(opts, :fee_type_filters, %{}))
|
||||
|> assign(:boolean_custom_field_filters, Map.get(opts, :boolean_filters, %{}))
|
||||
|> assign(:date_filters, Map.get(opts, :date_filters, DateFilter.default()))
|
||||
|> load_members()
|
||||
|> update_selection_assigns()
|
||||
|
||||
|
|
@ -664,7 +632,6 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|> maybe_update_group_filters(params)
|
||||
|> maybe_update_fee_type_filters(params)
|
||||
|> maybe_update_boolean_filters(params)
|
||||
|> maybe_update_date_filters(params)
|
||||
|> maybe_update_show_current_cycle(params)
|
||||
|> assign(:fields_in_url?, fields_in_url?)
|
||||
|> assign(:query, params["query"])
|
||||
|
|
@ -716,8 +683,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
socket.assigns.show_current_cycle,
|
||||
socket.assigns.boolean_custom_field_filters,
|
||||
socket.assigns.user_field_selection,
|
||||
socket.assigns[:visible_custom_field_ids] || [],
|
||||
socket.assigns[:date_filters]
|
||||
socket.assigns[:visible_custom_field_ids] || []
|
||||
}
|
||||
end
|
||||
|
||||
|
|
@ -817,12 +783,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
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_show_current_cycle(base_params, opts.show_current_cycle)
|
||||
base_params = 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))
|
||||
add_boolean_filters(base_params, opts.boolean_filters || %{})
|
||||
end
|
||||
|
||||
defp opts_for_query_params(socket, overrides \\ %{}) do
|
||||
|
|
@ -834,8 +795,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
group_filters: socket.assigns[:group_filters] || %{},
|
||||
show_current_cycle: socket.assigns.show_current_cycle,
|
||||
boolean_filters: socket.assigns.boolean_custom_field_filters || %{},
|
||||
fee_type_filters: socket.assigns[:fee_type_filters] || %{},
|
||||
date_filters: socket.assigns.date_filters
|
||||
fee_type_filters: socket.assigns[:fee_type_filters] || %{}
|
||||
}
|
||||
|> Map.merge(overrides)
|
||||
end
|
||||
|
|
@ -981,7 +941,26 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|> Ash.Query.new()
|
||||
|> 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)
|
||||
|
||||
|
|
@ -1005,13 +984,6 @@ defmodule MvWeb.MemberLive.Index do
|
|||
query =
|
||||
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)
|
||||
custom_fields_for_sort = socket.assigns.all_custom_fields
|
||||
|
||||
|
|
@ -1031,7 +1003,21 @@ defmodule MvWeb.MemberLive.Index do
|
|||
# Custom field values are already filtered at the database level in load_custom_field_values/2
|
||||
# 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)
|
||||
# Note: :groups is in computed_member_fields() but can be sorted in-memory, so we only block :membership_fee_status
|
||||
|
|
@ -1051,55 +1037,6 @@ defmodule MvWeb.MemberLive.Index do
|
|||
assign(socket, :members, members)
|
||||
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, custom_field_ids) do
|
||||
|
|
@ -1219,6 +1156,8 @@ defmodule MvWeb.MemberLive.Index do
|
|||
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, status, show_current)
|
||||
|
|
@ -1296,6 +1235,8 @@ defmodule MvWeb.MemberLive.Index do
|
|||
end
|
||||
end
|
||||
|
||||
defp valid_sort_field?(_), do: false
|
||||
|
||||
defp valid_sort_field_db_or_custom?(field) when is_atom(field) do
|
||||
non_sortable_fields = [:notes]
|
||||
valid_fields = Mv.Constants.member_fields() -- non_sortable_fields
|
||||
|
|
@ -1555,6 +1496,8 @@ defmodule MvWeb.MemberLive.Index do
|
|||
assign(socket, :group_filters, Map.take(filters, valid_group_ids))
|
||||
end
|
||||
|
||||
defp maybe_update_group_filters(socket, _), do: socket
|
||||
|
||||
defp maybe_update_fee_type_filters(socket, params) when is_map(params) do
|
||||
prefix = @fee_type_filter_prefix
|
||||
prefix_len = String.length(prefix)
|
||||
|
|
@ -1581,6 +1524,8 @@ defmodule MvWeb.MemberLive.Index do
|
|||
assign(socket, :fee_type_filters, Map.take(filters, valid_fee_type_ids))
|
||||
end
|
||||
|
||||
defp maybe_update_fee_type_filters(socket, _), do: socket
|
||||
|
||||
defp add_fee_type_filter_entry(acc, key, value_str, prefix_len) do
|
||||
key_str = to_string(key)
|
||||
raw_id = String.slice(key_str, prefix_len, String.length(key_str) - prefix_len)
|
||||
|
|
@ -1704,20 +1649,24 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|
||||
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
|
||||
# -------------------------------------------------------------
|
||||
|
||||
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
|
||||
|
||||
def get_boolean_custom_field_value(member, custom_field) do
|
||||
|
|
@ -1776,12 +1725,29 @@ defmodule MvWeb.MemberLive.Index do
|
|||
end
|
||||
|
||||
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
|
||||
cfv -> extract_boolean_value(cfv.value) == filter_value
|
||||
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
|
||||
members
|
||||
|> Enum.filter(fn member ->
|
||||
|
|
@ -1813,79 +1779,24 @@ defmodule MvWeb.MemberLive.Index do
|
|||
selected_count = Enum.count(members, &MapSet.member?(selected_members, &1.id))
|
||||
any_selected? = Enum.any?(members, &MapSet.member?(selected_members, &1.id))
|
||||
|
||||
# Scope drives the trigger label: the selection when present, otherwise the
|
||||
# whole list (filtered, when a search term or any filter is active).
|
||||
scope =
|
||||
cond do
|
||||
any_selected? -> :selection
|
||||
filters_active?(socket.assigns) -> :filtered
|
||||
true -> :all
|
||||
end
|
||||
|
||||
# Copy/Mailto recipients: the members in scope that have a usable email.
|
||||
# With a selection that is the selected subset (existing behaviour); without
|
||||
# a selection it is every member in scope (deliberate behaviour change). In
|
||||
# both cases members without an email are excluded, exactly as today's
|
||||
# format_selected_member_emails does for the selection case.
|
||||
recipient_emails = scope_member_emails(members, selected_members, any_selected?)
|
||||
recipient_count = length(recipient_emails)
|
||||
|
||||
# RFC 6068: mailto URI params must use %20 for spaces, not + (encode_www_form uses +)
|
||||
mailto_bcc =
|
||||
recipient_emails
|
||||
|> Enum.join(", ")
|
||||
|> URI.encode_www_form()
|
||||
|> String.replace("+", "%20")
|
||||
|
||||
mailto_disabled? = recipient_count >= Mv.Constants.max_mailto_bulk_recipients()
|
||||
if any_selected? do
|
||||
format_selected_member_emails(members, selected_members)
|
||||
|> Enum.join(", ")
|
||||
|> URI.encode_www_form()
|
||||
|> String.replace("+", "%20")
|
||||
else
|
||||
""
|
||||
end
|
||||
|
||||
socket
|
||||
|> assign(:selected_count, selected_count)
|
||||
|> assign(:scope, scope)
|
||||
|> assign(:recipient_count, recipient_count)
|
||||
|> assign(:mailto_disabled?, mailto_disabled?)
|
||||
|> assign(:any_selected?, any_selected?)
|
||||
|> assign(:mailto_bcc, mailto_bcc)
|
||||
|> assign_export_payload()
|
||||
end
|
||||
|
||||
# Returns the formatted "Name <email>" recipient list for the current scope:
|
||||
# the selected members when any are selected, otherwise every member in the
|
||||
# (filtered) list. Members without an email are excluded in both cases.
|
||||
defp scope_member_emails(members, selected_members, true = _any_selected?),
|
||||
do: format_selected_member_emails(members, selected_members)
|
||||
|
||||
defp scope_member_emails(members, _selected_members, false = _any_selected?) do
|
||||
members
|
||||
|> Enum.filter(fn member -> member.email && member.email != "" end)
|
||||
|> Enum.map(&format_member_email/1)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns true when the member list is restricted by a non-empty search term or
|
||||
any active filter (cycle status, group, fee type, boolean custom field, or a
|
||||
date filter differing from the default). Drives the "(gefiltert)" vs "(alle)"
|
||||
trigger label and reads only assigns — no DB access.
|
||||
"""
|
||||
def filters_active?(assigns) do
|
||||
search_active?(assigns) or selection_filters_active?(assigns) or date_filter_active?(assigns)
|
||||
end
|
||||
|
||||
defp search_active?(assigns) do
|
||||
query = assigns[:query]
|
||||
is_binary(query) and query != ""
|
||||
end
|
||||
|
||||
defp selection_filters_active?(assigns) do
|
||||
not is_nil(assigns[:cycle_status_filter]) or
|
||||
map_size(assigns[:group_filters] || %{}) > 0 or
|
||||
map_size(assigns[:fee_type_filters] || %{}) > 0 or
|
||||
map_size(assigns[:boolean_custom_field_filters] || %{}) > 0
|
||||
end
|
||||
|
||||
defp date_filter_active?(assigns) do
|
||||
(assigns[:date_filters] || DateFilter.default()) != DateFilter.default()
|
||||
end
|
||||
|
||||
defp assign_export_payload(socket) do
|
||||
payload = build_export_payload(socket)
|
||||
assign(socket, :export_payload_json, Jason.encode!(payload))
|
||||
|
|
|
|||
|
|
@ -3,15 +3,32 @@
|
|||
{@content_title}
|
||||
<:actions>
|
||||
<.live_component
|
||||
module={MvWeb.Components.BulkActionsDropdown}
|
||||
id="bulk-actions-dropdown"
|
||||
module={MvWeb.Components.ExportDropdown}
|
||||
id="export-dropdown"
|
||||
export_payload_json={@export_payload_json}
|
||||
selected_count={@selected_count}
|
||||
scope={@scope}
|
||||
mailto_bcc={@mailto_bcc}
|
||||
recipient_count={@recipient_count}
|
||||
mailto_disabled?={@mailto_disabled?}
|
||||
/>
|
||||
<.button
|
||||
variant="secondary"
|
||||
id="copy-emails-btn"
|
||||
phx-hook="CopyToClipboard"
|
||||
phx-click="copy_emails"
|
||||
disabled={not @any_selected?}
|
||||
aria-label={gettext("Copy email addresses of selected members")}
|
||||
>
|
||||
<.icon name="hero-clipboard-document" />
|
||||
{gettext("Copy email addresses")} ({@selected_count})
|
||||
</.button>
|
||||
<.button
|
||||
variant="secondary"
|
||||
id="open-email-btn"
|
||||
href={"mailto:?bcc=" <> @mailto_bcc}
|
||||
disabled={not @any_selected?}
|
||||
aria-label={gettext("Open email program with BCC recipients")}
|
||||
>
|
||||
<.icon name="hero-envelope" />
|
||||
{gettext("Open in email program")}
|
||||
</.button>
|
||||
<%= if can?(@current_user, :create, Mv.Membership.Member) do %>
|
||||
<.button variant="primary" navigate={~p"/members/new"} data-testid="member-new">
|
||||
<.icon name="hero-plus" /> {gettext("New Member")}
|
||||
|
|
@ -37,8 +54,6 @@
|
|||
fee_type_filters={@fee_type_filters}
|
||||
boolean_custom_fields={@boolean_custom_fields}
|
||||
boolean_filters={@boolean_custom_field_filters}
|
||||
date_custom_fields={@date_custom_fields}
|
||||
date_filters={@date_filters}
|
||||
member_count={length(@members)}
|
||||
/>
|
||||
<.tooltip
|
||||
|
|
@ -90,9 +105,7 @@
|
|||
<.table
|
||||
id="members"
|
||||
rows={@members}
|
||||
wrapper_overflow_class="overflow-visible"
|
||||
sticky_header={true}
|
||||
sticky_first_col={true}
|
||||
row_id={fn member -> "row-#{member.id}" end}
|
||||
row_click={fn member -> JS.push("select_row_and_navigate", value: %{id: member.id}) end}
|
||||
row_tooltip={gettext("Click for member details")}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
defp parse_cookie_header(_), do: %{}
|
||||
|
||||
@doc """
|
||||
Saves field selection to cookie.
|
||||
|
||||
|
|
@ -216,6 +218,8 @@ defmodule MvWeb.MemberLive.Index.FieldSelection do
|
|||
end
|
||||
end
|
||||
|
||||
defp parse_json(_), do: %{}
|
||||
|
||||
# Parses a comma-separated string of field names
|
||||
defp parse_fields_string(fields_string) do
|
||||
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
|
||||
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
|
||||
|
||||
@doc """
|
||||
|
|
|
|||
|
|
@ -1,12 +1,8 @@
|
|||
defmodule MvWeb.MemberLive.Index.FilterParams do
|
||||
@moduledoc """
|
||||
Shared parsing helpers for member list filter URL/params.
|
||||
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Parses a value for group or fee-type filter params.
|
||||
Returns `:in`, `:not_in`, or `nil`. Handles trimmed strings; no recursion.
|
||||
|
|
@ -23,29 +19,4 @@ defmodule MvWeb.MemberLive.Index.FilterParams do
|
|||
end
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -235,19 +235,6 @@ defmodule MvWeb.MemberLive.Show do
|
|||
<%= for custom_field <- @custom_fields do %>
|
||||
<% cfv = find_custom_field_value(@member.custom_field_values, custom_field.id) %>
|
||||
<.data_field label={custom_field.name}>
|
||||
<:label_suffix :if={custom_field.join_description}>
|
||||
<.tooltip
|
||||
content={"#{gettext("Join form:")} #{custom_field.join_description}"}
|
||||
wrap_class="ml-1 inline-flex items-center"
|
||||
>
|
||||
<span data-testid="join-description-tooltip">
|
||||
<.icon
|
||||
name="hero-information-circle"
|
||||
class="size-3.5 text-base-content/50"
|
||||
/>
|
||||
</span>
|
||||
</.tooltip>
|
||||
</:label_suffix>
|
||||
{format_custom_field_value(cfv, custom_field.value_type)}
|
||||
</.data_field>
|
||||
<% end %>
|
||||
|
|
@ -618,14 +605,11 @@ defmodule MvWeb.MemberLive.Show do
|
|||
attr :value, :string, default: nil
|
||||
attr :class, :string, default: ""
|
||||
slot :inner_block
|
||||
slot :label_suffix
|
||||
|
||||
defp data_field(assigns) do
|
||||
~H"""
|
||||
<dl class={@class}>
|
||||
<dt class="text-sm font-medium text-base-content/70 flex items-center">
|
||||
{@label}{render_slot(@label_suffix)}
|
||||
</dt>
|
||||
<dt class="text-sm font-medium text-base-content/70">{@label}</dt>
|
||||
<dd class="mt-1 text-base-content">
|
||||
<%= if @inner_block != [] do %>
|
||||
{render_slot(@inner_block)}
|
||||
|
|
|
|||
|
|
@ -1027,7 +1027,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
|> assign(:create_cycle_error, format_error(error))}
|
||||
end
|
||||
else
|
||||
{:error, reason} when reason in [:invalid_format, :invalid_date, :incompatible_calendars] ->
|
||||
:error ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> 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)
|
||||
end
|
||||
|
||||
defp format_error(error) when is_binary(error), do: error
|
||||
defp format_error(_error), do: gettext("An error occurred")
|
||||
|
||||
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
|
||||
{:ok, membership_fee_type} ->
|
||||
_ = notify_parent({:saved, membership_fee_type})
|
||||
notify_parent({:saved, membership_fee_type})
|
||||
|
||||
socket =
|
||||
socket
|
||||
|
|
@ -341,7 +341,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
|
|||
end
|
||||
end
|
||||
|
||||
@spec notify_parent(any()) :: {module(), any()}
|
||||
@spec notify_parent(any()) :: any()
|
||||
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
||||
|
||||
@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)
|
||||
end
|
||||
|
||||
defp format_error(error) when is_binary(error), do: error
|
||||
defp format_error(_error), do: gettext("An error occurred")
|
||||
|
||||
# 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
|
||||
{:ok, role} ->
|
||||
_ = notify_parent({:saved, role})
|
||||
notify_parent({:saved, role})
|
||||
|
||||
redirect_path =
|
||||
if socket.assigns.return_to == "show" do
|
||||
|
|
@ -186,7 +186,7 @@ defmodule MvWeb.RoleLive.Form do
|
|||
end
|
||||
end
|
||||
|
||||
@spec notify_parent(any()) :: {module(), any()}
|
||||
@spec notify_parent(any()) :: any()
|
||||
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
||||
|
||||
@spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
|
||||
|
|
|
|||
|
|
@ -734,7 +734,7 @@ defmodule MvWeb.UserLive.Form do
|
|||
end
|
||||
|
||||
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)
|
||||
|
||||
|
|
@ -775,7 +775,7 @@ defmodule MvWeb.UserLive.Form do
|
|||
)}
|
||||
end
|
||||
|
||||
@spec notify_parent(any()) :: {module(), any()}
|
||||
@spec notify_parent(any()) :: any()
|
||||
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
||||
|
||||
# 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)
|
||||
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
|
||||
case Authorization.list_roles(actor: actor) do
|
||||
{:ok, roles} -> roles
|
||||
|
|
@ -922,7 +922,7 @@ defmodule MvWeb.UserLive.Form do
|
|||
end
|
||||
|
||||
# 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
|
||||
# Take first error and extract message
|
||||
case List.first(errors) do
|
||||
|
|
@ -932,5 +932,6 @@ defmodule MvWeb.UserLive.Form do
|
|||
end
|
||||
end
|
||||
|
||||
defp extract_error_message(error) when is_binary(error), do: error
|
||||
defp extract_error_message(_), do: gettext("Unknown error")
|
||||
end
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ defmodule MvWeb.LiveHelpers do
|
|||
|
||||
def on_mount(:default, _params, session, socket) do
|
||||
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)
|
||||
connect_params = socket.private[:connect_params] || %{}
|
||||
|
|
@ -145,10 +145,7 @@ defmodule MvWeb.LiveHelpers do
|
|||
end
|
||||
"""
|
||||
@spec submit_form(AshPhoenix.Form.t(), map(), Mv.Accounts.User.t() | nil) ::
|
||||
{:ok, Ash.Resource.record() | nil | [Ash.Notifier.Notification.t()]}
|
||||
| {:ok, Ash.Resource.record(), [Ash.Notifier.Notification.t()]}
|
||||
| :ok
|
||||
| {:error, AshPhoenix.Form.t()}
|
||||
{:ok, Ash.Resource.t()} | {:error, AshPhoenix.Form.t()}
|
||||
def submit_form(form, params, actor) do
|
||||
AshPhoenix.Form.submit(form, params: params, action_opts: ash_actor_opts(actor))
|
||||
end
|
||||
|
|
|
|||
|
|
@ -31,24 +31,27 @@ defmodule MvWeb.LiveUserAuth do
|
|||
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
|
||||
%{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
|
||||
|
||||
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).
|
||||
locale = session["locale"] || Application.get_env(:mv, :default_locale, "de")
|
||||
_ = Gettext.put_locale(MvWeb.Gettext, locale)
|
||||
socket = assign(socket, :locale, locale)
|
||||
Gettext.put_locale(MvWeb.Gettext, locale)
|
||||
{:cont, assign(socket, :locale, locale)}
|
||||
|
||||
if socket.assigns[:current_user] do
|
||||
{:halt, LiveView.redirect(socket, to: ~p"/")}
|
||||
{:halt, Phoenix.LiveView.redirect(socket, to: ~p"/")}
|
||||
else
|
||||
{:cont, assign(socket, :current_user, nil)}
|
||||
end
|
||||
|
|
|
|||
|
|
@ -102,10 +102,6 @@ defmodule MvWeb.Router do
|
|||
# Import (Admin only)
|
||||
live "/admin/import", ImportLive
|
||||
|
||||
# Dynamic CSV import templates (admin only; generated from current custom fields)
|
||||
get "/admin/import/template/en", ImportTemplateController, :en
|
||||
get "/admin/import/template/de", ImportTemplateController, :de
|
||||
|
||||
post "/members/export.csv", MemberExportController, :export
|
||||
post "/members/export.pdf", MemberPdfExportController, :export
|
||||
post "/set_locale", LocaleController, :set_locale
|
||||
|
|
@ -116,7 +112,7 @@ defmodule MvWeb.Router do
|
|||
|
||||
# ASHAUTHENTICATION GENERATED AUTH ROUTES
|
||||
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
|
||||
sign_in_route register_path: "/register",
|
||||
|
|
@ -192,7 +188,7 @@ defmodule MvWeb.Router do
|
|||
get_locale_from_cookie(conn) ||
|
||||
extract_locale_from_headers(conn.req_headers)
|
||||
|
||||
_ = Gettext.put_locale(MvWeb.Gettext, locale)
|
||||
Gettext.put_locale(MvWeb.Gettext, locale)
|
||||
|
||||
conn
|
||||
|> put_session(:locale, locale)
|
||||
|
|
|
|||
|
|
@ -12,9 +12,7 @@ defmodule MvWeb.Translations.FieldTypes do
|
|||
"""
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
@type field_type :: :string | :integer | :boolean | :date | :email
|
||||
|
||||
@spec label(field_type()) :: String.t()
|
||||
@spec label(atom()) :: String.t()
|
||||
def label(:string), do: gettext("Text")
|
||||
def label(:integer), do: gettext("Number")
|
||||
def label(:boolean), do: gettext("Yes/No-Selection")
|
||||
|
|
|
|||
BIN
logo.png
BIN
logo.png
Binary file not shown.
|
Before Width: | Height: | Size: 34 KiB |
25
mix.exs
25
mix.exs
|
|
@ -12,7 +12,6 @@ defmodule Mv.MixProject do
|
|||
compilers: [:phoenix_live_view] ++ Mix.compilers(),
|
||||
aliases: aliases(),
|
||||
deps: deps(),
|
||||
dialyzer: dialyzer(),
|
||||
listeners: [Phoenix.CodeReloader],
|
||||
gettext: [write_reference_line_numbers: false]
|
||||
]
|
||||
|
|
@ -39,15 +38,15 @@ defmodule Mv.MixProject do
|
|||
[
|
||||
{:tidewave, "~> 0.5", only: [:dev]},
|
||||
{:sourceror, "~> 1.8", only: [:dev, :test]},
|
||||
{:live_debugger, "~> 1.0", only: [:dev]},
|
||||
{:ash_admin, "~> 1.0"},
|
||||
{:live_debugger, "~> 0.8", only: [:dev]},
|
||||
{:ash_admin, "~> 0.14"},
|
||||
{:ash_postgres, "~> 2.0"},
|
||||
{:ash_phoenix, "~> 2.0"},
|
||||
{:ash, "~> 3.0"},
|
||||
{:bcrypt_elixir, "~> 3.0"},
|
||||
{:ash_authentication, "~> 4.9"},
|
||||
{:ash_authentication_phoenix, "~> 2.10"},
|
||||
{:igniter, "~> 0.8", only: [:dev, :test]},
|
||||
{:igniter, "~> 0.7", only: [:dev, :test]},
|
||||
{:phoenix, "~> 1.8.0-rc.4", override: true},
|
||||
{:phoenix_ecto, "~> 4.5"},
|
||||
{:ecto_sql, "~> 3.10"},
|
||||
|
|
@ -81,7 +80,6 @@ defmodule Mv.MixProject do
|
|||
{:sobelow, "~> 0.14", only: [:dev, :test], runtime: false},
|
||||
{:bypass, "~> 2.1", only: [:dev, :test]},
|
||||
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
|
||||
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false},
|
||||
{:picosat_elixir, "~> 0.1"},
|
||||
{:ecto_commons, "~> 0.3"},
|
||||
{:slugify, "~> 1.3"},
|
||||
|
|
@ -114,21 +112,4 @@ defmodule Mv.MixProject do
|
|||
"phx.routes": ["phx.routes", "ash_authentication.phoenix.routes"]
|
||||
]
|
||||
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
|
||||
|
|
|
|||
52
mix.lock
52
mix.lock
|
|
@ -1,42 +1,39 @@
|
|||
%{
|
||||
"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_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": {:hex, :ash, "3.24.7", "6e2f32011e7c8f0809dae36712ccfb2efaf3c669cbda7443685436e80acdebf7", [: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", "c9fb4d21c3c8bb85636338d448afdc283dd98a433d869e4b2210ac57ade00624"},
|
||||
"ash_admin": {:hex, :ash_admin, "0.14.0", "1a8f61f6cef7af757852e94a916a152bd3f3c3620b094de84a008120675adccd", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.1.8 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", "d3bc34c266491ae3177f2a76ad97bbe916c4d3a41d56196db9d95e76413b3455"},
|
||||
"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_phoenix": {:hex, :ash_phoenix, "2.3.22", "f59a347ee09e1fa9973fe1b2faf7f8f793acc14dc09341062783b8eb1a9f5c99", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "4b34ea84e122c238ad1843888b8fd4d21aec27605b9b1e6e27e1b70329560fbb"},
|
||||
"ash_postgres": {:hex, :ash_postgres, "2.9.1", "bf4229d65706f794650edb47c9f30138a6e2d5af6efe002ca38e619306cca9f6", [:mix], [{:ash, "~> 3.24", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, "~> 0.6", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "72c0366649985a858d4ef8f906968cee339dfd7519bb0beaa2b4d87f3d5b0bb9"},
|
||||
"ash_sql": {:hex, :ash_sql, "0.6.3", "a708b34ba71b40141dab9e75dc44a095885ae4635b25135d3fd4c3620b299b97", [:mix], [{:ash, ">= 3.24.5 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "3ee461380d96dca32766a210ea60c64783f690ad5565f0434a00cd475e71e8b9"},
|
||||
"assent": {:hex, :assent, "0.2.13", "11226365d2d8661d23e9a2cf94d3255e81054ff9d88ac877f28bfdf38fa4ef31", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "bf9f351b01dd6bceea1d1f157f05438f6765ce606e6eb8d29296003d29bf6eab"},
|
||||
"bandit": {:hex, :bandit, "1.11.1", "1eb33123cc3c17ae0c3447874eb83399ee530f960c39711ed240342fbd4865fa", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "d4401016df9abbc6dcd325c0b78b2b193e7c7c96bb68f31e576112be025d84a5"},
|
||||
"bandit": {:hex, :bandit, "1.11.0", "dbdd9c9963f146ee9da9860d1ee5b0ffd65cea51fe2aab3f3273df84329d133a", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "c949d93a325a28da2333dde5a9ab61986ad2c2b7226347db6a28303b9139865e"},
|
||||
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
|
||||
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
|
||||
"bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"},
|
||||
"castore": {:hex, :castore, "1.0.19", "6903cabdfd9d1af46454126e7c8385186659dd33ecfb74a885cae52221ad6109", [:mix], [], "hexpm", "3669e6cab13f54c2df26b3e6833745d647f35b6e30d8ddd5975df0d5c842ca98"},
|
||||
"castore": {:hex, :castore, "1.0.18", "5e43ef0ec7d31195dfa5a65a86e6131db999d074179d2ba5a8de11fe14570f55", [:mix], [], "hexpm", "f393e4fe6317829b158fb74d86eb681f737d2fe326aa61ccf6293c4104957e34"},
|
||||
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
|
||||
"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.12.1", "02ae4988e025fb32c37e4e7f2e491586b952918c0dd99d856da13271cd680e16", [: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]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "a48b5677c1f57619d9d7564fb2bd7928f93750a2e8c0b1b145852a30ecf2aa20"},
|
||||
"circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"},
|
||||
"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.14.2", "4008be1df6ade45e4f2a4e9e2d22b36d0b5aba4e20b0a0d7049e28d124e34847", [: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", "569081da046e7b41b5df36aa359be71a0c8874e5b9cff6f747073fc57baf1ab9"},
|
||||
"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.0", "54592074ebbbb92ee4746c8a8846e5605052f29309d3a873468d76cdf932076f", [:make, :rebar3], [], "hexpm", "7f478d80d66b747344f0ea7708c187645cfcc08b11aa424632f78e25bf05db51"},
|
||||
"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"},
|
||||
"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"},
|
||||
"dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"},
|
||||
"crux": {:hex, :crux, "0.1.2", "4441c9e3a34f1e340954ce96b9ad5a2de13ceb4f97b3f910211227bb92e2ca90", [: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", "563ea3748ebfba9cc078e6d198a1d6a06015a8fae503f0b721363139f0ddb350"},
|
||||
"db_connection": {:hex, :db_connection, "2.10.0", "8ff756471e41765bd5563b633f73e9a94bbc138816e8644bb17d0d91bf260a95", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02cdd01b45efb1b550e68edbbea41be32de9b24bb07e1ea0e9cbc522ac377e54"},
|
||||
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
|
||||
"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_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"},
|
||||
"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"},
|
||||
"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_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"},
|
||||
"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.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [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", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"},
|
||||
"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"},
|
||||
"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 +42,7 @@
|
|||
"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"},
|
||||
"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.7.9", "8c573440b8127fd80be8220fb197e7422317a81072054fcc0b336029f035a416", [:mix], [{: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", "123513d09f3af149db851aad8492b5b49f861d2c466a72031b2a0cbd9f45526f"},
|
||||
"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"},
|
||||
"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"},
|
||||
|
|
@ -53,58 +50,57 @@
|
|||
"jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"},
|
||||
"lazy_html": {:hex, :lazy_html, "0.1.11", "136c8e9cd616b4f4e9c1562daa683880891120b759606dc4c3b6b18058ba5d79", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "3b1be592929c31eca1a21673d25696e5c14cddfe922d9d1a3e3b48be4163883b"},
|
||||
"libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"},
|
||||
"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, "0.8.0", "02545b5accdf42f48aa9bddabdd7574ddf532d5aa0cb0270d7e35031bc184286", [: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, "~> 0.20.8 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "4ecdec3c4267e665afd2266e3cb86239dd457f8c8fc4e63de6e150a2a7665920"},
|
||||
"luhn": {:hex, :luhn, "0.3.3", "5aa0c6a32c2db4b9db9f9b883ba8301c1ae169d57199b9e6cb1ba2707bc51d96", [:mix], [], "hexpm", "3e823a913a25aab51352c727f135278d22954874d5f0835be81ed4fec3daf78d"},
|
||||
"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.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [: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", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
|
||||
"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"},
|
||||
"nimble_csv": {:hex, :nimble_csv, "1.3.0", "b7f998dc62b222bce9596e46f028c7a5af04cb5dde6df2ea197c583227c54971", [:mix], [], "hexpm", "41ccdc18f7c8f8bb06e84164fc51635321e80d5a3b450761c4997d620925d619"},
|
||||
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
|
||||
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
|
||||
"owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"},
|
||||
"phoenix": {:hex, :phoenix, "1.8.7", "d8d755b4ff4b449f610223dd706b4ae64155cb720d3dc09c706c079ecea189e4", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, 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.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "47352f72d6ab31009ef77516b1b3a14745be97b54061fd458031b9d8294869d5"},
|
||||
"phoenix": {:hex, :phoenix, "1.8.6", "7106a0da114619c4b12b056bbaef39fdbc75d3d0cf9cf24af683364064c12dc3", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, 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.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "d0d0b7931916c8196b6903a1efa118b5da28487e7a75ad32a54dfd77de59d421"},
|
||||
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
|
||||
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
|
||||
"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_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_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_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"},
|
||||
"picosat_elixir": {:hex, :picosat_elixir, "0.2.3", "bf326d0f179fbb3b706bb2c15fbc367dacfa2517157d090fdfc32edae004c597", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f76c9db2dec9d2561ffaa9be35f65403d53e984e8cd99c832383b7ab78c16c66"},
|
||||
"plug": {:hex, :plug, "1.19.2", "e4950525b22c6789dfb38a3f95d47171ba159da3fc5a33be9643b43d5e8adb98", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b6fce20a56af5e60fa5dfecf3f907bb98ec981be43c79a3809a499bc3d133de0"},
|
||||
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
|
||||
"plug_cowboy": {:hex, :plug_cowboy, "2.8.1", "5aa391a5e8d1ac3192e36a3bcaff12b5fd6ef6c7e29b53a38e63a860783e77d0", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "4c200288673d5bc86a0ab7dc6a2a069176a74e5d573ef62740a1c517458a5f26"},
|
||||
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
|
||||
"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.1", "b3665ad17e15441557da8f45eeebfcd56e4a2b0b98538b855679a13d05e5cc5d", [: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", "df59f828b167b49a5853f645b65f57eb1bc5f3b230497ceaca7af5d8ac05afef"},
|
||||
"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"},
|
||||
"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"},
|
||||
"reactor": {:hex, :reactor, "1.0.1", "ca3b5cf3c04ec8441e67ea2625d0294939822060b1bfd00ffdaaf75b7682d991", [: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]}, {:libgraph, "~> 0.16", [hex: :libgraph, 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", "3497db2b204c9a3cabdaf1b26d2405df1dfbb138ce0ce50e616e9db19fec0043"},
|
||||
"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"},
|
||||
"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"},
|
||||
"sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"},
|
||||
"sourceror": {:hex, :sourceror, "1.12.0", "da354c5f35aad3cc1132f5d5b0d8437d865e2661c263260480bab51b5eedb437", [:mix], [], "hexpm", "755703683bd014ebcd5de9acc24b68fb874a660a568d1d63f8f98cd8a6ef9cd0"},
|
||||
"spark": {:hex, :spark, "2.7.0", "e685b33c038f12851993880bb7e3b326117612eb746fe15828678c152f8321c6", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "e2f675fbda32375b01d9ee7c652671531027fd043bf4a91bafdb2ab716aa1122"},
|
||||
"spitfire": {:hex, :spitfire, "0.3.12", "0f7780e4c6ea3753b65ea0c4924f3dfd5c21a51aaa734ffb9dd0b68d2544f27e", [:mix], [], "hexpm", "a389931287b85330c0e954ab06447e198516ab368a232a0200ed77ca13ca9acf"},
|
||||
"spitfire": {:hex, :spitfire, "0.3.11", "79dfcb033762470de472c1c26ea2b4e3aca74700c685dbffd9a13466272c323d", [:mix], [], "hexpm", "eb6e2dadf63214e8bfe65ca9788cef2b03b01027365d78d3c0e3d9ebd3d5b7b4"},
|
||||
"splode": {:hex, :splode, "0.3.1", "9843c54f84f71b7833fec3f0be06c3cfb5be6b35960ee195ea4fad84b1c25030", [:mix], [], "hexpm", "8f2309b6ec2ecbb01435656429ed1d9ed04ba28797a3280c3b0d1217018ecfbd"},
|
||||
"stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"},
|
||||
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
|
||||
"swoosh": {:hex, :swoosh, "1.25.1", "569fcff34817da8a03f28775146b3c8b71b4c9b14f8f78d37ff3ef422862a18b", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "58b3e8db6406fe417a89b5042358d2e8f15d32a3317d4f8581d7a3ae501e410b"},
|
||||
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
|
||||
"telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"},
|
||||
"telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"},
|
||||
"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"},
|
||||
"text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
|
||||
"thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{: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"},
|
||||
"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"},
|
||||
"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"},
|
||||
"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"},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -152,13 +152,3 @@ msgstr ""
|
|||
#, elixir-autogen, elixir-format
|
||||
msgid "Register"
|
||||
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
|
||||
msgid "Register"
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -84,7 +84,6 @@ msgstr "Über Mitgliedsbeitragsarten"
|
|||
msgid "Accounting-Software (Vereinfacht) Integration"
|
||||
msgstr "Buchhaltungs-Software (Vereinfacht) Integration"
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -367,6 +366,11 @@ msgstr "Mitglied werden"
|
|||
msgid "By submitting your application you will receive an email with a confirmation link. Once you have confirmed your email address, your application will be reviewed."
|
||||
msgstr "Mit Absenden deines Antrags erhältst du eine Mail mit einem Bestätigungslink. Sobald du deine Mail-Adresse bestätigt hast, wird dein Antrag geprüft."
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "CSV"
|
||||
msgstr "CSV"
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "CSV File"
|
||||
|
|
@ -386,7 +390,6 @@ msgstr "Kann jederzeit geändert werden. Änderungen des Betrags betreffen nur z
|
|||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#: lib/mv_web/live/member_field_live/form_component.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
|
|
@ -666,11 +669,16 @@ msgid_plural "Copied %{count} email addresses to clipboard"
|
|||
msgstr[0] "%{count} E-Mail-Adresse in die Zwischenablage kopiert"
|
||||
msgstr[1] "%{count} E-Mail-Adressen in die Zwischenablage kopiert"
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Copy email addresses"
|
||||
msgstr "E-Mail-Adressen kopieren"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Copy email addresses of selected members"
|
||||
msgstr "E-Mail-Adressen der ausgewählten Mitglieder kopieren"
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Could not load data fields. Please check your permissions."
|
||||
|
|
@ -1189,17 +1197,22 @@ msgstr "Austritte"
|
|||
msgid "Expense"
|
||||
msgstr "Ausgabe"
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Export"
|
||||
msgstr "Export"
|
||||
|
||||
#: lib/mv_web/controllers/member_pdf_export_controller.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Export contains %{count} rows, maximum is %{max}"
|
||||
msgstr "Export enthält %{count} Zeilen, Maximum ist %{max}."
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Export members to CSV"
|
||||
msgstr "Mitglieder als CSV exportieren"
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Export members to PDF"
|
||||
msgstr "Mitglieder als PDF exportieren"
|
||||
|
|
@ -1316,7 +1329,6 @@ msgstr "Feb."
|
|||
msgid "Fee Type"
|
||||
msgstr "Beitragsart"
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#: lib/mv_web/live/statistics_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Fee type"
|
||||
|
|
@ -1372,6 +1384,16 @@ msgstr "Festgelegt nach der Erstellung. Mitglieder können nur zwischen Beitrags
|
|||
msgid "From %{first} to %{last} (relevant years with membership data)"
|
||||
msgstr "Von %{first} bis %{last} (Jahre mit Mitgliederdaten)"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From MAIL_FROM_EMAIL"
|
||||
msgstr "Aus MAIL_FROM_EMAIL"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From MAIL_FROM_NAME"
|
||||
msgstr "Aus MAIL_FROM_NAME"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From OIDC_ADMIN_GROUP_NAME"
|
||||
|
|
@ -1407,11 +1429,31 @@ msgstr "Aus OIDC_ONLY"
|
|||
msgid "From OIDC_REDIRECT_URI"
|
||||
msgstr "Aus OIDC_REDIRECT_URI"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From SMTP_HOST"
|
||||
msgstr "Von SMTP_HOST"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From SMTP_PASSWORD"
|
||||
msgstr "Von SMTP_PASSWORD"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From SMTP_PORT"
|
||||
msgstr "Von SMTP_PORT"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From SMTP_SSL"
|
||||
msgstr "Von SMTP_SSL"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From SMTP_USERNAME"
|
||||
msgstr "Von SMTP_USERNAME"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From VEREINFACHT_API_KEY"
|
||||
|
|
@ -1476,7 +1518,6 @@ msgstr "Gruppe erfolgreich gespeichert."
|
|||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#: lib/mv_web/live/group_live/index.ex
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/translations/member_fields.ex
|
||||
|
|
@ -2192,6 +2233,16 @@ msgstr "Kein Mitglied verknüpft"
|
|||
msgid "No members in this group"
|
||||
msgstr "Keine Mitglieder in dieser Gruppe"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No members selected"
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned."
|
||||
|
|
@ -2345,7 +2396,12 @@ msgstr "Nur Administrator*innen oder die verknüpfte*n Benutzer*in(nen) können
|
|||
msgid "Only possible if no members are assigned to this type."
|
||||
msgstr "Nur möglich, wenn diesem Typ keine Mitglieder zugewiesen sind."
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Open email program with BCC recipients"
|
||||
msgstr "E-Mail-Programm mit BCC-Empfänger*innen öffnen"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Open in email program"
|
||||
msgstr "Im E-Mail-Programm öffnen"
|
||||
|
|
@ -2371,6 +2427,11 @@ msgstr "Optional"
|
|||
msgid "Options"
|
||||
msgstr "Optionen"
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "PDF"
|
||||
msgstr "PDF"
|
||||
|
||||
#: lib/mv/membership/members_pdf.ex
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
|
|
@ -2617,7 +2678,6 @@ msgstr "Geprüft von"
|
|||
msgid "Reviewed at"
|
||||
msgstr "Geprüft am"
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#: lib/mv_web/live/role_live/form.ex
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
|
|
@ -3278,6 +3338,11 @@ msgstr "Aufhebung der Verknüpfung geplant"
|
|||
msgid "Unpaid"
|
||||
msgstr "Unbezahlt"
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored. Groups and membership fees are not supported for import."
|
||||
msgstr "Verwende die Namen der Datenfelder als Spaltennamen in der CSV-Datei. Datenfelder müssen in Mila bereits angelegt sein, da unbekannte Spaltennamen ignoriert werden. Gruppen und Beitragsstatus können nicht importiert werden."
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "User"
|
||||
|
|
@ -3545,7 +3610,7 @@ msgstr "Dein Passwort wurde erfolgreich zurückgesetzt"
|
|||
msgid "admin - Unrestricted access"
|
||||
msgstr "admin – Uneingeschränkter Zugriff"
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "all"
|
||||
msgstr "alle"
|
||||
|
|
@ -3863,252 +3928,12 @@ msgstr "Die SMTP-Umgebungs-Konfiguration ist unvollständig. Fehlend: %{keys}"
|
|||
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."
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "%{field} from"
|
||||
msgstr "%{field} von"
|
||||
msgid "Association name is set via environment variable ASSOCIATION_NAME. This field is read-only."
|
||||
msgstr "Der Vereinsname wird über die Umgebungsvariable ASSOCIATION_NAME gesetzt. Dieses Feld ist schreibgeschützt."
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#: lib/mv_web/live/global_settings_live.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/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Join form:"
|
||||
msgstr "Beitrittsformular:"
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Description for join form"
|
||||
msgstr "Beschreibung für das Beitrittsformular"
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You can add links: full addresses (https://…) or as [link text](https://…)."
|
||||
msgstr "Du kannst Links einfügen: ganze Adressen (https://…) oder als [Linktext](https://…)."
|
||||
|
||||
#: lib/mv/membership/import/member_csv.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Fee type '%{name}' not found; using the default fee type."
|
||||
msgstr "Beitragsart '%{name}' nicht gefunden; Standard-Beitragsart wird verwendet."
|
||||
|
||||
#: lib/mv/membership/import/member_csv.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Group assignment failed: %{reason}"
|
||||
msgstr "Gruppenzuordnung fehlgeschlagen: %{reason}"
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Confirm and Import"
|
||||
msgstr "Bestätigen und importieren"
|
||||
|
||||
#: lib/mv_web/live/import_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No prepared import to confirm. Please upload again."
|
||||
msgstr "Kein vorbereiteter Import zum Bestätigen. Bitte erneut hochladen."
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Preview import"
|
||||
msgstr "Importvorschau"
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Column"
|
||||
msgstr "Spalte"
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Custom field"
|
||||
msgstr "Benutzerdefiniertes Feld"
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Ignored (system-computed field)"
|
||||
msgstr "Ignoriert (vom System berechnetes Feld)"
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Member field"
|
||||
msgstr "Mitgliedsfeld"
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Rows with an empty fee type will get the default fee type."
|
||||
msgstr "Zeilen ohne Beitragsart erhalten die Standard-Beitragsart."
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "These groups will be created automatically: %{names}"
|
||||
msgstr "Diese Gruppen werden automatisch erstellt: %{names}"
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Unknown (ignored)"
|
||||
msgstr "Unbekannt (ignoriert)"
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Unknown fee types (members get the default): %{names}"
|
||||
msgstr "Unbekannte Beitragsarten (Mitglieder erhalten den Standard): %{names}"
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitragsstatus) are always ignored and cannot be imported."
|
||||
msgstr "Beitragsstatus-Spalten (Membership Fee Status, Bezahlstatus, Mitgliedsbeitragsstatus) werden immer ignoriert und können nicht importiert werden."
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Groups column (recognized headers): Groups, Gruppen, Gruppe. Comma-separated group names are supported and missing groups are created automatically."
|
||||
msgstr "Gruppen-Spalte (erkannte Spaltennamen): Groups, Gruppen, Gruppe. Mehrere durch Komma getrennte Gruppennamen werden unterstützt; fehlende Gruppen werden automatisch erstellt."
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored."
|
||||
msgstr "Verwende die Namen der Datenfelder als Spaltennamen in der CSV-Datei. Datenfelder müssen in Mila bereits angelegt sein, da unbekannte Spaltennamen ignoriert werden."
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Fee type column (recognized headers): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unknown fee types fall back to the default."
|
||||
msgstr "Beitragsart-Spalte (erkannte Spaltennamen): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unbekannte Beitragsarten erhalten die Standard-Beitragsart."
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Create custom field"
|
||||
msgstr "Datenfeld erstellen"
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Create fee type"
|
||||
msgstr "Beitragsart erstellen"
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Row 1"
|
||||
msgstr "Zeile 1"
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Row 2"
|
||||
msgstr "Zeile 2"
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Row 3"
|
||||
msgstr "Zeile 3"
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Export to CSV"
|
||||
msgstr "Mitglieder als CSV exportieren"
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Export to PDF"
|
||||
msgstr "Mitglieder als PDF exportieren"
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Too many recipients for this function. Copy the addresses or export the list."
|
||||
msgstr "Zu viele Empfänger für diese Funktion. Kopiere die Adressen oder exportiere die Liste."
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "filtered"
|
||||
msgstr "gefiltert"
|
||||
|
||||
#~ #: lib/mv_web/components/export_dropdown.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "CSV"
|
||||
#~ msgstr "CSV"
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Copy email addresses of selected members"
|
||||
#~ msgstr "E-Mail-Adressen der ausgewählten Mitglieder kopieren"
|
||||
|
||||
#~ #: lib/mv_web/components/export_dropdown.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "Export"
|
||||
#~ msgstr "Export"
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/index.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "No members selected"
|
||||
#~ msgstr "Keine Mitglieder ausgewählt"
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Open email program with BCC recipients"
|
||||
#~ msgstr "E-Mail-Programm mit BCC-Empfänger*innen öffnen"
|
||||
|
||||
#~ #: lib/mv_web/components/export_dropdown.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "PDF"
|
||||
#~ msgstr "PDF"
|
||||
msgid "From ASSOCIATION_NAME"
|
||||
msgstr "Aus ASSOCIATION_NAME"
|
||||
|
|
|
|||
|
|
@ -85,7 +85,6 @@ msgstr ""
|
|||
msgid "Accounting-Software (Vereinfacht) Integration"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -368,6 +367,11 @@ msgstr ""
|
|||
msgid "By submitting your application you will receive an email with a confirmation link. Once you have confirmed your email address, your application will be reviewed."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "CSV"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "CSV File"
|
||||
|
|
@ -387,7 +391,6 @@ msgstr ""
|
|||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#: lib/mv_web/live/member_field_live/form_component.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
|
|
@ -667,11 +670,16 @@ msgid_plural "Copied %{count} email addresses to clipboard"
|
|||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Copy email addresses"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Copy email addresses of selected members"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Could not load data fields. Please check your permissions."
|
||||
|
|
@ -1190,17 +1198,22 @@ msgstr ""
|
|||
msgid "Expense"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Export"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/controllers/member_pdf_export_controller.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Export contains %{count} rows, maximum is %{max}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Export members to CSV"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Export members to PDF"
|
||||
msgstr ""
|
||||
|
|
@ -1317,7 +1330,6 @@ msgstr ""
|
|||
msgid "Fee Type"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#: lib/mv_web/live/statistics_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Fee type"
|
||||
|
|
@ -1373,6 +1385,16 @@ msgstr ""
|
|||
msgid "From %{first} to %{last} (relevant years with membership data)"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From MAIL_FROM_EMAIL"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From MAIL_FROM_NAME"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From OIDC_ADMIN_GROUP_NAME"
|
||||
|
|
@ -1408,11 +1430,31 @@ msgstr ""
|
|||
msgid "From OIDC_REDIRECT_URI"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From SMTP_HOST"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From SMTP_PASSWORD"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From SMTP_PORT"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From SMTP_SSL"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From SMTP_USERNAME"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From VEREINFACHT_API_KEY"
|
||||
|
|
@ -1477,7 +1519,6 @@ msgstr ""
|
|||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#: lib/mv_web/live/group_live/index.ex
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/translations/member_fields.ex
|
||||
|
|
@ -2193,6 +2234,16 @@ msgstr ""
|
|||
msgid "No members in this group"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No members selected"
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned."
|
||||
|
|
@ -2346,7 +2397,12 @@ msgstr ""
|
|||
msgid "Only possible if no members are assigned to this type."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Open email program with BCC recipients"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Open in email program"
|
||||
msgstr ""
|
||||
|
|
@ -2372,6 +2428,11 @@ msgstr ""
|
|||
msgid "Options"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "PDF"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv/membership/members_pdf.ex
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
|
|
@ -2618,7 +2679,6 @@ msgstr ""
|
|||
msgid "Reviewed at"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#: lib/mv_web/live/role_live/form.ex
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
|
|
@ -3279,6 +3339,11 @@ msgstr ""
|
|||
msgid "Unpaid"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored. Groups and membership fees are not supported for import."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "User"
|
||||
|
|
@ -3545,7 +3610,7 @@ msgstr ""
|
|||
msgid "admin - Unrestricted access"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "all"
|
||||
msgstr ""
|
||||
|
|
@ -3863,222 +3928,12 @@ msgstr ""
|
|||
msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "%{field} from"
|
||||
msgid "Association name is set via environment variable ASSOCIATION_NAME. This field is read-only."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#: lib/mv_web/live/global_settings_live.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 ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Join form:"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Description for join form"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You can add links: full addresses (https://…) or as [link text](https://…)."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv/membership/import/member_csv.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Fee type '%{name}' not found; using the default fee type."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv/membership/import/member_csv.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Group assignment failed: %{reason}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Confirm and Import"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No prepared import to confirm. Please upload again."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Preview import"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Column"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Custom field"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Ignored (system-computed field)"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Member field"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Rows with an empty fee type will get the default fee type."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "These groups will be created automatically: %{names}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Unknown (ignored)"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Unknown fee types (members get the default): %{names}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitragsstatus) are always ignored and cannot be imported."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Groups column (recognized headers): Groups, Gruppen, Gruppe. Comma-separated group names are supported and missing groups are created automatically."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Fee type column (recognized headers): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unknown fee types fall back to the default."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Create custom field"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Create fee type"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Row 1"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Row 2"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Row 3"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Export to CSV"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Export to PDF"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Too many recipients for this function. Copy the addresses or export the list."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "filtered"
|
||||
msgid "From ASSOCIATION_NAME"
|
||||
msgstr ""
|
||||
|
|
|
|||
|
|
@ -145,13 +145,3 @@ msgstr ""
|
|||
#, elixir-autogen, elixir-format
|
||||
msgid "Register"
|
||||
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 ""
|
||||
|
|
|
|||
|
|
@ -85,7 +85,6 @@ msgstr ""
|
|||
msgid "Accounting-Software (Vereinfacht) Integration"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -368,6 +367,11 @@ msgstr ""
|
|||
msgid "By submitting your application you will receive an email with a confirmation link. Once you have confirmed your email address, your application will be reviewed."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "CSV"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "CSV File"
|
||||
|
|
@ -387,7 +391,6 @@ msgstr ""
|
|||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#: lib/mv_web/live/member_field_live/form_component.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
|
|
@ -667,11 +670,16 @@ msgid_plural "Copied %{count} email addresses to clipboard"
|
|||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Copy email addresses"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Copy email addresses of selected members"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Could not load data fields. Please check your permissions."
|
||||
|
|
@ -1190,17 +1198,22 @@ msgstr ""
|
|||
msgid "Expense"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Export"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/controllers/member_pdf_export_controller.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Export contains %{count} rows, maximum is %{max}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Export members to CSV"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Export members to PDF"
|
||||
msgstr ""
|
||||
|
|
@ -1317,7 +1330,6 @@ msgstr ""
|
|||
msgid "Fee Type"
|
||||
msgstr "Fee Type"
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#: lib/mv_web/live/statistics_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Fee type"
|
||||
|
|
@ -1373,6 +1385,16 @@ msgstr ""
|
|||
msgid "From %{first} to %{last} (relevant years with membership data)"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From MAIL_FROM_EMAIL"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From MAIL_FROM_NAME"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From OIDC_ADMIN_GROUP_NAME"
|
||||
|
|
@ -1408,11 +1430,31 @@ msgstr ""
|
|||
msgid "From OIDC_REDIRECT_URI"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From SMTP_HOST"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From SMTP_PASSWORD"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From SMTP_PORT"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From SMTP_SSL"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From SMTP_USERNAME"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From VEREINFACHT_API_KEY"
|
||||
|
|
@ -1477,7 +1519,6 @@ msgstr ""
|
|||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#: lib/mv_web/live/group_live/index.ex
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/translations/member_fields.ex
|
||||
|
|
@ -2193,6 +2234,16 @@ msgstr ""
|
|||
msgid "No members in this group"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "No members selected"
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned."
|
||||
|
|
@ -2346,7 +2397,12 @@ msgstr ""
|
|||
msgid "Only possible if no members are assigned to this type."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Open email program with BCC recipients"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Open in email program"
|
||||
msgstr ""
|
||||
|
|
@ -2372,6 +2428,11 @@ msgstr ""
|
|||
msgid "Options"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "PDF"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv/membership/members_pdf.ex
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
|
|
@ -2618,7 +2679,6 @@ msgstr "Review by"
|
|||
msgid "Reviewed at"
|
||||
msgstr "Review date"
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#: lib/mv_web/live/role_live/form.ex
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
|
|
@ -3279,6 +3339,11 @@ msgstr ""
|
|||
msgid "Unpaid"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored. Groups and membership fees are not supported for import."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "User"
|
||||
|
|
@ -3545,7 +3610,7 @@ msgstr ""
|
|||
msgid "admin - Unrestricted access"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "all"
|
||||
msgstr ""
|
||||
|
|
@ -3861,254 +3926,14 @@ msgstr ""
|
|||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
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 ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "%{field} from"
|
||||
msgstr ""
|
||||
msgid "Association name is set via environment variable ASSOCIATION_NAME. This field is read-only."
|
||||
msgstr "Association name is set via environment variable ASSOCIATION_NAME. This field is read-only."
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#: lib/mv_web/live/global_settings_live.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/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Join form:"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Description for join form"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You can add links: full addresses (https://…) or as [link text](https://…)."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv/membership/import/member_csv.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Fee type '%{name}' not found; using the default fee type."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv/membership/import/member_csv.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Group assignment failed: %{reason}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Confirm and Import"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No prepared import to confirm. Please upload again."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Preview import"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Column"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Custom field"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Ignored (system-computed field)"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Member field"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Rows with an empty fee type will get the default fee type."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "These groups will be created automatically: %{names}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Unknown (ignored)"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Unknown fee types (members get the default): %{names}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitragsstatus) are always ignored and cannot be imported."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Groups column (recognized headers): Groups, Gruppen, Gruppe. Comma-separated group names are supported and missing groups are created automatically."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Fee type column (recognized headers): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unknown fee types fall back to the default."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Create custom field"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Create fee type"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Row 1"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Row 2"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Row 3"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Export to CSV"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Export to PDF"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Too many recipients for this function. Copy the addresses or export the list."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "filtered"
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/components/export_dropdown.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "CSV"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Copy email addresses of selected members"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/components/export_dropdown.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "Export"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/index.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "No members selected"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Open email program with BCC recipients"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/components/export_dropdown.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "PDF"
|
||||
#~ msgstr ""
|
||||
msgid "From ASSOCIATION_NAME"
|
||||
msgstr "From ASSOCIATION_NAME"
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
defmodule Mv.Repo.Migrations.AddJoinDescriptionToCustomFields do
|
||||
@moduledoc """
|
||||
Updates resources based on their most recent snapshots.
|
||||
|
||||
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
alter table(:custom_fields) do
|
||||
add :join_description, :text
|
||||
end
|
||||
end
|
||||
|
||||
def down do
|
||||
alter table(:custom_fields) do
|
||||
remove :join_description
|
||||
end
|
||||
end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue