Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ agiladmin:
cache: false
voluntary-hours: false
vat-percentage: 0
default-project: DIRECT

webserver:
host: localhost
Expand Down Expand Up @@ -244,12 +245,15 @@ Notes:
- `budgets.ssh-key` is the private key path used for Git access; if it does not exist, Agiladmin generates a new keypair and exposes the public key in the `/config` page
- project names are discovered from `*.yaml` files in `budgets.path`, using the part of the filename before the first `.`
- `cache` enables runtime in-memory caches when `true`; it defaults to `false`
- `default-project` is optional; when set, positive hours from assignment columns with a blank project cell are billed to that project
- if `default-project` is omitted, blank project cells keep the previous behavior and are ignored
- `voluntary-hours` controls whether personnel monthly summaries mention voluntary hours; it defaults to `false`
- `vat-percentage` controls personnel VAT display; it defaults to `0`, which hides the VAT sentence
- `pocketbase` is optional only if you are using dev auth locally
- `webserver.upload-max-size` is in bytes and defaults to `500000`
- `webserver.base-path` is the browser-visible mount prefix; if you publish under a subpath such as `/agiladmin`, your reverse proxy must strip that prefix before forwarding to Jetty routes
- when TLS terminates at Caddy or another reverse proxy, keep `webserver.ssl-redirect: false` and let the proxy handle HTTP to HTTPS redirects
- startup fails fast if `default-project` does not match a loadable project file under `budgets.path`; the error names the configured project and the reason it could not be loaded

## Project Configuration

Expand Down
1 change: 1 addition & 0 deletions doc/agiladmin.pocketbase.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
appname: agiladmin

agiladmin:
default-project: DIRECT
webserver:
anti-forgery: false
ssl-redirect: false
Expand Down
10 changes: 7 additions & 3 deletions scripts/e2e/generate-manager-fixture.clj
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,21 @@
(System/exit 1))

