# 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 = "" return button end def show_checkbox(name, checked) button = "= "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 "" 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 { "" + average_lag.to_s + "" } 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