Compare commits

...

29 Commits

Author SHA1 Message Date
Arthur POULET 35b69ef33a
Loosen on dependencies version
continuous-integration/drone/push Build is passing Details
2022-07-28 00:47:13 +02:00
Arthur POULET 46d14c2917
Update dependencies (security)
continuous-integration/drone/push Build is failing Details
continuous-integration/drone/tag Build is failing Details
2022-07-28 00:14:35 +02:00
Arthur POULET 3d607ade56 deps: execute bundle update 2022-06-20 23:48:15 +02:00
Arthur POULET 08a2729ce6
Merge remote-tracking branch 'origin/develop' into develop
continuous-integration/drone/push Build is passing Details
2022-04-23 11:30:35 +02:00
Arthur POULET d4de167df2
Merge remote-tracking branch 'origin/master'
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
2022-04-23 11:28:44 +02:00
Arthur POULET 29e78b7f52
cookies: add a duration for cookies 2022-04-23 11:27:55 +02:00
Arthur POULET 5f8afb008e drone: update ruby image
continuous-integration/drone/push Build is passing Details
2022-03-27 17:27:39 +02:00
Arthur POULET b9bc73b3b8 recalls: add a red pill to remind recalls to do
closes #61
2022-03-27 17:23:31 +02:00
Arthur POULET 8efe5d3ab3
offset: fix offset generator for yesterday 2022-03-19 11:13:47 +01:00
Arthur POULET db9c5cd7a4 prefs: add show full date checkbox
* factorize user prefs cookies
* add checkboxe
2022-02-28 23:47:14 +01:00
Arthur POULET 76da1fd1a8 systems: fix UserHelper inclusion 2022-02-28 22:56:26 +01:00
Arthur POULET 1f44afb893 layout: add the full date (today/yesterday) 2022-02-28 22:52:57 +01:00
Arthur POULET 0485f8131d
prefs: add offset as a user pref 2022-02-26 16:26:46 +01:00
Arthur POULET ac29e5bb41 preferences: add a page to edit user prefs
closes #63
2022-01-11 05:25:39 +01:00
Arthur POULET 690d41d33f csv: improve csv headers with category 2022-01-11 05:06:47 +01:00
Arthur POULET fef976ed5c csv: add csv export in /about 2022-01-05 23:02:25 +01:00
Arthur POULET c2b5dd96b2
pex: add date of last insert
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
2021-11-13 12:08:33 +01:00
Arthur POULET 5a24cdb6b7
deps: comment pg because sqlite is default
continuous-integration/drone/push Build is passing Details
2021-08-17 13:16:32 +02:00
Arthur POULET ab708456ed
drone: fix automated test
continuous-integration/drone/push Build is passing Details
2021-08-17 13:14:02 +02:00
Arthur POULET f2ad52586f
env: fix scripts env loading 2021-08-17 13:10:53 +02:00
Arthur POULET d1fae9bf63
script: improve db migration 2021-08-17 12:52:09 +02:00
Arthur POULET fb02993fa3
recap: add xAxis infos for charts dates 2021-08-14 21:57:37 +02:00
Arthur POULET b36249ae76
recap: add input to control how much data to fetch 2021-08-11 09:13:53 +02:00
Arthur POULET 2559c8c7a7
recap: improve recap by limiting amount of data output 2021-08-11 01:26:23 +02:00
Arthur POULET fd23535924
pex: fix update form
continuous-integration/drone/push Build was killed Details
2021-07-03 13:08:27 +02:00
Arthur POULET 0e26243315
pex: include an autocomplete in pex form
continuous-integration/drone/push Build is passing Details
2021-07-03 13:00:35 +02:00
Arthur POULET 9315c422a0
recalls: add recall warning on recap
continuous-integration/drone/push Build is passing Details
2021-07-03 12:43:34 +02:00
Arthur POULET 08c895f3a5
recalls: add recall link in nav bar 2021-07-03 12:23:44 +02:00
Arthur POULET 52a8d4b5fe
recalls: add recalls crd 2021-07-03 12:23:00 +02:00
32 changed files with 637 additions and 172 deletions

View File

@ -3,11 +3,11 @@ name: default
steps:
- name: test
image: ruby:3.0
image: ruby:3.1
environment:
LIFEPEX_DB: "sqlite://test.db"
LIFEPEX_ENV: "test"
commands:
- bundle install --jobs=1 --retry=1
- ./init/database.rb
- rake db:migrate
- rake test

32
Gemfile
View File

@ -3,34 +3,34 @@
source "https://rubygems.org"
# web
gem "puma", "~> 5.3"
gem "sinatra", "~> 2.1"
gem "sinatra-contrib", "~> 2.1"
gem "slim", "~> 4.1"
gem "puma", "~> 5"
gem "sinatra", "~> 2"
gem "sinatra-contrib", "~> 2"
gem "slim", "~> 4"
# database
# you # comment what you don't want
gem "sequel", "~> 5.43"
gem "sqlite3", "~> 1.4"
gem "pg", "~> 1.2"
gem "sequel", "~> 5"
# you # comment the drivers you don't want
gem "sqlite3", "~> 1"
# gem "pg", "~> 1.2"
# security
gem "jwt", "~> 2.2"
gem "bcrypt", "~> 3.1"
gem "rack_csrf", "~> 2.6"
gem "jwt", "~> 2"
gem "bcrypt", "~> 3"
gem "rack_csrf", "~> 2"
# api tools
gem "doc_my_routes"
# debug and helpers
gem "colorize", "~> 0.8.1"
gem "activesupport", "= 6.1.3.1"
gem "colorize", "~> 0.8"
gem "activesupport", "~> 6"
# tests
group :test do
gem "pry", "~> 0.14.1"
gem "rack-test", "~> 1.1", require: false
gem "pry"
gem "rack-test", "~> 1", require: false
# gem "simplecov", "~> 0.21.2", require: false
end
gem "dotenv", "~> 2.7"
gem "dotenv", "~> 2"

View File

