Compare commits

...

137 Commits
v3.19 ... main

Author SHA1 Message Date
Yann Weber 6c2c412cfc api: add 'max_places' argument to API (#89848)
gitea/chrono/pipeline/head This commit looks good Details
2024-04-30 14:50:47 +02:00
Yann Weber 1aca9c2a66 agendas: replace vobject by icalendar & recurring_ical_events (#88806)
gitea/chrono/pipeline/head This commit looks good Details
2024-04-30 14:40:56 +02:00
Frédéric Péters 1ef0ba26af journal: check request.user class when creating journal entry (#90162)
gitea/chrono/pipeline/head This commit looks good Details
2024-04-29 18:22:51 +02:00
Frédéric Péters 4c14a32d82 translation update
gitea/chrono/pipeline/head This commit looks good Details
2024-04-29 16:07:49 +02:00
Frédéric Péters 32d0a0c44b general: add journal app (#86632)
gitea/chrono/pipeline/head This commit looks good Details
2024-04-29 16:00:00 +02:00
Valentin Deniaud 733cdfc9a9 agendas: handle admin role in export file (#89990)
gitea/chrono/pipeline/head This commit looks good Details
2024-04-29 09:45:33 +02:00
Lauréline Guérin 77f3373820
agendas: fix custom fields export/import and display in inspect (#89485)
gitea/chrono/pipeline/head This commit looks good Details
2024-04-26 14:41:51 +02:00
Valentin Deniaud 169dc0a69a agendas: ignore exception source on import if file is missing (#89873)
gitea/chrono/pipeline/head This commit looks good Details
2024-04-23 13:42:32 +02:00
Lauréline Guérin 88d8feacd8
translation update
gitea/chrono/pipeline/head This commit looks good Details
2024-04-18 09:37:51 +02:00
Thomas NOËL 6ee8fbf78d agendas: use URLField for event url (#89447)
gitea/chrono/pipeline/head This commit looks good Details
2024-04-16 18:01:42 +02:00
Lauréline Guérin 3403295d3d snapshots: json diff, use gadjo to collapse lines between changes (#89484)
gitea/chrono/pipeline/head This commit looks good Details
2024-04-16 10:14:25 +02:00
Yann Weber 0563e0642d tests: fix event order in api fillslot tests (#89598)
gitea/chrono/pipeline/head This commit looks good Details
2024-04-15 16:24:51 +02:00
Yann Weber 5a90c4851b tests: add callback to clear timezone cache on settings update (#89097)
gitea/chrono/pipeline/head This commit looks good Details
2024-04-15 15:08:00 +02:00
Yann Weber d03e1e7940 tests: replace legacy Brazil/East timezone in fixture (#89097)
Replacing Brazil/East (legacy, not present in defaults zoneinfo anymore)
with America/Sao_Paulo
2024-04-15 15:08:00 +02:00
Yann Weber b0f956c223 tests: remove --dist loadfile option introducing a bug (#89097) 2024-04-15 15:08:00 +02:00
Yann Weber 570cf81c8e manager: make agenda's groups foldable (#85616)
gitea/chrono/pipeline/head This commit looks good Details
2024-04-15 14:44:09 +02:00
Benjamin Dauvergne 5fa96e62a8 agendas: fix counting of unlocked bookings with respect to waiting lists (#89266)
gitea/chrono/pipeline/head This commit looks good Details
2024-04-10 10:12:15 +02:00
Lauréline Guérin 7c91b91d89 export_import: post bundle (#89035)
gitea/chrono/pipeline/head This commit looks good Details
2024-04-04 15:49:05 +02:00
Lauréline Guérin a167a91cde export_import: replace APIError by APIErrorBadRequest (#88593) 2024-04-04 15:49:05 +02:00
Lauréline Guérin 56b794468f export_import: remove authent on redirect test (#88593) 2024-04-04 15:49:05 +02:00
Lauréline Guérin 184cf83dd7
translation update
gitea/chrono/pipeline/head This commit looks good Details
2024-04-04 15:41:00 +02:00
Yann Weber 4e6f41c4de tests: remove transactional_db fixture for migrations tests (#89040)
gitea/chrono/pipeline/head This commit looks good Details
2024-04-04 10:29:02 +02:00
Benjamin Dauvergne 42f73e2626 ants_hub: push Place.logo_url to ants-hub (#89020)
gitea/chrono/pipeline/head This commit looks good Details
2024-04-03 14:58:35 +02:00
Valentin Deniaud 3576928b2c agendas: import/export end time event field (#88615)
gitea/chrono/pipeline/head This commit looks good Details
2024-04-03 11:05:31 +02:00
Frédéric Péters ae55827939 api: add agenda slug to event details (#88764)
gitea/chrono/pipeline/head This commit looks good Details
2024-03-29 08:28:58 +01:00
Lauréline Guérin d733e91135 api: add primary_event in event details (#88559)
gitea/chrono/pipeline/head This commit looks good Details
2024-03-29 08:21:04 +01:00
Lauréline Guérin 4b8c3412e4
snapshot: do not delete snapshots on user deletion (#88623)
gitea/chrono/pipeline/head This commit looks good Details
2024-03-26 13:32:51 +01:00
Valentin Deniaud be975cfa29 ci: do not run tests in parallel by default (#88626)
gitea/chrono/pipeline/head This commit looks good Details
2024-03-25 14:13:08 +01:00
Lauréline Guérin f7e224ba9b
misc: fix failing test due to dst change (#88568)
gitea/chrono/pipeline/head This commit looks good Details
2024-03-23 10:30:24 +01:00
Thomas Jund 41cadbcfa9 manager: improve html & CSS of partial booking month view (#79863)
gitea/chrono/pipeline/head There was a failure building this commit Details
2024-03-22 09:40:49 +01:00
Lauréline Guérin a34d55879e
translation update
gitea/chrono/pipeline/head This commit looks good Details
2024-03-21 13:41:06 +01:00
Lauréline Guérin 43c42c507c
export_import: missing component in bundle (#88068)
gitea/chrono/pipeline/head This commit looks good Details
2024-03-21 13:35:50 +01:00
Lauréline Guérin 886afb206e
export_import: unknown component_type in urls (#88068) 2024-03-21 13:35:50 +01:00
Lauréline Guérin 2c30eec6ac
export_import: invalid bundle (#88068) 2024-03-21 13:35:49 +01:00
Lauréline Guérin 1896c33f29
manager: get snapshots to compare from application version (#87653)
gitea/chrono/pipeline/head This commit looks good Details
2024-03-21 11:49:48 +01:00
Lauréline Guérin 1f23f85b3d
export_import: redirect to compare view if compare in GET params (#87653) 2024-03-21 11:49:48 +01:00
Lauréline Guérin 393a20b87b
export_import: bundle-check endpoint (#87653) 2024-03-21 11:49:48 +01:00
Lauréline Guérin df0e356e75
export_import: snapshots on application import (#87653) 2024-03-21 10:10:48 +01:00
Frédéric Péters 07512150e8 api: limit export/import APIs to admin users (#88439)
gitea/chrono/pipeline/head This commit looks good Details
2024-03-21 09:50:18 +01:00
Lauréline Guérin 2576350aae
translation update
gitea/chrono/pipeline/head This commit looks good Details
2024-03-21 08:36:35 +01:00
Lauréline Guérin 2187bf3dde export_import: unknown component in urls (#88085)
gitea/chrono/pipeline/head This commit looks good Details
2024-03-21 08:31:19 +01:00
Lauréline Guérin 4add868dd9 agendas: fix import of incorrect ics file (#88090)
gitea/chrono/pipeline/head This commit looks good Details
2024-03-21 08:30:58 +01:00
Valentin Deniaud 9c19321fb9 tests: fix typo in partial bookings feature flag (#88098)
gitea/chrono/pipeline/head This commit looks good Details
2024-03-18 16:25:34 +01:00
Lauréline Guérin a88db00e04
misc: add pyquery in dependencies (#88222)
gitea/chrono/pipeline/head This commit looks good Details
2024-03-15 12:11:59 +01:00
Lauréline Guérin eecbb80809
translation update
gitea/chrono/pipeline/head This commit looks good Details
2024-03-15 10:40:59 +01:00
Lauréline Guérin e0f1d9541d
manager: prefill presence check form with unexpected presence (#88039)
gitea/chrono/pipeline/head This commit looks good Details
2024-03-15 08:38:14 +01:00
Lauréline Guérin 701733da57
misc: tests with --dist loadfile options (#87751)
gitea/chrono/pipeline/head This commit looks good Details
2024-03-11 16:15:34 +01:00
Lauréline Guérin d709fa9bc7
misc: verbose tests (#87751) 2024-03-11 16:15:34 +01:00
Lauréline Guérin 1d00c5fce8
snapshots: compare inspect (#87751) 2024-03-11 16:15:34 +01:00
Lauréline Guérin bd06f2b82f
manager: inspect views (#87751) 2024-03-08 14:14:04 +01:00
Lauréline Guérin ecf0ffd96e
agendas: fix permissions for agenda history views (#87751) 2024-03-07 16:45:37 +01:00
Lauréline Guérin 1b1bc13c82
agendas: fix snapshot on role update (#87751) 2024-03-07 16:45:37 +01:00
Lauréline Guérin 06ab6f12b7
agendas: export resources only for meetings agenda (#87751) 2024-03-07 16:45:37 +01:00
Lauréline Guérin 024b34b34f
agendas: object history and compare (#87316)
gitea/chrono/pipeline/head This commit looks good Details
2024-03-07 16:44:19 +01:00
Lauréline Guérin 0ea056dcd5
agendas: fix missing options in agenda import/export (#87679)
gitea/chrono/pipeline/head This commit looks good Details
2024-03-04 17:32:20 +01:00
Lauréline Guérin 3cef873ce4
export_import: fix event agenda dependencies (#87627)
gitea/chrono/pipeline/head This commit looks good Details
2024-02-29 15:44:34 +01:00
Lauréline Guérin 966d93829f
translation update
gitea/chrono/pipeline/head This commit looks good Details
2024-02-27 16:39:52 +01:00
Lauréline Guérin 03f9172c98
api: take snapshots (#87498)
gitea/chrono/pipeline/head This commit looks good Details
2024-02-27 15:33:48 +01:00
Lauréline Guérin 176d23aa4b
agendas: take snapshots (#86634)
gitea/chrono/pipeline/head This commit looks good Details
2024-02-27 15:33:14 +01:00
Lauréline Guérin 9331b06e04
snapshot: command to clear instances from snapshot (#86634) 2024-02-27 15:33:14 +01:00
Lauréline Guérin 3f8146c092
snapshot: init models (#86634) 2024-02-27 11:50:37 +01:00
Lauréline Guérin f6a0b58167
agendas: fix missing options in agenda import/export (#86634) 2024-02-27 11:50:37 +01:00
Lauréline Guérin e6db17f145
misc: move tests (#86634) 2024-02-27 11:50:36 +01:00
Lauréline Guérin 84581ed02e
misc: fix typos (#86634) 2024-02-27 11:50:36 +01:00
Lauréline Guérin 4f13f936e2
misc: fix missing migration (#86634) 2024-02-27 11:50:36 +01:00
Frédéric Péters 7df4de695d misc: use yield from (#87441)
gitea/chrono/pipeline/head This commit looks good Details
2024-02-25 19:16:27 +01:00
Yann Weber 095057839a tests: unpin pytest version (#86300)
gitea/chrono/pipeline/head This commit looks good Details
2024-02-15 10:38:18 +01:00
Frédéric Péters 69f9877ba5 translation update
gitea/chrono/pipeline/head This commit looks good Details
2024-02-01 09:50:22 +01:00
Lauréline Guérin 895758c70c
manager: display applications (#86148)
gitea/chrono/pipeline/head This commit looks good Details
2024-01-30 16:57:15 +01:00
Lauréline Guérin 3071fab8f8 manager: fix page-title-extra-label (#85941)
gitea/chrono/pipeline/head This commit looks good Details
2024-01-30 16:56:26 +01:00
Lauréline Guérin a4e5721dad manager: move buttons and links in sidebar (#85941) 2024-01-30 16:56:26 +01:00
Lauréline Guérin 068e5fe467 manager: fix base template and breadcrumb (#85941) 2024-01-30 16:56:26 +01:00
Yann Weber a36369ae1c manager: fix agenda's role edition when partial booking enabled (#85999)
gitea/chrono/pipeline/head This commit looks good Details
2024-01-30 15:27:35 +01:00
Yann Weber 3bfa450f97 notifications: move email recipients from To to Bcc (#81860)
gitea/chrono/pipeline/head This commit looks good Details
2024-01-30 15:18:29 +01:00
Yann Weber 917c918422 tests: pin pytest version to 7.4.4 (#86321)
gitea/chrono/pipeline/head This commit looks good Details
2024-01-30 15:13:21 +01:00
Frédéric Péters 9a1b37a5f7 translation update
gitea/chrono/pipeline/head This commit looks good Details
2024-01-23 14:21:30 +01:00
Frédéric Péters 5204fcda47 trivial: adjust spelling and typography (#85974) 2024-01-23 14:21:14 +01:00
Yann Weber 9945568a57 manager: add error when deleting an EventType linked to an Agenda (#85974)
gitea/chrono/pipeline/head This commit looks good Details
2024-01-23 14:14:46 +01:00
Yann Weber d428ef8385 agendas: change on_delete for Agenda -> EventsType to SET_NULL (#85974) 2024-01-23 14:14:46 +01:00
Frédéric Péters 9c660e7a1e misc: adjust title of meeting type deletion confirmation dialog (#85773)
gitea/chrono/pipeline/head This commit looks good Details
2024-01-18 20:26:38 +01:00
Yann Weber 47e7558298 manager: add __str__ to MeetingType, translating deletion popup (#85718)
gitea/chrono/pipeline/head This commit looks good Details
2024-01-17 11:23:18 +01:00
Yann Weber f2285f7880 api: add places_reserved field in booking API response (#84523)
gitea/chrono/pipeline/head This commit looks good Details
2024-01-17 09:56:00 +01:00
Benjamin Dauvergne 5a9379a7b8 api: allow modifying booking's data in waiting list (#85121)
gitea/chrono/pipeline/head This commit looks good Details
When presence informations are not modified.
2024-01-15 15:49:02 +01:00
Benjamin Dauvergne f749c5e9cb api: add explicit checks to DELETE /api/booking/<id>/ (#85121) 2024-01-15 15:49:02 +01:00
Benjamin Dauvergne f61d07f586 api: remove check on GET /api/booking/<id>/ (#85121) 2024-01-15 15:49:02 +01:00
Yann Weber 154fe0ccea test: add allowlist_externals for pylint.sh & getlasso3.sh (#85448)
gitea/chrono/pipeline/head This commit looks good Details
2024-01-15 11:43:57 +01:00
Yann Weber 14e7998895 api: add resize endpoint when reserving an event slot (#85190)
gitea/chrono/pipeline/head This commit looks good Details
2024-01-09 16:26:37 +01:00
Lauréline Guérin 8e35a25ad9
api: add adjusted values in cas of multi checks (#85088)
gitea/chrono/pipeline/head This commit looks good Details
2024-01-08 10:59:45 +01:00
Pierre Ducroquet 5db20c9434 views: do not use OR in join paths (#85107)
gitea/chrono/pipeline/head This commit looks good Details
2023-12-24 11:57:30 +01:00
Frédéric Péters eeca5783dd translation update
gitea/chrono/pipeline/head This commit looks good Details
2023-12-22 11:50:18 +01:00
Lauréline Guérin 3c052b467b export_import: add roles with minor=True (#85021)
gitea/chrono/pipeline/head This commit looks good Details
2023-12-22 11:42:23 +01:00
Frédéric Péters 888c0638d0 misc: increase allowed length for formdata related URLs (#85048)
gitea/chrono/pipeline/head This commit looks good Details
2023-12-22 09:47:54 +01:00
Lauréline Guérin 05aa65e72a export_import: complete redirect view for all components (#85010)
gitea/chrono/pipeline/head This commit looks good Details
2023-12-21 15:00:33 +01:00
Valentin Deniaud e83bfee4c3 setup: allow django-filter 23.1 (#82023)
gitea/chrono/pipeline/head This commit looks good Details
2023-12-18 11:09:39 +01:00
Valentin Deniaud 7bea1c912b translation update
gitea/chrono/pipeline/head This commit looks good Details
2023-12-18 10:09:29 +01:00
Valentin Deniaud 526f255ee5 manager: add styles and improve a11y for occupation rate graph (#78083)
gitea/chrono/pipeline/head This commit looks good Details
2023-12-18 10:05:49 +01:00
Valentin Deniaud 1740ebe572 manager: display occupation rate in partial bookings day view (#78083) 2023-12-18 10:05:49 +01:00
Lauréline Guérin 698bbfc7a4 manager: filter timesheet by booking status (#84260)
gitea/chrono/pipeline/head This commit looks good Details
2023-12-18 09:43:45 +01:00
Valentin Deniaud d02210ab66 api: add endpoint to check partial bookings (#84122)
gitea/chrono/pipeline/head This commit looks good Details
2023-12-18 09:41:20 +01:00
Nicolas Roche c4ecd1900a misc: remove copyright line from footer (#84813)
gitea/chrono/pipeline/head This commit looks good Details
2023-12-15 17:49:59 +01:00
Valentin Deniaud 7096938cda translation update
gitea/chrono/pipeline/head This commit looks good Details
2023-12-07 17:50:52 +01:00
Lauréline Guérin ee557adbcc
manager: filter partial bookings periods in day view (#84417)
gitea/chrono/pipeline/head This commit looks good Details
2023-12-07 15:54:25 +01:00
Valentin Deniaud ce96e674c2 manager: differentiate occasional partial bookings (#84140)
gitea/chrono/pipeline/head This commit looks good Details
2023-12-06 11:00:40 +01:00
Valentin Deniaud 5501b88c34 api: allow creating partial bookings agenda (#84121)
gitea/chrono/pipeline/head This commit looks good Details
2023-12-04 17:03:36 +01:00
Valentin Deniaud 440d02d505 api: forbid partial booking check outside of opening hours (#84211) 2023-12-04 17:03:32 +01:00
Valentin Deniaud f8748710bc manager: forbid partial booking check outside of opening hours (#84211) 2023-12-04 17:03:32 +01:00
Valentin Deniaud 6804b08cc6 manager: hide incomplete checks in partial bookings month view (#84124)
gitea/chrono/pipeline/head Build queued... Details
2023-12-04 14:49:52 +01:00
Benjamin Dauvergne aad10c71ee translation update
gitea/chrono/pipeline/head This commit looks good Details
2023-11-28 12:32:12 +01:00
Benjamin Dauvergne 2831272e56 manager: display placeholder for leased bookings (#82774) 2023-11-28 12:32:12 +01:00
Valentin Deniaud 14b7de35cc translation update
gitea/chrono/pipeline/head This commit looks good Details
2023-11-27 17:43:25 +01:00
Valentin Deniaud faccc579c5 manager: avoid crash in partial bookings month view if multiple checks (#82234)
gitea/chrono/pipeline/head This commit looks good Details
2023-11-27 15:24:54 +01:00
Valentin Deniaud 7182871b9f agendas: always order partial booking checks (#83544)
gitea/chrono/pipeline/head Build queued... Details
2023-11-27 15:24:32 +01:00
Valentin Deniaud 46e6fbcf5b agendas: forbid having more than two checks on booking (#83544) 2023-11-27 15:24:32 +01:00
Valentin Deniaud d9a93ac2e3 manager: forbid second partial booking check with same status (#83505)
gitea/chrono/pipeline/head This commit looks good Details
2023-11-27 15:24:08 +01:00
Valentin Deniaud 21cd345c35 agendas: do no send reminders for secondary bookings (#83861)
gitea/chrono/pipeline/head This commit looks good Details
2023-11-27 15:22:10 +01:00
Valentin Deniaud 3161f47cd1 manager: detect partial booking checks overlap using only form data (#82231)
gitea/chrono/pipeline/head This commit looks good Details
2023-11-27 15:13:23 +01:00
Valentin Deniaud b7c5d4f675 manager: regroup partial booking check operations in one form (#82231) 2023-11-27 15:13:23 +01:00
Valentin Deniaud 8a8bea24a6 manager: change partial booking check deletion UI (#82231) 2023-11-27 14:56:19 +01:00
Thomas Jund 9b27620a89 manager: add css for partial bookings with only start or end checking (#80047)
gitea/chrono/pipeline/head This commit looks good Details
2023-11-27 14:52:25 +01:00
Valentin Deniaud c4540c245a manager: allow separate arrival/departure check for partial bookings (#80047) 2023-11-27 14:52:25 +01:00
Valentin Deniaud db57ef6cf7 manager: display ids of guardians in shared custody agenda settings (#82957)
gitea/chrono/pipeline/head This commit looks good Details
2023-11-27 10:03:52 +01:00
Valentin Deniaud 0afa7b9244 ci: lift pylint limit to catch 3.0 (#82169)
gitea/chrono/pipeline/head This commit looks good Details
2023-11-22 13:46:59 +01:00
Thomas Jund 3e478042f6 manager: add hour indicator to partial booking today view (#80043)
gitea/chrono/pipeline/head This commit looks good Details
2023-11-16 13:04:57 +01:00
Benjamin Dauvergne aff03ffdea translation update
gitea/chrono/pipeline/head This commit looks good Details
2023-11-16 12:14:59 +01:00
Benjamin Dauvergne 0543594e30 ants_hub: proxy check-duplicate requests (#81229)
gitea/chrono/pipeline/head This commit looks good Details
To prevent having to configure the HUB URL and credentials in w.c.s.
2023-11-16 12:04:43 +01:00
Benjamin Dauvergne 7fab4c0f41 translation update
gitea/chrono/pipeline/head This commit looks good Details
2023-11-16 10:59:31 +01:00
Benjamin Dauvergne 5716d6b3dc ants_hub: do not synchronize locked meetings (#80489)
gitea/chrono/pipeline/head This commit looks good Details
2023-11-16 10:44:59 +01:00
Benjamin Dauvergne eafa816253 implement locking for event's agendas (#80489)
* add code to clean event's agendas lease/bookings
* add annotation helper method annotate_queryset_for_lock_code() to
  compute corrects places statistics given a lock_code (excluding
  bookings linked to this lock_code)
* use annotate_queryset_for_lock_code() in Datetimes and
  MultipleAgendasDatetimes
* make event's fillslot method completely atomic and add mechanic for
  handling the lock code
* removed handling of IntegrityError which cannot happen for events
* lock_code is for now not supported with RecurringFillslots
2023-11-16 10:40:35 +01:00
Benjamin Dauvergne d6a5861876 implement locking for meeting's agendas (#17685)
* add a Lease model to associate a lock_code to a booking,
* add a new command "clean_leases" run by cron every 5 minutes to clean
  expired leases,
* add new parameter lock_code to get_all_slots() and exclude conflicting
  booking linked to this lock_code if provided,
* accept new lock_code query string parameter in the datetimes endpoints
  (to see available slot minus the locked ones, if the user want to
  change the chosen slot)
* add new parameters lock_code and confirm_after_lock to the fillslot
  endpoint:
  - when lock_code is used without confirm_after_lock:
    1. look for available slots excluding events/booking pairs associated with the given lock_code, by passing lock_code to get_all_slots
    2. before creating the new event/booking pair, clean existing pairs
       associated to the lock code,
    3. after creating the new pair, create a new Lease object with the
       lock code
  - when lock_code is used with confirm_after_lock do all previous steps
    but 3., making a normal meeting booking.
* add tests with lock_code on meeting's datetimes and fillslot use,
  checking exclusion by resources or user_id works with lock_code
2023-11-16 10:37:00 +01:00
Benjamin Dauvergne 2d8912c0a3 agendas: add property for datetimes API url (#80489)
To simplify using datetimes URLs in tests.
2023-11-16 10:23:06 +01:00
Lauréline Guérin 3dac9ed0fb
api: set request_uuid and previous_state on bookings (#83098)
gitea/chrono/pipeline/head This commit looks good Details
2023-11-16 09:19:30 +01:00
Lauréline Guérin 63a575f303
api: revert endpoint (#83098) 2023-11-16 09:19:30 +01:00
Lauréline Guérin 678ac6c1de
agendas: new fields in Booking model (#83098) 2023-11-16 09:19:30 +01:00
Thomas NOËL 6a411b1859 debian: add back memory-report to uwsgi default configuration (#80451)
gitea/chrono/pipeline/head This commit looks good Details
2023-11-13 11:32:57 +01:00
Lauréline Guérin 4291cc73db
api: iter MultipleAgendasEventsCheckStatus on user_checks (#82849)
gitea/chrono/pipeline/head This commit looks good Details
2023-11-10 08:38:05 +01:00
Lauréline Guérin e4864ea95b agendas: partial bookings, compute double booking check (#82848)
gitea/chrono/pipeline/head This commit looks good Details
2023-11-10 08:37:09 +01:00
Lauréline Guérin 9d1c33970c agendas: refresh_computed_times on booking (#82848) 2023-11-10 08:37:09 +01:00
166 changed files with 12403 additions and 1329 deletions

2
Jenkinsfile vendored
View File

@ -6,7 +6,7 @@ pipeline {
stages {
stage('Unit Tests') {
steps {
sh 'tox -rv -- --numprocesses 3'
sh 'NUMPROCESSES=3 tox -rv'
}
post {
always {

View File

@ -9,6 +9,7 @@ recursive-include chrono/api/templates *.html
recursive-include chrono/agendas/templates *.html *.txt
recursive-include chrono/manager/templates *.html *.txt
recursive-include chrono/apps/ants_hub/templates *.html
recursive-include chrono/apps/journal/templates *.html
# sql (migrations)
recursive-include chrono/agendas/sql *.sql

View File

@ -0,0 +1,26 @@
# chrono - agendas system
# Copyright (C) 2020 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.core.management.base import BaseCommand
from chrono.agendas.models import Lease
class Command(BaseCommand):
help = 'Clean expired leases and related bookings and events'
def handle(self, **options):
Lease.clean()

View File

@ -59,6 +59,7 @@ class Command(BaseCommand):
event__start_datetime__lte=starts_before,
event__start_datetime__gte=starts_after,
in_waiting_list=False,
primary_booking__isnull=True,
**{f'{msg_type}_reminder_datetime__isnull': True},
).select_related('event', 'event__agenda', 'event__agenda__reminder_settings')

View File

@ -18,7 +18,7 @@ import copy
from urllib.parse import urljoin
from django.conf import settings
from django.core.mail import send_mail
from django.core.mail import EmailMultiAlternatives
from django.core.management.base import BaseCommand
from django.db.transaction import atomic
from django.template.loader import render_to_string
@ -72,4 +72,12 @@ class Command(BaseCommand):
with atomic():
setattr(event, status + '_notification_timestamp', timestamp)
event.save()
send_mail(subject, body, settings.DEFAULT_FROM_EMAIL, recipients, html_message=html_body)
mail_msg = EmailMultiAlternatives(
subject=subject,
body=body,
from_email=settings.DEFAULT_FROM_EMAIL,
to=[settings.DEFAULT_FROM_EMAIL],
bcc=recipients,
)
mail_msg.attach_alternative(html_body, 'text/html')
mail_msg.send()

View File

@ -11,7 +11,7 @@ class Migration(migrations.Migration):
operations = [
migrations.AlterModelOptions(
name='meetingtype',
options={'ordering': ['duration', 'label']},
options={'ordering': ['duration', 'label'], 'verbose_name': 'Meeting type'},
),
migrations.AddField(
model_name='timeperiodexception',

View File

@ -17,6 +17,6 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='event',
name='url',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='URL'),
field=models.URLField(blank=True, null=True, verbose_name='URL'),
),
]

View File

@ -10,6 +10,6 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='agenda',
name='desk_simple_management',
field=models.BooleanField(default=False),
field=models.BooleanField(default=False, verbose_name='Global desk management'),
),
]

View File

@ -18,8 +18,8 @@ class Migration(migrations.Migration):
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
('presence', models.BooleanField()),
('start_time', models.TimeField(null=True, verbose_name='Arrival')),
('end_time', models.TimeField(null=True, verbose_name='Departure')),
('start_time', models.TimeField(null=True, blank=True, verbose_name='Arrival')),
('end_time', models.TimeField(null=True, blank=True, verbose_name='Departure')),
('computed_end_time', models.TimeField(null=True)),
('computed_start_time', models.TimeField(null=True)),
('type_slug', models.CharField(blank=True, max_length=160, null=True)),
@ -33,5 +33,8 @@ class Migration(migrations.Migration):
),
),
],
options={
'ordering': ['start_time'],
},
),
]

View File

@ -0,0 +1,20 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('agendas', '0164_alter_bookingcheck_booking'),
]
operations = [
migrations.AddField(
model_name='booking',
name='previous_state',
field=models.CharField(max_length=10, null=True),
),
migrations.AddField(
model_name='booking',
name='request_uuid',
field=models.UUIDField(editable=False, null=True),
),
]

View File

@ -0,0 +1,41 @@
# Generated by Django 3.2.18 on 2023-08-22 08:19
import django.db.models.deletion
from django.db import migrations, models
from chrono.agendas.models import get_lease_expiration
class Migration(migrations.Migration):
dependencies = [
('agendas', '0165_booking_revert'),
]
operations = [
migrations.CreateModel(
name='Lease',
fields=[
(
'id',
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
('lock_code', models.CharField(max_length=64, verbose_name='Lock code')),
(
'expiration_datetime',
models.DateTimeField(verbose_name='Lease expiration time', default=get_lease_expiration),
),
(
'booking',
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
to='agendas.booking',
verbose_name='Booking',
),
),
],
options={
'verbose_name': 'Lease',
'verbose_name_plural': 'Leases',
},
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.2.21 on 2023-11-22 09:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('agendas', '0166_lease'),
]
operations = [
migrations.AddConstraint(
model_name='bookingcheck',
constraint=models.UniqueConstraint(
fields=('booking', 'presence'), name='max_2_checks_on_booking'
),
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 3.2.21 on 2023-12-06 09:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('agendas', '0167_bookingcheck_max_2_checks_on_booking'),
]
operations = [
migrations.AddField(
model_name='booking',
name='from_recurring_fillslots',
field=models.BooleanField(default=False),
),
]

View File

@ -0,0 +1,37 @@
# Generated by Django 3.2.16 on 2023-12-22 08:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('agendas', '0168_booking_from_recurring_fillslots'),
]
operations = [
migrations.AlterField(
model_name='booking',
name='absence_callback_url',
field=models.URLField(blank=True, max_length=500),
),
migrations.AlterField(
model_name='booking',
name='backoffice_url',
field=models.URLField(blank=True, max_length=500),
),
migrations.AlterField(
model_name='booking',
name='cancel_callback_url',
field=models.URLField(blank=True, max_length=500),
),
migrations.AlterField(
model_name='booking',
name='form_url',
field=models.URLField(blank=True, max_length=500),
),
migrations.AlterField(
model_name='booking',
name='presence_callback_url',
field=models.URLField(blank=True, max_length=500),
),
]

View File

@ -0,0 +1,25 @@
# Generated by Django 3.2.18 on 2024-01-22 10:55
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('agendas', '0169_urlfield_maxlength_increase'),
]
operations = [
migrations.AlterField(
model_name='agenda',
name='events_type',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='agendas',
to='agendas.eventstype',
verbose_name='Events type',
),
),
]

View File

@ -0,0 +1,118 @@
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('snapshot', '0002_snapshot_models'),
('agendas', '0170_alter_agenda_events_type'),
]
operations = [
migrations.AddField(
model_name='agenda',
name='created_at',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='agenda',
name='snapshot',
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='temporary_instance',
to='snapshot.agendasnapshot',
),
),
migrations.AddField(
model_name='agenda',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='category',
name='created_at',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='category',
name='snapshot',
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='temporary_instance',
to='snapshot.categorysnapshot',
),
),
migrations.AddField(
model_name='category',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='eventstype',
name='created_at',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='eventstype',
name='snapshot',
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='temporary_instance',
to='snapshot.eventstypesnapshot',
),
),
migrations.AddField(
model_name='eventstype',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='resource',
name='created_at',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='resource',
name='snapshot',
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='temporary_instance',
to='snapshot.resourcesnapshot',
),
),
migrations.AddField(
model_name='resource',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='unavailabilitycalendar',
name='created_at',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='unavailabilitycalendar',
name='snapshot',
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='temporary_instance',
to='snapshot.unavailabilitycalendarsnapshot',
),
),
migrations.AddField(
model_name='unavailabilitycalendar',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
]

File diff suppressed because it is too large Load Diff

View File

@ -89,11 +89,11 @@ class FillSlotSerializer(serializers.Serializer):
exclude_user = serializers.BooleanField(default=False)
events = serializers.CharField(max_length=16, allow_blank=True)
bypass_delays = serializers.BooleanField(default=False)
form_url = serializers.CharField(max_length=250, allow_blank=True)
backoffice_url = serializers.URLField(allow_blank=True)
cancel_callback_url = serializers.URLField(allow_blank=True)
presence_callback_url = serializers.URLField(allow_blank=True)
absence_callback_url = serializers.URLField(allow_blank=True)
form_url = serializers.CharField(max_length=500, allow_blank=True)
backoffice_url = serializers.URLField(allow_blank=True, max_length=500)
cancel_callback_url = serializers.URLField(allow_blank=True, max_length=500)
presence_callback_url = serializers.URLField(allow_blank=True, max_length=500)
absence_callback_url = serializers.URLField(allow_blank=True, max_length=500)
count = serializers.IntegerField(min_value=1)
cancel_booking_id = serializers.CharField(max_length=250, allow_blank=True, allow_null=True)
force_waiting_list = serializers.BooleanField(default=False)
@ -107,6 +107,8 @@ class FillSlotSerializer(serializers.Serializer):
check_overlaps = serializers.BooleanField(default=False)
start_time = serializers.TimeField(required=False, allow_null=True)
end_time = serializers.TimeField(required=False, allow_null=True)
lock_code = serializers.CharField(max_length=64, required=False, allow_blank=False, trim_whitespace=True)
confirm_after_lock = serializers.BooleanField(default=False)
def validate(self, attrs):
super().validate(attrs)
@ -323,6 +325,10 @@ class BookingSerializer(serializers.ModelSerializer):
'cancellation_datetime',
]
def __init__(self, *args, **kwargs):
self.user_check = kwargs.pop('user_check', None)
super().__init__(*args, **kwargs)
def to_internal_value(self, data):
if 'color' in data:
# legacy
@ -341,20 +347,34 @@ class BookingSerializer(serializers.ModelSerializer):
ret.pop('user_absence_reason', None)
ret.pop('user_presence_reason', None)
else:
user_was_present = self.instance.user_check.presence if self.instance.user_check else None
user_was_present = self.user_check.presence if self.user_check else None
ret['user_was_present'] = user_was_present
ret['user_absence_reason'] = (
self.instance.user_check.type_slug if user_was_present is False else None
)
ret['user_presence_reason'] = (
self.instance.user_check.type_slug if user_was_present is True else None
)
ret['user_absence_reason'] = self.user_check.type_slug if user_was_present is False else None
ret['user_presence_reason'] = self.user_check.type_slug if user_was_present is True else None
if self.instance.event.agenda.kind == 'events' and self.instance.event.agenda.partial_bookings:
self.instance.user_check_start_time = self.instance.user_check.start_time
self.instance.user_check_end_time = self.instance.user_check.end_time
self.instance.computed_start_time = self.instance.user_check.computed_start_time
self.instance.computed_end_time = self.instance.user_check.computed_end_time
for key in ['', 'user_check_', 'computed_']:
self.instance.user_check_start_time = self.user_check.start_time if self.user_check else None
self.instance.user_check_end_time = self.user_check.end_time if self.user_check else None
self.instance.computed_start_time = (
self.user_check.computed_start_time if self.user_check else None
)
self.instance.computed_end_time = self.user_check.computed_end_time if self.user_check else None
# adjust start_time (in case of multi checks)
self.instance.adjusted_start_time = self.instance.start_time
if (
self.instance.start_time
and self.instance.computed_start_time
and self.instance.start_time < self.instance.computed_start_time
):
self.instance.adjusted_start_time = self.instance.computed_start_time
# and end_time
self.instance.adjusted_end_time = self.instance.end_time
if (
self.instance.end_time
and self.instance.computed_end_time
and self.instance.end_time > self.instance.computed_end_time
):
self.instance.adjusted_end_time = self.instance.computed_end_time
for key in ['', 'user_check_', 'computed_', 'adjusted_']:
start_key, end_key, minutes_key = (
'%sstart_time' % key,
'%send_time' % key,
@ -426,6 +446,11 @@ class ResizeSerializer(serializers.Serializer):
count = serializers.IntegerField(min_value=1)
class PartialBookingsCheckSerializer(serializers.Serializer):
user_external_id = serializers.CharField(max_length=250, allow_blank=True)
timestamp = serializers.DateTimeField(input_formats=['iso-8601', '%Y-%m-%d'])
class StatisticsFiltersSerializer(serializers.Serializer):
time_interval = serializers.ChoiceField(choices=('day', _('Day')), default='day')
start = serializers.DateTimeField(required=False, input_formats=['iso-8601', '%Y-%m-%d'])
@ -442,11 +467,13 @@ class DateRangeSerializer(DateRangeMixin, serializers.Serializer):
class DatetimesSerializer(DateRangeSerializer):
min_places = serializers.IntegerField(min_value=1, default=1)
max_places = serializers.IntegerField(min_value=1, default=None)
user_external_id = serializers.CharField(required=False, max_length=250, allow_blank=True)
exclude_user_external_id = serializers.CharField(required=False, max_length=250, allow_blank=True)
events = serializers.CharField(required=False, max_length=32, allow_blank=True)
hide_disabled = serializers.BooleanField(default=False)
bypass_delays = serializers.BooleanField(default=False)
lock_code = serializers.CharField(max_length=64, required=False, allow_blank=False, trim_whitespace=True)
def validate(self, attrs):
super().validate(attrs)
@ -663,6 +690,7 @@ class AgendaSerializer(serializers.ModelSerializer):
'slug',
'label',
'kind',
'partial_bookings',
'minimal_booking_delay',
'minimal_booking_delay_in_working_days',
'maximal_booking_delay',
@ -713,6 +741,10 @@ class AgendaSerializer(serializers.ModelSerializer):
)
if attrs.get('events_type') and attrs.get('kind', 'events') != 'events':
raise ValidationError({'events_type': _('Option not available on %s agenda') % attrs['kind']})
if attrs.get('partial_bookings') and attrs.get('kind', 'events') != 'events':
raise ValidationError(
{'partial_bookings': _('Option not available on %s agenda') % attrs['kind']}
)
return attrs

View File

@ -14,7 +14,7 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.urls import path, re_path
from django.urls import include, path, re_path
from . import views
@ -38,6 +38,11 @@ urlpatterns = [
views.agendas_events_fillslots,
name='api-agendas-events-fillslots',
),
path(
'agendas/events/fillslots/<uuid:request_uuid>/revert/',
views.agendas_events_fillslots_revert,
name='api-agendas-events-fillslots-revert',
),
path(
'agendas/events/check-status/',
views.agendas_events_check_status,
@ -128,6 +133,11 @@ urlpatterns = [
views.subscription,
name='api-agenda-subscription',
),
path(
'agenda/<slug:agenda_identifier>/partial-bookings-check/',
views.partial_bookings_check,
name='api-partial-bookings-check',
),
path('bookings/', views.bookings, name='api-bookings'),
path('bookings/ics/', views.bookings_ics, name='api-bookings-ics'),
path('booking/<int:booking_pk>/', views.booking, name='api-booking'),
@ -145,4 +155,6 @@ urlpatterns = [
),
path('statistics/', views.statistics_list, name='api-statistics-list'),
path('statistics/bookings/', views.bookings_statistics, name='api-statistics-bookings'),
path('ants/', include('chrono.apps.ants_hub.api_urls')),
path('user-preferences/', include('chrono.apps.user_preferences.api_urls')),
]

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,23 @@
# chrono - agendas system
# Copyright (C) 2023 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.urls import path
from . import views
urlpatterns = [
path('check-duplicate/', views.CheckDuplicateAPI.as_view(), name='api-ants-check-duplicate'),
]

View File

@ -16,6 +16,7 @@
import requests
from django.conf import settings
from django.utils.translation import gettext as _
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
@ -71,3 +72,17 @@ def push_rendez_vous_disponibles(payload):
return True
except (TypeError, KeyError, requests.RequestException) as e:
raise AntsHubException(str(e))
def check_duplicate(identifiants_predemande: list):
params = [
('identifiant_predemande', identifiant_predemande)
for identifiant_predemande in identifiants_predemande
]
session = make_http_session()
try:
response = session.get(make_url('rdv-status/'), params=params)
response.raise_for_status()
return response.json()
except (ValueError, requests.RequestException) as e:
return {'err': 1, 'err_desc': f'ANTS hub is unavailable: {e!r}'}

View File

@ -208,6 +208,7 @@ class Place(models.Model):
'annulation_url': self.cancel_url,
'plages': list(self.iter_open_dates()),
'rdvs': list(self.iter_predemandes()),
'logo_url': self.logo_url,
}
return payload
@ -224,6 +225,7 @@ class Place(models.Model):
event__desk__agenda__in=agendas,
event__start_datetime__gt=now(),
extra_data__ants_identifiant_predemande__isnull=False,
lease__isnull=True,
)
.exclude(extra_data__ants_identifiant_predemande='')
.values_list(

View File

@ -14,14 +14,21 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import re
import sys
from django import forms
from django.conf import settings
from django.contrib import messages
from django.core.cache import cache
from django.shortcuts import get_object_or_404, redirect
from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext_noop as N_
from django.views.generic import CreateView, DeleteView, ListView, TemplateView, UpdateView
from rest_framework import permissions
from rest_framework.views import APIView
from chrono.api.utils import APIErrorBadRequest, Response
from . import hub, models
@ -249,3 +256,33 @@ class Synchronize(TemplateView):
ants_hub_city_push.spool(domain=getattr(tenant, 'domain_url', None))
else:
models.City.push()
class CheckDuplicateAPI(APIView):
permission_classes = (permissions.IsAuthenticated,)
identifiant_predemande_re = re.compile(r'^[A-Z0-9]{10}$')
def post(self, request):
if not settings.CHRONO_ANTS_HUB_URL:
raise APIErrorBadRequest(N_('CHRONO_ANTS_HUB_URL is not configured'))
data = request.data if isinstance(request.data, dict) else {}
identifiant_predemande = data.get('identifiant_predemande', request.GET.get('identifiant_predemande'))
identifiants_predemande = identifiant_predemande or []
if isinstance(identifiants_predemande, str):
identifiants_predemande = identifiants_predemande.split(',')
if not isinstance(identifiants_predemande, list):
raise APIErrorBadRequest(
N_('identifiant_predemande must be a list of identifiants separated by commas: %s'),
repr(identifiants_predemande),
)
identifiants_predemande = list(filter(None, map(str.upper, map(str.strip, identifiants_predemande))))
if not identifiants_predemande:
return Response({'err': 0, 'data': {'accept_rdv': True}})
return Response(hub.check_duplicate(identifiants_predemande))

View File

@ -14,19 +14,21 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import io
import json
import tarfile
from django.contrib.auth.models import Group
from django.contrib.contenttypes.models import ContentType
from django.http import Http404
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from rest_framework import permissions
from rest_framework.generics import GenericAPIView
from rest_framework.response import Response
from chrono.agendas.models import Agenda, Category, EventsType, Resource, UnavailabilityCalendar
from chrono.api.utils import APIErrorBadRequest
from chrono.apps.export_import.models import Application, ApplicationElement
from chrono.manager.utils import import_site
@ -34,18 +36,52 @@ klasses = {
klass.application_component_type: klass
for klass in [Agenda, Category, EventsType, Resource, UnavailabilityCalendar]
}
klasses['roles'] = Group
klasses_translation = {
'agendas_categories': 'categories', # categories type is already used in wcs for FormDef Category
}
klasses_translation_reverse = {v: k for k, v in klasses_translation.items()}
compare_urls = {
'agendas': 'chrono-manager-agenda-history-compare',
'categories': 'chrono-manager-category-history-compare',
'events_types': 'chrono-manager-events-type-history-compare',
'resources': 'chrono-manager-resource-history-compare',
'unavailability_calendars': 'chrono-manager-unavailability-calendar-history-compare',
}
def get_klass_from_component_type(component_type):
try:
return klasses[component_type]
except KeyError:
raise Http404
class Index(GenericAPIView):
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
permission_classes = (permissions.IsAdminUser,)
def get(self, request, *args, **kwargs):
data = []
for klass in klasses.values():
if klass == Group:
data.append(
{
'id': 'roles',
'text': _('Roles'),
'singular': _('Role'),
'urls': {
'list': request.build_absolute_uri(
reverse(
'api-export-import-components-list',
kwargs={'component_type': 'roles'},
)
),
},
'minor': True,
}
)
continue
component_type = {
'id': klass.application_component_type,
'text': klass.application_label_plural,
@ -70,6 +106,16 @@ index = Index.as_view()
def get_component_bundle_entry(request, component):
if isinstance(component, Group):
return {
'id': component.role.slug if hasattr(component, 'role') else component.id,
'text': component.name,
'type': 'roles',
'urls': {},
# include uuid in object reference, this is not used for applification API but is useful
# for authentic creating its role summary page.
'uuid': component.role.uuid if hasattr(component, 'role') else None,
}
return {
'id': str(component.slug),
'text': component.label,
@ -107,11 +153,14 @@ def get_component_bundle_entry(request, component):
class ListComponents(GenericAPIView):
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
permission_classes = (permissions.IsAdminUser,)
def get(self, request, *args, **kwargs):
klass = klasses[kwargs['component_type']]
response = [get_component_bundle_entry(request, x) for x in klass.objects.order_by('slug')]
klass = get_klass_from_component_type(kwargs['component_type'])
order_by = 'slug'
if klass == Group:
order_by = 'name'
response = [get_component_bundle_entry(request, x) for x in klass.objects.order_by(order_by)]
return Response({'data': response})
@ -119,11 +168,11 @@ list_components = ListComponents.as_view()
class ExportComponent(GenericAPIView):
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
permission_classes = (permissions.IsAdminUser,)
def get(self, request, slug, *args, **kwargs):
klass = klasses[kwargs['component_type']]
serialisation = klass.objects.get(slug=slug).export_json()
klass = get_klass_from_component_type(kwargs['component_type'])
serialisation = get_object_or_404(klass, slug=slug).export_json()
return Response({'data': serialisation})
@ -131,23 +180,13 @@ export_component = ExportComponent.as_view()
class ComponentDependencies(GenericAPIView):
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
permission_classes = (permissions.IsAdminUser,)
def get(self, request, slug, *args, **kwargs):
klass = klasses[kwargs['component_type']]
component = klass.objects.get(slug=slug)
klass = get_klass_from_component_type(kwargs['component_type'])
component = get_object_or_404(klass, slug=slug)
def dependency_dict(element):
if isinstance(element, Group):
return {
'id': element.role.slug if hasattr(element, 'role') else element.id,
'text': element.name,
'type': 'roles',
'urls': {},
# include uuid in object reference, this is not used for applification API but is useful
# for authentic creating its role summary page.
'uuid': element.role.uuid if hasattr(element, 'role') else None,
}
return get_component_bundle_entry(request, element)
dependencies = [dependency_dict(x) for x in component.get_dependencies() if x]
@ -158,49 +197,200 @@ component_dependencies = ComponentDependencies.as_view()
def component_redirect(request, component_type, slug):
klass = klasses[component_type]
klass = get_klass_from_component_type(component_type)
component = get_object_or_404(klass, slug=slug)
if component_type not in klasses or component_type == 'roles':
raise Http404
if (
'compare' in request.GET
and request.GET.get('application')
and request.GET.get('version1')
and request.GET.get('version2')
):
component_type = klasses_translation.get(component_type, component_type)
return redirect(
'%s?version1=%s&version2=%s&application=%s'
% (
reverse(compare_urls[component_type], args=[component.pk]),
request.GET['version1'],
request.GET['version2'],
request.GET['application'],
)
)
if klass == Agenda:
return redirect(reverse('chrono-manager-agenda-view', kwargs={'pk': component.pk}))
if klass == Category:
return redirect(reverse('chrono-manager-category-list'))
if klass == EventsType:
return redirect(reverse('chrono-manager-events-type-list'))
if klass == Resource:
return redirect(reverse('chrono-manager-resource-view', kwargs={'pk': component.pk}))
if klass == UnavailabilityCalendar:
return redirect(reverse('chrono-manager-unavailability-calendar-view', kwargs={'pk': component.pk}))
raise Http404
class BundleCheck(GenericAPIView):
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
permission_classes = (permissions.IsAdminUser,)
def put(self, request, *args, **kwargs):
return Response({'err': 0, 'data': {}})
def post(self, request, *args, **kwargs):
bundle = request.FILES['bundle']
try:
with tarfile.open(fileobj=bundle) as tar:
try:
manifest = json.loads(tar.extractfile('manifest.json').read().decode())
except KeyError:
raise APIErrorBadRequest(_('Invalid tar file, missing manifest'))
application_slug = manifest.get('slug')
application_version = manifest.get('version_number')
if not application_slug or not application_version:
return Response({'data': {}})
differences = []
unknown_elements = []
no_history_elements = []
legacy_elements = []
content_types = ContentType.objects.get_for_models(
*[v for k, v in klasses.items() if k != 'roles']
)
for element in manifest.get('elements'):
component_type = element['type']
if component_type not in klasses or component_type == 'roles':
continue
klass = klasses[component_type]
component_type = klasses_translation.get(component_type, component_type)
try:
component = klass.objects.get(slug=element['slug'])
except klass.DoesNotExist:
unknown_elements.append(
{
'type': component_type,
'slug': element['slug'],
}
)
continue
elements_qs = ApplicationElement.objects.filter(
application__slug=application_slug,
content_type=content_types[klass],
object_id=component.pk,
)
if not elements_qs.exists():
# object exists, but not linked to the application
legacy_elements.append(
{
'type': component.application_component_type,
'slug': str(component.slug),
# information needed here, Relation objects may not exist yet in hobo
'text': component.label,
'url': reverse(
'api-export-import-component-redirect',
kwargs={
'slug': str(component.slug),
'component_type': component.application_component_type,
},
),
}
)
continue
snapshot_for_app = (
klass.get_snapshot_model()
.objects.filter(
instance=component,
application_slug=application_slug,
application_version=application_version,
)
.order_by('timestamp')
.last()
)
if not snapshot_for_app:
# no snapshot for this bundle
no_history_elements.append(
{
'type': element['type'],
'slug': element['slug'],
}
)
continue
last_snapshot = (
klass.get_snapshot_model().objects.filter(instance=component).latest('timestamp')
)
if snapshot_for_app.pk != last_snapshot.pk:
differences.append(
{
'type': element['type'],
'slug': element['slug'],
'url': '%s?version1=%s&version2=%s'
% (
request.build_absolute_uri(
reverse(compare_urls[component_type], args=[component.pk])
),
snapshot_for_app.pk,
last_snapshot.pk,
),
}
)
except tarfile.TarError:
raise APIErrorBadRequest(_('Invalid tar file'))
return Response(
{
'data': {
'differences': differences,
'unknown_elements': unknown_elements,
'no_history_elements': no_history_elements,
'legacy_elements': legacy_elements,
}
}
)
bundle_check = BundleCheck.as_view()
class BundleImport(GenericAPIView):
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
permission_classes = (permissions.IsAdminUser,)
install = True
def put(self, request, *args, **kwargs):
tar_io = io.BytesIO(request.read())
def post(self, request, *args, **kwargs):
bundle = request.FILES['bundle']
components = {}
with tarfile.open(fileobj=tar_io) as tar:
manifest = json.loads(tar.extractfile('manifest.json').read().decode())
self.application = Application.update_or_create_from_manifest(
manifest,
tar,
editable=not self.install,
)
for element in manifest.get('elements'):
component_type = element['type']
if component_type not in klasses or element['type'] == 'roles':
continue
component_type = klasses_translation.get(component_type, component_type)
if component_type not in components:
components[component_type] = []
component_content = (
tar.extractfile('%s/%s' % (element['type'], element['slug'])).read().decode()
try:
with tarfile.open(fileobj=bundle) as tar:
try:
manifest = json.loads(tar.extractfile('manifest.json').read().decode())
except KeyError:
raise APIErrorBadRequest(_('Invalid tar file, missing manifest'))
self.application = Application.update_or_create_from_manifest(
manifest,
tar,
editable=not self.install,
)
components[component_type].append(json.loads(component_content).get('data'))
for element in manifest.get('elements'):
component_type = element['type']
if component_type not in klasses or element['type'] == 'roles':
continue
component_type = klasses_translation.get(component_type, component_type)
if component_type not in components:
components[component_type] = []
try:
component_content = (
tar.extractfile('%s/%s' % (element['type'], element['slug'])).read().decode()
)
except KeyError:
raise APIErrorBadRequest(
_(
'Invalid tar file, missing component %s/%s'
% (element['type'], element['slug'])
)
)
components[component_type].append(json.loads(component_content).get('data'))
except tarfile.TarError:
raise APIErrorBadRequest(_('Invalid tar file'))
# init cache of application elements, from manifest
self.application_elements = set()
# import agendas
@ -229,6 +419,11 @@ class BundleImport(GenericAPIView):
self.application, existing_component
)
self.application_elements.add(element.content_object)
if self.install is True:
existing_component.take_snapshot(
comment=_('Application (%s)') % self.application,
application=self.application,
)
def unlink_obsolete_objects(self):
known_elements = ApplicationElement.objects.filter(application=self.application)
@ -252,7 +447,7 @@ bundle_declare = BundleDeclare.as_view()
class BundleUnlink(GenericAPIView):
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
permission_classes = (permissions.IsAdminUser,)
def post(self, request, *args, **kwargs):
if request.POST.get('application'):

View File

@ -21,6 +21,14 @@ from django.contrib.contenttypes.models import ContentType
from django.db import models
class WithApplicationMixin:
@property
def applications(self):
if getattr(self, '_applications', None) is None:
Application.load_for_object(self)
return self._applications
class Application(models.Model):
name = models.CharField(max_length=100)
slug = models.SlugField(max_length=100, unique=True)
@ -47,10 +55,10 @@ class Application(models.Model):
slug=manifest.get('slug'), defaults={'editable': editable}
)
application.name = manifest.get('application')
application.description = manifest.get('description')
application.documentation_url = manifest.get('documentation_url')
application.description = manifest.get('description') or ''
application.documentation_url = manifest.get('documentation_url') or ''
application.version_number = manifest.get('version_number') or 'unknown'
application.version_notes = manifest.get('version_notes')
application.version_notes = manifest.get('version_notes') or ''
if not editable:
application.editable = editable
application.visible = manifest.get('visible', True)
@ -71,34 +79,23 @@ class Application(models.Model):
@classmethod
def populate_objects(cls, object_class, objects):
content_type = ContentType.objects.get_for_model(object_class)
elements = ApplicationElement.objects.filter(content_type=content_type)
elements = ApplicationElement.objects.filter(
content_type=content_type, application__visible=True
).prefetch_related('application')
elements_by_objects = collections.defaultdict(list)
for element in elements:
elements_by_objects[element.content_object].append(element)
applications_by_ids = {
a.pk: a for a in cls.objects.filter(pk__in=elements.values('application'), visible=True)
}
elements_by_objects[element.object_id].append(element)
for obj in objects:
applications = []
elements = elements_by_objects.get(obj) or []
for element in elements:
application = applications_by_ids.get(element.application_id)
if application:
applications.append(application)
applications = [element.application for element in elements_by_objects.get(obj.pk) or []]
obj._applications = sorted(applications, key=lambda a: a.name)
@classmethod
def load_for_object(cls, obj):
content_type = ContentType.objects.get_for_model(obj.__class__)
elements = ApplicationElement.objects.filter(content_type=content_type, object_id=obj.pk)
applications_by_ids = {
a.pk: a for a in cls.objects.filter(pk__in=elements.values('application'), visible=True)
}
applications = []
for element in elements:
application = applications_by_ids.get(element.application_id)
if application:
applications.append(application)
elements = ApplicationElement.objects.filter(
content_type=content_type, object_id=obj.pk, application__visible=True
).prefetch_related('application')
applications = [element.application for element in elements]
obj._applications = sorted(applications, key=lambda a: a.name)
def get_objects_for_object_class(self, object_class):
@ -106,6 +103,12 @@ class Application(models.Model):
elements = ApplicationElement.objects.filter(content_type=content_type, application=self)
return object_class.objects.filter(pk__in=elements.values('object_id'))
@classmethod
def get_orphan_objects_for_object_class(cls, object_class):
content_type = ContentType.objects.get_for_model(object_class)
elements = ApplicationElement.objects.filter(content_type=content_type, application__visible=True)
return object_class.objects.exclude(pk__in=elements.values('object_id'))
class ApplicationElement(models.Model):
application = models.ForeignKey(Application, on_delete=models.CASCADE)

View File

View File

@ -0,0 +1,56 @@
# chrono - agendas system
# Copyright (C) 2016-2024 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import datetime
import django_filters
from django.forms.widgets import DateInput
from django.utils.translation import gettext_lazy as _
from chrono.agendas.models import Agenda
from .models import AuditEntry
class DateWidget(DateInput):
input_type = 'date'
def __init__(self, *args, **kwargs):
kwargs['format'] = '%Y-%m-%d'
super().__init__(*args, **kwargs)
class DayFilter(django_filters.DateFilter):
def filter(self, qs, value):
if value:
qs = qs.filter(timestamp__gte=value, timestamp__lt=value + datetime.timedelta(days=1))
return qs
class JournalFilterSet(django_filters.FilterSet):
timestamp = DayFilter(widget=DateWidget())
agenda = django_filters.ModelChoiceFilter(queryset=Agenda.objects.all())
action_type = django_filters.ChoiceFilter(
choices=(
('booking', _('Booking')),
('check', _('Checking')),
('invoice', _('Invoicing')),
)
)
class Meta:
model = AuditEntry
fields = []

View File

@ -0,0 +1,6 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = []
operations = []

View File

@ -0,0 +1,52 @@
# Generated by Django 3.2.16 on 2024-04-23 11:49
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('agendas', '0171_snapshot_models'),
('journal', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='AuditEntry',
fields=[
(
'id',
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
('timestamp', models.DateTimeField(auto_now_add=True, verbose_name='Date')),
('action_type', models.CharField(max_length=100, verbose_name='Action type')),
('action_code', models.CharField(max_length=100, verbose_name='Action code')),
('extra_data', models.JSONField(blank=True, default=dict)),
(
'agenda',
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='audit_entries',
to='agendas.agenda',
),
),
(
'user',
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL,
verbose_name='User',
),
),
],
options={
'ordering': ('-timestamp',),
},
),
]

View File

@ -0,0 +1,57 @@
# chrono - agendas system
# Copyright (C) 2016-2024 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.conf import settings
from django.db import models
from django.utils.translation import gettext_lazy as _
MESSAGES = {
'booking:accept': _('acceptation of booking (%(booking_id)s) in event "%(event)s"'),
'booking:cancel': _('cancellation of booking (%(booking_id)s) in event "%(event)s"'),
'booking:create': _('created booking (%(booking_id)s) for event %(event)s'),
'booking:suspend': _('suspension of booking (%(booking_id)s) in event "%(event)s"'),
'check:mark': _('marked event %(event)s as checked'),
'check:mark-unchecked-absent': _('marked unchecked users as absent in %(event)s'),
'check:reset': _('reset check of %(user_name)s in %(event)s'),
'check:lock': _('marked event %(event)s as locked for checks'),
'check:unlock': _('unmarked event %(event)s as locked for checks'),
'check:absence': _('marked absence of %(user_name)s in %(event)s'),
'check:presence': _('marked presence of %(user_name)s in %(event)s'),
'invoice:mark': _('marked event %(event)s as invoiced'),
'invoice:unmark': _('unmarked event %(event)s as invoiced'),
}
class AuditEntry(models.Model):
timestamp = models.DateTimeField(verbose_name=_('Date'), auto_now_add=True)
user = models.ForeignKey(
settings.AUTH_USER_MODEL, verbose_name=_('User'), on_delete=models.SET_NULL, null=True
)
action_type = models.CharField(verbose_name=_('Action type'), max_length=100)
action_code = models.CharField(verbose_name=_('Action code'), max_length=100)
agenda = models.ForeignKey(
'agendas.Agenda', on_delete=models.SET_NULL, null=True, related_name='audit_entries'
)
extra_data = models.JSONField(blank=True, default=dict)
class Meta:
ordering = ('-timestamp',)
def get_action_text(self):
try:
return MESSAGES[f'{self.action_type}:{self.action_code}'] % self.extra_data
except KeyError:
return _('Unknown entry (%s:%s)') % (self.action_type, self.action_code)

View File

@ -0,0 +1,49 @@
{% extends "chrono/manager_base.html" %}
{% load gadjo i18n %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url 'chrono-manager-audit-journal' %}">{% trans "Audit journal" %}</a>
{% endblock %}
{% block appbar %}
<h2>{% trans "Audit journal" %}</h2>
{% endblock %}
{% block content %}
<table class="main">
<thead>
<tr>
<th>{% trans "Date" %}</th>
<th>{% trans "User" %}</th>
<th>{% trans "Agenda" %}</th>
<th>{% trans "Action" %}</th>
</tr>
</thead>
<tbody>
{% for line in object_list %}
<tr>
<td>{{ line.timestamp }}</td>
<td>{{ line.user.get_full_name }}</td>
<td>{{ line.agenda }}</td>
<td>{{ line.get_action_text }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% include "gadjo/pagination.html" %}
{% endblock %}
{% block sidebar %}
<aside id="sidebar">
<h3>{% trans "Search" %}</h3>
<form action=".">
{{ filter.form|with_template }}
<div class="buttons">
<button>{% trans "Search" %}</button>
</div>
</form>
</aside>
{% endblock %}

View File

@ -0,0 +1,24 @@
# chrono - agendas system
# Copyright (C) 2016-2024 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.urls import path
from . import views
urlpatterns = [
path('', views.journal_home, name='chrono-manager-audit-journal'),
]

View File

@ -0,0 +1,39 @@
# chrono - agendas system
# Copyright (C) 2016-2024 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.contrib.auth import get_user_model
from .models import AuditEntry
User = get_user_model()
def audit(action, request=None, user=None, agenda=None, extra_data=None):
action_type, action_code = action.split(':', 1)
extra_data = extra_data or {}
if 'event' in extra_data:
extra_data['event_id'] = extra_data['event'].id
extra_data['event'] = extra_data['event'].get_journal_label()
if 'booking' in extra_data:
extra_data['booking_id'] = extra_data['booking'].id
extra_data['booking'] = extra_data['booking'].get_journal_label()
return AuditEntry.objects.create(
user=request.user if request and isinstance(request.user, User) else user,
action_type=action_type,
action_code=action_code,
agenda=agenda,
extra_data=extra_data,
)

View File

@ -0,0 +1,42 @@
# chrono - agendas system
# Copyright (C) 2016-2024 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.core.exceptions import PermissionDenied
from django.views.generic import ListView
from .forms import JournalFilterSet
class JournalHomeView(ListView):
template_name = 'chrono/journal/home.html'
paginate_by = 10
def dispatch(self, request, *args, **kwargs):
if not request.user.is_staff:
raise PermissionDenied()
return super().dispatch(request, *args, **kwargs)
def get_queryset(self):
self.filterset = JournalFilterSet(self.request.GET)
return self.filterset.qs
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['filter'] = self.filterset
return context
journal_home = JournalHomeView.as_view()

View File

View File

@ -0,0 +1,30 @@
# chrono - agendas system
# Copyright (C) 2016-2024 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import datetime
from django.core.management.base import BaseCommand
from django.utils.timezone import now
from chrono.agendas.models import Agenda, Category, EventsType, Resource, UnavailabilityCalendar
class Command(BaseCommand):
help = 'Clear obsolete snapshot instances'
def handle(self, **options):
for model in [Agenda, Category, EventsType, Resource, UnavailabilityCalendar]:
model.snapshots.filter(updated_at__lte=now() - datetime.timedelta(days=1)).delete()

View File

@ -0,0 +1,7 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = []
operations = []

View File

@ -0,0 +1,186 @@
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('agendas', '0170_alter_agenda_events_type'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('snapshot', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='UnavailabilityCalendarSnapshot',
fields=[
(
'id',
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
('timestamp', models.DateTimeField(auto_now_add=True)),
('comment', models.TextField(blank=True, null=True)),
('serialization', models.JSONField(blank=True, default=dict)),
('label', models.CharField(blank=True, max_length=150, verbose_name='Label')),
('application_slug', models.CharField(max_length=100, null=True)),
('application_version', models.CharField(max_length=100, null=True)),
(
'instance',
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to='agendas.unavailabilitycalendar',
related_name='instance_snapshots',
),
),
(
'user',
models.ForeignKey(
null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL
),
),
],
options={
'ordering': ('-timestamp',),
'abstract': False,
},
),
migrations.CreateModel(
name='ResourceSnapshot',
fields=[
(
'id',
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
('timestamp', models.DateTimeField(auto_now_add=True)),
('comment', models.TextField(blank=True, null=True)),
('serialization', models.JSONField(blank=True, default=dict)),
('label', models.CharField(blank=True, max_length=150, verbose_name='Label')),
('application_slug', models.CharField(max_length=100, null=True)),
('application_version', models.CharField(max_length=100, null=True)),
(
'instance',
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to='agendas.resource',
related_name='instance_snapshots',
),
),
(
'user',
models.ForeignKey(
null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL
),
),
],
options={
'ordering': ('-timestamp',),
'abstract': False,
},
),
migrations.CreateModel(
name='EventsTypeSnapshot',
fields=[
(
'id',
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
('timestamp', models.DateTimeField(auto_now_add=True)),
('comment', models.TextField(blank=True, null=True)),
('serialization', models.JSONField(blank=True, default=dict)),
('label', models.CharField(blank=True, max_length=150, verbose_name='Label')),
('application_slug', models.CharField(max_length=100, null=True)),
('application_version', models.CharField(max_length=100, null=True)),
(
'instance',
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to='agendas.eventstype',
related_name='instance_snapshots',
),
),
(
'user',
models.ForeignKey(
null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL
),
),
],
options={
'ordering': ('-timestamp',),
'abstract': False,
},
),
migrations.CreateModel(
name='CategorySnapshot',
fields=[
(
'id',
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
('timestamp', models.DateTimeField(auto_now_add=True)),
('comment', models.TextField(blank=True, null=True)),
('serialization', models.JSONField(blank=True, default=dict)),
('label', models.CharField(blank=True, max_length=150, verbose_name='Label')),
('application_slug', models.CharField(max_length=100, null=True)),
('application_version', models.CharField(max_length=100, null=True)),
(
'instance',
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to='agendas.category',
related_name='instance_snapshots',
),
),
(
'user',
models.ForeignKey(
null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL
),
),
],
options={
'ordering': ('-timestamp',),
'abstract': False,
},
),
migrations.CreateModel(
name='AgendaSnapshot',
fields=[
(
'id',
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
('timestamp', models.DateTimeField(auto_now_add=True)),
('comment', models.TextField(blank=True, null=True)),
('serialization', models.JSONField(blank=True, default=dict)),
('label', models.CharField(blank=True, max_length=150, verbose_name='Label')),
('application_slug', models.CharField(max_length=100, null=True)),
('application_version', models.CharField(max_length=100, null=True)),
(
'instance',
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to='agendas.agenda',
related_name='instance_snapshots',
),
),
(
'user',
models.ForeignKey(
null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL
),
),
],
options={
'ordering': ('-timestamp',),
'abstract': False,
},
),
]

View File

@ -0,0 +1,182 @@
# chrono - agendas system
# Copyright (C) 2016-2024 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.conf import settings
from django.db import models
from django.utils.translation import gettext_lazy as _
class WithSnapshotManager(models.Manager):
snapshots = False
def __init__(self, *args, **kwargs):
self.snapshots = kwargs.pop('snapshots', False)
super().__init__(*args, **kwargs)
def get_queryset(self):
queryset = super().get_queryset()
if self.snapshots:
return queryset.filter(snapshot__isnull=False)
else:
return queryset.filter(snapshot__isnull=True)
class WithSnapshotMixin:
@classmethod
def get_snapshot_model(cls):
return cls._meta.get_field('snapshot').related_model
def take_snapshot(self, *args, **kwargs):
return self.get_snapshot_model().take(self, *args, **kwargs)
class AbstractSnapshot(models.Model):
timestamp = models.DateTimeField(auto_now_add=True)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True)
comment = models.TextField(blank=True, null=True)
serialization = models.JSONField(blank=True, default=dict)
label = models.CharField(_('Label'), max_length=150, blank=True)
application_slug = models.CharField(max_length=100, null=True)
application_version = models.CharField(max_length=100, null=True)
class Meta:
abstract = True
ordering = ('-timestamp',)
@classmethod
def get_instance_model(cls):
return cls._meta.get_field('instance').related_model
@classmethod
def take(cls, instance, request=None, comment=None, deletion=False, label=None, application=None):
snapshot = cls(instance=instance, comment=comment, label=label or '')
if request and not request.user.is_anonymous:
snapshot.user = request.user
if not deletion:
snapshot.serialization = instance.export_json()
else:
snapshot.serialization = {}
snapshot.comment = comment or _('deletion')
if application:
snapshot.application_slug = application.slug
snapshot.application_version = application.version_number
snapshot.save()
return snapshot
def get_instance(self):
try:
# try reusing existing instance
instance = self.get_instance_model().snapshots.get(snapshot=self)
except self.get_instance_model().DoesNotExist:
instance = self.load_instance(self.serialization, snapshot=self)
instance.slug = self.serialization['slug'] # restore slug
return instance
def load_instance(self, json_instance, snapshot=None):
return self.get_instance_model().import_json(json_instance, snapshot=snapshot)[1]
def load_history(self):
if self.instance is None:
self._history = []
return
history = type(self).objects.filter(instance=self.instance)
self._history = [s.id for s in history]
@property
def previous(self):
if not hasattr(self, '_history'):
self.load_history()
try:
idx = self._history.index(self.id)
except ValueError:
return None
if idx == 0:
return None
return self._history[idx - 1]
@property
def next(self):
if not hasattr(self, '_history'):
self.load_history()
try:
idx = self._history.index(self.id)
except ValueError:
return None
try:
return self._history[idx + 1]
except IndexError:
return None
@property
def first(self):
if not hasattr(self, '_history'):
self.load_history()
return self._history[0]
@property
def last(self):
if not hasattr(self, '_history'):
self.load_history()
return self._history[-1]
class AgendaSnapshot(AbstractSnapshot):
instance = models.ForeignKey(
'agendas.Agenda',
on_delete=models.SET_NULL,
null=True,
related_name='instance_snapshots',
)
class CategorySnapshot(AbstractSnapshot):
instance = models.ForeignKey(
'agendas.Category',
on_delete=models.SET_NULL,
null=True,
related_name='instance_snapshots',
)
class EventsTypeSnapshot(AbstractSnapshot):
instance = models.ForeignKey(
'agendas.EventsType',
on_delete=models.SET_NULL,
null=True,
related_name='instance_snapshots',
)
class ResourceSnapshot(AbstractSnapshot):
instance = models.ForeignKey(
'agendas.Resource',
on_delete=models.SET_NULL,
null=True,
related_name='instance_snapshots',
)
class UnavailabilityCalendarSnapshot(AbstractSnapshot):
instance = models.ForeignKey(
'agendas.UnavailabilityCalendar',
on_delete=models.SET_NULL,
null=True,
related_name='instance_snapshots',
)

View File

@ -0,0 +1,213 @@
# chrono - agendas system
# Copyright (C) 2016-2024 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import difflib
import json
import re
from django.http import Http404, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect
from django.template import loader
from django.urls import reverse
from django.utils.formats import date_format
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView
from lxml.html.diff import htmldiff
from pyquery import PyQuery as pq
from chrono.utils.timezone import localtime
class InstanceWithSnapshotHistoryView(ListView):
def get_queryset(self):
self.instance = get_object_or_404(self.model.get_instance_model(), pk=self.kwargs['pk'])
return self.instance.instance_snapshots.all().select_related('user')
def get_context_data(self, **kwargs):
kwargs[self.instance_context_key] = self.instance
kwargs['object'] = self.instance
current_date = None
context = super().get_context_data(**kwargs)
day_snapshot = None
for snapshot in context['object_list']:
if snapshot.timestamp.date() != current_date:
current_date = snapshot.timestamp.date()
snapshot.new_day = True
snapshot.day_other_count = 0
day_snapshot = snapshot
else:
day_snapshot.day_other_count += 1
return context
class InstanceWithSnapshotHistoryCompareView(DetailView):
def get_snapshots_from_application(self):
version1 = self.request.GET.get('version1')
version2 = self.request.GET.get('version2')
if not version1 or not version2:
raise Http404
snapshot_for_app1 = (
self.model.get_snapshot_model()
.objects.filter(
instance=self.object,
application_slug=self.request.GET['application'],
application_version=self.request.GET['version1'],
)
.order_by('timestamp')
.last()
)
snapshot_for_app2 = (
self.model.get_snapshot_model()
.objects.filter(
instance=self.object,
application_slug=self.request.GET['application'],
application_version=self.request.GET['version2'],
)
.order_by('timestamp')
.last()
)
return snapshot_for_app1, snapshot_for_app2
def get_snapshots(self):
if 'application' in self.request.GET:
return self.get_snapshots_from_application()
id1 = self.request.GET.get('version1')
id2 = self.request.GET.get('version2')
if not id1 or not id2:
raise Http404
snapshot1 = get_object_or_404(self.model.get_snapshot_model(), pk=id1, instance=self.object)
snapshot2 = get_object_or_404(self.model.get_snapshot_model(), pk=id2, instance=self.object)
return snapshot1, snapshot2
def get_context_data(self, **kwargs):
kwargs[self.instance_context_key] = self.object
mode = self.request.GET.get('mode') or 'json'
if mode not in ['json', 'inspect']:
raise Http404
snapshot1, snapshot2 = self.get_snapshots()
if not snapshot1 or not snapshot2:
return redirect(reverse(self.history_view, args=[self.object.pk]))
if snapshot1.timestamp > snapshot2.timestamp:
snapshot1, snapshot2 = snapshot2, snapshot1
kwargs['mode'] = mode
kwargs['snapshot1'] = snapshot1
kwargs['snapshot2'] = snapshot2
kwargs['fromdesc'] = self.get_snapshot_desc(snapshot1)
kwargs['todesc'] = self.get_snapshot_desc(snapshot2)
kwargs.update(getattr(self, 'get_compare_%s_context' % mode)(snapshot1, snapshot2))
return super().get_context_data(**kwargs)
def get(self, request, *args, **kwargs):
self.object = self.get_object()
context = self.get_context_data(object=self.object)
if isinstance(context, HttpResponseRedirect):
return context
return self.render_to_response(context)
def get_compare_inspect_context(self, snapshot1, snapshot2):
instance1 = snapshot1.get_instance()
instance2 = snapshot2.get_instance()
def get_context(instance):
return {
'object': instance,
}
def fix_result(panel_diff):
if not panel_diff:
return panel_diff
panel = pq(panel_diff)
# remove "Link" added by htmldiff
for link in panel.find('a'):
d = pq(link)
text = d.html()
new_text = re.sub(r' Link: .*$', '', text)
d.html(new_text)
# remove empty ins and del tags
for elem in panel.find('ins, del'):
d = pq(elem)
if not (d.html() or '').strip():
d.remove()
# prevent auto-closing behaviour of pyquery .html() method
for elem in panel.find('span, ul, div'):
d = pq(elem)
if not d.html():
d.html(' ')
return panel.html()
inspect1 = loader.render_to_string(self.inspect_template_name, get_context(instance1), self.request)
d1 = pq(str(inspect1))
inspect2 = loader.render_to_string(self.inspect_template_name, get_context(instance2), self.request)
d2 = pq(str(inspect2))
panels_attrs = [tab.attrib for tab in d1('[role="tabpanel"]')]
panels1 = list(d1('[role="tabpanel"]'))
panels2 = list(d2('[role="tabpanel"]'))
# build tab list (merge version 1 and version2)
tabs1 = d1.find('[role="tab"]')
tabs2 = d2.find('[role="tab"]')
tabs_order = [t.get('id') for t in panels_attrs]
tabs = {}
for tab in tabs1 + tabs2:
tab_id = pq(tab).attr('aria-controls')
tabs[tab_id] = pq(tab).outer_html()
tabs = [tabs[k] for k in tabs_order if k in tabs]
# build diff of each panel
panels_diff = list(map(htmldiff, panels1, panels2))
panels_diff = [fix_result(t) for t in panels_diff]
return {
'tabs': tabs,
'panels': zip(panels_attrs, panels_diff),
'tab_class_names': d1('.pk-tabs').attr('class'),
}
def get_compare_json_context(self, snapshot1, snapshot2):
s1 = json.dumps(snapshot1.serialization, sort_keys=True, indent=2)
s2 = json.dumps(snapshot2.serialization, sort_keys=True, indent=2)
diff_serialization = difflib.HtmlDiff(wrapcolumn=160).make_table(
fromlines=s1.splitlines(True),
tolines=s2.splitlines(True),
)
return {
'diff_serialization': diff_serialization,
}
def get_snapshot_desc(self, snapshot):
label_or_comment = ''
if snapshot.label:
label_or_comment = snapshot.label
elif snapshot.comment:
label_or_comment = snapshot.comment
if snapshot.application_version:
label_or_comment += ' (%s)' % _('Version %s') % snapshot.application_version
return '{name} ({pk}) - {label_or_comment} ({user}{timestamp})'.format(
name=_('Snapshot'),
pk=snapshot.id,
label_or_comment=label_or_comment,
user='%s ' % snapshot.user if snapshot.user_id else '',
timestamp=date_format(localtime(snapshot.timestamp), format='DATETIME_FORMAT'),
)

View File

View File

@ -0,0 +1,23 @@
# chrono - agendas system
# Copyright (C) 2024 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.urls import path
from . import api_views
urlpatterns = [
path('save', api_views.save_preference, name='api-user-preferences'),
]

View File

@ -0,0 +1,43 @@
# chrono - agendas system
# Copyright (C) 2024 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import json
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse, HttpResponseBadRequest
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from . import models
@csrf_exempt
@login_required
def save_preference(request):
user_pref, dummy = models.UserPreferences.objects.get_or_create(user=request.user)
if len(request.body) > 1000:
return HttpResponseBadRequest(_('Payload is too large'))
try:
prefs = json.loads(request.body)
except json.JSONDecodeError:
return HttpResponseBadRequest(_('Bad format'))
if not isinstance(prefs, dict) or len(prefs) != 1:
return HttpResponseBadRequest(_('Bad format'))
user_pref.preferences.update(prefs)
user_pref.save()
return HttpResponse('', status=204)

View File

@ -0,0 +1,32 @@
# Generated by Django 3.2.18 on 2024-04-11 15:30
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='UserPreferences',
fields=[
(
'id',
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
('preferences', models.JSONField(default=dict, verbose_name='Preferences')),
(
'user',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
],
),
]

View File

@ -0,0 +1,24 @@
# chrono - agendas system
# Copyright (C) 2024 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.conf import settings
from django.db import models
from django.utils.translation import gettext_lazy as _
class UserPreferences(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
preferences = models.JSONField(_('Preferences'), default=dict)

File diff suppressed because it is too large Load Diff

View File

@ -82,7 +82,7 @@ class AgendaAddForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if settings.PARTIAL_BOOKINGS_ENABLED:
if 'kind' in self.fields and settings.PARTIAL_BOOKINGS_ENABLED:
self.fields['kind'].choices += [('partial-bookings', _('Partial bookings'))]
class Meta:
@ -522,6 +522,20 @@ class BookingCheckFilterSet(django_filters.FilterSet):
)
self.filters['booking-status'].parent = self
if self.agenda.partial_bookings:
self.filters['display'] = django_filters.MultipleChoiceFilter(
label=_('Display'),
choices=[
('booked', _('Booked periods')),
('checked', _('Checked periods')),
('computed', _('Computed periods')),
],
widget=forms.CheckboxSelectMultiple,
method='do_nothing',
initial=['booked', 'checked', 'computed'],
)
self.filters['display'].parent = self
def filter_booking_status(self, queryset, name, value):
if value == 'not-booked':
return queryset.none()
@ -575,18 +589,29 @@ class BookingCheckPresenceForm(forms.Form):
def __init__(self, *args, **kwargs):
agenda = kwargs.pop('agenda')
subscription = kwargs.pop('subscription', False)
super().__init__(*args, **kwargs)
check_types = get_agenda_check_types(agenda)
self.presence_check_types = [ct for ct in check_types if ct.kind == 'presence']
self.fields['check_type'].choices = [('', '---------')] + [
(ct.slug, ct.label) for ct in self.presence_check_types
]
if not self.initial and subscription:
unexpected_presences = [ct for ct in check_types if ct.unexpected_presence]
if unexpected_presences:
self.initial['check_type'] = unexpected_presences[0].slug
class PartialBookingCheckForm(forms.ModelForm):
presence = forms.NullBooleanField(
label=_('Status'),
widget=forms.RadioSelect,
widget=forms.RadioSelect(
choices=(
(None, _('Not checked')),
(True, _('Present')),
(False, _('Absent')),
)
),
required=False,
)
presence_check_type = forms.ChoiceField(label=_('Type'), required=False)
@ -594,7 +619,7 @@ class PartialBookingCheckForm(forms.ModelForm):
class Meta:
model = BookingCheck
fields = ['start_time', 'end_time', 'presence', 'type_label', 'type_slug']
fields = ['presence', 'start_time', 'end_time', 'type_label', 'type_slug']
widgets = {
'start_time': widgets.TimeWidgetWithButton(
step=60, button_label=_('Fill with booking start time')
@ -604,20 +629,15 @@ class PartialBookingCheckForm(forms.ModelForm):
'type_slug': forms.HiddenInput(),
}
def __init__(self, *args, **kwargs):
def __init__(self, *args, first_check_form=None, **kwargs):
agenda = kwargs.pop('agenda')
self.event = kwargs.pop('event')
self.first_check_form = first_check_form
super().__init__(*args, **kwargs)
self.check_types = get_agenda_check_types(agenda)
absence_check_types = [(ct.slug, ct.label) for ct in self.check_types if ct.kind == 'absence']
presence_check_types = [(ct.slug, ct.label) for ct in self.check_types if ct.kind == 'presence']
presence_choices = []
if self.instance.pk:
presence_choices.append((None, _('Not checked')))
else:
self.initial['presence'] = True
presence_choices.append((True, _('Present')))
if presence_check_types:
self.fields['presence_check_type'].choices = [(None, '---------')] + presence_check_types
self.fields['presence_check_type'].initial = self.instance.type_slug
@ -633,20 +653,21 @@ class PartialBookingCheckForm(forms.ModelForm):
if not self.instance.booking.start_time:
self.fields['start_time'].widget = widgets.TimeWidget(step=60)
self.fields['end_time'].widget = widgets.TimeWidget(step=60)
self.fields['presence'].widget.choices = ((None, _('Not checked')), (True, _('Present')))
self.fields.pop('absence_check_type', None)
else:
presence_choices.append((False, _('Absent')))
self.fields['presence'].widget.choices = presence_choices
def clean(self):
if self.cleaned_data['end_time'] <= self.cleaned_data['start_time']:
raise ValidationError(_('Arrival must be before departure.'))
if self.cleaned_data.get('presence') is None:
return
if self.instance.overlaps_existing_check(
self.cleaned_data['start_time'], self.cleaned_data['end_time']
):
raise ValidationError(_('Booking check hours overlap existing check.'))
start_time = self.cleaned_data.get('start_time')
end_time = self.cleaned_data.get('end_time')
if not start_time and not end_time:
raise ValidationError(_('Both arrival and departure cannot not be empty.'))
if start_time and end_time and end_time <= start_time:
raise ValidationError(_('Arrival must be before departure.'))
if self.cleaned_data['presence'] is not None:
kind = 'presence' if self.cleaned_data['presence'] else 'absence'
@ -656,14 +677,41 @@ class PartialBookingCheckForm(forms.ModelForm):
self.cleaned_data['type_slug']
)
def clean_presence(self):
if (
self.first_check_form
and self.cleaned_data['presence'] is not None
and self.cleaned_data['presence'] == self.first_check_form.cleaned_data['presence']
):
raise ValidationError(_('Both booking checks cannot have the same status.'))
return self.cleaned_data['presence']
def clean_start_time(self):
start_time = self.cleaned_data['start_time']
if start_time and start_time < localtime(self.event.start_datetime).time():
raise ValidationError(_('Arrival must be after opening time.'))
return start_time
def clean_end_time(self):
end_time = self.cleaned_data['end_time']
if end_time and end_time > self.event.end_time:
raise ValidationError(_('Departure must be before closing time.'))
return end_time
def save(self):
booking = self.instance.booking
if self.cleaned_data['presence'] is None:
self.instance.delete()
if self.instance.pk:
self.instance.delete()
booking.refresh_computed_times(commit=True)
return self.instance
self.instance.refresh_computed_times()
return super().save()
super().save()
booking.refresh_computed_times(commit=True)
return self.instance
class EventsTimesheetForm(forms.Form):
@ -729,17 +777,29 @@ class EventsTimesheetForm(forms.Form):
],
initial='portrait',
)
booking_filter = forms.ChoiceField(
label=_('Filter by status'),
choices=[
('all', _('All')),
('with_booking', _('With booking')),
('without_booking', _('Without booking')),
],
initial='all',
)
def __init__(self, *args, **kwargs):
self.agenda = kwargs.pop('agenda')
self.event = kwargs.pop('event', None)
super().__init__(*args, **kwargs)
self.with_subscriptions = self.agenda.subscriptions.exists()
if self.event is not None:
del self.fields['date_start']
del self.fields['date_end']
del self.fields['date_display']
del self.fields['custom_nb_dates_per_page']
del self.fields['activity_display']
if not self.with_subscriptions:
del self.fields['booking_filter']
def get_slots(self):
extra_data = self.cleaned_data['extra_data'].split(',')
@ -807,20 +867,21 @@ class EventsTimesheetForm(forms.Form):
)
users = {}
subscriptions = self.agenda.subscriptions.filter(date_start__lt=max_start, date_end__gt=min_start)
for subscription in subscriptions:
if subscription.user_external_id in users:
continue
users[subscription.user_external_id] = {
'user_id': subscription.user_external_id,
'user_first_name': subscription.user_first_name,
'user_last_name': subscription.user_last_name,
'extra_data': {k: (subscription.extra_data or {}).get(k) or '' for k in all_extra_data},
'events': copy.deepcopy(event_slots),
}
if self.with_subscriptions:
subscriptions = self.agenda.subscriptions.filter(date_start__lt=max_start, date_end__gt=min_start)
for subscription in subscriptions:
if subscription.user_external_id in users:
continue
users[subscription.user_external_id] = {
'user_id': subscription.user_external_id,
'user_first_name': subscription.user_first_name,
'user_last_name': subscription.user_last_name,
'extra_data': {k: (subscription.extra_data or {}).get(k) or '' for k in all_extra_data},
'events': copy.deepcopy(event_slots),
}
booking_qs_kwargs = {}
if not self.agenda.subscriptions.exists():
if not self.with_subscriptions:
booking_qs_kwargs = {'cancellation_datetime__isnull': True}
booked_qs = (
Booking.objects.filter(
@ -856,6 +917,19 @@ class EventsTimesheetForm(forms.Form):
participants += 1
break
if self.cleaned_data.get('booking_filter') == 'with_booking':
# remove subscribed users without booking
users = {
k: user for k, user in users.items() if any(any(e['dates'].values()) for e in user['events'])
}
elif self.cleaned_data.get('booking_filter') == 'without_booking':
# remove subscribed users with booking
users = {
k: user
for k, user in users.items()
if not any(any(e['dates'].values()) for e in user['events'])
}
if self.cleaned_data['sort'] == 'lastname,firstname':
sort_fields = ['user_last_name', 'user_first_name']
else:

View File

@ -201,6 +201,18 @@ table.agenda-table {
text-align: center;
}
&.booking {
&.lease {
background:
repeating-linear-gradient(
135deg,
hsla(10, 10%, 75%, 0.7) 0,
hsla(10, 10%, 80%, 0.55) 10px,
transparent 11px,
transparent 20px);
// color: currentColor;
color: hsla(0, 0%, 0%, 0.7);
}
left: 0;
color: hsl(210, 84%, 40%);
padding: 1ex;
@ -562,6 +574,18 @@ div.agenda-settings .pk-tabs--container {
#event_details {
margin: 1em 0;
.objects-list .lease {
background:
repeating-linear-gradient(
135deg,
hsla(10, 10%, 75%, 0.7) 0,
hsla(10, 10%, 80%, 0.55) 10px,
transparent 11px,
transparent 20px);
}
.objects-list .lease span {
padding: 0 0.5ex 0 2ex;
}
}
@media print {
@ -609,10 +633,11 @@ div#main-content.partial-booking-dayview {
--zebra-color: hsla(0,0%,0%,0.05);
--separator-color: white;
--separator-size: 2px;
--padding: 0.5rem;
position: relative;
background: white;
padding: 0.5rem;
padding: var(--padding);
&--hours-list {
background: white;
@ -634,8 +659,88 @@ div#main-content.partial-booking-dayview {
visibility: hidden;
}
}
&--occupation-rate-list {
position: static;
display: grid;
grid-template-rows: 40px auto;
align-items: end;
margin-top: 0.33rem;
margin-bottom: 1rem;
border-top: 3px solid var(--zebra-color);
grid-template-columns: repeat(var(--nb-hours), 1fr);
@media (min-width: 761px) {
grid-template-columns: var(--registrant-name-width) repeat(var(--nb-hours), 1fr);
}
}
.occupation-rate-list--title {
margin: 0;
font-size: 1rem;
font-weight: normal;
justify-self: end;
align-self: end;
padding: .66rem;
padding-bottom: 0;
@media (max-width: 760px) {
grid-column: 1/-1;
grid-row: 2/3;
}
}
.occupation-rate {
@function linear-progress($from, $to) {
$ratio: #{($to - $from) / 100};
@return "calc(#{$ratio} * var(--rate-percent) + #{$from})";
}
--hue: #{linear-progress(40, 10)};
--saturation: #{linear-progress(50%, 75%)};
--luminosity: #{linear-progress(65%, 50%)};
background-color: hsl(var(--hue), var(--saturation), var(--luminosity));
height: calc(1% * var(--rate-percent));
margin: 0;
opacity: 80%;
position: relative;
&--info {
display: block;
position: absolute;
z-index: 5;
padding: .33em .66em;
text-align: center;
background-color: var(--font-color);
color: white;
top: 100%;
left: 50%;
transform: translateX(-50%);
margin-top: .5em;
font-weight: bold;
filter: drop-shadow(0 0 3px white);
&::before {
content: "";
display: block;
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border: .5em solid transparent;
border-bottom-color: var(--font-color);
}
}
&:not(:hover) .occupation-rate--info {
display: none;
}
&:hover {
opacity: 100%;
z-index: 4;
}
&.overbooked {
--hue: 0;
--saturation: 95%;
--luminosity: 40%;
}
}
&--registrant-items {
margin-top: 0.5rem;
position: relative;
}
&--registrant {
display: flex;
@ -682,50 +787,165 @@ div#main-content.partial-booking-dayview {
margin: 0.33rem 0;
}
&--bar {
--color: white;
box-sizing: border-box;
display: inline-block;
margin: 0;
position: relative;
padding: 0.33em 0.66em;
background-color: var(--background);
color: var(--color);
background-color: var(--bar-color);
color: white;
border: none;
&:not(:first-child) {
position: absolute;
top: 0;
}
.start-time, .end-time {
display: inline-block;
padding: 0.33em 0.66em;
}
.end-time {
float: right;
margin-left: .66em;
}
&.booking {
--background: #1066bc;
--bar-color: #1066bc;
.occasional {
font-style: italic;
font-size: 90%;
}
}
&.check.present, &.computed.present {
--background: var(--green);
--bar-color: var(--green);
}
&.check.absent, &.computed.absent {
--background: var(--red);
--bar-color: var(--red);
}
&.computed {
opacity: 0.6;
}
&.end-only, &.start-only {
background-color: transparent;
.end-time, .start-time {
background-color: var(--bar-color);
position: relative;
&::before {
content:"?";
color: var(--bar-color);
font-weight: 800;
line-height: 0;
position: absolute;
border: 0.75em solid transparent;
width: 0;
height: 0;
top: 0;
bottom: 0;
margin: auto;
}
}
.start-time::before {
left: 100%;
border-left-color: var(--bar-color);
text-indent: 0.25em;
}
.end-time::before {
right: 100%;
border-right-color: var(--bar-color);
text-indent: -0.75em;
}
}
}
}
}
&--hour-indicator-wrapper {
position: absolute;
inset: 0 var(--padding) 0 var(--padding);
@media (min-width: 761px) {
margin-left: var(--registrant-name-width);
}
}
&--hour-indicator {
position: absolute;
top: 0;
left: 0;
width: 1px;
height: 100%;
background-color: var(--red);
z-index: 3;
}
// Month view, table element
&-month {
width: 100%;
border-spacing: 0;
& col.we {
background-color: var(--zebra-color);
}
& col.today {
background-image: linear-gradient(
135deg,
hsl(65, 65%, 94%) 20%,
hsl(65, 55%, 92%) 70%,
hsl(65, 50%, 90%) 90%);
}
&--day {
padding: .33em;
a {
color: var(--font-color);
font-weight: normal;
text-decoration: none;
}
&.today a {
font-weight: bold;
}
}
& .registrant {
&--name {
box-sizing: border-box;
text-align: right;
padding: .66rem;
font-size: 130%;
color: #505050;
font-weight: normal;
width: var(--registrant-name-width);
}
&--day-cell {
border-left: var(--separator-size) solid var(--separator-color);
text-align: center;
vertical-align: middle;
padding: .33em;
line-height: 0;
& .booking {
display: inline-block;
width: Min(100%, 1.75em);
height: 1.75em;
--booking-color: #1066bc;
background-color: var(--booking-color);
&.present {
background: var(--green);
}
&.absent {
background: var(--red);
}
}
}
}
&--registrant:nth-child(odd) {
& th, & td {
background-color: var(--zebra-color);
}
}
&--registrant:nth-child(even) {
& th, & td {
--separator-color: var(--zebra-color);
}
}
}
}
.agenda-table.partial-bookings .booking {
height: 70%;
width: 100%;
position: absolute;
right: 0;
top: 15%;
background: #1066bc;
&.present {
background: hsl(120, 57%, 35%);
}
&.absent {
background: hsl(355, 80%, 45%);
.partial-booking--check-icon {
border: 0;
&::before {
content: "\f017"; /* clock */
font-family: FontAwesome;
padding-left: 1ex;
}
}
@ -774,3 +994,22 @@ ul.objects-list.single-links li.ants-setting-not-configured a.edit {
/* used for the city-edit link */
.icon-edit::before { content: "\f044"; }
a.button.button-paragraph {
text-align: left;
box-sizing: border-box;
display: block;
max-width: 100%;
margin-bottom: 1rem;
line-height: 150%;
padding-top: 0.8em;
padding-bottom: 0.8em;
}
.application-logo, .application-icon {
vertical-align: middle;
}
.snapshots-list .collapsed {
display: none;
}

View File

@ -1,4 +1,23 @@
$(function() {
const foldableClassObserver = new MutationObserver((mutations) => {
mutations.forEach(mu => {
const old_folded = (mu.oldValue.indexOf('folded') != -1);
const new_folded = mu.target.classList.contains('folded')
if (old_folded == new_folded) { return; }
var pref_message = Object();
pref_message[mu.target.dataset.sectionFoldedPrefName] = new_folded;
fetch('/api/user-preferences/save', {
method: 'POST',
credentials: 'include',
headers: {'Accept': 'application/json', 'Content-Type': 'application/json'},
body: JSON.stringify(pref_message)
});
});
});
document.querySelectorAll('[data-section-folded-pref-name]').forEach(
elt => foldableClassObserver.observe(elt, {attributes: true, attributeFilter: ['class'], attributeOldValue: true})
);
$('[data-total]').each(function() {
var total = $(this).data('total');
var booked = $(this).data('booked');

View File

@ -0,0 +1,13 @@
{% load thumbnail %}
{% if application %}
<h2>
{% thumbnail application.icon '64x64' format='PNG' as im %}
<img src="{{ im.url }}" alt="" class="application-logo" />
{% endthumbnail %}
{{ application }}
</h2>
{% elif no_application %}
<h2>{{ title_no_application }}</h2>
{% else %}
<h2>{{ title_object_list }}</h2>
{% endif %}

View File

@ -0,0 +1,5 @@
{% if application %}
<a href="{{ object_list_url }}?application={{ application.slug }}">{{ application }}</a>
{% elif no_application %}
<a href="{{ object_list_url }}?no-application">{{ title_no_application }}</a>
{% endif %}

View File

@ -0,0 +1,12 @@
{% load i18n thumbnail %}
{% if object.applications %}
<h3>{% trans "Applications" %}</h3>
{% for application in object.applications %}
<a class="button button-paragraph" href="{{ object_list_url }}?application={{ application.slug }}">
{% thumbnail application.icon '16x16' format='PNG' as im %}
<img src="{{ im.url }}" alt="" class="application-icon" width="16" />
{% endthumbnail %}
{{ application }}
</a>
{% endfor %}
{% endif %}

View File

@ -0,0 +1,8 @@
{% load thumbnail %}
{% if not application and not no_application %}
{% for application in object.applications %}
{% thumbnail application.icon '16x16' format='PNG' as im %}
<img src="{{ im.url }}" alt="" class="application-icon" width="16" />
{% endthumbnail %}
{% endfor %}
{% endif %}

View File

@ -0,0 +1,15 @@
{% load i18n thumbnail %}
{% if applications %}
<h3>{% trans "Applications" %}</h3>
{% for application in applications %}
<a class="button button-paragraph" href="?application={{ application.slug }}">
{% thumbnail application.icon '16x16' format='PNG' as im %}
<img src="{{ im.url }}" alt="" class="application-icon" width="16" />
{% endthumbnail %}
{{ application }}
</a>
{% endfor %}
<a class="button button-paragraph" href="?no-application">
{{ title_no_application }}
</a>
{% endif %}

View File

@ -0,0 +1,20 @@
<p class="snapshot-description">{{ fromdesc|safe }} ➔ {{ todesc|safe }}</p>
<div class="snapshot-diff">
{% if mode == 'json' %}
{{ diff_serialization|safe }}
{% else %}
<div class="{{ tab_class_names }}">
<div class="pk-tabs--tab-list" role="tablist">
{% for tab in tabs %}{{ tab|safe }}{% endfor %}
{{ tab_list|safe }}
</div>
<div class="pk-tabs--container">
{% for attrs, panel in panels %}
<div{% for k, v in attrs.items %} {{ k }}="{{ v }}"{% endfor %}>
{{ panel|safe }}
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>

View File

@ -0,0 +1,63 @@
{% load i18n %}
<div>
<form action="{{ compare_url }}" method="get">
{% if object_list|length > 1 %}
<p><button>{% trans "Show differences" %}</button></p>
{% endif %}
<table class="main">
<thead>
<th>{% trans 'Identifier' %}</th>
<th>{% trans 'Compare' %}</th>
<th>{% trans 'Date' %}</th>
<th>{% trans 'Description' %}</th>
<th>{% trans 'User' %}</th>
<th>{% trans 'Actions' %}</th>
</thead>
<tbody class="snapshots-list">
{% for snapshot in object_list %}
<tr data-day="{{ snapshot.timestamp|date:"Y-m-d" }}" class="{% if snapshot.new_day %}new-day{% else %}collapsed{% endif %}">
<td><span class="counter">#{{ snapshot.pk }}</span></td>
<td>
{% if object_list|length > 1 %}
{% if not forloop.last %}<input type="radio" name="version1" value="{{ snapshot.pk }}" {% if forloop.first %}checked="checked"{% endif %} />{% else %}&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;{% endif %}
{% if not forloop.first %}<input type="radio" name="version2" value="{{ snapshot.pk }}" {% if forloop.counter == 2 %}checked="checked"{% endif %}/>{% else %}&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;{% endif %}
{% endif %}
</td>
<td>
{{ snapshot.timestamp }}
{% if snapshot.new_day and snapshot.day_other_count %} — <a class="reveal" href="#day-{{ snapshot.timestamp|date:"Y-m-d"}}">
{% if snapshot.day_other_count >= 50 %}<strong>{% endif %}
{% blocktrans trimmed count counter=snapshot.day_other_count %}
1 other this day
{% plural %}
{{ counter }} others
{% endblocktrans %}
{% endif %}
</td>
<td>
{% if snapshot.label %}
<strong>{{ snapshot.label }}</strong>
{% elif snapshot.comment %}
{{ snapshot.comment }}
{% endif %}
{% if snapshot.application_version %}({% blocktrans with version=snapshot.application_version %}Version {{ version }}{% endblocktrans %}){% endif %}
</td>
<td>{% if snapshot.user %} {{ snapshot.user.get_full_name }}{% endif %}</td>
<td>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</form>
</div>
<script>
$(function() {
$('tr.new-day a.reveal').on('click', function() {
var day = $(this).parents('tr.new-day').data('day');
$('.snapshots-list tr[data-day="' + day + '"]:not(.new-day)').toggleClass('collapsed');
return false;
});
});
</script>

View File

@ -14,7 +14,7 @@
<a class="date-next pk-button" href="{{ view.get_next_day_url }}"><span class="sr-only">{% trans "Next day" %}</span></a>
</span>
<h2 class="date-nav">
<span class="date-title">{{ view.date|date:"l j F Y" }}</span>
<time datetime="{{ view.date|date:'Y-m-d' }}" class="date-title">{{ view.date|date:"l j F Y" }}</time>
<button class="date-picker-opener"><span class="sr-only">{% trans "Pick a date" %}</span></button>
{% with selected_day=view.date|date:"j" selected_month=view.date|date:"n" selected_year=view.date|date:"Y" %}
<div class="date-picker" style="display: none">

View File

@ -0,0 +1,19 @@
{% extends "chrono/manager_agenda_settings.html" %}
{% load i18n %}
{% block appbar %}
<h2>{% trans 'Agenda history' %} - {{ agenda }}</h2>
{% endblock %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url 'chrono-manager-agenda-history' pk=agenda.pk %}">{% trans "History" %}</a>
{% endblock %}
{% block content %}
{% url 'chrono-manager-agenda-history-compare' pk=agenda.pk as compare_url %}
{% include 'chrono/includes/snapshot_history_fragment.html' %}
{% endblock %}
{% block sidebar %}
{% endblock %}

View File

@ -0,0 +1,29 @@
{% extends "chrono/manager_agenda_history.html" %}
{% load gadjo static i18n %}
{% block css %}
{{ block.super }}
<link rel="stylesheet" type="text/css" media="all" href="{% static "css/gadjo.snapshotdiff.css" %}?{% start_timestamp %}"/>
{% endblock %}
{% block extrascripts %}
{{ block.super }}
<script src="{% static "js/gadjo.snapshotdiff.js" %}?{% start_timestamp %}"></script>
{% endblock %}
{% block appbar %}
<h2>{% trans 'Compare snapshots' %} ({% if mode == 'json' %}{% trans "JSON" %}{% else %}{% trans "Inspect" %}{% endif %})</h2>
<span class="actions">
<a href="?version1={{ snapshot1.pk }}&version2={{ snapshot2.pk }}&mode=inspect">{% trans "Compare inspect" %}</a>
<a href="?version1={{ snapshot1.pk }}&version2={{ snapshot2.pk }}&mode=json">{% trans "Compare JSON" %}</a>
</span>
{% endblock %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url 'chrono-manager-agenda-history-compare' pk=agenda.pk %}">{% trans "Compare snapshots" %}</a>
{% endblock %}
{% block content %}
{% include 'chrono/includes/snapshot_compare_fragment.html' %}
{% endblock %}

View File

@ -0,0 +1,19 @@
{% extends "chrono/manager_agenda_settings.html" %}
{% load i18n %}
{% block appbar %}
<h2>{% trans 'Inspect' %}</h2>
{% endblock %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url 'chrono-manager-agenda-inspect' pk=agenda.pk %}">{% trans "Inspect" %}</a>
{% endblock %}
{% block content %}
{% include 'chrono/manager_agenda_inspect_fragment.html' %}
{% endblock %}
{% block sidebar %}
{% endblock %}

View File

@ -0,0 +1,322 @@
{% load i18n %}
<div class="pk-tabs">
<div class="pk-tabs--tab-list" role="tablist">
<button aria-controls="panel-information" aria-selected="true" id="tab-information" role="tab" tabindex="0">{% trans "Information" %}</button>
<button aria-controls="panel-settings" aria-selected="false" id="tab-settings" role="tab" tabindex="-1">{% trans "Settings" %}</button>
<button aria-controls="panel-permissions" aria-selected="false" id="tab-permissions" role="tab" tabindex="-1">{% trans "Permissions" %}</button>
{% if object.kind == 'events' %}
<button aria-controls="panel-events" aria-selected="false" id="tab-events" role="tab" tabindex="-1">{% trans "Events" %}</button>
<button aria-controls="panel-exceptions" aria-selected="false" id="tab-exceptions" role="tab" tabindex="-1">{% trans "Recurrence exceptions" %}</button>
{% elif object.kind == 'meetings' %}
<button aria-controls="panel-meeting-types" aria-selected="false" id="tab-meeting-types" role="tab" tabindex="-1">{% trans "Meeting Types" %}</button>
<button aria-controls="panel-desks" aria-selected="false" id="tab-desks" role="tab" tabindex="-1">{% trans "Desks" %}</button>
<button aria-controls="panel-resources" aria-selected="false" id="tab-resources" role="tab" tabindex="-1">{% trans "Resources" %}</button>
{% elif object.kind == 'virtual' %}
<button aria-controls="panel-agendas" aria-selected="false" id="tab-agendas" role="tab" tabindex="-1">{% trans "Included Agendas" %}</button>
<button aria-controls="panel-time-periods" aria-selected="false" id="tab-time-periods" role="tab" tabindex="-1">{% trans "Excluded Periods" %}</button>
{% endif %}
</div>
<div class="pk-tabs--container">
<div aria-labelledby="tab-information" id="panel-information" role="tabpanel" tabindex="0">
<div class="section">
<ul>
{% for label, value in object.get_inspect_fields %}
<li class="parameter-{{ label|slugify }}">
<span class="parameter">{% blocktrans %}{{ label }}:{% endblocktrans %}</span>
{{ value }}
</li>
{% endfor %}
</ul>
</div>
</div>
<div aria-labelledby="tab-settings" hidden id="panel-settings" role="tabpanel" tabindex="0">
<div class="section">
{% if object.kind != 'virtual' %}
<h4>{% trans "Display options" %}</h4>
<ul>
{% for label, value in object.get_display_inspect_fields %}
<li class="parameter-{{ label|slugify }}">
<span class="parameter">{% blocktrans %}{{ label }}:{% endblocktrans %}</span>
{{ value }}
</li>
{% endfor %}
</ul>
{% endif %}
{% if object.kind == 'events' %}
<h4>{% trans "Booking check options" %}</h4>
<ul>
{% for label, value in object.get_booking_check_inspect_fields %}
<li class="parameter-{{ label|slugify }}">
<span class="parameter">{% blocktrans %}{{ label }}:{% endblocktrans %}</span>
{{ value }}
</li>
{% endfor %}
</ul>
{% endif %}
{% if object.kind == 'events' %}
{% if agenda.partial_bookings %}
<h4>{% trans "Invoicing options" %}</h4>
<ul>
{% for label, value in object.get_invoicing_inspect_fields %}
<li class="parameter-{{ label|slugify }}">
<span class="parameter">{% blocktrans %}{{ label }}:{% endblocktrans %}</span>
{{ value }}
</li>
{% endfor %}
</ul>
{% else %}
<h4>{% trans "Management notifications" %}</h4>
<ul>
{% for label, value in object.get_notifications_inspect_fields %}
<li class="parameter-{{ label|slugify }}">
<span class="parameter">{% blocktrans %}{{ label }}:{% endblocktrans %}</span>
{{ value }}
</li>
{% endfor %}
</ul>
{% endif %}
{% endif %}
{% if object.kind != 'virtual' and not object.partial_bookings %}
<h4>{% trans "Booking reminders" %}</h4>
<ul>
{% for label, value in object.get_reminder_inspect_fields %}
<li class="parameter-{{ label|slugify }}">
<span class="parameter">{% blocktrans %}{{ label }}:{% endblocktrans %}</span>
{{ value }}
</li>
{% endfor %}
</ul>
{% endif %}
<h4>{% trans "Booking Delays" %}</h4>
<ul>
{% for label, value in object.get_booking_delays_inspect_fields %}
<li class="parameter-{{ label|slugify }}">
<span class="parameter">{% blocktrans %}{{ label }}:{% endblocktrans %}</span>
{{ value }}
</li>
{% endfor %}
</ul>
</div>
</div>
<div aria-labelledby="tab-permissions" hidden id="panel-permissions" role="tabpanel" tabindex="0">
<div class="section">
<ul>
{% for label, value in object.get_permissions_inspect_fields %}
<li class="parameter-{{ label|slugify }}">
<span class="parameter">{% blocktrans %}{{ label }}:{% endblocktrans %}</span>
{{ value }}
</li>
{% endfor %}
</ul>
</div>
</div>
{% if object.kind == 'events' %}
<div aria-labelledby="tab-events" hidden id="panel-events" role="tabpanel" tabindex="0">
<div class="section">
{% for event in object.event_set.all %}
<h4>{{ event }}</h4>
<ul>
{% for label, value in event.get_inspect_fields %}
<li class="parameter-{{ label|slugify }}">
<span class="parameter">{% blocktrans %}{{ label }}:{% endblocktrans %}</span>
{{ value }}
</li>
{% endfor %}
{% if object.events_type %}
<li class="parameter-custom-fields">
<span class="parameter">{% trans "Custom fields:" %}</span>
<ul>
{% for value in object.events_type.get_custom_fields %}
<li class="parameter-custom-field-{{ value.varname }}">
<span class="parameter">{% blocktrans with label=value.label %}{{ label }}:{% endblocktrans %}</span>
{{ event.get_custom_fields|get:value.varname|default:"" }}
</li>
{% endfor %}
</ul>
</li>
{% endif %}
</ul>
{% endfor %}
</div>
</div>
<div aria-labelledby="tab-exceptions" hidden id="panel-exceptions" role="tabpanel" tabindex="0">
<div class="section">
{% for desk in object.desk_set.all %}{% if desk.slug == '_exceptions_holder' %}
<h4>{% trans "Unavailability calendars" %}</h4>
<ul>
{% for unavailability_calendar in desk.unavailability_calendars.all %}
<li class="parameter-unavailability-calendar }}">
{{ unavailability_calendar }}
</li>
{% endfor %}
</ul>
<h4>{% trans "Exception sources" %}</h4>
{% for source in desk.timeperiodexceptionsource_set.all %}
<h5>{{ source }}</h5>
<ul>
{% for label, value in source.get_inspect_fields %}
<li class="parameter-{{ label|slugify }}">
<span class="parameter">{% blocktrans %}{{ label }}:{% endblocktrans %}</span>
{{ value }}
</li>
{% endfor %}
</ul>
{% endfor %}
<h4>{% trans "Exceptions" %}</h4>
{% for exception in desk.timeperiodexception_set.all %}
<h5>{{ exception }}</h5>
<ul>
{% for label, value in exception.get_inspect_fields %}
<li class="parameter-{{ label|slugify }}">
<span class="parameter">{% blocktrans %}{{ label }}:{% endblocktrans %}</span>
{{ value }}
</li>
{% endfor %}
</ul>
{% endfor %}
{% endif %}{% endfor %}
</div>
</div>
{% elif object.kind == 'meetings' %}
<div aria-labelledby="tab-meeting-types" hidden id="panel-meeting-types" role="tabpanel" tabindex="0">
<div class="section">
{% for meeting_type in object.meetingtype_set.all %}
<h4>{{ meeting_type }}</h4>
<ul>
{% for label, value in meeting_type.get_inspect_fields %}
<li class="parameter-{{ label|slugify }}">
<span class="parameter">{% blocktrans %}{{ label }}:{% endblocktrans %}</span>
{{ value }}
</li>
{% endfor %}
</ul>
{% endfor %}
</div>
</div>
<div aria-labelledby="tab-desks" hidden id="panel-desks" role="tabpanel" tabindex="0">
<div class="section">
{% for desk in object.desk_set.all %}
<h4>{{ desk }}</h4>
<ul>
{% for label, value in desk.get_inspect_fields %}
<li class="parameter-{{ label|slugify }}">
<span class="parameter">{% blocktrans %}{{ label }}:{% endblocktrans %}</span>
{{ value }}
</li>
{% endfor %}
</ul>
<h5>{% trans "Opening hours" %}</h5>
{% for time_period in desk.timeperiod_set.all %}
<h6>{{ time_period }}</h6>
<ul>
{% for label, value in time_period.get_inspect_fields %}
<li class="parameter-{{ label|slugify }}">
<span class="parameter">{% blocktrans %}{{ label }}:{% endblocktrans %}</span>
{{ value }}
</li>
{% endfor %}
</ul>
{% endfor %}
<h5>{% trans "Unavailability calendars" %}</h5>
<ul>
{% for unavailability_calendar in desk.unavailability_calendars.all %}
<li class="parameter-unavailability-calendar }}">
{{ unavailability_calendar }}
</li>
{% endfor %}
</ul>
<h5>{% trans "Exception sources" %}</h5>
{% for source in desk.timeperiodexceptionsource_set.all %}
<h6>{{ source }}</h6>
<ul>
{% for label, value in source.get_inspect_fields %}
<li class="parameter-{{ label|slugify }}">
<span class="parameter">{% blocktrans %}{{ label }}:{% endblocktrans %}</span>
{{ value }}
</li>
{% endfor %}
</ul>
{% endfor %}
<h5>{% trans "Exceptions" %}</h5>
{% for exception in desk.timeperiodexception_set.all %}
<h6>{{ exception }}</h6>
<ul>
{% for label, value in exception.get_inspect_fields %}
<li class="parameter-{{ label|slugify }}">
<span class="parameter">{% blocktrans %}{{ label }}:{% endblocktrans %}</span>
{{ value }}
</li>
{% endfor %}
</ul>
{% endfor %}
{% endfor %}
</div>
</div>
<div aria-labelledby="tab-resources" hidden id="panel-resources" role="tabpanel" tabindex="0">
<div class="section">
<ul>
{% for resource in object.resources.all %}
<li class="parameter-resource }}">
{{ resource }}
</li>
{% endfor %}
</ul>
</div>
</div>
{% elif object.kind == "virtual" %}
<div aria-labelledby="tab-agendas" hidden id="panel-agendas" role="tabpanel" tabindex="0">
<div class="section">
<ul>
{% for agenda in object.real_agendas.all %}
<li class="parameter-agenda }}">
{{ agenda }}
</li>
{% endfor %}
</ul>
</div>
</div>
<div aria-labelledby="tab-time-periods" hidden id="panel-time-periods" role="tabpanel" tabindex="0">
<div class="section">
{% for time_period in object.excluded_timeperiods.all %}
<h4>{{ time_period }}</h4>
<ul>
{% for label, value in time_period.get_inspect_fields %}
<li class="parameter-{{ label|slugify }}">
<span class="parameter">{% blocktrans %}{{ label }}:{% endblocktrans %}</span>
{{ value }}
</li>
{% endfor %}
</ul>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>

View File

@ -16,14 +16,8 @@
</h2>
<span class="actions">
<a class="extra-actions-menu-opener"></a>
{% block agenda-extra-management-actions %}
{% endblock %}
<ul class="extra-actions-menu">
<li><a rel="popup" href="{% url 'chrono-manager-agenda-edit' pk=object.id %}">{% trans 'Options' %}</a></li>
{% block agenda-extra-menu-actions %}{% endblock %}
{% if user.is_staff %}
<li><a rel="popup" class="action-duplicate" href="{% url 'chrono-manager-agenda-duplicate' pk=object.pk %}">{% trans 'Duplicate' %}</a></li>
{% endif %}
<li><a download href="{% url 'chrono-manager-agenda-export' pk=object.id %}">{% trans 'Export Configuration (JSON)' %}</a></li>
{% if object.kind == 'events' %}
<li><a download href="{% url 'chrono-manager-agenda-export-events' pk=object.pk %}">{% trans 'Export Events (CSV)' %}</a></li>
@ -115,3 +109,25 @@
</div>
</div>
{% endblock %}
{% block sidebar %}
<aside id="sidebar">
<h3>{% trans "Actions" %}</h3>
{% block agenda-extra-management-actions %}{% endblock %}
<a class="button button-paragraph" rel="popup" href="{% url 'chrono-manager-agenda-edit' pk=object.id %}">{% trans 'Options' %}</a>
{% if user.is_staff %}
<a class="button button-paragraph" rel="popup" class="action-duplicate" href="{% url 'chrono-manager-agenda-duplicate' pk=object.pk %}">{% trans 'Duplicate' %}</a>
{% endif %}
<h3>{% trans "Navigation" %}</h3>
{% if show_history %}
<a class="button button-paragraph" href="{% url 'chrono-manager-agenda-history' pk=agenda.pk %}">{% trans 'History' %}</a>
{% endif %}
<a class="button button-paragraph" href="{% url 'chrono-manager-agenda-inspect' pk=agenda.pk %}">{% trans 'Inspect' %}</a>
{% block agenda-extra-navigation-actions %}{% endblock %}
{% url 'chrono-manager-homepage' as object_list_url %}
{% include 'chrono/includes/application_detail_fragment.html' %}
</aside>
{% endblock %}

View File

@ -11,7 +11,6 @@
{% trans 'Agendas' as default_site_title %}
{% firstof site_title default_site_title %}
{% endblock %}
{% block footer %}Chrono — Copyright © Entr'ouvert{% endblock %}
{% block homepage-url %}{{portal_agent_url}}{% endblock %}
{% block homepage-title %}{{portal_agent_title}}{% endblock %}

View File

@ -1,12 +1,33 @@
{% extends "chrono/manager_home.html" %}
{% extends "chrono/manager_category_list.html" %}
{% load i18n %}
{% block page-title-extra-label %}
{% if object.pk %}{{ object.label }}{% else %}{{ block.super }}{% endif %}
{% endblock %}
{% block breadcrumb %}
{{ block.super }}
{% if object.pk %}
<a href="{% url 'chrono-manager-category-edit' pk=object.pk %}">{{ object.label }}</a>
{% else %}
<a href="{% url 'chrono-manager-category-add' %}">{% trans "New Category" %}</a>
{% endif %}
{% endblock %}
{% block appbar %}
{% if object.id %}
<h2>{% trans "Edit Category" %}</h2>
{% else %}
<h2>{% trans "New Category" %}</h2>
{% endif %}
{% if category %}
<span class="actions">
<a href="{% url 'chrono-manager-category-inspect' pk=category.pk %}">{% trans 'Inspect' %}</a>
{% if show_history %}
<a href="{% url 'chrono-manager-category-history' pk=category.pk %}">{% trans 'History' %}</a>
{% endif %}
</span>
{% endif %}
{% endblock %}
{% block content %}
@ -20,3 +41,6 @@
</div>
</form>
{% endblock %}
{% block sidebar %}
{% endblock %}

View File

@ -0,0 +1,19 @@
{% extends "chrono/manager_category_form.html" %}
{% load i18n %}
{% block appbar %}
<h2>{% trans 'Category history' %} - {{ category }}</h2>
{% endblock %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url 'chrono-manager-category-history' pk=category.pk %}">{% trans "History" %}</a>
{% endblock %}
{% block content %}
{% url 'chrono-manager-category-history-compare' pk=category.pk as compare_url %}
{% include 'chrono/includes/snapshot_history_fragment.html' %}
{% endblock %}
{% block sidebar %}
{% endblock %}

View File

@ -0,0 +1,29 @@
{% extends "chrono/manager_category_history.html" %}
{% load gadjo static i18n %}
{% block css %}
{{ block.super }}
<link rel="stylesheet" type="text/css" media="all" href="{% static "css/gadjo.snapshotdiff.css" %}?{% start_timestamp %}"/>
{% endblock %}
{% block extrascripts %}
{{ block.super }}
<script src="{% static "js/gadjo.snapshotdiff.js" %}?{% start_timestamp %}"></script>
{% endblock %}
{% block appbar %}
<h2>{% trans 'Compare snapshots' %} ({% if mode == 'json' %}{% trans "JSON" %}{% else %}{% trans "Inspect" %}{% endif %})</h2>
<span class="actions">
<a href="?version1={{ snapshot1.pk }}&version2={{ snapshot2.pk }}&mode=inspect">{% trans "Compare inspect" %}</a>
<a href="?version1={{ snapshot1.pk }}&version2={{ snapshot2.pk }}&mode=json">{% trans "Compare JSON" %}</a>
</span>
{% endblock %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url 'chrono-manager-category-history-compare' pk=category.pk %}">{% trans "Compare snapshots" %}</a>
{% endblock %}
{% block content %}
{% include 'chrono/includes/snapshot_compare_fragment.html' %}
{% endblock %}

View File

@ -0,0 +1,18 @@
{% extends "chrono/manager_category_form.html" %}
{% load i18n %}
{% block appbar %}
<h2>{% trans 'Inspect' %}</h2>
{% endblock %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url 'chrono-manager-category-inspect' pk=category.pk %}">{% trans "Inspect" %}</a>
{% endblock %}
{% block content %}
{% include 'chrono/manager_category_inspect_fragment.html' %}
{% endblock %}
{% block sidebar %}
{% endblock %}

View File

@ -0,0 +1,21 @@
{% load i18n %}
<div class="pk-tabs">
<div class="pk-tabs--tab-list" role="tablist">
<button aria-controls="panel-information" aria-selected="true" id="tab-information" role="tab" tabindex="0">{% trans "Information" %}</button>
</div>
<div class="pk-tabs--container">
<div aria-labelledby="tab-information" id="panel-information" role="tabpanel" tabindex="0">
<div class="section">
<ul>
{% for label, value in object.get_inspect_fields %}
<li class="parameter-{{ label|slugify }}">
<span class="parameter">{% blocktrans %}{{ label }}:{% endblocktrans %}</span>
{{ value }}
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
</div>

View File

@ -1,32 +1,37 @@
{% extends "chrono/manager_base.html" %}
{% load i18n %}
{% block page-title-extra-label %}
{% trans "Categories" %}
{% endblock %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url 'chrono-manager-category-list' %}">{% trans "Categories" %}</a>
{% url 'chrono-manager-category-list' as object_list_url %}
<a href="{{ object_list_url }}">{% trans "Categories" %}</a>
{% include 'chrono/includes/application_breadcrumb_fragment.html' with title_no_application=_('Categories outside applications') %}
{% endblock %}
{% block appbar %}
<h2>{% trans 'Categories' %}</h2>
<span class="actions">
<a rel="popup" href="{% url 'chrono-manager-category-add' %}">{% trans 'New' %}</a>
</span>
{% include 'chrono/includes/application_appbar_fragment.html' with title_no_application=_('Categories outside applications') title_object_list=_('Categories') %}
{% endblock %}
{% block content %}
{% if object_list %}
<div>
<ul class="objects-list single-links">
{% for object in object_list %}
<li>
<a href="{% url 'chrono-manager-category-edit' pk=object.pk %}">{{ object.label }} ({{ object.slug }})</a>
<a href="{% url 'chrono-manager-category-edit' pk=object.pk %}">
{% include 'chrono/includes/application_icon_fragment.html' %}
{{ object.label }} ({{ object.slug }})
</a>
<a rel="popup" class="delete" href="{% url 'chrono-manager-category-delete' pk=object.id %}">{% trans "remove" %}</a>
</li>
{% endfor %}
</ul>
</div>
{% else %}
{% elif not no_application %}
<div class="big-msg-info">
{% blocktrans trimmed %}
This site doesn't have any category yet. Click on the "New" button in the top
@ -35,3 +40,14 @@
</div>
{% endif %}
{% endblock %}
{% block sidebar %}
{% if not application and not no_application %}
<aside id="sidebar">
<h3>{% trans "Actions" %}</h3>
<a class="button button-paragraph" rel="popup" href="{% url 'chrono-manager-category-add' %}">{% trans 'New category' %}</a>
{% include 'chrono/includes/application_list_fragment.html' with title_no_application=_('Categories outside applications') %}
</aside>
{% endif %}
{% endblock %}

View File

@ -29,3 +29,6 @@
{% include "gadjo/pagination.html" %}
</div>
{% endblock %}
{% block sidebar %}
{% endblock %}

View File

@ -28,12 +28,16 @@
<ul class="objects-list single-links">
{% for booking in booked %}
<li>
<a {% if booking.get_backoffice_url %}href="{{ booking.get_backoffice_url }}"{% endif %}>{{ booking.get_user_block }}, {{ booking.creation_datetime|date:"DATETIME_FORMAT" }}</a>
{% if not booking.primary_booking %}
<a rel="popup" class="delete" href="{% url 'chrono-manager-booking-cancel' pk=agenda.id booking_pk=booking.id %}?next={{ request.path }}">{% trans "Cancel" %}</a>
<li{% if booking.lease %} class="lease"{% endif %}>
{% if booking.lease %}
<span>{% trans "Currently being booked..." %}</span>
{% else %}
<a class="delete disabled" title="{% trans "Can not cancel a secondary booking" %}" href="#">{% trans "Cancel" %}</a>
<a {% if booking.get_backoffice_url %}href="{{ booking.get_backoffice_url }}"{% endif %}>{{ booking.get_user_block }}, {{ booking.creation_datetime|date:"DATETIME_FORMAT" }}</a>
{% if not booking.primary_booking %}
<a rel="popup" class="delete" href="{% url 'chrono-manager-booking-cancel' pk=agenda.id booking_pk=booking.id %}?next={{ request.path }}">{% trans "Cancel" %}</a>
{% else %}
<a class="delete disabled" title="{% trans "Can not cancel a secondary booking" %}" href="#">{% trans "Cancel" %}</a>
{% endif %}
{% endif %}
</li>
{% endfor %}
@ -53,7 +57,7 @@
<div>
<ul class="objects-list single-links">
{% for booking in waiting %}
<li><a {% if booking.get_backoffice_url %}href="{{ booking.get_backoffice_url }}"{% endif %}>{{ booking.get_user_block }}, {{ booking.creation_datetime|date:"DATETIME_FORMAT" }}</a></li>
<li{% if booking.lease %} class="lease"{% endif %}><a {% if booking.get_backoffice_url %}href="{{ booking.get_backoffice_url }}"{% endif %}>{% if booking.lease %}{% trans "Currently being booked..." %}{% else %}{{ booking.get_user_block }}, {{ booking.creation_datetime|date:"DATETIME_FORMAT" }}{% endif %}</a></li>
{% endfor %}
</ul>
</div>

View File

@ -2,13 +2,13 @@
{% load i18n %}
{% block agenda-extra-management-actions %}
<a rel="popup" href="{% url 'chrono-manager-agenda-import-events' pk=object.id %}">{% trans 'Import Events' %}</a>
<a rel="popup" href="{% url 'chrono-manager-agenda-add-event' pk=object.id %}">{% trans 'New Event' %}</a>
<a class="button button-paragraph" rel="popup" href="{% url 'chrono-manager-agenda-import-events' pk=object.id %}">{% trans 'Import Events' %}</a>
<a class="button button-paragraph" rel="popup" href="{% url 'chrono-manager-agenda-add-event' pk=object.id %}">{% trans 'New Event' %}</a>
{% endblock %}
{% block agenda-extra-menu-actions %}
{% block agenda-extra-navigation-actions %}
{% with lingo_url=object.get_lingo_url %}{% if lingo_url %}
<li><a href="{{ lingo_url }}">{% trans 'Pricing' context 'pricing' %}</a></li>
<a class="button button-paragraph" href="{{ lingo_url }}">{% trans 'Pricing' context 'pricing' %}</a>
{% endif %}{% endwith %}
{% endblock %}

View File

@ -1,6 +1,10 @@
{% extends "chrono/manager_events_type_list.html" %}
{% load i18n %}
{% block page-title-extra-label %}
{% if object.pk %}{{ object.label }}{% else %}{{ block.super }}{% endif %}
{% endblock %}
{% block breadcrumb %}
{{ block.super }}
{% if object.pk %}
@ -16,6 +20,14 @@
{% else %}
<h2>{% trans "New events type" %}</h2>
{% endif %}
{% if object.pk %}
<span class="actions">
<a href="{% url 'chrono-manager-events-type-inspect' pk=object.pk %}">{% trans 'Inspect' %}</a>
{% if show_history %}
<a href="{% url 'chrono-manager-events-type-history' pk=object.pk %}">{% trans 'History' %}</a>
{% endif %}
</span>
{% endif %}
{% endblock %}
{% block content %}
@ -55,3 +67,6 @@
</div>
</form>
{% endblock %}
{% block sidebar %}
{% endblock %}

View File

@ -0,0 +1,19 @@
{% extends "chrono/manager_events_type_form.html" %}
{% load i18n %}
{% block appbar %}
<h2>{% trans 'Events type history' %} - {{ events_type }}</h2>
{% endblock %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url 'chrono-manager-events-type-history' pk=events_type.pk %}">{% trans "History" %}</a>
{% endblock %}
{% block content %}
{% url 'chrono-manager-events-type-history-compare' pk=events_type.pk as compare_url %}
{% include 'chrono/includes/snapshot_history_fragment.html' %}
{% endblock %}
{% block sidebar %}
{% endblock %}

View File

@ -0,0 +1,29 @@
{% extends "chrono/manager_events_type_history.html" %}
{% load gadjo static i18n %}
{% block css %}
{{ block.super }}
<link rel="stylesheet" type="text/css" media="all" href="{% static "css/gadjo.snapshotdiff.css" %}?{% start_timestamp %}"/>
{% endblock %}
{% block extrascripts %}
{{ block.super }}
<script src="{% static "js/gadjo.snapshotdiff.js" %}?{% start_timestamp %}"></script>
{% endblock %}
{% block appbar %}
<h2>{% trans 'Compare snapshots' %} ({% if mode == 'json' %}{% trans "JSON" %}{% else %}{% trans "Inspect" %}{% endif %})</h2>
<span class="actions">
<a href="?version1={{ snapshot1.pk }}&version2={{ snapshot2.pk }}&mode=inspect">{% trans "Compare inspect" %}</a>
<a href="?version1={{ snapshot1.pk }}&version2={{ snapshot2.pk }}&mode=json">{% trans "Compare JSON" %}</a>
</span>
{% endblock %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url 'chrono-manager-events-type-history-compare' pk=events_type.pk %}">{% trans "Compare snapshots" %}</a>
{% endblock %}
{% block content %}
{% include 'chrono/includes/snapshot_compare_fragment.html' %}
{% endblock %}

View File

@ -0,0 +1,18 @@
{% extends "chrono/manager_events_type_form.html" %}
{% load i18n %}
{% block appbar %}
<h2>{% trans 'Inspect' %}</h2>
{% endblock %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url 'chrono-manager-events-type-inspect' pk=events_type.pk %}">{% trans "Inspect" %}</a>
{% endblock %}
{% block content %}
{% include 'chrono/manager_events_type_inspect_fragment.html' %}
{% endblock %}
{% block sidebar %}
{% endblock %}

View File

@ -0,0 +1,47 @@
{% load i18n %}
<div class="pk-tabs">
<div class="pk-tabs--tab-list" role="tablist">
<button aria-controls="panel-information" aria-selected="true" id="tab-information" role="tab" tabindex="0">{% trans "Information" %}</button>
<button aria-controls="panel-custom-fields" aria-selected="false" id="tab-custom-fields" role="tab" tabindex="-1">{% trans "Custom fields" %}</button>
</div>
<div class="pk-tabs--container">
<div aria-labelledby="tab-information" id="panel-information" role="tabpanel" tabindex="0">
<div class="section">
<ul>
{% for label, value in object.get_inspect_fields %}
<li class="parameter-{{ label|slugify }}">
<span class="parameter">{% blocktrans %}{{ label }}:{% endblocktrans %}</span>
{{ value }}
</li>
{% endfor %}
</ul>
</div>
</div>
<div aria-labelledby="tab-custom-fields" hidden id="panel-custom-fields" role="tabpanel" tabindex="0">
<div class="section">
{% for value in object.get_custom_fields %}
<h4>{{ value.label }}</h4>
<ul>
<li class="parameter-varname">
<span class="parameter">{% trans "Field slug:" %}</span>
{{ value.varname }}
</li>
<li class="parameter-label">
<span class="parameter">{% trans "Field label:" %}</span>
{{ value.label }}
</li>
<li class="parameter-field-type">
<span class="parameter">{% trans "Field type:" %}</span>
{% if value.field_type == 'text' %}{% trans "Text" %}{% endif %}
{% if value.field_type == 'textarea' %}{% trans "Textarea" %}{% endif %}
{% if value.field_type == 'textbool' %}{% trans "Boolean" %}{% endif %}
</li>
</ul>
{% endfor %}
</div>
</div>
</div>
</div>

View File

@ -1,16 +1,19 @@
{% extends "chrono/manager_base.html" %}
{% load i18n %}
{% block page-title-extra-label %}
{% trans "Events types" %}
{% endblock %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url 'chrono-manager-events-type-list' %}">{% trans "Events types" %}</a>
{% url 'chrono-manager-events-type-list' as object_list_url %}
<a href="{{ object_list_url }}">{% trans "Events types" %}</a>
{% include 'chrono/includes/application_breadcrumb_fragment.html' with title_no_application=_('Events types outside applications') %}
{% endblock %}
{% block appbar %}
<h2>{% trans 'Events types' %}</h2>
<span class="actions">
<a rel="popup" href="{% url 'chrono-manager-events-type-add' %}">{% trans 'New' %}</a>
</span>
{% include 'chrono/includes/application_appbar_fragment.html' with title_no_application=_('Events types outside applications') title_object_list=_('Events types') %}
{% endblock %}
{% block content %}
@ -22,13 +25,16 @@
<ul class="objects-list single-links">
{% for object in object_list %}
<li>
<a href="{% url 'chrono-manager-events-type-edit' pk=object.pk %}">{{ object.label }} ({{ object.slug }})</a>
<a href="{% url 'chrono-manager-events-type-edit' pk=object.pk %}">
{% include 'chrono/includes/application_icon_fragment.html' %}
{{ object.label }} ({{ object.slug }})
</a>
<a rel="popup" class="delete" href="{% url 'chrono-manager-events-type-delete' pk=object.pk %}">{% trans "remove" %}</a>
</li>
{% endfor %}
</ul>
</div>
{% else %}
{% elif not no_application %}
<div class="big-msg-info">
{% blocktrans trimmed %}
This site doesn't have any events type yet. Click on the "New" button in the top
@ -37,3 +43,14 @@
</div>
{% endif %}
{% endblock %}
{% block sidebar %}
{% if not application and not no_application %}
<aside id="sidebar">
<h3>{% trans "Actions" %}</h3>
<a class="button button-paragraph" rel="popup" href="{% url 'chrono-manager-events-type-add' %}">{% trans 'New events type' %}</a>
{% include 'chrono/includes/application_list_fragment.html' with title_no_application=_('Events types outside applications') %}
</aside>
{% endif %}
{% endblock %}

View File

@ -1,36 +1,14 @@
{% extends "chrono/manager_base.html" %}
{% load i18n %}
{% load i18n thumbnail chrono %}
{% block appbar %}
<h2>{% trans 'Agendas' %}</h2>
<span class="actions">
{% if user.is_staff or has_access_to_unavailability_calendars %}
<a class="extra-actions-menu-opener"></a>
<ul class="extra-actions-menu">
{% if user.is_staff %}
<li><a rel="popup" href="{% url 'chrono-manager-agendas-import' %}">{% trans 'Import' %}</a></li>
<li><a rel="popup" href="{% url 'chrono-manager-agendas-export' %}" data-autoclose-dialog="true">{% trans 'Export' %}</a></li>
<li><a href="{% url 'chrono-manager-events-type-list' %}">{% trans 'Events types' %}</a></li>
{% if shared_custody_enabled %}
<li><a rel="popup" href="{% url 'chrono-manager-shared-custody-settings' %}">{% trans 'Shared custody' %}</a></li>
{% endif %}
{% endif %}
{% if has_access_to_unavailability_calendars %}
<li><a href="{% url 'chrono-manager-unavailability-calendar-list' %}">{% trans 'Unavailability calendars' %}</a></li>
{% endif %}
{% if user.is_staff %}
<li><a href="{% url 'chrono-manager-resource-list' %}">{% trans 'Resources' %}</a></li>
{% endif %}
{% if ants_hub_enabled and user.is_staff %}
<li><a href="{% url 'chrono-manager-ants-hub' %}">{% trans 'ANTS Hub' %}</a></li>
{% endif %}
</ul>
{% endif %}
{% if user.is_staff %}
<a href="{% url 'chrono-manager-category-list' %}">{% trans 'Categories' %}</a>
<a rel="popup" href="{% url 'chrono-manager-agenda-add' %}">{% trans 'New' %}</a>
{% endif %}
</span>
{% include 'chrono/includes/application_appbar_fragment.html' with title_no_application=_('Agendas outside applications') title_object_list=_('Agendas') %}
{% endblock %}
{% block breadcrumb %}
{{ block.super }}
{% url 'chrono-manager-homepage' as object_list_url %}
{% include 'chrono/includes/application_breadcrumb_fragment.html' with title_no_application=_('Agendas outside applications') %}
{% endblock %}
{% block content %}
@ -38,16 +16,26 @@
{% if object_list %}
{% regroup object_list by category as agenda_groups %}
{% for group in agenda_groups %}
<div class="section">
{% if group.grouper %}<h3>{{ group.grouper }}</h3>{% elif not forloop.first %}<h3>{% trans "Misc" %}</h3>{% endif %}
<ul class="objects-list single-links">
{% for object in group.list %}
<li><a href="{% url 'chrono-manager-agenda-view' pk=object.id %}"><span class="badge">{{ object.get_real_kind_display }}</span> {{ object.label }}{% if user.is_staff %} <span class="identifier">[{% trans "identifier:" %} {{ object.slug }}]{% endif %}</span></a></li>
{% endfor %}
</ul>
{% with i=group.grouper.id|stringformat:"s" %}
{% with foldname='foldable-manager-category-group-'|add:i %}
<div class="section foldable {% if user|get_preference:foldname %}folded{% endif %}" data-section-folded-pref-name="{{foldname}}">
{% endwith %}
{% endwith %}
{% if group.grouper %}<h3>{{ group.grouper }}</h3>{% elif not forloop.first %}<h3>{% trans "Misc" %}</h3>{% endif %}
<ul class="objects-list single-links">
{% for object in group.list %}
<li>
<a href="{% url 'chrono-manager-agenda-view' pk=object.id %}">
<span class="badge">{{ object.get_real_kind_display }}</span>
{% include 'chrono/includes/application_icon_fragment.html' %}
{{ object.label }}{% if user.is_staff %} <span class="identifier">[{% trans "identifier:" %} {{ object.slug }}]{% endif %}</span>
</a>
</li>
{% endfor %}
</ul>
</div>
{% endfor %}
{% else %}
{% elif not no_application %}
<div class="big-msg-info">
{% blocktrans trimmed %}
This site doesn't have any agenda yet. Click on the "New" button in the top
@ -57,3 +45,42 @@
{% endif %}
{% endblock %}
{% block sidebar %}
{% if with_sidebar and not application and not no_application %}
<aside id="sidebar">
{% if user.is_staff %}
<h3>{% trans "Actions" %}</h3>
<a class="button button-paragraph" rel="popup" href="{% url 'chrono-manager-agenda-add' %}">{% trans 'New agenda' %}</a>
<a class="button button-paragraph" rel="popup" href="{% url 'chrono-manager-agendas-export' %}" data-autoclose-dialog="true">{% trans 'Export site' %}</a>
<a class="button button-paragraph" rel="popup" href="{% url 'chrono-manager-agendas-import' %}">{% trans 'Import site' %}</a>
{% endif %}
{% if user.is_staff or has_access_to_unavailability_calendars %}
<h3>{% trans "Navigation" %}</h3>
{% if user.is_staff %}
<a class="button button-paragraph" href="{% url 'chrono-manager-category-list' %}">{% trans 'Categories' %}</a>
<a class="button button-paragraph" href="{% url 'chrono-manager-events-type-list' %}">{% trans 'Events types' %}</a>
{% if shared_custody_enabled %}
<a class="button button-paragraph" rel="popup" href="{% url 'chrono-manager-shared-custody-settings' %}">{% trans 'Shared custody' %}</a>
{% endif %}
{% endif %}
{% if has_access_to_unavailability_calendars %}
<a class="button button-paragraph" href="{% url 'chrono-manager-unavailability-calendar-list' %}">{% trans 'Unavailability calendars' %}</a>
{% endif %}
{% if user.is_staff %}
<a class="button button-paragraph" href="{% url 'chrono-manager-resource-list' %}">{% trans 'Resources' %}</a>
{% if ants_hub_enabled %}
<a class="button button-paragraph" href="{% url 'chrono-manager-ants-hub' %}">{% trans 'ANTS Hub' %}</a>
{% endif %}
{% endif %}
{% if user.is_staff and audit_journal_enabled %}
<a class="button button-paragraph" href="{% url 'chrono-manager-audit-journal' %}">{% trans 'Audit journal' %}</a>
{% endif %}
{% endif %}
{% include 'chrono/includes/application_list_fragment.html' with title_no_application=_('Agendas outside applications') %}
</aside>
{% endif %}
{% endblock %}

View File

@ -55,3 +55,6 @@
</div>
</form>
{% endblock %}
{% block sidebar %}
{% endblock %}

View File

@ -38,12 +38,16 @@
{% endif %}
{% for booking in desk_info.bookings %}
<div class="booking{% if booking.color %} booking-color-{{ booking.color.index }}{% endif %}"
<div class="booking{% if booking.color %} booking-color-{{ booking.color.index }}{% endif %}{% if booking.lease %} lease{% endif %}"
style="height: {{ booking.css_height }}%; min-height: {{ booking.css_height }}%; top: {{ booking.css_top }}%;"
><span class="start-time">{{booking.event.start_datetime|date:"TIME_FORMAT"}}</span>
<a {% if booking.get_backoffice_url %}href="{{booking.get_backoffice_url}}"{% endif %}>{{ booking.get_user_block }}</a>
<a rel="popup" class="cancel" href="{% url 'chrono-manager-booking-cancel' pk=booking.event.agenda_id booking_pk=booking.pk %}?next={{ request.path }}">{% trans "Cancel" %}</a>
{% if booking.color %}<span class="booking-color-label booking-bg-color-{{ booking.color.index }}">{{ booking.color }}</span>{% endif %}
{% if booking.lease %}
{% trans "Currently being booked..." %}
{% else %}
<a {% if booking.get_backoffice_url %}href="{{booking.get_backoffice_url}}"{% endif %}>{{ booking.get_user_block }}</a>
<a rel="popup" class="cancel" href="{% url 'chrono-manager-booking-cancel' pk=booking.event.agenda_id booking_pk=booking.pk %}?next={{ request.path }}">{% trans "Cancel" %}</a>
{% if booking.color %}<span class="booking-color-label booking-bg-color-{{ booking.color.index }}">{{ booking.color }}</span>{% endif %}
{% endif %}
</div>
{% endfor %}
</td>

View File

@ -31,12 +31,16 @@
{% endfor %}
{% for slot in day.infos.booked_slots %}
<div class="booking{% if slot.booking.color %} booking-color-{{ slot.booking.color.index }}{% endif %}" style="left:{{ slot.css_left|stringformat:".1f" }}%;height:{{ slot.css_height|stringformat:".1f" }}%;min-height:{{ slot.css_height|stringformat:".1f" }}%;top:{{ slot.css_top|stringformat:".1f" }}%;width:{{ slot.css_width|stringformat:".1f" }}%;">
<div class="booking{% if slot.booking.color %} booking-color-{{ slot.booking.color.index }}{% endif %}{% if slot.booking.lease %} lease{% endif %}" style="left:{{ slot.css_left|stringformat:".1f" }}%;height:{{ slot.css_height|stringformat:".1f" }}%;min-height:{{ slot.css_height|stringformat:".1f" }}%;top:{{ slot.css_top|stringformat:".1f" }}%;width:{{ slot.css_width|stringformat:".1f" }}%;">
<span class="start-time">{{slot.booking.event.start_datetime|date:"TIME_FORMAT"}}</span>
<a {% if slot.booking.get_backoffice_url %}href="{{slot.booking.get_backoffice_url}}"{% endif %}>{{ slot.booking.get_user_block }}</a>
<a rel="popup" class="cancel" href="{% url 'chrono-manager-booking-cancel' pk=slot.booking.event.agenda_id booking_pk=slot.booking.id %}?next={{ request.path }}">{% trans "Cancel" %}</a>
{% if not single_desk %}<span class="desk">{{ slot.desk }}</span>{% endif %}
{% if slot.booking.color %}<span class="booking-color-label booking-bg-color-{{ slot.booking.color.index }}">{{ slot.booking.color }}</span>{% endif %}
{% if slot.booking.lease %}
{% trans "Currently being booked..." %}
{% else %}
<a {% if slot.booking.get_backoffice_url %}href="{{slot.booking.get_backoffice_url}}"{% endif %}>{{ slot.booking.get_user_block }}</a>
<a rel="popup" class="cancel" href="{% url 'chrono-manager-booking-cancel' pk=slot.booking.event.agenda_id booking_pk=slot.booking.id %}?next={{ request.path }}">{% trans "Cancel" %}</a>
{% if not single_desk %}<span class="desk">{{ slot.desk }}</span>{% endif %}
{% if slot.booking.color %}<span class="booking-color-label booking-bg-color-{{ slot.booking.color.index }}">{{ slot.booking.color }}</span>{% endif %}
{% endif %}
</div>
{% endfor %}
{% endif %}

View File

@ -7,28 +7,69 @@
{% endblock %}
{% block appbar %}
<h2>{% trans "Check booking" %}</h2>
<h2>
{% blocktrans trimmed with user=view.bookings.0.user_name %}
Check booking for {{ user }}
{% endblocktrans %}
</h2>
{% endblock %}
{% block content %}
{% if multiple_bookings %}
<div class="pk-tabs">
<div class="pk-tabs--tab-list" role="tablist" aria-label="{% trans "Booking tabs" %}">
{% for booking in view.bookings %}
<button role="tab"
aria-selected="{{ forloop.first|yesno:"true,false" }}"
aria-controls="panel-{{ booking.pk }}"
id="tab-{{ booking.pk }}"
tabindex="{{ forloop.first|yesno:"0,-1" }}"
>
{{ booking.start_time|time:"H:i" }} - {{ booking.end_time|time:"H:i" }}
</button>
{% endfor %}
</div>
{% endif %}
<form
method="post"
enctype="multipart/form-data"
data-fill-start_time="{{ object.booking.start_time|time:"H:i" }}"
data-fill-end_time="{{ object.booking.end_time|time:"H:i" }}"
{% if multiple_bookings %}class="pk-tabs--container"{% endif %}
>
{% if allow_adding_check %}
<p>
<a
rel="popup"
href="{% url 'chrono-manager-partial-booking-check' pk=agenda.pk booking_pk=object.booking.pk %}"
>
{% trans "Add second booking check" %}
</a>
</p>
{% endif %}
{% csrf_token %}
{{ form|with_template }}
{% for booking in view.bookings %}
<div
class="booking-check-forms"
data-fill-start_time="{{ booking.start_time|time:"H:i" }}"
data-fill-end_time="{{ booking.end_time|time:"H:i" }}"
{% if multiple_bookings %}
id="panel-{{ booking.pk }}"
role="tabpanel" tabindex="0" {% if not forloop.first %}hidden{% endif %}
data-tab-slug="{{ booking.pk }}"
aria-labelledby="tab-{{ booking.pk }}"
{% endif %}
>
<div class="booking-check-form">
{{ booking.check_forms.0|with_template }}
</div>
{% if forms|length > 1 %}
<fieldset
class="gadjo-foldable {% if not forms.1.instance.pk and not forms.1.errors %}gadjo-folded{% endif %}"
>
<legend class="gadjo-foldable-widget">{% trans "Second booking check" %}</legend>
<div class="booking-check-form gadjo-folding">
{{ booking.check_forms.1|with_template }}
</div>
</fieldset>
{% endif %}
</div>
{% endfor %}
<div class="buttons">
<button class="submit-button">{% trans "Save" %}</button>
<a class="cancel" href="{{ agenda.get_absolute_url }}">{% trans 'Cancel' %}</a>
@ -36,29 +77,37 @@
<script>
$(function () {
presence_check_type_select = $('.widget[id=id_presence_check_type_p]');
absence_check_type_select = $('.widget[id=id_absence_check_type_p]');
$('input[type=radio][name=presence]').change(function() {
if (!this.checked)
return;
if (this.value == 'True') {
presence_check_type_select.show();
absence_check_type_select.hide();
} else if (this.value == 'False') {
absence_check_type_select.show();
presence_check_type_select.hide();
} else {
presence_check_type_select.hide();
absence_check_type_select.hide();
}
}).change();
// Tabs are not loaded if form is in popup, remove this block when fixed in gadjo
$(document.querySelectorAll('.pk-tabs')).each(function(i, el) {
el.tabs = new gadjo_js.Tabs(el);
});
$('.booking-check-form').each(function () {
let presence_check_type_select = $(this).children('.widget[id*=presence_check_type]');
let absence_check_type_select = $(this).children('.widget[id*=absence_check_type]');
$(this).find('input[type=radio][name*=presence]').change(function() {
if (!this.checked)
return;
if (this.value == 'True') {
$(this).parents('.widget').siblings('.widget').show();
absence_check_type_select.hide();
} else if (this.value == 'False') {
$(this).parents('.widget').siblings('.widget').show();
presence_check_type_select.hide();
} else {
$(this).parents('.widget').siblings('.widget').hide();
}
}).change();
});
$('.time-widget-button').on('click', function() {
var widget_name = $(this).data('related-widget');
var value = $(this).parents('form').data('fill-' + widget_name);
var widget_id = widget_name.split('-').at(-1);
var value = $(this).parents('.booking-check-forms').data('fill-' + widget_id);
$('[name="' + widget_name + '"]').val(value);
});
});
</script>
</form>
{% if multiple_bookings %}</div>{% endif %}
{% endblock %}

View File

@ -44,83 +44,133 @@
</div>
{% endif %}
<div class="partial-booking" style="--nb-hours: {{ hours|length }}">
<div class="partial-booking--hours-list" aria-hidden="true">
<div
class="partial-booking"
style="--nb-hours: {{ hours|length }}"
data-start-datetime="{{ start_datetime.isoformat }}"
data-end-datetime="{{ end_datetime.isoformat }}"
>
{% if view.date.date == today %}
<div class="partial-booking--hour-indicator-wrapper" aria-hidden="true">
<div class="partial-booking--hour-indicator" hidden></div>
<script>
const hour_indicator = (function() {
const indicator = document.querySelector('.partial-booking--hour-indicator')
const div_container = document.querySelector('.partial-booking')
const start = new Date(div_container.dataset.startDatetime).getTime()
const end = new Date(div_container.dataset.endDatetime).getTime() + 3600000 - start
const indicator_position = function() {
const now = Date.now() - start
indicator.style.left = now * 100 / end + "%"
}
indicator_position();
setInterval(indicator_position, 60000)
indicator.hidden = false;
})();
</script>
</div>
{% endif %}
<div class="partial-booking--hours-list">
{% for hour in hours %}
<div class="partial-booking--hour">{{ hour|time:"H" }}&#x202Fh</div>
{% endfor %}
</div>
<div class="partial-booking--occupation-rate-list">
<h3 class="occupation-rate-list--title">{% trans "Occupation rate" %}</h3>
{% for rate in occupation_rates %}
<p
class="occupation-rate {% if rate.overbooked %}overbooked{% endif %}"
style="--rate-percent: {{ rate.height_percent }};"
aria-label="{% blocktrans trimmed with start=rate.start_time|time:"H:i" end=rate.end_time|time:"H:i" %}
From {{ start }} to {{ end }}:
{% endblocktrans %}
{{ rate.percent }}% ({{ rate.booked_places }}/{{ event.places }})"
>
<span class="occupation-rate--info">
{{ rate.percent }}% <br> ({{ rate.booked_places }}/{{ event.places }})
</span>
</p>
{% endfor %}
</div>
<div class="partial-booking--registrant-items">
{% for user in users %}
<section class="partial-booking--registrant">
{% spaceless %}
<h3 class="registrant--name">
{% if allow_check and user.check_url %}
<span class="registrant--name-label">{{ user.name }}</span>
{% if allow_check %}
<a
class="partial-booking--check-icon"
rel="popup"
{% if user.bookings|length > 1 %}data-selector=".pk-tabs"{% endif %}
href="{{ user.check_url }}"
>{{ user.name }}</a>
{% else %}
<span>{{ user.name }}</span>
><span class="sr-only">{% trans "Check" %}</span></a>
{% endif %}
</h3>
{% endspaceless %}
<div class="registrant--datas">
<div class="registrant--bar-container">
{% for booking in user.bookings %}
{% if booking.start_time %}
<a
class="registrant--bar clearfix booking"
title="{% trans "Booked period" %}"
style="left: {{ booking.css_left }}%; width: {{ booking.css_width }}%;"
{% if allow_check and not booking.user_check %}
rel="popup"
href="{% url 'chrono-manager-partial-booking-check' pk=agenda.pk booking_pk=booking.pk %}"
{% endif %}
>
<strong class="sr-only">{% trans "Booked period:" %}</strong>
<time class="start-time" datetime="{{ booking.start_time|time:"H:i" }}">{{ booking.start_time|time:"H:i" }}</time>
<time class="end-time" datetime="{{ booking.end_time|time:"H:i" }}">{{ booking.end_time|time:"H:i" }}</time>
</a>
{% endif %}
{% endfor %}
</div>
{% if user.bookings %}
{% if not filterset.form.cleaned_data or 'booked' in filterset.form.cleaned_data.display %}
<div class="registrant--bar-container">
{% for check in user.booking_checks %}
<a
class="registrant--bar clearfix check {{ check.css_class }}"
title="{% trans "Checked period" %}"
style="left: {{ check.css_left }}%; width: {{ check.css_width }}%;"
{% if allow_check %}
rel="popup"
href="{% url 'chrono-manager-partial-booking-update-check' pk=agenda.pk check_pk=check.pk %}"
{% endif %}
>
<strong class="sr-only">{% trans "Checked period:" %}</strong>
<time class="start-time" datetime="{{ check.start_time|time:"H:i" }}">{{ check.start_time|time:"H:i" }}</time>
<time class="end-time" datetime="{{ check.end_time|time:"H:i" }}">{{ check.end_time|time:"H:i" }}</time>
{% if check.type_label %}<span>{{ check.type_label }}</span>{% endif %}
</a>
{% endfor %}
</div>
<div class="registrant--bar-container">
{% for check in user.booking_checks %}
{% if check.computed_start_time and check.computed_end_time %}
{% for booking in user.bookings %}
{% if booking.start_time %}
<p
class="registrant--bar clearfix computed {{ check.css_class }}"
title="{% trans "Computed period" %}"
style="left: {{ check.computed_css_left }}%; width: {{ check.computed_css_width }}%;"
class="registrant--bar booking"
title="{% trans "Booked period" %}"
style="left: {{ booking.css_left }}%; width: {{ booking.css_width }}%;"
>
<strong class="sr-only">{% trans "Computed period:" %}</strong>
<time class="start-time" datetime="{{ check.computed_start_time|time:"H:i" }}">{{ check.computed_start_time|time:"H:i" }}</time>
<time class="end-time" datetime="{{ check.computed_end_time|time:"H:i" }}">{{ check.computed_end_time|time:"H:i" }}</time>
<strong class="sr-only">{% trans "Booked period:" %}</strong>
<time class="start-time" datetime="{{ booking.start_time|time:"H:i" }}">{{ booking.start_time|time:"H:i" }}</time>
<time class="end-time" datetime="{{ booking.end_time|time:"H:i" }}">{{ booking.end_time|time:"H:i" }}</time>
{% if not booking.from_recurring_fillslots %}
<span class="occasional">{% trans "occasional" %}</span>
{% endif %}
</p>
{% endif %}
{% endfor %}
</div>
{% endif %}
{% if user.bookings %}
{% if not filterset.form.cleaned_data or 'checked' in filterset.form.cleaned_data.display %}
<div class="registrant--bar-container">
{% for check in user.booking_checks %}
<p
class="registrant--bar check {{ check.css_class }}"
title="{% trans "Checked period" %}"
style="left: {{ check.css_left }}%;{% if check.css_width %} width: {{ check.css_width }}%;{% endif %}"
>
<strong class="sr-only">{% trans "Checked period:" %}</strong>
{% if check.start_time %}
<time class="start-time" datetime="{{ check.start_time|time:"H:i" }}">{{ check.start_time|time:"H:i" }}</time>
{% endif %}
{% if check.end_time %}
<time class="end-time" datetime="{{ check.end_time|time:"H:i" }}">{{ check.end_time|time:"H:i" }}</time>
{% endif %}
{% if check.type_label %}<span>{{ check.type_label }}</span>{% endif %}
</p>
{% endfor %}
</div>
{% endif %}
{% if not filterset.form.cleaned_data or 'computed' in filterset.form.cleaned_data.display %}
<div class="registrant--bar-container">
{% for check in user.booking_checks %}
{% if check.computed_start_time and check.computed_end_time %}
<p
class="registrant--bar computed {{ check.css_class }}"
title="{% trans "Computed period" %}"
style="left: {{ check.computed_css_left }}%; width: {{ check.computed_css_width }}%;"
>
<strong class="sr-only">{% trans "Computed period:" %}</strong>
<time class="start-time" datetime="{{ check.computed_start_time|time:"H:i" }}">{{ check.computed_start_time|time:"H:i" }}</time>
<time class="end-time" datetime="{{ check.computed_end_time|time:"H:i" }}">{{ check.computed_end_time|time:"H:i" }}</time>
</p>
{% endif %}
{% endfor %}
</div>
{% endif %}
{% endif %}
</div>
</section>

View File

@ -3,33 +3,54 @@
{% block content %}
<table class="agenda-table partial-bookings">
<thead>
<tr>
<td></td>
<div class="pk-table-wrapper">
<table class="partial-booking partial-booking-month">
<colgroup>
<col class="name" />
{% for day in days %}
<th>
<a href="{% url 'chrono-manager-agenda-day-view' pk=agenda.pk year=day.date|date:"Y" month=day.date|date:"m" day=day.date|date:"d" %}">{{ day|date:"d" }}</a>
</th>
<col class="{% if day|date:"w" == "0" or day|date:"w" == "6" %}we{% endif %}
{% if today == day.date %}today{% endif %}
" />
{% endfor %}
</tr>
</thead>
</colgroup>
<tbody>
{% for booking_info in user_booking_info %}
<tr class="{% cycle 'odd' 'even' %}">
<th>{{ booking_info.user_name }}</th>
{% for booking in booking_info.bookings %}
<td class="day-cell">
{% if booking %}
<span class="booking {{ booking.check_css_class }}"></span>
{% endif %}
</td>
<thead>
<tr class="partial-booking-month--day-list">
<td></td>
{% for day in days %}
<th scope="col" class="partial-booking-month--day{% if today == day.date %} today{% endif %}">
<a href="{% url 'chrono-manager-agenda-day-view' pk=agenda.pk year=day.date|date:"Y" month=day.date|date:"m" day=day.date|date:"d" %}">
<time datetime="{{ day|date:"Y-m-d" }}">{{ day|date:"d" }}</time>
</a>
</th>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</thead>
</table>
<tbody class="partial-booking-month--registrant-items">
{% for booking_info in user_booking_info %}
<tr class="partial-booking-month--registrant">
<th class="registrant--name" scope="row">{{ booking_info.user_name }}</th>
{% for booking in booking_info.bookings %}
<td class="registrant--day-cell">
{% if booking %}
{% if booking.check_css_class == 'present' %}
{% trans "Present" as booking_status %}
{% elif booking.check_css_class == 'absent' %}
{% trans "Absent" as booking_status %}
{% else %}
{% trans "Not checked" as booking_status %}
{% endif %}
<span title="{{ booking_status }}" class="booking {{ booking.check_css_class }}">
<span class="sr-only">{{ booking_status }}</span>
</span>
{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -59,3 +59,6 @@
{% endfor %}
{% endblock %}
{% block sidebar %}
{% endblock %}

View File

@ -2,7 +2,7 @@
{% load i18n %}
{% block page-title-extra-label %}
- {{ resource.label }}
{{ resource.label }}
{% endblock %}
{% block breadcrumb %}
@ -17,8 +17,10 @@
<span class="actions">
{% block appbar-extras %}
{% if request.user.is_staff %}
<a rel="popup" href="{% url 'chrono-manager-resource-edit' pk=resource.pk %}">{% trans 'Edit' %}</a>
<a rel="popup" href="{% url 'chrono-manager-resource-delete' pk=resource.pk %}">{% trans 'Delete' %}</a>
<a class="extra-actions-menu-opener"></a>
<ul class="extra-actions-menu">
<li><a rel="popup" href="{% url 'chrono-manager-resource-delete' pk=resource.pk %}">{% trans 'Delete' %}</a></li>
</ul>
{% endif %}
{% include "chrono/manager_resource_view_buttons_fragment.html" with no_today=True no_opened=True %}
{% endblock %}
@ -53,3 +55,21 @@
</div>
{% endblock %}
{% block sidebar %}
<aside id="sidebar">
{% if request.user.is_staff %}
<h3>{% trans "Actions" %}</h3>
<a class="button button-paragraph" rel="popup" href="{% url 'chrono-manager-resource-edit' pk=resource.pk %}">{% trans 'Edit' %}</a>
{% endif %}
<h3>{% trans "Navigation" %}</h3>
<a class="button button-paragraph" href="{% url 'chrono-manager-resource-inspect' pk=resource.pk %}">{% trans 'Inspect' %}</a>
{% if show_history %}
<a class="button button-paragraph" href="{% url 'chrono-manager-resource-history' pk=resource.pk %}">{% trans 'History' %}</a>
{% endif %}
{% url 'chrono-manager-resource-list' as object_list_url %}
{% include 'chrono/includes/application_detail_fragment.html' %}
</aside>
{% endblock %}

View File

@ -1,6 +1,15 @@
{% extends "chrono/manager_home.html" %}
{% extends "chrono/manager_resource_list.html" %}
{% load i18n %}
{% block breadcrumb %}
{{ block.super }}
{% if object.pk %}
<a href="{% url 'chrono-manager-resource-edit' pk=object.pk %}">{{ object.label }}</a>
{% else %}
<a href="{% url 'chrono-manager-resource-add' %}">{% trans "New Resource" %}</a>
{% endif %}
{% endblock %}
{% block appbar %}
{% if object.id %}
<h2>{% trans "Edit Resource" %}</h2>
@ -20,3 +29,6 @@
</div>
</form>
{% endblock %}
{% block sidebar %}
{% endblock %}

View File

@ -0,0 +1,19 @@
{% extends "chrono/manager_resource_detail.html" %}
{% load i18n %}
{% block appbar %}
<h2>{% trans 'Resource history' %} - {{ resource }}</h2>
{% endblock %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url 'chrono-manager-resource-history' pk=resource.pk %}">{% trans "History" %}</a>
{% endblock %}
{% block content %}
{% url 'chrono-manager-resource-history-compare' pk=resource.pk as compare_url %}
{% include 'chrono/includes/snapshot_history_fragment.html' %}
{% endblock %}
{% block sidebar %}
{% endblock %}

View File

@ -0,0 +1,29 @@
{% extends "chrono/manager_resource_history.html" %}
{% load gadjo static i18n %}
{% block css %}
{{ block.super }}
<link rel="stylesheet" type="text/css" media="all" href="{% static "css/gadjo.snapshotdiff.css" %}?{% start_timestamp %}"/>
{% endblock %}
{% block extrascripts %}
{{ block.super }}
<script src="{% static "js/gadjo.snapshotdiff.js" %}?{% start_timestamp %}"></script>
{% endblock %}
{% block appbar %}
<h2>{% trans 'Compare snapshots' %} ({% if mode == 'json' %}{% trans "JSON" %}{% else %}{% trans "Inspect" %}{% endif %})</h2>
<span class="actions">
<a href="?version1={{ snapshot1.pk }}&version2={{ snapshot2.pk }}&mode=inspect">{% trans "Compare inspect" %}</a>
<a href="?version1={{ snapshot1.pk }}&version2={{ snapshot2.pk }}&mode=json">{% trans "Compare JSON" %}</a>
</span>
{% endblock %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url 'chrono-manager-resource-history-compare' pk=resource.pk %}">{% trans "Compare snapshots" %}</a>
{% endblock %}
{% block content %}
{% include 'chrono/includes/snapshot_compare_fragment.html' %}
{% endblock %}

View File

@ -0,0 +1,18 @@
{% extends "chrono/manager_resource_detail.html" %}
{% load i18n %}
{% block appbar %}
<h2>{% trans 'Inspect' %}</h2>
{% endblock %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url 'chrono-manager-resource-inspect' pk=resource.pk %}">{% trans "Inspect" %}</a>
{% endblock %}
{% block content %}
{% include 'chrono/manager_resource_inspect_fragment.html' %}
{% endblock %}
{% block sidebar %}
{% endblock %}

View File

@ -0,0 +1,22 @@
{% load i18n %}
<div class="pk-tabs">
<div class="pk-tabs--tab-list" role="tablist">
<button aria-controls="panel-information" aria-selected="true" id="tab-information" role="tab" tabindex="0">{% trans "Information" %}</button>
</div>
<div class="pk-tabs--container">
<div aria-labelledby="tab-information" id="panel-information" role="tabpanel" tabindex="0">
<div class="section">
<ul>
{% for label, value in object.get_inspect_fields %}
<li class="parameter-{{ label|slugify }}">
<span class="parameter">{% blocktrans %}{{ label }}:{% endblocktrans %}</span>
{{ value }}
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
</div>

View File

@ -1,16 +1,19 @@
{% extends "chrono/manager_base.html" %}
{% load i18n %}
{% block page-title-extra-label %}
{% trans "Resources" %}
{% endblock %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url 'chrono-manager-resource-list' %}">{% trans "Resources" %}</a>
{% url 'chrono-manager-resource-list' as object_list_url %}
<a href="{{ object_list_url }}">{% trans "Resources" %}</a>
{% include 'chrono/includes/application_breadcrumb_fragment.html' with title_no_application=_('Resources outside applications') %}
{% endblock %}
{% block appbar %}
<h2>{% trans 'Resources' %}</h2>
<span class="actions">
<a rel="popup" href="{% url 'chrono-manager-resource-add' %}">{% trans 'New' %}</a>
</span>
{% include 'chrono/includes/application_appbar_fragment.html' with title_no_application=_('Resources outside applications') title_object_list=_('Resources') %}
{% endblock %}
@ -20,12 +23,15 @@
<ul class="objects-list single-links">
{% for object in object_list %}
<li>
<a href="{% url 'chrono-manager-resource-view' pk=object.pk %}">{{ object.label }} ({{ object.slug }})</a>
<a href="{% url 'chrono-manager-resource-view' pk=object.pk %}">
{% include 'chrono/includes/application_icon_fragment.html' %}
{{ object.label }} ({{ object.slug }})
</a>
</li>
{% endfor %}
</ul>
</div>
{% else %}
{% elif not no_application %}
<div class="big-msg-info">
{% blocktrans trimmed %}
This site doesn't have any resource yet. Click on the "New" button in the top
@ -34,3 +40,14 @@
</div>
{% endif %}
{% endblock %}
{% block sidebar %}
{% if not application and not no_application %}
<aside id="sidebar">
<h3>{% trans "Actions" %}</h3>
<a class="button button-paragraph" rel="popup" href="{% url 'chrono-manager-resource-add' %}">{% trans 'New resource' %}</a>
{% include 'chrono/includes/application_list_fragment.html' with title_no_application=_('Resources outside applications') %}
</aside>
{% endif %}
{% endblock %}

View File

@ -32,3 +32,6 @@
{% block content %}
{% include "chrono/manager_resource_week_timetable_fragment.html" %}
{% endblock %}
{% block sidebar %}
{% endblock %}

Some files were not shown because too many files have changed in this diff Show More