class ASF::MeetingUtil
Constants
- APPLICATION_EXPIRY_POST_VOTE_DAYS
www.apache.org/foundation/bylaws.html#article-iv application must be received by the Secretary no later than 30 days following the vote. Current thinking is that the vote is considered to have occurred when the results are announced. TBC
- APPLICATION_EXPIRY_POST_VOTE_SECS
- MEETINGS_DIR
- MEETING_FILES
The URL is generated using emit_link() in meeting.cgi if the name includes ‘/’ then use as is unless it starts with ‘runbook/’
- PROXIES_FILENAME
- RECORDS
- VCAL_EVENTS_FILENAME
Public Class Methods
Annotate the attendance.json file with cohorts by id This allows easy use by other tools
# File lib/whimsy/asf/meeting-util.rb, line 271 def self.annotate_attendance(dir) attendance = JSON.parse(IO.read(File.join(dir, 'attendance.json'))) memapps = read_memapps(dir) iclas = ASF::ICLA.preload memapp_map = JSON.parse(IO.read(File.join(dir, 'memapp-map.json'))) attendance['cohorts'] = {} attendance['unmatched'] = [] attendance['members'].each do |date, ary| next unless date.start_with? '20' # exclude 'active' ary.each do |nam| found = iclas.select{|i| i.icla.legal_name == nam} found = iclas.select{|i| i.icla.name == nam} if found.empty? if found.empty? if memapps.has_key?(nam) attendance['cohorts'][memapps[nam][0]] = date elsif memapp_map.has_key?(nam) attendance['cohorts'][memapp_map[nam]] = date else attendance['unmatched'] << nam end else attendance['cohorts'][found[0].icla.id] = date end end end File.open(File.join(dir, 'attendance-cohorts.json'), 'w') do |f| # Do not overwrite blindly; manual copy if desired f.puts JSON.pretty_generate(attendance) end end
How long remains before applications close? (Time is measured from scheduled end of the meeting in which the votes were declared) Returned as hash, e.g. {:hoursremain=>605, :days=>25, :hours=>5} If applications have expired, :hoursremain is negative and :days/:hours are elapsed time since expiry
# File lib/whimsy/asf/meeting-util.rb, line 438 def self.application_time_remaining meetingend = self.meeting_end # this is in seconds now = DateTime.now.to_time.to_i remain = (meetingend + APPLICATION_EXPIRY_POST_VOTE_SECS - now) / 3600 {hoursremain: remain, days: remain.abs/24, hours: remain.abs%24} end
Is this particular membership application still valid? Used to check if an application was received before the close date return: true/false
# File lib/whimsy/asf/meeting-util.rb, line 455 def self.application_valid?(message_datetime) expirytime = self.meeting_end + APPLICATION_EXPIRY_POST_VOTE_SECS msgtime = DateTime.iso8601(message_datetime).to_time.to_i msgtime <= expirytime end
Are membership applications still valid? Applications close date has yet to be reached return: true/false
# File lib/whimsy/asf/meeting-util.rb, line 448 def self.applications_valid self.application_time_remaining[:hoursremain] > 0 end
Calculate how many members required to attend first half for quorum Returns: num_members, quorum_need, num_proxies, attend_irc where: num_members = number of active members (taken from ‘record’ if possible, else members.txt) quorum_need = (num_members + 2) / 3 num_proxies = number of files under ‘proxies-received’ attend_irc = quorum_need - num_proxies
# File lib/whimsy/asf/meeting-util.rb, line 56 def self.calculate_quorum(mtg_dir) begin begin num_members = File.read(File.join(mtg_dir, 'record')).each_line.count rescue num_members = ASF::Member.list.length - ASF::Member.status.length end quorum_need = (num_members + 2) / 3 num_proxies = Dir[File.join(mtg_dir, 'proxies-received', '*')].count attend_irc = quorum_need - num_proxies attend_irc = 0 if attend_irc < 0 # allow for more proxies than quorum rescue StandardError => e # Ensure we can't break rest of script puts "ERROR: #{e}" return 0, 0, 0, 0 end return num_members, quorum_need, num_proxies, attend_irc end
return a function to determine the current status of a member by id
# File lib/whimsy/asf/meeting-util.rb, line 334 def self.current_status(cur_mtg_dir) proxies = Dir["#{cur_mtg_dir}/proxies-received/*"]. map {|file| File.basename(file, '.*')} _tag,emeritus = ASF::SVN.getlisting('emeritus-requests-received') emeritus.map! {|file| File.basename(file, '.*')} lambda do |id| if emeritus.include? id 'Emeritus request received' elsif proxies.include? id 'Proxy received' else 'No response' end end end
get list of proxy assignments returns array of: [proxy, subject, subject id]
# File lib/whimsy/asf/meeting-util.rb, line 93 def self.getProxyAssignments(mtg_dir=nil) _, assignments = self.parseProxies(mtg_dir) hdr = assignments.shift # work out the column layout re = %r{^((\s+)<name>\s+)<name>} if hdr.match re total, offset = [$1.length, $2.length] else raise ArgumentError, "proxies: bad header '#{hdr}'" end assignments.map do |line| proxy = line[offset..total-1].strip if line[total..-1].strip.match %r{(.+) +\((.+)\)} proxied = $1 uid = $2 else raise ArgumentError, "proxies: bad assignment '#{line}'" end [proxy, proxied, uid] end end
get list of proxy nominees
# File lib/whimsy/asf/meeting-util.rb, line 116 def self.getProxyNominees(mtg_dir=nil) assignments = self.getProxyAssignments(mtg_dir) assignments.map do |line| line[0] end.uniq end
get list of proxy volunteers
# File lib/whimsy/asf/meeting-util.rb, line 86 def self.getVolunteers(mtg_dir=nil) volunteers, _ = self.parseProxies(mtg_dir) volunteers.each.filter_map {|line| l = line.strip; l if l.length > 0} end
Precompute matrix and dates from attendance
# File lib/whimsy/asf/meeting-util.rb, line 302 def self.get_attend_matrices(dir) attendance = MeetingUtil.get_attendance(dir) # extract and format dates dates = attendance['dates'].sort. map {|date| Date.parse(date).strftime('%Y-%b')} # compute mappings of names to ids members = ASF::Member.list active = Hash[members.select {|_id, data| not data['status']}] nameMap = Hash[members.map {|id, data| [id, data[:name]]}] idMap = Hash[nameMap.to_a.map(&:reverse)] # analyze attendance matrix = attendance['matrix'].map do |name, meetings| id = idMap[name] next unless id and active[id] # exclude 'active entry' data = meetings.select {|key, value| key.start_with? '20'}. sort.reverse.map(&:last) first = data.length missed = (data.index {|datum| datum != '-'} || data.length) [id, name, first, missed] end return attendance, matrix.compact, dates, nameMap end
Read attendance.json file
# File lib/whimsy/asf/meeting-util.rb, line 174 def self.get_attendance(mtg_root) return JSON.parse(IO.read(File.join(mtg_root, 'attendance.json'))) end
get the times from the VCAL events file returns: hash with keys: nominations_close
:, polls_close
:, meeting_start
, meeting_close:
# File lib/whimsy/asf/meeting-util.rb, line 380 def self.get_invite_times times = {} File.readlines(File.join(latest_meeting_dir, VCAL_EVENTS_FILENAME)).slice_before(/^BEGIN:VEVENT/).drop(1).each do |ev| uid = nil dtstart = dtend = nil ev.each do |line| case line when /^UID:(.+)/ uid = $1.chomp.sub(/-?\d{4}/, '') when /^DTSTART;TZID=(.+):(.+)/ tz = $1 if tz == 'UTC' dtstart = DateTime.iso8601($2.chomp).to_time.to_i else raise ArgumentError.new("Cannot parse #{line.chomp} in #{VCAL_EVENTS_FILENAME}") end when /^DTEND;TZID=(.+):(.+)/ tz = $1 if tz == 'UTC' dtend = DateTime.iso8601($2.chomp).to_time.to_i else raise ArgumentError.new("Cannot parse #{line.chomp} in #{VCAL_EVENTS_FILENAME}") end end end times[uid] = dtstart times['asf-members-end'] = dtend if uid == 'asf-members' end return { nominations_close: times['asf-members-nominations-close'], polls_close: times['asf-members-polls-close'], meeting_start: times['asf-members'], meeting_end: times['asf-members-end'], } end
Get the latest available Meetings dir
# File lib/whimsy/asf/meeting-util.rb, line 164 def self.get_latest(mtg_root) return Dir[File.join(mtg_root, '2*')].max end
Get the latest completed Meetings dir (i.e. has raw-irc-log; this can be overridden) TODO: is that the most appropriate file to check?
# File lib/whimsy/asf/meeting-util.rb, line 155 def self.get_latest_completed(mtg_root, sentinel='raw-irc-log') return Dir[File.join(mtg_root, '2*')].select {|d| File.exist? File.join(d, sentinel) }.max end
# File lib/whimsy/asf/meeting-util.rb, line 159 def self.get_latest_file(file='.', mtg_root=nil) return Dir[File.join(mtg_root || MEETINGS_DIR, '2???????', file)].max end
Get the second latest available Meetings dir
# File lib/whimsy/asf/meeting-util.rb, line 169 def self.get_previous(mtg_root) return Dir[File.join(mtg_root, '2*')].sort[-2] end
Get proxy info for current user @return “help text”, [“id | name (proxy)”, …] if they are a proxy for other(s) @return “You have already submitted a proxy form” to someone else @return nil otherwise
# File lib/whimsy/asf/meeting-util.rb, line 127 def self.is_user_proxied(mtg_dir, id) proxylist = self.getProxyAssignments(mtg_dir) user = ASF::Person.find(id) help = nil copypasta = [] # theiravailid | Their Name in Rolls (proxy) max_uid_len = 16 # for alignment begin proxylist.each do |proxy, subject, uid| if user.cn == proxy copypasta << "#{uid.ljust(max_uid_len)} | #{subject} (proxy)" elsif user.id == uid help = "NOTE: You have already submitted a proxy form for #{proxy} to mark your attendance (be sure they know to mark you at Roll Call)! " end end rescue StandardError => e (help ||= '') << "ERROR, could not read LDAP, proxy data may not be correct: #{e.message}" end if copypasta.empty? return help else (help ||= '') << "During the meeting, to mark your proxies' attendance, AFTER the 2. Roll Call is called, you may copy/paste the below lines to mark your and your proxies attendance." copypasta.unshift("#{user.id.ljust(max_uid_len)} | #{user.cn}") return help, copypasta end end
return the dir containing the latest meeting files
# File lib/whimsy/asf/meeting-util.rb, line 353 def self.latest_meeting_dir MeetingUtil.get_latest(MEETINGS_DIR) end
# File lib/whimsy/asf/meeting-util.rb, line 429 def self.meeting_end self.get_invite_times[:meeting_end] end
# File lib/whimsy/asf/meeting-util.rb, line 425 def self.meeting_start self.get_invite_times[:meeting_start] end
Shorthand methods for callers
# File lib/whimsy/asf/meeting-util.rb, line 417 def self.nominations_close self.get_invite_times[:nominations_close] end
parse the proxies file
# File lib/whimsy/asf/meeting-util.rb, line 76 def self.parseProxies(mtg_dir=nil) mtg_dir ||= latest_meeting_dir lines = IO.readlines(File.join(mtg_dir, PROXIES_FILENAME)) parts = lines.slice_before(%r{^(Volunteers|Assignments):}).drop(1) volunteers = parts.shift.drop(3) # heading assignments = parts.shift.drop(4) return volunteers, assignments end
parse a memapp file, optionally returning the format Params:
- path to file; if omitted, pick the latest found - parse header to extract format, default false
Does not support files before 2010 Return: array of arrays or [array of arrays, format, hdr lines] The original contents can be regenerated as follows: Parse the file:
list,hdr,fmt = ASF::MeetingUtil.parse_memapp(nil, true)
Regenerate an indidividual entry: fmt % entry Regenerate all the contents:
[hdr, list.map{|item| fmt % item}].join("\n")
N.B. you may need to add a trailing EOL or two when writing the file
# File lib/whimsy/asf/meeting-util.rb, line 205 def self.parse_memapp(path=nil,header=false) path ||= get_latest_file('memapp-received.txt') text = File.read(path) # latest layout; look for at least one yes column; trim the user name list = text.scan(/^(no|yes)\s+(no|yes)(?:\s+(no|yes)\s+(no|yes))?\s+(\S+)\s+(.+)/).each {|a| a.last.strip!} if header hdr = text.split(/\R/)[0..1] # Assume 2 line header # Assume 6 columns for now hyphens=hdr[1].scan(/^(--+ +)(---+ +)(---+ +)(---+ +)(---+ +)(----+ *)$/).first hyphens.pop # drop last; don't want to pad that fmt = [hyphens.map{|h| '%%-%ds' % (h.size - 1)},'%s'].join(' ') return [list, hdr, fmt] else return list end end
parse a memapp file; if omitted, pick the latest found optionally return the line format and key list Does not support files before 2010 Return: array of hash entries with the symbolic keys: :invite :apply :mail :karma :id :name optionally followed by format, keylist, hdr The original contents can be regenerated as follows: Parse the file:
list,hdr,fmt,keys = ASF::MeetingUtil.parse_memapp_to_h(nil,true)
Regenerate an indidividual entry: fmt % keys.map{|key| entry} Regenerate all the contents:
[hdr, list.map{|item| fmt % keys.map{|key| item[key]} }].join("\n")
N.B. you may need to add a trailing EOL or two when writing the file
# File lib/whimsy/asf/meeting-util.rb, line 237 def self.parse_memapp_to_h(path=nil,header=false) keys = %i(invite apply mail karma id name) res = self.parse_memapp(path, header) if header list, hdr, fmt = res # split the response return [list.map{|entry| keys.zip(entry).to_h}, hdr, fmt, keys] else return res.map{|entry| keys.zip(entry).to_h} end end
# File lib/whimsy/asf/meeting-util.rb, line 421 def self.polls_close self.get_invite_times[:polls_close] end
Parse all memapp-received.txt files to get better set of names @see whimsy/www/members/attendance-xcheck.cgi
# File lib/whimsy/asf/meeting-util.rb, line 250 def self.read_memapps(dir) memapps = Hash.new('unknown') Dir[File.join(dir, '*', 'memapp-received.txt')].each do |received| meeting = File.basename(File.dirname(received)) next if meeting.include? 'template' text = File.read(received) list = text.scan(/(.+)\s<(.*)@.*>.*Yes/i) # early layout if list.empty? # latest layout; look for at least one yes column list = text.scan(/^(?:no\s*)*(?:yes\s+)+(\w\S*)\s+(.*)\s*/) else # reverse order of id name type files list.each {|a| a[0], a[1] = a[1], a[0] } end list.each { |itm| memapps[itm[1].strip] = [itm[0], meeting] } end return memapps end
return the current status of all inactive members
# File lib/whimsy/asf/meeting-util.rb, line 358 def self.tracker(meetingsMissed) cur_mtg_dir = MeetingUtil.get_latest(MEETINGS_DIR) current_status = self.current_status(cur_mtg_dir) _attendance, matrix, dates, _nameMap = MeetingUtil.get_attend_matrices(MEETINGS_DIR) inactive = matrix.select do |id, _name, _first, missed| id and missed >= meetingsMissed end Hash[inactive.map {|id, name, first, missed| [id, { 'name' => name, 'missed' => missed, 'status' => current_status[id], 'since' => dates[-first-1] || dates.first, 'last' => dates[-missed-1] }] }] end