@ -1,83 +1,81 @@
GEM
remote: https://rubygems.org/
specs:
activesupport (6.1.3.1)
activesupport (6.1.6.1)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
tzinfo (~> 2.0)
zeitwerk (~> 2.3)
bcrypt (3.1.16)
bcrypt (3.1.18)
coderay (1.1.3)
colorize (0.8.1)
concurrent-ruby (1.1.9)
concurrent-ruby (1.1.10)
doc_my_routes (0.13.0)
dotenv (2.7.6)
i18n (1.8.10)
dotenv (2.8.1)
i18n (1.12.0)
concurrent-ruby (~> 1.0)
jwt (2.2.3)
jwt (2.4.1)
method_source (1.0.0)
minitest (5.14.4)
minitest (5.16.2)
multi_json (1.15.0)
mustermann (1.1.1)
mustermann (2.0.2)
ruby2_keywords (~> 0.0.1)
nio4r (2.5.7)
pg (1.2.3)
nio4r (2.5.8)
pry (0.14.1)
coderay (~> 1.1)
method_source (~> 1.0)
puma (5.3.2)
puma (5.6.4)
nio4r (~> 2.0)
rack (2.2.3)
rack-protection (2.1.0)
rack (2.2.4)
rack-protection (2.2.2)
rack
rack-test (1.1.0)
rack (>= 1.0, < 3)
rack_csrf (2.6.0)
rack (>= 1.1.0)
ruby2_keywords (0.0.4)
sequel (5.45.0)
sinatra (2.1.0)
mustermann (~> 1.0)
ruby2_keywords (0.0.5)
sequel (5.58.0)
sinatra (2.2.2)
mustermann (~> 2.0)
rack (~> 2.2)
rack-protection (= 2.1.0)
rack-protection (= 2.2.2)
tilt (~> 2.0)
sinatra-contrib (2.1.0)
sinatra-contrib (2.2.2)
multi_json
mustermann (~> 1.0)
rack-protection (= 2.1.0)
sinatra (= 2.1.0)
mustermann (~> 2.0)
rack-protection (= 2.2.2)
sinatra (= 2.2.2)
tilt (~> 2.0)
slim (4.1.0)
temple (>= 0.7.6, < 0.9)
tilt (>= 2.0.6, < 2.1)
sqlite3 (1.4.2)
sqlite3 (1.4.4)
temple (0.8.2)
tilt (2.0.10)
tzinfo (2.0.4)
tilt (2.0.11)
tzinfo (2.0.5)
concurrent-ruby (~> 1.0)
zeitwerk (2.4.2)
zeitwerk (2.6.0)
PLATFORMS
x86_64-linux
DEPENDENCIES
activesupport (= 6.1.3.1)
bcrypt (~> 3.1)
colorize (~> 0.8.1)
activesupport (~> 6)
bcrypt (~> 3)
colorize (~> 0.8)
doc_my_routes
dotenv (~> 2.7)
jwt (~> 2.2)
pg (~> 1.2)
pry (~> 0.14.1)
puma (~> 5.3)
rack-test (~> 1.1)
rack_csrf (~> 2.6)
sequel (~> 5.43)
sinatra (~> 2.1)
sinatra-contrib (~> 2.1)
slim (~> 4.1)
sqlite3 (~> 1.4)
dotenv (~> 2)
jwt (~> 2)
pry
puma (~> 5)
rack-test (~> 1)
rack_csrf (~> 2)
sequel (~> 5)
sinatra (~> 2)
sinatra-contrib (~> 2)
slim (~> 4)
sqlite3 (~> 1)
BUNDLED WITH
2.2.16

View File

