Import the first public release

Signed-off-by: OKTET Labs Ltd. <diary-maint@oktetlabs.ru>
master
OKTET Labs Ltd 2021-12-20 12:32:51 +00:00 committed by Sergey Bogdanov
commit a8df72ef9e
47 changed files with 24998 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
.reviewboardrc
diary_env.rb

73
LICENSE Normal file
View File

@ -0,0 +1,73 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

2
NOTICE Normal file
View File

@ -0,0 +1,2 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright (C) 2021 OKTET Labs Ltd. All rights reserved.

4
README.md Normal file
View File

@ -0,0 +1,4 @@
##############################################
## OKTET Labs. Diary Management Application ##
##############################################

23
apache_diary.conf Normal file
View File

@ -0,0 +1,23 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright (C) 2021 OKTET Labs Ltd. All rights reserved.
# vim: syntax=apache ts=4 sw=4 sts=4 sr noet
ScriptAlias /cgi-bin/ /var/www/cgi-bin/
<Directory "/var/www/cgi-bin">
AllowOverride None
Options ExecCGI FollowSymLinks
</Directory>
#Settings fo Diary
Alias /main/diary/css /var/www/cgi-bin/diary/css
Alias /main/diary/js /var/www/cgi-bin/diary/js
Alias /main/diary/diary.js /var/www/cgi-bin/diary/diary.js
Alias /main/diary/diary.css /var/www/cgi-bin/diary/diary.css
Alias /main/diary/favicon.css /var/www/cgi-bin/diary/favicon.css
Alias /main/diary/diary_next.png /var/www/cgi-bin/diary/diary_next.png
Alias /main/diary/diary_prev.png /var/www/cgi-bin/diary/diary_prev.png
Alias /public/logo-small.gif /var/www/cgi-bin/diary/logo-small.gif
<Location "/cgi-bin/diary">
Require ldap-filter o=People
</Location>

134
cgi.rb Executable file
View File

@ -0,0 +1,134 @@
#!/usr/bin/ruby -w
# SPDX-License-Identifier: Apache-2.0
# Copyright (C) 2021 OKTET Labs Ltd. All rights reserved.
#
# Cgi for Diary Management Application
#
# "." is removed from LOAD_PATH in Ruby 1.9
# Backporting require_relative to Ruby 1.8
# See http://steveklabnik.github.io/require_relative/require_relative.html
#
unless Object.new.respond_to?(:require_relative, true)
def require_relative(relative_feature)
file = caller.first.split(/:\d/,2).first
raise LoadError, "require_relative is called in #{$1}" if
/\A\((.*)\)/ =~ file
require File.expand_path(relative_feature, File.dirname(file))
end
end
require 'cgi'
require 'ldap'
require_relative 'diary_env'
require 'dbi'
require 'amrita/template'
include Amrita
class AmritaTemplate < Amrita::TemplateText
def initialize(filename)
# Use TemplateText instead of TemplateFile to avoid problem
# in amrita/node.rb with tainted template strings, because
# they have been read from external file
super(IO.read(filename).untaint)
self.amrita_id = :_id
self.asxml = true
end
end
require_relative 'sql_cache'
require_relative 'project'
require_relative 'diary'
require_relative 'mycgi'
$SAFE = 1
$DEBUG = 1
class NeedConfirm < Exception
def initialize(code = nil)
@code = code
end
attr :code
end
#
# Initialize global variables
#
# CGI default parameters
cgi_defaults = {
"do" => "diary",
"prj" => {
"list" => {
"customer" => ["*"],
"prj_status" => [], #Project::STATUS_DEFAULT,
"edit" => []
},
"action" => {}
},
"diary" => {
"list" => {
"start" => MyCGI::date2hash(Date.today),
"end" => MyCGI::date2hash(Date.today),
"prj_id" => ["*"],
"customer" => ["*"],
"who" => ["*"],
"newest_first" => ["off"],
"just_stats" => ["off"],
"edit" => []
},
"action" => {}
},
"confirm" => []
}
env = DiaryEnv.instance
env.user = ENV["REMOTE_USER"]
# Customize default parameters
# TODO: make this user-defined
cgi_defaults["diary"]["list"]["start"]["day"] = 1
cgi_defaults["diary"]["list"]["who"] = [env.user.uid] if env.user.local?
# CGI object
cgi = MyCGI.new("post", cgi_defaults, ["list", "confirm"], ["descr"], "html4Tr")
prj = ProjectUI.new(cgi)
diary = DiaryUI.new(cgi)
cgi.out("charset" => "utf-8") do begin
env.confirmation = cgi.tree_params["confirm"]
# Process cgi parameters
prj.action
diary.action
# Output html data
if cgi.tree_params["do"] == "project"
prj.show
elsif cgi.tree_params["do"] == "diary"
diary.show
else
""
end
rescue NeedConfirm => detail
tmpl = AmritaTemplate.new("confirm.html")
s = String.new
tmpl.expand(s, {
:message => noescape{$!},
:hiddens => cgi.hiddens(cgi.tree_flatten([])),
:code => cgi.hiddens([["confirm", detail.code]])
})
rescue RuntimeError
tmpl = AmritaTemplate.new("error.html")
s = String.new
tmpl.expand(s, {
:message => noescape{$!},
:debug => $!.backtrace.join("\n")
})
end
end

19
confirm.html Normal file
View File

@ -0,0 +1,19 @@
<?xml version="1.0"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Style-Type" content="text/css" />
<link rel="stylesheet" type="text/css" href="/main/diary/diary.css"/>
<title>Diary Management</title>
</head>
<body>
<h1>Diary Management</h1>
<h2 class="warning">Confirmation is required!</h2>
<p _id="message">Message text</p>
<form method="post" action="#">
<input type="hidden" _id="hiddens">
<input type="hidden" _id="code">
<input type="submit" value="Confirm">
</form>
</body>
</html>

77
create.mysql Normal file
View File

@ -0,0 +1,77 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright (C) 2021 OKTET Labs Ltd. All rights reserved.
#
# Usage: mysql diary <create.mysql
#
CREATE TABLE `director` (
`nick` varchar(30) NOT NULL
);
INSERT INTO director SET `nick`='director';
CREATE TABLE `project` (
`id` mediumint(9) unsigned NOT NULL auto_increment,
`tag` varchar(30) NOT NULL,
`name` varchar(70) NOT NULL,
`prj_desc` mediumtext NOT NULL,
`prj_status` enum('NEW','ACTIVE','TERMINATED') default 'ACTIVE' not NULL,
`status_desc` mediumtext,
`issues` mediumtext,
`manager` varchar(30) NOT NULL,
`leader` varchar(30),
`customer` varchar(30) NOT NULL,
`cust_man` varchar(30) NOT NULL,
`cust_tech` varchar(30),
`maillists` varchar(120),
`svndir` varchar(120),
`storagedir` varchar(120),
`bugzilla_prod` varchar(64),
`extra_rights` varchar(255),
`hide_hrs` tinyint(1) default NULL,
PRIMARY KEY (`id`),
UNIQUE `name` (`name`),
UNIQUE `tag` (`customer`, `tag`)
);
CREATE TABLE `cc_list` (
`prj_id` mediumint(9) unsigned NOT NULL default 0,
`person` varchar(30) NOT NULL
);
CREATE TABLE `diary` (
`id` mediumint(9) unsigned NOT NULL auto_increment,
`who` varchar(30) NOT NULL,
`prj_id` mediumint(9) unsigned NOT NULL default 0,
`hours` tinyint unsigned NOT NULL default 0,
`ddate` date NOT NULL default '0000-00-00',
`descr` mediumtext NOT NULL,
`private` tinyint(1) NOT NULL default 0,
`bugid` mediumint(9),
`modified` datetime,
`created` datetime,
`state` tinyint unsigned,
PRIMARY KEY (`id`),
KEY `who` (`who`),
KEY `prj_id` (`prj_id`),
KEY `ddate` (`ddate`)
);
CREATE TABLE `sandbox` (
`id` int(11) default NULL,
`modified` datetime default NULL
);
CREATE TABLE `approval` (
`who` varchar(30) NOT NULL,
`prj_id` mediumint(9) unsigned NOT NULL,
`approver` varchar(30) NOT NULL,
PRIMARY KEY (`who`, `prj_id`)
);
CREATE TABLE `approval_note` (
`id` mediumint(9) unsigned NOT NULL,
`created` datetime NOT NULL,
`author` varchar(30) NOT NULL,
`note` mediumtext
);

2050
css/bootstrap-grid.css Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

7
css/bootstrap-grid.min.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

330
css/bootstrap-reboot.css Normal file
View File

