1
2
3 """ S3 Organizer (Calendar-based CRUD)
4
5 @copyright: 2018-2019 (c) Sahana Software Foundation
6 @license: MIT
7
8 Permission is hereby granted, free of charge, to any person
9 obtaining a copy of this software and associated documentation
10 files (the "Software"), to deal in the Software without
11 restriction, including without limitation the rights to use,
12 copy, modify, merge, publish, distribute, sublicense, and/or sell
13 copies of the Software, and to permit persons to whom the
14 Software is furnished to do so, subject to the following
15 conditions:
16
17 The above copyright notice and this permission notice shall be
18 included in all copies or substantial portions of the Software.
19
20 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
21 EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22 OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23 NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24 HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25 WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26 FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27 OTHER DEALINGS IN THE SOFTWARE.
28 """
29
30 __all__ = ("S3Organizer",
31 "S3OrganizerWidget",
32 )
33
34 import datetime
35 try:
36 import dateutil
37 import dateutil.tz
38 except ImportError:
39 import sys
40 sys.stderr.write("ERROR: python-dateutil module needed for date handling\n")
41 raise
42 import json
43 import os
44 import uuid
45
46 from gluon import current, DIV, INPUT
47 from gluon.storage import Storage
48
49 from s3datetime import s3_decode_iso_datetime
50 from s3rest import S3Method
51 from s3utils import s3_str
52 from s3validators import JSONERRORS
53 from s3widgets import S3DateWidget
57 """ Calendar-based CRUD Method """
58
59
61 """
62 Page-render entry point for REST interface.
63
64 @param r: the S3Request instance
65 @param attr: controller attributes
66 """
67
68 output = {}
69 if r.http == "GET":
70 if r.representation == "json":
71 output = self.get_json_data(r, **attr)
72 elif r.interactive:
73 output = self.organizer(r, **attr)
74 else:
75 r.error(415, current.ERROR.BAD_FORMAT)
76 elif r.http == "POST":
77 if r.representation == "json":
78 output = self.update_json(r, **attr)
79 else:
80 r.error(415, current.ERROR.BAD_FORMAT)
81 else:
82 r.error(405, current.ERROR.BAD_METHOD)
83
84 return output
85
86
88 """
89 Render the organizer view (HTML method)
90
91 @param r: the S3Request instance
92 @param attr: controller attributes
93
94 @returns: dict of values for the view
95 """
96
97 output = {}
98
99 resource = self.resource
100 get_config = resource.get_config
101
102
103 config = self.parse_config(resource)
104 start = config["start"]
105 end = config["end"]
106
107 widget_id = "organizer"
108
109
110 hide_filter = self.hide_filter
111 filter_widgets = get_config("filter_widgets", None)
112
113 show_filter_form = False
114 default_filters = None
115
116 if filter_widgets and not hide_filter:
117
118
119
120 fw = []
121 prefix_selector = self.prefix_selector
122 for filter_widget in filter_widgets:
123 if not filter_widget:
124 continue
125 filter_field = filter_widget.field
126 if isinstance(filter_field, basestring):
127 filter_field = prefix_selector(resource, filter_field)
128 if start and start.selector == filter_field or \
129 end and end.selector == filter_field:
130 continue
131 fw.append(filter_widget)
132 filter_widgets = fw
133
134 if filter_widgets:
135 show_filter_form = True
136
137 from s3filter import S3FilterForm
138 default_filters = S3FilterForm.apply_filter_defaults(r, resource)
139
140
141 if show_filter_form:
142
143 get_vars = r.get_vars
144
145
146 filter_submit_url = attr.get("filter_submit_url")
147 if not filter_submit_url:
148 get_vars_ = self._remove_filters(get_vars)
149 filter_submit_url = r.url(vars=get_vars_)
150
151
152 filter_ajax_url = attr.get("filter_ajax_url")
153 if filter_ajax_url is None:
154 filter_ajax_url = r.url(method = "filter",
155 vars = {},
156 representation = "options",
157 )
158
159 filter_clear = get_config("filter_clear",
160 current.deployment_settings.get_ui_filter_clear())
161 filter_formstyle = get_config("filter_formstyle", None)
162 filter_submit = get_config("filter_submit", True)
163 filter_form = S3FilterForm(filter_widgets,
164 clear = filter_clear,
165 formstyle = filter_formstyle,
166 submit = filter_submit,
167 ajax = True,
168 url = filter_submit_url,
169 ajaxurl = filter_ajax_url,
170 _class = "filter-form",
171 _id = "%s-filter-form" % widget_id
172 )
173 fresource = current.s3db.resource(resource.tablename)
174 alias = resource.alias if r.component else None
175 output["list_filter_form"] = filter_form.html(fresource,
176 get_vars,
177 target = widget_id,
178 alias = alias
179 )
180 else:
181
182 output["list_filter_form"] = ""
183
184
185 crud_string = self.crud_string
186 if r.representation != "iframe":
187 if r.component:
188 title = crud_string(r.tablename, "title_display")
189 else:
190 title = crud_string(self.tablename, "title_list")
191 output["title"] = title
192
193
194 permitted = self._permitted
195 resource_config = {"ajaxURL": r.url(representation="json"),
196 "useTime": config.get("use_time"),
197 "baseURL": r.url(method=""),
198 "labelCreate": s3_str(crud_string(self.tablename, "label_create")),
199 "insertable": resource.get_config("insertable", True) and \
200 permitted("create"),
201 "editable": resource.get_config("editable", True) and \
202 permitted("update"),
203 "startEditable": start.field and start.field.writable,
204 "durationEditable": end and end.field and end.field.writable,
205 "deletable": resource.get_config("deletable", True) and \
206 permitted("delete"),
207
208
209 "reloadOnUpdate": config.get("reload_on_update", False),
210 }
211
212
213 resource_config["start"] = start.selector if start else None
214 resource_config["end"] = end.selector if end else None
215
216
217 labels = []
218 for rfield in config["description"]:
219 label = rfield.label
220 if label is not None:
221 label = s3_str(label)
222 labels.append((rfield.colname, label))
223 resource_config["columns"] = labels
224
225
226 color = config.get("color")
227 if color:
228 resource_config["color"] = color.colname
229 resource_config["colors"] = config.get("colors")
230
231
232 formkey = uuid.uuid4().get_hex()
233
234
235 session = current.session
236 keyname = "_formkey[%s]" % self.formname(r)
237 session[keyname] = session.get(keyname, [])[-9:] + [formkey]
238
239
240 widget = S3OrganizerWidget([resource_config])
241 output["organizer"] = widget.html(widget_id = widget_id,
242 formkey = formkey,
243 )
244
245
246 current.response.view = self._view(r, "organize.html")
247
248 return output
249
250
252 """
253 Extract the resource data and return them as JSON (Ajax method)
254
255 @param r: the S3Request instance
256 @param attr: controller attributes
257
258 TODO correct documentation!
259 @returns: JSON string containing an array of items, format:
260 [{"id": the record ID,
261 "title": the record title,
262 "start": start date as ISO8601 string,
263 "end": end date as ISO8601 string (if resource has end dates),
264 "description": array of item values to render a description,
265 TODO:
266 "editable": item date/duration can be changed (true|false),
267 "deletable": item can be deleted (true|false),
268 },
269 ...
270 ]
271 """
272
273 db = current.db
274 auth = current.auth
275
276 resource = self.resource
277 table = resource.table
278 id_col = str(resource._id)
279
280 config = self.parse_config(resource)
281
282
283 fields = [resource._id.name]
284
285 start_rfield = config["start"]
286 fields.append(start_rfield)
287
288 end_rfield = config["end"]
289 if end_rfield:
290 fields.append(end_rfield)
291
292 represent = config["title"]
293 if hasattr(represent, "selector"):
294 title_field = represent.colname
295 fields.append(represent)
296 else:
297 title_field = None
298
299 description = config["description"]
300 if description:
301 fields.extend(description)
302 columns = [rfield.colname for rfield in description]
303 else:
304 columns = None
305
306 color = config["color"]
307 if color:
308 fields.append(color)
309
310
311 start, end = self.parse_interval(r.get_vars.get("$interval"))
312 if start and end:
313 from s3query import FS
314 start_fs = FS(start_rfield.selector)
315 if not end_rfield:
316 query = (start_fs >= start) & (start_fs < end)
317 else:
318 end_fs = FS(end_rfield.selector)
319 query = (start_fs < end) & (end_fs >= start) | \
320 (start_fs >= start) & (start_fs < end) & (end_fs == None)
321 resource.add_filter(query)
322 else:
323 r.error(400, "Invalid interval parameter")
324
325
326 data = resource.select(fields,
327 limit = None,
328 raw_data = True,
329 represent = True,
330 )
331 rows = data.rows
332
333
334 record_ids = [row._row[id_col] for row in rows]
335 if hasattr(represent, "bulk"):
336 representations = represent.bulk(record_ids)
337 else:
338 representations = None
339
340
341 query = table.id.belongs(record_ids)
342
343 q = query & auth.s3_accessible_query("update", table)
344 accessible_rows = db(q).select(table._id,
345 limitby = (0, len(record_ids)),
346 )
347 editable = set(row[id_col] for row in accessible_rows)
348
349 q = query & auth.s3_accessible_query("delete", table)
350 accessible_rows = db(q).select(table._id,
351 limitby = (0, len(record_ids)),
352 )
353 deletable = set(row[id_col] for row in accessible_rows)
354
355
356 items = []
357 for row in rows:
358
359 raw = row._row
360 record_id = raw[id_col]
361
362
363 if start_rfield:
364 start_date = self.isoformat(raw[start_rfield.colname])
365 else:
366 start_date = None
367 if start_date is None:
368
369 continue
370
371
372 if title_field:
373 title = row[title_field]
374 elif representations:
375 title = representations.get(record_id)
376 elif callable(represent):
377 title = represent(record_id)
378 else:
379
380 title = row[id_col]
381
382
383 item = {"id": record_id,
384 "t": s3_str(title),
385 "s": start_date,
386 "pe": 1 if record_id in editable else 0,
387 "pd": 1 if record_id in deletable else 0,
388 }
389
390 if end_rfield:
391 end_date = self.isoformat(raw[end_rfield.colname])
392 item["e"] = end_date
393
394 if columns:
395 data = []
396 for colname in columns:
397 value = row[colname]
398 if value is not None:
399 value = s3_str(value)
400 data.append(value)
401 item["d"] = data
402
403 if color:
404 item["c"] = raw[color.colname]
405
406 items.append(item)
407
408 return json.dumps({"c": columns, "r": items})
409
410
412 """
413 Update or delete calendar items (Ajax method)
414
415 @param r: the S3Request instance
416 @param attr: controller attributes
417 """
418
419
420 s = r.body
421 s.seek(0)
422 try:
423 options = json.load(s)
424 except JSONERRORS:
425 options = None
426 if not isinstance(options, dict):
427 r.error(400, "Invalid request options")
428
429
430 keyname = "_formkey[%s]" % self.formname(r)
431 formkey = options.get("k")
432 if not formkey or formkey not in current.session.get(keyname, []):
433 r.error(403, current.ERROR.NOT_PERMITTED)
434
435 resource = self.resource
436 tablename = resource.tablename
437
438
439 items = options.get("u")
440 if items and type(items) is list:
441
442
443 if not resource.get_config("editable", True):
444 r.error(403, current.ERROR.NOT_PERMITTED)
445
446
447 config = self.parse_config(resource)
448 start = config.get("start")
449 if start:
450 if not start.field or start.tname != tablename:
451
452
453 start = None
454 end = config.get("end")
455 if end:
456 if not end.field or end.tname != tablename:
457
458
459 end = None
460
461
462 db = current.db
463 table = resource.table
464 prefix, name = resource.prefix, resource.name
465
466
467 s3db = current.s3db
468 onaccept = s3db.onaccept
469 update_super = s3db.update_super
470
471
472 auth = current.auth
473 audit = current.audit
474 permitted = auth.s3_has_permission
475 set_realm_entity = auth.set_realm_entity
476
477
478 for item in items:
479
480
481 record_id = item.get("id")
482 if not record_id:
483 continue
484
485
486 if not permitted("update", table, record_id=record_id):
487 r.unauthorised()
488
489
490 data = {}
491 error = None
492 if "s" in item:
493 if not start:
494 error = "Event start not editable"
495 else:
496 try:
497 dt = s3_decode_iso_datetime(item["s"])
498 except ValueError:
499 error = "Invalid start date"
500 if start.field:
501 dt, error = start.field.validate(dt)
502 data[start.fname] = dt
503 if not error and "e" in item:
504 if not end:
505 error = "Event end not editable"
506 else:
507 try:
508 dt = s3_decode_iso_datetime(item["e"])
509 except ValueError:
510 error = "Invalid end date"
511 if end.field:
512 dt, error = end.field.validate(dt)
513 data[end.fname] = dt
514 if error:
515 r.error(400, error)
516
517
518 if data:
519 success = db(table._id == record_id).update(**data)
520 if not success:
521 r.error(400, "Failed to update %s#%s" % (tablename, record_id))
522 else:
523 data[table._id.name] = record_id
524
525
526 audit("update", prefix, name,
527 record=record_id, representation="json")
528
529 update_super(table, data)
530
531 if resource.get_config("update_realm"):
532 set_realm_entity(table, record_id, force_update=True)
533
534 onaccept(table, data, method="update")
535
536
537 items = options.get("d")
538 if items and type(items) is list:
539
540
541 if not resource.get_config("deletable", True):
542 r.error(403, current.ERROR.NOT_PERMITTED)
543
544
545 delete_ids = []
546 for item in items:
547 record_id = item.get("id")
548 if not record_id:
549 continue
550 delete_ids.append(record_id)
551
552
553
554 if delete_ids:
555 dresource = current.s3db.resource(tablename, id=delete_ids)
556 deleted = dresource.delete(cascade=True)
557 if deleted != len(delete_ids):
558 r.error(400, "Failed to delete %s items" % tablename)
559
560 return current.xml.json_message()
561
562
563 @classmethod
565 """
566 Parse the resource configuration and add any fallbacks
567
568 @param resource: the S3Resource
569
570 @returns: the resource organizer configuration, format:
571 {"start": S3ResourceField,
572 "end": S3ResourceField or None,
573 "use_time": whether this resource has timed events,
574 "title": selector or callable to produce item titles,
575 "description": list of selectors for the item description,
576 }
577 """
578
579 prefix = lambda selector: cls.prefix_selector(resource, selector)
580
581 table = resource.table
582 config = resource.get_config("organize")
583 if not config:
584 config = {}
585
586
587 introspect = False
588 start_rfield = end_rfield = None
589 start = config.get("start")
590 if not start:
591 introspect = True
592 for fn in ("date", "start_date"):
593 if fn in table.fields:
594 start = fn
595 break
596 if start:
597 start_rfield = resource.resolve_selector(prefix(start))
598 else:
599 raise AttributeError("No start date found in %s" % table)
600
601
602 end = config.get("end")
603 if not end and introspect:
604 for fn in ("end_date", "closed_on"):
605 if fn in table.fields:
606 start = fn
607 break
608 if end:
609 end_rfield = resource.resolve_selector(prefix(end))
610 if start_rfield.colname == end_rfield.colname:
611 end_rfield = None
612
613
614 use_time = config.get("use_time", True)
615 if start_rfield.ftype == "date":
616 use_time = False
617 elif end_rfield:
618 if end_rfield.ftype == "date":
619 if introspect:
620
621 end_rfield = None
622 else:
623 use_time = False
624
625
626 represent = config.get("title")
627 if represent is None:
628 for fn in ("subject", "name", "type_id"):
629 if fn in table.fields:
630 represent = fn
631 break
632
633
634 if type(represent) is str:
635 represent = resource.resolve_selector(prefix(represent))
636
637
638 setting = config.get("description")
639 description = []
640 if isinstance(setting, (tuple, list)):
641 for item in setting:
642 if type(item) is tuple and len(item) > 1:
643 label, selector = item[:2]
644 else:
645 label, selector = None, item
646 rfield = resource.resolve_selector(prefix(selector))
647 if label is not None:
648 rfield.label = label
649 description.append(rfield)
650
651
652 color = config.get("color")
653 if color:
654 colors = config.get("colors")
655 if callable(colors):
656 colors = colors(resource, color)
657 color = resource.resolve_selector(prefix(color))
658 else:
659 colors = None
660
661 return {"start": start_rfield,
662 "end": end_rfield,
663 "use_time": use_time,
664 "title": represent,
665 "description": description,
666 "color": color,
667 "colors": colors,
668 }
669
670
671 @staticmethod
673 """
674 Parse an interval string of the format "<ISO8601>--<ISO8601>"
675 into a pair of datetimes
676
677 @param intervalstr: the interval string
678
679 @returns: tuple of datetimes (start, end)
680 """
681
682 start = end = None
683
684 if intervalstr:
685 dates = intervalstr.split("--")
686 if len(dates) != 2:
687 return start, end
688
689 try:
690 start = s3_decode_iso_datetime(dates[0])
691 except ValueError:
692 pass
693 else:
694 start = start.replace(hour=0, minute=0, second=0)
695 try:
696 end = s3_decode_iso_datetime(dates[1])
697 except ValueError:
698 pass
699 else:
700 end = end.replace(hour=0, minute=0, second=0)
701
702 return start, end
703
704
705 @staticmethod
733
734
735 @staticmethod
744
745
746 @staticmethod
764
908
909
910