@ -46,7 +46,7 @@ Since I suck with SGDB administration, this is a sample of what you may do.
psql -U postgres postgres -c "CREATE USER root WITH PASSWORD 'toor' SUPERUSER;"
psql -U postgres postgres -c "CREATE DATABASE life_pex"
echo LIFEPEX_DB=postgres://root:toor@localhost/life_pex >> .env.local
./init/database.rb
rake db:migrate
```
### Start
@ -91,7 +91,13 @@ editor .env.test.local # don't forget to set a new database !!!
Then init the database
```
LIFEPEX_ENV=test ./init/database.rb
LIFEPEX_ENV=test rake db:migrate
```
Then if you want to run the test, simply type `rake test` (you will need the startup env variable to be set first).
### Debug
Some debug options can be enabled with the env variable
`LIFEPEX_ENV=debug`

View File

@ -5,4 +5,17 @@ Rake::TestTask.new do |t|
t.pattern = "test/*_test.rb"
end
namespace "db" do
desc "Migrate the database to the lasted schema"
task "migrate" do
require_relative "./init/migrate_db"
end
desc "Reset all tables, schema, data"
task "reset" do
require_relative "./init/load_env"
DB.tables.each {|t| DB.drop_table t }
end
end
task default: :test

13
init/load_env.rb Normal file
View File

@ -0,0 +1,13 @@
require "sequel"
require "colorize"
require_relative "../src/utils/env"
require_relative "../src/utils/semver"
load_dotenv
DB = Sequel.connect ENV["LIFEPEX_DB"]
if ENV["environment"] == "debug"
require "pry"
binding.pry
end

View File

@ -1,41 +1,30 @@
#!/usr/bin/env ruby
require "sequel"
require "colorize"
require "dotenv"
Dotenv.load(".env.local", ".env")
require_relative "../src/utils/semver.rb"
DB = Sequel.connect ENV["LIFEPEX_DB"]
if ENV["environment"] == "debug"
require "pry"
binding.pry
end
require_relative "./load_env"
def current_version
DB[:meta].first[:version] rescue 0
end
def migrate(version, &block)
puts "Check migration #{version}"
def migrate(version, message = nil, &block)
puts "Check migration #{version}".on_blue
if current_version < version
puts "Migrate #{version}".blue
begin
yield
DB[:meta].update(version: version)
puts "Successfuly set version #{version}"
puts "Successfuly set version #{version}".green
puts message.green if message
rescue => err
puts err.message.red
puts err.message.on_red
exit 1
end
else
puts "Already migrated #{version}".blue
puts "Already migrated #{version}".yellow
end
end
migrate 1 do
migrate 1, "Initialized database" do
DB.create_table :meta do
primary_key :id
Int :version
@ -60,33 +49,29 @@ migrate 1 do
String :username
String :hashed_password
end rescue puts "users already exists".yellow
puts "Initialized database".green
end
migrate 2 do
migrate 2, "Add pex categories" do
DB.alter_table :pexs do
add_column :category, String
end rescue puts "pexs.category already exists".yellow
puts "Migrated categories".green
end
migrate 3 do
migrate 3, "Add pex user belonging" do
DB.alter_table :pexs do
add_column :user_id, :Int
end rescue puts "pexs.user_id already exists".yellow
DB[:pexs].update(user_id: 1)
puts "Migrated pex belonging to user".green
end
migrate 4 do
migrate 4, "Fix pex category default behavior" do
DB.alter_table :pexs do
set_column_default(:category, '')
end rescue puts "pexs.category default already exists".yellow
DB[:pexs].where(category: nil).update(category: '')
puts "Migrated pex default category".green
end
migrate 5 do
migrate 5, "Add meta schema" do
DB.alter_table :meta do
add_column :code_version, String
end rescue puts "meta.code_version already exists".yellow
@ -99,18 +84,20 @@ migrate 6 do
end rescue puts "meta.code_date already exists".yellow
end
migrate 7 do
DB[:pexs].each { |pex| DB[:pexs].where(id: pex[:id]).update(category: pex[:category].to_s.downcase) }
migrate 7, "Fix pex initial category" do
DB[:pexs].each do |pex|
DB[:pexs].where(id: pex[:id]).update(category: pex[:category].to_s.downcase)
end
end
migrate 8 do
migrate 8, "Add default hidden for pexs" do
DB.alter_table :pexs do
add_column :hidden, TrueClass
end rescue puts "pex.hidden already exists".yellow
DB[:pexs].each { |pex| DB[:pexs].where(id: pex[:id]).update(hidden: false) }
end
migrate 9 do
migrate 9, "Add achievements" do
DB.create_table :achievements do
primary_key :id
Int :user_id
@ -121,14 +108,25 @@ migrate 9 do
end
end
migrate 10 do
migrate 10, "Add generic flag to pexs, for bookmarking" do
DB.alter_table :pexs do
add_column :flag, :String
end rescue puts "pexs.flag already exists".yellow
puts "Migrated pex flag for bookmarks".green
end
puts "End migration".green
migrate 11, "Add recalls" do
DB.create_table :recalls do
primary_key :id
Int :user_id
Int :pex_id
String :name
Int :span_duration
Int :repeated
end
puts "Migrated recalls".green
end
puts "End migration".on_blue
CODE_VERSION = `git tag`.split("\n").map{ |str| Utils::Semver.new(str) }.sort.last.to_s
CODE_DATE=`git show #{CODE_VERSION} --pretty="format:%as"`.split("\n").first
DB[:meta].update(code_version: CODE_VERSION)

View File