(defn -main [& args]
(let [[src dst owner] args
(let [[src dst owner blank-project-col] args
owner-name (or owner "Manager")]
(when (str/blank? src)
(fail! "Missing src path"))
(when (str/blank? dst)
(fail! "Missing dst path"))
(let [workbook (xls/load-workbook src)
sheet (first (xls/sheet-seq workbook))]
sheets (xls/sheet-seq workbook)
sheet (first sheets)]
(when (nil? sheet)
(fail! "Workbook has no sheets"))
(xls/set-cell! (xls/select-cell "B3" sheet) owner-name)
(doseq [current-sheet sheets]
(xls/set-cell! (xls/select-cell "B3" current-sheet) owner-name)
(when-not (str/blank? blank-project-col)
(xls/set-cell! (xls/select-cell (str (str/upper-case blank-project-col) "7") current-sheet) "")))
(xls/save-workbook! dst workbook))))

(when (= *file* (System/getProperty "babashka.file"))
Expand Down
15 changes: 12 additions & 3 deletions scripts/e2e/start-agiladmin.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ function yamlConfig(budgetsPath, sshKeyPath) {
" source:",
" git: https://github.com/dyne/agiladmin",
" update: true",
" default-project: DIRECT",
" webserver:",
" host: 127.0.0.1",
" port: 18080",
Expand All @@ -52,18 +53,22 @@ function yamlConfig(budgetsPath, sshKeyPath) {
async function copyBudgetFixtures(targetDir) {
const srcDir = path.join(REPO_ROOT, "test", "assets");
const entries = await fs.readdir(srcDir, { withFileTypes: true });
const whitelist = new Set(["UNO.yaml", "DUE.yaml", "TRE.yaml", "BADFIELDS.yaml", "BROKEN.yaml", "INVALIDYAML.yaml"]);
const whitelist = new Set(["UNO.yaml", "DIRECT.yaml", "DUE.yaml", "TRE.yaml", "BADFIELDS.yaml", "BROKEN.yaml", "INVALIDYAML.yaml"]);
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith(".yaml")) continue;
if (!whitelist.has(entry.name)) continue;
await fs.copyFile(path.join(srcDir, entry.name), path.join(targetDir, entry.name));
}
}

async function generateOwnedFixture(sourceFixturePath, targetFixturePath, ownerName) {
function toCljStringLiteral(value) {
return value == null ? "nil" : JSON.stringify(value);
}

async function generateOwnedFixture(sourceFixturePath, targetFixturePath, ownerName, blankProjectCol = null) {
const expr = [
"(load-file \"scripts/e2e/generate-manager-fixture.clj\")",
`(agiladmin.e2e.generate-manager-fixture/-main ${JSON.stringify(sourceFixturePath)} ${JSON.stringify(targetFixturePath)} ${JSON.stringify(ownerName)})`,
`(agiladmin.e2e.generate-manager-fixture/-main ${toCljStringLiteral(sourceFixturePath)} ${toCljStringLiteral(targetFixturePath)} ${toCljStringLiteral(ownerName)} ${toCljStringLiteral(blankProjectCol)})`,
].join(" ");
await new Promise((resolve, reject) => {
const child = spawn(CLOJURE_CMD, ["-M", "-e", expr], {
Expand Down Expand Up @@ -103,9 +108,11 @@ async function prepareEnv() {
const adminFixturePath = path.join(fixturesDir, "2016_timesheet_Luca-Pacioli.xlsx");
const managerFixturePath = path.join(fixturesDir, "2016_timesheet_Manager.xlsx");
const guestFixturePath = path.join(fixturesDir, "2016_timesheet_Guest.xlsx");
const unassignedFixturePath = path.join(fixturesDir, "2016_timesheet_Luca-Pacioli-unassigned.xlsx");
await fs.copyFile(path.join(REPO_ROOT, "test", "assets", "2016_timesheet_Luca-Pacioli.xlsx"), adminFixturePath);
await generateOwnedFixture(adminFixturePath, managerFixturePath, "Manager");
await generateOwnedFixture(adminFixturePath, guestFixturePath, "Guest");
await generateOwnedFixture(adminFixturePath, unassignedFixturePath, "L.Pacioli", "B");

await fs.copyFile(managerFixturePath, path.join(budgetsDir, "2026_timesheet_Manager.xlsx"));
await fs.copyFile(guestFixturePath, path.join(budgetsDir, "2026_timesheet_Guest.xlsx"));
Expand All @@ -121,6 +128,7 @@ async function prepareEnv() {
admin: adminFixturePath,
manager: managerFixturePath,
guest: guestFixturePath,
unassigned: unassignedFixturePath,
},
basePath: E2E_BASE_PATH,
logPath: LOG_PATH,
Expand All @@ -135,6 +143,7 @@ async function prepareEnv() {
console.error("[DEBUG_E2E] fixture_admin:", adminFixturePath);
console.error("[DEBUG_E2E] fixture_manager:", managerFixturePath);
console.error("[DEBUG_E2E] fixture_guest:", guestFixturePath);
console.error("[DEBUG_E2E] fixture_unassigned:", unassignedFixturePath);
}
return state;
}
Expand Down
42 changes: 38 additions & 4 deletions src/agiladmin/config.clj
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

(ns agiladmin.config
(:require [clojure.pprint :refer [pprint]]
[clojure.string :as str :refer [upper-case]]
[clojure.string :as str :refer [blank? trim upper-case]]
[clojure.java.io :as io]
[clojure.walk :refer [keywordize-keys]]
[auxiliary.core :as aux]
Expand All @@ -28,13 +28,17 @@
[yaml.core :as yaml]
[cheshire.core :refer :all]))

(def NonBlankString
(s/constrained s/Str #(not (blank? %)) 'non-blank-string))

(s/defschema Config
{s/Keyword
{:budgets {:git s/Str
:ssh-key s/Str
:path s/Str}
(s/optional-key :projects) [s/Str]
(s/optional-key :cache) s/Bool
(s/optional-key :default-project) NonBlankString
(s/optional-key :voluntary-hours) s/Bool
(s/optional-key :vat-percentage) s/Num
(s/optional-key :webserver) {(s/optional-key :port) s/Num
Expand Down Expand Up @@ -99,6 +103,8 @@

(def project-defaults {})

(declare load-project)

(defn- project-entry-map?
[value]
(and (map? value)
Expand Down Expand Up @@ -246,6 +252,33 @@
;; (f/fail (log/spy :error ["Invalid configuration: " conf ex]))))
(get-in conf path))

(defn default-project
"Return the configured default project name in canonical uppercase form."
[conf]
(get-in conf [:agiladmin :default-project]))

(defn validate-default-project
"Ensure the configured default project resolves to a valid project configuration."
[conf]
(if-let [project-name (default-project conf)]
(let [project (load-project conf project-name)]
(if (f/failed? project)
(f/fail (str "Configured default project "
project-name
" cannot be loaded: "
(f/message project)))
conf))
conf))

(defn- normalize-default-project
[conf]
(let [project (get-in conf [:agiladmin :default-project])]
(cond
(nil? project) conf
(string? project) (assoc-in conf [:agiladmin :default-project]
(-> project trim upper-case))
:else conf)))

(defn- project-file?
[conf file]
(let [name (.getName file)
Expand Down Expand Up @@ -288,9 +321,10 @@
conf (if (f/failed? conf)
conf
(let [app-key (keyword (:appname conf))]
(update-in conf
[app-key :webserver]
#(merge (:webserver default-settings) %))))
(-> conf
(update-in [app-key :webserver]
#(merge (:webserver default-settings) %))
normalize-default-project)))
loaded-paths (->> (:paths conf)
(filter #(.exists (io/as-file %)))
vec)
Expand Down
103 changes: 65 additions & 38 deletions src/agiladmin/core.clj
Original file line number Diff line number Diff line change
Expand Up @@ -103,42 +103,66 @@
(log/spy :error) f/fail)
cell)))

(defn effective-project
"Return the explicit project when present, otherwise the configured default."
[project default-project]
(if (f/failed? project)
project
(let [project (some-> project trim)]
(cond
(blank? project) default-project
(strcasecmp project "total") nil
:else project))))

(defn- load-monthly-hours*
[timesheet month cond-fn default-project]
(if-let [sheet (select-sheet month (:xls timesheet))]
(loop [[n & cols] timesheet-cols-projects
res []]
(let [proj (f/ok-> (get-cell sheet n "7") str trim) ;; row project
task (f/ok-> (get-cell sheet n "8") str trim) ;; row task
tag (f/ok-> (get-cell sheet n "9") str trim) ;; row tag(s) (TODO: support multiple tags)
;; take lowest in row totals starting from 42 (as month lenght varies)
hours (first (for [i timesheet-rows-hourtots
:let [cell (get-cell sheet n i)]
:when (not (nil? cell))]
cell))
project (effective-project proj default-project)
info {:project project
:task task
:tag tag
:hours hours}
entry (if (and (not (nil? hours))
(> hours 0.0)
(not (f/failed? project))
(not (nil? project))
(cond-fn info))
{:month month
:name (:name timesheet)
:project (upper-case project)
:task (if-not (blank? task) (upper-case task) "") ;; uppercase all tasks
:tag (if-not (blank? tag) (upper-case tag) "") ;; uppercase all tags
:hours hours} nil)]
Comment on lines +135 to +145
;; check for errors
(map #(when (f/failed? %) (log/error (f/message %)))
[proj task tag project])
Comment on lines +146 to +148

(if (empty? cols) (if (nil? entry) res (conj res entry))
(recur cols (if (nil? entry) res (conj res entry))))))))

(defn monthly-hours-loader
"Return a load-monthly-hours compatible function using configuration defaults."
[conf]
(let [default-project (conf/default-project conf)]
(fn [timesheet month cond-fn]
(load-monthly-hours* timesheet month cond-fn default-project))))

(defn load-monthly-hours
"load hours from a timesheet month if conditions match"
([timesheet month]
(load-monthly-hours timesheet month #(true)))
([timesheet month cond-fn]
(if-let [sheet (select-sheet month (:xls timesheet))]
(loop [[n & cols] timesheet-cols-projects
res []]
(let [proj (f/ok-> (get-cell sheet n "7") str trim) ;; row project
task (f/ok-> (get-cell sheet n "8") str trim) ;; row task
tag (f/ok-> (get-cell sheet n "9") str trim) ;; row tag(s) (TODO: support multiple tags)
;; take lowest in row totals starting from 42 (as month lenght varies)
hours (first (for [i timesheet-rows-hourtots
:let [cell (get-cell sheet n i)]
:when (not (nil? cell))]
cell))
entry (if (and (not (nil? hours))
(> hours 0.0)
(not (blank? proj))
(not (strcasecmp proj "total"))
(cond-fn {:project proj
:task task
:tag tag
:hours hours}))
{:month month
:name (:name timesheet)
:project (upper-case proj)
:task (if-not (blank? task) (upper-case task) "") ;; uppercase all tasks
:tag (if-not (blank? tag) (upper-case tag) "") ;; uppercase all tags
:hours hours} nil)]
;; check for errors
(map #(when (f/failed? %) (log/error (f/message %)))
[proj task tag])

(if (empty? cols) (if (nil? entry) res (conj res entry))
(recur cols (if (nil? entry) res (conj res entry)))))))))
(load-monthly-hours* timesheet month cond-fn nil)))

(defn map-timesheets
"Map a function across all loaded timesheets. The function prototype
Expand All @@ -164,12 +188,15 @@
(defn load-project-monthly-hours
"load the named project hours from a sequence of timesheets and
return a bidimensional vector: [\"Name\" \"Date\" \"Task\" \"Hours\"]"
[timesheets pname]
(log/info (str "Loading project hours: " pname))
(map-timesheets timesheets load-monthly-hours
(fn [info]
(and (not (strcasecmp (:tag info) "VOL"))
(strcasecmp (:project info) pname)))))
([timesheets pname]
(load-project-monthly-hours nil timesheets pname))
([conf timesheets pname]
(log/info (str "Loading project hours: " pname))
(map-timesheets timesheets
(monthly-hours-loader conf)
(fn [info]
(and (not (strcasecmp (:tag info) "VOL"))
(strcasecmp (:project info) pname))))))

(def time-format (tf/formatter "dd-MM-yyyy"))
(defn current-proj-month [conf]
Expand Down Expand Up @@ -399,7 +426,7 @@
[conf path current-year]
(let [recent-years #{current-year (dec current-year)}]
(->> (map-timesheets (load-all-timesheets conf path #".*_timesheet_.*xlsx$")
load-monthly-hours
(monthly-hours-loader conf)
(fn [info]
(when-let [month (:month info)]
(contains? recent-years
Expand Down
4 changes: 4 additions & 0 deletions src/agiladmin/ring.clj
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@
(reset! config (conf/load-config
(or (System/getenv "AGILADMIN_CONF") "agiladmin")
conf/default-settings))
(when (f/failed? @config)
(throw (ex-info (f/message @config)
{:type ::config-load-failed})))
(reset! config (conf/validate-default-project @config))
(when (f/failed? @config)
(throw (ex-info (f/message @config)
{:type ::config-load-failed})))
Expand Down
2 changes: 1 addition & 1 deletion src/agiladmin/view_person.clj
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@
ts-file (util/name-year-to-timesheet person year)
timesheet (load-timesheet (str ts-path ts-file))
projects (load-all-projects config)
hours (map-timesheets [timesheet] load-monthly-hours (fn [_] true))]
hours (map-timesheets [timesheet] (monthly-hours-loader config) (fn [_] true))]
{:ts-file ts-file
:timesheet timesheet
:projects projects
Expand Down
2 changes: 1 addition & 1 deletion src/agiladmin/view_project.clj
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@
[config projname]
(let [ts-path (conf/q config [:agiladmin :budgets :path])
timesheets (load-all-timesheets config ts-path #".*_timesheet_.*xlsx$")]
(load-project-monthly-hours timesheets projname)))
(load-project-monthly-hours config timesheets projname)))

(defn project-costs
[config project-conf projname]
Expand Down
4 changes: 2 additions & 2 deletions src/agiladmin/view_timesheet.clj
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@
(f/attempt-all
[_ (require-upload-ownership account filename path)
ts (load-timesheet path)
hours (map-timesheets [ts])]
hours (map-timesheets [ts] (monthly-hours-loader config))]
(render-workspace
request
account
Expand Down Expand Up @@ -433,7 +433,7 @@
(load-timesheet
(str (conf/q config [:agiladmin :budgets :path])
(fs/base-name filename)))
old-hours (map-timesheets [old-ts])]
old-hours (map-timesheets [old-ts] (monthly-hours-loader config))]
(timesheet-diff old-hours hours)
(f/when-failed [e]
(web/render-error
Expand Down
Loading