diary/diary.rb

785 lines
28 KiB
Ruby

# SPDX-License-Identifier: Apache-2.0
# Copyright (C) 2021-2022 OKTET Labs Ltd. All rights reserved.
#
# Class Diary for Diary Management Application.
#
require_relative 'ldap_record'
require_relative 'project'
require_relative 'diary_policy'
require_relative 'diary_datamapper'
DIARY_TEMPLATE = AmritaTemplate.new("diary.html")
def debug_log(msg)
STDERR.puts "DEBUG: " + msg
end
def show_action_button(value, name)
button = "<button type=\"submit\" name=\"diary:action:verb\" "
button += "value=\"" + value + "\""
button += " class=\"btn btn-primary btn-sm\" _id=\"edit\">" + name + "</button>"
return button
end
def show_checkbox(name, checked)
button = "<input type=\"checkbox\" name=\"diary:list:" + name + "\" class=\"form-check-input\" _id=\"" + name + "\" "
button += "checked" if checked[0] == "on"
button += ">"
return noescape { button }
end
def transform_links(prj, text)
if prj and text
text = prj.transform_links(text)
end
return noescape { text }
end
# All timestamps in database are in UTC, so we convert them to localtime
# before printing if they are valid
class DbTime
@@offset = DateTime.now.offset
def DbTime.now
# DBI used previously DBI::Timestamp for mapping SQL DATETIME,
# now it is deprecated, DateTime is used
if DBI::VERSION >= "0.4"
DateTime.now.new_offset(0)
else
Time.now.utc
end
end
def initialize(t)
@time = t
end
def to_s
if @time.is_a? DBI::Timestamp
@time.to_time.localtime.strftime("%Y-%m-%d %H:%M:%S")
elsif @time.is_a? DateTime and @time.mjd > 0
@time.new_offset(@@offset).strftime("%Y-%m-%d %H:%M:%S")
elsif @time.class == Time
@time.to_time.localtime.strftime("%Y-%m-%d %H:%M:%S")
else
"<N/A>"
end
end
end
class DiaryState
NORMAL = 0
REQ_APPROVAL = 1
APPROVED = 2
REJECTED = 3
DENIED = 4
def DiaryState.valid(value)
value.to_i == NORMAL || value.to_i == APPROVED
end
def initialize(x)
@value = x
end
def to_s
case @value
when NORMAL then "regular"
when REQ_APPROVAL then "requires approval"
when APPROVED then "approved"
when REJECTED then "rejected"
when DENIED then "denied"
else "unknown"
end
end
def to_html
state_name = to_s
@value == NORMAL ? state_name :
Amrita.e(:span, :class =>
"state-" + state_name.tr_s(' ','-')){ state_name }
end
end
class DiaryMapper < DataMapper
def initialize
super("diary")
end
def instantiate(id, attributes)
Diary.new(id, attributes)
end
end
class DiaryPerson
attr_reader :person
attr_accessor :hours
attr_accessor :records
attr_accessor :lag
attr_accessor :week
def initialize(person)
@person = person
@hours = 0
@records = 0
@lag = 0
@week = Hash.new
Date::ABBR_DAYNAMES.each { |x| @week[x] = {:hours => 0, :records => 0} }
end
def register(hours, ddate, created)
@hours += hours.to_i
@records += 1
# We assume that 20:00 is the rough time people submit the diary
tlag = (created -((Time.strptime(ddate.to_s + " 00:00:00", "%Y-%m-%d %H:%M:%S")) + 20*60*60))/60/60/24
if tlag > 0
@lag += tlag
end
week_day_name = Date::ABBR_DAYNAMES[ddate.wday]
@week[week_day_name][:hours] += hours.to_i
@week[week_day_name][:records] += 1
end
def lag_to_html
average_lag = (@lag / @records).round(1).to_f
average_lag = 0 if average_lag < 0
if average_lag > 5
css_style = 'text-danger'
elsif average_lag > 2
css_style = 'text-warning'
elsif average_lag < 0.3
css_style = 'text-muted'
else
css_style = 'text-body'
end
noescape { "<span class=\"" + css_style + "\">" + average_lag.to_s + "</span>" }
end
def week_to_html
out = ""
@week.each { |key, record|
if record[:hours] > 0
out = out + key + ": " + record[:hours].to_s + " "
end
}
out
end
def day_to_html(day, d_start, d_end)
# calculate number of days between s_date and e_date
day_number = DateTime.parse(day).wday
days_list = (d_start..d_end).to_a.select { |d| day_number == d.wday }
number_of_days = days_list.count
if not @week.has_key?(day) or @week[day][:records] == 0
""
else
Rational(@week[day][:hours], number_of_days).round(+1).to_f.to_s
end
end
end
class Diary
@@mapper = nil
def self.mapper
return @@mapper unless @@mapper.nil?
@@mapper = DiaryMapper.new
end
def initialize(id = nil, values = nil)
@id = id
@attributes = values ? values : Hash.new
@approval_note = nil
end
def self.get(id)
mapper.find(id)
end
def project
@attributes.has_key?("prj_id") ?
Project.get(@attributes["prj_id"]) : nil
end
def who
Person.find_or_create(@attributes["who"])
end
def approval_note
policy = DiaryEnv.instance.policy
return [] if (not policy.can_edit?(project, who) and
not policy.can_approve?(project, who)) or
DiaryState.valid(@attributes["state"].to_i)
@approval_note = DataMapper.database.query("select * from approval_note where id = '%s'" % @id).collect { |row| row.to_h }
return @approval_note
end
def self.approve_all(prj, who)
DataMapper.database.query("update diary set state = '%s' where prj_id = '%s' AND who = '%s' AND state IN ('%s','%s')" %
[
DiaryState::APPROVED,
DataMapper.database.escape(prj.id.to_s),
DataMapper.database.escape(who.to_s),
DiaryState::REQ_APPROVAL, DiaryState::REJECTED
])
end
def destroy
end
def [](tag)
@attributes[tag]
end
def []=(tag, value)
@attributes[tag] = value
end
def self.predict_project(who)
# TODO: select the most used of N last entries
last_diary = DataMapper.database.query("SELECT prj_id FROM diary WHERE who='%s' ORDER BY ddate DESC LIMIT 1" % who.uid ).first
return last_diary ? last_diary["prj_id"].to_i : nil
end
def self.find(args)
who = args[:who]
prj_id = args[:prj_id]
customer = args[:customer]
policy = DiaryEnv.instance.policy
dates = args[:ddate]
raise ArgumentError unless dates.is_a? Range
query_clause = " where ddate >= '%s' and ddate <= '%s'"
query_args = [dates.first, dates.last]
if policy.restriction.include?(:project)
prj_list = policy.project_list.collect {|prj| prj.id }
prj_list = prj_list & [prj_id] if prj_id
return [] if prj_list.empty?
query_clause += " AND prj_id IN (" +
prj_list.collect { "'%s'" }.join(",") + ")"
query_args += prj_list
elsif prj_id
query_clause += " AND prj_id = '%s'"
query_args = query_args << prj_id
end
if policy.restriction.include?(:engineer)
eng_list = policy.engineer_list.collect {|eng| eng.uid }
eng_list = eng_list & [who] if who
return [] if eng_list.empty?
query_clause += " AND who IN (" +
eng_list.collect { "'%s'" }.join(",") + ")"
query_args += eng_list
elsif who
query_clause += " AND who = '%s'"
query_args = query_args << who
end
if customer
query_clause += " AND customer = '%s'"
query_args = query_args << customer
end
DataMapper.database.query("select diary.*, project.customer, " +
" project.leader, project.manager, diary.id as id" +
", diary.state as state" +
" from diary left join project on diary.prj_id = project.id" +
query_clause % (query_args) + " order by ddate, prj_id, who"
).collect do |row|
self.new(row["id"], row.to_h)
end
end
def self.for_approve(who)
DataMapper.database.query("select *, diary.id as id from" +
" diary inner join approval on approval.who = diary.who and" +
" approval.prj_id = diary.prj_id left join project on" +
" diary.prj_id = project.id where approval.approver = '%s'" % who.uid +
" and diary.state = '%s' order by diary.ddate" % DiaryState::REQ_APPROVAL
).collect do |row|
self.new(row["id"], row.to_h)
end
end
def self.add_approval_note(id, message)
if message.is_a?(String) and message.length > 0
DataMapper.database.query("insert into approval_note(id,created,author,note) values ('%s',%s,'%s','%s')" %
[
DataMapper.database.escape(id.to_s),
"STR_TO_DATE('%s'," % DbTime.now + " '%Y-%m-%dT%h:%i:%s+00:00')",
DataMapper.database.escape(DiaryEnv.instance.user.uid),
DataMapper.database.escape(message)
])
end
end
end
class DiaryUI
def initialize(cgi)
@cgi = cgi
@user = DiaryEnv.instance.user
@policy = DiaryEnv.instance.policy
@diary_table = SQL_Cache.new(DataMapper.database, "diary", "")
@projects = nil
end
# Array of projects suitable for diary entries
def projects
return @projects unless @projects.nil?
@projects = Project.all(:prj_status => 'ACTIVE')
end
def project_list
projects.sort.collect do |prj|
[prj.id, prj.to_s]
end
end
def customer_list
projects.collect {|prj| prj.customer}.uniq.sort.collect do |org|
[org.uid, org.to_s]
end
end
def show_total(list, d_start, d_end, detailed)
totals = Hash.new
totals[:customer] = Array.new
totals[:person] = Array.new
totals[:hours] = 0
persons = Hash.new
projects = Hash.new
customers = Hash.new
list.each do |x|
who = Person.find_or_create(x["who"])
prj = Project.get(x["prj_id"])
next if not @policy.can_account?(prj)
if DiaryState.valid(x["state"].to_i)
projects[prj] =
projects[prj].to_i + x["hours"].to_i
if not persons[who]
persons[who] = DiaryPerson.new(who)
end
persons[who].register(x["hours"], x["ddate"], x["created"])
totals[:hours] += x["hours"].to_i
end
end
projects.sort.each do |item|
prj = item[0]
org = Organization.find(prj["customer"])
customers[org] = Array.new unless customers[org]
customers[org].push({:name => prj["name"],
:customer => prj["customer"],
:tag => prj["tag"],
:hours => item[1]})
end
customers.sort.each do |item|
hours = item[1].inject(0) { |sum, value| sum += value[:hours] }
totals[:customer].push({:name => item[0].to_s,
:hours => hours,
:project => item[1]})
end
if detailed
persons.sort.each do |item|
dp = item[1]
totals[:person].push({:name => item[0].to_html,
:hours => dp.hours,
:delay => dp.lag_to_html,
:mon_stat => dp.day_to_html('Mon', d_start, d_end),
:tue_stat => dp.day_to_html('Tue', d_start, d_end),
:wed_stat => dp.day_to_html('Wed', d_start, d_end),
:thu_stat => dp.day_to_html('Thu', d_start, d_end),
:fri_stat => dp.day_to_html('Fri', d_start, d_end),
:sat_stat => dp.day_to_html('Sat', d_start, d_end),
:sun_stat => dp.day_to_html('Sun', d_start, d_end),
})
end
else
persons.sort.each do |item|
totals[:person].push({:name => item[0].to_html,
:hours => item[1].hours,
})
end
end
return totals[:hours] > 0 ? totals : nil
end
private :show_total
def show_approval_notes(entry)
return nil if entry.approval_note.empty?
entry.approval_note.collect do |note|
{
:created => DbTime.new(note["created"]).to_s,
:author => note["author"],
:note => note["note"]
}
end
end
def show_one(values = nil, edit = false, approve = false)
if (values == nil)
edit = add = true
values = Diary.new
else
add = false
edit = false if values["state"].to_i == DiaryState::DENIED
end
edit ||= add
# Set project to the last edited one (for new entries)
if not values.project
values["prj_id"] = Diary.predict_project(@user)
end
prj = Project.get(values["prj_id"].to_i)
if edit
edit_data = {
:hiddens => @cgi.hiddens(@cgi.tree_flatten(["diary",
"list"]).delete_if do |x|
x == ["diary:list:edit", values["id"].to_s]
end),
:date => MyCGI::select_date_e("diary:edit:ddate",
values["ddate"] || Date.today, edit),
:person => MyCGI::list_options(
@policy.engineer_list.collect {|x| [x.uid, x.to_s]},
(values["who"] || @user.uid)),
:hours => a(:value => values["hours"]),
:project => MyCGI::list_options(project_list,
prj ? prj.id : ""),
:descr => values["descr"]
}
edit_data[:id] = a(:value => values["id"]) if values["id"]
if prj and prj.approvals.has_key?(values["who"])
edit_data[:approval] = Hash.new
edit_data[:approval][:appnote] = show_approval_notes(values)
end
return { :edit => edit_data }
else
view = {
:id => a(:name => values["id"]),
:hiddens => @cgi.hiddens(@cgi.tree_flatten(["diary", "list"])),
:date => MyCGI::select_date_e("diary:edit:ddate",
values["ddate"] || Date.today, edit),
:person => Person.find_or_create(values["who"]).to_html,
:project_name => prj["name"],
:project => prj["customer"]+ "/" + prj["tag"],
:descr => transform_links(prj, values["descr"]),
:edit_id => a(:value => values["id"]),
:modified => DbTime.new(values["modified"]).to_s,
:created => DbTime.new(values["created"]).to_s,
:state => DiaryState.new(values["state"].to_i).to_html
}
view[:hours] = values["hours"].to_s + " h" if
@policy.can_account?(prj)
view[:appnote] = show_approval_notes(values) if
not values.approval_note.empty?
if @policy.can_edit?(prj, Person.find(values["who"])) or approve
view[:edit] = { }
return { :view => a(:action => "#" +
MyCGI::element_id(values["id"])){ view }}
else
return { :view_only => view }
end
end
end
private :show_one
def menu
{
:start_date => MyCGI::select_date_e("diary:list:start",
@cgi.tree_params["diary"]["list"]["start"]),
:end_date => MyCGI::select_date_e("diary:list:end",
@cgi.tree_params["diary"]["list"]["end"]),
:project => MyCGI::list_options([["*", "- All -"]] +
project_list,
@cgi.tree_params["diary"]["list"]["prj_id"]),
:customer => MyCGI::list_options([["*", "Customer: all"]] +
customer_list,
@cgi.tree_params["diary"]["list"]["customer"]),
:who => MyCGI::list_options([["*", "Engineer: all"]] +
@policy.engineer_list.collect {|x| [x.uid, x.to_s]},
@cgi.tree_params["diary"]["list"]["who"]),
:newest_first => show_checkbox("newest_first", @cgi.tree_params["diary"]["list"]["newest_first"]),
:just_stats => show_checkbox("just_stats", @cgi.tree_params["diary"]["list"]["just_stats"])
}
end
def show
diary_data = Hash.new
MyCGI::add_commons(diary_data)
diary_data[:menu] = menu
diary_data[:new] = show_one() if @user.local?
if @cgi.tree_params["do"] == "diary"
d_start = MyCGI::hash2date(
@cgi.tree_params["diary"]["list"]["start"])
d_end = MyCGI::hash2date(
@cgi.tree_params["diary"]["list"]["end"])
prj_id = @cgi.tree_params["diary"]["list"]["prj_id"][0].to_i
prj_id = nil if prj_id <= 0
customer = @cgi.tree_params["diary"]["list"]["customer"][0]
customer = nil if not Organization.exists?(customer)
who = @cgi.tree_params["diary"]["list"]["who"][0].strip.downcase
who = nil if who == "" or who == "*"
list_approval = Diary.for_approve(@user)
if list_approval.length > 0
diary_data[:massapproval] = a(:__id__)
diary_data[:approvals] = { :entries =>
list_approval.inject([]) do |data, item|
data << a(:__id__ => MyCGI::element_id(item["id"])){
show_one(item, false, true)}
end
}
end
list = Diary.find(:who => who,
:prj_id => prj_id,
:customer => customer,
:ddate => (d_start..d_end))
list.delete_if do |entry|
not DiaryState.valid(entry["state"]) and
not @policy.can_edit?(entry.project, entry.who)
end
if list.length > 0
if @policy.is_director?
diary_data[:detailed_totals] = show_total(list, d_start, d_end, @policy.is_director?)
else
diary_data[:totals] = show_total(list, d_start, d_end, false)
end
if @cgi.tree_params["diary"]["list"]["just_stats"][0] != "on"
if @cgi.tree_params["diary"]["list"]["newest_first"][0] == "on"
list.reverse!
end
diary_data[:details] = {
:entries =>
list.inject([]) do |data, item|
data << a(:__id__ => MyCGI::element_id(item["id"])){
show_one(item,
@cgi.tree_params["diary"]["list"]["edit"].
include?(item["id"].to_s)) }
end
}
else
diary_data[:nodata] = {}
end
else
diary_data[:nodata] = {}
end
end
s = String.new
DIARY_TEMPLATE.expand(s, diary_data)
s
end
def approve_item(id, action)
entry = @diary_table[id].dup
raise "Entry does not exist" if not entry
raise "You have no rights to approve diary entries for " +
"#{Person.find(entry["who"]).to_s} in this project." if
not @policy.can_approve?(Project.get(entry["prj_id"]),
Person.find_or_create(entry["who"]))
entry["state"] = case action
when "Approve" then DiaryState::APPROVED
when "Deny" then DiaryState::DENIED
when "Reject" then DiaryState::REJECTED
end
@diary_table.modify(id, entry)
return if not @cgi.tree_params["diary"].include?("edit")
data = @cgi.tree_params["diary"]["edit"]
Diary.add_approval_note(id, data["approval"])
end
def approval_action
id = @cgi.tree_params["diary"]["action"]["id"].to_i
action = @cgi.tree_params["diary"]["action"]["verb"]
approve_item(id, action)
end
def list_approval_action
list_action = @cgi.tree_params["diary"]["action"]["verb"]
action = case list_action
when "ApproveAll" then "Approve"
when "DenyAll" then "Deny"
when "RejectAll" then "Reject"
end
list_approval = Diary.for_approve(@user)
if list_approval.length > 0
list_approval.each do |item|
approve_item(item["id"], action)
end
end
end
def action
debug_log(@cgi.tree_params.inspect)
# Delete an entry
if @cgi.tree_params["diary"]["action"]["verb"] == "Delete"
id = @cgi.tree_params["diary"]["action"]["id"].to_i
entry = Diary.get(id)
raise "You are trying to delete diary entry #{id} " +
"which does not exist." if not entry
raise "You have no rights to delete diary entries for " +
"#{entry.who.to_s} in this project." if
not @policy.can_edit?(entry.project, entry.who)
raise NeedConfirm.new("delete"),
"You are deleting diary entry of " +
"#{entry.who.to_s} for #{entry["ddate"]}!" if
not DiaryEnv.confirmed?("delete")
@diary_table.delete(id)
return
end
if @cgi.tree_params["diary"]["action"]["verb"] == "ApproveAll" or
@cgi.tree_params["diary"]["action"]["verb"] == "RejectAll" or
@cgi.tree_params["diary"]["action"]["verb"] == "DenyAll"
list_approval_action
return
end
if @cgi.tree_params["diary"]["action"]["verb"] == "Approve" or
@cgi.tree_params["diary"]["action"]["verb"] == "Reject" or
@cgi.tree_params["diary"]["action"]["verb"] == "Deny"
approval_action
return
end
# After adding/editing entry
return if not @cgi.tree_params["diary"].include?("edit")
data = @cgi.tree_params["diary"]["edit"].dup
# normalize input
data["who"].strip!
data["who"].downcase!
raise "No such person #{data["who"]} " if not Person.exists?(data["who"])
data["prj_id"] = data["prj_id"].to_i
prj = Project.get(data["prj_id"])
who = Person.find(data["who"])
raise "No such project #{data["prj_id"]}" unless prj
raise "Project #{prj.to_s} is terminated" if
prj["prj_status"] == "TERMINATED"
raise "You have no rights to add/change diary records " +
"for #{who.to_s}." if not @policy.can_edit?(prj, who)
data["hours"] = data["hours"].to_i
data["ddate"] = MyCGI::hash2date(data["ddate"])
raise NeedConfirm.new("future"),
"You're posting diary for the future" if
data["ddate"] > Date.today and not DiaryEnv.confirmed?("future")
raise "Don't use non-English symbols - not all customers can read Russian" if
not data["descr"].ascii_only?
raise "Please use at least 10 characters in diary entry description" if
data["descr"].length < 10
# Check total hours
total = DataMapper.database.query("SELECT SUM(hours) as sum FROM diary WHERE who='%s' AND ddate='%s'" %
[
data["who"],
data["ddate"]
] + (data.include?("id") ? " AND id<>#{data["id"]}" : "")
).first["sum"].to_i +
data["hours"]
raise "You have more than 24 hours " +
"for #{data["ddate"]}" if total > 24
raise NeedConfirm.new("overwork"),
"You have more (#{total}) than 15 hours " +
"for #{data["ddate"]}" if
total > 15 and not DiaryEnv.confirmed?("overwork")
if @policy.needs_confirmation?(prj, who)
raise NeedConfirm.new("past"), "You are " +
(data.include?("id") ? "editing" : "adding") +
" a diary record for #{data["ddate"]} that is in the past" if
data["ddate"] < Date.today and not DiaryEnv.confirmed?("past")
end
raise NeedConfirm.new("progressive"),
"You are trying to add #{total} hours " +
"for today, while it is just #{Time.now.hour} hours now" if
data["ddate"] == Date.today and total > Time.now.hour and
not DiaryEnv.confirmed?("progressive")
data["state"] = (prj.approvals.has_key?(data["who"]) ?
DiaryState::REQ_APPROVAL : DiaryState::NORMAL)
# Automatically reject creating (not editing) too old entry
if not data.include?("id") and
data["state"] == DiaryState::REQ_APPROVAL and
Date.today - data["ddate"] > DiaryEnv::DIARY_MAX_AGE
if DiaryEnv.confirmed?("too_old")
data["state"] = DiaryState::REJECTED
else
raise NeedConfirm.new("too_old"),
"Date is too old, entry will be rejected"
end
end
# Insert/replace DB entry
data["modified"] = DbTime.now
if data.include?("id")
data.delete("id")
if data["hours"].to_i == 0
@diary_table.delete(@cgi.tree_params["diary"]["edit"]["id"])
else
@diary_table.modify(@cgi.tree_params["diary"]["edit"]["id"],
data)
end
else
raise "You should specify at least 1 hour" if data["hours"] < 1
raise "You are inserting a duplicate diary record!" if
DataMapper.database.query(
"SELECT COUNT(*) as count FROM diary WHERE " +
"who = '%s' and descr = '%s' and hours = '%s' and ddate = '%s'" %
[
DataMapper.database.escape(data["who"].to_s),
DataMapper.database.escape(data["descr"].to_s),
DataMapper.database.escape(data["hours"].to_s),
DataMapper.database.escape(data["ddate"].to_s)
]
).first["count"].to_i > 0
data["created"] = data["modified"]
@diary_table.create(data)
end
Diary.add_approval_note(@cgi.tree_params["diary"]["edit"]["id"].to_i,
data["approval"]) if
data["approval"] and data["approval"].length > 0
# Adjust start/end day of the list to make new record visible
@cgi.tree_params["diary"]["list"]["start"] =
MyCGI::date2hash(data["ddate"]) if data["ddate"] <
MyCGI::hash2date(@cgi.tree_params["diary"]["list"]["start"])
@cgi.tree_params["diary"]["list"]["end"] =
MyCGI::date2hash(data["ddate"]) if data["ddate"] >
MyCGI::hash2date(@cgi.tree_params["diary"]["list"]["end"])
end
end