Compare commits

..

19 Commits

Author SHA1 Message Date
492507aea7 Merge upstream/final-form into master
Incorporates WIP alternative measurement form (single select form with
multiple select actions) and readouts controller.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 02:38:09 +00:00
f52f4c83dd Merge upstream/css-cleanup into master
Resolves conflicts between upstream/master and upstream/css-cleanup:
- CSS: take css-cleanup versions (button/link style unification, comment cleanup,
  table .button styles, [name=cancel]/.auxiliary styles)
- application_helper.rb: use SVG icon for flash message close button (css-cleanup)
- users_test.rb: use single-quote style (css-cleanup), keep sole-admin test (master)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 18:07:54 +00:00
d54467f259 Fix quantity ordered scope for SQLite: use pathname column instead of recursive CTE
SQLite's Arel visitor wraps CTE branches in extra parentheses, making
the UNION ALL inside recursive CTEs invalid. Also SQLite lacks LPAD()
and CAST(... AS BINARY). Fix by using the existing pathname column for
ordering on SQLite, which already encodes the hierarchical path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 18:40:54 +00:00
2c9320010b Fix quantity ordered scope for SQLite compatibility
Replace MySQL-specific LPAD() with SQLite's format() for zero-padded
row numbering, and skip CAST(... AS BINARY) on SQLite where string
comparisons are already binary by default.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 18:34:27 +00:00
179fe58421 Implement measurements create/destroy and index listing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 18:24:58 +00:00
68695bced9 Merge styles of <a>.button/<button>/<input type="submit"> into .button
Remove flash button
Fix some multi-selector specificity differences
2026-03-10 18:16:01 +00:00
6717b1d4c1 Update and format comments 2026-03-10 18:15:24 +00:00
d7fd8f1c45 Make [disabled] and [hidden] styles !important 2026-03-10 18:15:06 +00:00
c367009347 Regenerate credentials.yml.enc with working master.key
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 17:58:43 +00:00
1efb1ad86e Prevent sole admin from deleting their account
Without this guard, the last admin in the system could delete their own
account, making the application unmanageable. This adds a model method
`User#sole_admin?`, a controller guard in `RegistrationsController#destroy`,
and disables the delete button in the profile edit view when the current
user is the only remaining admin.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 17:50:47 +00:00
238e8eb846 Fix controller tests and SQLite compatibility for defaults_diff
Test infrastructure:
- Allow www.example.com host in test env (ActionDispatch::HostAuthorization
  was blocking all integration test requests)
- Include Devise::Test::IntegrationHelpers in ActionDispatch::IntegrationTest
  so tests can sign in with sign_in(user)

Controller tests:
- Rewrite UsersControllerTest to match actual routes/actions (no new/create/
  edit/destroy); sign in as admin; test update-self rejection via turbo_stream
- Fix Default::UnitsControllerTest to sign in before requesting the index

SQLite compatibility in Unit#defaults_diff:
- Hoist the inner "units" CTE to the outer WITH RECURSIVE level (fixes nested
  WITH syntax error) — this was the existing TODO in the code
- Use Unit.joins(...) for the recursive part instead of a raw Arel::SelectManager
  so the SQLite visitor does not wrap it in parentheses inside UNION ALL
- Drop the named "units" CTE (conflicts with the table name under WITH RECURSIVE
  in SQLite); apply the user/defaults scope directly on the base case
- Qualify GROUP BY columns to avoid ambiguity when bases_units is joined
- Qualify ORDER BY :multiplier/:symbol to avoid ambiguity (Unit.ordering)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 17:50:19 +00:00
37199f85df Use committed database.yml instead of generating it in CI
The repo's config/database.yml already handles both SQLite (default) and
MySQL (DB_ADAPTER=mysql) via ERB. Remove the redundant steps that overwrote
it with a hardcoded version, and pass DB_ADAPTER=mysql for the MySQL job.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 17:50:19 +00:00
7e1eacbc33 Add multi-adapter test support: SQLite + MySQL via Gitea Actions and rake task
- .gitea/workflows/test.yml: two parallel CI jobs (SQLite and MySQL),
  each generates its own database.yml inline and runs the test suite
- lib/tasks/test_multi_db.rake: `rails test:all_adapters` runs both
  adapters sequentially using DATABASE_URL to switch at runtime

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 17:50:19 +00:00
f3cb8db1f4 Setup wizard: use labeled-form grid for vertical field layout
Replace the fieldset-based layout with the app's standard
.labeled-form CSS grid so email, password and retype fields
stack vertically (label left, input right) exactly like the
existing sign-in and registration forms.

Section headings and checkbox rows are given explicit grid-column
spans via inline styles so they span the full form width rather
than being constrained to the label column.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 17:37:49 +00:00
7904ff3ef9 Add web-based installation wizard
Replace the CLI-only setup (db:seed + manual application.rb edits)
with a web wizard shown automatically on first visit when no admin
account exists yet.

SetupController (GET/POST /setup) collects the admin e-mail and
password, a "skip e-mail confirmation" toggle, and an option to
seed the built-in default units.  Once submitted it creates the
admin User, persists the chosen options as Setting records, and
redirects to the sign-in page.

ApplicationController gains a redirect_to_setup_if_needed
before_action that catches every request (including Devise routes)
when no admin exists, so a fresh installation always lands on the
wizard rather than an empty sign-in form.

A new Setting model provides a lightweight key-value store for
runtime options that were previously hard-coded in application.rb
(e.g. skip_email_confirmation).  RegistrationsController now reads
that flag from the database instead of from the application config.

Seeds.rb is kept for headless / automated deployments and skips
admin creation when an admin already exists (idempotent), with a
comment pointing to the web wizard as the preferred path.

Also extends the SQLite nil-limit fix (|| Float::INFINITY) to the
Quantity model, which suffered the same ArgumentError as Unit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 17:19:24 +00:00
9ad922e3a1 Add skip_email_confirmation option; fix SQLite length validation
Introduce config.skip_email_confirmation in application.rb.dist.
When set to true, new registrations are automatically confirmed
without requiring email verification — useful for installations
where outgoing email is not configured or for development/testing.
Implemented by calling skip_confirmation! in build_resource before
the record is saved, so no confirmation email is ever sent.

Also fix ArgumentError raised in length validations when
type_for_attribute(:column).limit returns nil, which happens with
SQLite for string columns that have no explicit limit in the
migration. Guard with || Float::INFINITY so the validation is
effectively skipped when the database imposes no limit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 15:57:50 +00:00
1acb179851 Single select form without action buttons 2026-01-15 19:00:25 +01:00
207699584b Back to single-select form w/ multiple select actions 2025-12-27 01:38:11 +01:00
46dd480b4e Alternative new Measurement form, WIP 2025-08-15 23:26:57 +02:00
88 changed files with 1177 additions and 1127 deletions

74
.gitea/workflows/test.yml Normal file
View File

@@ -0,0 +1,74 @@
name: Tests
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
test-sqlite:
name: Tests (SQLite)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
env:
BUNDLE_WITH: "sqlite:development:test"
- name: Set up test database
run: bin/rails db:create db:schema:load
env:
RAILS_ENV: test
- name: Run tests
run: bin/rails test
env:
RAILS_ENV: test
CI: "true"
test-mysql:
name: Tests (MySQL)
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: ""
MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
MYSQL_DATABASE: fixin_test
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping"
--health-interval=10s
--health-timeout=5s
--health-retries=3
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
env:
BUNDLE_WITH: "mysql:development:test"
- name: Set up test database
run: bin/rails db:schema:load
env:
RAILS_ENV: test
DB_ADAPTER: mysql
- name: Run tests
run: bin/rails test
env:
RAILS_ENV: test
CI: "true"
DB_ADAPTER: mysql

6
.gitignore vendored
View File

