]> git.evergreen-ils.org Git - working/Evergreen.git/blob - Open-ILS/src/sql/Pg/090.schema.action.sql
Show format icons in results and record detail pages
[working/Evergreen.git] / Open-ILS / src / sql / Pg / 090.schema.action.sql
1 /*
2  * Copyright (C) 2004-2008  Georgia Public Library Service
3  * Copyright (C) 2007-2008  Equinox Software, Inc.
4  * Mike Rylander <miker@esilibrary.com> 
5  *
6  * This program is free software; you can redistribute it and/or
7  * modify it under the terms of the GNU General Public License
8  * as published by the Free Software Foundation; either version 2
9  * of the License, or (at your option) any later version.
10  *
11  * This program is distributed in the hope that it will be useful,
12  * but WITHOUT ANY WARRANTY; without even the implied warranty of
13  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14  * GNU General Public License for more details.
15  *
16  */
17
18 DROP SCHEMA IF EXISTS action CASCADE;
19
20 BEGIN;
21
22 CREATE SCHEMA action;
23
24 CREATE TABLE action.in_house_use (
25         id              SERIAL                          PRIMARY KEY,
26         item            BIGINT                          NOT NULL, -- REFERENCES asset.copy (id) DEFERRABLE INITIALLY DEFERRED, -- XXX could be an serial.issuance
27         staff           INT                             NOT NULL REFERENCES actor.usr (id) DEFERRABLE INITIALLY DEFERRED,
28         org_unit        INT                             NOT NULL REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
29         use_time        TIMESTAMP WITH TIME ZONE        NOT NULL DEFAULT NOW()
30 );
31 CREATE INDEX action_in_house_use_staff_idx      ON action.in_house_use ( staff );
32
33 CREATE TABLE action.non_cataloged_circulation (
34         id              SERIAL                          PRIMARY KEY,
35         patron          INT                             NOT NULL REFERENCES actor.usr (id) DEFERRABLE INITIALLY DEFERRED,
36         staff           INT                             NOT NULL REFERENCES actor.usr (id) DEFERRABLE INITIALLY DEFERRED,
37         circ_lib        INT                             NOT NULL REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
38         item_type       INT                             NOT NULL REFERENCES config.non_cataloged_type (id) DEFERRABLE INITIALLY DEFERRED,
39         circ_time       TIMESTAMP WITH TIME ZONE        NOT NULL DEFAULT NOW()
40 );
41 CREATE INDEX action_non_cat_circ_patron_idx ON action.non_cataloged_circulation ( patron );
42 CREATE INDEX action_non_cat_circ_staff_idx  ON action.non_cataloged_circulation ( staff );
43
44 CREATE TABLE action.non_cat_in_house_use (
45         id              SERIAL                          PRIMARY KEY,
46         item_type       BIGINT                          NOT NULL REFERENCES config.non_cataloged_type(id) DEFERRABLE INITIALLY DEFERRED,
47         staff           INT                             NOT NULL REFERENCES actor.usr (id) DEFERRABLE INITIALLY DEFERRED,
48         org_unit        INT                             NOT NULL REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
49         use_time        TIMESTAMP WITH TIME ZONE        NOT NULL DEFAULT NOW()
50 );
51 CREATE INDEX non_cat_in_house_use_staff_idx ON action.non_cat_in_house_use ( staff );
52
53 CREATE TABLE action.survey (
54         id              SERIAL                          PRIMARY KEY,
55         owner           INT                             NOT NULL REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
56         start_date      TIMESTAMP WITH TIME ZONE        NOT NULL DEFAULT NOW(),
57         end_date        TIMESTAMP WITH TIME ZONE        NOT NULL DEFAULT NOW() + '10 years'::INTERVAL,
58         usr_summary     BOOL                            NOT NULL DEFAULT FALSE,
59         opac            BOOL                            NOT NULL DEFAULT FALSE,
60         poll            BOOL                            NOT NULL DEFAULT FALSE,
61         required        BOOL                            NOT NULL DEFAULT FALSE,
62         name            TEXT                            NOT NULL,
63         description     TEXT                            NOT NULL
64 );
65 CREATE UNIQUE INDEX asv_once_per_owner_idx ON action.survey (owner,name);
66
67 CREATE TABLE action.survey_question (
68         id              SERIAL  PRIMARY KEY,
69         survey          INT     NOT NULL REFERENCES action.survey DEFERRABLE INITIALLY DEFERRED,
70         question        TEXT    NOT NULL
71 );
72
73 CREATE TABLE action.survey_answer (
74         id              SERIAL  PRIMARY KEY,
75         question        INT     NOT NULL REFERENCES action.survey_question DEFERRABLE INITIALLY DEFERRED,
76         answer          TEXT    NOT NULL
77 );
78
79 CREATE SEQUENCE action.survey_response_group_id_seq;
80
81 CREATE TABLE action.survey_response (
82         id                      BIGSERIAL                       PRIMARY KEY,
83         response_group_id       INT,
84         usr                     INT, -- REFERENCES actor.usr
85         survey                  INT                             NOT NULL REFERENCES action.survey DEFERRABLE INITIALLY DEFERRED,
86         question                INT                             NOT NULL REFERENCES action.survey_question DEFERRABLE INITIALLY DEFERRED,
87         answer                  INT                             NOT NULL REFERENCES action.survey_answer DEFERRABLE INITIALLY DEFERRED,
88         answer_date             TIMESTAMP WITH TIME ZONE,
89         effective_date          TIMESTAMP WITH TIME ZONE        NOT NULL DEFAULT NOW()
90 );
91 CREATE INDEX action_survey_response_usr_idx ON action.survey_response ( usr );
92
93 CREATE OR REPLACE FUNCTION action.survey_response_answer_date_fixup () RETURNS TRIGGER AS '
94 BEGIN
95         NEW.answer_date := NOW();
96         RETURN NEW;
97 END;
98 ' LANGUAGE 'plpgsql';
99 CREATE TRIGGER action_survey_response_answer_date_fixup_tgr
100         BEFORE INSERT ON action.survey_response
101         FOR EACH ROW
102         EXECUTE PROCEDURE action.survey_response_answer_date_fixup ();
103
104
105 CREATE TABLE action.circulation (
106         target_copy             BIGINT                          NOT NULL, -- asset.copy.id
107         circ_lib                INT                             NOT NULL, -- actor.org_unit.id
108         circ_staff              INT                             NOT NULL, -- actor.usr.id
109         checkin_staff           INT,                                      -- actor.usr.id
110         checkin_lib             INT,                                      -- actor.org_unit.id
111         renewal_remaining       INT                             NOT NULL, -- derived from "circ duration" rule
112         due_date                TIMESTAMP WITH TIME ZONE,
113         stop_fines_time         TIMESTAMP WITH TIME ZONE,
114         checkin_time            TIMESTAMP WITH TIME ZONE,
115         create_time             TIMESTAMP WITH TIME ZONE    NOT NULL DEFAULT NOW(),
116         duration                INTERVAL,                                 -- derived from "circ duration" rule
117         fine_interval           INTERVAL                        NOT NULL DEFAULT '1 day'::INTERVAL, -- derived from "circ fine" rule
118         recurring_fine          NUMERIC(6,2),                             -- derived from "circ fine" rule
119         max_fine                NUMERIC(6,2),                             -- derived from "max fine" rule
120         phone_renewal           BOOL                            NOT NULL DEFAULT FALSE,
121         desk_renewal            BOOL                            NOT NULL DEFAULT FALSE,
122         opac_renewal            BOOL                            NOT NULL DEFAULT FALSE,
123         duration_rule           TEXT                            NOT NULL, -- name of "circ duration" rule
124         recurring_fine_rule     TEXT                            NOT NULL, -- name of "circ fine" rule
125         max_fine_rule           TEXT                            NOT NULL, -- name of "max fine" rule
126         stop_fines              TEXT                            CHECK (stop_fines IN (
127                                                'CHECKIN','CLAIMSRETURNED','LOST','MAXFINES','RENEW','LONGOVERDUE','CLAIMSNEVERCHECKEDOUT')),
128         workstation         INT        REFERENCES actor.workstation(id)
129                                        ON DELETE SET NULL
130                                                                    DEFERRABLE INITIALLY DEFERRED,
131         checkin_workstation INT        REFERENCES actor.workstation(id)
132                                        ON DELETE SET NULL
133                                                                    DEFERRABLE INITIALLY DEFERRED,
134         checkin_scan_time   TIMESTAMP WITH TIME ZONE
135 ) INHERITS (money.billable_xact);
136 ALTER TABLE action.circulation ADD PRIMARY KEY (id);
137 ALTER TABLE action.circulation
138         ADD COLUMN parent_circ BIGINT
139         REFERENCES action.circulation( id )
140         DEFERRABLE INITIALLY DEFERRED;
141 CREATE INDEX circ_open_xacts_idx ON action.circulation (usr) WHERE xact_finish IS NULL;
142 CREATE INDEX circ_outstanding_idx ON action.circulation (usr) WHERE checkin_time IS NULL;
143 CREATE INDEX circ_checkin_time ON "action".circulation (checkin_time) WHERE checkin_time IS NOT NULL;
144 CREATE INDEX circ_circ_lib_idx ON "action".circulation (circ_lib);
145 CREATE INDEX circ_open_date_idx ON "action".circulation (xact_start) WHERE xact_finish IS NULL;
146 CREATE INDEX circ_all_usr_idx       ON action.circulation ( usr );
147 CREATE INDEX circ_circ_staff_idx    ON action.circulation ( circ_staff );
148 CREATE INDEX circ_checkin_staff_idx ON action.circulation ( checkin_staff );
149 CREATE INDEX action_circulation_target_copy_idx ON action.circulation (target_copy);
150 CREATE UNIQUE INDEX circ_parent_idx ON action.circulation ( parent_circ ) WHERE parent_circ IS NOT NULL;
151 CREATE UNIQUE INDEX only_one_concurrent_checkout_per_copy ON action.circulation(target_copy) WHERE checkin_time IS NULL;
152
153 CREATE TRIGGER mat_summary_create_tgr AFTER INSERT ON action.circulation FOR EACH ROW EXECUTE PROCEDURE money.mat_summary_create ('circulation');
154 CREATE TRIGGER mat_summary_change_tgr AFTER UPDATE ON action.circulation FOR EACH ROW EXECUTE PROCEDURE money.mat_summary_update ();
155 CREATE TRIGGER mat_summary_remove_tgr AFTER DELETE ON action.circulation FOR EACH ROW EXECUTE PROCEDURE money.mat_summary_delete ();
156
157 CREATE OR REPLACE FUNCTION action.push_circ_due_time () RETURNS TRIGGER AS $$
158 BEGIN
159     IF (EXTRACT(EPOCH FROM NEW.duration)::INT % EXTRACT(EPOCH FROM '1 day'::INTERVAL)::INT) = 0 THEN
160         NEW.due_date = (NEW.due_date::DATE + '1 day'::INTERVAL - '1 second'::INTERVAL)::TIMESTAMPTZ;
161     END IF;
162
163     RETURN NEW;
164 END;
165 $$ LANGUAGE PLPGSQL;
166
167 CREATE TRIGGER push_due_date_tgr BEFORE INSERT OR UPDATE ON action.circulation FOR EACH ROW EXECUTE PROCEDURE action.push_circ_due_time();
168
169 CREATE TABLE action.aged_circulation (
170         usr_post_code           TEXT,
171         usr_home_ou             INT     NOT NULL,
172         usr_profile             INT     NOT NULL,
173         usr_birth_year          INT,
174         copy_call_number        INT     NOT NULL,
175         copy_location           INT     NOT NULL,
176         copy_owning_lib         INT     NOT NULL,
177         copy_circ_lib           INT     NOT NULL,
178         copy_bib_record         BIGINT  NOT NULL,
179         LIKE action.circulation
180
181 );
182 ALTER TABLE action.aged_circulation ADD PRIMARY KEY (id);
183 ALTER TABLE action.aged_circulation DROP COLUMN usr;
184 CREATE INDEX aged_circ_circ_lib_idx ON "action".aged_circulation (circ_lib);
185 CREATE INDEX aged_circ_start_idx ON "action".aged_circulation (xact_start);
186 CREATE INDEX aged_circ_copy_circ_lib_idx ON "action".aged_circulation (copy_circ_lib);
187 CREATE INDEX aged_circ_copy_owning_lib_idx ON "action".aged_circulation (copy_owning_lib);
188 CREATE INDEX aged_circ_copy_location_idx ON "action".aged_circulation (copy_location);
189 CREATE INDEX action_aged_circulation_target_copy_idx ON action.aged_circulation (target_copy);
190
191 CREATE OR REPLACE VIEW action.all_circulation AS
192     SELECT  id,usr_post_code, usr_home_ou, usr_profile, usr_birth_year, copy_call_number, copy_location,
193         copy_owning_lib, copy_circ_lib, copy_bib_record, xact_start, xact_finish, target_copy,
194         circ_lib, circ_staff, checkin_staff, checkin_lib, renewal_remaining, due_date,
195         stop_fines_time, checkin_time, create_time, duration, fine_interval, recurring_fine,
196         max_fine, phone_renewal, desk_renewal, opac_renewal, duration_rule, recurring_fine_rule,
197         max_fine_rule, stop_fines, workstation, checkin_workstation, checkin_scan_time, parent_circ
198       FROM  action.aged_circulation
199             UNION ALL
200     SELECT  DISTINCT circ.id,COALESCE(a.post_code,b.post_code) AS usr_post_code, p.home_ou AS usr_home_ou, p.profile AS usr_profile, EXTRACT(YEAR FROM p.dob)::INT AS usr_birth_year,
201         cp.call_number AS copy_call_number, cp.location AS copy_location, cn.owning_lib AS copy_owning_lib, cp.circ_lib AS copy_circ_lib,
202         cn.record AS copy_bib_record, circ.xact_start, circ.xact_finish, circ.target_copy, circ.circ_lib, circ.circ_staff, circ.checkin_staff,
203         circ.checkin_lib, circ.renewal_remaining, circ.due_date, circ.stop_fines_time, circ.checkin_time, circ.create_time, circ.duration,
204         circ.fine_interval, circ.recurring_fine, circ.max_fine, circ.phone_renewal, circ.desk_renewal, circ.opac_renewal, circ.duration_rule,
205         circ.recurring_fine_rule, circ.max_fine_rule, circ.stop_fines, circ.workstation, circ.checkin_workstation, circ.checkin_scan_time,
206         circ.parent_circ
207       FROM  action.circulation circ
208         JOIN asset.copy cp ON (circ.target_copy = cp.id)
209         JOIN asset.call_number cn ON (cp.call_number = cn.id)
210         JOIN actor.usr p ON (circ.usr = p.id)
211         LEFT JOIN actor.usr_address a ON (p.mailing_address = a.id)
212         LEFT JOIN actor.usr_address b ON (p.billing_address = a.id);
213
214 CREATE OR REPLACE FUNCTION action.age_circ_on_delete () RETURNS TRIGGER AS $$
215 DECLARE
216 found char := 'N';
217 BEGIN
218
219     -- If there are any renewals for this circulation, don't archive or delete
220     -- it yet.   We'll do so later, when we archive and delete the renewals.
221
222     SELECT 'Y' INTO found
223     FROM action.circulation
224     WHERE parent_circ = OLD.id
225     LIMIT 1;
226
227     IF found = 'Y' THEN
228         RETURN NULL;  -- don't delete
229         END IF;
230
231     -- Archive a copy of the old row to action.aged_circulation
232
233     INSERT INTO action.aged_circulation
234         (id,usr_post_code, usr_home_ou, usr_profile, usr_birth_year, copy_call_number, copy_location,
235         copy_owning_lib, copy_circ_lib, copy_bib_record, xact_start, xact_finish, target_copy,
236         circ_lib, circ_staff, checkin_staff, checkin_lib, renewal_remaining, due_date,
237         stop_fines_time, checkin_time, create_time, duration, fine_interval, recurring_fine,
238         max_fine, phone_renewal, desk_renewal, opac_renewal, duration_rule, recurring_fine_rule,
239         max_fine_rule, stop_fines, workstation, checkin_workstation, checkin_scan_time, parent_circ)
240       SELECT
241         id,usr_post_code, usr_home_ou, usr_profile, usr_birth_year, copy_call_number, copy_location,
242         copy_owning_lib, copy_circ_lib, copy_bib_record, xact_start, xact_finish, target_copy,
243         circ_lib, circ_staff, checkin_staff, checkin_lib, renewal_remaining, due_date,
244         stop_fines_time, checkin_time, create_time, duration, fine_interval, recurring_fine,
245         max_fine, phone_renewal, desk_renewal, opac_renewal, duration_rule, recurring_fine_rule,
246         max_fine_rule, stop_fines, workstation, checkin_workstation, checkin_scan_time, parent_circ
247         FROM action.all_circulation WHERE id = OLD.id;
248
249     RETURN OLD;
250 END;
251 $$ LANGUAGE 'plpgsql';
252
253 CREATE TRIGGER action_circulation_aging_tgr
254         BEFORE DELETE ON action.circulation
255         FOR EACH ROW
256         EXECUTE PROCEDURE action.age_circ_on_delete ();
257
258
259 CREATE OR REPLACE FUNCTION action.age_parent_circ_on_delete () RETURNS TRIGGER AS $$
260 BEGIN
261
262     -- Having deleted a renewal, we can delete the original circulation (or a previous
263     -- renewal, if that's what parent_circ is pointing to).  That deletion will trigger
264     -- deletion of any prior parents, etc. recursively.
265
266     IF OLD.parent_circ IS NOT NULL THEN
267         DELETE FROM action.circulation
268         WHERE id = OLD.parent_circ;
269     END IF;
270
271     RETURN OLD;
272 END;
273 $$ LANGUAGE 'plpgsql';
274
275 CREATE TRIGGER age_parent_circ
276         AFTER DELETE ON action.circulation
277         FOR EACH ROW
278         EXECUTE PROCEDURE action.age_parent_circ_on_delete ();
279
280
281 CREATE OR REPLACE VIEW action.open_circulation AS
282         SELECT  *
283           FROM  action.circulation
284           WHERE checkin_time IS NULL
285           ORDER BY due_date;
286                 
287
288 CREATE OR REPLACE VIEW action.billable_circulations AS
289         SELECT  *
290           FROM  action.circulation
291           WHERE xact_finish IS NULL;
292
293 CREATE OR REPLACE FUNCTION action.circulation_claims_returned () RETURNS TRIGGER AS $$
294 BEGIN
295         IF OLD.stop_fines IS NULL OR OLD.stop_fines <> NEW.stop_fines THEN
296                 IF NEW.stop_fines = 'CLAIMSRETURNED' THEN
297                         UPDATE actor.usr SET claims_returned_count = claims_returned_count + 1 WHERE id = NEW.usr;
298                 END IF;
299                 IF NEW.stop_fines = 'CLAIMSNEVERCHECKEDOUT' THEN
300                         UPDATE actor.usr SET claims_never_checked_out_count = claims_never_checked_out_count + 1 WHERE id = NEW.usr;
301                 END IF;
302                 IF NEW.stop_fines = 'LOST' THEN
303                         UPDATE asset.copy SET status = 3 WHERE id = NEW.target_copy;
304                 END IF;
305         END IF;
306         RETURN NEW;
307 END;
308 $$ LANGUAGE 'plpgsql';
309 CREATE TRIGGER action_circulation_stop_fines_tgr
310         BEFORE UPDATE ON action.circulation
311         FOR EACH ROW
312         EXECUTE PROCEDURE action.circulation_claims_returned ();
313
314 CREATE TABLE action.hold_request_cancel_cause (
315     id      SERIAL  PRIMARY KEY,
316     label   TEXT    UNIQUE
317 );
318 INSERT INTO action.hold_request_cancel_cause (id,label) VALUES (1,'Untargeted expiration');
319 INSERT INTO action.hold_request_cancel_cause (id,label) VALUES (2,'Hold Shelf expiration');
320 INSERT INTO action.hold_request_cancel_cause (id,label) VALUES (3,'Patron via phone');
321 INSERT INTO action.hold_request_cancel_cause (id,label) VALUES (4,'Patron in person');
322 INSERT INTO action.hold_request_cancel_cause (id,label) VALUES (5,'Staff forced');
323 INSERT INTO action.hold_request_cancel_cause (id,label) VALUES (6,'Patron via OPAC');
324 SELECT SETVAL('action.hold_request_cancel_cause_id_seq', 100);
325
326 CREATE TABLE action.hold_request (
327         id                      SERIAL                          PRIMARY KEY,
328         request_time            TIMESTAMP WITH TIME ZONE        NOT NULL DEFAULT NOW(),
329         capture_time            TIMESTAMP WITH TIME ZONE,
330         fulfillment_time        TIMESTAMP WITH TIME ZONE,
331         checkin_time            TIMESTAMP WITH TIME ZONE,
332         return_time             TIMESTAMP WITH TIME ZONE,
333         prev_check_time         TIMESTAMP WITH TIME ZONE,
334         expire_time             TIMESTAMP WITH TIME ZONE,
335         cancel_time             TIMESTAMP WITH TIME ZONE,
336         cancel_cause    INT REFERENCES action.hold_request_cancel_cause (id) ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED,
337         cancel_note             TEXT,
338         target                  BIGINT                          NOT NULL, -- see hold_type
339         current_copy            BIGINT,                         -- REFERENCES asset.copy (id) ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED,  -- XXX could be an serial.unit now...
340         fulfillment_staff       INT                             REFERENCES actor.usr (id) DEFERRABLE INITIALLY DEFERRED,
341         fulfillment_lib         INT                             REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
342         request_lib             INT                             NOT NULL REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
343         requestor               INT                             NOT NULL REFERENCES actor.usr (id) DEFERRABLE INITIALLY DEFERRED,
344         usr                     INT                             NOT NULL REFERENCES actor.usr (id) DEFERRABLE INITIALLY DEFERRED,
345         selection_ou            INT                             NOT NULL,
346         selection_depth         INT                             NOT NULL DEFAULT 0,
347         pickup_lib              INT                             NOT NULL REFERENCES actor.org_unit DEFERRABLE INITIALLY DEFERRED,
348         hold_type               TEXT                            NOT NULL, -- CHECK (hold_type IN ('M','T','V','C')),  -- XXX constraint too constraining...
349         holdable_formats        TEXT,
350         phone_notify            TEXT,
351         email_notify            BOOL                            NOT NULL DEFAULT TRUE,
352         frozen                  BOOL                            NOT NULL DEFAULT FALSE,
353         thaw_date               TIMESTAMP WITH TIME ZONE,
354         shelf_time              TIMESTAMP WITH TIME ZONE,
355     cut_in_line     BOOL,
356         mint_condition  BOOL NOT NULL DEFAULT TRUE,
357         shelf_expire_time TIMESTAMPTZ
358 );
359
360 CREATE INDEX hold_request_target_idx ON action.hold_request (target);
361 CREATE INDEX hold_request_usr_idx ON action.hold_request (usr);
362 CREATE INDEX hold_request_pickup_lib_idx ON action.hold_request (pickup_lib);
363 CREATE INDEX hold_request_current_copy_idx ON action.hold_request (current_copy);
364 CREATE INDEX hold_request_prev_check_time_idx ON action.hold_request (prev_check_time);
365 CREATE INDEX hold_request_fulfillment_staff_idx ON action.hold_request ( fulfillment_staff );
366 CREATE INDEX hold_request_requestor_idx         ON action.hold_request ( requestor );
367
368
369 CREATE TABLE action.hold_request_note (
370
371     id     BIGSERIAL PRIMARY KEY,
372     hold   BIGINT    NOT NULL REFERENCES action.hold_request (id)
373                               ON DELETE CASCADE
374                               DEFERRABLE INITIALLY DEFERRED,
375     title  TEXT      NOT NULL,
376     body   TEXT      NOT NULL,
377     slip   BOOL      NOT NULL DEFAULT FALSE,
378     pub    BOOL      NOT NULL DEFAULT FALSE,
379     staff  BOOL      NOT NULL DEFAULT FALSE  -- created by staff
380
381 );
382 CREATE INDEX ahrn_hold_idx ON action.hold_request_note (hold);
383
384
385 CREATE TABLE action.hold_notification (
386         id              SERIAL                          PRIMARY KEY,
387         hold            INT                             NOT NULL REFERENCES action.hold_request (id)
388                                                                         ON DELETE CASCADE
389                                                                         DEFERRABLE INITIALLY DEFERRED,
390         notify_staff    INT                     REFERENCES actor.usr (id) DEFERRABLE INITIALLY DEFERRED,
391         notify_time     TIMESTAMP WITH TIME ZONE        NOT NULL DEFAULT NOW(),
392         method          TEXT                            NOT NULL, -- email address or phone number
393         note            TEXT
394 );
395 CREATE INDEX ahn_hold_idx ON action.hold_notification (hold);
396 CREATE INDEX ahn_notify_staff_idx ON action.hold_notification ( notify_staff );
397
398 CREATE TABLE action.hold_copy_map (
399         id              BIGSERIAL       PRIMARY KEY,
400         hold            INT     NOT NULL REFERENCES action.hold_request (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
401         target_copy     BIGINT  NOT NULL, -- REFERENCES asset.copy (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, -- XXX could be an serial.issuance
402         CONSTRAINT copy_once_per_hold UNIQUE (hold,target_copy)
403 );
404 -- CREATE INDEX acm_hold_idx ON action.hold_copy_map (hold);
405 CREATE INDEX acm_copy_idx ON action.hold_copy_map (target_copy);
406
407 CREATE TABLE action.transit_copy (
408         id                      SERIAL                          PRIMARY KEY,
409         source_send_time        TIMESTAMP WITH TIME ZONE,
410         dest_recv_time          TIMESTAMP WITH TIME ZONE,
411         target_copy             BIGINT                          NOT NULL, -- REFERENCES asset.copy (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, -- XXX could be an serial.issuance
412         source                  INT                             NOT NULL REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
413         dest                    INT                             NOT NULL REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
414         prev_hop                INT                             REFERENCES action.transit_copy (id) DEFERRABLE INITIALLY DEFERRED,
415         copy_status             INT                             NOT NULL REFERENCES config.copy_status (id) DEFERRABLE INITIALLY DEFERRED,
416         persistant_transfer     BOOL                            NOT NULL DEFAULT FALSE,
417         prev_dest       INT                             REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED
418 );
419 CREATE INDEX active_transit_dest_idx ON "action".transit_copy (dest); 
420 CREATE INDEX active_transit_source_idx ON "action".transit_copy (source);
421 CREATE INDEX active_transit_cp_idx ON "action".transit_copy (target_copy);
422
423
424 CREATE TABLE action.hold_transit_copy (
425         hold    INT     REFERENCES action.hold_request (id) ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED
426 ) INHERITS (action.transit_copy);
427 ALTER TABLE action.hold_transit_copy ADD PRIMARY KEY (id);
428 -- ALTER TABLE action.hold_transit_copy ADD CONSTRAINT ahtc_tc_fkey FOREIGN KEY (target_copy) REFERENCES asset.copy (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED; -- XXX could be an serial.issuance
429 CREATE INDEX active_hold_transit_dest_idx ON "action".hold_transit_copy (dest);
430 CREATE INDEX active_hold_transit_source_idx ON "action".hold_transit_copy (source);
431 CREATE INDEX active_hold_transit_cp_idx ON "action".hold_transit_copy (target_copy);
432
433
434 CREATE TABLE action.unfulfilled_hold_list (
435         id              BIGSERIAL                       PRIMARY KEY,
436         current_copy    BIGINT                          NOT NULL,
437         hold            INT                             NOT NULL,
438         circ_lib        INT                             NOT NULL,
439         fail_time       TIMESTAMP WITH TIME ZONE        NOT NULL DEFAULT NOW()
440 );
441 CREATE INDEX uhr_hold_idx ON action.unfulfilled_hold_list (hold);
442
443 CREATE OR REPLACE VIEW action.unfulfilled_hold_loops AS
444     SELECT  u.hold,
445             c.circ_lib,
446             count(*)
447       FROM  action.unfulfilled_hold_list u
448             JOIN asset.copy c ON (c.id = u.current_copy)
449       GROUP BY 1,2;
450
451 CREATE OR REPLACE VIEW action.unfulfilled_hold_min_loop AS
452     SELECT  hold,
453             min(count)
454       FROM  action.unfulfilled_hold_loops
455       GROUP BY 1;
456
457 CREATE OR REPLACE VIEW action.unfulfilled_hold_innermost_loop AS
458     SELECT  DISTINCT l.*
459       FROM  action.unfulfilled_hold_loops l
460             JOIN action.unfulfilled_hold_min_loop m USING (hold)
461       WHERE l.count = m.min;
462
463 CREATE VIEW action.unfulfilled_hold_max_loop AS
464     SELECT  hold,
465             max(count) AS max
466       FROM  action.unfulfilled_hold_loops
467       GROUP BY 1;
468
469
470 CREATE TABLE action.fieldset (
471     id              SERIAL          PRIMARY KEY,
472     owner           INT             NOT NULL REFERENCES actor.usr (id)
473                                     DEFERRABLE INITIALLY DEFERRED,
474         owning_lib      INT             NOT NULL REFERENCES actor.org_unit (id)
475                                     DEFERRABLE INITIALLY DEFERRED,
476         status          TEXT            NOT NULL
477                                         CONSTRAINT valid_status CHECK ( status in
478                                                                         ( 'PENDING', 'APPLIED', 'ERROR' )),
479     creation_time   TIMESTAMPTZ     NOT NULL DEFAULT NOW(),
480     scheduled_time  TIMESTAMPTZ,
481     applied_time    TIMESTAMPTZ,
482     classname       TEXT            NOT NULL, -- an IDL class name
483     name            TEXT            NOT NULL,
484     stored_query    INT             REFERENCES query.stored_query (id)
485                                     DEFERRABLE INITIALLY DEFERRED,
486     pkey_value      TEXT,
487         CONSTRAINT lib_name_unique UNIQUE (owning_lib, name),
488     CONSTRAINT fieldset_one_or_the_other CHECK (
489         (stored_query IS NOT NULL AND pkey_value IS NULL) OR
490         (pkey_value IS NOT NULL AND stored_query IS NULL)
491     )
492         -- the CHECK constraint means we can update the fields for a single
493         -- row without all the extra overhead involved in a query
494 );
495
496 CREATE INDEX action_fieldset_sched_time_idx ON action.fieldset( scheduled_time );
497 CREATE INDEX action_owner_idx               ON action.fieldset( owner );
498
499
500 CREATE TABLE action.fieldset_col_val (
501     id              SERIAL  PRIMARY KEY,
502     fieldset        INT     NOT NULL REFERENCES action.fieldset
503                                          ON DELETE CASCADE
504                                          DEFERRABLE INITIALLY DEFERRED,
505     col             TEXT    NOT NULL,  -- "field" from the idl ... the column on the table
506     val             TEXT,              -- value for the column ... NULL means, well, NULL
507     CONSTRAINT fieldset_col_once_per_set UNIQUE (fieldset, col)
508 );
509
510
511 -- represents a circ chain summary
512 CREATE TYPE action.circ_chain_summary AS (
513     num_circs INTEGER,
514     start_time TIMESTAMP WITH TIME ZONE,
515     checkout_workstation TEXT,
516     last_renewal_time TIMESTAMP WITH TIME ZONE, -- NULL if no renewals
517     last_stop_fines TEXT,
518     last_stop_fines_time TIMESTAMP WITH TIME ZONE,
519     last_renewal_workstation TEXT, -- NULL if no renewals
520     last_checkin_workstation TEXT,
521     last_checkin_time TIMESTAMP WITH TIME ZONE,
522     last_checkin_scan_time TIMESTAMP WITH TIME ZONE
523 );
524
525
526 CREATE OR REPLACE FUNCTION action.circ_chain ( ctx_circ_id INTEGER ) RETURNS SETOF action.circulation AS $$
527 DECLARE
528     tmp_circ action.circulation%ROWTYPE;
529     circ_0 action.circulation%ROWTYPE;
530 BEGIN
531
532     SELECT INTO tmp_circ * FROM action.circulation WHERE id = ctx_circ_id;
533
534     IF tmp_circ IS NULL THEN
535         RETURN NEXT tmp_circ;
536     END IF;
537     circ_0 := tmp_circ;
538
539     -- find the front of the chain
540     WHILE TRUE LOOP
541         SELECT INTO tmp_circ * FROM action.circulation WHERE id = tmp_circ.parent_circ;
542         IF tmp_circ IS NULL THEN
543             EXIT;
544         END IF;
545         circ_0 := tmp_circ;
546     END LOOP;
547
548     -- now send the circs to the caller, oldest to newest
549     tmp_circ := circ_0;
550     WHILE TRUE LOOP
551         IF tmp_circ IS NULL THEN
552             EXIT;
553         END IF;
554         RETURN NEXT tmp_circ;
555         SELECT INTO tmp_circ * FROM action.circulation WHERE parent_circ = tmp_circ.id;
556     END LOOP;
557
558 END;
559 $$ LANGUAGE 'plpgsql';
560
561 CREATE OR REPLACE FUNCTION action.summarize_circ_chain ( ctx_circ_id INTEGER ) RETURNS action.circ_chain_summary AS $$
562
563 DECLARE
564
565     -- first circ in the chain
566     circ_0 action.circulation%ROWTYPE;
567
568     -- last circ in the chain
569     circ_n action.circulation%ROWTYPE;
570
571     -- circ chain under construction
572     chain action.circ_chain_summary;
573     tmp_circ action.circulation%ROWTYPE;
574
575 BEGIN
576     
577     chain.num_circs := 0;
578     FOR tmp_circ IN SELECT * FROM action.circ_chain(ctx_circ_id) LOOP
579
580         IF chain.num_circs = 0 THEN
581             circ_0 := tmp_circ;
582         END IF;
583
584         chain.num_circs := chain.num_circs + 1;
585         circ_n := tmp_circ;
586     END LOOP;
587
588     chain.start_time := circ_0.xact_start;
589     chain.last_stop_fines := circ_n.stop_fines;
590     chain.last_stop_fines_time := circ_n.stop_fines_time;
591     chain.last_checkin_time := circ_n.checkin_time;
592     chain.last_checkin_scan_time := circ_n.checkin_scan_time;
593     SELECT INTO chain.checkout_workstation name FROM actor.workstation WHERE id = circ_0.workstation;
594     SELECT INTO chain.last_checkin_workstation name FROM actor.workstation WHERE id = circ_n.checkin_workstation;
595
596     IF chain.num_circs > 1 THEN
597         chain.last_renewal_time := circ_n.xact_start;
598         SELECT INTO chain.last_renewal_workstation name FROM actor.workstation WHERE id = circ_n.workstation;
599     END IF;
600
601     RETURN chain;
602
603 END;
604 $$ LANGUAGE 'plpgsql';
605
606 -- Return the list of circ chain heads in xact_start order that the user has chosen to "retain"
607 CREATE OR REPLACE FUNCTION action.usr_visible_circs (usr_id INT) RETURNS SETOF action.circulation AS $func$
608 DECLARE
609     c               action.circulation%ROWTYPE;
610     view_age        INTERVAL;
611     usr_view_age    actor.usr_setting%ROWTYPE;
612     usr_view_start  actor.usr_setting%ROWTYPE;
613 BEGIN
614     SELECT * INTO usr_view_age FROM actor.usr_setting WHERE usr = usr_id AND name = 'history.circ.retention_age';
615     SELECT * INTO usr_view_start FROM actor.usr_setting WHERE usr = usr_id AND name = 'history.circ.retention_start';
616
617     IF usr_view_age.value IS NOT NULL AND usr_view_start.value IS NOT NULL THEN
618         -- User opted in and supplied a retention age
619         IF oils_json_to_text(usr_view_age.value)::INTERVAL > AGE(NOW(), oils_json_to_text(usr_view_start.value)::TIMESTAMPTZ) THEN
620             view_age := AGE(NOW(), oils_json_to_text(usr_view_start.value)::TIMESTAMPTZ);
621         ELSE
622             view_age := oils_json_to_text(usr_view_age.value)::INTERVAL;
623         END IF;
624     ELSIF usr_view_start.value IS NOT NULL THEN
625         -- User opted in
626         view_age := AGE(NOW(), oils_json_to_text(usr_view_start.value)::TIMESTAMPTZ);
627     ELSE
628         -- User did not opt in
629         RETURN;
630     END IF;
631
632     FOR c IN
633         SELECT  *
634           FROM  action.circulation
635           WHERE usr = usr_id
636                 AND parent_circ IS NULL
637                 AND xact_start > NOW() - view_age
638           ORDER BY xact_start
639     LOOP
640         RETURN NEXT c;
641     END LOOP;
642
643     RETURN;
644 END;
645 $func$ LANGUAGE PLPGSQL;
646
647 CREATE OR REPLACE FUNCTION action.usr_visible_circ_copies( INTEGER ) RETURNS SETOF BIGINT AS $$
648     SELECT DISTINCT(target_copy) FROM action.usr_visible_circs($1)
649 $$ LANGUAGE SQL;
650
651 CREATE OR REPLACE FUNCTION action.usr_visible_holds (usr_id INT) RETURNS SETOF action.hold_request AS $func$
652 DECLARE
653     h               action.hold_request%ROWTYPE;
654     view_age        INTERVAL;
655     view_count      INT;
656     usr_view_count  actor.usr_setting%ROWTYPE;
657     usr_view_age    actor.usr_setting%ROWTYPE;
658     usr_view_start  actor.usr_setting%ROWTYPE;
659 BEGIN
660     SELECT * INTO usr_view_count FROM actor.usr_setting WHERE usr = usr_id AND name = 'history.hold.retention_count';
661     SELECT * INTO usr_view_age FROM actor.usr_setting WHERE usr = usr_id AND name = 'history.hold.retention_age';
662     SELECT * INTO usr_view_start FROM actor.usr_setting WHERE usr = usr_id AND name = 'history.hold.retention_start';
663
664     FOR h IN
665         SELECT  *
666           FROM  action.hold_request
667           WHERE usr = usr_id
668                 AND fulfillment_time IS NULL
669                 AND cancel_time IS NULL
670           ORDER BY request_time DESC
671     LOOP
672         RETURN NEXT h;
673     END LOOP;
674
675     IF usr_view_start.value IS NULL THEN
676         RETURN;
677     END IF;
678
679     IF usr_view_age.value IS NOT NULL THEN
680         -- User opted in and supplied a retention age
681         IF oils_json_to_string(usr_view_age.value)::INTERVAL > AGE(NOW(), oils_json_to_string(usr_view_start.value)::TIMESTAMPTZ) THEN
682             view_age := AGE(NOW(), oils_json_to_string(usr_view_start.value)::TIMESTAMPTZ);
683         ELSE
684             view_age := oils_json_to_string(usr_view_age.value)::INTERVAL;
685         END IF;
686     ELSE
687         -- User opted in
688         view_age := AGE(NOW(), oils_json_to_string(usr_view_start.value)::TIMESTAMPTZ);
689     END IF;
690
691     IF usr_view_count.value IS NOT NULL THEN
692         view_count := oils_json_to_text(usr_view_count.value)::INT;
693     ELSE
694         view_count := 1000;
695     END IF;
696
697     -- show some fulfilled/canceled holds
698     FOR h IN
699         SELECT  *
700           FROM  action.hold_request
701           WHERE usr = usr_id
702                 AND ( fulfillment_time IS NOT NULL OR cancel_time IS NOT NULL )
703                 AND request_time > NOW() - view_age
704           ORDER BY request_time DESC
705           LIMIT view_count
706     LOOP
707         RETURN NEXT h;
708     END LOOP;
709
710     RETURN;
711 END;
712 $func$ LANGUAGE PLPGSQL;
713
714 CREATE OR REPLACE FUNCTION action.purge_circulations () RETURNS INT AS $func$
715 DECLARE
716     usr_keep_age    actor.usr_setting%ROWTYPE;
717     usr_keep_start  actor.usr_setting%ROWTYPE;
718     org_keep_age    INTERVAL;
719     org_keep_count  INT;
720
721     keep_age        INTERVAL;
722
723     target_acp      RECORD;
724     circ_chain_head action.circulation%ROWTYPE;
725     circ_chain_tail action.circulation%ROWTYPE;
726
727     purge_position  INT;
728     count_purged    INT;
729 BEGIN
730
731     count_purged := 0;
732
733     SELECT value::INTERVAL INTO org_keep_age FROM config.global_flag WHERE name = 'history.circ.retention_age' AND enabled;
734
735     SELECT value::INT INTO org_keep_count FROM config.global_flag WHERE name = 'history.circ.retention_count' AND enabled;
736     IF org_keep_count IS NULL THEN
737         RETURN count_purged; -- Gimme a count to keep, or I keep them all, forever
738     END IF;
739
740     -- First, find copies with more than keep_count non-renewal circs
741     FOR target_acp IN
742         SELECT  target_copy,
743                 COUNT(*) AS total_real_circs
744           FROM  action.circulation
745           WHERE parent_circ IS NULL
746                 AND xact_finish IS NOT NULL
747           GROUP BY target_copy
748           HAVING COUNT(*) > org_keep_count
749     LOOP
750         purge_position := 0;
751         -- And, for those, select circs that are finished and older than keep_age
752         FOR circ_chain_head IN
753             SELECT  *
754               FROM  action.circulation
755               WHERE target_copy = target_acp.target_copy
756                     AND parent_circ IS NULL
757               ORDER BY xact_start
758         LOOP
759
760             -- Stop once we've purged enough circs to hit org_keep_count
761             EXIT WHEN target_acp.total_real_circs - purge_position <= org_keep_count;
762
763             SELECT * INTO circ_chain_tail FROM action.circ_chain(circ_chain_head.id) ORDER BY xact_start DESC LIMIT 1;
764             EXIT WHEN circ_chain_tail.xact_finish IS NULL;
765
766             -- Now get the user settings, if any, to block purging if the user wants to keep more circs
767             usr_keep_age.value := NULL;
768             SELECT * INTO usr_keep_age FROM actor.usr_setting WHERE usr = circ_chain_head.usr AND name = 'history.circ.retention_age';
769
770             usr_keep_start.value := NULL;
771             SELECT * INTO usr_keep_start FROM actor.usr_setting WHERE usr = circ_chain_head.usr AND name = 'history.circ.retention_start';
772
773             IF usr_keep_age.value IS NOT NULL AND usr_keep_start.value IS NOT NULL THEN
774                 IF oils_json_to_text(usr_keep_age.value)::INTERVAL > AGE(NOW(), oils_json_to_text(usr_keep_start.value)::TIMESTAMPTZ) THEN
775                     keep_age := AGE(NOW(), oils_json_to_text(usr_keep_start.value)::TIMESTAMPTZ);
776                 ELSE
777                     keep_age := oils_json_to_text(usr_keep_age.value)::INTERVAL;
778                 END IF;
779             ELSIF usr_keep_start.value IS NOT NULL THEN
780                 keep_age := AGE(NOW(), oils_json_to_text(usr_keep_start.value)::TIMESTAMPTZ);
781             ELSE
782                 keep_age := COALESCE( org_keep_age::INTERVAL, '2000 years'::INTERVAL );
783             END IF;
784
785             EXIT WHEN AGE(NOW(), circ_chain_tail.xact_finish) < keep_age;
786
787             -- We've passed the purging tests, purge the circ chain starting at the end
788             DELETE FROM action.circulation WHERE id = circ_chain_tail.id;
789             WHILE circ_chain_tail.parent_circ IS NOT NULL LOOP
790                 SELECT * INTO circ_chain_tail FROM action.circulation WHERE id = circ_chain_tail.parent_circ;
791                 DELETE FROM action.circulation WHERE id = circ_chain_tail.id;
792             END LOOP;
793
794             count_purged := count_purged + 1;
795             purge_position := purge_position + 1;
796
797         END LOOP;
798     END LOOP;
799 END;
800 $func$ LANGUAGE PLPGSQL;
801
802
803 CREATE OR REPLACE FUNCTION action.apply_fieldset(
804         fieldset_id IN INT,        -- id from action.fieldset
805         table_name  IN TEXT,       -- table to be updated
806         pkey_name   IN TEXT,       -- name of primary key column in that table
807         query       IN TEXT        -- query constructed by qstore (for query-based
808                                    --    fieldsets only; otherwise null
809 )
810 RETURNS TEXT AS $$
811 DECLARE
812         statement TEXT;
813         fs_status TEXT;
814         fs_pkey_value TEXT;
815         fs_query TEXT;
816         sep CHAR;
817         status_code TEXT;
818         msg TEXT;
819         update_count INT;
820         cv RECORD;
821 BEGIN
822         -- Sanity checks
823         IF fieldset_id IS NULL THEN
824                 RETURN 'Fieldset ID parameter is NULL';
825         END IF;
826         IF table_name IS NULL THEN
827                 RETURN 'Table name parameter is NULL';
828         END IF;
829         IF pkey_name IS NULL THEN
830                 RETURN 'Primary key name parameter is NULL';
831         END IF;
832         --
833         statement := 'UPDATE ' || table_name || ' SET';
834         --
835         SELECT
836                 status,
837                 quote_literal( pkey_value )
838         INTO
839                 fs_status,
840                 fs_pkey_value
841         FROM
842                 action.fieldset
843         WHERE
844                 id = fieldset_id;
845         --
846         IF fs_status IS NULL THEN
847                 RETURN 'No fieldset found for id = ' || fieldset_id;
848         ELSIF fs_status = 'APPLIED' THEN
849                 RETURN 'Fieldset ' || fieldset_id || ' has already been applied';
850         END IF;
851         --
852         sep := '';
853         FOR cv IN
854                 SELECT  col,
855                                 val
856                 FROM    action.fieldset_col_val
857                 WHERE   fieldset = fieldset_id
858         LOOP
859                 statement := statement || sep || ' ' || cv.col
860                                          || ' = ' || coalesce( quote_literal( cv.val ), 'NULL' );
861                 sep := ',';
862         END LOOP;
863         --
864         IF sep = '' THEN
865                 RETURN 'Fieldset ' || fieldset_id || ' has no column values defined';
866         END IF;
867         --
868         -- Add the WHERE clause.  This differs according to whether it's a
869         -- single-row fieldset or a query-based fieldset.
870         --
871         IF query IS NULL        AND fs_pkey_value IS NULL THEN
872                 RETURN 'Incomplete fieldset: neither a primary key nor a query available';
873         ELSIF query IS NOT NULL AND fs_pkey_value IS NULL THEN
874             fs_query := rtrim( query, ';' );
875             statement := statement || ' WHERE ' || pkey_name || ' IN ( '
876                          || fs_query || ' );';
877         ELSIF query IS NULL     AND fs_pkey_value IS NOT NULL THEN
878                 statement := statement || ' WHERE ' || pkey_name || ' = '
879                                      || fs_pkey_value || ';';
880         ELSE  -- both are not null
881                 RETURN 'Ambiguous fieldset: both a primary key and a query provided';
882         END IF;
883         --
884         -- Execute the update
885         --
886         BEGIN
887                 EXECUTE statement;
888                 GET DIAGNOSTICS update_count = ROW_COUNT;
889                 --
890                 IF UPDATE_COUNT > 0 THEN
891                         status_code := 'APPLIED';
892                         msg := NULL;
893                 ELSE
894                         status_code := 'ERROR';
895                         msg := 'No eligible rows found for fieldset ' || fieldset_id;
896         END IF;
897         EXCEPTION WHEN OTHERS THEN
898                 status_code := 'ERROR';
899                 msg := 'Unable to apply fieldset ' || fieldset_id
900                            || ': ' || sqlerrm;
901         END;
902         --
903         -- Update fieldset status
904         --
905         UPDATE action.fieldset
906         SET status       = status_code,
907             applied_time = now()
908         WHERE id = fieldset_id;
909         --
910         RETURN msg;
911 END;
912 $$ LANGUAGE plpgsql;
913
914 COMMENT ON FUNCTION action.apply_fieldset( INT, TEXT, TEXT, TEXT ) IS $$
915 /**
916  * Applies a specified fieldset, using a supplied table name and primary
917  * key name.  The query parameter should be non-null only for
918  * query-based fieldsets.
919  *
920  * Returns NULL if successful, or an error message if not.
921  */
922 $$;
923
924
925 COMMIT;