@ -70,6 +70,10 @@ h2 {
height: 45px;
}
.smaller {
font-size: x-small !important;
}
.highcharts-figure, .highcharts-data-table table {
min-width: 360px;
max-width: 800px;

View File

@ -1,4 +1,4 @@
function setupChart(...cumuls) {
function setupChart({ all_dates }, ...cumuls) {
const chart = Highcharts.chart('recap-xp-container', {
chart: {
backgroundColor: '#000',
@ -7,6 +7,9 @@ function setupChart(...cumuls) {
text: '',
color: '#55f5f5',
},
xAxis: {
categories: all_dates,
},
series: cumuls.map((cumul, idx) => ({
// color: '#55f5f5',
visible: idx != 0,
@ -16,15 +19,41 @@ function setupChart(...cumuls) {
});
}
document.addEventListener('DOMContentLoaded', async function () {
function objectToQueryParam(obj) {
return Object.entries(obj).map(tuple => tuple.join("=")).join("&");
}
function urlWithQueryParams(base, object_with_params) {
if (Object.keys(object_with_params).length > 0) {
return `${base}?${objectToQueryParam(object_with_params)}`;
}
return base;
}
async function requestChart() {
let url = "/api/pex/v1/recap";
const days_ago = document.querySelector("#inputDaysAgo").value;
if (days_ago.length > 0) {
url = urlWithQueryParams(url, { days_ago });
}
ajax({
method: "GET",
url: "/api/pex/v1/recap",
url: url,
body: null,
headers: { Accept: "application/json" },
on_success: (body) => {
const json_output = JSON.parse(body);
setupChart(...json_output.pex_tables);
setupChart({ all_dates: json_output.all_dates }, ...json_output.pex_tables);
},
});
}
document.addEventListener('DOMContentLoaded', async function () {
requestChart();
const form = document.querySelector(".recap-xp form#reloader");
form.addEventListener("submit", (event) => {
event.preventDefault();
requestChart();
});
});

View File

@ -18,16 +18,15 @@ require "active_support"
require "active_support/core_ext"
require "pry" # debug
# Load environment variables
require_relative "./utils/env"
module LifePex
APP_ENV = ENV.fetch("LIFEPEX_ENV") { "development" }
APP_ENV = load_dotenv
end
require "dotenv"
Dotenv.load(".env.#{LifePex::APP_ENV}.local", ".env.local", ".env")
# Initialize framework
require_relative "./utils/url.rb"
require_relative "./utils/boot_framework"
LifePex::BootFramework.boot_application do
harshly_need_env "LIFEPEX_DB"
kindly_ask_env "LIFEPEX_BASE_URL"
@ -40,6 +39,7 @@ LifePex::BootFramework.boot_application do
end
end.finish!
# Setup module variables
module LifePex
DB = Sequel.connect ENV["LIFEPEX_DB"]
BASE_URL = ENV["LIFEPEX_BASE_URL"] || "http://localhost:4567"
@ -53,6 +53,8 @@ module LifePex
end
end
# Then we load all the systems
Dir[File.join(__dir__, 'utils', '*.rb')].each { |file| require file }
require_relative "./achievements/dsl.rb"
@ -61,10 +63,12 @@ require_relative "./achievements/achievement.rb"
Dir[File.join(__dir__, 'models', '*.rb')].each { |file| require file }
Dir[File.join(__dir__, 'systems', '*.rb')].each { |file| require file }
# Static file serving in this file because it is overkill to create a file for this
class LifePex::Systems::PublicSystem < Sinatra::Base
set :public_folder, "public"
end
# Main app
class LifePex::App < Sinatra::Base
DocMyRoutes.configure do |config|
config.title = "LifePex"

View File

@ -1,6 +1,7 @@
class LifePex::Pex < Sequel::Model(LifePex::DB[:pexs].order(:category, :name))
one_to_many :user_pexs
many_to_one :user
one_to_many :recalls
# note: wont work on #update
def before_validation

4
src/models/recall.rb Normal file
View File

@ -0,0 +1,4 @@
class LifePex::Recall < Sequel::Model(LifePex::DB[:recalls].order(:name))
many_to_one :user
many_to_one :pex
end

View File

@ -1,8 +1,23 @@
class LifePex::User < Sequel::Model(:users)
one_to_many :user_pexs
one_to_many :pexs
one_to_many :recalls
def password=(clear_password)
self.hashed_password = BCrypt::Password.create(clear_password)
end
def recalls_not_validated(cached: true)
@recalls_not_validated = nil if !cached
pexs = LifePex::Pex.setup_user_pexs(user_id: id, user_pexs: user_pexs)
@recalls_not_validated ||= recalls.filter do |recall|
pex = pexs[recall[:pex_id]]
validated = pex[:count_by_date].filter do |date, _|
date >= Date.today - recall[:span_duration]
end.values.sum
validated < recall[:repeated]
end
end
end

View File

@ -1,4 +1,12 @@
class LifePex::UserPex < Sequel::Model(:user_pexs)
many_to_one :user
many_to_one :pex
def self.last_inserted_at(user_id)
LifePex::UserPex
# .join(:pexs, :id => :pex_id)
# .where(Sequel[:user_pexs][:user_id] => user_id)
# .group(:pex_id)
# .select_append{max(created_at).as(:last_inserted_at)}
end
end

View File

@ -1,11 +1,33 @@
class LifePex::Systems::AuthSystem < Sinatra::Base
helpers Sinatra::Cookies
include JSON::API
include LifePex::UsersHelper
def setup_user_cookie!(user_id)
response.set_cookie(
"auth",
{
value: JWT.encode({ "user_id" => user_id }, LifePex::SECRET),
expires: Time.now + 2.days,
path: "/",
})
end
def renew_user_cookie!
response.set_cookie(
"auth",
{
value: cookies["auth"],
expires: Time.now + 2.days,
path: "/",
})
end
def user_id_decoded(cookies = nil)
cookies = cookies() if cookies.nil?
begin
decoded = JWT.decode(cookies["auth"], LifePex::SECRET)
renew_user_cookie!
decoded[0]["user_id"]
rescue => err
STDERR.puts "user_id_decoded: #{err}"
@ -37,7 +59,7 @@ class LifePex::Systems::AuthSystem < Sinatra::Base
condition do
unless logged_in?
if accept_json?
halt 401, { message: 'You need to /api/v1/register an account and /api/v1/login to get a cookie first' }.to_json
halt 401, { message: 'You need to POST /api/user/v1/register to register an account and POST /api/user/v1/login to get a cookie first' }.to_json
else
redirect "/login", 303
end

View File

@ -1,32 +1,14 @@
require "date"
require "active_support/all"
require_relative "./auth.rb"
require_relative "./csrf.rb"
require_relative "../utils/users.rb"
class LifePex::Systems::PexSystem < LifePex::Systems::AuthSystem
include JSON::API
include LifePex::Systems::CrlfHelper
def my_user_pexs(cookies, date = nil)
params = {
user_id: user_id_decoded(cookies),
}
params[:created_at] = date unless date.nil?
LifePex::UserPex.where(params).all()
end
def get_user_date
date = cookies["date"]
if date && Date.respond_to?(date)
Date.send date
elsif date == "yesterday"
Date.today - 1
else
Date.today
end
end
get "/today", auth: [] do
cookies.set "date", { value: "today", httponly: false }
cookies.set "date", { value: "now", httponly: false }
redirect "/"
end
@ -39,13 +21,18 @@ class LifePex::Systems::PexSystem < LifePex::Systems::AuthSystem
user_pexs = my_user_pexs(cookies, get_user_date)
user_pexs_amounts = user_pexs.group_by { |user_pex| user_pex[:pex_id] }
pexs = LifePex::Pex.where(user_id: current_user_id)
pexs = pexs.where(hidden: false) if filter_hidden
pexs = pexs.all
user_pexs_last_inserted_at = LifePex::UserPex.last_inserted_at(current_user_id).all
pex_by_models = pexs.map do |pex|
{
pex: pex,
user_pexs: {
at_date: (user_pexs_amounts[pex[:id]] || []).length
at_date: (user_pexs_amounts[pex[:id]] || []).length,
last_inserted_at: (user_pexs_last_inserted_at.find{ |up| up[:pex_id] == pex[:id] } || {})[:last_inserted_at],
},
}
end
@ -71,12 +58,22 @@ class LifePex::Systems::PexSystem < LifePex::Systems::AuthSystem
end
get "/pexs", auth: [] do
slim :pex_form
categories = LifePex::Pex
.select(:category)
.where(user_id: current_user_id)
.distinct
.pluck(:category)
slim :pex_form, locals: { categories: categories }
end
get "/pexs/update", auth: [] do
pex = LifePex::Pex.where(id: params["id"]).first
slim :pex_form, locals: { pex: pex, submit_name: "update" }
categories = LifePex::Pex
.select(:category)
.where(user_id: current_user_id)
.distinct
.pluck(:category)
slim :pex_form, locals: { pex: pex, categories: categories, submit_name: "update" }
end
post "/pexs", auth: [] do
@ -118,19 +115,28 @@ class LifePex::Systems::PexSystem < LifePex::Systems::AuthSystem
end
end
def get_recap_infos(cookies)
def get_recap_infos(params, cookies)
user_pexs = my_user_pexs(cookies)
pexs = LifePex::Pex.setup_user_pexs(user_id: current_user_id, user_pexs: user_pexs)
total_xp = pexs.values.sum { |up| up[:total_by_date].values.sum }
level = LifePex::Level.new total_xp
all_dates = user_pexs.map(&:created_at).uniq
all_category = pexs.values.map(&:category).uniq
start_date = 60.days.ago.to_date
if params["days_ago"]
start_date = params["days_ago"].to_i.days.ago.to_date
elsif params["since_first_jan"]
year = Date.today.year
first_jan = Date.new(year, 1, 1)
start_date = first_jan
end
all_dates = (start_date..(Date.today)).to_a
pex_table_by_date = all_dates.map { |date| [date, 0] }.to_h
pexs.values.each do |pex|
pex[:total_by_date].each do |date, total|
# pex_table_by_date[date] ||= 0
pex_table_by_date[date] += total
pex_table_by_date[date] += total if pex_table_by_date[date]
end
end
@ -146,7 +152,7 @@ class LifePex::Systems::PexSystem < LifePex::Systems::AuthSystem
category = pex[:category]
# pex_table_by_category_and_date[category] ||= {}
# pex_table_by_category_and_date[category][date] ||= 0
pex_table_by_category_and_date[category][date] += total
pex_table_by_category_and_date[category][date] += total if pex_table_by_category_and_date[category][date]
end
end
@ -161,6 +167,15 @@ class LifePex::Systems::PexSystem < LifePex::Systems::AuthSystem
}
end.unshift(highchart_total)
recalls = LifePex::Recall.where(user_id: current_user_id).all
recalls_not_validated = recalls.filter do |recall|
pex = pexs[recall[:pex_id]]
validated = pex[:count_by_date].filter do |date, _|
date >= Date.today - recall[:span_duration]
end.values.sum
validated < recall[:repeated]
end
all_setup = LifePex::Achievement.where(user_id: current_user_id).map(&:to_dsl_setup)
medals = all_setup.filter do |success|
success.call(user_pexs, pexs.values)
@ -168,13 +183,16 @@ class LifePex::Systems::PexSystem < LifePex::Systems::AuthSystem
{
pex_tables: highchart_tables,
start_date: start_date,
all_dates: all_dates,
level: level,
medals: medals,
recalls_not_validated: recalls_not_validated,
}
end
get "/recap", auth: [], provides: 'html' do
recap_infos = get_recap_infos(cookies)
recap_infos = get_recap_infos(params, cookies)
slim :recap, locals: recap_infos
end
@ -185,9 +203,10 @@ class LifePex::Systems::PexSystem < LifePex::Systems::AuthSystem
produces 'application/json'
status_codes [200]
get "/recap", auth: [], provides: 'json' do
recap_infos = get_recap_infos(cookies)
recap_infos = get_recap_infos(params, cookies)
{
pex_tables: recap_infos[:pex_tables],
all_dates: recap_infos[:all_dates],
level: recap_infos[:level].to_h,
}.to_json
end

View File

@ -25,7 +25,7 @@ class LifePex::Systems::Pex2System < LifePex::Systems::PexSystem
produces 'application/json'
status_codes [200]
parameter :pluck, required: false, type: 'string', in: 'query', description: 'improve performance by only fetching one field'
get "", auth: [], provides: 'json' do
get '', auth: [], provides: "json" do
pexs = current_user.pexs
pexs = pexs.select(json_params["pluck"]) if json_params["pluck"]
pexs.to_json
@ -38,7 +38,7 @@ class LifePex::Systems::Pex2System < LifePex::Systems::PexSystem
parameter :category, required: true, type: 'string', in: 'body'
parameter :amount, required: true, type: 'number', in: 'body'
parameter :auth, required: true, type: 'string', in: 'cookies'
post "", auth: [], provides: 'json' do
post '', auth: [], provides: 'json' do
halt 400, "\"name\" is required" unless json_params["name"]
halt 400, "\"category\" is required" unless json_params["category"]
halt 400, "\"amount\" is required" unless json_params["amount"]
@ -67,7 +67,7 @@ class LifePex::Systems::Pex2System < LifePex::Systems::PexSystem
parameter :category, required: false, type: 'string', in: 'body'
parameter :amount, required: false, type: 'number', in: 'body'
parameter :auth, required: true, ype: 'string', in: 'cookies'
put "/:id", auth: [], provides: 'json' do
put '/:id', auth: [], provides: 'json' do
pex = LifePex::Pex.find(
id: params["id"],
user_id: current_user.id,
@ -88,7 +88,7 @@ class LifePex::Systems::Pex2System < LifePex::Systems::PexSystem
status_codes [200]
parameter :id, require: true, type: 'integer', in: 'path'
parameter :auth, required: true, type: 'string', in: 'cookies'
delete "/:id", auth: [], provides: 'json' do
delete '/:id', auth: [], provides: 'json' do
pex = LifePex::Pex.find(
id: params["id"],
user_id: current_user.id,
@ -105,7 +105,7 @@ class LifePex::Systems::Pex2System < LifePex::Systems::PexSystem
produces 'application/json'
status_codes [200]
parameter :id, required: true, type: 'string', in: 'path'
get "/:id", auth: [], provides: 'json' do
get '/:id', auth: [], provides: 'json' do
pex = LifePex::Pex.find(
id: params["id"],
user_id: current_user.id,
@ -120,7 +120,7 @@ class LifePex::Systems::Pex2System < LifePex::Systems::PexSystem
produces 'application/json,text/html'
status_codes [200]
parameter :id, required: true, type: 'string', in: 'path'
get "/:id/more", auth: [], provides: 'json' do
get '/:id/more', auth: [], provides: 'json' do
pex = LifePex::Pex.find(
id: params["id"],
user_id: current_user_id,
@ -152,27 +152,55 @@ class LifePex::Systems::Pex2System < LifePex::Systems::PexSystem
end
end
summary 'Get the amount of a pex each day since first occurence since 1 january'
notes ""
summary 'Get the amount of a pex each day since first occurence since a given date'
notes ''
produces 'application/json'
status_codes [200]
parameter :id, required: true, type: 'integer', in: 'path'
parameter :auth, required: true, type: 'string', in: 'cookies'
post "/:id/recap", auth: [], provides: 'json' do
parameter :days_ago, required: false, type: 'integer', in: 'body', description: "default: 30, limit the amount of results to the n last days included"
parameter :since_first_jan, required: false, type: 'boolean', in: 'body', description: "default: false, if no days_ago defined, limit to all dates after (included) 1st jan of the current year"
post '/:id/recap', auth: [], provides: 'json' do
pex_id = params["id"]
pex = LifePex::Pex.find(id: pex_id, user_id: current_user_id)
halt 404, { message: "No pex #{params["id"]}" }.to_json if pex.nil?
year = Date.today.year
first_jan = Date.new(year, 1, 1)
user_pexs = LifePex::UserPex.where {
self.user_id == current_user_id && self.pex_id == pex_id && created_at > first_jan
start_date = 30.days.ago
if params["days_ago"]
start_date = params["days_ago"].to_i.days.ago
elsif params["since_first_jan"]
year = Date.today.year
first_jan = Date.new(year, 1, 1)
start_date = first_jan
end
user_pexs = LifePex::UserPex.where { # TODO query that
self.user_id == current_user_id && self.pex_id == pex_id && created_at >= start_date
}
{ pex: pex, user_pexs: user_pexs }.to_json
end
end
namespace '/infos' do
summary 'Get for all existing pex the last date a user_pex has been added'
produces 'application/json'
status_codes [200]
parameter :id, required: true, type: 'string', in: 'path'
get '/last-inserts', auth: [], provides: 'json' do
pexs = LifePex::UserPex.last_inserted_at(current_user.id)
pexs.map { |pex|
{
pex_id: pex[:pex_id],
name: pex[:name],
last_inserted_at: pex[:last_inserted_at],
}
}.to_json
end
end
end
include LifePex::Systems::ApiList
end

47
src/systems/recall.rb Normal file
View File

@ -0,0 +1,47 @@
require_relative "./csrf.rb"
require_relative "./auth.rb"
class LifePex::Systems::RecallSystem < LifePex::Systems::AuthSystem
include LifePex::Systems::CrlfHelper
get "/recalls", auth: [] do
recalls = current_user.recalls
slim :recalls, locals: { recalls: recalls }
end
get "/recalls/new", auth: [] do
pexs = current_user.pexs
slim :recall_form, locals: { pexs: pexs }
end
post "/recalls", auth: [] do
flash = {}
if !LifePex::Pex.where(user_id: current_user_id, id: params["pex_id"]).first
flash[:danger] = "Invalid pex"
else
recall = LifePex::Recall.new(
user_id: current_user_id,
pex_id: params["pex_id"],
name: params["name"],
span_duration: params["span_duration"],
repeated: params["repeated"],
).save
flash[:success] = "Recall #{recall.name} created"
end
recalls = current_user.recalls
slim :recalls, locals: { recalls: recalls, flash: flash }
end
post "/recalls/delete", auth: [] do
recall = LifePex::Recall.where(user_id: current_user_id, id: params["id"]).first
flash = {}
if recall
recall.destroy
flash[:success] = "Recall #{recall.name} removed"
else
flash[:danger] = "Recall do not exists"
end
recalls = current_user.recalls
slim :recalls, locals: { recalls: recalls, flash: flash }
end
end

View File

@ -3,7 +3,6 @@ require_relative "./auth.rb"
require_relative "./csrf.rb"
class LifePex::Systems::UserSystem < LifePex::Systems::AuthSystem
include JSON::API
include LifePex::Systems::CrlfHelper
DEFAULT_PEXS_FOR_NEW_USERS = YAML.load_file "config/default_pexs_for_new_users.yaml"
@ -12,14 +11,10 @@ class LifePex::Systems::UserSystem < LifePex::Systems::AuthSystem
slim :login
end
def setup_user_cookie!(user_id)
cookies["auth"] = JWT.encode({ "user_id" => user_id }, LifePex::SECRET)
end
def login(params)
user = LifePex::User.where(username: params["username"]).first
if user && BCrypt::Password.new(user[:hashed_password]) == params["password"]
cookies["auth"] = JWT.encode({ "user_id" => user[:id] }, LifePex::SECRET)
setup_user_cookie!(user[:id])
user
else
nil
@ -28,7 +23,6 @@ class LifePex::Systems::UserSystem < LifePex::Systems::AuthSystem
post "/login", provides: 'html' do
if user = login(params)
cookies["auth"] = JWT.encode({ "user_id" => user[:id] }, LifePex::SECRET)
redirect "/"
else
slim :login, locals: { flash: { danger: "Failed to login" } }
@ -89,6 +83,40 @@ class LifePex::Systems::UserSystem < LifePex::Systems::AuthSystem
slim :about
end
get "/preferences" do
show_preferences
end
UserPreferenceCookie = Struct.new(:cookie, :allow_blank, :convert, :html, :description)
USER_PREFERENCES = {
"inputRecapDays" => UserPreferenceCookie.new("recap_days", false, :to_i, { type: "text" }, "Amount of days to show in the recap"),
"inputLateDayOffset" => UserPreferenceCookie.new("late_day_offset", false, :to_f, { type: "number", min: "0", max: "24", step: "0.1" }, "Offset for today (so a few hours after midnight is still today)"),
"inputShowFullDate" => UserPreferenceCookie.new("show_full_date", true, :to_s, { type: "checkbox" }, "Show or hide the date of the current tab"),
}
post "/preferences" do
USER_PREFERENCES.each do |param_name, upc|
current_param = params[param_name]
next if !upc.allow_blank && current_param.blank?
cookies[upc.cookie] =
case upc.convert
when Symbol
current_param.send(upc.convert)
when Proc
upc.convert.call(current_param)
else
current_param
end
end
show_preferences
end
def show_preferences
slim :preferences
end
extend DocMyRoutes::Annotatable
register Sinatra::Namespace
namespace '/api/user/v1' do

View File

@ -1,14 +1,57 @@
require_relative "./auth.rb"
require_relative "./api_response"
require "csv"
class LifePex::Systems::UserPexSystem < LifePex::Systems::AuthSystem
include JSON::API
include LifePex::Systems::ApiResponse
set :protection, :except => [:frame_options, :json_csrf]
extend DocMyRoutes::Annotatable # included by PexSystem
register Sinatra::Namespace # included by PexSystem
namespace "/api/user-pex/v1" do
summary "Export every single user pex and pex"
produces "application/json,application/csv"
status_codes [200]
# parameter :id, required: true, type: "integer", in: "path"
# parameter :date, required: true, type: "string", in: "path"
get "/export", auth: [], provides: %w(json application/csv) do
if accept? "application/csv"
export_csv
else
pexs = LifePex::Pex.where(user_id: current_user_id).map{ |pex| [pex, pex.user_pexs] }.to_h
api_response({ pexs: pexs, entity_type: "pexs" })
end
end
summary "Export every single user pex and pex as CSV"
produces "application/csv"
status_codes [200]
# parameter :id, required: true, type: "integer", in: "path"
# parameter :date, required: true, type: "string", in: "path"
get "/export.csv", auth: [], provides: %w(application/csv) do
export_csv
end
private def export_csv
pexs = LifePex::Pex.where(user_id: current_user_id).order(:id).all
pexs_names = pexs.map { |pex| "[#{pex.category}] #{pex.name}" }
pexs_ids = pexs.map(&:id)
user_pexs_by_date = LifePex::UserPex
.where(Sequel.qualify(:user_pexs, :user_id) => current_user_id)
.order_by(:created_at).all.group_by(&:created_at)
csv_output = CSV.generate do |csv|
csv << ["date", *pexs_names]
user_pexs_by_date.each do |date, user_pexs|
amount_by_column = user_pexs.group_by(&:pex_id).transform_values { |v| v.size }
user_pex_amount_by_date_all_column = pexs_ids.map { |id| amount_by_column[id] || 0 }
csv << [date, *user_pex_amount_by_date_all_column]
end
end
content_type "application/csv"
csv_output
end
namespace "/pexs" do
summary "Get the amount of user_pex for a given day"
@ -90,5 +133,6 @@ class LifePex::Systems::UserPexSystem < LifePex::Systems::AuthSystem
end
end
include LifePex::Systems::ApiList
end

11
src/utils/env.rb Normal file
View File

@ -0,0 +1,11 @@
require "dotenv"
def get_env
ENV.fetch("LIFEPEX_ENV") { "development" }
end
def load_dotenv(app_env = nil)
app_env ||= get_env
Dotenv.load(".env.#{app_env}.local", ".env.local", ".env")
app_env
end

View File

@ -1,3 +1,5 @@
require "active_support/all"
module JSON::API
def json_params
begin
@ -10,18 +12,26 @@ module JSON::API
end
end
def date_input_convertor(date)
if date && Date.respond_to?(date)
Date.send date
elsif date == "yesterday"
Date.today - 1
else
begin
Date.parse date
rescue => _
Date.today
DATE_GENERATOR = {
"yesterday" => ->() { DateTime.now - 24.hours },
"now" => ->() { DateTime.now },
"today" => ->() { DateTime.now },
nil => ->() { DateTime.now },
}
# @param [String] date: either a date iso formatted or a word to be sent to DateTime
# @param [Float] offset: an amount of hours to remove from the date, useful for setting the start of the day hours after midnight
def date_input_convertor(date = "now", offset = 0.0)
base_time =
if DATE_GENERATOR.key?(date)
DATE_GENERATOR[date].call()
else
begin
DateTime.parse date
rescue => _
DateTime.now
end
end
end
(base_time - offset.hours).to_date
end
# params:

15
src/utils/users.rb Normal file
View File

@ -0,0 +1,15 @@
module LifePex::UsersHelper
def my_user_pexs(cookies, date = nil)
params = {
user_id: user_id_decoded(cookies),
}
params[:created_at] = date unless date.nil?
LifePex::UserPex.where(params).all()
end
def get_user_date
date = cookies["date"]
offset = cookies["late_day_offset"].to_f
date_input_convertor(date, offset)
end
end

View File

@ -17,7 +17,10 @@
I engage my honor to do never read or modify personnal data you may have put on
the server, and do my best to ensure its security.
You should look at the code source if you want to audit it.
br
h2
| GDPR
p
| In case you want to take a look at your data or want to delete it, I may in the futur
provide a feature for do it yourself.
Meanwhile you can still drop me an issue or a message on &nbsp;
@ -26,6 +29,8 @@
| &nbsp;
i
| #lifepex.
a.btn.btn-success(href="/api/user-pex/v1/export.csv")
| Export as CSV
h2
| Service version

View File

@ -20,9 +20,13 @@ html lang="en"
- if cookies["date"] == "yesterday"
a.navbar-brand href="/"
| Yesterday
- if cookies["show_full_date"] == "on"
.smaller= get_user_date
- else
a.navbar-brand href="/"
| Today
- if cookies["show_full_date"] == "on"
.smaller= get_user_date
button.navbar-toggler type="button" data-bs-toggle="collapse" data-bs-target="#navbar-collapser" aria-controls="navbar-collapser" aria-expanded="false" aria-label="Toggle navigation"
span.navbar-toggler-icon/
.collapse.navbar-collapse#navbar-collapser
@ -35,13 +39,21 @@ html lang="en"
li.nav-item
a.btn.btn-lg.btn-dark href="/yesterday" Yesterday
li.nav-item
a.btn.btn-lg.btn-dark href="/recap" Recap
a.btn.btn-lg.btn-dark.position-relative href="/recap"
| Recap
- if (recalls_count = current_user.recalls_not_validated.count) > 0
span.position-absolute.top-0.start-100.translate-middle.badge.rounded-pill.bg-danger
= recalls_count
li.nav-item
a.btn.btn-lg.btn-dark href="/achievements" Achievements
li.nav-item
a.btn.btn-lg.btn-dark href="/recalls" Recalls
li.nav-item
a.btn.btn-lg.btn-dark href="/password" Change password
li.nav-item
a.btn.btn-lg.btn-dark href="/about" About lifepex
li.nav-item
a.btn.btn-lg.btn-dark href="/preferences" Profil preference
li.nav-item
a.btn.btn-lg.btn-dark href="/?filter_hidden=false" Show hidden
li.nav-item

View File

@ -28,7 +28,12 @@ tr.full-row(class=@row_class)
| <<
a.btn.btn-warning href="/pexs/update?id=#{pex_with_amount[:pex][:id]}"
| u
td.col-8=pex_with_amount[:pex][:name]
td.col-8
.row
.col-8
| #{pex_with_amount[:pex][:name]}
.col-4
.small=pex_with_amount[:user_pexs][:last_inserted_at]
td.col-1.center
form.userpexvalidation.userpexvalidationdecrease method="POST" action="/" onsubmit="return userpexValidation(event)"
== csrf_tag

View File

@ -15,7 +15,10 @@ main.col-md-9.ms-sm-auto.col-lg-10.px-md-4
input#inputUsername.form-control.form-control-lg name="name" type="text" value=pex&.name /
.form-group.row
label.col-sm-12.col-form-label for="inputCategory" Category
input#inputCategory.form-control.form-control-lg name="category" type="text" placeholder="sport" value=pex&.category /
input#inputCategory.form-control.form-control-lg name="category" type="text" placeholder="sport" list="categoryList" value=pex&.category /
datalist#categoryList
- categories.each do |category|
option value=category
.form-group.row
label.col-sm-12.col-form-label for="inputAmount" Xp Amount by check
input#inputAmount.form-control.form-control-lg name="amount" type="integer" min="-50" max="50" value=(pex&.amount||"1") /

View File

@ -0,0 +1,45 @@
.container
h1
| Profil preferences
h2
| Recap
form(method="POST")
== csrf_tag
/-
- USER_PREFERENCES.each do |param_name, upc|
- if upc.html[:type] == "checkbox"
.form-group.form-check.form-switch
label.col-sm-12.form-check-label for=param_name
strong=upc.description
- if cookies[upc.cookie] == "on"
input.form-check-input *upc.html name=param_name checked="on" /
- else
input.form-check-input *upc.html name=param_name /
- else
.form-group.row
label.col-sm-12.col-form-label for=param_name
strong=upc.description
input.form-control.form-control-lg *upc.html name=param_name value=cookies[upc.cookie] /
.form-group.row
input.btn.btn-lg.btn-block type="submit" value="Update"
- if LifePex::APP_ENV == "debug"
h2
| Debug
p
table
- cookies.each do |k, v|
tr
td= "cookies.#{k}"
td= v
tr
td
| get_user_date
td= get_user_date
h2
| Private data export
a.btn.btn-success(href="/api/user-pex/v1/export.csv")
| Export as CSV

View File

@ -0,0 +1,33 @@
main.col-md-9.ms-sm-auto.col-lg-10.px-md-4
h1
| Add a new Recall
form.col-md-6 method="POST" action="/recalls"
== csrf_tag
.form-group.row
label.col-sm-12.col-form-label for="name"
strong
| Name
input#name.form-control.form-control-lg name="name" type="text" /
.form-group.row
label.col-sm-12.col-form-label for="span_duration"
strong
| Span duration
input#span_duration.form-control.form-control-lg name="span_duration" type="number" value="7" /
.form-group.row
label.col-sm-12.col-form-label for="repeated"
strong
| Repeated
input#inputRepeated.form-control.form-control-lg name="repeated" type="number" value="1" /
.form-group.row
label.col-sm-12.col-form-label for="pex_id"
strong
| PexId
select.form-select.form-control-lg arial-label="Pex Id" name="pex_id"
- pexs.each.with_index do |pex, pex_idx|
- if pex_idx == 0
option value=pex[:id] selected="true" =pex[:name]
- else
option value=pex[:id] =pex[:name]
.form-group.row
input.btn.btn-lg.btn-block type="submit" value="create"

25
src/views/recalls.slim Normal file
View File

@ -0,0 +1,25 @@
h1
| Current recalls
a.btn.btn-dark href="/recalls/new"
| Add a new recall
table.table
thead
tr
th Name
th Span duration
th Repeated
th
tbody
- recalls.each do |recall|
tr
td=recall.name
td=recall.span_duration
td=recall.repeated
td
form method="POST" action="/recalls/delete"
== csrf_tag
input type="hidden" name="id" value=recall[:id]
button.btn.btn-danger type="submit" onclick="return confirm('Confirm to permanently REMOVE \'#{recall[:name]}\' ?')"
| x

View File

@ -2,6 +2,17 @@ script src="https://code.highcharts.com/highcharts.js"
h1 Recap
.recap-recalls
- if !recalls_not_validated.empty?
p
| You have a warning to consider!
h2
- recalls_not_validated.each do |recall|
span.badge.bg-danger
| #{recall[:name]}
br/
.recap-level
span.badge.bg-primary
| Level #{level.current_level.round}
@ -10,6 +21,15 @@ h1 Recap
| #{level.xp_from_current_level.round} / #{level.xp_for_complete_level.round}
.recap-xp
.float-end
form#reloader.col-md-6 method="GET"
== csrf_tag
.form-group.row
label.col-form-label for="inputDaysAgo" Load how many days since today ?
input#inputDaysAgo.form-control.form-control-lg name="days_ago" type="number" min="3" max="365" step="1" value=(params["days_ago"] || cookies["recap_days"] || "60") /
.form-group.row
p
input.btn.btn-lg.btn-block type="submit" value="Reload"
#recap-xp-container style="width:100%; height:400px;"
.recap-success

View File

@ -22,7 +22,7 @@ class UserSystemTest < LifePexTest
api_post '/api/user/v1/register', data
assert last_response.ok?
assert_not_empty last_response.headers["Set-Cookie"]
assert_match /auth=[\-\.\w]+; domain=example.org; path=\//, last_response.headers["Set-Cookie"]
assert_match (/auth=[\-\.\w]+; domain=example.org; path=\//), last_response.headers["Set-Cookie"]
end
def test_register_refuse_duplicate