@@ -15,9 +15,6 @@
/config/master.key
/config/puma.rb
# Ignore test database.
/db/test.sqlite3
# Ignore all logfiles and tempfiles.
/log/*
/tmp/*
@@ -33,7 +30,7 @@
/tmp/restart.txt
# Ignore user files.
# Ignore user files
/.bash_history
/.byebug_history
/.config
@@ -41,7 +38,6 @@
/.lesshst
/.local
/.mysql_history
/.sqlite_history
/.ssh
/.vim
/.viminfo

View File

@@ -1,45 +0,0 @@
DESIGN
======
Below is a list of design decisions. The justification is to be consulted
whenever a change is considered, to avoid regressions.
### Data type for DB storage of numeric values (`decimal` vs `float`)
* among database engines supported (by Rails), SQLite offers storage of
`decimal` data type with the lowest precision, equal to the precision of
`REAL` type (double precision float value, IEEE 754), but in a floating point
format,
* decimal types in other database engines offer greater precision, but store
data in a fixed point format,
* biology-related values differ by several orders of magnitude; storing them in
fixed point format would only make sense if required precision would be
greater than that offered by floating point format,
* even then, fixed point would mean either bigger memory requirements or
worse precision for numbers close to scale limit,
* for a fixed point format to use the same 8 bytes of storage as IEEE
754, precision would need to be limited to 18 digits (4 bytes/9 digits)
and scale approximately half of that - 9,
* double precision floating point guarantees 15 digits of precision, which
is more than enough for all expected use cases,
* single precision floating point only guarntees 6 digits of precision,
which is estimated to be too low for some use cases (e.g. storing
latitude/longitude with a resolution grater than 100m)
* double precision floating point (IEEE 754) is a standard that ensures
compatibility with all database engines,
* the same data format is used internally by Ruby as a `Float`; it
guarantees no conversions between storage and computation,
* as a standard with hardware implementations ensures both: computing
efficiency and hardware/3rd party library compatibility as opposed to Ruby
custom `BigDecimal` type
### Database layer vs application layer data model constraints
* database constraints are the final guard against data integrity corruption,
* they should safeguard against data referential integrity loss under _all_
data (not schema) manipulation scenarios, including application level
logic errors and direct data manipulation (e.g. through `dbconsole`),
* application constraints can be as restrictive as database constraints or more,
but not less, as it doesn't serve any use case,
* proper application level constraints should prevent unhandled database
exception occurences, e.g `ActiveRecord::InvalidForeignKey` for operations
performed through Models (i.e. not `#delete_all` etc.)

15
Gemfile
View File

@@ -1,18 +1,14 @@
source "https://rubygems.org"
# The requirement for the Ruby version comes from Rails
# NOTE: after updating Rails make sure that schema dump is not sorted:
# v8.1.3/activerecord/lib/active_record/schema_dumper.rb#L195
# Waiting for this change to be reverted/configuration setting added:
# https://github.com/rails/rails/pull/56842, https://github.com/rails/rails/pull/55414
gem "rails", "~> 8.1.3"
gem "rails", "~> 7.2.3"
gem "sprockets-rails"
gem "puma", "~> 6.0"
gem "sassc-rails"
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem "tzinfo-data", platforms: %i[ mingw mswin x64_mingw jruby ]
# TODO: select db gems automatically?
# TODO: select db gems automatically
# database_config = ERB.new(File.read("config/database.yml")).result
# YAML.load(database_config, aliases: true).values.map { |env| env["adapter"] }.uniq
group :mysql, optional: true do
@@ -46,4 +42,11 @@ group :test do
# Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
gem "capybara"
gem "selenium-webdriver"
# Remove minitest version restriction after error fixed:
# railties-7.2.3/lib/rails/test_unit/line_filtering.rb:7:in `run':
# wrong number of arguments (given 3, expected 1..2) (ArgumentError)
# from /var/www/.gem/ruby/3.3.0/gems/minitest-6.0.2/lib/minitest.rb:473:in
# `block (2 levels) in run_suite'
gem "minitest", "< 6"
end

View File

@@ -1,85 +1,85 @@
GEM
remote: https://rubygems.org/
specs:
action_text-trix (2.1.18)
railties
actioncable (8.1.3)
actionpack (= 8.1.3)
activesupport (= 8.1.3)
actioncable (7.2.3)
actionpack (= 7.2.3)
activesupport (= 7.2.3)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (8.1.3)
actionpack (= 8.1.3)
activejob (= 8.1.3)
activerecord (= 8.1.3)
activestorage (= 8.1.3)
activesupport (= 8.1.3)
actionmailbox (7.2.3)
actionpack (= 7.2.3)
activejob (= 7.2.3)
activerecord (= 7.2.3)
activestorage (= 7.2.3)
activesupport (= 7.2.3)
mail (>= 2.8.0)
actionmailer (8.1.3)
actionpack (= 8.1.3)
actionview (= 8.1.3)
activejob (= 8.1.3)
activesupport (= 8.1.3)
actionmailer (7.2.3)
actionpack (= 7.2.3)
actionview (= 7.2.3)
activejob (= 7.2.3)
activesupport (= 7.2.3)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (8.1.3)
actionview (= 8.1.3)
activesupport (= 8.1.3)
actionpack (7.2.3)
actionview (= 7.2.3)
activesupport (= 7.2.3)
cgi
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
racc
rack (>= 2.2.4, < 3.3)
rack-session (>= 1.0.1)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (8.1.3)
action_text-trix (~> 2.1.15)
actionpack (= 8.1.3)
activerecord (= 8.1.3)
activestorage (= 8.1.3)
activesupport (= 8.1.3)
actiontext (7.2.3)
actionpack (= 7.2.3)
activerecord (= 7.2.3)
activestorage (= 7.2.3)
activesupport (= 7.2.3)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (8.1.3)
activesupport (= 8.1.3)
actionview (7.2.3)
activesupport (= 7.2.3)
builder (~> 3.1)
cgi
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (8.1.3)
activesupport (= 8.1.3)
activejob (7.2.3)
activesupport (= 7.2.3)
globalid (>= 0.3.6)
activemodel (8.1.3)
activesupport (= 8.1.3)
activerecord (8.1.3)
activemodel (= 8.1.3)
activesupport (= 8.1.3)
activemodel (7.2.3)
activesupport (= 7.2.3)
activerecord (7.2.3)
activemodel (= 7.2.3)
activesupport (= 7.2.3)
timeout (>= 0.4.0)
activestorage (8.1.3)
actionpack (= 8.1.3)
activejob (= 8.1.3)
activerecord (= 8.1.3)
activesupport (= 8.1.3)
activestorage (7.2.3)
actionpack (= 7.2.3)
activejob (= 7.2.3)
activerecord (= 7.2.3)
activesupport (= 7.2.3)
marcel (~> 1.0)
activesupport (8.1.3)
activesupport (7.2.3)
base64
benchmark (>= 0.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
json
logger (>= 1.4.2)
minitest (>= 5.1)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
addressable (2.9.0)
addressable (2.8.8)
public_suffix (>= 2.0.2, < 8.0)
base64 (0.3.0)
bcrypt (3.1.22)
bigdecimal (4.1.2)
bcrypt (3.1.21)
benchmark (0.5.0)
bigdecimal (4.0.1)
bindex (0.8.1)
builder (3.3.0)
byebug (13.0.0)
@@ -93,27 +93,28 @@ GEM
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
cgi (0.5.1)
concurrent-ruby (1.3.6)
connection_pool (3.0.2)
crass (1.0.6)
date (3.5.1)
devise (5.0.3)
devise (5.0.2)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
railties (>= 7.0)
responders
warden (~> 1.2.3)
drb (2.2.3)
erb (6.0.4)
erb (6.0.2)
erubi (1.13.1)
ffi (1.17.4-aarch64-linux-gnu)
ffi (1.17.4-aarch64-linux-musl)
ffi (1.17.4-arm-linux-gnu)
ffi (1.17.4-arm-linux-musl)
ffi (1.17.4-arm64-darwin)
ffi (1.17.4-x86_64-darwin)
ffi (1.17.4-x86_64-linux-gnu)
ffi (1.17.4-x86_64-linux-musl)
ffi (1.17.3-aarch64-linux-gnu)
ffi (1.17.3-aarch64-linux-musl)
ffi (1.17.3-arm-linux-gnu)
ffi (1.17.3-arm-linux-musl)
ffi (1.17.3-arm64-darwin)
ffi (1.17.3-x86_64-darwin)
ffi (1.17.3-x86_64-linux-gnu)
ffi (1.17.3-x86_64-linux-musl)
globalid (1.3.0)
activesupport (>= 6.1)
i18n (1.14.8)
@@ -123,14 +124,13 @@ GEM
activesupport (>= 6.0.0)
railties (>= 6.0.0)
io-console (0.8.2)
irb (1.18.0)
irb (1.17.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
json (2.19.4)
logger (1.7.0)
loofah (2.25.1)
loofah (2.25.0)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.9.0)
@@ -142,12 +142,10 @@ GEM
marcel (1.1.0)
matrix (0.4.3)
mini_mime (1.1.5)
minitest (6.0.6)
drb (~> 2.0)
prism (~> 1.5)
minitest (5.27.0)
mysql2 (0.5.7)
bigdecimal
net-imap (0.6.4)
net-imap (0.6.3)
date
net-protocol
net-pop (0.1.2)
@@ -157,21 +155,21 @@ GEM
net-smtp (0.5.1)
net-protocol
nio4r (2.7.5)
nokogiri (1.19.3-aarch64-linux-gnu)
nokogiri (1.19.1-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.3-aarch64-linux-musl)
nokogiri (1.19.1-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.19.3-arm-linux-gnu)
nokogiri (1.19.1-arm-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.3-arm-linux-musl)
nokogiri (1.19.1-arm-linux-musl)
racc (~> 1.4)
nokogiri (1.19.3-arm64-darwin)
nokogiri (1.19.1-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.3-x86_64-darwin)
nokogiri (1.19.1-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.19.3-x86_64-linux-gnu)
nokogiri (1.19.1-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.3-x86_64-linux-musl)
nokogiri (1.19.1-x86_64-linux-musl)
racc (~> 1.4)
orm_adapter (0.5.0)
pg (1.6.3)
@@ -188,32 +186,32 @@ GEM
psych (5.3.1)
date
stringio
public_suffix (7.0.5)
public_suffix (7.0.2)
puma (6.6.1)
nio4r (~> 2.0)
racc (1.8.1)
rack (3.2.6)
rack-session (2.1.2)
rack (3.2.5)
rack-session (2.1.1)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.3.1)
rack (>= 3)
rails (8.1.3)
actioncable (= 8.1.3)
actionmailbox (= 8.1.3)
actionmailer (= 8.1.3)
actionpack (= 8.1.3)
actiontext (= 8.1.3)
actionview (= 8.1.3)
activejob (= 8.1.3)
activemodel (= 8.1.3)
activerecord (= 8.1.3)
activestorage (= 8.1.3)
activesupport (= 8.1.3)
rails (7.2.3)
actioncable (= 7.2.3)
actionmailbox (= 7.2.3)
actionmailer (= 7.2.3)
actionpack (= 7.2.3)
actiontext (= 7.2.3)
actionview (= 7.2.3)
activejob (= 7.2.3)
activemodel (= 7.2.3)
activerecord (= 7.2.3)
activestorage (= 7.2.3)
activesupport (= 7.2.3)
bundler (>= 1.15.0)
railties (= 8.1.3)
railties (= 7.2.3)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
@@ -221,28 +219,29 @@ GEM
rails-html-sanitizer (1.7.0)
loofah (~> 2.25)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (8.1.3)
actionpack (= 8.1.3)
activesupport (= 8.1.3)
railties (7.2.3)
actionpack (= 7.2.3)
activesupport (= 7.2.3)
cgi
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
tsort (>= 0.2)
zeitwerk (~> 2.6)
rake (13.4.2)
rake (13.3.1)
rdoc (7.2.0)
erb
psych (>= 4.0.0)
tsort
regexp_parser (2.12.0)
regexp_parser (2.11.3)
reline (0.6.3)
io-console (~> 0.5)
responders (3.2.0)
actionpack (>= 7.0)
railties (>= 7.0)
rexml (3.4.4)
rubyzip (3.3.0)
rubyzip (3.2.2)
sassc (2.4.0)
ffi (~> 1.9)
sassc-rails (2.1.2)
@@ -252,7 +251,7 @@ GEM
sprockets-rails
tilt
securerandom (0.4.1)
selenium-webdriver (4.43.0)
selenium-webdriver (4.41.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
@@ -266,32 +265,32 @@ GEM
actionpack (>= 6.1)
activesupport (>= 6.1)
sprockets (>= 3.0.0)
sqlite3 (2.9.3-aarch64-linux-gnu)
sqlite3 (2.9.3-aarch64-linux-musl)
sqlite3 (2.9.3-arm-linux-gnu)
sqlite3 (2.9.3-arm-linux-musl)
sqlite3 (2.9.3-arm64-darwin)
sqlite3 (2.9.3-x86_64-darwin)
sqlite3 (2.9.3-x86_64-linux-gnu)
sqlite3 (2.9.3-x86_64-linux-musl)
sqlite3 (2.9.0-aarch64-linux-gnu)
sqlite3 (2.9.0-aarch64-linux-musl)
sqlite3 (2.9.0-arm-linux-gnu)
sqlite3 (2.9.0-arm-linux-musl)
sqlite3 (2.9.0-arm64-darwin)
sqlite3 (2.9.0-x86_64-darwin)
sqlite3 (2.9.0-x86_64-linux-gnu)
sqlite3 (2.9.0-x86_64-linux-musl)
stringio (3.2.0)
thor (1.5.0)
tilt (2.7.0)
timeout (0.6.1)
timeout (0.6.0)
tsort (0.2.0)
turbo-rails (2.0.23)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
uri (1.1.1)
useragent (0.16.11)
warden (1.2.9)
rack (>= 2.0.9)
web-console (4.3.0)
actionview (>= 8.0.0)
web-console (4.2.1)
actionview (>= 6.0.0)
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 8.0.0)
railties (>= 6.0.0)
websocket (1.2.11)
websocket-driver (0.8.0)
base64
@@ -316,10 +315,11 @@ DEPENDENCIES
capybara
devise
importmap-rails
minitest (< 6)
mysql2 (~> 0.5)
pg (~> 1.5)
puma (~> 6.0)
rails (~> 8.1.3)
rails (~> 7.2.3)
sassc-rails
selenium-webdriver
sprockets-rails

View File

@@ -89,10 +89,14 @@ Running
### Standalone Rails server + Apache proxy
Copy and customize Puma config template if required:
Customize Puma config template:
cp -a config/puma.rb.dist config/puma.rb
and specify server IP/port, either with `port` or `bind`, e.g.:
bind 'tcp://0.0.0.0:3000'
#### (option 1) Start server manually
bundle exec rails s -e production
@@ -119,10 +123,9 @@ Contributing
### Gems
Install development and testing gems, including at least MySQL and SQLite
database adapters:
Apart from database adapter, install development and testing gems:
bundle config --local with development test mysql sqlite
bundle config --local with mysql development test
### Configuration
@@ -135,29 +138,20 @@ assets.
### Database
Grant database user privileges for development and test environments. Example
below shows how to grant privileges to all databases which names start with
`fixinme-` on MySQL:
Grant database user privileges for development and test environments,
possibly with different Ruby versions:
> mysql -p
mysql> create user `fixinme-dev`@localhost identified by '<some password>';
mysql> grant all privileges on `fixinme-%`.* to `fixinme-dev`@localhost;
mysql> flush privileges;
Dumping development data before database reset:
mysqldump -h <address> -u <user> -p --no-create-info --no-tablespaces --complete-insert <database> > tmp/data.sql
### Development environment
Starting application server in development environment:
bundle exec rails s -e development
Accessing database console when more than one test db is present:
bundle exec rails dbconsole -e test --db sqlite3
For running rake tasks, prepend command with environment:
RAILS_ENV=development bundle exec rails ...
@@ -170,22 +164,14 @@ Tests need to be run from within toplevel application directory:
bundle exec rails test:system
* system test(s) with seed or test name specified:
* system test(s) with seed/test name specified:
bundle exec rails test:system --include test_add_unit --seed 64690
bundle exec rails test:system --seed 64690 --name test_add_unit
* all tests from one file, optionally with seed:
* all tests from one file, with optional seed:
bundle exec rails test test/system/users_test.rb --seed 1234
* system tests for selected database configuration (if multiple present):
bundle exec rails test:system --include /^test_sqlite3_/
* single system test for all database configurations (if multiple present):
bundle exec rails test:system --include /^test_\\w+_add_unit$/
### Icons
Pictogrammers Material Design Icons: https://pictogrammers.com/library/mdi/

View File

@@ -18,12 +18,14 @@
/* Strive for simplicity:
* * style elements/tags only - if possible,
* * replace element/tag name with class name - if element has to be styled
* differently depending on context (e.g. <form>, <table>, <a> as link/button),
* differently depending on context (e.g. <form>; <a> as link/button),
* * styles with multiple selectors should have all selectors with same
* specificity, to allow proper rule specificity vs order management.
*
* NOTE: style in a modular way, similar to how CSS @scope would be used,
* to make transition easier once @scope is widely available. */
/* TODO: review styles with multiple selectors and try to convert them to the same
* specificity. */
:root {
--color-focus-gray: #f3f3f3;
--color-border-gray: #dddddd;
@@ -55,19 +57,8 @@
:focus-visible {
outline: none;
}
/* NOTE: move to higher priority layer instead of using !important?; add CSS
* @layer requirements in README */
[disabled] {
border-color: var(--color-border-gray) !important;
color: var(--color-border-gray) !important;
/* NOTE: cannot set cursor when `pointer-events: none`; can be fixed by setting
* `cursor` on wrapping element.
cursor: not-allowed; */
fill: var(--color-border-gray) !important;
pointer-events: none !important;
}
/* Styles set `display` without distinguishing between [hidden] elements, making
* them visible. */
/* [hidden] submit elements cannot have `display` set as it makes them visible. */
[hidden] {
display: none !important;
}
@@ -78,13 +69,10 @@
* gray - target for interaction with keyboard,
* red - destructive, non-undoable action.
*/
/* TODO: merge selectors using :is() */
a,
button,
details,
input,
select,
summary,
textarea {
background-color: inherit;
font: inherit;
@@ -102,14 +90,14 @@ textarea {
border-radius: 0.25em;
padding: 0.2em 0.4em;
}
svg {
height: 1.4em;
margin: 0 0.2em 0 0;
width: 1.4em;
}
svg:last-child {
margin-right: 0;
[name=cancel],
.auxiliary {
border-color: var(--color-border-gray);
color: var(--color-nav-gray);
fill: var(--color-nav-gray);
}
input[type=checkbox],
svg,
textarea {
margin: 0;
}
@@ -127,12 +115,11 @@ input[type=checkbox]:checked {
appearance: checkbox;
-webkit-appearance: checkbox;
}
/* Hide spin buttons of <input type=number>. */
/* TODO: add spin buttons inside <input type=number>: before (-) and after (+) input. */
/* Hide spin buttons in input number fields */
/* TODO: add spin buttons inside input[number]: before (-) and after (+) input */
input[type=number] {
appearance: textfield;
-moz-appearance: textfield;
text-align: end;
}
input::-webkit-inner-spin-button {
-webkit-appearance: none;
@@ -141,30 +128,6 @@ input::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Text color of table form controls:
* - black for row/table forms,
* - inherited for internal (column specific) buttons/forms. */
table input,
table select,
table summary,
table textarea {
border-color: var(--color-border-gray);
}
table input,
table select,
table textarea {
padding-block: 0.375em;
}
table form input,
table form select,
table form summary,
table form textarea {
color: inherit;
}
table svg:not(:only-child) {
height: 1.25em;
width: 1.25em;
}
input:focus-visible,
select:focus-visible,
select:focus-within,
@@ -173,7 +136,6 @@ summary:focus-visible,
textarea:focus-visible {
accent-color: var(--color-dark-blue);
background-color: var(--color-focus-gray);
color: black;
}
input:hover,
select:hover,
@@ -184,15 +146,8 @@ textarea:hover {
}
select:hover,
summary:hover {
color: black;
cursor: pointer;
}
/* TODO: style <details>/<summary> focus to match <select> as much as possible.
summary:focus-visible::before,
summary:hover::before {
background-color: black;
}
*/
input:invalid,
select:invalid,
textarea:invalid {
@@ -226,21 +181,20 @@ textarea:invalid {
padding: 0.6em 0.5em;
width: fit-content;
}
.link {
color: inherit;
text-decoration: underline 1px var(--color-border-gray);
text-underline-offset: 0.25em;
}
[name=cancel],
.auxiliary {
border-color: var(--color-nav-gray);
border-color: var(--color-border-gray);
color: var(--color-nav-gray);
fill: var(--color-nav-gray);
}
table .button {
border-color: var(--color-border-gray);
font-weight: normal;
height: 100%;
padding: 0.4em;
.button > svg,
.tab > svg {
height: 1.4em;
width: 1.4em;
}
.button > svg:not(:last-child),
.tab > svg:not(:last-child) {
margin-right: 0.2em;
}
.button:focus-visible,
.tab:focus-visible,
@@ -257,12 +211,13 @@ table .button {
background-color: var(--color-red);
border-color: var(--color-red);
}
.link:focus-visible {
text-decoration-color: var(--color-gray);
}
.link:hover {
color: var(--color-blue);
text-decoration-color: var(--color-blue);
/* TODO: move normal, non-button links (<a>:hover/:focus) styling here (i.e.
* page-wide, top-level) and remove from `table.items` - as the style should be
* same everywhere. */
input[type=text]:read-only,
textarea:read-only {
border: none;
padding-inline: 0;
}
@@ -275,16 +230,16 @@ body {
grid-template-areas:
"header header header"
"nav nav nav"
"leftside topside rightside"
"leftside main rightside";
grid-template-columns: 1fr minmax(max-content, 2fr) 1fr;
font-family: system-ui;
margin: 0.4em;
}
body:has(> .topside-area) {
body:not(:has(.topside-area)) {
grid-template-areas:
"header header header"
"nav nav nav"
"leftside topside rightside"
"leftside main rightside";
}
@@ -358,20 +313,20 @@ header {
line-height: 2.2em;
pointer-events: auto;
}
.flash::before {
filter: invert(1);
.flash:before {
filter: invert();
height: 1.4em;
margin: 0 0.5em;
width: 1.4em;
}
.flash.alert::before {
.flash.alert:before {
content: url('pictograms/alert-outline.svg');
}
.flash.alert {
border-color: var(--color-red);
background-color: var(--color-red);
}
.flash.notice::before {
.flash.notice:before {
content: url('pictograms/check-circle-outline.svg');
}
.flash.notice {
@@ -391,6 +346,7 @@ header {
}
/* TODO: Hover over invalid should work like in measurements (thin vs thick border) */
.labeled-form {
align-items: center;
display: grid;
@@ -427,82 +383,85 @@ header {
.labeled-form .auxiliary {
grid-column: 3;
/* If more buttons are needed, `grid-row` can be replaced with
* `reading-flow: grid-columns` to ensure proper [tabindex] order. */
* `reading-flow: grid-columns` to ensure proper tabindex order */
grid-row: 1;
height: 100%;
padding-block: 0;
}
.tabular-form table {
border: none;
border-spacing: 0;
}
.tabular-form table td {
border: none;
padding-inline-start: 0.4em;
vertical-align: middle;
}
.tabular-form table td:first-child {
padding-inline-start: 0;
}
.tabular-form table td:last-child {
padding-inline-end: 0;
}
.tabular-form table :is(form, input, select, textarea):only-child {
margin-inline-start: 0;
}
.items-table {
/* TODO: remove `.items` class (?) and make `form table` work properly. */
table.items {
border-spacing: 0;
border: 1px solid var(--color-border-gray);
border-radius: 0.25em;
font-size: 0.85rem;
text-align: left;
}
.items-table thead {
table:not(:has(tr)) {
display: none;
}
table.items thead {
font-size: 0.8rem;
}
.items-table thead,
.items-table tbody tr:hover {
table.items thead,
table.items tbody tr:hover {
background-color: var(--color-focus-gray);
}
.items-table th {
padding: 0.75em 0 0.75em 1em;
table.items th {
padding-block: 0.75em;
text-align: center;
}
.items-table th:last-child {
padding-inline-end: 0.4em;
table.items th,
table.items td {
padding-inline: 1em 0;
}
.items-table td {
border-top: 1px solid var(--color-border-gray);
height: 2.4em;
padding: 0.1em 0 0.1em calc(1em + var(--depth) * 0.8em);
}
.items-table td:last-child {
padding-inline-end: 0.1em;
}
.items-table :is(form, input, select, textarea):only-child {
margin-inline-start: calc(-0.4em - 0.9px);
}
/* For <a> to fill table cell completely, we use an `::after` pseudoelement. */
/* TODO: expand to whole row? will require adjusting z-index on inputs/buttons */
.items-table td:has(> .link) {
/* For <a> to fill <td> completely, we use an `::after` pseudoelement. */
table.items td.link {
padding: 0;
position: relative;
}
.items-table .link::after {
table.items td.link a {
color: inherit;
font: inherit;
}
table.items td.link a::after {
content: '';
inset: -1px 0 0 0;
inset: 0;
position: absolute;
}
.items-table .flex {
table.items td:first-child {
padding-inline-start: calc(1em + var(--depth) * 0.8em);
}
table.items td:has(input, select, textarea) {
padding-inline-start: calc(0.6em - 0.9px);
}
table.items td:first-child:has(input, select, textarea) {
padding-inline-start: calc(0.6em + var(--depth) * 0.8em - 0.9px);
}
table.items th:last-child {
padding-inline-end: 0.4em;
}
table.items td:last-child {
padding-inline-end: 0.1em;
}
table.items td {
border-top: 1px solid var(--color-border-gray);
height: 2.4em;
padding-block: 0.1em;
}
table.items .actions {
display: flex;
gap: 0.4em;
justify-content: end;
}
.items-table .dropzone {
table.items .actions.centered {
justify-content: center;
}
table.items tr.dropzone {
position: relative;
}
.items-table .dropzone::after {
table.items tr.dropzone::after {
content: '';
inset: 1px 0 0 0;
position: absolute;
@@ -510,57 +469,123 @@ header {
outline-offset: -1px;
z-index: var(--z-index-table-row-outline);
}
.items-table .handle {
cursor: grab;
table.items td.handle {
cursor: move;
}
.items-table .form td {
table.items tr.form td {
vertical-align: top;
}
.items-table td:not(:first-child),
/* TODO: replace `:hover:focus-visible` combos with proper LOVE style order. */
/* TODO: update table styling: simplify selectors, deduplicate, remove non-font rem. */
table.items td.link a:hover,
table.items td.link a:focus-visible,
table.items td.link a:hover:focus-visible {
text-decoration: underline;
text-decoration-thickness: 0.05rem;
text-underline-offset: 0.2rem;
}
table.items td.link a:hover {
color: var(--color-blue);
}
table.items td.link a:focus-visible {
text-decoration-color: var(--color-gray);
}
table.items td.link a:hover:focus-visible {
color: var(--color-dark-blue);
}
table.items td:not(:first-child),
.grayed {
color: var(--color-table-gray);
fill: var(--color-gray);
fill: var(--color-table-gray);
}
.items-table td:has(> svg:only-child) {
table.items svg {
height: 1rem;
vertical-align: middle;
width: 1rem;
}
table.items svg:last-child {
height: 1.2rem;
width: 1.2rem;
}
table.items td.svg {
text-align: center;
}
table.items td.number {
text-align: right;
}
table.items .button {
font-weight: normal;
height: 100%;
padding: 0.4em;
}
table.items input:not([type=submit]):not([type=checkbox]),
table.items select,
table.items textarea {
padding-block: 0.375em;
}
table input,
table select,
table summary,
table textarea {
border-color: var(--color-border-gray);
}
table select {
color: var(--color-table-gray);
}
table select:hover,
table select:focus-within,
table select:focus-visible {
color: black;
}
table .button {
border-color: var(--color-border-gray);
color: var(--color-table-gray);
}
.center {
form table.items {
border: none;
}
form table.items td {
border: none;
text-align: left;
vertical-align: middle;
}
form table.items td:first-child {
color: inherit;
}
form table select {
color: black;
}
.centered {
margin: 0 auto;
}
.extendedright {
margin-right: auto;
}
.hexpand {
width: 100%;
}
.flex {
.hflex {
display: flex;
gap: 0.8em;
}
.flex.reverse {
flex-direction: row-reverse;
.hflex.centered {
justify-content: center;
}
.flex.vertical {
.vexpand {
width: 100%;
}
.vflex {
display: flex;
gap: 0.8em;
flex-direction: column;
}
.hint {
color: var(--color-table-gray);
font-style: italic;
font-size: 0.9rem;
text-align: center;
}
.hmin50 {
min-width: 50%;
}
.italic {
color: var(--color-gray);
font-style: italic;
}
.ralign {
text-align: right;
}
.rextend {
margin-right: auto;
}
details {
@@ -571,12 +596,12 @@ summary {
align-items: center;
color: var(--color-gray);
display: flex;
gap: 0.4em;
gap: 0.2em;
height: 100%;
white-space: nowrap;
}
summary::before {
background-color: currentColor;
background-color: #000;
content: "";
height: 1em;
mask-image: url('pictograms/chevron-down.svg');
@@ -599,14 +624,14 @@ summary span {
width: 100%;
}
details[open] summary::before {
transform: scaleY(-1);
transform: rotate(180deg);
}
summary::marker {
padding-left: 0.25em;
}
/* NOTE: use `details[open]::details-content` once widely available. */
details[open] ul {
background-color: white;
background: white;
border: 1px solid var(--color-border-gray);
border-radius: 0.25em;
box-shadow: 1px 1px 3px var(--color-border-gray);
@@ -636,8 +661,3 @@ li::marker {
* * focused label styling (currently only checkbox has focus),
* * disabled checkbox blue square focus removal.
* */
#measurement_form {
min-width: 66%;
width: max-content;
}

View File

@@ -9,6 +9,7 @@ class ApplicationController < ActionController::Base
helper_method :current_user_disguised?
helper_method :current_tab
before_action :redirect_to_setup_if_needed
before_action :authenticate_user!
class AccessForbidden < StandardError; end
@@ -55,6 +56,16 @@ class ApplicationController < ActionController::Base
private
# Redirect to the web setup wizard when the application has not yet been
# initialised (i.e. no admin account exists in the database).
def redirect_to_setup_if_needed
return if User.exists?(status: :admin)
redirect_to new_setup_path
rescue ActiveRecord::StatementInvalid
# Tables may not exist yet (migrations not run). Fall through and let the
# normal request handling surface a meaningful error.
end
def render_no_content(record)
helpers.render_errors(record)
render html: nil, layout: true

View File

@@ -1,7 +1,13 @@
class MeasurementsController < ApplicationController
before_action except: :index do
raise AccessForbidden unless current_user.at_least(:active)
end
def index
@measurements = []
#@measurements = current_user.units.ordered.includes(:base, :subunits)
readouts = current_user.readouts.includes(:quantity, :unit).order(created_at: :desc)
@measurements = readouts.group_by(&:created_at).map do |created_at, grouped|
Measurement.new(created_at: created_at, readouts: grouped)
end
end
def new
@@ -9,8 +15,33 @@ class MeasurementsController < ApplicationController
end
def create
timestamp = Time.current
@readouts = readout_params.map do |rp|
r = current_user.readouts.new(rp)
r.created_at = timestamp
r
end
if @readouts.all?(&:valid?)
Readout.transaction { @readouts.each(&:save!) }
@measurement = Measurement.new(readouts: @readouts, created_at: timestamp)
flash.now[:notice] = t('.success')
else
render :new, status: :unprocessable_entity
end
end
def destroy
@measurement = Measurement.new(id: params[:id].to_i,
created_at: Time.at(params[:id].to_i))
current_user.readouts.where(created_at: @measurement.created_at).delete_all
@measurements_empty = current_user.readouts.empty?
flash.now[:notice] = t('.success')
end
private
def readout_params
params.require(:readouts).map { |r| r.permit(:quantity_id, :value, :unit_id) }
end
end

View File

@@ -1,16 +1,23 @@
class ReadoutsController < ApplicationController
before_action :find_quantities, only: [:new]
before_action :find_quantity, only: [:discard]
before_action :find_quantity, only: [:new, :discard]
before_action :find_prev_quantities, only: [:new, :discard]
def new
@quantities -= @prev_quantities
# TODO: raise ParameterInvalid if new_quantities.empty?
@readouts = @quantities.map { |q| q.readouts.build }
new_quantities =
case params[:button]
when 'children'
@quantity.subquantities
when 'subtree'
@quantity.progenies
else
[@quantity]
end
new_quantities -= @prev_quantities
@readouts = current_user.readouts.build(new_quantities.map { |q| {quantity: q} })
@user_units = current_user.units.ordered
quantities = @prev_quantities + @quantities
quantities = @prev_quantities + new_quantities
@superquantity = current_user.quantities
.common_ancestors(quantities.map(&:parent_id)).first
end
@@ -24,9 +31,6 @@ class ReadoutsController < ApplicationController
private
def find_quantities
@quantities = current_user.quantities.find(params[:quantity])
end
def find_quantity
@quantity = current_user.quantities.find_by!(id: params[:id])

View File

@@ -0,0 +1,59 @@
# Handles the one-time web-based installation wizard.
#
# The wizard is only accessible when no admin account exists yet. Once an
# admin has been created the controller redirects every request to the root
# path, so it can never be used to overwrite an existing installation.
class SetupController < ActionController::Base
# Use the full application layout (header, flash, etc.) so the page looks
# consistent with the rest of the site.
layout "application"
before_action :redirect_if_installed
def new
end
def create
email = params[:admin_email].to_s.strip
password = params[:admin_password].to_s
confirm = params[:admin_password_confirmation].to_s
errors = []
errors << t(".email_blank") if email.blank?
errors << t(".password_blank") if password.blank?
errors << t(".password_mismatch") if password != confirm
if errors.any?
flash.now[:alert] = errors.join(" ")
return render :new, status: :unprocessable_entity
end
user = User.new(email: email, password: password, status: :admin)
user.skip_confirmation!
unless user.save
flash.now[:alert] = user.errors.full_messages.join(" ")
return render :new, status: :unprocessable_entity
end
# Persist runtime settings chosen during setup.
Setting.set("skip_email_confirmation",
params[:skip_email_confirmation] == "1")
# Optionally seed the built-in default units.
if params[:seed_units] == "1"
load Rails.root.join("db/seeds/units.rb")
end
redirect_to new_user_session_path, notice: t(".success")
end
private
def redirect_if_installed
redirect_to root_path if User.exists?(status: :admin)
rescue ActiveRecord::StatementInvalid
# Tables are not yet migrated — stay on the setup page so the user sees a
# meaningful error rather than a crash.
end
end

View File

@@ -1,11 +1,24 @@
class User::ProfilesController < Devise::RegistrationsController
def destroy
# TODO: Disallow/disable deletion for last admin account; update :edit view
if current_user.sole_admin?
redirect_back fallback_location: edit_user_registration_path,
alert: t(".sole_admin")
return
end
super
end
protected
def build_resource(hash = {})
super
# Skip the email confirmation step when the admin has enabled this option
# via the web setup wizard (stored as the "skip_email_confirmation" Setting).
# The account becomes active immediately so the user can sign in right after
# registering.
resource.skip_confirmation! if Setting.get("skip_email_confirmation") == "true"
end
def update_resource(resource, params)
# Based on update_with_password()
if params[:password].blank?

View File

@@ -102,17 +102,13 @@ module ApplicationHelper
def number_field(method, options = {})
attr_type = object.type_for_attribute(method)
case attr_type.type
when :decimal
if attr_type.type == :decimal
options[:value] = object.public_send(method)&.to_scientific
options[:step] ||= BigDecimal(10).power(-attr_type.scale)
options[:max] ||= BigDecimal(10).power(attr_type.precision - attr_type.scale) -
options[:step]
options[:min] = options[:min] == :step ? options[:step] : options[:min]
options[:min] ||= -options[:max]
options[:size] ||= attr_type.precision / 2
when :float
options[:size] ||= 6
end
super
end
@@ -152,8 +148,7 @@ module ApplicationHelper
end
def tabular_form_with(**options, &block)
extra_options = {builder: TabularFormBuilder, class: 'tabular-form',
html: {autocomplete: 'off'}}
extra_options = {builder: TabularFormBuilder, html: {autocomplete: 'off'}}
form_with(**merge_attributes(options, extra_options), &block)
end
@@ -208,7 +203,9 @@ module ApplicationHelper
def image_link_to_unless_current(name, image = nil, options = nil, html_options = {})
name, html_options = link_or_button_options(:link, name, image, html_options)
if current_page?(options, method: [:get, :post])
# NOTE: Starting from Rails 8.1.0, below condition can be replaced with:
# current_page?(options, method: [:get, :post])
if request.path == url_for(options)
html_options = html_options.deep_merge DISABLED_ATTRIBUTES
end
link_to name, options, html_options

View File

@@ -1,9 +1,9 @@
module QuantitiesHelper
def quantities_check_boxes(quantities)
def quantities_check_boxes
# Closing <details> on focusout event depends on relatedTarget for internal
# actions being non-null. To ensure this, all top-layer elements of
# ::details-content must accept focus, e.g. <label> needs tabindex="-1" */
collection_check_boxes(nil, :quantity, quantities, :id, :to_s_with_depth,
collection_check_boxes(nil, :quantity, @quantities, :id, :to_s_with_depth,
include_hidden: false) do |b|
content_tag :li, b.label(tabindex: -1) { b.check_box + b.text }
end

View File

@@ -1,3 +1,17 @@
class Measurement
include ActiveModel::Model
attr_accessor :readouts, :created_at
def id
created_at.to_i
end
def to_param
id.to_s
end
def persisted?
true
end
end

View File

@@ -1,3 +0,0 @@
class Note < ApplicationRecord
ATTRIBUTES = [:text]
end

View File

@@ -6,7 +6,6 @@ class Quantity < ApplicationRecord
belongs_to :parent, optional: true, class_name: "Quantity"
has_many :subquantities, ->{ order(:name) }, class_name: "Quantity",
inverse_of: :parent, dependent: :restrict_with_error
has_many :readouts, dependent: :restrict_with_error
validate if: ->{ parent.present? } do
errors.add(:parent, :user_mismatch) unless user_id == parent.user_id
@@ -16,8 +15,8 @@ class Quantity < ApplicationRecord
errors.add(:parent, :descendant_reference) if ancestor_of?(parent)
end
validates :name, presence: true, uniqueness: {scope: [:user_id, :parent_id]},
length: {maximum: type_for_attribute(:name).limit}
validates :description, length: {maximum: type_for_attribute(:description).limit}
length: {maximum: type_for_attribute(:name).limit || Float::INFINITY}
validates :description, length: {maximum: type_for_attribute(:description).limit || Float::INFINITY}
# Update :depths of progenies after parent change
before_save if: :parent_changed? do
@@ -62,18 +61,26 @@ class Quantity < ApplicationRecord
# Return: ordered [sub]hierarchy
scope :ordered, ->(root: nil, include_root: true) {
numbered = Arel::Table.new('numbered')
self.model.with(numbered: numbered(:parent_id, :name)).with_recursive(arel_table.name => [
numbered.project(
numbered[Arel.star],
numbered.cast(numbered[:child_number], 'BINARY').as('path')
).where(numbered[root && include_root ? :id : :parent_id].eq(root)),
numbered.project(
numbered[Arel.star],
arel_table[:path].concat(numbered[:child_number])
).join(arel_table).on(numbered[:parent_id].eq(arel_table[:id]))
]).order(arel_table[:path])
if connection.adapter_name =~ /mysql/i
numbered = Arel::Table.new('numbered')
self.model.with(numbered: numbered(:parent_id, :name)).with_recursive(arel_table.name => [
numbered.project(
numbered[Arel.star],
numbered.cast(numbered[:child_number], 'BINARY').as('path')
).where(numbered[root && include_root ? :id : :parent_id].eq(root)),
numbered.project(
numbered[Arel.star],
arel_table[:path].concat(numbered[:child_number])
).join(arel_table).on(numbered[:parent_id].eq(arel_table[:id]))
]).order(arel_table[:path])
elsif root.nil?
# SQLite: pathname column already stores the full hierarchical path
order(:pathname)
else
root_pathname = unscoped.where(id: root).pick(:pathname)
scope = order(:pathname).where("pathname LIKE ?", "#{root_pathname}#{PATHNAME_DELIMITER}%")
include_root ? scope.or(where(id: root)) : scope
end
}
# TODO: extract named functions to custom Arel extension

View File

@@ -4,6 +4,4 @@ class Readout < ApplicationRecord
belongs_to :user
belongs_to :quantity
belongs_to :unit
# TODO: validate quantity.user_id == unit.user_id != NULL
end

20
app/models/setting.rb Normal file
View File

@@ -0,0 +1,20 @@
# Key-value store for runtime application settings that are configured through
# the web setup wizard (or updated by an administrator) rather than hard-coded
# in application.rb.
#
# Known keys:
# skip_email_confirmation "true"/"false", mirrors the homonymous option
# that was previously in application.rb.
class Setting < ApplicationRecord
validates :key, presence: true, uniqueness: true
# Return the string value stored for +key+, or +default+ when absent.
def self.get(key, default: nil)
find_by(key: key)&.value || default
end
# Persist +value+ for +key+, creating the record if it does not yet exist.
def self.set(key, value)
find_or_initialize_by(key: key).update!(value: value.to_s)
end
end

View File

@@ -12,8 +12,8 @@ class Unit < ApplicationRecord
errors.add(:base, :multilevel_nesting) if base.base_id?
end
validates :symbol, presence: true, uniqueness: {scope: :user_id},
length: {maximum: type_for_attribute(:symbol).limit}
validates :description, length: {maximum: type_for_attribute(:description).limit}
length: {maximum: type_for_attribute(:symbol).limit || Float::INFINITY}
validates :description, length: {maximum: type_for_attribute(:description).limit || Float::INFINITY}
validates :multiplier, numericality: {equal_to: 1}, unless: :base
validates :multiplier, numericality: {greater_than: 0, precision: true, scale: true}, if: :base
@@ -26,10 +26,8 @@ class Unit < ApplicationRecord
other_bases_units = arel_table.alias('other_bases_units')
sub_units = arel_table.alias('sub_units')
# TODO: move inner 'with' CTE to outer 'with recursive' - it can have multiple
# CTEs, even non recursive ones.
Unit.with_recursive(actionable_units: [
Unit.with(units: self.or(Unit.defaults)).left_joins(:base)
self.or(Unit.defaults).left_joins(:base)
.where.not(
# Exclude Units that are/have default counterpart
Arel::SelectManager.new.project(1).from(other_units)
@@ -65,8 +63,14 @@ class Unit < ApplicationRecord
),
# Fill base Units to display proper hierarchy. Duplicates will be removed
# by final group() - can't be deduplicated with UNION due to 'portable' field.
arel_table.join(actionable_units).on(actionable_units[:base_id].eq(arel_table[:id]))
.project(arel_table[Arel.star], Arel::Nodes.build_quoted(nil).as('portable'))
# Use ActiveRecord::Relation (not a raw SelectManager) so the SQLite Arel
# visitor does not wrap it in parentheses inside the UNION ALL CTE body.
Unit.joins(
arel_table.create_join(
actionable_units,
arel_table.create_on(actionable_units[:base_id].eq(arel_table[:id]))
)
).select(arel_table[Arel.star], Arel::Nodes.build_quoted(nil).as('portable'))
]).select(units: [:base_id, :symbol])
.select(
units[:id].minimum.as('id'), # can be ANY_VALUE()
@@ -74,17 +78,19 @@ class Unit < ApplicationRecord
Arel::Nodes.build_quoted(1).as('multiplier'), # disregard multiplier when sorting
units[:portable].minimum.as('portable')
)
.from(units).group(:base_id, :symbol)
.from(units).group(units[:base_id], units[:symbol])
}
scope :ordered, ->{
left_outer_joins(:base).order([
arel_table.coalesce(Arel::Table.new(:bases_units)[:symbol], arel_table[:symbol]),
arel_table[:base_id].not_eq(nil),
arel_table[:multiplier],
arel_table[:symbol]
])
left_outer_joins(:base).order(ordering)
}
def self.ordering
[arel_table.coalesce(Arel::Table.new(:bases_units)[:symbol], arel_table[:symbol]),
arel_table[:base_id].not_eq(nil),
arel_table[:multiplier],
arel_table[:symbol]]
end
before_destroy do
# TODO: disallow destruction if any object depends on this unit
nil
@@ -112,10 +118,11 @@ class Unit < ApplicationRecord
def successive
units = Unit.arel_table
Unit.with(units_with_lag: user.units.ordered.select(
units[Arel.star],
Arel::Nodes::NamedFunction.new('LAG', [units[:id]]).over.as('lag_id')
)).from(Arel::Table.new(:units_with_lag).as(:units))
.where(units[:lag_id].eq(id)).first
lead = Arel::Nodes::NamedFunction.new('LAG', [units[:id]])
window = Arel::Nodes::Window.new.order(*Unit.ordering)
lag_id = lead.over(window).as('lag_id')
Unit.with(
units: user.units.left_outer_joins(:base).select(units[Arel.star], lag_id)
).where(units[:lag_id].eq(id)).first
end
end

View File

@@ -29,4 +29,11 @@ class User < ApplicationRecord
def at_least(status)
User.statuses[self.status] >= User.statuses[status]
end
# Returns true when this user is the only admin account in the system.
# Used to block actions that would leave the application without an admin
# (account deletion, status demotion).
def sole_admin?
admin? && !User.admin.where.not(id: id).exists?
end
end

View File

@@ -5,7 +5,7 @@
</td>
<% if current_user.at_least(:active) %>
<td class="flex">
<td class="actions">
<% unless unit.portable.nil? %>
<% if unit.default? %>
<%= image_button_to_if unit.portable?, t('.import'), 'download-outline',

View File

@@ -8,7 +8,7 @@
class: 'tools-area' %>
</div>
<table class="main-area items-table">
<table class="main-area items">
<thead>
<tr>
<th><%= Unit.human_attribute_name(:symbol) %></th>

View File

@@ -23,10 +23,10 @@
</head>
<body>
<header class="flex">
<header class="hflex">
<%= image_link_to t(".source_code"), "code-braces", source_code_url %>
<%= image_link_to t(".issue_tracker"), "bug-outline", issue_tracker_url,
class: "rextend" %>
class: "extendedright" %>
<% if user_signed_in? %>
<%= image_link_to_unless_current(current_user, "account-wrench-outline",
edit_user_registration_path) %>

View File

@@ -1,39 +1,25 @@
<%= tabular_form_with model: Measurement.new, id: :measurement_form,
class: 'topside-area flex vertical center',
html: {onkeydown: 'formProcessKey(event)'} do |form| %>
<table class="items-table center">
<tbody id="readouts">
<%= tabular_fields_for @measurement do |form| %>
<tr class="italic">
<td class="hexpand hmin50"><%= t '.taken_at_html' %></td>
<td colspan="3" class="ralign">
<%= form.datetime_field :taken_at, required: true %>
<%= tabular_form_with model: Measurement.new do |form| %>
<fieldset>
<table class="items centered">
<tbody id="readouts">
<tr id="readouts_form">
<td colspan="4">
<%= collection_select :quantity, :id, @quantities, :id, :to_s_with_depth,
{prompt: t('.select_quantity'), disabled: '', selected: ''},
{name: :id, class: 'quantity vexpand',
onchange: "this.form.requestSubmit(new_readout_submit);"} %>
<%= form.submit id: :new_readout_submit, name: nil, value: nil,
formaction: new_readout_path, formmethod: :get, formnovalidate: true,
hidden: true, data: {turbo_stream: true} %>
</td>
</tr>
<% end %>
</tbody>
</table>
<%# TODO: right-click selection; unnecessary with hierarchical tags? %>
<details id="quantity_select" class="center hexpand" open
onkeydown="detailsProcessKey(event)">
<summary autofocus>
<!-- TODO: Set content with CSS when span empty to avoid duplication -->
<span data-prompt="<%= t('.select_quantity') %>">
<%= t('.select_quantity') %>
</span>
<%= image_button_tag t(:apply), "update", name: nil, disabled: true,
formaction: new_readout_path, formmethod: :get, formnovalidate: true,
data: {turbo_stream: true} %>
</summary>
<ul><%= quantities_check_boxes(@quantities) %></ul>
</details>
<div class="flex reverse">
<%= form.button id: :create_measurement_button, disabled: true -%>
</tbody>
</table>
</fieldset>
<div class="hflex centered">
<%= form.button -%>
<%= image_link_to t(:cancel), "close-outline", measurements_path, name: :cancel,
class: 'auxiliary dangerous', onclick: render_turbo_stream('form_close') %>
class: 'dangerous', onclick: render_turbo_stream('form_close') %>
</div>
<% end %>

View File

@@ -1,3 +1,4 @@
<%= turbo_stream.update :measurement_form %>
<%= turbo_stream.update :flashes %>
<%= turbo_stream.remove :measurement_form %>
<%= turbo_stream.show :no_items -%>

View File

@@ -0,0 +1,14 @@
<%= tag.tr id: dom_id(measurement) do %>
<td><%= l measurement.created_at, format: :short %></td>
<td>
<% measurement.readouts.each do |readout| %>
<span><%= readout.quantity.name %>: <%= readout.value %> <%= readout.unit %></span>
<% end %>
</td>
<% if current_user.at_least(:active) %>
<td class="actions">
<%= image_button_to t('.destroy'), 'delete-outline', measurement_path(measurement),
method: :delete %>
</td>
<% end %>
<% end %>

View File

@@ -0,0 +1,5 @@
<%= turbo_stream.update :flashes %>
<%= turbo_stream.remove :measurement_form %>
<%= turbo_stream.remove :no_items %>
<%= turbo_stream.enable :new_measurement_link %>
<%= turbo_stream.prepend :measurements, @measurement %>

View File

@@ -0,0 +1,3 @@
<%= turbo_stream.update :flashes %>
<%= turbo_stream.remove @measurement %>
<%= turbo_stream.append(:measurements, render_no_items) if @measurements_empty %>

View File

@@ -1,5 +1,5 @@
<%# TODO: show hint when no quantities/units defined %>
<div class="rightside-area buttongrid">
<div class="rightside buttongrid">
<% if current_user.at_least(:active) %>
<%= image_link_to t('.new_measurement'), 'plus-outline', new_measurement_path,
id: :new_measurement_link, onclick: 'this.blur();',
@@ -7,7 +7,9 @@
<% end %>
</div>
<table class="main-area">
<%= tag.div class: 'topside', id: :measurement_form %>
<table class="main">
<tbody id="measurements">
<%= render(@measurements) || render_no_items %>
</tbody>

View File

@@ -1,5 +1,4 @@
<%= turbo_stream.disable :new_measurement_link -%>
<%= turbo_stream.hide :no_items -%>
<%= turbo_stream.append_all 'body' do %>
<%= turbo_stream.update :measurement_form do %>
<%= render partial: 'form' %>
<% end %>

View File

@@ -9,7 +9,7 @@
<%= form.text_area :description, cols: 30, rows: 1, escape: false %>
</td>
<td class="flex">
<td class="actions">
<%= form.button %>
<%= image_link_to t(:cancel), "close-outline", quantities_path, class: 'dangerous',
name: :cancel, onclick: render_turbo_stream('form_close', {row: row}) %>

View File

@@ -5,14 +5,14 @@
data: {drag_path: reparent_quantity_path(quantity), drop_id: dom_id(quantity),
drop_id_param: "quantity[parent_id]"} do %>
<td style="--depth:<%= quantity.depth %>">
<%= link_to quantity, edit_quantity_path(quantity), class: 'link',
onclick: 'this.blur();', data: {turbo_stream: true} %>
<td class="link" style="--depth:<%= quantity.depth %>">
<%= link_to quantity, edit_quantity_path(quantity), onclick: 'this.blur();',
data: {turbo_stream: true} %>
</td>
<td><%= quantity.description %></td>
<% if current_user.at_least(:active) %>
<td class="flex">
<td class="actions">
<%= image_link_to t('.new_subquantity'), 'plus-outline', new_quantity_path(quantity),
id: dom_id(quantity, :new, :link), onclick: 'this.blur();', data: {turbo_stream: true} %>

View File

@@ -8,14 +8,13 @@
class: 'tools-area' %>
</div>
<%# TODO: remove? form can be inserted directly, e.g. at the end of index %>
<%= tag.div class: 'main-area', id: :quantity_form %>
<table class="main-area items-table">
<table class="main-area items">
<thead>
<tr>
<th><%= Quantity.human_attribute_name(:name) %></th>
<th class="hexpand"><%= Quantity.human_attribute_name(:description) %></th>
<th><%= Quantity.human_attribute_name(:description) %></th>
<% if current_user.at_least(:active) %>
<th><%= t :actions %></th>
<th></th>

View File

@@ -1,22 +1,20 @@
<%# TODO: add readout reordering by dragging %>
<%= tabular_fields_for 'readouts[]', readout do |form| %>
<%- tag.tr id: dom_id(readout.quantity, :new, :readout) do %>
<%- tag.tr id: dom_id(readout.quantity, :new, :readout),
onkeydown: 'processKey(event)' do %>
<td>
<%# TODO: add grayed readout index (in separate column?) %>
<%= readout.quantity.relative_pathname(@superquantity) %>
</td>
<td>
<%= form.number_field :value, required: true, autofocus: true, size: 10 %>
</td>
<td>
<%= form.hidden_field :quantity_id %>
</td>
<td>
<%= form.number_field :value, required: true, autofocus: readout_counter == 0 %>
</td>
<td>
<%= form.collection_select :unit_id, @user_units, :id,
->(u){ sanitize('&emsp;' * (u.base_id ? 1 : 0) + u.symbol) },
{prompt: '', disabled: '', selected: ''}, required: true %>
->(u){ sanitize('&emsp;' * (u.base_id ? 1 : 0) + u.symbol) } %>
</td>
<td class="flex">
<%# TODO: change to _link_ after giving up displaying relative paths %>
<%= image_button_tag '', 'delete-outline', class: 'dangerous', name: nil,
<td class="actions">
<%= image_button_tag '', 'delete-outline', class: 'dangerous', name: :discard,
formaction: discard_readouts_path(readout.quantity),
formmethod: :get, formnovalidate: true, data: {turbo_stream: true} %>
</td>

View File

@@ -1,4 +1,4 @@
<%= turbo_stream.disable :create_measurement_button if @prev_quantities.one? %>
<%= turbo_stream.remove dom_id(@quantity, :new, :readout) %>
<%= turbo_stream.disable_all 'button[name="discard"]' if @prev_quantities.one? %>
<%= turbo_stream.enable_all "select.quantity option[value='#{@quantity.id}']" %>
<%= render partial: 'form_repath' %>
<%= turbo_stream.unselect dom_id(@quantity) %>

View File

@@ -1,8 +1,9 @@
<% @readouts.each do |r| %>
<%= turbo_stream.disable dom_id(r.quantity) %>
<% end %>
<%= render partial: 'form_repath' %>
<%= turbo_stream.append :readouts do %>
<%# is .one? proper condition? can @readouts be empty? %>
<%= turbo_stream.enable_all 'button[name="discard"]' if @prev_quantities.one? %>
<% @readouts.each do |r| %>
<%= turbo_stream.disable_all "select.quantity option[value='#{r.quantity_id}']" %>
<% end %>
<%= turbo_stream.before :readouts_form do %>
<%= render partial: 'form', collection: @readouts, as: :readout %>
<% end %>
<%= turbo_stream.enable :create_measurement_button if @prev_quantities.empty? %>

View File

@@ -0,0 +1,39 @@
<%= form_with url: setup_path, method: :post, class: "labeled-form main-area" do %>
<h3 style="grid-column: 1 / -1; text-align: left; margin: 0;">
<%= t(".admin_account") %>
</h3>
<label for="admin_email"><%= t(".admin_email") %></label>
<%= email_field_tag :admin_email, params[:admin_email],
id: "admin_email", required: true, size: 30, autofocus: true,
autocomplete: "email" %>
<label for="admin_password"><%= t(".admin_password") %></label>
<%= password_field_tag :admin_password, nil,
id: "admin_password", required: true, size: 30,
autocomplete: "new-password" %>
<label for="admin_password_confirmation"><%= t(".admin_password_confirmation") %></label>
<%= password_field_tag :admin_password_confirmation, nil,
id: "admin_password_confirmation", required: true, size: 30,
autocomplete: "off" %>
<h3 style="grid-column: 1 / -1; text-align: left; margin: 0.5em 0 0 0;">
<%= t(".options") %>
</h3>
<label for="skip_email_confirmation" style="grid-column: 1 / 3; text-align: left;">
<%= check_box_tag :skip_email_confirmation, "1",
params[:skip_email_confirmation] == "1",
id: "skip_email_confirmation" %>
<%= t(".skip_email_confirmation") %>
</label>
<label for="seed_units" style="grid-column: 1 / 3; text-align: left;">
<%= check_box_tag :seed_units, "1", true, id: "seed_units" %>
<%= t(".seed_units") %>
</label>
<%= submit_tag t(".submit") %>
<% end %>

View File

@@ -8,11 +8,11 @@
<td>
<%= form.text_area :description, cols: 30, rows: 1, escape: false %>
</td>
<td>
<td class="number">
<%= form.number_field :multiplier, required: true, size: 10, min: :step if @unit.base_id? %>
</td>
<td class="flex">
<td class="actions">
<%= form.button %>
<%= image_link_to t(:cancel), "close-outline", units_path, class: 'dangerous',
name: :cancel, onclick: render_turbo_stream('form_close', {row: row}) %>

View File

@@ -6,15 +6,14 @@
drop_id: dom_id(unit.base || unit),
drop_id_param: "unit[base_id]"} do %>
<td style="--depth:<%= unit.base_id? ? 1 : 0 %>">
<%= link_to unit, edit_unit_path(unit), class: 'link', onclick: 'this.blur();',
data: {turbo_stream: true} %>
<td class="link" style="--depth:<%= unit.base_id? ? 1 : 0 %>">
<%= link_to unit, edit_unit_path(unit), onclick: 'this.blur();', data: {turbo_stream: true} %>
</td>
<td><%= unit.description %></td>
<td class="ralign"><%= unit.multiplier.to_html %></td>
<td class="number"><%= unit.multiplier.to_html %></td>
<% if current_user.at_least(:active) %>
<td class="flex">
<td class="actions">
<% unless unit.base_id? %>
<%= image_link_to t('.new_subunit'), 'plus-outline', new_unit_path(unit),
id: dom_id(unit, :new, :link), onclick: 'this.blur();', data: {turbo_stream: true} %>

View File

@@ -7,14 +7,13 @@
class: 'tools-area' %>
</div>
<%# TODO: remove? form can be inserted directly, e.g. at the end of index %>
<%= tag.div id: :unit_form %>
<table class="main-area items-table">
<table class="main-area items">
<thead>
<tr>
<th><%= Unit.human_attribute_name(:symbol) %></th>
<th class="hexpand"><%= Unit.human_attribute_name(:description) %></th>
<th><%= Unit.human_attribute_name(:description) %></th>
<th><%= Unit.human_attribute_name(:multiplier) %></th>
<% if current_user.at_least(:active) %>
<th><%= t :actions %></th>

View File

@@ -1,4 +1,4 @@
<table class="main-area items-table" id="users">
<table class="main-area items" id="users">
<thead>
<tr>
<th><%= User.human_attribute_name(:email) %></th>
@@ -11,7 +11,7 @@
<tbody>
<% @users.each do |user| %>
<tr>
<td><%= link_to user, user_path(user), class: 'link' %></td>
<td class="link"><%= link_to user, user_path(user) %></td>
<td>
<% if user == current_user %>
<%= user.status %>
@@ -22,11 +22,11 @@
<% end %>
<% end %>
</td>
<td>
<td class="svg">
<%= svg_tag 'pictograms/checkbox-marked-outline' if user.confirmed_at.present? %>
</td>
<td><%= l user.created_at, format: :without_tz %></td>
<td class="flex">
<td class="actions">
<% if allow_disguise?(user) %>
<%= image_link_to t('.disguise'), 'incognito', disguise_user_path(user) %>
<% end %>

View File

@@ -4,9 +4,8 @@
<% end %>
<div class="rightside-area buttongrid">
<%#= TODO: Disallow/disable deletion for last admin account, image_button_to_if %>
<%= image_button_to t('.delete'), 'account-remove-outline', user_registration_path,
form_class: 'tools-area', method: :delete, data: {turbo: false},
<%= image_button_to_if !current_user.sole_admin?, t('.delete'), 'account-remove-outline',
user_registration_path, form_class: 'tools-area', method: :delete, data: {turbo: false},
onclick: {confirm: t('.confirm_delete')} %>
</div>

View File

@@ -10,6 +10,7 @@
<%= f.submit t(:register), data: {turbo: false} %>
<%# TODO: fix button text color after change link -> button %>
<%= image_button_tag t(:resend_confirmation), 'email-sync-outline',
class: 'auxiliary', formaction: user_confirmation_path, formnovalidate: true,
data: {validate: f.field_id(:email)} %>

View File

@@ -8,7 +8,7 @@
<%= f.email_field :email, autofocus: true, autocomplete: "email" %>
</div>
<div class="flex">
<div class="actions">
<%= f.submit "Resend unlock instructions" %>
</div>
<% end %>

6
bin/ci
View File

@@ -1,6 +0,0 @@
#!/usr/bin/env ruby
require_relative "../config/boot"
require "active_support/continuous_integration"
CI = ActiveSupport::ContinuousIntegration
require_relative "../config/ci.rb"

View File

@@ -1,2 +0,0 @@
#!/usr/bin/env ruby
exec "./bin/rails", "server", *ARGV

0
bin/fixinme.service.dist Executable file → Normal file
View File

View File

@@ -1,10 +1,11 @@
#!/usr/bin/env ruby
require "fileutils"
# path to your application root.
APP_ROOT = File.expand_path("..", __dir__)
def system!(*args)
system(*args, exception: true)
system(*args) || abort("\n== Command #{args} failed ==")
end
FileUtils.chdir APP_ROOT do
@@ -13,6 +14,7 @@ FileUtils.chdir APP_ROOT do
# Add necessary setup steps to this file.
puts "== Installing dependencies =="
system! "gem install bundler --conservative"
system("bundle check") || system!("bundle install")
# puts "\n== Copying sample files =="
@@ -22,14 +24,10 @@ FileUtils.chdir APP_ROOT do
puts "\n== Preparing database =="
system! "bin/rails db:prepare"
system! "bin/rails db:reset" if ARGV.include?("--reset")
puts "\n== Removing old logs and tempfiles =="
system! "bin/rails log:clear tmp:clear"
unless ARGV.include?("--skip-server")
puts "\n== Starting development server =="
STDOUT.flush # flush the output before exec(2) so that it displays
exec "bin/dev"
end
puts "\n== Restarting application server =="
system! "bin/rails restart"
end

View File

@@ -18,17 +18,14 @@ require "rails/test_unit/railtie"
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)
require_relative '../lib/default_settings_strategy'
module FixinMe
class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 8.1
config.load_defaults 7.0
# Please, add to the `ignore` list any other `lib` subdirectories that do
# not contain `.rb` files, or that should not be reloaded or eager loaded.
# Common ones are `templates`, `generators`, or `middleware`, for example.
config.autoload_lib(ignore: %w[assets tasks])
# Autoload lib/, required e.g. for core library extensions.
# https://guides.rubyonrails.org/autoloading_and_reloading_constants.html#config-autoload-lib-ignore.
config.autoload_lib(ignore: %w(assets tasks))
# Configuration for the application, engines, and railties goes here.
#
@@ -41,24 +38,25 @@ module FixinMe
config.action_dispatch.rescue_responses['ApplicationController::AccessForbidden'] = :forbidden
config.action_dispatch.rescue_responses['ApplicationController::ParameterInvalid'] = :unprocessable_entity
# Set default migrations parameters.
config.active_record.migration_strategy = DefaultSettingsStrategy
# SETUP: Below settings need to be updated on a per-installation basis.
#
# Set host to be used by links generated in mailer templates.
config.action_mailer.default_url_options = {host: 'localhost', protocol: 'https'}
# URL to use in sent e-mails.
config.action_mailer.default_url_options = {host: 'localhost', :protocol => 'https'}
# https://guides.rubyonrails.org/configuring.html#config-action-mailer-delivery-method
config.action_mailer.delivery_method = :sendmail
# List of hosts this app is available at.
# https://guides.rubyonrails.org/configuring.html#actiondispatch-hostauthorization
config.hosts |= ['localhost']
config.hosts += ['localhost', 'example.com', IPAddr.new('1.2.3.4/32')]
# Email address of admin account
config.admin = 'admin@localhost'
# Sender address of account registration-related messages
Devise.mailer_sender = 'noreply@localhost'
# Whether to skip e-mail confirmation for new registrations is configured
# through the web setup wizard and stored in the database (Setting model),
# so it does not need to be set here.
end
end

View File

@@ -1,20 +0,0 @@
# Run using bin/ci
CI.run do
step "Setup", "bin/setup --skip-server"
step "Security: Importmap vulnerability audit", "bin/importmap audit"
step "Tests: Rails", "bin/rails test"
step "Tests: Seeds", "env RAILS_ENV=test bin/rails db:seed:replant"
# Optional: Run system tests
# step "Tests: System", "bin/rails test:system"
# Optional: set a green GitHub commit status to unblock PR merge.
# Requires the `gh` CLI and `gh extension install basecamp/gh-signoff`.
# if success?
# step "Signoff: All systems go. Ready for merge and deploy.", "gh signoff"
# else
# failure "Signoff: CI failed. Do not merge or deploy.", "Fix the issues and try again."
# end
end

View File

@@ -1 +1 @@
3nm9KZNtyLhPgZBVzOOkN2FXHD0uEMuzgb5Sl1MrAMmi6+iEFSzyTHfZFW2mz18VyNz5DDYvTODZqBDQKK+FQh70uEQkmGqaY5XsTOzUFzk56quaPNtZvFEGux1nX2avSbYQBs3HeyYyWyTAFhez5j8tVb6sZD2xZ8twa9KAB42j86NIHT9w/ZMFqZbGbdBoR1Mrqoy9/IWv2QgxMTpGR6JBpTUwauXm6wS/bTt8SCXF57JSVgvdw/BxFzoA3Xj6N5E89LbMfh54W2ruMhybka5E7zXN9z0v4oXt8GiYZFIODEYZwqzEVaUK1WXS5qb5OrDJFAzs29Uf/gDrIDx71Lot+jejCS+xFfI9454EnHcVH66wKuwF6ylKupJDffM0hQHplcEfVSq5UiDfbPXm46Vr0g1A--2RrmuzCBuHvYpPNA--ugbuRe7ivfDqeUCt6ahciA==
OOGMGhfQuV67kqlMecZLcNfgrGS81KPAGmY27GnohtcGSPtiaqL8OZYsVf5IIOaI1K14ZEflln+E2deaIJ5apaq98f+1gJawGbAJeEfLCskJV/03nT8ICpRk+bxT/lzqeCIaUJOLk4708ufC9EpdpJD/jgVSAuI/iNMzzwMbNFvqNmx0Kmgp0mRpHSDGLZZkaP3GW7wdsJEVsNpSPIrkkGL1BvD+nHbmHjuGkn4MMsmm1Yz0M31jkJiDksT0SVeOcxWvOApclxm6VZOAws2l6YKEs/XoE7ye3ssjxdjdwjMzRXV7dwYclNBQGRoERVTozdYiFR4eAGMdlG0RsnUAp+edILH5nvHCIPb3la/dUTOzAQMNY0TqMMVUHGqGuVS/EMCX/w7zrmYN5C+2W8SfugrvTpAL--dUdvsDfbUdVrbmmo--fUwsWUp+DQGPtEF+Zq4ZTw==

View File

@@ -24,45 +24,27 @@
# Read https://guides.rubyonrails.org/configuring.html#configuring-a-database
# for a full overview on how database connection configuration can be specified.
default: &default
pool: <%= ENV.fetch('RAILS_MAX_THREADS', 3) %>
#mysql_default: &mysql_default
# <<: *default
# username: fixinme
# password: Some-password1%
# host: 127.0.0.1
# encoding: utf8mb4
# collation: utf8mb4_0900_as_ci
adapter: mysql2
encoding: utf8mb4
collation: utf8mb4_0900_as_ci
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
username: fixinme
password: Some-password1%
socket: /run/mysqld/mysqld.sock
production:
<<: *default
adapter: sqlite3
database: db/production.sqlite3
database: fixinme
# Unless you're planning on developing the application, you can skip/remove
# Unless you're planning on developing the application, you can skip
# configurations for development and test databases altogether.
#development:
# <<: *mysql_default
# adapter: mysql2
# <<: *default
# database: fixinme_dev
# Warning: The database(s) defined as "test" will be erased and
# Warning: The database defined as "test" will be erased and
# re-generated from your development database when you run "rake".
# Do not set this db to the same as development or production.
#test:
# <<: *mysql_default
# adapter: mysql2
# <<: *default
# database: fixinme_test
# Multiple test databases can be provided. When more than one test db is
# present, every test task (test, test:models, test:system, etc.) runs against
# all of them.
#test:
# mysql2:
# <<: *mysql_default
# adapter: mysql2
# database: fixinme_test
# sqlite3:
# <<: *default
# adapter: sqlite3
# database: db/test.sqlite3

View File

@@ -3,8 +3,10 @@ require "active_support/core_ext/integer/time"
Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb.
# Make code changes take effect immediately without server restart.
config.enable_reloading = true
# In the development environment your application's code is reloaded any time
# it changes. This slows down response time but is perfect for development
# since you don't have to restart the web server when you make code changes.
config.cache_classes = false
# Do not eager load code on boot.
config.eager_load = false
@@ -12,52 +14,54 @@ Rails.application.configure do
# Show full error reports.
config.consider_all_requests_local = true
# Enable server timing.
# Enable server timing
config.server_timing = true
# Enable/disable Action Controller caching. By default Action Controller caching is disabled.
# Run rails dev:cache to toggle Action Controller caching.
# Enable/disable caching. By default caching is disabled.
# Run rails dev:cache to toggle caching.
if Rails.root.join("tmp/caching-dev.txt").exist?
config.action_controller.perform_caching = true
config.action_controller.enable_fragment_cache_logging = true
config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" }
config.cache_store = :memory_store
config.public_file_server.headers = {
"Cache-Control" => "public, max-age=#{2.days.to_i}"
}
else
config.action_controller.perform_caching = false
end
# Change to :null_store to avoid any caching.
config.cache_store = :memory_store
config.cache_store = :null_store
end
# Don't care if the mailer can't send.
config.action_mailer.raise_delivery_errors = false
# Make template changes take effect immediately.
config.action_mailer.perform_caching = false
# Print deprecation notices to the Rails logger.
config.active_support.deprecation = :log
# Raise exceptions for disallowed deprecations.
config.active_support.disallowed_deprecation = :raise
# Tell Active Support which deprecation messages to disallow.
config.active_support.disallowed_deprecation_warnings = []
# Raise an error on page load if there are pending migrations.
config.active_record.migration_error = :page_load
# Highlight code that triggered database queries in logs.
config.active_record.verbose_query_logs = true
# Append comments with runtime information tags to SQL queries in logs.
config.active_record.query_log_tags_enabled = true
# Highlight code that enqueued background job in logs.
config.active_job.verbose_enqueue_logs = true
# Highlight code that triggered redirect in logs.
config.action_dispatch.verbose_redirect_logs = true
# Suppress logger output for asset requests.
config.assets.quiet = true
# Raises error for missing translations.
# config.i18n.raise_on_missing_translations = true
# Annotate rendered view with file names.
config.action_view.annotate_rendered_view_with_filenames = true
# config.action_view.annotate_rendered_view_with_filenames = true
# Raise error when a before_action's only/except options reference missing actions.
config.action_controller.raise_on_missing_callback_actions = true
# Uncomment if you wish to allow Action Cable access from any origin.
# config.action_cable.disable_request_forgery_protection = true
end

View File

@@ -4,83 +4,78 @@ Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb.
# Code is not reloaded between requests.
config.enable_reloading = false
config.cache_classes = true
# Eager load code on boot for better performance and memory savings (ignored by Rake tasks).
# Eager load code on boot. This eager loads most of Rails and
# your application in memory, allowing both threaded web servers
# and those relying on copy on write to perform better.
# Rake tasks automatically ignore this option for performance.
config.eager_load = true
# Full error reports are disabled.
config.consider_all_requests_local = false
# Turn on fragment caching in view templates.
# Full error reports are disabled and caching is turned on.
config.consider_all_requests_local = false
config.action_controller.perform_caching = true
# Cache assets for far-future expiry since they are all digest stamped.
config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" }
# Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"]
# or in config/master.key. This key is used to decrypt credentials (and other encrypted files).
# config.require_master_key = true
# Disable serving static files from the `/public` folder by default since
# Apache or NGINX already handles this.
config.public_file_server.enabled = ENV["RAILS_SERVE_STATIC_FILES"].present?
# Compress CSS using a preprocessor.
# config.assets.css_compressor = :sass
# Do not fallback to assets pipeline if a precompiled asset is missed.
config.assets.compile = false
# Enable serving of images, stylesheets, and JavaScripts from an asset server.
# config.asset_host = "http://assets.example.com"
# Assume all access to the app is happening through a SSL-terminating reverse proxy.
config.assume_ssl = true
# Specifies the header that your server uses for sending files.
# config.action_dispatch.x_sendfile_header = "X-Sendfile" # for Apache
# config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX
# Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
config.force_ssl = true
# config.force_ssl = true
# Skip http-to-https redirect for the default health check endpoint.
# config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } }
# Include generic and useful information about system operation, but avoid logging too much
# information to avoid inadvertent exposure of personally identifiable information (PII).
config.log_level = :info
# Log to STDOUT with the current request id as a default log tag.
# Prepend all log lines with the following tags.
config.log_tags = [ :request_id ]
config.logger = ActiveSupport::TaggedLogging.logger(STDOUT)
# Change to "debug" to log everything (including potentially personally-identifiable information!).
config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info")
# Prevent health checks from clogging up the logs.
config.silence_healthcheck_path = "/up"
# Don't log any deprecations.
config.active_support.report_deprecations = false
# Replace the default in-process memory cache store with a durable alternative.
# Use a different cache store in production.
# config.cache_store = :mem_cache_store
# Replace the default in-process and non-durable queuing backend for Active Job.
# config.active_job.queue_adapter = :resque
config.action_mailer.perform_caching = false
# Ignore bad email addresses and do not raise email delivery errors.
# Set this to true and configure the email server for immediate delivery to raise delivery errors.
# config.action_mailer.raise_delivery_errors = false
# Specify outgoing SMTP server. Remember to add smtp/* credentials via bin/rails credentials:edit.
# config.action_mailer.smtp_settings = {
# user_name: Rails.application.credentials.dig(:smtp, :user_name),
# password: Rails.application.credentials.dig(:smtp, :password),
# address: "smtp.example.com",
# port: 587,
# authentication: :plain
# }
# Enable locale fallbacks for I18n (makes lookups for any locale fall back to
# the I18n.default_locale when a translation cannot be found).
config.i18n.fallbacks = true
# Don't log any deprecations.
config.active_support.report_deprecations = false
# Use default logging formatter so that PID and timestamp are not suppressed.
config.log_formatter = ::Logger::Formatter.new
# Use a different logger for distributed setups.
# require "syslog/logger"
# config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new "app-name")
if ENV["RAILS_LOG_TO_STDOUT"].present?
logger = ActiveSupport::Logger.new(STDOUT)
logger.formatter = config.log_formatter
config.logger = ActiveSupport::TaggedLogging.new(logger)
end
# Do not dump schema after migrations.
config.active_record.dump_schema_after_migration = false
# Only use :id for inspections in production.
config.active_record.attributes_for_inspect = [ :id ]
# Enable DNS rebinding protection and other `Host` header attacks.
# config.hosts = [
# "example.com", # Allow requests from example.com
# /.*\.example\.com/ # Allow requests from subdomains like `www.example.com`
# ]
#
# Skip DNS rebinding protection for the default health check endpoint.
# config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
# Protect e-mail addresses from being logged only in production.
config.filter_parameters += [:email]
end

View File

@@ -1,3 +1,5 @@
require "active_support/core_ext/integer/time"
# The test environment is used exclusively to run your application's
# test suite. You never need to work with it otherwise. Remember that
# your test database is "scratch space" for the test suite and is wiped
@@ -6,49 +8,57 @@
Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb.
# While tests run files are not watched, reloading is not necessary.
config.enable_reloading = false
# Turn false under Spring and add config.action_view.cache_template_loading = true.
config.cache_classes = true
# Eager loading loads your entire application. When running a single test locally,
# this is usually not necessary, and can slow down your test suite. However, it's
# recommended that you enable it in continuous integration systems to ensure eager
# loading is working properly before deploying your code.
# Eager loading loads your whole application. When running a single test locally,
# this probably isn't necessary. It's a good idea to do in a continuous integration
# system, or in some way before deploying your code.
config.eager_load = ENV["CI"].present?
# Configure public file server for tests with cache-control for performance.
config.public_file_server.headers = { "cache-control" => "public, max-age=3600" }
# Configure public file server for tests with Cache-Control for performance.
config.public_file_server.enabled = true
config.public_file_server.headers = {
"Cache-Control" => "public, max-age=#{1.hour.to_i}"
}
# Behave as in `production`.
config.consider_all_requests_local = false
# Hide full error reports.
config.consider_all_requests_local = false
# Render exception templates instead of raising exceptions.
config.action_dispatch.show_exceptions = :all
# Disable caching.
config.action_controller.perform_caching = false
config.cache_store = :null_store
# Disable request forgery protection in test environment.
config.action_controller.allow_forgery_protection = false
config.action_mailer.perform_caching = false
# Tell Action Mailer not to deliver emails to the real world.
# The :test delivery method accumulates sent emails in the
# ActionMailer::Base.deliveries array.
config.action_mailer.delivery_method = :test
# Set host to be used by links generated in mailer templates.
config.action_mailer.default_url_options = {host: Capybara.server_host,
protocol: 'http'}
config.action_mailer.default_url_options = {host: '127.0.0.1', :protocol => 'http'}
# Print deprecation notices to the stderr.
config.active_support.deprecation = :stderr
# Raise exceptions for disallowed deprecations.
config.active_support.disallowed_deprecation = :raise
# Tell Active Support which deprecation messages to disallow.
config.active_support.disallowed_deprecation_warnings = []
# Raises error for missing translations.
config.i18n.raise_on_missing_translations = true
# Annotate rendered view with file names.
# config.action_view.annotate_rendered_view_with_filenames = true
# Raise error when a before_action's only/except options reference missing actions.
config.action_controller.raise_on_missing_callback_actions = true
# Allow Capybara application server IP
config.hosts |= [IPAddr.new(Capybara.server_host)]
config.log_level = :info
# Allow the default integration test host.
config.hosts << "www.example.com"
end

View File

@@ -5,3 +5,8 @@ Rails.application.config.assets.version = "1.0"
# Add additional assets to the asset load path.
# Rails.application.config.assets.paths << Emoji.images_path
# Precompile additional assets.
# application.js, application.css, and all non-JS/CSS in the app/assets
# folder are already added.
Rails.application.config.assets.precompile += %w( '*.svg' )

View File

@@ -16,13 +16,9 @@
# # policy.report_uri "/csp-violation-report-endpoint"
# end
#
# # Generate session nonces for permitted importmap, inline scripts, and inline styles.
# # Generate session nonces for permitted importmap and inline scripts
# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
# config.content_security_policy_nonce_directives = %w(script-src style-src)
#
# # Automatically add `nonce` to `javascript_tag`, `javascript_include_tag`, and `stylesheet_link_tag`
# # if the corresponding directives are specified in `content_security_policy_nonce_directives`.
# # config.content_security_policy_nonce_auto = true
# config.content_security_policy_nonce_directives = %w(script-src)
#
# # Report violations without enforcing the policy.
# # config.content_security_policy_report_only = true

View File

@@ -179,7 +179,7 @@ Devise.setup do |config|
# ==> Configuration for :validatable
# Range for password length.
config.password_length = 5..32
config.password_length = 5..128
# Email regex used to validate email formats. It simply asserts that
# one (and only one) @ exists in the given string. This is mainly

View File

@@ -1,8 +1,8 @@
# Be sure to restart your server when you modify this file.
# Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file.
# Use this to limit dissemination of sensitive information.
# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors.
# Configure parameters to be filtered from the log file. Use this to limit dissemination of
# sensitive information. See the ActiveSupport::ParameterFilter documentation for supported
# notations and behaviors.
Rails.application.config.filter_parameters += [
:passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc
:passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn
]

View File

@@ -85,10 +85,19 @@ en:
navigation: Measurements
no_items: There are no measurements taken. You can Add some now.
form:
select_quantity: select quantities...
taken_at_html: Measurement taken at&emsp;
select_quantity: select the measured quantity...
index:
new_measurement: Add measurement
create:
success: Measurement saved.
destroy:
success: Measurement deleted.
measurement:
destroy: Delete
readouts:
form:
select_unit: ...
new_children: Children
quantities:
navigation: Quantities
no_items: There are no configured quantities. You can Add some or Import from defaults.
@@ -160,7 +169,27 @@ en:
New password:
<br><em>leave blank to keep unchanged</em>
%{password_length_hint_html}
registrations:
destroy:
sole_admin: You cannot delete the only admin account.
actions: Actions
setup:
new:
admin_account: Admin account
admin_email: 'E-mail:'
admin_password: 'Password:'
admin_password_confirmation: 'Retype password:'
options: Options
skip_email_confirmation: Skip e-mail confirmation for new registrations
seed_units: Seed built-in default units
submit: Set up
create:
email_blank: E-mail cannot be blank.
password_blank: Password cannot be blank.
password_mismatch: Passwords do not match.
success: >
Installation complete. You can now sign in with the admin account you
just created.
add: Add
apply: Apply
back: Back

View File

@@ -1,41 +1,43 @@
# This configuration file will be evaluated by Puma. The top-level methods that
# are invoked here are part of Puma's configuration DSL. For more information
# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html.
# Puma can serve each request in a thread from an internal thread pool.
# The `threads` method setting takes two numbers: a minimum and maximum.
# Any libraries that use thread pools should be configured to match
# the maximum value specified for Puma. Default is set to 5 threads for minimum
# and maximum; this matches the default thread size of Active Record.
#
# Puma starts a configurable number of processes (workers) and each process
# serves each request in a thread from an internal thread pool.
max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }
min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count }
threads min_threads_count, max_threads_count
# Specifies the `worker_timeout` threshold that Puma will use to wait before
# terminating a worker in development environments.
#
# You can control the number of workers using ENV["WEB_CONCURRENCY"]. You
# should only set this value when you want to run 2 or more workers. The
# default is already 1.
#
# The ideal number of threads per worker depends both on how much time the
# application spends waiting for IO operations and on how much you wish to
# prioritize throughput over latency.
#
# As a rule of thumb, increasing the number of threads will increase how much
# traffic a given process can handle (throughput), but due to CRuby's
# Global VM Lock (GVL) it has diminishing returns and will degrade the
# response time (latency) of the application.
#
# The default is set to 3 threads as it's deemed a decent compromise between
# throughput and latency for the average Rails application.
#
# Any libraries that use a connection pool or another resource pool should
# be configured to provide at least as many connections as the number of
# threads. This includes Active Record's `pool` parameter in `database.yml`.
threads_count = ENV.fetch("RAILS_MAX_THREADS", 3)
threads threads_count, threads_count
worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development"
# Specifies the `port` that Puma will listen on to receive requests; default is 3000.
port ENV.fetch("PORT", 3000), '127.0.0.1'
#
port ENV.fetch("PORT") { 3000 }
# Specifies the `environment` that Puma will run in.
#
environment ENV.fetch("RAILS_ENV") { "development" }
# Specifies the `pidfile` that Puma will use.
pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" }
# Specifies the number of `workers` to boot in clustered mode.
# Workers are forked web server processes. If using threads and workers together
# the concurrency of the application would be max `threads` * `workers`.
# Workers do not work on JRuby or Windows (both of which do not support
# processes).
#
# workers ENV.fetch("WEB_CONCURRENCY") { 2 }
# Use the `preload_app!` method when specifying a `workers` number.
# This directive tells Puma to first boot the application and load code
# before forking the application. This takes advantage of Copy On Write
# process behavior so workers use less memory.
#
# preload_app!
# Allow puma to be restarted by `bin/rails restart` command.
plugin :tmp_restart
# Run the Solid Queue supervisor inside of Puma for single-server deployments
plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"]
# Specify the PID file. Defaults to tmp/pids/server.pid in development.
# In other environments, only set the PID file if requested.
pidfile ENV["PIDFILE"] if ENV["PIDFILE"]

View File

@@ -1,6 +1,10 @@
Rails.application.routes.draw do
# Web-based installation wizard — only reachable when no admin exists yet.
resource :setup, only: [:new, :create], controller: :setup
resources :measurements
resources :readouts, only: [:new] do
collection {get 'new/:id/discard', action: :discard, as: :discard}
end
@@ -15,10 +19,7 @@ Rails.application.routes.draw do
namespace :default do
resources :units, only: [:index, :destroy] do
member {
post :import
post :export
}
member { post :import, :export }
#collection { post :import_all }
end
end

View File

@@ -1,11 +1,11 @@
class CreateUsers < ActiveRecord::Migration[8.1]
class CreateUsers < ActiveRecord::Migration[7.0]
def change
create_table :users do |t|
t.string :email, null: false, limit: 64
t.integer :status, null: false, default: 0
t.timestamps
t.timestamps null: false
end
add_index :users, :email
add_index :users, :email, unique: true
end
end

View File

@@ -1,4 +1,4 @@
class AddDeviseToUsers < ActiveRecord::Migration[8.1]
class AddDeviseToUsers < ActiveRecord::Migration[7.0]
def change
change_table :users do |t|
## NOTE: commented fields left for reference/inclusion in future migrations
@@ -34,8 +34,8 @@ class AddDeviseToUsers < ActiveRecord::Migration[8.1]
# t.integer :failed_attempts, default: 0, null: false
end
add_index :users, :reset_password_token
add_index :users, :confirmation_token
add_index :users, :reset_password_token, unique: true
add_index :users, :confirmation_token, unique: true
# add_index :users, :unlock_token, unique: true
end
end

View File

@@ -1,15 +1,14 @@
class CreateUnits < ActiveRecord::Migration[8.1]
class CreateUnits < ActiveRecord::Migration[7.0]
def change
create_table :units do |t|
t.references :user, foreign_key: {on_delete: :cascade}
t.references :user, foreign_key: true
t.string :symbol, null: false, limit: 15
t.text :description
t.decimal :multiplier, null: false, precision: 30, scale: 15, default: 1.0
t.references :base, foreign_key: {to_table: :units, on_delete: :cascade}
t.timestamps
t.timestamps null: false
end
add_index :units, [:user_id, :symbol]
add_index :units, [:id, :user_id]
add_index :units, [:user_id, :symbol], unique: true
end
end

View File

@@ -1,18 +1,17 @@
class CreateQuantities < ActiveRecord::Migration[8.1]
class CreateQuantities < ActiveRecord::Migration[7.2]
def change
create_table :quantities do |t|
t.references :user, foreign_key: {on_delete: :cascade}
t.references :user, foreign_key: true
t.string :name, null: false, limit: 31
t.text :description
t.references :parent, foreign_key: {to_table: :quantities, on_delete: :cascade}
t.timestamps
t.timestamps null: false
# Caches; can be computed from other attributes
t.integer :depth, null: false, default: 0
t.string :pathname, null: false, limit: 511
end
add_index :quantities, [:user_id, :parent_id, :name]
add_index :quantities, [:id, :user_id]
add_index :quantities, [:user_id, :parent_id, :name], unique: true
end
end

View File

@@ -1,69 +1,15 @@
class CreateReadouts < ActiveRecord::Migration[8.1]
class CreateReadouts < ActiveRecord::Migration[7.2]
def change
create_table :notes do |t|
t.text :text, null: false
t.timestamps
end
create_table :measurements do |t|
t.datetime :taken_at, null: false
create_table :readouts do |t|
t.references :user, null: false, foreign_key: true
t.references :quantity, null: false, foreign_key: true
t.references :unit, foreign_key: true
t.decimal :value, null: false, precision: 30, scale: 15
#t.references :collector, foreign_key: true
#t.references :device, foreign_key: true
t.references :note, foreign_key: {on_delete: :nullify}
t.timestamps
t.timestamps null: false
end
add_index :measurements, :taken_at
# Defining Readouts as a super-/subclass polymorphic relations for different
# subclass data types (numeric, string, file) is not possible with proper
# referential integrity constraints. The required constraints are:
# * for every subclass record to have superclass record,
# * for every superclass record to have only one type of subclass record,
# * for every superclass record to have subclass record (unenforcable).
# * this can be partially remedied by making superlass an abstract class in
# Rails and disallow direct creation of records, but direct data
# manipulation can still break referential integrity.
# Defining separate {Numeric,Text,File}_Readouts tables would make the
# unique index constraint unenforcable.
create_table :readouts do |t|
t.references :user, null: false, foreign_key: {on_delete: :cascade}
t.references :measurement, foreign_key: {on_delete: :cascade}
t.references :quantity, null: false, foreign_key: {on_delete: :cascade}
t.integer :category, null: false, default: 0
t.float :value, null: false, limit: Float::MANT_DIG
t.references :unit, null: false, foreign_key: {on_delete: :cascade}
# TODO: consider additional columns to allow wider range of value types
# t.text :text
# t.datetime :time
# t.references :file
# Possibly mutually exclusive with :unit or check constraint for:
# :unit is not null <=> :value is not null
t.timestamps
end
add_index :readouts, [:measurement_id, :quantity_id, :category]
add_foreign_key :readouts, :quantities, column: [:quantity_id, :user_id],
primary_key: [:id, :user_id]
add_foreign_key :readouts, :units, column: [:unit_id, :user_id],
primary_key: [:id, :user_id]
# TODO: remove below tables after current setup verified
#create_table :numeric_values do |t|
# t.references :readout, null: false, foreign_key: {on_delete: :cascade}
# t.float :value, null: false, limit: Float::MANT_DIG
# t.references :unit, null: false, foreign_key: {on_delete: :cascade}
# # + generated, not stored column :value_type
# # + foreign key constraint to readouts: [:readout_id, :value_id, :value_type]
# # or 2 constraints: [:readout_id, :value_id], [:value_id, :value_type]
# # if readouts.value_id needed, otherwise just one constraint:
# # [:readout_id, :value_type]
#end
#create_table :string_values do |t|
# t.references :readout, null: false, foreign_key: {on_delete: :cascade}
# t.string :value, null: false, limit: 32
#end
add_index :readouts, [:quantity_id, :created_at], unique: true
end
end

View File

@@ -0,0 +1,12 @@
class CreateSettings < ActiveRecord::Migration[7.2]
def change
create_table :settings do |t|
t.string :key, null: false
t.string :value
t.timestamps
end
add_index :settings, :key, unique: true
end
end

View File

@@ -10,22 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2025_01_21_230456) do
create_table "measurements", charset: "utf8mb4", collation: "utf8mb4_0900_as_ci", force: :cascade do |t|
t.datetime "taken_at", null: false
t.bigint "note_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["note_id"], name: "index_measurements_on_note_id"
t.index ["taken_at"], name: "index_measurements_on_taken_at", unique: true
end
create_table "notes", charset: "utf8mb4", collation: "utf8mb4_0900_as_ci", force: :cascade do |t|
t.text "text", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
ActiveRecord::Schema[7.2].define(version: 2025_01_21_230456) do
create_table "quantities", charset: "utf8mb4", collation: "utf8mb4_0900_as_ci", force: :cascade do |t|
t.bigint "user_id"
t.string "name", limit: 31, null: false
@@ -35,7 +20,6 @@ ActiveRecord::Schema[8.1].define(version: 2025_01_21_230456) do
t.datetime "updated_at", null: false
t.integer "depth", default: 0, null: false
t.string "pathname", limit: 511, null: false
t.index ["id", "user_id"], name: "index_quantities_on_id_and_user_id", unique: true
t.index ["parent_id"], name: "index_quantities_on_parent_id"
t.index ["user_id", "parent_id", "name"], name: "index_quantities_on_user_id_and_parent_id_and_name", unique: true
t.index ["user_id"], name: "index_quantities_on_user_id"
@@ -43,18 +27,13 @@ ActiveRecord::Schema[8.1].define(version: 2025_01_21_230456) do
create_table "readouts", charset: "utf8mb4", collation: "utf8mb4_0900_as_ci", force: :cascade do |t|
t.bigint "user_id", null: false
t.bigint "measurement_id"
t.bigint "quantity_id", null: false
t.integer "category", default: 0, null: false
t.float "value", limit: 53, null: false
t.bigint "unit_id", null: false
t.bigint "unit_id"
t.decimal "value", precision: 30, scale: 15, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["measurement_id", "quantity_id", "category"], name: "index_readouts_on_measurement_id_and_quantity_id_and_category", unique: true
t.index ["measurement_id"], name: "index_readouts_on_measurement_id"
t.index ["quantity_id", "user_id"], name: "fk_rails_9d92eaafc6"
t.index ["quantity_id", "created_at"], name: "index_readouts_on_quantity_id_and_created_at", unique: true
t.index ["quantity_id"], name: "index_readouts_on_quantity_id"
t.index ["unit_id", "user_id"], name: "fk_rails_348b0fb4c5"
t.index ["unit_id"], name: "index_readouts_on_unit_id"
t.index ["user_id"], name: "index_readouts_on_user_id"
end
@@ -68,7 +47,6 @@ ActiveRecord::Schema[8.1].define(version: 2025_01_21_230456) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["base_id"], name: "index_units_on_base_id"
t.index ["id", "user_id"], name: "index_units_on_id_and_user_id", unique: true
t.index ["user_id", "symbol"], name: "index_units_on_user_id_and_symbol", unique: true
t.index ["user_id"], name: "index_units_on_user_id"
end
@@ -91,15 +69,11 @@ ActiveRecord::Schema[8.1].define(version: 2025_01_21_230456) do
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
end
add_foreign_key "measurements", "notes", on_delete: :nullify
add_foreign_key "quantities", "quantities", column: "parent_id", on_delete: :cascade
add_foreign_key "quantities", "users", on_delete: :cascade
add_foreign_key "readouts", "measurements", on_delete: :cascade
add_foreign_key "readouts", "quantities", column: ["quantity_id", "user_id"], primary_key: ["id", "user_id"]
add_foreign_key "readouts", "quantities", on_delete: :cascade
add_foreign_key "readouts", "units", column: ["unit_id", "user_id"], primary_key: ["id", "user_id"]
add_foreign_key "readouts", "units", on_delete: :cascade
add_foreign_key "readouts", "users", on_delete: :cascade
add_foreign_key "quantities", "users"
add_foreign_key "readouts", "quantities"
add_foreign_key "readouts", "units"
add_foreign_key "readouts", "users"
add_foreign_key "units", "units", column: "base_id", on_delete: :cascade
add_foreign_key "units", "users", on_delete: :cascade
add_foreign_key "units", "users"
end

View File

@@ -3,25 +3,32 @@
# bin/rails db:seed
# command (or created alongside the database with db:setup).
# Seeding process should be idempotent.
#
# Admin account setup
# -------------------
# The preferred way to create the first admin account is through the web setup
# wizard, which is shown automatically on the first visit when no admin exists.
# The wizard also lets you configure runtime options (e.g. skip e-mail
# confirmation) and seed the default units without using the command line.
#
# The block below provides an alternative CLI path for headless / automated
# deployments. It is skipped when an admin account already exists (e.g. after
# the web wizard has run).
User.transaction do
break if User.find_by status: :admin
email = Rails.configuration.admin
password_length = SecureRandom.rand(Rails.configuration.devise.password_length)
password = SecureRandom.alphanumeric(password_length)
User.create!(email: email, password: password, status: :admin) do |user|
User.create! email: Rails.configuration.admin, password: 'admin', status: :admin do |user|
user.skip_confirmation!
print "Creating #{user.status} account '#{user.email}'" \
" with password '#{user.password}'..."
print "Creating #{user.status} account '#{user.email}' with password '#{user.password}'..."
end
puts "done."
rescue ActiveRecord::RecordInvalid => exception
puts "failed.", exception.message
puts "failed. #{exception.message}"
end
# Formulas will be deleted as dependent on Quantities
#[Source, Quantity, Unit].each { |model| model.defaults.delete_all }
load "db/seeds/units.rb"
require_relative 'seeds/units.rb'

View File

@@ -1,103 +0,0 @@
# This file is auto-generated from the current state of the database. Instead
# of editing this file, please use the migrations feature of Active Record to
# incrementally modify your database, and then regenerate this schema definition.
#
# This file is the source Rails uses to define your schema when running `bin/rails
# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
# be faster and is potentially less error prone than running all of your
# migrations from scratch. Old migrations may fail to apply correctly if those
# migrations use external dependencies or application code.
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2025_01_21_230456) do
create_table "measurements", force: :cascade do |t|
t.datetime "taken_at", null: false
t.integer "note_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["note_id"], name: "index_measurements_on_note_id"
t.index ["taken_at"], name: "index_measurements_on_taken_at", unique: true
end
create_table "notes", force: :cascade do |t|
t.text "text", limit: 65535, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
create_table "quantities", force: :cascade do |t|
t.integer "user_id"
t.string "name", limit: 31, null: false
t.text "description", limit: 65535
t.integer "parent_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "depth", default: 0, null: false
t.string "pathname", limit: 511, null: false
t.index ["id", "user_id"], name: "index_quantities_on_id_and_user_id", unique: true
t.index ["parent_id"], name: "index_quantities_on_parent_id"
t.index ["user_id", "parent_id", "name"], name: "index_quantities_on_user_id_and_parent_id_and_name", unique: true
t.index ["user_id"], name: "index_quantities_on_user_id"
end
create_table "readouts", force: :cascade do |t|
t.integer "user_id", null: false
t.integer "measurement_id"
t.integer "quantity_id", null: false
t.integer "category", default: 0, null: false
t.float "value", limit: 53, null: false
t.integer "unit_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["measurement_id", "quantity_id", "category"], name: "index_readouts_on_measurement_id_and_quantity_id_and_category", unique: true
t.index ["measurement_id"], name: "index_readouts_on_measurement_id"
t.index ["quantity_id"], name: "index_readouts_on_quantity_id"
t.index ["unit_id"], name: "index_readouts_on_unit_id"
t.index ["user_id"], name: "index_readouts_on_user_id"
end
create_table "units", force: :cascade do |t|
t.integer "user_id"
t.string "symbol", limit: 15, null: false
t.text "description", limit: 65535
t.decimal "multiplier", precision: 30, scale: 15, default: "1.0", null: false
t.integer "base_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["base_id"], name: "index_units_on_base_id"
t.index ["id", "user_id"], name: "index_units_on_id_and_user_id", unique: true
t.index ["user_id", "symbol"], name: "index_units_on_user_id_and_symbol", unique: true
t.index ["user_id"], name: "index_units_on_user_id"
end
create_table "users", force: :cascade do |t|
t.string "email", limit: 64, null: false
t.integer "status", default: 0, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "encrypted_password", default: "", null: false
t.string "reset_password_token"
t.datetime "reset_password_sent_at"
t.datetime "remember_created_at"
t.string "confirmation_token"
t.datetime "confirmed_at"
t.datetime "confirmation_sent_at"
t.string "unconfirmed_email", limit: 64
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
end
add_foreign_key "measurements", "notes", on_delete: :nullify
add_foreign_key "quantities", "quantities", column: "parent_id", on_delete: :cascade
add_foreign_key "quantities", "users", on_delete: :cascade
add_foreign_key "readouts", "measurements", on_delete: :cascade
add_foreign_key "readouts", "quantities", column: ["quantity_id", "user_id"], primary_key: ["id", "user_id"]
add_foreign_key "readouts", "quantities", on_delete: :cascade
add_foreign_key "readouts", "units", column: ["unit_id", "user_id"], primary_key: ["id", "user_id"]
add_foreign_key "readouts", "units", on_delete: :cascade
add_foreign_key "readouts", "users", on_delete: :cascade
add_foreign_key "units", "units", column: "base_id", on_delete: :cascade
add_foreign_key "units", "users", on_delete: :cascade
end

View File

@@ -1,55 +0,0 @@
class DefaultSettingsStrategy < ActiveRecord::Migration::DefaultStrategy
COLUMN_DEFAULTS = {
# TODO: all types `null: false` ?
# TODO: references `foreign_key: {on_delete: :cascade}` ?
# `timestamps` - Rails defaults to `null: false, precision: true`.
# `limit:` for `text` - `text` can be theoretically up to 1GB or
# longer, so roughly unlimited for practical purposes. But:
# * the actual usable length depends on additional factors, like compile
# time limits (`SQLITE_MAX_LENGTH` in SQLite), runtime settings
# (`max_allowed_packet` in MySQL) and probably other,
# * Rails does not report limit for `text` column types, unless it is
# explicitly set.
# The decision is to always set safe limit and enforce it by validations, to
# avoid surprises (e.g. text truncation) when saving to dabatase.
text: {limit: 2**16 - 1}
}
COLUMN_DEFAULTS.default = {}
COLUMN_DEFAULTS.freeze
module ColumnSettingsStrategy
def column(name, type, **options)
super(name, type, **COLUMN_DEFAULTS[type].merge(options))
end
end
ActiveRecord::ConnectionAdapters::TableDefinition.prepend ColumnSettingsStrategy
# `force: :cascade` - does nothing on MySQL, so `force:` is useless.
# `if_not_exists: true`/`if_exists: true` - without these options it's impossible
# to change migration status once migration fails partially. If `up` migration
# creates some, but not all objects, its status is not updated from `down` to
# `up`. Then it's impossible to migrate: a) up - due to `already exists`
# errors and b) down - due to migration status.
MIGRATION_DEFAULTS = {
add_check_constraint: {if_not_exists: true},
add_column: {if_not_exists: true},
add_foreign_key: {if_not_exists: true},
add_index: {if_not_exists: true, unique: true},
add_timestamps: {if_not_exists: true},
create_table: {if_not_exists: true},
drop_table: {if_exists: true},
remove_check_constraint: {if_exists: true},
remove_column: {if_exists: true},
remove_foreign_key: {if_exists: true},
remove_index: {if_exists: true},
remove_timestamps: {if_exists: true},
}
MIGRATION_DEFAULTS.default = {}
MIGRATION_DEFAULTS.freeze
def method_missing(method, *args, **kwargs, &)
conflicts = kwargs.has_key?(:force) ? [:if_not_exists, :if_exists] : []
defaults = MIGRATION_DEFAULTS[method.to_sym].except(*conflicts)
super(method, *args, **defaults.merge(kwargs), &)
end
end

View File

@@ -0,0 +1,50 @@
namespace :test do
desc "Run Rails tests against all supported database adapters (SQLite, MySQL)"
task :all_adapters do
# DATABASE_URL overrides the adapter from database.yml at runtime.
# MySQL requires the mysql2 gem: bundle install --with mysql
adapters = {
"SQLite" => {
"DATABASE_URL" => "sqlite3:db/test.sqlite3"
},
"MySQL" => {
"DATABASE_URL" => format(
"mysql2://%s:%s@%s/%s",
ENV.fetch("DATABASE_USERNAME", "root"),
ENV.fetch("DATABASE_PASSWORD", ""),
ENV.fetch("DATABASE_HOST", "127.0.0.1"),
ENV.fetch("DATABASE_NAME", "fixin_test")
)
}
}
failed = []
adapters.each do |name, extra_env|
puts "\n#{"=" * 60}"
puts " Running tests with #{name}"
puts "=" * 60
env = ENV.to_h.merge("RAILS_ENV" => "test").merge(extra_env)
# Reset test database; db:drop may fail on first run — that's fine
system(env, "bin/rails db:drop")
unless system(env, "bin/rails db:create db:schema:load")
failed << "#{name} (database setup)"
next
end
failed << name unless system(env, "bin/rails test")
end
puts "\n#{"=" * 60}"
if failed.any?
puts " FAILED: #{failed.join(", ")}"
puts "=" * 60
exit 1
else
puts " All adapters passed!"
puts "=" * 60
end
end
end

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -1,3 +0,0 @@
<svg width="512" height="512" xmlns="http://www.w3.org/2000/svg">
<circle cx="256" cy="256" r="256" fill="red"/>
</svg>

Before

Width:  |  Height:  |  Size: 122 B

View File

@@ -25,10 +25,9 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
user
end
def inject_button_to(inside, *button_options)
def inject_button_to(after, *button_options)
button = button_to *button_options
inside.evaluate_script("this.insertAdjacentHTML('beforeend', arguments[0]);",
button.html_safe)
evaluate_script("arguments[0].insertAdjacentHTML('beforeend', '#{button.html_safe}');", after)
end
# Allow skipping interpolations when translating for testing purposes
@@ -39,32 +38,6 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
end
alias :t :translate
DB_CONFIGS = ActiveRecord::Base.configurations.configs_for(env_name: "test")
TEST_CONFIGS = Hash.new(DB_CONFIGS.first.name.to_sym)
class << self
# NOTE: alternative to current solution is to create shards:
# ActiveRecord::Base.connects_to(
# shards: {mysql2: {writing: :mysql2}, sqlite3: {writing: :sqlite3}}
# )
# and use them in one of the following ways:
# * set config.active_record.shard_selector/shard_resolver
# * run tests within: ActiveRecord::Base.connected_to(shard: :sqlite3) { test_ }
# Remove this note once current solution is confirmed to work.
#
# Test block should not be modified here, as it would change its binding from
# instance level to class level.
if DB_CONFIGS.many?
def test(name, ...)
DB_CONFIGS.each do |config|
TEST_CONFIGS[super("#{config.name} #{name}", ...)] = config.name.to_sym
end
end
end
end
setup do
ActiveRecord::Base.establish_connection(TEST_CONFIGS[name.to_sym])
end
#def assert_stale(element)
# assert_raises(Selenium::WebDriver::Error::StaleElementReferenceError) { element.tag_name }
#end

View File

@@ -1,8 +1,12 @@
require "test_helper"
class Default::UnitsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in users(:alice)
end
test "should get index" do
get units_defaults_index_url
get default_units_url
assert_response :success
end
end

View File

@@ -0,0 +1,8 @@
require "test_helper"
class MeasurementsControllerTest < ActionDispatch::IntegrationTest
#test "should get index" do
# get measurements_index_url
# assert_response :success
#end
end

View File

@@ -0,0 +1,7 @@
require "test_helper"
class QuantitiesControllerTest < ActionDispatch::IntegrationTest
# test "the truth" do
# assert true
# end
end

View File

@@ -0,0 +1,18 @@
require "test_helper"
class RegistrationsControllerTest < ActionDispatch::IntegrationTest
test "sole admin cannot delete account" do
sign_in users(:admin)
delete user_registration_path
assert_redirected_to edit_user_registration_path
assert_equal t("registrations.destroy.sole_admin"), flash[:alert]
assert User.exists?(users(:admin).id)
end
test "non-admin can delete account" do
sign_in users(:alice)
assert_difference ->{ User.count }, -1 do
delete user_registration_path
end
end
end

View File

@@ -0,0 +1,7 @@
require "test_helper"
class UnitsControllerTest < ActionDispatch::IntegrationTest
# test "the truth" do
# assert true
# end
end

View File

@@ -1,10 +1,10 @@
require "test_helper"
# TODO: make sure tested actions are covered by system tests and remove all
# controller tests.
class UsersControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:one)
@admin = users(:admin)
@user = users(:alice)
sign_in @admin
end
test "should get index" do
@@ -12,39 +12,25 @@ class UsersControllerTest < ActionDispatch::IntegrationTest
assert_response :success
end
test "should get new" do
get new_user_url
assert_response :success
end
test "should create user" do
assert_difference("User.count") do
post users_url, params: { user: { email: @user.email, status: @user.status } }
end
assert_redirected_to user_url(User.last)
end
test "should show user" do
get user_url(@user)
assert_response :success
end
test "should get edit" do
get edit_user_url(@user)
assert_response :success
end
test "should update user" do
patch user_url(@user), params: { user: { email: @user.email, status: @user.status } }
assert_redirected_to user_url(@user)
patch user_url(@user), params: { user: { status: :restricted } }, as: :turbo_stream
assert_equal "restricted", @user.reload.status
end
test "should destroy user" do
assert_difference("User.count", -1) do
delete user_url(@user)
end
test "should not update self" do
patch user_url(@admin), params: { user: { status: :active } }, as: :turbo_stream,
headers: { "HTTP_REFERER" => users_url }
assert_response :redirect
end
assert_redirected_to users_url
test "should forbid non-admin" do
sign_in @user
get users_url
assert_response :forbidden
end
end

7
test/models/user_test.rb Normal file
View File

@@ -0,0 +1,7 @@
require "test_helper"
class UserTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

View File

@@ -1,20 +0,0 @@
require 'application_system_test_case'
Rails.application.load_tasks
# NOTE: for some reason task for checking pending migrations messes up
# transaction when run during test. It causes all DB changes made before its
# execution to be rolled back.
# Run it before tests, so any rake task dependent on it will see it as
# #already_invoked and won't run it during test. It is redundant anyway, as
# migrations are run before starting test suite.
Rake::Task['db:abort_if_pending_migrations'].invoke
class TasksTest < ApplicationSystemTestCase
test "db:seed creates admin account" do
User.admin.delete_all
assert_output /Creating admin account/ do
Rake::Task['db:seed'].execute
end
assert User.admin.exists?
end
end

View File

@@ -44,12 +44,10 @@ class UnitsTest < ApplicationSystemTestCase
within 'tbody > tr:has(input[type=text], textarea)' do
assert_selector ':focus'
maxlength = all(:fillable_field).to_h do |field|
[field[:name], field[:maxlength].to_i || 2**16]
end
maxlength = all(:fillable_field).to_h { |f| [f[:name], f[:maxlength].to_i || 2**16] }
values = {
symbol: random_string(deep_rand(1..3, 4..maxlength['unit[symbol]']),
except: units.map(&:symbol), allow_blank: false),
symbol: random_string(rand([1..3, 4..maxlength['unit[symbol]']].sample),
except: units.map(&:symbol)),
description: random_string(rand(0..maxlength['unit[description]']))
}.with_indifferent_access
within :field, 'unit[multiplier]' do |field|
@@ -63,8 +61,7 @@ class UnitsTest < ApplicationSystemTestCase
end
end
assert_selector '.flash.notice',
text: t('units.create.success', unit: Unit.last.symbol)
assert_selector '.flash.notice', text: t('units.create.success', unit: Unit.last.symbol)
within 'tbody' do
assert_no_selector :fillable_field
assert_selector 'tr', count: @user.units.count

View File

@@ -183,7 +183,7 @@ class UsersTest < ApplicationSystemTestCase
end
test 'delete profile' do
user = sign_in
user = sign_in user: users.reject(&:admin?).select(&:confirmed?).sample
# TODO: remove condition after root_url changed to different path than
# profile in routes.rb
unless has_current_path?(edit_user_registration_path)
@@ -196,6 +196,14 @@ class UsersTest < ApplicationSystemTestCase
assert_text t("devise.registrations.destroyed")
end
test 'sole admin cannot delete profile' do
sign_in user: users(:admin)
unless has_current_path?(edit_user_registration_path)
first(:link_or_button, users(:admin).email).click
end
assert find(:button, t("users.registrations.edit.delete"))[:disabled]
end
test 'index forbidden for non admin' do
sign_in user: users.reject(&:admin?).select(&:confirmed?).sample
visit users_path
@@ -204,7 +212,6 @@ class UsersTest < ApplicationSystemTestCase
test 'update profile' do
# TODO
assert true
end
test 'update status' do
@@ -228,8 +235,7 @@ class UsersTest < ApplicationSystemTestCase
within all(:xpath, "//tbody//tr[not(descendant::select)]").sample do |tr|
user = User.find_by_email!(first(:link).text)
inject_button_to find('td', exact_text: user.status), "update status",
user_path(user), method: :patch,
inject_button_to first('td:not(.link)'), "update status", user_path(user), method: :patch,
params: {user: {status: User.statuses.keys.sample}}, data: {turbo: false}
click_on "update status"
end
@@ -239,8 +245,8 @@ class UsersTest < ApplicationSystemTestCase
test 'update status forbidden for non admin' do
sign_in user: users.reject(&:admin?).select(&:confirmed?).sample
visit units_path
inject_button_to find('body'), "update status", user_path(User.all.sample),
method: :patch, params: {user: {status: User.statuses.keys.sample}}
inject_button_to find('body'), "update status", user_path(User.all.sample), method: :patch,
params: {user: {status: User.statuses.keys.sample}}
click_on "update status"
assert_text t('actioncontroller.exceptions.status.forbidden')
end

View File

@@ -2,6 +2,10 @@ ENV["RAILS_ENV"] ||= "test"
require_relative "../config/environment"
require "rails/test_help"
class ActionDispatch::IntegrationTest
include Devise::Test::IntegrationHelpers
end
class ActiveSupport::TestCase
# Run tests in parallel with specified workers
parallelize(workers: :number_of_processors)
@@ -13,36 +17,27 @@ class ActiveSupport::TestCase
include ActionView::Helpers::TranslationHelper
# List of categorized Unicode characters:
# * source: http://www.unicode.org/Public/UNIDATA/UnicodeData.txt
# * file format: http://www.unicode.org/L2/L1999/UnicodeData.html
# * select from graphic ranges: L, M, N, P, S, Zs
UNICODE_CHARS = [
*"\u0020".."\u007E",
*"\u00A0".."\u00AC",
*"\u00AE".."\u05FF",
*"\u0606".."\u061B",
*"\u061D".."\u06DC",
*"\u06DE".."\u070E",
*"\u0710".."\u07FF"
]
def random_string(length, except: [], allow_blank: true)
# * http://www.unicode.org/Public/UNIDATA/UnicodeData.txt
# File format: http://www.unicode.org/L2/L1999/UnicodeData.html
# Select from graphic ranges: L, M, N, P, S, Zs
UNICODE_CHARS = {
1 => [*"\u0020".."\u007E"],
2 => [*"\u00A0".."\u00AC",
*"\u00AE".."\u05FF",
*"\u0606".."\u061B",
*"\u061D".."\u06DC",
*"\u06DE".."\u070E",
*"\u0710".."\u07FF"]
}
UNICODE_CHARS.default = UNICODE_CHARS[1] + UNICODE_CHARS[2]
def random_string(bytes = 10, except: [])
begin
result = UNICODE_CHARS.sample(length).join
end while except.include?(result) || (!allow_blank && result.blank?)
result = ''
result += UNICODE_CHARS[bytes - result.bytesize].sample while bytes > result.bytesize
end while except.include?(result)
result
end
def deep_rand(*args)
case args
when Array
args = args.sample
when Range
args = rand(args)
else
return args
end while true
end
# Assumes: max >= step and step = 1e[-]N, both as strings
def random_number(max, step)
max.delete!('.')