Initial revision
authoradamch <adamch>
Thu, 14 Apr 2005 19:19:36 +0000 (19:19 +0000)
committeradamch <adamch>
Thu, 14 Apr 2005 19:19:36 +0000 (19:19 +0000)
23 files changed:
.cvsignore [new file with mode: 0644]
after.mlt [new file with mode: 0644]
balance.sig [new file with mode: 0644]
balance.sml [new file with mode: 0644]
balances.mlt [new file with mode: 0644]
before.mlt [new file with mode: 0644]
exn.mlt [new file with mode: 0644]
footer.mlt [new file with mode: 0644]
group.sig [new file with mode: 0644]
group.sml [new file with mode: 0644]
groups.mlt [new file with mode: 0644]
header.mlt [new file with mode: 0644]
init.sig [new file with mode: 0644]
init.sml [new file with mode: 0644]
mlt.conf [new file with mode: 0644]
money.mlt [new file with mode: 0644]
money.sig [new file with mode: 0644]
money.sml [new file with mode: 0644]
portal.mlt [new file with mode: 0644]
tables.sql [new file with mode: 0644]
users.mlt [new file with mode: 0644]
util.sig [new file with mode: 0644]
util.sml [new file with mode: 0644]

diff --git a/.cvsignore b/.cvsignore
new file mode 100644 (file)
index 0000000..e25901e
--- /dev/null
@@ -0,0 +1,3 @@
+out
+.cm
+CM
diff --git a/after.mlt b/after.mlt
new file mode 100644 (file)
index 0000000..a7fc86f
--- /dev/null
+++ b/after.mlt
@@ -0,0 +1,3 @@
+<% Init.done () %>
+
+<!-- After -->
\ No newline at end of file
diff --git a/balance.sig b/balance.sig
new file mode 100644 (file)
index 0000000..9cd300a
--- /dev/null
@@ -0,0 +1,14 @@
+signature BALANCE =
+sig
+    type balance = {id :int, name : string, amount : real}
+
+    val addBalance : string -> int
+    val lookupBalance : int -> balance
+    val modBalance : balance -> unit
+    val deleteBalance : int -> unit
+    val listBalances : unit -> balance list
+    val listBalanceUsers : int -> Init.user list
+
+    val validBalanceName : string -> bool
+    val balanceNameToId : string -> int option
+end
\ No newline at end of file
diff --git a/balance.sml b/balance.sml
new file mode 100644 (file)
index 0000000..b2e905c
--- /dev/null
@@ -0,0 +1,65 @@
+structure Balance :> BALANCE =
+struct
+
+open Util Sql Init
+
+
+(* Managing balances *)
+
+type balance = {id :int, name : string, amount : real}
+
+fun mkBalanceRow [id, name, amount] =
+    {id = C.intFromSql id, name = C.stringFromSql name, amount = C.realFromSql amount}
+  | mkBalanceRow row = raise Fail ("Bad balance row : " ^ makeSet id row)
+
+fun addBalance name =
+    let
+       val db = getDb ()
+       val id = nextSeq (db, "BalanceSeq")
+    in
+       C.dml db ($`INSERT INTO Balance (id, name, amount)
+                    VALUES (^id, ^(C.stringToSql name), 0.0)`);
+       C.intFromSql id
+    end
+
+fun lookupBalance id =
+    let
+       val c = getDb ()
+    in
+       (case C.oneOrNoRows c ($`SELECT id, name, amount FROM Balance WHERE id = ^(C.intToSql id)`) of
+            NONE => raise Fail "Balance not found"
+          | SOME r => mkBalanceRow r)
+    end
+
+fun modBalance (balance : balance) =
+    let
+       val db = getDb ()
+    in
+       ignore (C.dml db ($`UPDATE Balance
+                           SET name = ^(C.stringToSql (#name balance))
+                           WHERE id = ^(C.intToSql (#id balance))`))
+    end
+
+fun deleteBalance id =
+    ignore (C.dml (getDb ()) ($`DELETE FROM Balance WHERE id = ^(C.intToSql id)`))
+
+fun listBalances () =
+    C.map (getDb ()) mkBalanceRow ($`SELECT id, name, amount FROM Balance
+                                  ORDER BY name`)
+
+fun validBalanceName name =
+    size name <= 20
+    andalso CharVector.all (fn ch => Char.isAlpha ch orelse ch = #"+") name
+
+fun balanceNameToId name =
+    case C.oneOrNoRows (getDb ()) ($`SELECT id FROM Balance WHERE name = ^(C.stringToSql name)`) of
+       SOME [id] => SOME (C.intFromSql id)
+      | _ => NONE
+
+fun listBalanceUsers bal =
+    C.map (getDb ()) mkUserRow ($`SELECT id, name, rname, bal, joined
+                                 FROM WebUser
+                                 WHERE bal = ^(C.intToSql bal)
+                                 ORDER BY name`)
+
+end
\ No newline at end of file
diff --git a/balances.mlt b/balances.mlt
new file mode 100644 (file)
index 0000000..d2c19c9
--- /dev/null
@@ -0,0 +1,69 @@
+<% Group.requireGroupNum 0;
+
+@header [("title", ["Balance management"])];
+
+ref showNormal = true;
+
+if $"cmd" = "Create" then
+       if not (Balance.validBalanceName ($"name")) then %>
+               <h3><b>Invalid balance name</b></h3>
+       <% else switch Balance.balanceNameToId ($"name") of
+                 SOME _ => %>
+                       <h3><b>Balance name already in use</b></h3>
+<%             | NONE =>
+                       val id = Balance.addBalance ($"name") %>
+                       <h3><b>Balance added</b></h3>
+<%             end
+       end
+elseif $"mod" <> "" then
+       showNormal := false;
+       val balance = Balance.lookupBalance (Web.stoi ($"mod")) %>
+<h3><b>Modify balance record</b></h3>
+
+<form action="balances">
+<input type="hidden" name="id" value="<% $"mod" %>">
+<table>
+<tr> <td align="right"><b>Name</b>:</td> <td><input name="name" value="<% #name balance %>"></td> </tr>
+<tr> <td><input type="submit" name="cmd" value="Save"></td> </tr>
+</table>
+</form>
+
+<% elseif $"cmd" = "Save" then
+       val balance = Balance.lookupBalance (Web.stoi ($"id"));
+       Balance.modBalance {balance with name = $"name"} %>
+       <h3><b>Balance record saved.</b></h3>
+
+<% elseif $"del" <> "" then
+       showNormal := false;
+       val balance = Balance.lookupBalance (Web.stoi ($"del")) %>
+       <h3><b>Are you sure you want to delete balance <% #name balance %></a>?</b></h3>
+       <a href="balances?del2=<% $"del" %>">Yes, delete <% #name balance %>!</a>
+
+<% elseif $"del2" <> "" then
+       val balance = Balance.lookupBalance (Web.stoi ($"del2"));
+       Balance.deleteBalance (Web.stoi ($"del2")) %>
+       <h3><b><% #name balance %> deleted!</b></h3>
+
+<% end %>
+
+<% if showNormal then %>
+<h3><b>New balance</b></h3>
+
+<form action="balances">
+<table>
+<tr> <td align="right"><b>Name</b>:</td> <td><input name="name"></td> </tr>
+<tr> <td><input type="submit" name="cmd" value="Create"></td> </tr>
+</table>
+</form>
+
+<h3><b>Manage current balances</b></h3>
+
+<table>
+<% foreach balance in Balance.listBalances () do %>
+       <tr> <td><% Web.html (#name balance) %></td> <td><% #amount balance %></td> <td><a href="balances?mod=<% #id balance %>">[Modify]</a> <a href="balances?del=<% #id balance %>">[Delete]</a></td> </tr>
+<% end %>
+</table>
+
+<% end %>
+
+<% @footer [] %>
diff --git a/before.mlt b/before.mlt
new file mode 100644 (file)
index 0000000..5bc0423
--- /dev/null
@@ -0,0 +1,3 @@
+<% Init.init () %>
+
+<!-- Before -->
\ No newline at end of file
diff --git a/exn.mlt b/exn.mlt
new file mode 100644 (file)
index 0000000..e645be1
--- /dev/null
+++ b/exn.mlt
@@ -0,0 +1,19 @@
+<% @header [("title", ["Exception"])] %>
+
+<% switch Web.getExn () of
+     Fail msg => %>
+<b>Fail</b>: <% Web.htmlNl msg %>
+<% | Init.C.Sql msg => %>
+<b>SQL</b>: <% Web.htmlNl msg %>
+<% | Init.Access msg => %>
+<b>Authorization error</b>: <% Web.htmlNl msg %>
+<% | Web.Format s => %>
+<b>Format</b>: <% Web.htmlNl s %>
+<% | ex => %>
+<b>Unknown exception kind.</b> Backtrace:
+<% foreach s in SMLofNJ.exnHistory ex do %>
+<li> <% Web.html s %></li>
+<% end
+end %>
+
+<% @footer [] %>
diff --git a/footer.mlt b/footer.mlt
new file mode 100644 (file)
index 0000000..8634a86
--- /dev/null
@@ -0,0 +1 @@
+</body></html>
\ No newline at end of file
diff --git a/group.sig b/group.sig
new file mode 100644 (file)
index 0000000..d914acb
--- /dev/null
+++ b/group.sig
@@ -0,0 +1,29 @@
+signature GROUP =
+sig
+    type group = {id :int, name : string}
+    type membership = {usr : int, grp : int}
+
+    val addGroup : string -> int
+    val lookupGroup : int -> group
+    val modGroup : group -> unit
+    val deleteGroup : int -> unit
+    val listGroups : unit -> group list
+
+    val validGroupName : string -> bool
+    val groupNameToId : string -> int option
+
+    val userInGroupNum : int * int -> bool
+    val userInGroupName : int * string -> bool
+
+    val addToGroup : membership -> unit
+    val addToGroups : int * int list -> unit
+    val removeFromGroup : membership -> unit
+    val groupMembers : int -> Init.user list
+
+    val inGroupNum : int -> bool
+    val inGroupName : string -> bool
+
+    (* These raise Access if the check fails *)
+    val requireGroupNum : int -> unit
+    val requireGroupName : string -> unit
+end
\ No newline at end of file
diff --git a/group.sml b/group.sml
new file mode 100644 (file)
index 0000000..8bad77f
--- /dev/null
+++ b/group.sml
@@ -0,0 +1,144 @@
+structure Group :> GROUP =
+struct
+
+open Util Sql Init
+
+
+(* Managing groups *)
+
+type group = {id :int, name : string}
+
+fun mkGroupRow [id, name] =
+    {id = C.intFromSql id, name = C.stringFromSql name}
+  | mkGroupRow row = raise Fail ("Bad group row : " ^ makeSet id row)
+
+fun addGroup name =
+    let
+       val db = getDb ()
+       val id = nextSeq (db, "WebGroupSeq")
+    in
+       C.dml db ($`INSERT INTO WebGroup (id, name)
+                    VALUES (^id, ^(C.stringToSql name))`);
+       C.intFromSql id
+    end
+
+fun lookupGroup id =
+    let
+       val c = getDb ()
+    in
+       (case C.oneOrNoRows c ($`SELECT id, name FROM WebGroup WHERE id = ^(C.intToSql id)`) of
+            NONE => raise Fail "Group not found"
+          | SOME r => mkGroupRow r)
+    end
+
+fun modGroup (group : group) =
+    let
+       val db = getDb ()
+    in
+       ignore (C.dml db ($`UPDATE WebGroup
+                           SET name = ^(C.stringToSql (#name group))
+                           WHERE id = ^(C.intToSql (#id group))`))
+    end
+
+fun deleteGroup id =
+    ignore (C.dml (getDb ()) ($`DELETE FROM WebGroup WHERE id = ^(C.intToSql id)`))
+
+fun listGroups () =
+    C.map (getDb ()) mkGroupRow ($`SELECT id, name FROM WebGroup
+                                  ORDER BY name`)
+
+(* Checking group membership *)
+
+fun userInGroupNum (usr, grp) =
+    let
+       val c = getDb ()
+    in
+       (case C.oneOrNoRows c ($`SELECT COUNT( * )
+                                   FROM Membership
+                                   WHERE grp = ^(C.intToSql grp)
+                                      AND usr = ^(C.intToSql usr)`) of
+            SOME[x] => x <> "0"
+          | _ => false)
+    end
+
+fun userInGroupName (usr, grp) =
+    let
+       val c = getDb ()
+    in
+       (case C.oneOrNoRows c ($`SELECT COUNT( * )
+                                   FROM Membership, WebGroup
+                                   WHERE name = ^(C.stringToSql grp)
+                                      AND usr = ^(C.intToSql usr)
+                                       AND grp = id`) of
+            SOME[x] => x <> "0"
+          | _ => false)
+    end
+
+
+(* Managing group memberships *)
+
+type membership = {usr : int, grp : int}
+
+fun addToGroup (mem : membership) =
+    let
+       val usr = #usr mem
+       val grp = #grp mem
+    in
+       if userInGroupNum (usr, grp) then
+           ()
+       else
+           ignore (C.dml (getDb ()) ($`INSERT INTO Membership (grp, usr)
+                                       VALUES (^(C.intToSql grp), ^(C.intToSql usr))`))
+    end
+
+fun addToGroups (usr, grps) =
+    List.app (fn grp => addToGroup {usr = usr, grp = grp}) grps
+
+fun removeFromGroup (mem : membership) =
+    let
+       val usr = #usr mem
+       val grp = #grp mem
+    in
+       ignore (C.dml (getDb ()) ($`DELETE FROM Membership
+                                   WHERE grp = ^(C.intToSql grp)
+                                      AND usr = ^(C.intToSql usr)`))
+    end
+
+fun mkMembershipRow [grp, usr] =
+    {grp = C.intFromSql grp, usr = C.intFromSql usr}
+  | mkMembershipRow row = raise Fail ("Bad membership row : " ^ makeSet id row)
+
+fun groupMembers grp =
+    C.map (getDb ()) mkUserRow ($`SELECT id, name, rname, bal, joined FROM Membership, WebUser
+                                       WHERE grp = ^(C.intToSql grp)
+                                          AND usr = id
+                                       ORDER BY name`)
+
+
+(* Checking memberships of the current user *)
+
+fun inGroupNum grp = userInGroupNum (getUserId(), grp)
+fun inGroupName grp = userInGroupName (getUserId(), grp)
+
+fun requireGroupNum grp =
+    if inGroupNum grp then
+       ()
+    else
+       raise Access ("You aren't a member of group #" ^ Int.toString grp)
+
+fun requireGroupName grp =
+    if inGroupName grp then
+       ()
+    else
+       raise Access ("You aren't a member of group \"" ^ grp ^ "\"")
+
+fun validGroupName name =
+    size name <= 10
+    andalso CharVector.all Char.isAlpha name
+
+fun groupNameToId name =
+    case C.oneOrNoRows (getDb ()) ($`SELECT id FROM WebGroup WHERE name = ^(C.stringToSql name)`) of
+       SOME [id] => SOME (C.intFromSql id)
+      | _ => NONE
+
+end
\ No newline at end of file
diff --git a/groups.mlt b/groups.mlt
new file mode 100644 (file)
index 0000000..3941620
--- /dev/null
@@ -0,0 +1,99 @@
+<% Group.requireGroupNum 0;
+
+@header [("title", ["Group management"])];
+
+ref showNormal = true;
+
+if $"cmd" = "Create" then
+       if not (Group.validGroupName ($"name")) then %>
+               <h3><b>Invalid group name</b></h3>
+       <% else switch Group.groupNameToId ($"name") of
+                 SOME _ => %>
+                       <h3><b>Group name already in use</b></h3>
+<%             | NONE =>
+                       val id = Group.addGroup ($"name") %>
+                       <h3><b>Group added</b></h3>
+<%             end
+       end
+elseif $"mod" <> "" then
+       showNormal := false;
+       val group = Group.lookupGroup (Web.stoi ($"mod")) %>
+<h3><b>Modify group record</b></h3>
+
+<form action="groups">
+<input type="hidden" name="id" value="<% $"mod" %>">
+<table>
+<tr> <td align="right"><b>Name</b>:</td> <td><input name="name" value="<% #name group %>"></td> </tr>
+<tr> <td><input type="submit" name="cmd" value="Save"></td> </tr>
+</table>
+</form>
+
+<% elseif $"cmd" = "Save" then
+       val group = Group.lookupGroup (Web.stoi ($"id"));
+       Group.modGroup {group with name = $"name"} %>
+       <h3><b>Group record saved.</b></h3>
+
+<% elseif $"del" <> "" then
+       showNormal := false;
+       val group = Group.lookupGroup (Web.stoi ($"del")) %>
+       <h3><b>Are you sure you want to delete group <% #name group %>?</b></h3>
+       <a href="groups?del2=<% $"del" %>">Yes, delete <% #name group %>!</a>
+
+<% elseif $"del2" <> "" then
+       val group = Group.lookupGroup (Web.stoi ($"del2"));
+       Group.deleteGroup (Web.stoi ($"del2")) %>
+       <h3><b><% #name group %> deleted!</b></h3>
+
+<% elseif $"cmd" = "Grant" then
+       Group.addToGroup {usr = Web.stoi ($"usr"), grp = Web.stoi ($"grp")} %>
+       <h3><b>Membership granted.</b></h3>
+
+<% elseif $"revoke" <> "" then
+       Group.removeFromGroup {usr = Web.stoi ($"revoke"), grp = Web.stoi ($"grp")} %>
+       <h3><b>Membership revoked.</b></h3>
+
+<% end %>
+
+<% if showNormal then %>
+
+<h3><b>Create group</b></h3>
+
+<form action="groups">
+<table>
+<tr> <td align="right"><b>Name</b>:</td> <td><input name="name"></td> </tr>
+<tr> <td><input type="submit" name="cmd" value="Create"></td> </tr>
+</table>
+</form>
+
+<h3><b>Grant membership</b></h3>
+
+<form action="groups">
+<table>
+<tr> <td align="right"><b>Group</b>:</td> <td><select name="grp">
+<% foreach group in Group.listGroups () do %>
+       <option value="<% #id group %>"><% Web.html (#name group) %></option>
+<% end %>
+</select></td> </tr>
+<tr> <td align="right"><b>Member</b>:</td> <td><select name="usr">
+<% foreach user in Init.listUsers () do %>
+       <option value="<% #id user %>"><% Web.html (#name user) %></option>
+<% end %>
+</select></td> </tr>
+<tr> <td><input type="submit" name="cmd" value="Grant"></td> </tr>
+</table>
+</form>
+
+<h3><b>Manage current groups</b></h3>
+
+<table>
+<% foreach group in Group.listGroups () do %>
+       <tr> <td><% Web.html (#name group) %>(#<% #id group %>)</td> <td></td> <td><a href="groups?mod=<% #id group %>">[Modify]</a> <a href="groups?del=<% #id group %>">[Delete]</a></td> </tr>
+<% foreach user in Group.groupMembers (#id group) do %>
+       <tr> <td></td> <td><a href="user?id=<% #id user %>"><% Web.html (#name user) %></a></td> <td><a href="groups?revoke=<% #id user %>&grp=<% #id group %>">[Revoke]</a></td> </tr>
+<% end
+end %>
+</table>
+
+<% end %>
+
+<% @footer [] %>
\ No newline at end of file
diff --git a/header.mlt b/header.mlt
new file mode 100644 (file)
index 0000000..68e3d67
--- /dev/null
@@ -0,0 +1,13 @@
+<html><head>
+<% val title =
+       case $"title" of
+                 "" => "Hcoop Portal"
+               | t => ("Hcoop Portal: " ^ t) %>
+<title><% Web.html title %></title>
+</head><body>
+
+<% if Group.inGroupNum 0 then %>
+<p><b>Admin</b>: <a href="users">Members</a> | <a href="groups">Groups</a> | <a href="balances">Balances</a></p>
+<% end %>
+
+<h2><b><% Web.html title %></b></h2>
diff --git a/init.sig b/init.sig
new file mode 100644 (file)
index 0000000..4746bf2
--- /dev/null
+++ b/init.sig
@@ -0,0 +1,33 @@
+signature INIT = sig
+    structure C : SQL_CLIENT
+
+    exception Access of string
+
+    type user = {id : int, name : string, rname : string, bal : int, joined : C.timestamp}
+
+    val mkUserRow : string list -> user
+
+    (* Open or close a session, wrapped in a transaction *)
+    val init : unit -> unit
+    val done : unit -> unit
+
+    val nextSeq : C.conn * string -> string
+
+    (* Fun with users *)
+
+    val lookupUser : int -> user
+    val listUsers : unit -> user list
+    val addUser : string * string * int -> int
+    (* Pass name, real name, and balance ID *)
+    val modUser : user -> unit
+    val deleteUser : int -> string
+
+    val validUsername : string -> bool
+    val userNameToId : string -> int option
+
+    val getDb : unit -> C.conn
+
+    val getUser : unit -> user
+    val getUserId : unit -> int
+    val getUserName : unit -> string
+end
\ No newline at end of file
diff --git a/init.sml b/init.sml
new file mode 100644 (file)
index 0000000..390b9a8
--- /dev/null
+++ b/init.sml
@@ -0,0 +1,99 @@
+structure Init :> INIT =
+struct
+
+open Util Sql
+structure C = PgClient
+
+exception Access of string
+
+fun conn () = C.conn "dbname='hcoop'"
+val close = C.close
+
+type user = {id : int, name : string, rname : string, bal : int, joined : C.timestamp}
+
+val db = ref (NONE : C.conn option)
+val user = ref (NONE : user option)
+
+fun getDb () = valOf (!db)
+
+fun mkUserRow [id, name, rname, bal, joined] =
+    {id = C.intFromSql id, name = C.stringFromSql name, rname = C.stringFromSql rname,
+     bal = C.intFromSql bal, joined = C.timestampFromSql joined}
+  | mkUserRow row = raise Fail ("Bad user row : " ^ makeSet id row)
+
+fun init () =
+    let
+       val c = conn ()
+    in
+       C.dml c "BEGIN";
+       case Web.getCgi "REMOTE_USER" of
+           NONE => raise Fail "Not logged in"
+         | SOME name =>
+           (case C.oneOrNoRows c ($`SELECT id, name, rname, bal, joined
+                                    FROM WebUser
+                                    WHERE name=^(C.stringToSql name)`) of
+                NONE => raise Fail "User not found"
+              | SOME r => user := SOME (mkUserRow r));
+           db := SOME c
+    end
+
+fun done () =
+    let
+       val db = getDb ()
+    in
+       C.dml db "COMMIT";
+       close db
+    end
+
+fun getUser () = valOf (!user)
+fun getUserId () = #id (getUser ())
+fun getUserName () = #name (getUser ())
+
+fun lookupUser id =
+    mkUserRow (C.oneRow (getDb ()) ($`SELECT id, name, rname, bal, joined
+                                     FROM WebUser
+                                     WHERE id = ^(C.intToSql id)`))
+
+fun listUsers () =
+    C.map (getDb ()) mkUserRow ($`SELECT id, name, rname, bal, joined
+                                 FROM WebUser
+                                 ORDER BY name`)
+
+fun nextSeq (db, seq) =
+    case C.oneRow db ($`SELECT nextval('^(seq)')`) of
+       [id] => id
+      | _ => raise Fail "Bad next sequence val"
+
+fun addUser (name, rname, bal) =
+    let
+       val db = getDb ()
+       val id = nextSeq (db, "WebUserSeq")
+    in
+       C.dml db ($`INSERT INTO WebUser (id, name, rname, bal, joined)
+                   VALUES (^id, ^(C.stringToSql name), ^(C.stringToSql rname), ^(C.intToSql bal), CURRENT_TIMESTAMP)`);
+       C.intFromSql id
+    end
+
+fun modUser (user : user) =
+    let
+       val db = getDb ()
+    in
+       ignore (C.dml db ($`UPDATE WebUser SET
+                           name = ^(C.stringToSql (#name user)), rname = ^(C.stringToSql (#rname user)),
+                              bal = ^(C.intToSql (#bal user))
+                           WHERE id = ^(C.intToSql (#id user))`))
+    end
+
+fun deleteUser id =
+    C.dml (getDb ()) ($`DELETE FROM WebUser WHERE id = ^(C.intToSql id)`)
+
+fun validUsername name =
+    size name <= 10
+    andalso CharVector.all Char.isAlpha name
+
+fun userNameToId name =
+    case C.oneOrNoRows (getDb ()) ($`SELECT id FROM WebUser WHERE name = ^(C.stringToSql name)`) of
+       SOME [id] => SOME (C.intFromSql id)
+      | _ => NONE
+
+end
\ No newline at end of file
diff --git a/mlt.conf b/mlt.conf
new file mode 100644 (file)
index 0000000..3540eff
--- /dev/null
+++ b/mlt.conf
@@ -0,0 +1,12 @@
+print real = Util.printReal
+print int  = Util.printInt
+
+before before
+after  after
+exn    exn
+
+out    /home/hcoop/public_html/src/hcoop/out
+pub    /var/www/users.hcoop.net/cgi/hcoop
+
+cm     /usr/local/share/smlsql/smlsql.cm
+cm     /usr/local/share/smlsql/libpq/sources.cm
diff --git a/money.mlt b/money.mlt
new file mode 100644 (file)
index 0000000..e2bf406
--- /dev/null
+++ b/money.mlt
@@ -0,0 +1,194 @@
+<% @header [("title", ["MoneyMatters"])];
+
+ref showNormal = true;
+
+if $"cmd" = "list" then
+       showNormal := false %>
+
+<h3><b>Transactions</b></h3>
+
+<table>
+<tr> <td><b>Date</b></td> <td><b>Description</b></td> <td><b>Amount</b></td> <td><b>Last modified</b></td> <td><b>Participants</b></td> </tr>
+<% foreach trn in Money.listTransactions () do %>
+<tr> <td><% #d trn %></td> <td><a href="money?trn=<% #id trn %>"><% Web.html (#descr trn) %></a></td> <td><% #amount trn %></td> <td><% #stamp trn %></td>
+<% switch Money.listChargesWithNames (#id trn) of
+         [(name, cha)] => %><td><a href="user?id=<% #usr cha %>"><% name %></a></td> </tr><%
+       | _ => %><td><i>multi</i></td> </tr><%
+end
+end %>
+</table>
+
+<% elseif $"cmd" = "bals" then
+       showNormal := false %>
+
+<h3><b>Balances</b></h3>
+
+<table>
+<% foreach bal in Balance.listBalances () do %>
+<tr><td><% #name bal %></td> <td><% #amount bal %></td> <td>
+<% switch Balance.listBalanceUsers (#id bal) of
+         [] =>
+       | (user :: users) =>
+               %><a href="user?id=<% #id user %>"><% Web.html (#name user) %></a><%
+               foreach user in users do
+                       %>, <a href="user?id=<% # id user %>"><% Web.html (#name user) %></a><%
+               end
+end %></td> </tr>
+<% end %>
+</table>
+
+<% elseif $"cmd" = "hosting" then
+       Group.requireGroupName "money";
+       showNormal := false %>
+
+<h3><b>New hosting bill</b></h3>
+
+<form action="money">
+<input type="hidden" name="cmd" value="hosting2">
+<table>
+<tr> <td align="right"><b>Description</b>:</td> <td><input name="descr"></td> </tr>
+<tr> <td align="right"><b>Date</b>:</td> <td><input name="d"></td> </tr>
+<tr> <td align="right"><b>Amount</b>:</td> <td><input name="amount"></td> </tr>
+<tr> <td align="right"><b>Free bandwidth cutoff (MB)</b>:</td> <td><input name="cutoff" value="200"></td> </tr>
+<tr> <td align="right"><b>Cost/GB</b>:</td> <td><input name="cost" value="4"></td> </tr>
+<tr> <td align="right" valign="top"><b>Member usage</b>:</td> <td><textarea wrap="soft" name="usage" rows="24" cols="80"></textarea></td> </tr>
+<tr> <td><input type="submit" value="Add"></td> </tr>
+</table>
+
+<% elseif $"cmd" = "hosting2" then
+       val id = Money.addTransaction ($"descr", Web.stor ($"amount"), $"d");
+       Money.addHostingCharges {trn = id, cutoff = 1000 * Web.stoi ($"cutoff"), cost = Web.stor ($"cost"), usage = $"usage"};
+
+       %><h3><b>Hosting transaction added.</b></h3>
+
+<% elseif $"cmd" = "evenForm" then
+       Group.requireGroupName "money";
+       showNormal := false %>
+<h3><b>New generic/even transaction</b></h3>
+<form action="money">
+<input type="hidden" name="cmd" value="even">
+<table>
+<tr> <td align="right"><b>Description</b>:</td> <td><input name="descr"></td> </tr>
+<tr> <td align="right"><b>Date</b>:</td> <td><input name="d"></td> </tr>
+<tr> <td align="right"><b>Amount</b>:</td> <td><input name="amount"></td> </tr>
+<tr> <td align="right" valign="top"><b>Members</b>:</td> <td><select name="usrs" size="5" multiple>
+<% foreach usr in Init.listUsers () do %>
+       <option value="<% #id usr %>"><% #name usr %></option>
+<% end %>
+</select></td> </tr>
+<tr> <td><input type="submit" value="Add"></td> </tr>
+</table>
+</form>
+
+<% elseif $"cmd" = "even" then
+       val id = Money.addTransaction ($"descr", Web.stor ($"amount"), $"d");
+       Money.addEvenCharges (id, map Web.stoi (Web.getMultiParam "usrs"))
+
+       %><h3><b>Even transaction added.</b></h3>
+
+<% elseif $"modEven" <> "" then
+       showNormal := false;
+       val trn = Money.lookupTransaction (Web.stoi ($"modEven")) %>
+<h3><b>Modify even transaction</b></h3>
+
+<form action="money">
+<input type="hidden" name="saveEven" value="<% $"modEven" %>">
+<table>
+<tr> <td align="right"><b>Description</b>:</td> <td><input name="descr" value="<% Web.html (#descr trn) %>"></td> </tr>
+<tr> <td align="right"><b>Date</b>:</td> <td><input name="d" value="<% Web.html (#d trn) %>"></td> </tr>
+<tr> <td align="right"><b>Amount</b>:</td> <td><input name="amount" value="<% #amount trn %>"></td> </tr>
+<tr> <td align="right" valign="top"><b>Members</b>:</td> <td><select name="usrs" size="5" multiple>
+<% foreach (sel, usr) in Money.listUsers (#id trn) do %>
+       <option value="<% #id usr %>"<% if sel then %> selected<% end %>><% #name usr %></option>
+<% end %>
+</select></td> </tr>
+<tr> <td><input type="submit" value="Save"></td> </tr>
+</table>
+</form>
+
+<% elseif $"saveEven" <> "" then
+       val id = Web.stoi ($"saveEven");
+       val trn = Money.lookupTransaction id;
+       Money.clearCharges id;
+       Money.modTransaction {trn with descr = $"descr", d = $"d", amount = Web.stor ($"amount")};
+       Money.addEvenCharges (id, map Web.stoi (Web.getMultiParam "usrs"))
+
+       %><h3><b>Even transaction modified</b></h3>
+
+<% elseif $"del" <> "" then
+       showNormal := false;
+       val trn = Money.lookupTransaction (Web.stoi ($"del")) %>
+       <h3><b>Are you sure you want to delete transaction <a href="money?trn=<% #id trn %>"><% Web.html (#descr trn) %></a>?</b></h3>
+       <a href="money?del2=<% $"del" %>">Yes, delete <% Web.html (#descr trn) %>!</a>
+
+<% elseif $"del2" <> "" then
+       val id = Web.stoi ($"del2");
+       val trn = Money.lookupTransaction id;
+       Money.clearCharges id;
+       Money.deleteTransaction id %>
+       <h3><b><% Web.html (#descr trn) %> deleted!</b></h3>
+
+<% elseif $"trn" <> "" then
+       showNormal := false;
+       val id = Web.stoi ($"trn");
+       val trn = Money.lookupTransaction id %>
+<table>
+<tr> <td align="right"><b>TRN#</b>:</td> <td><% id %></td> </tr>
+<tr> <td align="right"><b>Description</b>:</td> <td><% Web.html (#descr trn) %></td> </tr>
+<tr> <td align="right"><b>Date</b>:</td> <td><% #d trn %></td> </tr>
+<tr> <td align="right"><b>Amount</b>:</td> <td>$<% #amount trn %></td> </tr>
+<tr> <td align="right" valign="top"><b>Distribution</b>:</td>
+
+<% ref first = true;
+foreach (name, cha) in Money.listChargesWithNames id do
+       if first then
+               first := false
+       else
+               %><tr> <td></td> <%
+       end
+       %><td><a href="user?id=<% #usr cha %>"><% name %></a></td> <td>$<% #amount cha %></td> </tr><%
+end %>
+</table>
+
+<% end %>
+
+<% if showNormal then %>
+
+<a href="money?cmd=list">List all transactions</a><br>
+<a href="money?cmd=bals">List all balances</a><br>
+
+<% if Group.inGroupName "money" then %>
+
+<br><b><u>New transaction:</u></b><br>
+<a href="money?cmd=hosting">Hosting bill</a><br>
+<a href="money?cmd=evenForm">Generic/even</a><br>
+
+<h3><b>Most recent transactions</b></h3>
+
+<table>
+<tr> <td><b>Date</b></td> <td><b>Description</b></td> <td><b>Amount</b></td> <td><b>Participants</b></td> <td><b>Replace</b></td> <td><b>Delete</b></td> </tr>
+<% foreach trn in Money.listTransactionsLimit 20 do %>
+<tr> <td><% #d trn %></td> <td><a href="money?trn=<% #id trn %>"><% Web.html (#descr trn) %></a></td> <td><% #amount trn %></td>
+<% switch Money.listChargesWithNames (#id trn) of
+         [(name, cha)] => %><td><a href="user?id=<% #usr cha %>"><% name %></a></td><%
+       | _ => %><td><i>multi</i></td><%
+end %>
+<td><a href="money?modEven=<% #id trn %>">[Even]</a></td> <td><a href="money?del=<% #id trn %>">[Delete]</a></td> </tr>
+<% end %>
+</table>
+
+<% else %>
+
+<h3><b>Your recent account activity</b></h3>
+
+<table>
+<tr> <td><b>Date</b></td> <td><b>Description</b></td> <td><b>Amount</b></td> </tr>
+<% foreach (amount, trn) in Money.listUserTransactionsLimit (Init.getUserId (), 20) do %>
+<tr> <td><% #d trn %></td> <td><a href="money?trn=<% #id trn %>"><% Web.html (#descr trn) %></a></td> <td><% amount %>/<% #amount trn %></td> </tr>
+<% end %>
+</table>
+
+<% end
+end %>
+
+<% @footer [] %>
diff --git a/money.sig b/money.sig
new file mode 100644 (file)
index 0000000..e35df38
--- /dev/null
+++ b/money.sig
@@ -0,0 +1,30 @@
+signature MONEY =
+sig
+    type transaction = {id :int, descr : string, amount : real, d : string, stamp : Init.C.timestamp}
+
+    val addTransaction : string * real * string -> int
+    val lookupTransaction : int -> transaction
+    val modTransaction : transaction -> unit
+    val deleteTransaction : int -> unit
+    val listTransactions : unit -> transaction list
+    val listTransactionsLimit : int -> transaction list
+    val listUserTransactions : int -> (real * transaction) list
+    val listUserTransactionsLimit : int * int -> (real * transaction) list
+    (* Returns list of (your part, overall) pairs *)
+    val listUsers : int -> (bool * Init.user) list
+    (* List users and indicate whether they participated in a transaction *)
+
+    type charge = {trn : int, usr : int, amount : real}
+
+    val addCharge : charge -> unit
+    val listCharges : int -> charge list
+    val listChargesWithNames : int -> (string * charge) list
+
+    val clearCharges : int -> unit
+    val applyCharges : Init.C.conn -> int -> unit
+
+    val addEvenCharges : int * int list -> unit
+
+    type hosting = {trn : int, cutoff : int, cost : real, usage : string}
+    val addHostingCharges : hosting -> unit
+end
\ No newline at end of file
diff --git a/money.sml b/money.sml
new file mode 100644 (file)
index 0000000..133e37b
--- /dev/null
+++ b/money.sml
@@ -0,0 +1,233 @@
+structure Money :> MONEY =
+struct
+
+open Util Sql Init
+
+
+(* Managing transactions *)
+
+type transaction = {id :int, descr : string, amount : real, d : string, stamp : C.timestamp}
+
+fun mkTransactionRow [id, descr, amount, d, stamp] =
+    {id = C.intFromSql id, descr = C.stringFromSql descr, amount = C.realFromSql amount,
+     d = C.stringFromSql d, stamp = C.timestampFromSql stamp}
+  | mkTransactionRow row = raise Fail ("Bad transaction row : " ^ makeSet id row)
+
+fun addTransaction (descr, amount, d) =
+    let
+       val db = getDb ()
+       val id = nextSeq (db, "TransactionSeq")
+    in
+       C.dml db ($`INSERT INTO Transaction (id, descr, amount, d, stamp)
+                    VALUES (^id, ^(C.stringToSql descr), ^(C.realToSql amount), ^(C.stringToSql d), CURRENT_TIMESTAMP)`);
+       C.intFromSql id
+    end
+
+fun lookupTransaction id =
+    let
+       val c = getDb ()
+    in
+       (case C.oneOrNoRows c ($`SELECT id, descr, amount, d, stamp FROM Transaction WHERE id = ^(C.intToSql id)`) of
+            NONE => raise Fail "Transaction not found"
+          | SOME r => mkTransactionRow r)
+    end
+
+fun modTransaction (trans : transaction) =
+    let
+       val db = getDb ()
+    in
+       ignore (C.dml db ($`UPDATE TRANSACTION
+                           SET descr = ^(C.stringToSql (#descr trans)), amount = ^(C.realToSql (#amount trans)),
+                               d = ^(C.stringToSql (#d trans)), stamp = CURRENT_TIMESTAMP
+                           WHERE id = ^(C.intToSql (#id trans))`))
+    end
+
+fun deleteTransaction id =
+    ignore (C.dml (getDb ()) ($`DELETE FROM Transaction WHERE id = ^(C.intToSql id)`))
+
+fun listTransactions () =
+    C.map (getDb ()) mkTransactionRow ($`SELECT id, descr, amount, d, stamp FROM Transaction
+                                  ORDER BY d DESC`)
+
+fun listTransactionsLimit lim =
+    C.map (getDb ()) mkTransactionRow ($`SELECT id, descr, amount, d, stamp FROM Transaction
+                                        ORDER BY d DESC
+                                        LIMIT ^(C.intToSql lim)`)
+
+fun listUserTransactions usr =
+    let
+       val mkRow =
+           fn (amount :: row) => (C.realFromSql amount, mkTransactionRow row)
+            | _ => raise Fail "Bad charge+transaction row"
+    in
+       C.map (getDb ()) mkRow ($`SELECT C.amount, T.id, T.descr, T.amount, T.d, T.stamp FROM Transaction T, Charge C
+                                 WHERE id = trn
+                                 AND usr = ^(C.intToSql usr)
+                                 ORDER BY T.d DESC`)
+    end
+
+fun listUserTransactionsLimit (usr, lim) =
+    let
+       val mkRow =
+           fn (amount :: row) => (C.realFromSql amount, mkTransactionRow row)
+            | _ => raise Fail "Bad charge+transaction row"
+    in
+       C.map (getDb ()) mkRow ($`SELECT C.amount, T.id, T.descr, T.amount, T.d, T.stamp FROM Transaction T, Charge C
+                                 WHERE id = trn
+                                 AND usr = ^(C.intToSql usr)
+                                 ORDER BY T.d DESC
+                                 LIMIT ^(C.intToSql lim)`)
+    end
+
+val mkUserRow' =
+    fn (trn :: rest) =>
+       (if C.isNull trn then false else true,
+       mkUserRow rest)
+     | row => raise Fail ("Bad listUsers row: " ^ makeSet id row)
+
+fun listUsers trn =
+    C.map (getDb ()) mkUserRow' ($`SELECT trn, id, name, rname, bal, joined
+                                  FROM WebUser LEFT OUTER JOIN Charge ON usr = id AND trn = ^(C.intToSql trn)
+                                  ORDER BY name`)
+
+
+(* Managing charges *)
+
+type charge = {trn : int, usr : int, amount : real}
+
+fun mkChargeRow [trn, usr, amount] =
+    {trn = C.intFromSql trn, usr = C.intFromSql usr, amount = C.realFromSql amount}
+  | mkChargeRow row = raise Fail ("Bad charge row : " ^ makeSet id row)
+
+fun addCharge {trn, usr, amount} =
+    ignore (C.dml (getDb ()) ($`INSERT INTO Charge (trn, usr, amount)
+                               VALUES (^(C.intToSql trn), ^(C.intToSql usr), ^(C.realToSql amount))`))
+
+fun listCharges trn =
+    C.map (getDb ()) mkChargeRow ($`SELECT trn, usr, amount FROM Charge
+                                   WHERE trn = ^(C.intToSql trn)`)
+
+val mkChargeRow' =
+    fn (name :: rest) => (C.stringFromSql name, mkChargeRow rest)
+     | row => raise Fail ("Bad name+charge row: " ^ makeSet id row)
+
+fun listChargesWithNames trn =
+    C.map (getDb ()) mkChargeRow' ($`SELECT name, trn, usr, amount FROM Charge, WebUser
+                                    WHERE trn = ^(C.intToSql trn)
+                                       AND usr = id
+                                    ORDER BY name`)
+
+
+(* Macro-operations *)
+
+fun clearCharges trn =
+    let
+       val db = getDb ()
+
+       fun clearCharge [bal, amount] =
+           ignore (C.dml db ($`UPDATE Balance
+                               SET amount = amount - ^amount
+                               WHERE id = ^bal`))
+         | clearCharge row = raise Fail ("Bad clearCharge row : " ^ makeSet id row)
+    in
+       C.app db clearCharge ($`SELECT bal, SUM(amount) FROM Charge, WebUser
+                               WHERE trn = ^(C.intToSql trn)
+                                  AND usr = id
+                               GROUP BY bal`);
+       ignore (C.dml db ($`DELETE FROM Charge WHERE trn = ^(C.intToSql trn)`))
+    end
+
+fun applyCharges db trn =
+    let
+       fun applyCharge [bal, amount] =
+           ignore (C.dml db ($`UPDATE Balance
+                               SET amount = amount + ^amount
+                               WHERE id = ^bal`))
+         | applyCharge row = raise Fail ("Bad applyCharge row : " ^ makeSet id row)
+    in
+       C.app db applyCharge ($`SELECT bal, SUM(amount) FROM Charge, WebUser
+                               WHERE trn = ^(C.intToSql trn)
+                                  AND usr = id
+                               GROUP BY bal`)
+    end
+
+fun addEvenCharges (trn, usrs) =
+    let
+       val tran = lookupTransaction trn
+       val nUsrs = length usrs
+
+       val split = #amount tran / (real nUsrs)
+
+       val db = getDb ()
+
+       fun addCharge usr =
+           ignore (C.dml db ($`INSERT INTO Charge (trn, usr, amount)
+                               VALUES (^(C.intToSql trn), ^(C.intToSql usr), ^(C.realToSql split))`))
+    in
+       app addCharge usrs;
+       applyCharges db trn
+    end
+
+(* Automated hosting charges *)
+
+type hosting = {trn : int, cutoff : int, cost : real, usage : string}
+
+structure StringKey = struct
+   type ord_key = string
+   val compare = String.compare
+end
+
+structure SM = BinaryMapFn(StringKey)
+
+fun addHostingCharges {trn, cutoff, cost, usage} =
+    let
+       val tran = lookupTransaction trn
+
+       val paying =
+           case Group.groupNameToId "paying" of
+               NONE => raise Fail "No 'paying' group"
+             | SOME id => id
+
+       val nvs = String.tokens Char.isSpace usage
+
+       fun walkNvs (nvs, umap, amount) =
+           case nvs of
+               name :: bw :: rest =>
+               let
+                   val bw = Web.stoi bw
+               in
+                   if bw > cutoff then
+                       let
+                           val extra = cost * (real (bw - cutoff) / 1000000.0)
+                       in
+                           walkNvs (rest,
+                                    SM.insert (umap, name, extra),
+                                    amount - extra)
+                       end
+                   else
+                       walkNvs (rest, umap, amount)
+               end
+             | _ => (umap, amount)
+
+       val (umap, amount) = walkNvs (nvs, SM.empty, #amount tran)
+
+       val payers = Group.groupMembers paying
+       val even = amount / real (length payers)
+
+       fun doUser (usr : Init.user, umap)  =
+           let
+               val (charge, umap) =
+                   case SM.find (umap, #name usr) of
+                       NONE => (even, umap)
+                     | SOME extra => (even + extra, #1 (SM.remove (umap, #name usr)))
+           in
+               addCharge {trn = trn, usr = #id usr, amount = charge};
+               umap
+           end
+    in
+       if SM.numItems (foldl doUser umap payers) = 0 then
+           ()
+       else
+           raise Fail "Usage description contains an unknown username"
+    end
+end
\ No newline at end of file
diff --git a/portal.mlt b/portal.mlt
new file mode 100644 (file)
index 0000000..03be2b2
--- /dev/null
@@ -0,0 +1,5 @@
+<% val you = Init.getUser ();
+
+@header [] %>
+
+<% @footer [] %>
\ No newline at end of file
diff --git a/tables.sql b/tables.sql
new file mode 100644 (file)
index 0000000..3105801
--- /dev/null
@@ -0,0 +1,64 @@
+CREATE TABLE Balance(
+       id INTEGER PRIMARY KEY,
+       name TEXT NOT NULL,
+       amount REAL NOT NULL);
+
+CREATE SEQUENCE BalanceSeq START 1;
+
+INSERT INTO Balance
+       (id, name, amount) VALUES
+       (0, 'adam', 0.0);
+
+CREATE TABLE WebUser(
+       id INTEGER PRIMARY KEY,
+       name TEXT NOT NULL,
+       rname TEXT NOT NULL,
+       bal INTEGER NOT NULL,
+       joined TIMESTAMP NOT NULL,
+       FOREIGN KEY (bal) REFERENCES Balance(id) ON DELETE CASCADE);
+
+CREATE SEQUENCE WebUserSeq START 1;
+
+INSERT INTO WebUser
+       (id, name, bal) VALUES
+       (0, 'adam', 0);
+
+CREATE TABLE WebGroup(
+       id INTEGER PRIMARY KEY,
+       name TEXT NOT NULL);
+
+CREATE SEQUENCE WebGroupSeq START 1;
+
+INSERT INTO WebGroup
+       (id, name) VALUES
+       (0, 'root');
+
+CREATE TABLE Membership(
+       grp INTEGER NOT NULL,
+       usr INTEGER NOT NULL,
+       PRIMARY KEY (grp, usr),
+       FOREIGN KEY (grp) REFERENCES WebGroup(id) ON DELETE CASCADE,
+       FOREIGN KEY (usr) REFERENCES WebUser(id) ON DELETE CASCADE);
+
+INSERT INTO Membership
+       (grp, usr) VALUES
+       (0, 0);
+
+CREATE TABLE Transaction(
+       id INTEGER PRIMARY KEY,
+       descr TEXT NOT NULL,
+       amount REAL NOT NULL,
+       d DATE NOT NULL,
+       stamp TIMESTAMP NOT NULL);
+
+CREATE SEQUENCE TransactionSeq START 1;
+
+CREATE TABLE Charge(
+       trn INTEGER NOT NULL,
+       usr INTEGER NOT NULL,
+       amount REAL NOT NULL,
+       PRIMARY KEY (trn, usr),
+       FOREIGN KEY (trn) REFERENCES Transaction(id) ON DELETE CASCADE,
+       FOREIGN KEY (usr) REFERENCES WebUser(id) ON DELETE CASCADE);
+
+
diff --git a/users.mlt b/users.mlt
new file mode 100644 (file)
index 0000000..6dc4ce8
--- /dev/null
+++ b/users.mlt
@@ -0,0 +1,98 @@
+<% Group.requireGroupNum 0;
+
+@header [("title", ["Member management"])];
+
+ref showNormal = true;
+
+if $"cmd" = "Create" then
+       if not (Init.validUsername ($"name")) then %>
+               <h3><b>Invalid username</b></h3>
+       <% else switch Init.userNameToId ($"name") of
+                 SOME _ => %>
+                       <h3><b>Username already in use</b></h3>
+<%             | NONE =>
+                       val bal =
+                               (case $"bal" of
+                                         "" => Balance.addBalance ($"name")
+                                       | s => Web.stoi s);
+
+                       val id = Init.addUser ($"name", $"rname", bal);
+                       Group.addToGroups (id, map Web.stoi (Web.getMultiParam "grp")) %>
+                       <h3><b>Member added</b></h3>
+<%             end
+       end
+elseif $"mod" <> "" then
+       showNormal := false;
+       val user = Init.lookupUser (Web.stoi ($"mod")) %>
+<h3><b>Modify member record</b></h3>
+
+<form action="users">
+<input type="hidden" name="id" value="<% $"mod" %>">
+<table>
+<tr> <td align="right"><b>Name</b>:</td> <td><input name="name" value="<% #name user %>"></td> </tr>
+<tr> <td align="right"><b>Real name</b>:</td> <td><input name="rname" value="<% #rname user %>"></td> </tr>
+<tr> <td align="right" valign="top"><b>Funded by</b>:</td> <td><select name="bal">
+<% foreach bal in Balance.listBalances () do %>
+       <option value="<% #id bal %>"<% if #bal user = #id bal then %> selected <% end %>><% Web.html (#name bal) %></option>
+<% end %>
+</select></td></tr>
+<tr> <td><input type="submit" name="cmd" value="Save"></td> </tr>
+</table>
+</form>
+
+<% elseif $"cmd" = "Save" then
+       val user = Init.lookupUser (Web.stoi ($"id"));
+       Init.modUser {user with name = $"name", bal = Web.stoi ($"bal")} %>
+       <h3><b>Member record saved.</b></h3>
+
+<% elseif $"del" <> "" then
+       showNormal := false;
+       val user = Init.lookupUser (Web.stoi ($"del")) %>
+       <h3><b>Are you sure you want to delete member <a href="user?id=<% #id user %>"><% #name user %></a>?</b></h3>
+       <a href="users?del2=<% $"del" %>">Yes, delete <% #name user %>!</a>
+
+<% elseif $"del2" <> "" then
+       val user = Init.lookupUser (Web.stoi ($"del2"));
+       Init.deleteUser (Web.stoi ($"del2")) %>
+       <h3><b><% #name user %> deleted!</b></h3>
+
+<% end %>
+
+<% if showNormal then %>
+<h3><b>New member</b></h3>
+
+<form action="users">
+<table>
+<tr> <td align="right"><b>Name</b>:</td> <td><input name="name"></td> </tr>
+<tr> <td align="right"><b>Real name</b>:</td> <td><input name="rname"></td> </tr>
+<tr> <td align="right" valign="top"><b>Funded by</b>:</td> <td><select name="bal">
+       <option value="">A new balance</option>
+<% foreach bal in Balance.listBalances () do %>
+       <option value="<% #id bal %>"><% Web.html (#name bal) %></option>
+<% end %>
+</select></td></tr>
+<tr> <td align="right"><b>Groups</b>:</td> <td><select name="grp" size="5" multiple>
+<% foreach group in Group.listGroups () do %>
+       <option value="<% #id group %>"><% Web.html (#name group) %></option>
+<% end %>
+</select></td></tr>
+<tr> <td><input type="submit" name="cmd" value="Create"></td> </tr>
+</table>
+</form>
+
+<h3><b>Manage current members</b></h3>
+
+<table>
+<% foreach user in Init.listUsers () do %>
+       <tr> <td><a href="user?id=<% #id user %>"><% Web.html (#name user) %></a></td> <td>
+<% val bal = Balance.lookupBalance (#bal user);
+if #name bal <> #name user then %>
+<i><% Web.html (#name bal) %></i>
+<% end %>
+       </td><td><a href="users?mod=<% #id user %>">[Modify]</a> <a href="users?del=<% #id user %>">[Delete]</a></td> </tr>
+<% end %>
+</table>
+
+<% end %>
+
+<% @footer [] %>
\ No newline at end of file
diff --git a/util.sig b/util.sig
new file mode 100644 (file)
index 0000000..3a03953
--- /dev/null
+++ b/util.sig
@@ -0,0 +1,8 @@
+signature UTIL =
+sig
+    val printInt : int -> unit
+    val printReal : real -> unit
+
+    val id : 'a -> 'a
+    val makeSet : ('a -> string) -> 'a list -> string
+end
\ No newline at end of file
diff --git a/util.sml b/util.sml
new file mode 100644 (file)
index 0000000..6f067ac
--- /dev/null
+++ b/util.sml
@@ -0,0 +1,24 @@
+structure Util :> UTIL =
+struct
+
+fun printInt n =
+    Web.print (if n < 0 then
+                  "-" ^ Int.toString (~n)
+              else
+                  Int.toString n)
+
+fun printReal n =
+    Web.print (if n < 0.0 then
+                  "-" ^ Real.fmt (StringCvt.FIX (SOME 2)) (~n)
+              else
+                  Real.fmt (StringCvt.FIX (SOME 2)) n)
+
+fun id x = x
+
+fun makeSet f items =
+    case items of
+       [] => "()"
+      | [usr] => "(" ^ f usr ^ ")"
+      | usr::rest => foldl (fn (usr, s) => s ^ ", " ^ f usr) ("(" ^ f usr) rest ^ ")"
+
+end
\ No newline at end of file