@ -0,0 +1,330 @@
/*!
* Bootstrap Reboot v4.0.0 (https://getbootstrap.com)
* Copyright 2011-2018 The Bootstrap Authors
* Copyright 2011-2018 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
*/
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
font-family: sans-serif;
line-height: 1.15;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
-ms-overflow-style: scrollbar;
-webkit-tap-highlight-color: transparent;
}
@-ms-viewport {
width: device-width;
}
article, aside, dialog, figcaption, figure, footer, header, hgroup, main, nav, section {
display: block;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
color: #212529;
text-align: left;
background-color: #fff;
}
[tabindex="-1"]:focus {
outline: 0 !important;
}
hr {
box-sizing: content-box;
height: 0;
overflow: visible;
}
h1, h2, h3, h4, h5, h6 {
margin-top: 0;
margin-bottom: 0.5rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title],
abbr[data-original-title] {
text-decoration: underline;
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
border-bottom: 0;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: .5rem;
margin-left: 0;
}
blockquote {
margin: 0 0 1rem;
}
dfn {
font-style: italic;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 80%;
}
sub,
sup {
position: relative;
font-size: 75%;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -.25em;
}
sup {
top: -.5em;
}
a {
color: #007bff;
text-decoration: none;
background-color: transparent;
-webkit-text-decoration-skip: objects;
}
a:hover {
color: #0056b3;
text-decoration: underline;
}
a:not([href]):not([tabindex]) {
color: inherit;
text-decoration: none;
}
a:not([href]):not([tabindex]):hover, a:not([href]):not([tabindex]):focus {
color: inherit;
text-decoration: none;
}
a:not([href]):not([tabindex]):focus {
outline: 0;
}
pre,
code,
kbd,
samp {
font-family: monospace, monospace;
font-size: 1em;
}
pre {
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
-ms-overflow-style: scrollbar;
}
figure {
margin: 0 0 1rem;
}
img {
vertical-align: middle;
border-style: none;
}
svg:not(:root) {
overflow: hidden;
}
table {
border-collapse: collapse;
}
caption {
padding-top: 0.75rem;
padding-bottom: 0.75rem;
color: #6c757d;
text-align: left;
caption-side: bottom;
}
th {
text-align: inherit;
}
label {
display: inline-block;
margin-bottom: .5rem;
}
button {
border-radius: 0;
}
button:focus {
outline: 1px dotted;
outline: 5px auto -webkit-focus-ring-color;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
input {
overflow: visible;
}
button,
select {
text-transform: none;
}
button,
html [type="button"],
[type="reset"],
[type="submit"] {
-webkit-appearance: button;
}
button::-moz-focus-inner,
[type="button"]::-moz-focus-inner,
[type="reset"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner {
padding: 0;
border-style: none;
}
input[type="radio"],
input[type="checkbox"] {
box-sizing: border-box;
padding: 0;
}
input[type="date"],
input[type="time"],
input[type="datetime-local"],
input[type="month"] {
-webkit-appearance: listbox;
}
textarea {
overflow: auto;
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
display: block;
width: 100%;
max-width: 100%;
padding: 0;
margin-bottom: .5rem;
font-size: 1.5rem;
line-height: inherit;
color: inherit;
white-space: normal;
}
progress {
vertical-align: baseline;
}
[type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button {
height: auto;
}
[type="search"] {
outline-offset: -2px;
-webkit-appearance: none;
}
[type="search"]::-webkit-search-cancel-button,
[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
summary {
display: list-item;
cursor: pointer;
}
template {
display: none;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.css.map */

File diff suppressed because one or more lines are too long

8
css/bootstrap-reboot.min.css vendored Normal file
View File

@ -0,0 +1,8 @@
/*!
* Bootstrap Reboot v4.0.0 (https://getbootstrap.com)
* Copyright 2011-2018 The Bootstrap Authors
* Copyright 2011-2018 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
*/*,::after,::before{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;-ms-overflow-style:scrollbar;-webkit-tap-highlight-color:transparent}@-ms-viewport{width:device-width}article,aside,dialog,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol";font-size:1rem;font-weight:400;line-height:1.5;color:#212529;text-align:left;background-color:#fff}[tabindex="-1"]:focus{outline:0!important}hr{box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem}p{margin-top:0;margin-bottom:1rem}abbr[data-original-title],abbr[title]{text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;border-bottom:0}address{margin-bottom:1rem;font-style:normal;line-height:inherit}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}dfn{font-style:italic}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#007bff;text-decoration:none;background-color:transparent;-webkit-text-decoration-skip:objects}a:hover{color:#0056b3;text-decoration:underline}a:not([href]):not([tabindex]){color:inherit;text-decoration:none}a:not([href]):not([tabindex]):focus,a:not([href]):not([tabindex]):hover{color:inherit;text-decoration:none}a:not([href]):not([tabindex]):focus{outline:0}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}pre{margin-top:0;margin-bottom:1rem;overflow:auto;-ms-overflow-style:scrollbar}figure{margin:0 0 1rem}img{vertical-align:middle;border-style:none}svg:not(:root){overflow:hidden}table{border-collapse:collapse}caption{padding-top:.75rem;padding-bottom:.75rem;color:#6c757d;text-align:left;caption-side:bottom}th{text-align:inherit}label{display:inline-block;margin-bottom:.5rem}button{border-radius:0}button:focus{outline:1px dotted;outline:5px auto -webkit-focus-ring-color}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,input{overflow:visible}button,select{text-transform:none}[type=reset],[type=submit],button,html [type=button]{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{padding:0;border-style:none}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}input[type=date],input[type=datetime-local],input[type=month],input[type=time]{-webkit-appearance:listbox}textarea{overflow:auto;resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;max-width:100%;padding:0;margin-bottom:.5rem;font-size:1.5rem;line-height:inherit;color:inherit;white-space:normal}progress{vertical-align:baseline}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:none}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item;cursor:pointer}template{display:none}[hidden]{display:none!important}
/*# sourceMappingURL=bootstrap-reboot.min.css.map */

File diff suppressed because one or more lines are too long

8975
css/bootstrap.css Normal file

File diff suppressed because it is too large Load Diff

1
css/bootstrap.css.map Normal file

File diff suppressed because one or more lines are too long

7
css/bootstrap.min.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

116
diary.css Normal file
View File

@ -0,0 +1,116 @@
body { font-family: sans-serif }
textarea, pre { font-family: monospace }
#header {
height: 72px;
border-bottom: #333333 solid 1px;
}
#footer {
border-top: #333333 solid 1px;
padding-top: 10px;
margin-top: 10px;
font-size: 80%;
}
#logo {
float: right;
}
h1 { font-size: 200%;
color: blue
}
h2 { font-size: 130% }
img {
border: 0;
}
/* http://www.longren.org/wrapping-text-inside-pre-tags/ */
pre {
overflow-x: auto; /* Use horizontal scroller if needed */
white-space: pre-wrap; /* CSS3 */
white-space: -moz-pre-wrap !important; /* Mozilla */
white-space: -pre-wrap; /* Opera 4-6 */
white-space: -o-prep-wrap; /* Opera 7 */
word-wrap: break-word; /* IE 5.5+ */
width: 99%;
}
.mark { color: red }
.error { color: red }
.warning { color: #ff3333 }
#debug { color: #cccccc; font-size: 80% }
h4 {
color: #000033;
background-color: #ccccff;
border: 1px dashed gray;
padding-left: 5px;
}
.edit h4 {
background-color: #cccccc;
}
.edit h5 {
border: 1px dotted gray;
padding: 2px;
background-color: #eeeeee;
}
.state, .timestamp {
color: #cccccc;
font-size: 70%;
}
.appnote {
color: #888888;
font-size: 70%;
}
.approval {
background-color: #eeeeee;
border: 1px dotted gray;
padding-left: 5px;
padding-bottom: 5px;
font-size: 70%;
}
.approval_header {
font-weight: bold;
}
.state-rejected {
color: red;
}
.state-approved {
color: green;
}
.date_picker {
padding: 3px;
margin-right: 10px;
cursor: default;
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
/* -moz-user-select: none; */
-ms-user-select: none;
user-select: none;
}
.arrow_next {
background-image: url('/main/diary/diary_next.png');
background-repeat: no-repeat;
display: inline-block;
width: 16px;
height: 16px;
cursor: pointer;
}
.arrow_prev {
background-image: url('/main/diary/diary_prev.png');
background-repeat: no-repeat;
display: inline-block;
width: 16px;
height: 16px;
cursor: pointer;
}

427
diary.html Normal file
View File

@ -0,0 +1,427 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<link rel="stylesheet" type="text/css" href="/main/diary/css/bootstrap.min.css" />
<link rel="stylesheet" type="text/css" href="/main/diary/favicon.css" />
<title>Diary Management
</title>
<script type="text/javascript" src="/main/diary/diary.js">
</script>
</head>
<body>
<div class="container mx-auto" style="width: 80%; margin-left: ($spacer * .25) !important">
<header _id="header">
</header>
<!-- Add new entry -->
<div id="new" _id="new" style="border-width: .2rem; border: solid #f7f7f9; padding: 1.5rem; background-color: #e0eefa;">
<form method="post" action="#" _id="edit">
<h3>New diary record:
</h3>
<div class="form-row">
<input type="hidden" name="do" value="diary" />
<input type="hidden" name="diary:edit:id" _id="id" />
<input type="hidden" _id="hiddens" />
<div class="form-group col-xs-3">
<label for="date">When</label>
<span class="arrow_prev" onclick="change_date('diary:edit:ddate', -1)">
</span>
<span class="form-inline" _id="date">
</span>
<span class="arrow_next" onclick="change_date('diary:edit:ddate', 1)">
</span>
</div>
<div class="from-group col-xs-2">
<label for="person">Engineer</label>
<select name="diary:edit:who" class="custom-select">
<option _id="person">Engineer
</option>
</select>
</div>
<div class="from-group col-xs-3">
<label for="hours">Hours</label>
<input _id="hours" autocomplete="off" type="text" size="2" name="diary:edit:hours" class="form-control" />
</div>
<div class="form-group col-xs-3">
<label for="project">Project</label>
<select name="diary:edit:prj_id" class="custom-select">
<option class="btn btn-primary" _id="project">Project
</option>
</select>
</div>
</div>
<div style="width: 800px; overflow: auto; ">
<textarea name="diary:edit:descr" cols="70" rows="10" class="form-control"></textarea>
</div>
<div>
<button type="submit" value="Submit" class="btn btn-primary btn-sm">Submit / Ctrl+Enter</button>
</div>
</form>
</div>
<hr>
<div>
<form method="post" action="#">
<div id="massapproval" _id="massapproval">
<button type="submit" name="diary:action:verb" value="ApproveAll" class="btn btn-primary btn-sm" _id="edit">Approve All
</button>
<button type="submit" name="diary:action:verb" value="DenyAll" class="btn btn-warning btn-sm" _id="edit">Deny All
</button>
<button type="submit" name="diary:action:verb" value="RejectAll" class="btn btn-danger btn-sm" _id="edit">Reject All
</button>
</div>
</form>
</div>
<!-- Entries to be approved -->
<div id="approvals" _id="approvals">
<h3 class="display-4">Records for approval
</h3>
<hr class="my-4">
<div _id="entries">
<div class="input-group mb-3">
<form method="post" _id="view" action="#">
<input type="hidden" name="do" value="diary" />
<input type="hidden" name="diary:action:id" _id="edit_id" />
<input type="hidden" _id="hiddens" />
<div class="alert alert-info">
<span _id="date">01 Jan 1970
</span>
<span _id="person">Engineer name
</span>
<strong><span _id="hours">100
</span></strong> hour(s);
Project
<span _id="project_name">Project name
</span>
<span class="badge badge-primary badge-pill" _id="project">customer/tag
</span>
</div>
<div style="width: 800px; overflow: auto; margin-left: 10px;">
<pre _id="descr">Work description</pre>
</div>
<div class="timestamp badge">Status:
<span class="badge badge-warning badge-pill" _id="state">valid
</span> created
<span _id="created">2006-10-23 10:20:00
</span>, modified
<span _id="modified">2006-10-24 15:17:00
</span>
</div>
<div _id="appnote" class="form-control">
<span _id="created">01 10 1970
</span>
<span _id="author">Author
</span>:
<span _id="note">Approval note
</span>
</div>
<p>
<textarea name="diary:edit:approval" cols="70" rows="5" class="form-control"></textarea>
</p>
<p>
<button type="submit" name="diary:action:verb" value="Approve" class="btn btn-success btn-sm" _id="edit">Approve
</button>
<button type="submit" name="diary:action:verb" value="Deny" class="btn btn-warning btn-sm" _id="edit">Deny
</button>
<button type="submit" name="diary:action:verb" value="Reject" class="btn btn-danger btn-sm" _id="edit">Reject
</button>
</p>
</form>
</div>
</div>
</div>
<!-- Selection menu -->
<div id="menu" _id="menu" style="border-width: .2rem; border: solid #f7f7f9; padding: 1.5rem; background-color: #e0eefa;">
<form method="get" action="#">
<h3>Select diary records to list:
</h3>
<input type="hidden" name="do" value="diary">
<div class="form-row">
<div class="form-group col-xs-3">
<label for="start_date">From</label>
<span class="form-inline" _id="start_date">
</span>
<small class="form-text text-muted"># - current day or year</small>
</div>
<div class="form-group col-xs-3">
<label for="end_date">To</label>
<span class="form-inline" _id="end_date">
</span>
<small class="form-text text-muted">$ - end of month</small>
</div>
<div class="form-group col-xs-3">
<label for="project">Project</label>
<select name="diary:list:prj_id" class="custom-select">
<option class="btn btn-primary" _id="project">Project
</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group col-xs-3">
<select name="diary:list:customer" class="custom-select">
<option _id="customer">Customer
</option>
</select>
</div>
<div class="form-group col-xs-3">
<select name="diary:list:who" class="custom-select">
<option _id="who">Engineer
</option>
</select>
</div>
</div>
<div class="form-check form-group">
<span _id="newest_first"></span>
<label class=" form-check-label" for="newest_first">Newest first</label>
</div>
<div class="form-check form-group">
<span _id="just_stats"></span>
<label class="form-check-label" for="just_stats">Just stats</label>
</div>
<button type="submit" class="btn btn-primary btn-sm">Search
</button>
</form>
</div>
<div _id="nodata" id="nodata">
No entries
</div>
<hr>
<!-- Summary -->
<div _id="detailed_totals" id="detailed_totals">
<h3>Summary
</h3>
<p>Total hours:
<span _id="hours">100
</span>
</p>
<h4>By projects:
</h4>
<ul>
<li _id="customer">
<span _id="name">Name
</span>,
<span _id="hours">1000
</span> hour(s)
<ul>
<li _id="project">
<span _id="name">Name</span> (<span _id="customer">customer</span>/<span _id="tag">tag</span>),
<span _id="hours">100</span> hour(s)
</li>
</ul>
</li>
</ul>
<h4>By engineers:
</h4>
<table class="table table-striped table-hover table-sm">
<caption>Lag: days it took to submit the records, Mon,Tue etc. : average number of hours per day in the selected period (vacations are not handled)</caption>
<thead>
<tr>
<th scope="col">Engineer</th>
<th scope="col">Hours</th>
<th scope="col">Lag</th>
<th scope="col">Mon</th>
<th scope="col">Tue</th>
<th scope="col">Wed</th>
<th scope="col">Thu</th>
<th scope="col">Fri</th>
<th scope="col">Sat</th>
<th scope="col">Sun</th>
</tr>
</thead>
<tbody>
<tr _id="person">
<th scope="row" _id="name"> </th>
<td _id="hours"> </td>
<td _id="delay"> </td>
<td _id="mon_stat"> </td>
<td _id="tue_stat"> </td>
<td _id="wed_stat"> </td>
<td _id="thu_stat"> </td>
<td _id="fri_stat"> </td>
<td _id="sat_stat"> </td>
<td _id="sun_stat"> </td>
</tr>
</tbody>
</table>
</div>
<div _id="totals" id="totals">
<h3>Summary
</h3>
<p>Total hours:
<span _id="hours">100
</span>
</p>
<h4>By projects:
</h4>
<ul>
<li _id="customer">
<span _id="name">Name
</span>,
<span _id="hours">1000
</span> hour(s)
<ul>
<li _id="project">
<span _id="name">Name</span> (<span _id="customer">customer</span>/<span _id="tag">tag</span>),
<span _id="hours">100</span> hour(s)
</li>
</ul>
</li>
</ul>
<h4>By engineers:
</h4>
<ul>
<li _id="person">
<span _id="name">Person
</span>,
<span _id="hours">100
</span> hour(s)
</li>
</ul>
</div>
<hr>
<!-- Details -->
<div _id="details" id="details">
<h3>Details
</h3>
<div _id="entries">
<form _id="view_only" style="margin-top: 3px;" class="card">
<div class="card-body">
<div class="card-title">
<span _id="date">01 Jan 1970
</span>:
<span _id="person">Engineer name
</span>
<span _id="hours">100 hour(s)
</span>;
Project
<span _id="project_name">Project name
</span>
<span class="badge badge-success badge-pill" _id="project">customer/tag
</span>
</div>
<div style="width: 900px; overflow: auto;" class="card-body">
<pre _id="descr">Work description</pre>
</div>
</div>
</form>
<form method="post" _id="view" style="margin-top: 3px;" class="card">
<div class="card-body">
<div class="card-title">
<input type="hidden" name="do" value="diary" />
<input type="hidden" name="diary:list:edit" _id="edit_id" />
<input type="hidden" name="diary:action:id" _id="edit_id" />
<input type="hidden" _id="hiddens" />
<span _id="date">01 Jan 1970
</span>
<span _id="person">Engineer name
</span>
<span _id="hours">100 h
</span>
<span _id="project_name">Project name
</span>
<span class="badge badge-success badge-pill" _id="project">customer/tag
</span>
<button type="submit" name="diary:action:verb" value="Edit" class="btn btn-outline-primary btn-sm" _id="edit">Edit
</button>
<button type="submit" name="diary:action:verb" value="Delete" class="btn btn-outline-secondary btn-sm" _id="edit">Delete
</button>
</div>
<div class="card-body" style="width: 900px; overflow: auto;">
<pre _id="descr">Work description</pre>
</div>
<div _id="appnote" class="appnote">
<p>
<span _id="created">01 10 1970
</span>
<span _id="author">Author
</span>:
<span _id="note">Approval note
</span>
</p>
</div>
<div class="timestamp card-subtitle"><small>Status:
<span _id="state">valid
</span>; created
<span _id="created">2006-10-23 10:20:00
</span>, modified
<span _id="modified">2006-10-24 15:17:00
</span>
</small>
</div>
</div>
</form>
<form method="post" _id="edit" style="margin-top: 3px;" class="card">
<div class="edit">
<h5>Edit diary record:
</h5>
<div class="form-row">
<input type="hidden" name="do" value="diary" />
<input type="hidden" name="diary:edit:id" _id="id" />
<input type="hidden" _id="hiddens" />
<div class="form-group col-xs-3">
<span class="form-inline" _id="date">
</span>
</div>
<div class="form-group col-xs-2">
<select name="diary:edit:who" class="custom-select">
<option _id="person">
</option>
</select>
</div>
<div class="form-group col-xs-3">
<input _id="hours" type="text" size="2" name="diary:edit:hours" class="form-control" />
</div>
<div class="form-group col-xs-3">
<select name="diary:edit:prj_id" class="custom-select">
<option _id="project">Project 1
</option>
</select>
</div>
</div>
<div style="width: 800px; overflow: auto; ">
<textarea name="diary:edit:descr" cols="70" rows="10" _id="descr" class="form-control">Work description</textarea>
</div>
<div _id="approval">
<div _id="appnote" class="form-control form-inline">
<span _id="created">01 10 1970
</span>
<span _id="author">Author
</span>:
<span _id="note">Approval note
</span>
</div>
<p>
<textarea name="diary:edit:approval" cols="70" rows="10">Approval note</textarea>
</p>
</div>
<p>
<button type="submit" value="Submit" class="btn btn-primary btn-sm">Submit / Ctrl+Enter</button>
</p>
</div>
</form>
</div>
</div>
<span _id="footer">
</span>
</div>
</body>
</html>

32
diary.js Normal file
View File

@ -0,0 +1,32 @@
/* $Id$ */
/**
* Update data in input elements for date by shifting it by @p days days.
*
* @param prefix Common prefix in input names, e.g. 'diary:edit:ddate'
* @param days Size of value shift, in days
*/
function change_date(prefix, days) {
var d = document.getElementsByName(prefix + ':day')[0];
var m = document.getElementsByName(prefix + ':month')[0];
var y = document.getElementsByName(prefix + ':year')[0];
var z = new Date(y.value, m.value - 1, d.value);
z.setTime(z.getTime() + 86400000 * days);
d.value = z.getDate();
m.value = z.getMonth() + 1;
y.value = z.getFullYear();
}
document.addEventListener('keypress', logKey);
function logKey(e) {
if (e.ctrlKey && e.keyCode == 10) {
var target = e.target
if (target.form)
target.form.submit()
} else if (e.ctrlKey && e.keyCode == 13) {
var target = e.target
if (target.form)
target.form.submit()
}
}

773
diary.rb Normal file
View File

@ -0,0 +1,773 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright (C) 2021 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")
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
@lag += (created - (ddate + Rational(20, 24)))
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.select_all(
"select * from approval_note where id = ?", @id
).collect { |row| row.to_h }
return @approval_note
end
def self.approve_all(prj, who)
DataMapper.database.do("update diary set state = ? where prj_id = ? " +
"AND who = ? AND state IN (?,?)", DiaryState::APPROVED,
prj.id, who, 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.select_one(
"SELECT prj_id FROM diary " +
"WHERE who=? ORDER BY ddate DESC " +
"LIMIT 1", who.uid)
return last_diary ? last_diary[0].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 >= ? and ddate <= ?"
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 { "?" }.join(",") + ")"
query_args += prj_list
elsif prj_id
query_clause += " AND prj_id = ?"
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 { "?" }.join(",") + ")"
query_args += eng_list
elsif who
query_clause += " AND who = ?"
query_args = query_args << who
end
if customer
query_clause += " AND customer = ?"
query_args = query_args << customer
end
DataMapper.database.select_all("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 + " order by ddate, prj_id, who",
*query_args).collect do |row|
self.new(row["id"], row.to_h)
end
end
def self.for_approve(who)
DataMapper.database.select_all("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 = ?" +
" and diary.state = ? order by diary.ddate",
who.uid, 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.do(
"insert into approval_note(id,created,author,note) " +
"values (?,?,?,?)", id, DbTime.now,
DiaryEnv.instance.user.uid, 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.select_one(
"SELECT SUM(hours) FROM diary " +
"WHERE who=? AND ddate=?" +
(data.include?("id") ?
" AND id<>#{data["id"]}" : ""),
*([data["who"], data["ddate"]]))[0].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.select_one(
"SELECT COUNT(*) FROM diary WHERE " +
"who = ? and descr = ? and hours = ? " +
"and ddate =?",
*([data["who"], data["descr"],
data["hours"], data["ddate"]]))[0].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

108
diary_datamapper.rb Normal file
View File

@ -0,0 +1,108 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright (C) 2021 OKTET Labs Ltd. All rights reserved.
#
# Class DataMapper for Diary Management Application.
#
require 'dbi'
class DataMapper
@@mapper = Hash.new
@@database = nil
def initialize(table, key = "id")
@key = key
@table = table
@@mapper[@table] = self
@cache = Hash.new
@columns = @@database.columns(@table).collect { |col| col["name"] } - [@key]
end
def self.setup(args)
raise "Database parameters is not a hash" unless args.is_a? Hash
@@database = DBI.connect("dbi:#{args[:adapter]}:#{args[:database]}:" +
"#{args[:host]}",
args[:username], args[:password])
end
def self.database
@@database
end
def pick(row)
row_data = row.to_h
id = row_data[@key]
return @cache[id] if @cache.has_key?(id)
@cache[id] = instantiate(id, row_data)
end
def find(id)
return @cache[id] if @cache.has_key?(id)
row = @@database.select_one("select * from #{@table} where #{@key} = ?", id)
return nil unless row
pick(row)
end
def where(clause, *args)
@@database.select_all("select * from #{@table} where " + clause,
*args).collect { |row| pick(row) }
end
def all(args)
# TODO: args == nil
raise ArgumentError unless args.is_a?(Hash)
query_clause = Array.new
query_args = Array.new
args.each do |tag, value|
next unless @columns.include?(tag.to_s) or tag.to_s == @key
if value.is_a?(Array)
if value.length > 0
query_clause << "#{tag.to_s} " +
"IN (#{value.collect {"?"}.join(",")})"
query_args = query_args + value
end
elsif value.is_a?(Range)
query_clause << "#{tag.to_s} >= ? AND #{tag.to_s} <= ?"
query_args << value.first << value.last
else
query_clause << "#{tag.to_s} = ?"
query_args << value
end
end
# raise "#{MyCGI.dump(query_clause)} : #{MyCGI.dump(query_args)}"
@@database.select_all("select * from #{@table}" +
(query_clause.empty? ? "" :
" where " + query_clause.join(" and ")),
*query_args).collect { |row| pick(row) }
end
def insert(obj)
raise ArgumentError unless obj.id.nil?
fields = @columns & obj.attributes.keys
@@database.do("insert into #{@table} (" + fields.join(",") +
") values (" + fields.collect {"?"}.join(",") + ")",
*(fields.collect {|field| obj[field]}))
obj.id = @@database.select_one("select last_insert_id()")[0]
@@cache[obj.id] = obj
end
def update(obj)
raise ArgumentError if obj.id.nil?
fields = @columns & obj.attributes.keys
@@database.do("update #{@table} set " +
fields.collect {|field| field + " = ?"}.join(",") +
" where #{@key} = ?",
*(fields.collect {|field| obj[field]} << obj.id))
end
def delete(obj)
raise ArgumentError if obj.id.nil?
@@database.do("delete from #{@table} where #{@key} = ?", obj.id)
@@cache.delete(obj.id)
obj.id = nil
end
def self.[](table)
@@mapper[table]
end
end

69
diary_env.example.rb Normal file
View File

@ -0,0 +1,69 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright (C) 2021 OKTET Labs Ltd. All rights reserved.
#
# Class DiaryEnv for Diary Management Application.
#
require_relative 'ldap_record'
require_relative 'diary_datamapper'
class DiaryEnv
include Singleton
DB_HOST = "localhost"
DB_USERNAME = "diary"
DB_PASSWORD = "diary_pass"
DB_DATABASE = "diary"
LDAP_HOST = 'ldap.example.com'
LDAP_ROOT = "ou=People,dc=example,dc=com"
HOME_ORGANIZATION = "Example ORG"
HOME_OU = "Employees"
SMTP_HOST = 'mail.example.com'
SMTP_FROM = 'diary@example.com'
# Maximum age of diary entry (in days) to accept (approval mode only)
DIARY_MAX_AGE = 2
def initialize
@confirmation = Array.new
Person.setup(:host => LDAP_HOST,
:root => LDAP_ROOT,
:key => "uid")
Person.set_local(HOME_ORGANIZATION, HOME_OU)
DataMapper.setup(:adapter => "Mysql",
:database => DB_DATABASE,
:host => DB_HOST,
:username => DB_USERNAME,
:password => DB_PASSWORD)
DataMapper.database.execute("SELECT nick FROM director") do |q|
raise "'Director' table is not filled" unless
q and name = q.fetch[0]
@director = [ Person.new('director') ]
end
end
def user=(name)
@user = Person.find(name)
raise "Invalid user #{name}" unless @user
@policy = @user.employee? ? EmployeePolicy.new(@user) :
CustomerPolicy.new(@user)
end
def confirmation=(value)
if value.is_a? Array
@confirmation = value
end
end
attr_reader :director, :confirmation, :policy, :user
def self.director
instance.director
end
def self.confirmed?(token)
instance.confirmation.include?(token)
end
end

BIN
diary_next.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 B

154
diary_policy.rb Normal file
View File

@ -0,0 +1,154 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright (C) 2021 OKTET Labs Ltd. All rights reserved.
#
# Class Policy for Diary Management Application.
#
require_relative 'ldap_record'
#require_relative 'project'
require_relative 'diary_env'
require_relative 'diary_datamapper'
# Policy for full employees
class EmployeePolicy
def initialize(user)
@user = user
end
def customer_list
Organization.all.sort
end
def project_list
Project.where("1").sort
end
def engineer_list
Person.find_by_org(DiaryEnv::HOME_ORGANIZATION).sort
end
# Check whether current user can edit entries for user {who}
# in project {prj}
def can_edit?(prj, who)
@user == who or
@user == self.is_director? or (prj != nil and
(@user.uid == prj["leader"] or @user.uid == prj["manager"]))
end
# Check whether current user needs confirmations when editing things in
# the past
def needs_confirmation?(prj, who)
not (self.is_director? or
(prj != nil and
(@user.uid == prj["leader"] or @user.uid == prj["manager"])))
end
def can_approve?(prj, who)
prj.approvals[who.uid] == @user.uid
end
def can_account?(prj)
true
end
def can_edit_project?(prj = nil)
true
end
def is_director?
DiaryEnv.director.include?(@user)
end
def can_edit_approval?(prj)
#@user.director? or @user.uid == prj["leader"]
true
end
def restriction
[]
end
end
# Policy for customers and contractors
class CustomerPolicy
def initialize(user)
@user = user
@extra_prj = nil
end
def extra_project_list
return @extra_prj if @extra_prj
@extra_prj = Array.new
Project.where("extra_rights IS NOT NULL").each do |prj|
if not Person.find_by_filter("(&(uid=#{@user.uid})#{prj["extra_rights"]})").empty?
@extra_prj.push(prj)
end
end
@extra_prj
end
def customer_list
([@user.organization] +
extra_project_list.collect {|prj| prj.customer }).uniq.sort
end
def project_list
(Project.where("customer=? AND extra_rights IS NULL",
@user.organization.uid) +
extra_project_list).uniq.sort
end
def engineer_list
return [@user] if @user.local? # For contractors
engs = Person.find_by_org(DiaryEnv::HOME_ORGANIZATION)
if @user.customer?
engs_active = Array.new
DataMapper.database.select_all("SELECT DISTINCT who " +
"FROM diary INNER JOIN project " +
"ON diary.prj_id = project.id " +
"WHERE project.id IN " +
"(#{project_list.collect {"?"}.join(",")})",
*(project_list.collect { |prj| prj.id })
) do |row|
engs_active.push(row["who"])
end
engs.delete_if { |x| not engs_active.include?(x.uid) }
end
engs.sort
end
def can_account?(prj)
not prj["hide_hrs"]
end
def can_edit?(prj, who)
@user.local? ? who == @user : false
end
def needs_confirmation?(prj, who)
return true
end
def can_approve?(prj, who)
false
end
def can_edit_project?(prj = nil)
false
end
def can_edit_approval?(prj)
false
end
def is_director?
false
end
def restriction
@user.local? ? [:project, :engineer] : [:project]
end
end

BIN
diary_prev.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 B

14
error.html Normal file
View File

@ -0,0 +1,14 @@
<?xml version="1.0"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Style-Type" content="text/css" />
<link rel="stylesheet" type="text/css" href="/main/diary/css/bootstrap.min.css"/>
<title>Diary Management</title>
</head>
<body>
<h1>Diary Management</h1>
<h2 class="error">Error</h2>
<p _id="message">Message text</p>
</body>
</html>

17
favicon.css Normal file
View File

@ -0,0 +1,17 @@
.arrow_next {
background - image: url('/main/diary/diary_next.png');
background - repeat: no - repeat;
display: inline - block;
width: 16 px;
height: 16 px;
cursor: pointer;
}
.arrow_prev {
background - image: url('/main/diary/diary_prev.png');
background - repeat: no - repeat;
display: inline - block;
width: 16 px;
height: 16 px;
cursor: pointer;
}

4
footer.html Normal file
View File

@ -0,0 +1,4 @@
<div id="footer">
With problems, contact with
<a href="mailto:webmaster@example.com">webmaster</a>
</div>

26
header.html Normal file
View File

@ -0,0 +1,26 @@
<nav id="header" class="nav navbar-expand bg-light nav-pills">
<a class="navbar-brand" id="logo" href="/cgi-bin/diary/cgi.rb">
<img src="/public/logo-small.gif" width="80" height="38" alt="Org-logo" class="d-inline-block align-top" />
</a>
<div class="collapse navbar-collapse">
<ul class="navbar-nav mr-auto">
<li class="nav-item">
<a class="nav-link" href="?do=diary#new">New record</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#approvals">Approvals</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#menu">Select</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#totals">Summary</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#details">Records</a>
</li>
<li class="nav-item">
<a class="nav-link" href="?do=project">Projects</a>
</li>
</div>
</nav>

6328
js/bootstrap.bundle.js Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

7
js/bootstrap.bundle.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

3894
js/bootstrap.js Normal file

File diff suppressed because it is too large Load Diff

1
js/bootstrap.js.map Normal file

File diff suppressed because one or more lines are too long

7
js/bootstrap.min.js vendored Normal file

File diff suppressed because one or more lines are too long

1
js/bootstrap.min.js.map Normal file

File diff suppressed because one or more lines are too long

67
ldap_cache.rb Normal file
View File

@ -0,0 +1,67 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright (C) 2021 OKTET Labs Ltd. All rights reserved.
#
# Class LDAP::Cache for Diary Management Application.
#
require 'ldap'
class LDAP::Cache < Hash
def initialize(ldap, base_dn, scope, field)
@ldap = ldap
@base_dn = base_dn
@scope = scope
@field = field
@lists = Hash.new
super(0)
end
attr_reader :ldap, :lists
attr_accessor :base_dn, :scope, :field
def search(filter)
list = Array.new
begin
@ldap.search(@base_dn, @scope, filter) do |entry|
hash = Hash.new
entry.to_hash.each do |key, value|
hash[key] = value[0]
end
self[hash[@field]] = hash
list.push(hash)
end
rescue
$stderr.printf "Failed to search in LDAP\n"
exit (-1)
end
list
end
private :search
def list(filter, main_value)
if not @lists[filter]
@lists[filter] = search(filter).sort_by do |entry|
entry[main_value]
end.collect do |entry|
[entry[@field], entry[main_value]]
end
end
@lists[filter]
end
def [](key)
if super(key) == 0
list = search("(#{@field}=#{key})")
if list.length != 1
self[key] = nil
else
self[key] = list[0]
end
end
super(key)
end
end

176
ldap_record.rb Normal file
View File

@ -0,0 +1,176 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright (C) 2021 OKTET Labs Ltd. All rights reserved.
#
# Class LdapRecord for Diary Management Application.
#
require 'ldap_cache'
require 'net/smtp'
class LdapRecord
@@connection = nil
@@cache = {}
attr_reader :id
def LdapRecord.setup(args)
raise "Invalid parameters" unless args.is_a? Hash
raise "LDAP server is not specified" unless args[:host]
@@ldap = LDAP::Conn.new(args[:host], args[:port] || LDAP::LDAP_PORT)
#@@ldap.bind # Bind is optional for LDAPv3
raise "LDAP tree root is not specified" unless args[:root]
raise "LDAP key attribute is not specified" unless args[:key]
@@connection = LDAP::Cache.new(@@ldap, args[:root],
LDAP::LDAP_SCOPE_SUBTREE, args[:key])
end
def initialize(id)
raise "LDAP connection is not established" if not @@connection
@id = id
@@cache[id] = self
@attributes = @@connection[id]
end
def LdapRecord.find(id)
# entry = @@cache[id] || (exists?(id) ? new(id) : nil)
# entry = nil if (entry && !entry.valid?)
# entry
return @@cache[id] if valid?(@@cache[id])
exists?(id) ? new(id) : nil
end
def LdapRecord.valid?(entry)
entry != nil
end
def LdapRecord.find_or_create(id)
@@cache[id] || new(id)
end
def LdapRecord.exists?(id)
@@connection[id] != nil
end
def LdapRecord.find_by_filter(filter)
@@connection.list(filter, "uid").collect { |x| find(x[0]) }.find_all { |x| x }
end
def method_missing(method, *args)
@attributes ? @attributes[method.to_s] : nil
end
end
class Person < LdapRecord
MAX_LENGTH = 30
attr_reader :organization
def initialize(id)
super(id)
@organization = Organization.find_by_name(self.o)
end
def Person.exists?(id)
super(id) && @@connection[id]["objectClass"] == "inetOrgPerson"
end
def Person.valid?(entry)
super(entry) && entry.objectClass == "inetOrgPerson"
end
def Person.set_local(o_name, ou_name)
@@local_o = o_name
@@local_ou = ou_name
end
def to_s
self.cn || @id
end
def to_html
if not self.cn
e(:span, :style => "color:red"){ @id }
elsif self.mail
e(:a, :href => "mailto:" + self.mail){ self.cn }
else
e(:span){ self.cn }
end
end
def local?
self.o == @@local_o
end
def employee?
local? and self.ou == @@local_ou
end
def customer?
self.o != nil and not local?
end
def Person.find_by_org(org_name)
find_by_filter("(&(o=#{org_name})(objectClass=inetOrgPerson))")
end
def <=>(rhs)
if self.cn
rhs.cn ? self.cn <=> rhs.cn : -1
else
rhs.cn ? 1 : @id <=> rhs.id
end
end
end
class Organization < LdapRecord
def to_s
self.o || @id
end
def to_html
if not self.o
e(:span, :style => "color:red"){ @id }
else
e(:span){ self.o }
end
end
def Organization.exists?(id)
super(id) && @@connection[id]["objectClass"] == "organization"
end
def Organization.valid?(entry)
super(entry) && entry.objectClass == "organization"
end
def Organization.all
find_by_filter("(objectClass=organization)")
end
def Organization.find_by_name(name)
return nil unless name.is_a? String
res = find_by_filter("(&(o=#{name})(objectClass=organization))")
res ? res[0] : nil
end
def <=>(rhs)
if self.o
rhs.o ? self.o <=> rhs.o : -1
else
rhs.o ? 1 : @id <=> rhs.id
end
end
end
class Notifier
def notify(who, message)
raise "No such person \"#{who.to_s}\"" if not who.is_a? Person
msg = "From: Diary Management Application <#{DiaryEnv::SMTP_FROM}>\n" +
"To: #{who.cn} <#{who.mail}>\n" +
"Subject: Diary Management\n" +
"\n" + message
Net::SMTP.start(DiaryEnv::SMTP_HOST) do |smtp|
smtp.send_message msg, DiaryEnv::SMTP_FROM, who.mail.untaint
end
end
end

304
mycgi.rb Normal file
View File

@ -0,0 +1,304 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright (C) 2021 OKTET Labs Ltd. All rights reserved.
#
# General CGI helpers
#
HEADER_FILE = "header.html"
FOOTER_FILE = "footer.html"
CURRENT_MONTH = "- Current -"
# Where to place it in the months list
CURRENT_MONTH_NUMBER = 0
def cgi_debug_log(msg)
STDERR.puts "DEBUG: " + msg
end
class MyCGI < CGI
# http://blade.nagaokaut.ac.jp/cgi-bin/scat.rb/ruby/ruby-talk/23950?help-en
def MyCGI::pretty(string, shift = " ")
i = 0; preserve = {}
lines = string.gsub(/\n*<pre.*<\/pre>\n*/inm) {
t = "%PRE#{i}"; i += 1
preserve[t] = $&
t
}
lines = lines.gsub(/\n*<textarea.*<\/textarea>\n*/inm) {
t = "%TEXTAREA#{i}"; i += 1
preserve[t] = $&
t
}
lines = lines.gsub(/(?!\A)<(?:.|\n)*?>/n,
"\n\\0").gsub(/<(?:.|\n)*?>(?!\n)/n, "\\0\n")
end_pos = 0
while end_pos = lines.index(/^<\/(\w+)/n, end_pos)
element = $1.dup
start_pos = lines.rindex(/^\s*<#{element}/ni, end_pos)
lines[start_pos ... end_pos] = "__" +
lines[start_pos ... end_pos].
gsub(/\n(?!\z)/n, "\n" + shift) +
"__"
end
lines = lines.gsub(/^((?:#{Regexp::quote(shift)})*)__(?=<\/?\w)/n,
'\1')
preserve.each { |t, s|
lines.sub!(/#{Regexp::quote(t)}/, s)
}
lines
end
def MyCGI::list_select(list, param)
param = Array.new if not param
list.collect do |entry|
param.include?(entry[0]) ? entry.dup.push(true) : entry
end
end
def initialize(form_type, default_params, multiple_key, multiline_key, *args)
super(*args)
@form_type = form_type
@tree_params = default_params
@multiple_key = multiple_key
@multiline_key = multiline_key
self.params.each do |key, value|
@tree_params = add_value(@tree_params, key, value, @multiple_key.include?(key))
end
end
def add_value(hash, key, value, multiple)
hash = Hash.new if not hash
if /^(.*?):(.*)$/ =~ key
hash[$1] = add_value(hash[$1], $2, value, @multiple_key.include?($1))
elsif @multiline_key.include?(key)
hash[key] = value.join
else
hash[key] = (multiple or value.length > 1) ? value : value[0]
end
hash
end
def hiddens(value)
value.inject([]) do |res, item|
res + [Amrita.a(:name => item[0], :value => item[1])]
end
end
# Create array of pairs [name, value] from the parameters subtree
def tree_flatten(keys)
hash = @tree_params
keys.each do |key|
return [] if not hash[key]
hash = hash[key]
end
add_flatten(keys[0..-2], keys[-1], hash)
end
def add_flatten(prefix, field, data)
names = field ? prefix + [field] : prefix
if data.is_a? Hash
data.inject([]) do |res, pair|
res + add_flatten(names, pair[0], pair[1])
end
elsif data.is_a? Array
data.inject([]) do |res, value|
res << [names.join(":"), value.to_s]
end
else
# single value
[[names.join(":"), data.to_s]]
end
end
def tree_hidden(keys)
hash = @tree_params
keys.each do |key|
return "" if not hash[key]
hash = hash[key]
end
add_hidden(keys[0..-2], keys[-1], hash)
end
def MyCGI::list_options(data, selections = nil)
selection = ((selections.is_a? Array) ? selections[0] : selections).to_s
data.collect do |pair|
item = Amrita.a(:value => pair[0]){ pair[1] }
item << Amrita.a(:selected => "selected")[0] if
pair[0].to_s == selection
item
end
end
def tree_hidden_e(keys)
hash = @tree_params
keys.each do |key|
return [] if not hash[key]
hash = hash[key]
end
add_hidden_e(keys[0..-2], keys[-1], hash)
end
def add_hidden_e(prefix, field, value)
if value.is_a? Hash
value.inject([]) do |res, pair|
res + add_hidden_e(prefix + [field], pair[0], pair[1])
end
else
value.inject([]) do |res, value1|
res + [ Amrita.a(:name => (prefix + [field]).join(":"),
:value => value1) ]
end
end
end
def add_hidden(prefix, field, value)
if value.is_a?Hash
value.inject("") do |res, pair|
res + add_hidden(prefix + [field], pair[0], pair[1])
end
else
value.inject("") do |res, value1|
res + hidden((prefix + [field]).join(":"), value1)
end
end
end
def MyCGI::hash2date(hash)
begin
value = %w(year month day).collect do |p|
hash[p].is_a?(Array) ? hash[p][0] : hash[p]
end
value[0] = case value[0]
when '$' then 9999
when '#' then Date.today.year
else value[0].to_i
end
value[1] = case value[1]
when '$' then Date::MONTHNAMES.length - 1
when CURRENT_MONTH then Date.today.month
when CURRENT_MONTH_NUMBER.to_s then Date.today.month
else value[1].to_i
end
if value[2] == '$'
value[2] = 1
(Date.new(*value) >> 1) - 1
elsif value[2] == '#'
value[2] = Date.today.day
Date.new(*value)
else
value[2] = value[2].to_i
Date.new(*value)
end
rescue ArgumentError
raise "Invalid date #{hash["year"]}-#{hash["month"]}-#{hash["day"]}"
end
end
def MyCGI::date2hash(date)
{
"year" => [date.year.to_s],
"month" => [date.mon.to_s],
"day" => [date.day.to_s]
}
end
def MyCGI::select_date(prefix = "", value = nil, edit = false)
if value == nil
edit = true
value = MyCGI::date2hash(Date.today)
elsif value.is_a?Hash
# TODO: !!!!
value = { "year" => [value["year"]],
"month" => [value["month"]],
"day" => [value["day"]] } unless value["year"].is_a?Array
edit = true
else
value = MyCGI::date2hash(value)
end
if edit
template = Amrita::TemplateText.new(
"<input class='form-control' id='day' size='2' type='text'>
<select class='form-control' id='month'><option id='item'></select>
<input class='form-control' id='year' size='4' type='text'>")
template.asxml = true
s = String.new
template.expand(s,
{:day => Amrita.a(
:name => prefix + ":day",
:value => value["day"][0]),
:month => Amrita.a(
:name => prefix + ":month"){
{:item => list_options(
(0..(Date::MONTHNAMES.length - 1)).collect do |m|
case m
when CURRENT_MONTH_NUMBER then [m.to_s, CURRENT_MONTH]
else [m.to_s, Date::MONTHNAMES[m]]
end
end, value["month"][0])
}
},
:year => Amrita.a(
:name => prefix + ":year",
:value => value["year"][0])
})
s
else
d = Date.new(value["year"][0].to_i, value["month"][0].to_i,
value["day"][0].to_i)
sprintf("%s %s %s (%s)", value["day"][0],
Date::MONTHNAMES[value["month"][0].to_i],
value["year"][0],
Date::ABBR_DAYNAMES[d.wday])
end
end
def MyCGI::select_date_e(prefix = "", value = nil, edit = false)
noescape{ select_date(prefix, value, edit) }
end
# Assign values to the all-site common parts of the page
#
# @param data Hash of data for amrita template of the page
def MyCGI::add_commons(data)
data[:header] = noescape{ File.open(HEADER_FILE) { |f| f.read }}
data[:footer] = noescape{ File.open(FOOTER_FILE) { |f| f.read }}
end
def MyCGI::optional(data)
{ :value => data }
end
# Create string from the 'value' that can be used
# as a value of HTML DOM attribute 'id'
#
# @return String of proposed attribute value
def MyCGI::element_id(value)
id = value.to_s
if id.empty? or id !~ /[A-Za-z].*/
"s" + id
else
id
end
end
def MyCGI.dump(value)
case value.class.to_s
when "Hash" then
"{" + value.collect do |key, x|
"#{key} => #{dump(x)}"
end.join(", ") + "}"
when "String" then
"\"#{value}\""
when "Array" then
"[#{value.collect {|x| dump(x)}.join(",")}]"
else
"#{value.class}(#{value.to_s})"
end
end
attr_accessor :form_type, :tree_params, :multiple_key
private :add_value
end

212
project.html Normal file
View File

@ -0,0 +1,212 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<link rel="stylesheet" type="text/css" href="/main/diary/diary.css"/>
<link rel="stylesheet" type="text/css" href="/main/diary/css/bootstrap.min.css"/>
<title>Diary Management</title>
</head>
<body>
<span _id="header"></span>
<p><a href="?do=diary">View/edit diaries</a></p>
<!-- Selection menu -->
<div id="menu" _id="menu">
<form method="post" action="#">
<h4>Select projects to list:</h4>
<p>
<input type="hidden" name="do" value="project" />
Status of the project:
<select name="prj:list:prj_status" multiple="multiple">
<option _id="status">Active</option>
</select>
Customer organization
<select name="prj:list:customer">
<option _id="customer">Customer</option>
</select>
<input type="submit" />
</p>
</form>
</div>
<!-- Project list -->
<div id="list">
<div _id="nodata" id="nodata">
No projects found.
</div>
<div _id="list" class="prj_item">
<form method="post" _id="view">
<h4 _id="name">Project Name</h4>
<p>
<input type="hidden" name="do" value="project">
<input type="hidden" name="prj:list:edit" _id="edit_id">
<input type="hidden" name="prj:action:id" _id="edit_id">
<input type="hidden" _id="hiddens">
<input type="submit" _id="edit" name="prj:action:verb" value="Edit"></p>
<p>Project tag: <span _id="tag">tag</span></p>
<p>Customer organization: <span _id="customer">customer</span></p>
<p>Administrative customer contact: <span _id="cust_man">uid</span>
<span _id="cust_tech">Technical customer contact:
<span _id="value">uid</span>
</span></p>
<p>Internal project manager: <span _id="manager">uid</span>
<span _id="leader">Internal project leader:
<span _id="value">uid</span>
</span></p>
<p>Long description of the project:</p>
<pre _id="prj_desc">Description</pre>
<p>Current status of the project: <span _id="prj_status">Active</span></p>
<span _id="status_desc"><p>Description of project status:</p>
<pre _id="value">Status description</pre></span>
<span _id="issues"><p>Issues:</p>
<pre _id="value">Issues</pre></span>
<span _id="maillists"><p>Mailing lists: <span _id="value">maillist</span></p></span>
<span _id="storagedir"><p>Project directory in <a href="/storage/">library</a>:
<a _id="value"></a></p></span>
<span _id="svndir"><p>Project directory in <a href="/svnroot/">SubVersion</a>:
<a _id="value"></a></p></span>
<span _id="extra_rights"><p>Extra user rights (in LDAP filter syntax):
<span _id="value"></span></p></span>
<span _id="bugzilla_prod"><p>Bugzilla product: - not implemented -</p></span>
<div _id="approval" class="approval">
<p><span class="approval_header">Approval mode</span></p>
<p _id="approval_list"><select name="prj:approve:list" multiple="multiple">
<option _id="approval_item">User</option>
</select>
<input type="submit" name="prj:action:verb" value="Delete"></p>
Approve <select name="prj:approve:who">
<option _id="person">Engineer</option>
</select> by
<select name="prj:approve:approver">
<option _id="person">Engineer</option>
</select>
<input type="submit" name="prj:action:verb" value="Add">
</div>
</form>
<form method="post" action="#" _id="edit">
<div class="edit">
<h4>Editing project</h4>
<p>
<input type="hidden" name="do" value="project">
<input type="hidden" name="prj:edit:id" _id="id">
<input type="hidden" _id="hiddens">
Project name<span class="mark"/>*</span>:
<input _id="name" type="text" name="prj:edit:name">
Project tag<span class="mark">*</span>:
<input _id="tag" type="text" name="prj:edit:tag"></p>
<p>Customer organization<span class="mark">*</span>:
<select name="prj:edit:customer">
<option _id="customer">Customer</option>
</select></p>
<p>Administrative customer contact<span class="mark">*</span>:
<input _id="cust_man" type="text" name="prj:edit:cust_man">
Technical customer contact:
<input _id="cust_tech" type="text" name="prj:edit:cust_tech">
</p>
<p>Internal project manager<span class="mark">*</span>:
<input _id="manager" type="text" name="prj:edit:manager">
Internal project leader:
<input _id="leader" type="text" name="prj:edit:leader"></p>
<p>Long description of the project<span class="mark">*</span>:</p>
<p><textarea _id="prj_desc" name="prj:edit:prj_desc"
cols="70" rows="10">Project description</textarea></p>
<p><input _id="hide_hrs" type="checkbox" name="prj:edit:hide_hrs" />
Hide number of hours from customer
</p>
<p>Current status of the project:
<select name="prj:edit:prj_status">
<option _id="prj_status"></option>
</select>
<p>Description of project status:</p>
<p><textarea _id="status_desc" name="prj:edit:status_desc"
cols="70" rows="10">Project status description</textarea>
</p>
<p>Issues:</p>
<p><textarea _id="issues" name="prj:edit:issues"
cols="70" rows="10">Issues</textarea></p>
<p>Mailing lists:
<input _id="maillists" type="text" name="prj:edit:maillists"></p>
<p>Project directory in <a href="/storage/">library</a>:
<input _id="storagedir" type="text" name="prj:edit:storagedir"></p>
<p>Project directory in <a href="/svnroot/">SubVersion</a>:
<input _id="svndir" type="text" name="prj:edit:svndir"></p>
<p>Extra user rights (in LDAP filter syntax):
<input _id="extra_rights" type="text" name="prj:edit:extra_rights"></p>
<p>
<a href="/cgi-bin/bugzilla/editproducts.cgi">Bugzilla product</a>:
-- not implemented --</p>
<!--
<p>Add new CC address: <input type="text" name="prj:edit:ccadd" /></p>
<select name="prj:edit:cclist" multiple="multiple">
<option _id="cclist">user@domain.org</option>
</select>
<p>Remove selected CC <input type="checkbox" name="prj:edit:ccdel" /></p>
-->
<p><input type="submit" value="Submit changes" /></p>
</div>
</form>
</div>
</div>
<!-- Add new project -->
<div id="new" _id="new">
<h4>Add new project</h4>
<form method="post" action="#" _id="edit">
<p>
<input type="hidden" name="do" value="project">
<input type="hidden" name="prj:edit:id" _id="id">
<input type="hidden" _id="hiddens">
Project name<span class="mark"/>*</span>:
<input type="text" name="prj:edit:name" />
Project tag<span class="mark">*</span>:
<input type="text" name="prj:edit:tag" /></p>
<p>Customer organization<span class="mark">*</span>:
<select name="prj:edit:customer">
<option _id="customer">Customer</option>
</select></p>
<p>Administrative customer contact<span class="mark">*</span>:
<input type="text" name="prj:edit:cust_man" />
Technical customer contact:
<input type="text" name="prj:edit:cust_tech" /></p>
<p>Internal project manager<span class="mark">*</span>:
<input type="text" name="prj:edit:manager" _id="manager">
Internal project leader:
<input type="text" name="prj:edit:leader" /></p>
<p>Long description of the project<span class="mark">*</span>:</p>
<p><textarea name="prj:edit:prj_desc" cols="70" rows="10"></textarea></p>
<p><input type="checkbox" name="prj:edit:make_active" />
Mark the project as "ACTIVE"
</p>
<p><input type="checkbox" name="prj:edit:hide_hrs" />
Hide number of hours from customer
</p>
<p>Description of project status:</p>
<p><textarea name="prj:edit:status_desc" cols="70" rows="10"></textarea></p>
<p>Issues:</p>
<p><textarea name="prj:edit:issues" cols="70" rows="10"></textarea></p>
<p>Mailing lists:
<input type="text" name="prj:edit:maillists" /></p>
<p>Project directory in <a href="/storage/">library</a>:
<input type="text" name="prj:edit:storagedir" /></p>
<p>Project directory in <a href="/svnroot/">SubVersion</a>:
<input type="text" name="prj:edit:svndir" /></p>
<p>Extra user rights (in LDAP filter syntax):
<input type="text" name="prj:edit:extra_rights" /></p>
<p>
<a href="/cgi-bin/bugzilla/editproducts.cgi">Bugzilla product</a>:
-- not implemented --</p>
<!--
<p>Add new CC address: <input type="text" name="prj:edit:ccadd" /></p>
<select name="prj:edit:cclist" multiple="multiple">
<option _id="cclist">user@domain.org</option>
</select>
<p>Remove selected CC <input type="checkbox" name="prj:edit:ccdel" /></p>
-->
<p><input type="submit" value="Add new project" /></p>
</form>
</div>
<span _id="footer"></span>
</body>
</html>

456
project.rb Normal file
View File

@ -0,0 +1,456 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright (C) 2021 OKTET Labs Ltd. All rights reserved.
#
# Class Project for Diary Management Application.
#
PROJECT_TEMPLATE = AmritaTemplate.new("project.html")
require 'dbi'
require_relative 'diary_datamapper'
require_relative 'diary_policy'
class Project
STATUS = ["NEW", "ACTIVE", "TERMINATED"]
STATUS_DEFAULT = "ACTIVE"
@@mapper = nil
def initialize(id = nil, values = nil)
@id = id
@attributes = values ? values : Hash.new
@cc = nil
@approvals = nil
end
def self.get(id)
mapper.find(id)
end
def self.where(clause, *args)
mapper.where(clause, *args)
end
def save_approvals
self.class.mapper.save_approvals(@id, @approvals)
end
def approvals
return @approvals unless @approvals.nil?
@approvals = self.class.mapper.find_approvals(@id)
end
def id
@id
end
def [](key)
@attributes[key]
end
def <=>(rhs)
to_s <=> rhs.to_s
end
def to_s
@attributes["customer"] + "/" + @attributes["tag"]
end
def self.mapper
return @@mapper unless @@mapper.nil?
@@mapper = ProjectMapper.new
end
def customer
Organization.find_or_create(@attributes["customer"])
end
# we should not hardcode things
def transform_links(text)
return text
end
def self.all(args)
policy = DiaryEnv.instance.policy
res = mapper.all(args)
res = res & policy.project_list if
policy.restriction.include?(:project)
return res
end
end
class ProjectMapper < DataMapper
def initialize
super("project")
end
def instantiate(id, values)
Project.new(id, values)
end
def insert(prj)
super(prj)
if prj.cc and prj.cc.length > 0
@@database.do("insert into cc_list (prj_id, person) values " +
(["(#{prj.id}, ?)"] * prj.cc.length).join(", "),
*prj.cc);
end
save_approvals(prj.id, prj.approvals)
end
def find_approvals(id)
app = Hash.new
@@database.select_all("select * from approval where prj_id = ?",
id) { |row| app[row["who"]] = row["approver"] }
app
end
def save_approvals(id, app)
@@database.do("delete from approval where prj_id = ?", id)
app.each do |who, approver|
@@database.do("insert into approval (prj_id, who, approver) " +
"values (?, ?, ?)", id, who, approver)
end
end
end
class ProjectUI < Hash
PERSONS = %w(manager leader cust_man cust_tech)
MANDATORY = %w(tag name prj_status manager customer cust_man prj_desc hide_hrs)
OPTIONAL = %w(status_desc issues leader cust_tech maillists svndir
storagedir bugzilla_prod extra_rights)
def status_list(selected)
MyCGI::list_options(
Project::STATUS.collect {|status| [status.to_s, status.to_s]},
selected)
end
def update(id = @id)
@id = id
self.clear
if @id
DataMapper.database.execute("select * from project where id = #{@id}") do |q|
### For some reason MySQL driver always returns 0 in q.rows
# if q.rows != 1
# raise "Project #{@id} has #{q.rows} instances"
# end
row = q.fetch_hash
raise "Project #{@id} has no instances" if not row
self.replace(row)
self.delete("id")
end
self["cc"] = Array.new
DataMapper.database.select_all("select person from cc_list where " +
"prj_id = #{@id}") do |row|
self["cc"].push(row["person"])
end
end
true
end
def initialize(cgi)
@id = nil
@cgi = cgi
@user = DiaryEnv.instance.user
@policy = DiaryEnv.instance.policy
end
def create(data)
return unless @policy.can_edit_project?
#raise "Create project #{data["name"]}"
inst = data.keys
inst -= ["cc"]
DataMapper.database.do("insert into project (" + inst.join(", ") +
") values (" + (inst.collect do "?" end).join(", ") +
")",
*(inst.collect do |field| data[field] end))
@id = DataMapper.database.select_one("select last_insert_id()")[0]
if data["cc"].length > 0
DataMapper.database.do("insert into cc_list (prj_id, person) values " +
(["(#{@id}, ?)"] * data["cc"].length).join(", "),
*data["cc"]);
end
# Send notifications XXX
end
def modify(current, data)
return if not @policy.can_edit_project?(@id)
changes = Array.new
data.each_key do |field|
if data[field] != current[field]
changes.push(field)
end
end
if (changes - ["cc"]).length > 0
DataMapper.database.do("update project set " +
(changes.collect do
|field| field + " = ?" if field != "cc"
end).join(", ") +
" where id = #{@id}",
*(changes.collect do |field| data[field] end));
end
if (current["cc"] - data["cc"]).length > 0
DataMapper.database.do("delete from cc_list where " +
"prj_id = #{@id} and (" +
(["person = ?"] * (current["cc"] - data["cc"]).length
).join(" or ") + ")",
*(current["cc"] - data["cc"]))
end
if (data["cc"] - current["cc"]).length > 0
DataMapper.database.do("insert into cc_list (prj_id, person) " +
"values " +
(["(#{@id}, ?)"] * (data["cc"] - current["cc"]).length
).join(", "),
*(data["cc"] - current["cc"]))
end
# Send notifications XXX
#@people.notify("mis", "Project '#{data["name"]}' is changed")
end
# Set project data to SQL tables.
def set(data)
MANDATORY.each do |field|
if not data[field] or data[field].length == 0
raise "Mandatory field #{field} is not set"
end
end
data.each_key do |field|
if not MANDATORY.include?(field) and
not OPTIONAL.include?(field) and field != "cc"
raise "Field #{field} is unknown"
end
end
data["svndir"].sub!(/^\/*/, '')
data["storagedir"].sub!(/^\/*/, '')
data = data.delete_if do |field, value|
value.length == 0
end
data.each do |field, value|
data[field] = value
end
PERSONS.each do |pers|
if data[pers] and not Person.find(data[pers])
raise "No such person #{pers}: #{data[pers]} "
end
end
if not data["cc"]
data["cc"] = Array.new
end
if @id
modify(self, data)
else
create(data)
end
end
attr_reader :id
def list
return [] if @cgi.tree_params["prj"]["list"]["prj_status"].empty?
args = Hash.new
customer = @cgi.tree_params["prj"]["list"]["customer"]
args[:customer] = customer if
customer.is_a?(Array) and not customer.include?("*")
args[:prj_status] = @cgi.tree_params["prj"]["list"]["prj_status"] ||
[Project::STATUS_DEFAULT]
Project.all(args).collect do |prj|
a(:__id__ => MyCGI::element_id(prj.id)){
list_item(prj, @cgi.tree_params["prj"]["list"]["edit"].
include?(prj.id.to_s))}
end
end
def list_item(values = nil, edit = false)
if (values == nil)
edit = add = true
values = Hash.new
else
add = false
end
if not @policy.can_edit_project?(@id)
edit = add = false
end
if edit
edit_data = {
:hiddens => @cgi.hiddens(@cgi.tree_flatten(["prj", "list"]).
delete_if do |x|
x == ["prj:list:edit", values["id"].to_s]
end),
:name => a(:value => values["name"]),
:tag => a(:value => values["tag"],
:size => Person::MAX_LENGTH),
:customer => MyCGI::list_options(Organization.all.collect { |org|
[org.uid, org.o] },
values["customer"]),
:cust_man => a(:value => values["cust_man"],
:size => Person::MAX_LENGTH),
:cust_tech => a(:value => values["cust_tech"],
:size => Person::MAX_LENGTH),
:manager => a(:value => values["manager"] || DiaryEnv.director[0].uid,
:size => Person::MAX_LENGTH),
:leader => a(:value => values["leader"],
:size => Person::MAX_LENGTH),
:prj_desc => values["prj_desc"] || "",
:prj_status => status_list(@cgi.tree_params["prj"]["list"]["prj_status"]),
:status_desc => values["status_desc"] || "",
:issues => values["issues"] || "",
:maillists => a(:value => values["maillists"]),
:storagedir => a(:value => values["storagedir"]),
:svndir => a(:value => values["svndir"]),
:extra_rights => a(:value => values["extra_rights"]),
:bugzilla_prod => a(:value => values["bugzilla_prod"]),
# :cclist => MyCGI::list_options(values["cc"] ? values["cc"] : [])
:hide_hrs => a(:checked => values["hide_hrs"] ? "checked" : nil)
}
edit_data[:id] = a(:value => values["id"]) if values["id"]
return { :edit => edit_data }
else
view = {
:id => a(:name => values["id"]),
:edit_id => a(:value => values["id"]),
:hiddens => @cgi.hiddens(@cgi.tree_flatten(["prj","list"])),
:name => values["name"],
:tag => values["tag"],
:customer => Organization.find_or_create(values["customer"]).to_html,
:cust_man => Person.find_or_create(values["cust_man"]).to_html,
:manager => Person.find_or_create(values["manager"]).to_html,
:prj_desc => values["prj_desc"],
:prj_status => values["prj_status"]
}
if @policy.can_edit_project?(@id)
approval_items = MyCGI::list_options(
values.approvals.collect do |who, approver|
[ who, "#{Person.find_or_create(who).to_s} => " +
"#{Person.find_or_create(approver).to_s}"]
end)
view[:approval] = {
:person => MyCGI::list_options(
Person.find_by_org(DiaryEnv::HOME_ORGANIZATION).sort.collect { |x| [x.uid, x.to_s] })
}
view[:approval][:approval_list] = {
:approval_item => approval_items
} if not approval_items.empty?
end
view[:edit] = {} if @policy.can_edit_project?(@id)
view[:cust_tech] = MyCGI::optional(
Person.find_or_create(values["cust_tech"]).to_html) if
values["cust_tech"]
view[:leader] = MyCGI::optional(Person.find_or_create(values["leader"]).to_html) if
values["leader"]
view[:status_desc] = MyCGI::optional(values["status_desc"]) if
values["status_desc"]
view[:issues] = MyCGI::optional(values["issues"]) if values["issues"]
view[:maillists] = MyCGI::optional(values["maillists"]) if
values["maillists"]
view[:storagedir] = MyCGI::optional(a(:href => "/storage/" +
values["storagedir"]){values["storagedir"]}) if
values["storagedir"]
view[:svndir] = MyCGI::optional(a(:href => "/svnroot/" +
values["svndir"]){ values["svndir"] }) if
values["svndir"]
view[:extra_rights] = MyCGI::optional(values["extra_rights"]) if
values["extra_rights"]
return { :view => a(:action => "#" +
MyCGI::element_id(values["id"])){ view }}
end
end
def menu
return {
:status => status_list(
@cgi.tree_params["prj"]["list"]["prj_status"].empty? ?
Project::STATUS_DEFAULT :
@cgi.tree_params["prj"]["list"]["prj_status"]),
:customer => MyCGI::list_options([["*", "- All -"]] +
@policy.customer_list.collect { |org|
[org.uid, org.to_s] },
@cgi.tree_params["prj"]["list"]["customer"])
}
end
def add_approval(id, who, approver)
# TODO: user rights
prj = Project.get(id)
raise "No project #{id}" unless prj
raise "You have no rights to edit project" unless
@policy.can_edit_approval?(prj)
prj.approvals[who] = approver
prj.save_approvals
end
def delete_approval(id, who)
who = [who] unless who.is_a? Array
# TODO: user rights
prj = Project.get(id)
raise "No project #{id}" unless prj
raise "You have no rights to edit project" unless
@policy.can_edit_approval?(prj)
raise NeedConfirm.new("cancel_approval"),
"Cancelling the approval mode will approve " +
"ALL diary entries of " +
"#{who.collect{ |x| Person.find(x).to_s }.join(", ")} " +
"in project \"#{prj["name"]} (#{prj.to_s})\"!" if
not DiaryEnv.confirmed?("cancel_approval")
who.each do |x|
prj.approvals.delete(x)
Diary.approve_all(prj, x)
end
prj.save_approvals
end
def action
if @cgi.tree_params["prj"].include?("approve")
id = @cgi.tree_params["prj"]["action"]["id"]
case @cgi.tree_params["prj"]["action"]["verb"]
when "Add"
add_approval(id,
@cgi.tree_params["prj"]["approve"]["who"],
@cgi.tree_params["prj"]["approve"]["approver"])
@cgi.tree_params["prj"]["list"]["edit"].delete(id.to_s)
return
when "Delete"
delete_approval(id,
@cgi.tree_params["prj"]["approve"]["list"])
@cgi.tree_params["prj"]["list"]["edit"].delete(id.to_s)
return
end
end
if @cgi.tree_params["prj"].include?("edit")
attrs = @cgi.tree_params["prj"]["edit"]
attrs["hide_hrs"] = [attrs.include?("hide_hrs") ? 1 : 0]
if attrs.include?("id")
update(attrs["id"])
attrs.delete("id")
set(attrs)
else
attrs["prj_status"] =
attrs.include?("make_active") ? "ACTIVE" : "NEW"
attrs.delete("make_active")
set(attrs)
end
end
end
def show
project_data = Hash.new
MyCGI::add_commons(project_data)
project_data[:menu] = menu
project_data[:new] = list_item() if @policy.can_edit_project?
prj_list = list
if prj_list.length == 0
project_data[:nodata] = {}
else
project_data[:list] = list
end
s = String.new
PROJECT_TEMPLATE.expand(s, project_data)
s
end
end

86
sql_cache.rb Normal file
View File

@ -0,0 +1,86 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright (C) 2021 OKTET Labs Ltd. All rights reserved.
#
# SQL class for Diary Management Application
#
class SQL_Cache < Hash
def initialize(sql, table, string_expr)
@sql = sql
@name = table
@string_expr = string_expr
@lists = Hash.new
@fields = @sql.columns(@name).collect { |col| col["name"] }
super(0)
end
attr_reader :sql, :name
attr_accessor :string_expr
def search(where)
list = Array.new
@sql.select_all("select * from #{@name} where #{where} order by id") do |row|
self[row["id"]] = row.to_h
list.push(self[row["id"]])
end
list
end
private :search
def list(where)
if not @lists[where]
@lists[where] = search(where).collect do |entry|
string = @string_expr.dup
entry.each do |field, value|
string.gsub!(/#\{#{field}\}/, value.to_s) if value
end
[entry["id"].to_s, string]
end
end
@lists[where]
end
def [](id)
id = id.to_i
if super(id) == 0
if search("id = #{id}").length != 1
self[id] = nil
end
end
super(id)
end
def create(data)
fields = data.keys & @fields
@sql.do("insert into #{@name} (" +
fields.join(", ") +
") values (" + fields.collect do "?" end.join(", ") +
")",
*(fields.collect do |field| data[field] end))
@sql.select_one("select last_insert_id()")[0]
end
def modify(id, data)
fields = (@fields & self[id].keys) - ["id"]
changes = fields.inject([]) do |ch, field|
if data.has_key?(field) and data[field] != self[id][field]
ch.push(field)
else
ch
end
end
@sql.do("update #{@name} set " +
changes.collect do |field|
field + " = ?"
end.join(", ") + " where id = #{id}",
*(changes.collect do |field| data[field] end)
) if changes.length > 0
end
def delete(id)
@sql.do("delete from #{@name} where id = #{id}")
super(id)
end
end