Compare commits

...

300 Commits

Author SHA1 Message Date
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
Frédéric Péters 031961ad80 translation update
gitea/chrono/pipeline/head This commit looks good Details
2023-11-02 14:47:56 +01:00
Lauréline Guérin 5b8419efe5 agendas: add times in notify_checked for partial bookings (#82842)
gitea/chrono/pipeline/head This commit looks good Details
2023-11-02 12:22:11 +01:00
Lauréline Guérin cff4ce0861 agendas: notify_checked, loop on booking checks instead of bookings (#82842) 2023-11-02 12:22:11 +01:00
Lauréline Guérin 737ba6f0bb manager: fix display of "check event" button (#82839)
gitea/chrono/pipeline/head Build queued... Details
2023-11-02 12:20:22 +01:00
Lauréline Guérin 72be0166f3 manager: fold check filters (#82839) 2023-11-02 12:20:22 +01:00
Lauréline Guérin dae40958f4 manager: fix event details head title (#82839) 2023-11-02 12:20:22 +01:00
Lauréline Guérin 05703dddb1 misc: fix failing tests at midnight (#82926)
gitea/chrono/pipeline/head This commit looks good Details
2023-11-02 12:19:12 +01:00
Frédéric Péters 78928bc760 api: strip white spaces and dots from received phone numbers (#82889)
gitea/chrono/pipeline/head This commit looks good Details
2023-10-31 16:32:29 +01:00
Thomas NOËL a548753f2a debian: add uwsgi/chrono SyslogIdentifier in service (#82977)
gitea/chrono/pipeline/head This commit looks good Details
2023-10-31 13:18:22 +01:00
Emmanuel Cazenave 8a7f83a02d translation update
gitea/chrono/pipeline/head This commit looks good Details
2023-10-31 12:03:11 +01:00
Emmanuel Cazenave 36d1ea9ec0 setup: compute pep440 compliant dirty version number (#81731)
gitea/chrono/pipeline/head This commit looks good Details
2023-10-30 17:40:37 +01:00
Lauréline Guérin 368c239218 manager: fix wording in partial booking day view (#82840)
gitea/chrono/pipeline/head This commit looks good Details
2023-10-30 16:10:06 +01:00
Lauréline Guérin 81aa0d95fc manager: partial bookings, no delete option if no check object (#82840) 2023-10-30 16:10:06 +01:00
Lauréline Guérin 61a6bc35bb
misc: fix failing tests at midnight (#82920)
gitea/chrono/pipeline/head This commit looks good Details
2023-10-28 00:43:33 +02:00
Lauréline Guérin b15e4a3c7c
api: complete agendas dependencies (#82713)
gitea/chrono/pipeline/head This commit looks good Details
2023-10-27 15:51:50 +02:00
Lauréline Guérin f5e3f625d2
api: make import/export endpoints generic for other kinds of objects (#82713) 2023-10-27 15:51:50 +02:00
Lauréline Guérin a940ee3961 api: export/import, add uuid for role in dependencies view (#82764)
gitea/chrono/pipeline/head This commit looks good Details
2023-10-27 15:49:46 +02:00
Lauréline Guérin 9defbefe1e
misc: fix failing tests at midnight (#82753)
gitea/chrono/pipeline/head This commit looks good Details
2023-10-27 10:40:29 +02:00
Emmanuel Cazenave cba5520541 api: add module with applification API (#82198)
gitea/chrono/pipeline/head This commit looks good Details
2023-10-17 10:29:46 +02:00
Valentin Deniaud a25a8e6ef1 translation update
gitea/chrono/pipeline/head This commit looks good Details
2023-10-09 10:58:28 +02:00
Valentin Deniaud 2a56ba5432 manager: allow adding second check to partial booking (#80371)
gitea/chrono/pipeline/head This commit looks good Details
2023-10-09 10:48:50 +02:00
Valentin Deniaud 2e22706c08 manager: add separate view to update booking check (#80371) 2023-10-09 10:48:50 +02:00
Valentin Deniaud 81e93dd4c5 agendas: allow multiple checks by booking (#80371) 2023-10-09 10:48:50 +02:00
Valentin Deniaud 3cb80d478a agendas: store computed start/end times on booking check (#80371) 2023-10-09 10:48:50 +02:00
Valentin Deniaud ec497c66d9 all: use new BookingCheck model (#80371) 2023-10-09 10:48:50 +02:00
Valentin Deniaud bcae843c0d agendas: migrate booking check data into new model (#80371) 2023-10-09 10:48:50 +02:00
Valentin Deniaud 6d31c85dd7 agendas: add BookingCheck model (#80371) 2023-10-09 10:48:50 +02:00
Frédéric Péters 17cddfbd4a tox: keep on testing drf 3.12 only for now (#81946)
gitea/chrono/pipeline/head This commit looks good Details
2023-10-05 22:04:24 +02:00
Frédéric Péters 57a67073e3 misc: replace serializers.NullBooleanField (#81946) 2023-10-05 22:04:24 +02:00
Frédéric Péters ca32dc3a36 setup: allow djangorestframework 3.14 (#81946) 2023-10-05 22:04:24 +02:00
Valentin Deniaud fb7d928206 all: do not write booking check info in secondary bookings (#81986)
gitea/chrono/pipeline/head This commit looks good Details
2023-10-05 11:06:53 +02:00
Valentin Deniaud 9b315f4be3 api: count secondary booking presence from primary booking in stats (#81986) 2023-10-05 11:06:53 +02:00
Valentin Deniaud 1fd95681fe manager: allow checking partial bookings separately (#81370)
gitea/chrono/pipeline/head Build queued... Details
2023-10-05 10:56:41 +02:00
Frédéric Péters fc86701ab2 translation update
gitea/chrono/pipeline/head This commit looks good Details
2023-10-04 14:32:49 +02:00
Valentin Deniaud f34af55592 ants_hub: differentiate "place" translation (#81980)
gitea/chrono/pipeline/head This commit looks good Details
2023-10-04 11:59:07 +02:00
Valentin Deniaud 2cae3b7724 translation update
gitea/chrono/pipeline/head This commit looks good Details
2023-10-03 11:56:38 +02:00
Valentin Deniaud 23a1b70dd7 manager: hide unused settings for partial bookings agendas (#80465)
gitea/chrono/pipeline/head This commit looks good Details
2023-10-03 10:41:33 +02:00
Valentin Deniaud a13003cdec api: allow different hours per day in partial bookings recurring fillslots (#78086)
gitea/chrono/pipeline/head This commit looks good Details
2023-10-03 09:34:18 +02:00
Frédéric Péters 6aa243817e ci: keep on using pylint 2 while pylint-django is not ready (#81905)
gitea/chrono/pipeline/head This commit looks good Details
2023-10-03 06:45:34 +02:00
Lauréline Guérin 15b2b26c08
misc: fix test for partial bookings running after 18h (#80877)
gitea/chrono/pipeline/head This commit looks good Details
2023-10-02 15:48:17 +02:00
Lauréline Guérin 918903fc8c
agendas: store computed times for partial bookings (#80877) 2023-10-02 15:48:17 +02:00
Valentin Deniaud 33e53a694a manager: forbid checking arrival after departure for partial bookings (#81619)
gitea/chrono/pipeline/head This commit looks good Details
2023-10-02 10:11:54 +02:00
Valentin Deniaud ec86a9bbcc manager: partial bookings, allow user check without booking (#80369)
gitea/chrono/pipeline/head This commit looks good Details
2023-10-02 10:11:32 +02:00
Lauréline Guérin 28c3641d50
manager: need to be staff to duplicate an agenda (#81583)
gitea/chrono/pipeline/head This commit looks good Details
2023-09-26 16:23:51 +02:00
Frédéric Péters 655ffeb610 translation update
gitea/chrono/pipeline/head This commit looks good Details
2023-09-21 20:12:47 +02:00
Thomas Jund bdce64d56e partial booking manager: change #main-content overflow (#80356)
gitea/chrono/pipeline/head This commit looks good Details
to allow sticky hours list
2023-09-21 12:02:37 +02:00
Thomas Jund e6be5342e6 partial booking manager: move end time at right (#80356) 2023-09-21 11:59:10 +02:00
Lauréline Guérin 60de169359 manager: disable check for partial bookings if check locked (#80983)
gitea/chrono/pipeline/head This commit looks good Details
2023-09-21 10:47:42 +02:00
Lauréline Guérin e231d27751 manager: mark event as checked for partial bookings (#80983) 2023-09-21 10:47:42 +02:00
Valentin Deniaud 42cc548a33 api: allow getting all user bookings as ICS (#80685)
gitea/chrono/pipeline/head This commit looks good Details
2023-09-21 10:34:45 +02:00
Thomas Jund 8b924ef670 manager css: allow multiple partial bookings on the same line (#80050)
gitea/chrono/pipeline/head This commit looks good Details
2023-09-19 17:00:19 +02:00
Valentin Deniaud 7c34e4fe7f manager: allow multiple partial bookings for one user on the same day (#80050) 2023-09-19 16:59:12 +02:00
Valentin Deniaud 11ef5b4bd2 api: allow partial booking in all event fillslot endpoints (#80050) 2023-09-19 16:57:23 +02:00
Valentin Deniaud 9b340a01d6 manager: add button to prefill partial booking check hours (#80045)
gitea/chrono/pipeline/head This commit looks good Details
2023-09-18 15:33:53 +02:00
Frédéric Péters 9a841fc31e translation update
gitea/chrono/pipeline/head This commit looks good Details
2023-09-15 10:44:42 +02:00
Serghei Mihai 5fe881fdb5 manager: don't show booking colours of cancelled bookings (#81110)
gitea/chrono/pipeline/head This commit looks good Details
2023-09-15 09:33:53 +02:00
Lauréline Guérin 93081c6e46
manager: partial bookings, events redirect to day view (#80982)
gitea/chrono/pipeline/head This commit looks good Details
2023-09-15 08:57:21 +02:00
Lauréline Guérin c8d71aa997
api: events check endpoint, return also times and minutes (#80973)
gitea/chrono/pipeline/head Build queued... Details
2023-09-15 08:57:01 +02:00
Lauréline Guérin 2b288340b6
manager: display computed period for partial bookings (#80842)
gitea/chrono/pipeline/head This commit looks good Details
2023-09-15 08:54:09 +02:00
Lauréline Guérin 16e3602391
agendas: methods to compute start and end times from check times (#80842) 2023-09-15 08:54:09 +02:00
Lauréline Guérin df0223abf2
manager: configure invoicing options for partial bookings (#80842) 2023-09-15 08:54:09 +02:00
Lauréline Guérin 0fe3933ed1
manager: fix wording for checked period in day view (#80842) 2023-09-15 08:54:09 +02:00
Lauréline Guérin 0b6ca9d5d2
agendas: fix event_overlaps method with recurrences (#80851)
gitea/chrono/pipeline/head This commit looks good Details
2023-09-08 12:08:55 +02:00
Lauréline Guérin d16b35067e
manager: fix day view for partial bookings and recurring event (#80851) 2023-09-08 12:08:55 +02:00
Lauréline Guérin fbe2deea93
api: add partial_bookings field in agenda details (#81002)
gitea/chrono/pipeline/head This commit looks good Details
2023-09-08 12:01:12 +02:00
Valentin Deniaud 7e946138ac agendas: import/export all exception sources (#80219)
gitea/chrono/pipeline/head This commit looks good Details
2023-09-04 16:55:43 +02:00
Benjamin Dauvergne a68026e839 ants_hub: fix typo in order_by (#80590)
gitea/chrono/pipeline/head This commit looks good Details
2023-08-28 13:15:11 +02:00
Benjamin Dauvergne 5fbbe0e984 ants_hub: allow multiple identifiant_predemande (#80592)
gitea/chrono/pipeline/head This commit looks good Details
2023-08-28 11:30:55 +02:00
Frédéric Péters 0f81147829 translation update
gitea/chrono/pipeline/head This commit looks good Details
2023-08-17 21:46:35 +02:00
Valentin Deniaud 7859f0558e manager: use proper widget for agenda minimal booking time (#75884)
gitea/chrono/pipeline/head This commit looks good Details
2023-08-17 09:52:46 +02:00
Valentin Deniaud e2d70795b1 tests: add missing ordering in test_recurring_events (#80402)
gitea/chrono/pipeline/head This commit looks good Details
2023-08-16 14:24:22 +02:00
Valentin Deniaud 84463c84bf api: remove legacy fillslots views (#80352)
gitea/chrono/pipeline/head This commit looks good Details
2023-08-16 11:13:38 +02:00
Valentin Deniaud 8127fbff66 manager: report all errors at once in CSV import (#70523)
gitea/chrono/pipeline/head This commit looks good Details
2023-08-16 09:46:26 +02:00
Valentin Deniaud b9c02c20bd manager: split methods of CSV import (#70523) 2023-08-16 09:46:26 +02:00
Valentin Deniaud 60f31525ee api: allow changing bookings from date in recurring fillslots (#78921)
gitea/chrono/pipeline/head This commit looks good Details
2023-08-16 09:46:01 +02:00
Valentin Deniaud f371341d7d api: allow weekday name in recurring event display template (#80042)
gitea/chrono/pipeline/head Build queued... Details
2023-08-03 17:42:17 +02:00
Frédéric Péters 5f14f2a47b general: add a timestamp to static URLs, to avoid caching issues (#80227)
gitea/chrono/pipeline/head This commit looks good Details
2023-08-03 09:23:09 +02:00
Valentin Deniaud b0f8af1dea misc: update git-blame-ignore-revs to ignore quote changes (#79866)
gitea/chrono/pipeline/head This commit looks good Details
2023-08-02 12:18:24 +02:00
Valentin Deniaud b71dc670c7 misc: apply double-quote-string-fixer (#79866) 2023-08-02 12:17:51 +02:00
Valentin Deniaud ebe3b7eb10 misc: add pre commit hook to force single quotes (#79866) 2023-08-02 12:17:51 +02:00
Valentin Deniaud 3d576b48ee api: return virtual agenda booking count in statistics (#79355)
gitea/chrono/pipeline/head This commit looks good Details
2023-08-02 12:17:40 +02:00
Valentin Deniaud 95618bd475 manager: fix event index when CSV import file has header (#79845)
gitea/chrono/pipeline/head This commit looks good Details
2023-08-02 12:08:04 +02:00
Valentin Deniaud c1dd25d2c7 manager: hide empty check type field for partial bookings (#80048)
gitea/chrono/pipeline/head This commit looks good Details
2023-07-31 10:04:36 +02:00
Valentin Deniaud a70045ea5b manager: allow more precise time check for partial bookings (#80046)
gitea/chrono/pipeline/head Build queued... Details
2023-07-31 10:04:10 +02:00
Valentin Deniaud 6b964d708b manager: display partial booking agenda badge on homepage (#80041)
gitea/chrono/pipeline/head This commit looks good Details
2023-07-27 14:57:15 +02:00
Thomas NOËL 7359d50232 debian: remove memory-report from uwsgi default configuration (#79890)
gitea/chrono/pipeline/head This commit looks good Details
2023-07-20 17:56:25 +02:00
Valentin Deniaud 7dbf299eda translation update
gitea/chrono/pipeline/head This commit looks good Details
2023-07-18 18:17:00 +02:00
Valentin Deniaud d1597d7ab3 manager: add partial bookings month view (#79654)
gitea/chrono/pipeline/head This commit looks good Details
2023-07-18 09:47:55 +02:00
Valentin Deniaud 0cc06d2047 manager: respect enable_check_for_future_events in partial bookings (#79642)
gitea/chrono/pipeline/head This commit looks good Details
2023-07-18 09:44:31 +02:00
Valentin Deniaud 0146309c4f manager: include booking check filters in partial bookings view (#79623)
gitea/chrono/pipeline/head Build queued... Details
2023-07-18 09:43:24 +02:00
Valentin Deniaud bfea238c08 manager: move event checks code to mixin (#79623) 2023-07-18 09:43:24 +02:00
Valentin Deniaud 46e60b37a1 manager: allow booking check in partial bookings agendas (#78081)
gitea/chrono/pipeline/head This commit looks good Details
2023-07-18 09:42:00 +02:00
Valentin Deniaud beb31a38ca manager: handle only one partial booking by user (#78081) 2023-07-18 09:42:00 +02:00
Frédéric Péters e6b5ace001 tox: limit weasyprint (#76965)
gitea/chrono/pipeline/head This commit looks good Details
2023-07-14 17:13:47 +02:00
Frédéric Péters 0835bb633d build: remove weasyprint limit (#76965) 2023-07-14 17:13:41 +02:00
Valentin Deniaud 6b79f58bd5 tests: fix recurring event test (#78084)
gitea/chrono/pipeline/head This commit looks good Details
2023-07-04 13:41:09 +02:00
Valentin Deniaud 152110888c api: allow updating partial bookings in recurring events fillslots (#78084)
gitea/chrono/pipeline/head There was a failure building this commit Details
2023-07-04 13:29:45 +02:00
Valentin Deniaud f28cd4d104 api: factorize booking filter in recurring events fillslots (#78084) 2023-07-04 13:29:45 +02:00
Thomas Jund 00ad7b0747 manager: update partial bookings html & css (#78728)
gitea/chrono/pipeline/head This commit looks good Details
2023-07-03 17:29:18 +02:00
Valentin Deniaud b615c57bad translation update
gitea/chrono/pipeline/head This commit looks good Details
2023-07-03 16:31:43 +02:00
Valentin Deniaud 4834743c6d manager: forbid multiple events on same day in partial bookings agenda (#79112)
gitea/chrono/pipeline/head This commit looks good Details
2023-07-03 15:42:20 +02:00
Valentin Deniaud 06af90608f agendas: convert week day in db to iso numbering (#79168)
gitea/chrono/pipeline/head This commit looks good Details
2023-07-03 14:42:32 +02:00
Valentin Deniaud 900300dd05 agendas: use iso week days in events and shared custody (#79168) 2023-07-03 14:42:32 +02:00
Lauréline Guérin 747928c680 api: remove useless code in fillslots views (#79300)
gitea/chrono/pipeline/head This commit looks good Details
2023-07-03 11:45:05 +02:00
Lauréline Guérin 848d014720 api: dispatch fillslots views in other views (#79300) 2023-07-03 11:45:05 +02:00
Valentin Deniaud cdcb663f85 manager: support full day opening in partial bookings agenda (#79171)
gitea/chrono/pipeline/head This commit looks good Details
2023-07-03 09:45:59 +02:00
Valentin Deniaud 9125b7af10 api: return agenda booking_form_url (#72545)
gitea/chrono/pipeline/head Build queued... Details
2023-06-29 15:16:58 +02:00
Valentin Deniaud cce129b8bc api: do not disabled full events when booked in events datetimes (#79120)
gitea/chrono/pipeline/head This commit looks good Details
2023-06-28 09:37:23 +02:00
Lauréline Guérin 90d3c29b72
manager: fix check page with empty values in extra_data filters (#79053)
gitea/chrono/pipeline/head This commit looks good Details
2023-06-26 15:26:06 +02:00
Frédéric Péters 4492026242 misc: apply french orthography rectifications of 1990 (#79004)
gitea/chrono/pipeline/head This commit looks good Details
2023-06-25 09:40:15 +02:00
Frédéric Péters 36b8fd4f9d ci: build deb package for bookworm (#78968)
gitea/chrono/pipeline/head This commit looks good Details
2023-06-23 17:25:27 +02:00
Lauréline Guérin cb9944050e manager: fix views with year < 1000 (#78231)
gitea/chrono/pipeline/head This commit looks good Details
2023-06-23 08:02:37 +02:00
Valentin Deniaud f9ae449f7c manager: fix import of virtual agendas (#78897)
gitea/chrono/pipeline/head This commit looks good Details
2023-06-22 14:41:08 +02:00
Benjamin Dauvergne dae3c05148 translation update (#78588)
gitea/chrono/pipeline/head This commit looks good Details
2023-06-15 19:15:50 +02:00
Benjamin Dauvergne 1a59dcb97c ants-hub: add the case for 5 persons (#78588) 2023-06-15 19:14:55 +02:00
Emmanuel Cazenave 51a888fe81 translation update
gitea/chrono/pipeline/head This commit looks good Details
2023-06-15 15:38:22 +02:00
Benjamin Dauvergne ff90c12afc css: add icon-edit declaration to scss file (#78501)
gitea/chrono/pipeline/head This commit looks good Details
2023-06-15 10:14:25 +02:00
Benjamin Dauvergne 6b4f6a3a0c ants_hub: ignore bookings with an empty string in ants_predemande (#78530)
gitea/chrono/pipeline/head This commit looks good Details
2023-06-14 19:08:38 +02:00
Emmanuel Cazenave a303d359d8 api: add real agenda info for each slot (#78064)
gitea/chrono/pipeline/head This commit looks good Details
2023-06-14 11:52:01 +02:00
Emmanuel Cazenave 0e84adb3ac tests: move datetimes virtual tests in its own file (#78064) 2023-06-14 11:52:01 +02:00
Benjamin Dauvergne 17269bc8c4 translation update
gitea/chrono/pipeline/head This commit looks good Details
2023-06-13 12:08:57 +02:00
Benjamin Dauvergne 505c53fcdd ants_hub: display meeting type duration when configuration ANTS meeting types (#78398)
gitea/chrono/pipeline/head This commit looks good Details
2023-06-13 01:06:28 +02:00
Benjamin Dauvergne be5852f6b6 tests: add more meeting types in ants tests (#78398) 2023-06-13 01:06:28 +02:00
Benjamin Dauvergne 849a1cfd76 tests: remove unused import in ants tests (#78398) 2023-06-12 19:11:09 +02:00
Benjamin Dauvergne 6c55dea4de ants_hub: hide full_sync model flags (#78353)
gitea/chrono/pipeline/head This commit looks good Details
2023-06-12 13:24:25 +02:00
Lauréline Guérin b51790ae8e agendas: an event with partial bookings is never full (#78082)
gitea/chrono/pipeline/head This commit looks good Details
2023-06-10 09:42:11 +02:00
Frédéric Péters 7a94673f94 translation update
gitea/chrono/pipeline/head This commit looks good Details
2023-06-08 11:03:19 +02:00
Benjamin Dauvergne b1286b72fa ants_hub: fix use of uwsgidecorators.spool (#78242)
gitea/chrono/pipeline/head This commit looks good Details
2023-06-07 17:37:32 +02:00
Benjamin Dauvergne 0465c9311f ants_hub: do not synchronize if CHRONO_ANTS_HUB_URL is absent (#78243)
gitea/chrono/pipeline/head This commit looks good Details
2023-06-07 16:02:06 +02:00
Emmanuel Cazenave ca5265c1e3 translation update
gitea/chrono/pipeline/head This commit looks good Details
2023-06-07 15:55:45 +02:00
Benjamin Dauvergne 26e80f7d61 packaging: include chrono/apps/ants_hub/templates (#78239)
gitea/chrono/pipeline/head This commit looks good Details
2023-06-07 15:25:46 +02:00
Benjamin Dauvergne 7508be742d add ants_hub application (#76286)
gitea/chrono/pipeline/head This commit looks good Details
It manages synchronization of meetings agendas with the ANTS hub.
2023-06-07 14:52:29 +02:00
Benjamin Dauvergne c6056d676c misc: import urls_utils from combo (#76286)
The existing one does not work with path() patterns.
2023-06-07 14:52:29 +02:00
Valentin Deniaud 028c51e86a translation update
gitea/chrono/pipeline/head This commit looks good Details
2023-06-01 13:33:51 +02:00
Valentin Deniaud aa511e416f manager: allow importing partial bookings events (#78061)
gitea/chrono/pipeline/head This commit looks good Details
2023-06-01 11:56:38 +02:00
Valentin Deniaud 18470cb6be api: include end_datetime for partial bookings events (#78056)
gitea/chrono/pipeline/head This commit looks good Details
2023-06-01 10:05:42 +02:00
Valentin Deniaud 2a776007e0 api: allow partial booking in recurring events fillslots (#78056) 2023-06-01 10:05:42 +02:00
Valentin Deniaud 1b9bd2f428 manager: add partial bookings day view (#78056) 2023-06-01 10:05:42 +02:00
Valentin Deniaud 21c0d0da1f manager: configure partial bookings agenda (#78056) 2023-06-01 10:05:42 +02:00
Valentin Deniaud 640466742c agendas: add fields to allow partial bookings (#78056) 2023-06-01 10:05:42 +02:00
Lauréline Guérin 12a1fbaa72
misc: fix failing tests at midnigth (#78069)
gitea/chrono/pipeline/head This commit looks good Details
2023-06-01 09:47:08 +02:00
Benjamin Dauvergne df6721d580 agendas: implements free time calculation (#76335)
gitea/chrono/pipeline/head This commit looks good Details
SharedTimePeriod gets a get_intervals(mintime, maxtime) method returning the
list of intervals of open time between mintime and maxtime.

Agenda gets a get_free_time(mintime, maxtime) method returning the
list of intervals of open time between mintime and maxtim.
2023-05-30 22:24:57 +02:00
Benjamin Dauvergne 8278e6dca1 tests: add helper functions to manage meetings agendas (#76335) 2023-05-30 22:24:57 +02:00
Benjamin Dauvergne fd28d075a5 utils: add IntervalSet.__add__ (#76335)
Most algo around agendas amounts to adding a bunch of intervals them
removing some. The method to add them was missing.
2023-05-30 22:24:57 +02:00
Benjamin Dauvergne 881b585c3d misc: move interval module in chrono.utils (#76335)
Just some cleaning.
2023-05-30 22:24:57 +02:00
Benjamin Dauvergne 343e06552c agendas: move get_all_slots() and get_min/max_datetime() as Agenda's methods (#76335) 2023-05-30 22:24:57 +02:00
Frédéric Péters ffb83732ba translation update
gitea/chrono/pipeline/head This commit looks good Details
2023-05-30 18:04:04 +02:00
Valentin Deniaud 13f44f7cd0 api: disable legacy fillslots api by default (#77806)
gitea/chrono/pipeline/head This commit looks good Details
2023-05-30 15:04:47 +02:00
Pierre Ducroquet 84bdeb3ed3 tests: force an analyze pass after we load data so PG has usable statistics (#77590)
gitea/chrono/pipeline/head This commit looks good Details
2023-05-30 10:08:42 +02:00
Pierre Ducroquet 3318680f5f api: push back common criteria to 'help' PG (#77590) 2023-05-30 10:08:42 +02:00
Lauréline Guérin 31f4e5c059 agendas: add an index on Event (#77590) 2023-05-30 10:08:42 +02:00
Lauréline Guérin 11559acd35 api: don't build details if not requested (#77590) 2023-05-30 10:08:42 +02:00
Valentin Deniaud 2d2052f91c api: add agenda patch endpoint (#77852)
gitea/chrono/pipeline/head Build queued... Details
2023-05-30 10:05:15 +02:00
Valentin Deniaud 80b2b70654 manager: allow exporting agendas by category (#77790)
gitea/chrono/pipeline/head This commit looks good Details
2023-05-30 10:04:50 +02:00
Frédéric Péters c19cc93e39 debian: apply new pre-commit-debian (#77727)
gitea/chrono/pipeline/head This commit looks good Details
2023-05-27 21:40:20 +02:00
Frédéric Péters a5529dff96 ci: upgrade pre-commit-debian (#77727) 2023-05-27 21:40:08 +02:00
Frédéric Péters 16851755e6 fix erroneous translation of maximum delay string (#77819)
gitea/chrono/pipeline/head This commit looks good Details
2023-05-23 08:50:15 +02:00
Valentin Deniaud f0041d5ad8 manager: hide internal roles in agenda settings (#77155)
gitea/chrono/pipeline/head This commit looks good Details
2023-05-17 16:31:33 +02:00
Thomas Jund 6f85279226 css: remove useless styles to appbar title (#77258)
gitea/chrono/pipeline/head This commit looks good Details
in relation with #74725
2023-05-17 10:44:19 +02:00
Valentin Deniaud 656574d410 translation update
gitea/chrono/pipeline/head This commit looks good Details
2023-05-15 17:21:32 +02:00
Frédéric Péters a61d68d26d ci: disable concurrent builds (#77617)
gitea/chrono/pipeline/head This commit looks good Details
2023-05-15 16:38:53 +02:00
Valentin Deniaud b35708abcb manager: restore week number display (#75011)
gitea/chrono/pipeline/head This commit looks good Details
2023-05-15 10:34:28 +02:00
Valentin Deniaud 139f96672d manager: show date range in week view page title (#75010)
gitea/chrono/pipeline/head Build queued... Details
2023-05-15 10:34:03 +02:00
Valentin Deniaud 4ec41f3e09 manager: move today button before date view buttons group (#75008)
gitea/chrono/pipeline/head There was a failure building this commit Details
2023-05-15 10:33:08 +02:00
Valentin Deniaud 8e3631a346 manager: highlight Today button on today's page (#75009)
gitea/chrono/pipeline/head This commit looks good Details
2023-05-15 10:20:19 +02:00
Valentin Deniaud 9ed93daca8 manager: move settings and print buttons to kebab menu (#75013)
gitea/chrono/pipeline/head There was a failure building this commit Details
2023-05-15 10:16:26 +02:00
Valentin Deniaud 2fa8da2617 manager: factorize appbar between date views (#75013) 2023-05-15 10:16:26 +02:00
Thomas Jund c603e2a180 use input type date to swich days in meetings agenda day view (#75017)
gitea/chrono/pipeline/head There was a failure building this commit Details
2023-05-15 10:06:14 +02:00
Frédéric Péters 006d732479 misc: pass string to gettext before variable interpolation (#77517)
gitea/chrono/pipeline/head This commit looks good Details
2023-05-12 08:58:24 +02:00
227 changed files with 23735 additions and 4882 deletions

View File

@ -21,3 +21,5 @@ e07c450d7c8a5f80aafe185c85ebed73fe39d9e7
b38f5f901e1bef556bd95f45bcc041b092b1a617
# misc: bump djhtml version (#75442)
34309253eddc15f17a280656a3ffec072e79731a
# misc: apply double-quote-string-fixer (#79866)
b71dc670c7f90c675edb510643b992aaf69f852a

View File

@ -1,6 +1,10 @@
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
hooks:
- id: double-quote-string-fixer
- repo: https://github.com/psf/black
rev: 23.1.0
hooks:
@ -27,6 +31,6 @@ repos:
- id: djhtml
args: ['--tabwidth', '2']
- repo: https://git.entrouvert.org/pre-commit-debian.git
rev: v0.1
rev: v0.3
hooks:
- id: pre-commit-debian

7
Jenkinsfile vendored
View File

@ -2,10 +2,11 @@
pipeline {
agent any
options { disableConcurrentBuilds() }
stages {
stage('Unit Tests') {
steps {
sh 'tox -rv -- --numprocesses 3'
sh 'NUMPROCESSES=3 tox -rv'
}
post {
always {
@ -31,9 +32,9 @@ pipeline {
'''
).trim()
if (env.GIT_BRANCH == 'main' || env.GIT_BRANCH == 'origin/main') {
sh "sudo -H -u eobuilder /usr/local/bin/eobuilder -d bullseye ${SHORT_JOB_NAME}"
sh "sudo -H -u eobuilder /usr/local/bin/eobuilder -d bullseye,bookworm ${SHORT_JOB_NAME}"
} else if (env.GIT_BRANCH.startsWith('hotfix/')) {
sh "sudo -H -u eobuilder /usr/local/bin/eobuilder -d bullseye --branch ${env.GIT_BRANCH} --hotfix ${SHORT_JOB_NAME}"
sh "sudo -H -u eobuilder /usr/local/bin/eobuilder -d bullseye,bookworm --branch ${env.GIT_BRANCH} --hotfix ${SHORT_JOB_NAME}"
}
}
}

View File

@ -8,6 +8,7 @@ recursive-include chrono/manager/static *.css *.scss *.js
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
# 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

@ -3,6 +3,8 @@
import django.contrib.postgres.fields
from django.db import migrations, models
from chrono.agendas.models import WEEKDAY_CHOICES
class Migration(migrations.Migration):
dependencies = [
@ -14,9 +16,7 @@ class Migration(migrations.Migration):
model_name='event',
name='recurrence_days',
field=django.contrib.postgres.fields.ArrayField(
base_field=models.IntegerField(
choices=[(0, 'Mo'), (1, 'Tu'), (2, 'We'), (3, 'Th'), (4, 'Fr'), (5, 'Sa'), (6, 'Su')]
),
base_field=models.IntegerField(choices=WEEKDAY_CHOICES),
blank=True,
null=True,
size=None,

View File

@ -4,6 +4,8 @@ import django.contrib.postgres.fields
import django.db.models.deletion
from django.db import migrations, models
from chrono.agendas.models import WEEKDAY_CHOICES
class Migration(migrations.Migration):
dependencies = [
@ -60,17 +62,7 @@ class Migration(migrations.Migration):
(
'days',
django.contrib.postgres.fields.ArrayField(
base_field=models.IntegerField(
choices=[
(0, 'Mo'),
(1, 'Tu'),
(2, 'We'),
(3, 'Th'),
(4, 'Fr'),
(5, 'Sa'),
(6, 'Su'),
]
),
base_field=models.IntegerField(choices=WEEKDAY_CHOICES),
size=None,
verbose_name='Days',
),

View File

@ -18,7 +18,7 @@ class Migration(migrations.Migration):
blank=True,
default=datetime.time(0, 0),
null=True,
help_text='Ex.: 08:00:00. If left empty, available events will be those that are later than the current time.',
help_text='If left empty, available events will be those that are later than the current time.',
verbose_name='Booking opening time',
),
),

View File

@ -0,0 +1,21 @@
import django.db.models.expressions
import django.db.models.functions.datetime
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('agendas', '0152_auto_20230331_0834'),
]
operations = [
migrations.AddIndex(
model_name='event',
index=models.Index(
django.db.models.functions.datetime.ExtractWeekDay('start_datetime'),
django.db.models.expressions.F('start_datetime'),
condition=models.Q(('cancelled', False)),
name='start_datetime_dow_index',
),
),
]

View File

@ -0,0 +1,32 @@
# Generated by Django 3.2.18 on 2023-05-31 08:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('agendas', '0153_event_index'),
]
operations = [
migrations.AddField(
model_name='agenda',
name='partial_bookings',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='booking',
name='end_time',
field=models.TimeField(null=True),
),
migrations.AddField(
model_name='booking',
name='start_time',
field=models.TimeField(null=True),
),
migrations.AddField(
model_name='event',
name='end_time',
field=models.TimeField(null=True, verbose_name='End time'),
),
]

View File

@ -0,0 +1,18 @@
import os
from django.db import migrations
with open(
os.path.join(
os.path.dirname(os.path.realpath(__file__)), '..', 'sql', 'event_booked_places_and_full_triggers.sql'
)
) as sql_file:
sql_forwards = sql_file.read()
class Migration(migrations.Migration):
dependencies = [
('agendas', '0154_partial_booking_fields'),
]
operations = [migrations.RunSQL(sql=sql_forwards, reverse_sql=migrations.RunSQL.noop)]

View File

@ -0,0 +1,27 @@
# Generated by Django 3.2.18 on 2023-06-28 10:46
import django.db.models.expressions
import django.db.models.functions.datetime
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('agendas', '0155_event_triggers'),
]
operations = [
migrations.RemoveIndex(
model_name='event',
name='start_datetime_dow_index',
),
migrations.AddIndex(
model_name='event',
index=models.Index(
django.db.models.functions.datetime.ExtractIsoWeekDay('start_datetime'),
django.db.models.expressions.F('start_datetime'),
condition=models.Q(('cancelled', False)),
name='start_datetime_dow_index',
),
),
]

View File

@ -0,0 +1,41 @@
# Generated by Django 3.2.18 on 2023-06-28 10:47
from django.db import migrations, models
from django.db.models import F, Func, OuterRef, Subquery
class ArraySubquery(Subquery):
template = 'ARRAY(%(subquery)s)'
def convert_week_days(apps, schema_editor):
Event = apps.get_model('agendas', 'Event')
SharedCustodyRule = apps.get_model('agendas', 'SharedCustodyRule') # TODO
events_with_days = (
Event.objects.filter(pk=OuterRef('pk'))
.annotate(week_day=Func(F('recurrence_days'), function='unnest', output_field=models.IntegerField()))
.annotate(new_week_day=F('week_day') + 1)
.values('new_week_day')
)
Event.objects.filter(recurrence_days__isnull=False).update(
recurrence_days=ArraySubquery(events_with_days)
)
rules_with_days = (
SharedCustodyRule.objects.filter(pk=OuterRef('pk'))
.annotate(week_day=Func(F('days'), function='unnest', output_field=models.IntegerField()))
.annotate(new_week_day=F('week_day') + 1)
.values('new_week_day')
)
SharedCustodyRule.objects.update(days=ArraySubquery(rules_with_days))
class Migration(migrations.Migration):
dependencies = [
('agendas', '0156_update_dow_index'),
]
operations = [
migrations.RunPython(convert_week_days, migrations.RunPython.noop),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 3.2.18 on 2023-07-05 10:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('agendas', '0157_convert_week_days'),
]
operations = [
migrations.AddField(
model_name='booking',
name='user_check_end_time',
field=models.TimeField(null=True, verbose_name='Departure'),
),
migrations.AddField(
model_name='booking',
name='user_check_start_time',
field=models.TimeField(null=True, verbose_name='Arrival'),
),
]

View File

@ -0,0 +1,33 @@
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('agendas', '0158_partial_booking_check_fields'),
]
operations = [
migrations.AddField(
model_name='agenda',
name='invoicing_tolerance',
field=models.PositiveSmallIntegerField(
default=0, validators=[django.core.validators.MaxValueValidator(59)], verbose_name='Tolerance'
),
),
migrations.AddField(
model_name='agenda',
name='invoicing_unit',
field=models.CharField(
choices=[
('hour', 'Per hour'),
('half_hour', 'Per half hour'),
('quarter', 'Per quarter-hour'),
('minute', 'Per minute'),
],
default='hour',
max_length=10,
verbose_name='Invoicing',
),
),
]

View File

@ -0,0 +1,20 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('agendas', '0159_partial_bookings_invoicing'),
]
operations = [
migrations.AddField(
model_name='booking',
name='computed_end_time',
field=models.TimeField(null=True),
),
migrations.AddField(
model_name='booking',
name='computed_start_time',
field=models.TimeField(null=True),
),
]

View File

@ -0,0 +1,40 @@
# Generated by Django 3.2.21 on 2023-10-04 13:51
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('agendas', '0160_computed_times'),
]
operations = [
migrations.CreateModel(
name='BookingCheck',
fields=[
(
'id',
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
('presence', models.BooleanField()),
('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)),
('type_label', models.CharField(blank=True, max_length=150, null=True)),
(
'booking',
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name='user_check',
to='agendas.booking',
),
),
],
options={
'ordering': ['start_time'],
},
),
]

View File

@ -0,0 +1,62 @@
# Generated by Django 3.2.18 on 2023-08-22 15:47
from django.db import migrations
def migrate_booking_check_data(apps, schema_editor):
Booking = apps.get_model('agendas', 'Booking')
BookingCheck = apps.get_model('agendas', 'BookingCheck')
booking_checks = []
bookings = list(Booking.objects.filter(user_was_present__isnull=False))
for booking in bookings:
booking_check = BookingCheck(
booking=booking,
presence=booking.user_was_present,
start_time=booking.user_check_start_time,
end_time=booking.user_check_end_time,
computed_start_time=booking.computed_start_time,
computed_end_time=booking.computed_end_time,
type_slug=booking.user_check_type_slug,
type_label=booking.user_check_type_label,
)
booking_checks.append(booking_check)
BookingCheck.objects.bulk_create(booking_checks)
def reverse_migrate_booking_check_data(apps, schema_editor):
Booking = apps.get_model('agendas', 'Booking')
bookings = list(Booking.objects.filter(user_check__isnull=False).select_related('user_check'))
for booking in bookings:
booking.user_was_present = booking.user_check.presence
booking.user_check_start_time = booking.user_check.start_time
booking.user_check_end_time = booking.user_check.end_time
booking.computed_start_time = booking.computed_start_time
booking.computed_end_time = booking.computed_end_time
booking.user_check_type_slug = booking.user_check.type_slug
booking.user_check_type_label = booking.user_check.type_label
Booking.objects.bulk_update(
bookings,
fields=[
'user_was_present',
'user_check_start_time',
'user_check_end_time',
'computed_start_time',
'computed_end_time',
'user_check_type_slug',
'user_check_type_label',
],
)
class Migration(migrations.Migration):
dependencies = [
('agendas', '0161_add_booking_check_model'),
]
operations = [
migrations.RunPython(migrate_booking_check_data, reverse_migrate_booking_check_data),
]

View File

@ -0,0 +1,40 @@
# Generated by Django 3.2.18 on 2023-08-22 15:51
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('agendas', '0162_migrate_booking_check_data'),
]
operations = [
migrations.RemoveField(
model_name='booking',
name='user_check_end_time',
),
migrations.RemoveField(
model_name='booking',
name='user_check_start_time',
),
migrations.RemoveField(
model_name='booking',
name='user_check_type_label',
),
migrations.RemoveField(
model_name='booking',
name='user_check_type_slug',
),
migrations.RemoveField(
model_name='booking',
name='user_was_present',
),
migrations.RemoveField(
model_name='booking',
name='computed_end_time',
),
migrations.RemoveField(
model_name='booking',
name='computed_start_time',
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 3.2.21 on 2023-10-05 11:41
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('agendas', '0163_remove_booking_check_fields'),
]
operations = [
migrations.AlterField(
model_name='bookingcheck',
name='booking',
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, related_name='user_checks', to='agendas.booking'
),
),
]

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

@ -13,6 +13,14 @@ BEGIN
WHERE b.event_id = NEW.id AND b.cancellation_datetime IS NULL;
END IF;
-- for events agenda with partial bookings, event is never full
PERFORM 1 FROM agendas_agenda a WHERE a.id = NEW.agenda_id AND a.partial_bookings IS TRUE;
IF FOUND THEN
NEW.almost_full = false;
NEW.full = false;
RETURN NEW;
END IF;
-- update almost_full field
IF (NEW.booked_places >= NEW.places * 0.9) THEN
NEW.almost_full = true;

View File

@ -1,5 +1,6 @@
import collections
import datetime
import re
from django.contrib.auth.models import Group
from django.db import models, transaction
@ -42,6 +43,15 @@ class StringOrListField(serializers.ListField):
return super().to_internal_value(data)
class PhoneNumbersStringOrListField(serializers.ListField):
def to_internal_value(self, data):
if isinstance(data, str):
data = [s.strip() for s in data.split(',') if s.strip()]
# strip white spaces and dots
data = [re.sub(r'[\s\.]', '', x) for x in data]
return super().to_internal_value(data)
class CommaSeparatedStringField(serializers.ListField):
def get_value(self, dictionary):
return super(serializers.ListField, self).get_value(dictionary)
@ -79,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)
@ -91,10 +101,24 @@ class FillSlotSerializer(serializers.Serializer):
extra_emails = StringOrListField(
required=False, child=serializers.EmailField(max_length=250, allow_blank=False)
)
extra_phone_numbers = StringOrListField(
extra_phone_numbers = PhoneNumbersStringOrListField(
required=False, child=serializers.CharField(max_length=16, allow_blank=False)
)
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)
use_partial_bookings = any(agenda.partial_bookings for agenda in self.context.get('agendas', []))
if use_partial_bookings:
if not attrs.get('start_time') or not attrs.get('end_time'):
raise ValidationError(_('must include start_time and end_time for partial bookings agenda'))
if attrs['start_time'] > attrs['end_time']:
raise ValidationError(_('start_time must be before end_time'))
return attrs
class SlotsSerializer(serializers.Serializer):
@ -212,14 +236,65 @@ class RecurringFillslotsSerializer(MultipleAgendasEventsFillSlotsSerializer):
% {'event_slug': event_slug, 'agenda_slug': agenda_slug}
)
# convert ISO day number to db lookup day number
day = (day + 1) % 7 + 1
slots[agenda_slug][event_slug].append(day)
return slots
class RecurringFillslotsByDaySerializer(FillSlotSerializer):
weekdays = {
'monday': 1,
'tuesday': 2,
'wednesday': 3,
'thursday': 4,
'friday': 5,
'saturday': 6,
'sunday': 7,
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for weekday in self.weekdays:
self.fields[weekday] = CommaSeparatedStringField(
child=serializers.TimeField(), required=False, min_length=2, max_length=2, allow_null=True
)
setattr(self, 'validate_%s' % weekday, self.validate_hour_range)
def validate_hour_range(self, value):
if not value:
return None
start_time, end_time = value
if start_time >= end_time:
raise ValidationError(_('Start hour must be before end hour.'))
return value
def validate(self, attrs):
agendas = self.context['agendas']
if len(agendas) > 1:
raise ValidationError('Multiple agendas are not supported.')
agenda = agendas[0]
if not agenda.partial_bookings:
raise ValidationError('Agenda kind must be partial bookings.')
attrs['hours_by_days'] = hours_by_days = {}
for weekday, weekday_index in self.weekdays.items():
if attrs.get(weekday):
hours_by_days[weekday_index] = attrs[weekday]
days_by_event = collections.defaultdict(list)
for event in agenda.get_open_recurring_events():
for day in event.recurrence_days:
if day in hours_by_days:
days_by_event[event.slug].append(day)
attrs['slots'] = {agenda.slug: days_by_event}
return attrs
class BookingSerializer(serializers.ModelSerializer):
user_was_present = serializers.BooleanField(required=False, allow_null=True)
user_absence_reason = serializers.CharField(required=False, allow_blank=True, allow_null=True)
user_presence_reason = serializers.CharField(required=False, allow_blank=True, allow_null=True)
use_color_for = serializers.CharField(required=False, allow_blank=True, allow_null=True, source='color')
@ -250,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
@ -268,12 +347,53 @@ class BookingSerializer(serializers.ModelSerializer):
ret.pop('user_absence_reason', None)
ret.pop('user_presence_reason', None)
else:
ret['user_absence_reason'] = (
self.instance.user_check_type_slug if self.instance.user_was_present is False else None
)
ret['user_presence_reason'] = (
self.instance.user_check_type_slug if self.instance.user_was_present is True 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.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.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,
'%sduration' % key,
)
ret[start_key] = getattr(self.instance, start_key)
ret[end_key] = getattr(self.instance, end_key)
ret[minutes_key] = None
if (
getattr(self.instance, start_key) is not None
and getattr(self.instance, end_key) is not None
):
start_minutes = (
getattr(self.instance, start_key).hour * 60 + getattr(self.instance, start_key).minute
)
end_minutes = (
getattr(self.instance, end_key).hour * 60 + getattr(self.instance, end_key).minute
)
ret[minutes_key] = end_minutes - start_minutes
return ret
def _validate_check_type(self, kind, value):
@ -326,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'])
@ -347,6 +472,7 @@ class DatetimesSerializer(DateRangeSerializer):
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)
@ -401,6 +527,14 @@ class AgendaOrSubscribedSlugsMixin(AgendaSlugsMixin):
attrs['agenda_slugs'] = [agenda.slug for agenda in agendas]
else:
attrs['agenda_slugs'] = self.agenda_slugs
if any(
agenda.partial_bookings != attrs['agendas'][0].partial_bookings for agenda in attrs['agendas']
):
raise serializers.ValidationError(
{'agendas': _('Cannot mix partial bookings agendas with other kinds.')}
)
return attrs
def validate_agendas(self, value):
@ -428,7 +562,7 @@ class AgendaOrSubscribedSlugsSerializer(AgendaOrSubscribedSlugsMixin, DateRangeM
class RecurringFillslotsQueryStringSerializer(AgendaOrSubscribedSlugsSerializer):
action = serializers.ChoiceField(required=True, choices=['update', 'book', 'unbook'])
action = serializers.ChoiceField(required=True, choices=['update', 'update-from-date', 'book', 'unbook'])
class RecurringEventsListSerializer(AgendaOrSubscribedSlugsSerializer):
@ -477,11 +611,12 @@ class EventSerializer(serializers.ModelSerializer):
field_classes = {
'text': serializers.CharField,
'textarea': serializers.CharField,
'bool': serializers.NullBooleanField,
'bool': serializers.BooleanField,
}
field_options = {
'text': {'allow_blank': True},
'textarea': {'allow_blank': True},
'bool': {'allow_null': True},
}
for custom_field in self.instance.agenda.events_type.get_custom_fields():
field_class = field_classes[custom_field['field_type']]
@ -492,6 +627,10 @@ class EventSerializer(serializers.ModelSerializer):
**(field_options.get(custom_field['field_type']) or {}),
)
def validate_recurrence_days(self, value):
# keep stable weekday numbering after switch to ISO in db
return [i + 1 for i in value]
def validate(self, attrs):
if not self.instance.agenda.events_type:
return attrs
@ -517,6 +656,9 @@ class EventSerializer(serializers.ModelSerializer):
def to_representation(self, instance):
ret = super().to_representation(instance)
if ret.get('recurrence_days'):
# keep stable weekday numbering after switch to ISO in db
ret['recurrence_days'] = [i - 1 for i in ret['recurrence_days']]
if not self.instance.agenda.events_type:
return ret
defaults = {
@ -547,6 +689,7 @@ class AgendaSerializer(serializers.ModelSerializer):
'slug',
'label',
'kind',
'partial_bookings',
'minimal_booking_delay',
'minimal_booking_delay_in_working_days',
'maximal_booking_delay',
@ -555,6 +698,7 @@ class AgendaSerializer(serializers.ModelSerializer):
'edit_role',
'view_role',
'category',
'booking_form_url',
'mark_event_checked_auto',
'disable_check_update',
'booking_check_filters',
@ -596,6 +740,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
@ -23,6 +23,11 @@ urlpatterns = [
path('agendas/datetimes/', views.agendas_datetimes, name='api-agendas-datetimes'),
path('agendas/recurring-events/', views.recurring_events_list, name='api-agenda-recurring-events'),
path('agendas/recurring-events/fillslots/', views.recurring_fillslots, name='api-recurring-fillslots'),
path(
'agendas/recurring-events/fillslots-by-day/',
views.recurring_fillslots_by_day,
name='api-recurring-fillslots-by-day',
),
path(
'agendas/events/',
views.agendas_events,
@ -33,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,
@ -57,9 +67,6 @@ urlpatterns = [
views.fillslot,
name='api-fillslot',
),
re_path(
r'^agenda/(?P<agenda_identifier>[\w-]+)/fillslots/$', views.fillslots, name='api-agenda-fillslots'
),
re_path(
r'^agenda/(?P<agenda_identifier>[\w-]+)/events/fillslots/$',
views.events_fillslots,
@ -126,7 +133,13 @@ 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'),
path('booking/<int:booking_pk>/cancel/', views.cancel_booking, name='api-cancel-booking'),
path('booking/<int:booking_pk>/accept/', views.accept_booking, name='api-accept-booking'),
@ -142,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

0
chrono/apps/__init__.py Normal file
View File

View File

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

@ -0,0 +1,88 @@
# 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/>.
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
class AntsHubException(Exception):
pass
def make_http_session(retries=3):
session = requests.Session()
retry = Retry(
total=retries,
read=retries,
connect=retries,
backoff_factor=0.5,
status_forcelist=(502, 503),
)
adapter = HTTPAdapter(max_retries=retry)
session.mount('http://', adapter)
session.mount('https://', adapter)
return session
def make_url(path):
return f'{settings.CHRONO_ANTS_HUB_URL}{path}'
def ping(timeout=1):
session = make_http_session()
try:
response = session.get(make_url('ping/'), timeout=timeout)
response.raise_for_status()
err = response.json()['err']
if err != 0:
raise AntsHubException(err)
except requests.Timeout:
pass
except (TypeError, KeyError, requests.RequestException) as e:
raise AntsHubException(str(e))
def push_rendez_vous_disponibles(payload):
session = make_http_session()
try:
response = session.post(make_url('rendez-vous-disponibles/'), json=payload)
response.raise_for_status()
data = response.json()
err = data['err']
if err != 0:
raise AntsHubException(err)
return data
except requests.Timeout:
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

@ -0,0 +1,29 @@
# 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.conf import settings
from django.core.management.base import BaseCommand
from chrono.apps.ants_hub import models
class Command(BaseCommand):
help = 'Synchronize agendas with the ANTS hub.'
def handle(self, **options):
if not settings.CHRONO_ANTS_HUB_URL:
return
models.City.push()

View File

@ -0,0 +1,159 @@
# Generated by Django 3.2.18 on 2023-04-06 00:34
import django.db.models.deletion
from django.db import migrations, models
import chrono.apps.ants_hub.models
class Migration(migrations.Migration):
initial = True
dependencies = [
('agendas', '0152_auto_20230331_0834'),
]
operations = [
migrations.CreateModel(
name='City',
fields=[
(
'id',
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
('name', chrono.apps.ants_hub.models.CharField(unique=True, verbose_name='Name')),
(
'url',
chrono.apps.ants_hub.models.URLField(
blank=True,
default=chrono.apps.ants_hub.models.get_portal_url,
verbose_name='Portal URL',
),
),
('logo_url', chrono.apps.ants_hub.models.URLField(blank=True, verbose_name='Logo URL')),
(
'meeting_url',
chrono.apps.ants_hub.models.URLField(
blank=True,
help_text='URL of the web form to make a booking.',
verbose_name='Booking URL',
),
),
(
'management_url',
chrono.apps.ants_hub.models.URLField(
blank=True,
help_text='Generic URL to find and manage an existing booking.',
verbose_name='Booking management URL',
),
),
(
'cancel_url',
chrono.apps.ants_hub.models.URLField(
blank=True,
help_text='Generic URL to find and cancel an existing booking.',
verbose_name='Booking cancellation URL',
),
),
('created', models.DateTimeField(auto_now_add=True, verbose_name='Created')),
('last_update', models.DateTimeField(auto_now=True, verbose_name='Last update')),
('full_sync', models.BooleanField(default=False, verbose_name='Full sync')),
('last_sync', models.DateTimeField(editable=False, null=True, verbose_name='Last sync')),
],
options={
'verbose_name': 'City',
'verbose_name_plural': 'Cities',
},
),
migrations.CreateModel(
name='Place',
fields=[
(
'id',
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
('name', chrono.apps.ants_hub.models.CharField(verbose_name='Name')),
('address', chrono.apps.ants_hub.models.CharField(verbose_name='Address')),
('zipcode', chrono.apps.ants_hub.models.CharField(verbose_name='Code postal')),
('city_name', chrono.apps.ants_hub.models.CharField(verbose_name='City name')),
('longitude', models.FloatField(default=2.476, verbose_name='Longitude')),
('latitude', models.FloatField(default=46.596, verbose_name='Latitude')),
('url', chrono.apps.ants_hub.models.URLField(blank=True, verbose_name='Portal URL')),
('logo_url', chrono.apps.ants_hub.models.URLField(blank=True, verbose_name='Logo URL')),
(
'meeting_url',
chrono.apps.ants_hub.models.URLField(
blank=True,
help_text='URL of the web form to make a booking.',
verbose_name='Booking URL',
),
),
(
'management_url',
chrono.apps.ants_hub.models.URLField(
blank=True,
help_text='Generic URL to find and manage an existing booking.',
verbose_name='Booking management URL',
),
),
(
'cancel_url',
chrono.apps.ants_hub.models.URLField(
blank=True,
help_text='Generic URL to find and cancel an existing booking.',
verbose_name='Booking cancellation URL',
),
),
('created', models.DateTimeField(auto_now_add=True, verbose_name='Created')),
('last_update', models.DateTimeField(auto_now=True, verbose_name='Last update')),
('full_sync', models.BooleanField(default=False, verbose_name='Full sync')),
('last_sync', models.DateTimeField(editable=False, null=True, verbose_name='Last sync')),
(
'city',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='places',
to='ants_hub.city',
verbose_name='City',
),
),
],
options={
'verbose_name': 'place',
'verbose_name_plural': 'places',
'unique_together': {('city', 'name')},
},
),
migrations.CreateModel(
name='PlaceAgenda',
fields=[
(
'id',
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
('setting', models.JSONField(blank=True, default=dict, verbose_name='Setting')),
(
'agenda',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='+',
related_query_name='ants_place',
to='agendas.agenda',
verbose_name='Agenda',
),
),
(
'place',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='agendas',
to='ants_hub.place',
verbose_name='Place',
),
),
],
options={
'unique_together': {('place', 'agenda')},
},
),
]

View File

@ -0,0 +1,365 @@
# 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/>.
import collections
import datetime
from django import forms
from django.conf import settings
from django.db import models, transaction
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.utils.translation import pgettext_lazy
from chrono.agendas.models import Agenda, Booking
from chrono.utils.timezone import localtime, now
from .hub import push_rendez_vous_disponibles
# fields without max_length problems
class CharField(models.TextField):
'''TextField using forms.TextInput as widget'''
def formfield(self, **kwargs):
defaults = {'widget': forms.TextInput}
defaults.update(**kwargs)
return super().formfield(**defaults)
class URLField(models.URLField):
'''URLField using a TEXT column for storage'''
def get_internal_type(self):
return 'TextField'
class CityManager(models.Manager):
def get_by_natural_key(self, name):
return self.get(name=name)
def get_portal_url():
template_vars = getattr(settings, 'TEMPLATE_VARS', {})
return template_vars.get('portal_url', '')
class City(models.Model):
name = CharField(verbose_name=_('Name'), unique=True)
url = URLField(verbose_name=_('Portal URL'), default=get_portal_url, blank=True)
logo_url = URLField(verbose_name=_('Logo URL'), blank=True)
meeting_url = URLField(
verbose_name=_('Booking URL'), help_text=_('URL of the web form to make a booking.'), blank=True
)
management_url = URLField(
verbose_name=_('Booking management URL'),
help_text=_('Generic URL to find and manage an existing booking.'),
blank=True,
)
cancel_url = URLField(
verbose_name=_('Booking cancellation URL'),
help_text=_('Generic URL to find and cancel an existing booking.'),
blank=True,
)
created = models.DateTimeField(verbose_name=_('Created'), auto_now_add=True)
last_update = models.DateTimeField(verbose_name=_('Last update'), auto_now=True)
full_sync = models.BooleanField(
verbose_name=_('Full synchronization next time'), default=False, editable=False
)
last_sync = models.DateTimeField(verbose_name=_('Last synchronization'), null=True, editable=False)
objects = CityManager()
def __str__(self):
return f'{self.name}'
def natural_key(self):
return (self.name,)
def get_absolute_url(self):
return reverse('chrono-manager-ants-hub')
def details(self):
details = []
for key in ['url', 'logo_url', 'management_url', 'cancel_url']:
value = getattr(self, key, None)
if value:
verbose_name = type(self)._meta.get_field(key).verbose_name
details.append((verbose_name, value))
return details
@classmethod
@transaction.atomic(savepoint=False)
def push(cls):
reference = now()
# prevent concurrent pushes with locks
cities = list(cls.objects.select_for_update())
push_rendez_vous_disponibles(
{
'collectivites': [city.export_to_push() for city in cities],
}
)
City.objects.update(last_sync=reference)
Place.objects.update(last_sync=reference)
def export_to_push(self):
payload = {
'full': True,
'id': str(self.pk),
'nom': self.name,
'url': self.url,
'logo_url': self.logo_url,
'rdv_url': self.meeting_url,
'gestion_url': self.management_url,
'annulation_url': self.cancel_url,
'lieux': [place.export_to_push() for place in self.places.all()],
}
return payload
class Meta:
verbose_name = _('City')
verbose_name_plural = _('Cities')
class PlaceManager(models.Manager):
def get_by_natural_key(self, name, *args):
return self.get(name=name, city=City.objects.get_by_natural_key(*args))
class Place(models.Model):
city = models.ForeignKey(
verbose_name=_('City'), to=City, related_name='places', on_delete=models.CASCADE, editable=False
)
name = CharField(verbose_name=_('Name'))
address = CharField(verbose_name=_('Address'))
zipcode = CharField(verbose_name=_('Code postal'))
city_name = CharField(verbose_name=_('City name'))
longitude = models.FloatField(verbose_name=_('Longitude'), default=2.476)
latitude = models.FloatField(verbose_name=_('Latitude'), default=46.596)
url = URLField(verbose_name=_('Portal URL'), blank=True)
logo_url = URLField(verbose_name=_('Logo URL'), blank=True)
meeting_url = URLField(
verbose_name=_('Booking URL'), help_text=_('URL of the web form to make a booking.'), blank=True
)
management_url = URLField(
verbose_name=_('Booking management URL'),
help_text=_('Generic URL to find and manage an existing booking.'),
blank=True,
)
cancel_url = URLField(
verbose_name=_('Booking cancellation URL'),
help_text=_('Generic URL to find and cancel an existing booking.'),
blank=True,
)
created = models.DateTimeField(verbose_name=_('Created'), auto_now_add=True)
last_update = models.DateTimeField(verbose_name=_('Last update'), auto_now=True)
full_sync = models.BooleanField(verbose_name=_('Full synchronization'), default=False, editable=False)
last_sync = models.DateTimeField(verbose_name=_('Last synchronization'), null=True, editable=False)
objects = PlaceManager()
def __str__(self):
return f'{self.name}'
def natural_key(self):
return (self.name,) + self.city.natural_key()
natural_key.dependencies = ['ants_hub.city']
def get_absolute_url(self):
return reverse('chrono-manager-ants-hub-place', kwargs={'city_pk': self.city_id, 'pk': self.pk})
def url_details(self):
details = []
for key in ['url', 'logo_url', 'management_url', 'cancel_url']:
value = getattr(self, key, None)
if value:
verbose_name = type(self)._meta.get_field(key).verbose_name
details.append((verbose_name, value))
return details
def export_to_push(self):
payload = {
'full': True,
'id': str(self.pk),
'nom': self.name,
'numero_rue': self.address,
'code_postal': self.zipcode,
'ville': self.city_name,
'longitude': self.longitude,
'latitude': self.latitude,
'url': self.url,
'rdv_url': self.meeting_url,
'gestion_url': self.management_url,
'annulation_url': self.cancel_url,
'plages': list(self.iter_open_dates()),
'rdvs': list(self.iter_predemandes()),
'logo_url': self.logo_url,
}
return payload
def iter_open_dates(self):
for place_agenda in self.agendas.all():
yield from place_agenda.iter_open_dates()
def iter_predemandes(self):
agendas = Agenda.objects.filter(ants_place__place=self)
agendas |= Agenda.objects.filter(virtual_agendas__ants_place__place=self)
agendas = set(agendas)
bookings = (
Booking.objects.filter(
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(
'extra_data__ants_identifiant_predemande', 'event__start_datetime', 'cancellation_datetime'
)
.order_by('event__start_datetime')
)
for identifiant_predemande_data, start_datetime, cancellation_datetime in bookings:
if not isinstance(identifiant_predemande_data, str):
continue
# split data on commas, and remove trailing whitespaces
identifiant_predemandes = filter(
None, (part.strip() for part in identifiant_predemande_data.split(','))
)
for identifiant_predemande in identifiant_predemandes:
rdv = {
'id': identifiant_predemande,
'date': start_datetime.isoformat(),
}
if cancellation_datetime is not None:
rdv['annule'] = True
yield rdv
class Meta:
verbose_name = pgettext_lazy('location', 'place')
verbose_name_plural = pgettext_lazy('location', 'places')
unique_together = [
('city', 'name'),
]
class ANTSMeetingType(models.IntegerChoices):
CNI = 1, _('CNI')
PASSPORT = 2, _('Passport')
CNI_PASSPORT = 3, _('CNI and passport')
@property
def ants_name(self):
return super().name.replace('_', '-')
class ANTSPersonsNumber(models.IntegerChoices):
ONE = 1, _('1 person')
TWO = 2, _('2 persons')
THREE = 3, _('3 persons')
FOUR = 4, _('4 persons')
FIVE = 5, _('5 persons')
class PlaceAgenda(models.Model):
place = models.ForeignKey(
verbose_name=_('Place'),
to=Place,
on_delete=models.CASCADE,
related_name='agendas',
)
agenda = models.ForeignKey(
verbose_name=_('Agenda'),
to='agendas.Agenda',
on_delete=models.CASCADE,
related_name='+',
related_query_name='ants_place',
)
setting = models.JSONField(verbose_name=_('Setting'), default=dict, blank=True)
def set_meeting_type_setting(self, meeting_type, key, value):
assert key in ['ants_meeting_type', 'ants_persons_number']
meeting_types = self.setting.setdefault('meeting-types', {})
value = map(int, value)
if key == 'ants_meeting_type':
value = list(map(ANTSMeetingType, value))
else:
value = list(map(ANTSPersonsNumber, value))
meeting_types.setdefault(str(meeting_type.slug), {})[key] = value
def get_meeting_type_setting(self, meeting_type, key):
assert key in ['ants_meeting_type', 'ants_persons_number']
meeting_types = self.setting.setdefault('meeting-types', {})
value = meeting_types.get(str(meeting_type.slug), {}).get(key, [])
value = map(int, value)
if key == 'ants_meeting_type':
value = list(map(ANTSMeetingType, value))
else:
value = list(map(ANTSPersonsNumber, value))
return value
def iter_ants_meeting_types_and_persons(self):
d = collections.defaultdict(set)
for meeting_type in self.agenda.iter_meetingtypes():
for ants_meeting_type in self.get_meeting_type_setting(meeting_type, 'ants_meeting_type'):
for ants_persons_number in self.get_meeting_type_setting(meeting_type, 'ants_persons_number'):
meeting_type.id = meeting_type.slug
d[(meeting_type, ants_persons_number)].add(ants_meeting_type)
for (meeting_type, ants_persons_number), ants_meeting_types in d.items():
yield meeting_type, ants_persons_number, ants_meeting_types
@property
def ants_properties(self):
rdv = set()
persons = set()
for meeting_type in self.agenda.meetingtype_set.all():
rdv.update(self.get_meeting_type_setting(meeting_type, 'ants_meeting_type'))
persons.update(self.get_meeting_type_setting(meeting_type, 'ants_persons_number'))
rdv = sorted(list(rdv))
persons = sorted(list(persons))
return [x.label for x in rdv] + [x.label for x in persons]
def __str__(self):
return str(self.agenda)
def get_absolute_url(self):
return self.place.get_absolute_url()
def iter_open_dates(self):
settings = list(self.iter_ants_meeting_types_and_persons())
if not settings:
return
intervals = self.agenda.get_free_time(end_datetime=now() + datetime.timedelta(days=6 * 30))
for start, end in intervals:
for meeting_type, ants_persons_number, ants_meeting_types in settings:
duration = datetime.timedelta(minutes=meeting_type.duration)
if end - start < duration:
continue
yield {
'date': localtime(start).date().isoformat(),
# use naive local time representation
'heure_debut': localtime(start).time().replace(tzinfo=None).isoformat(),
'heure_fin': localtime(end).time().replace(tzinfo=None).isoformat(),
'duree': meeting_type.duration,
'personnes': int(ants_persons_number),
'types_rdv': [x.ants_name for x in ants_meeting_types],
}
class Meta:
unique_together = [
('place', 'agenda'),
]

View File

@ -0,0 +1,57 @@
{% extends "chrono/manager_ants_hub_base.html" %}
{% load i18n %}
{% block appbar %}
<h2>{% trans 'ANTS Hub' %}</h2>
<span class="actions">
<a class="extra-actions-menu-opener"></a>
<ul class="extra-actions-menu">
<li><a rel="popup" href="{% url 'chrono-manager-ants-hub-synchronize' %}">{% trans 'Synchronize agendas' %}</a></li>
</ul>
<a rel="popup" href="{% url 'chrono-manager-ants-hub-city-add' %}">{% trans 'New city' %}</a>
</span>
{% endblock %}
{% block content %}
{% if object_list %}
{% for object in object_list %}
<div class="section">
<h3>
<span>
{{ object }}
<a class="icon-edit" rel="popup" href="{% url 'chrono-manager-ants-hub-city-edit' pk=object.pk %}"></a>
</span>
<a class="button delete-button" rel="popup" href="{% url 'chrono-manager-ants-hub-city-delete' pk=object.pk %}">{% trans "Remove" %}</a>
</h3>
<div>
<ul class="objects-list single-links">
<p>
{% if not object.last_sync %}{% trans "Never synchronized" %}{% else %}{% blocktrans with date=object.last_sync|date:"SHORT_DATETIME_FORMAT" %}Last synchronization on {{ date }}{% endblocktrans %}{% endif %}.
</p>
{% for label, value in object.details %}
<p>{{ label }}: {{ value }}</p>
{% endfor %}
{% for place in object.places.all %}
<li>
<a href="{% url 'chrono-manager-ants-hub-place' city_pk=object.pk pk=place.pk %}">{{ place }}
{% if place.agendas.count %}<span class="identifier">({% blocktrans count counter=place.agendas.count %}1 agenda{% plural %}{{ counter }} agendas{% endblocktrans %})</span>{% endif %}</a>
<a class="delete" rel="popup" href="{% url 'chrono-manager-ants-hub-place-delete' city_pk=object.pk pk=place.pk %}">{% trans "remove" %}</a>
</li>
{% endfor %}
</ul>
<p>
<a class="button" rel="popup" href="{% url 'chrono-manager-ants-hub-place-add' pk=object.pk %}">{% trans "Add place" %}</a>
</p>
</div>
</div>
{% endfor %}
{% else %}
<div class="big-msg-info">
{% blocktrans trimmed %}
This site doesn't have any city yet. Click on the "New city" button in the top
right of the page to add a first one.
{% endblocktrans %}
</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,19 @@
{% extends "chrono/manager_home.html" %}
{% load i18n gadjo %}
{% block appbar %}
<h2>{% if object %}{{ object }}{% else %}{{ view.name }}{% endif %}</h2>
{% endblock %}
{% block content %}
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form|with_template }}
<div class="buttons">
<button class="submit-button">{% trans "Save" %}</button>
<a class="cancel" href="{% url 'chrono-manager-ants-hub' %}">{% trans 'Cancel' %}</a>
</div>
{% block after-form %}
{% endblock %}
</form>
{% endblock %}

View File

@ -0,0 +1,57 @@
{% extends "chrono/manager_home.html" %}
{% load i18n gadjo %}
{% block appbar %}
<h2><a href="{{ object.agenda.get_absolute_url }}">{{ object.agenda }}</a></h2>
{% endblock %}
{% block content %}
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<p>{% blocktrans with url=object.agenda.get_absolute_url name=object.agenda %}Configure the mapping between meeting types and ANTS meeting types for agenda <a href="{{ url }}">{{ name }}</a>.{% endblocktrans %}</p>
{% if form.meeting_types %}
<table class="main ants-setting">
<tbody>
{% for label, fields in form.field_by_labels %}
<tr>
<td class="meeting-type">{{ label }}</td>
<td>
{% for field in fields %}
{% if field.errors %}
<div class="error"><p>
{% for error in field.errors %}
{{ error }}{% if not forloop.last %}<br>{% endif %}
{% endfor %}
</p></div>
{% endif %}
{{ field }}
{% endfor %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="big-msg-info">{% blocktrans trimmed %}
This agenda doesn't have any meeting type yet.
{% endblocktrans %}</div>
{% endif %}
<script>
$('form').on('click', 'label', function (event) {
console.log(event);
});
$('form').on('change', 'input', function (event) {
$(event.target).parent().toggleClass('checked');
});
$('form input:checked').each(function (i, elt) {
$(elt).parent().toggleClass('checked');
});
</script>
<div class="buttons">
{% if form.meeting_types %}
<button class="submit-button">{% trans "Save" %}</button>
{% endif %}
<a class="cancel" href="{% url 'chrono-manager-ants-hub' %}">{% trans 'Cancel' %}</a>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,7 @@
{% extends "chrono/manager_base.html" %}
{% load i18n %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url "chrono-manager-ants-hub" %}">{% trans "ANTS Hub" %}</a>
{% endblock %}

View File

@ -0,0 +1,78 @@
{% extends "chrono/manager_ants_hub_base.html" %}
{% load i18n %}
{% block breadcrumb %}
{{ block.super }}
<a href=".">{{ view.place }}</a>
{% endblock %}
{% block appbar %}
<h2>{{ view.place }}</h2>
<a rel="popup" class="delete-button" href="{% url 'chrono-manager-ants-hub-place-delete' city_pk=view.place.city_id pk=view.place.pk %}">{% trans "Remove" %}</a>
{% endblock %}
{% block content %}
<p>
{% if not view.place.last_sync %}{% trans "Never synchronized" %}{% else %}{% blocktrans with date=view.place.last_sync|date:"SHORT_DATETIME_FORMAT" %}Last synchronization on {{ date }}{% endblocktrans %}{% endif %}.
</p>
<div class="section">
<h3>
{% trans "Address" %}
<a class="button" rel="popup" href="{% url 'chrono-manager-ants-hub-place-edit' city_pk=view.place.city_id pk=view.place.pk %}">{% trans "Edit" %}</a>
</h3>
<div>
<p>
{{ view.place.address }}</br>
{{ view.place.zipcode }} {{ view.place.city_name }}
</p>
<p>
{% trans "Geolocation:" %} {{ view.place.longitude }} {{ view.place.latitude }}
</p>
</div>
</div>
<div class="section">
<h3>
{% trans "URLs" %}
<a class="button" rel="popup" href="{% url 'chrono-manager-ants-hub-place-url' city_pk=view.place.city_id pk=view.place.pk %}">{% trans "Edit" %}</a>
</h3>
<table class="main">
<tbody>
{% for label, value in view.place.url_details %}
<tr><td>{{ label }}</td><td>{{ value }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="section">
<h3>
{% trans "Agendas" %}
<a rel="popup" class="button" href="{% url 'chrono-manager-ants-hub-agenda-add' city_pk=view.place.city_id pk=view.place.pk %}">{% trans 'Add' %}</a>
</h3>
{% if object_list %}
<ul class="objects-list single-links">
{% for object in object_list %}
<li {% if not object.ants_properties %}class="ants-setting-not-configured"{% endif %}>
<a rel="popup" id="open-place-agenda-{{ object.pk }}" class="edit" href="{% url 'chrono-manager-ants-hub-agenda-edit' pk=object.pk city_pk=view.place.city_id place_pk=view.place.pk %}">
<span class="label">{{ object }}</span>
<span class="properties">({% if object.ants_properties %}{{ object.ants_properties|join:", " }}{% else %}{% trans "not configured" %}{% endif %})</span></a>
<a class="delete" rel="popup" href="{% url 'chrono-manager-ants-hub-agenda-delete' pk=object.pk city_pk=view.place.city_id place_pk=view.place.pk %}">{% trans "remove" %}</a>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% if not object_list %}
<div class="big-msg-info">
{% blocktrans trimmed %}
This site doesn't have any place yet. Click on the "New place" button in the top
right of the page to add a first one.
{% endblocktrans %}
</div>
{% endif %}
<script>
setTimeout(function () {
$(window.location.hash).click();
}, 100);
</script>
{% endblock %}

View File

@ -0,0 +1,17 @@
{% extends "chrono/manager_ants_hub_add_form.html" %}
{% load i18n gadjo %}
{% block after-form %}
<script>
$('form').on('change keyup', '#id_address, #id_zipcode, #id_city_name', function (event) {
var q = $('#id_address').val() + ' ' + $('#id_zipcode').val() + ' ' + $('#id_city_name').val();
console.log('q', q)
var url = "https://api-adresse.data.gouv.fr/search/?q=" + encodeURIComponent(q);
$.ajax(url).done(function (data) {
var coords = data.features[0].geometry.coordinates;
$('#id_longitude').val(coords[0]);
$('#id_latitude').val(coords[1]);
})
});
</script>
{% endblock %}

View File

@ -0,0 +1,19 @@
{% extends "chrono/manager_home.html" %}
{% load i18n %}
{% block appbar %}
<h2>{% trans "Synchronize agendas" %}</h2>
{% endblock %}
{% block content %}
<form method="post">
{% csrf_token %}
<p>
{% blocktrans %}Are you sure you want to synchronize your agendas with the ANTS now?{% endblocktrans %}
</p>
<div class="buttons">
<button class="button" >{% trans 'Synchronize' %}</button>
<a class="cancel" href="{% url 'chrono-manager-ants-hub' %}">{% trans 'Cancel' %}</a>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,87 @@
# 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/>.
import functools
from django.conf import settings
from django.contrib import messages
from django.core.exceptions import PermissionDenied
from django.shortcuts import redirect
from django.urls import path
from django.utils.translation import gettext as _
from . import views
def view_decorator(func):
@functools.wraps(func)
def wrapper(request, *args, **kwargs):
if not request.user.is_staff:
raise PermissionDenied
if not settings.CHRONO_ANTS_HUB_URL:
messages.info(
request, _('Configure CHRONO_ANTS_HUB_URL to get access to ANTS-Hub configuration panel.')
)
return redirect('chrono-manager-homepage')
return func(request, *args, **kwargs)
return wrapper
urlpatterns = [
path('', views.Homepage.as_view(), name='chrono-manager-ants-hub'),
path('synchronize/', views.Synchronize.as_view(), name='chrono-manager-ants-hub-synchronize'),
path('city/add/', views.CityAddView.as_view(), name='chrono-manager-ants-hub-city-add'),
path('city/<int:pk>/edit/', views.CityEditView.as_view(), name='chrono-manager-ants-hub-city-edit'),
path('city/<int:pk>/delete/', views.CityDeleteView.as_view(), name='chrono-manager-ants-hub-city-delete'),
path('city/<int:pk>/place/add/', views.PlaceAddView.as_view(), name='chrono-manager-ants-hub-place-add'),
path(
'city/<int:city_pk>/place/<int:pk>/', views.PlaceView.as_view(), name='chrono-manager-ants-hub-place'
),
path(
'city/<int:city_pk>/place/<int:pk>/edit/',
views.PlaceEditView.as_view(),
name='chrono-manager-ants-hub-place-edit',
),
path(
'city/<int:city_pk>/place/<int:pk>/url/',
views.PlaceUrlEditView.as_view(),
name='chrono-manager-ants-hub-place-url',
),
path(
'city/<int:city_pk>/place/<int:pk>/delete/',
views.PlaceDeleteView.as_view(),
name='chrono-manager-ants-hub-place-delete',
),
path(
'city/<int:city_pk>/place/<int:pk>/agenda/add/',
views.PlaceAgendaAddView.as_view(),
name='chrono-manager-ants-hub-agenda-add',
),
path(
'city/<int:city_pk>/place/<int:place_pk>/agenda/<int:pk>/edit/',
views.PlaceAgendaEditView.as_view(),
name='chrono-manager-ants-hub-agenda-edit',
),
path(
'city/<int:city_pk>/place/<int:place_pk>/agenda/<int:pk>/delete/',
views.PlaceAgendaDeleteView.as_view(),
name='chrono-manager-ants-hub-agenda-delete',
),
]
for pattern in urlpatterns:
pattern.callback = view_decorator(pattern.callback)

View File

@ -0,0 +1,288 @@
# 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/>.
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
class Homepage(ListView):
template_name = 'chrono/manager_ants_hub.html'
model = models.City
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ok = cache.get('ants-hub-ok')
if not ok:
try:
hub.ping()
except hub.AntsHubException as e:
messages.warning(self.request, _('ANTS Hub is down: "%s".') % e)
else:
messages.info(self.request, _('ANTS Hub is responding.'))
cache.set('ants-hub-ok', True, 600)
return ctx
class CityAddView(CreateView):
template_name = 'chrono/manager_ants_hub_add_form.html'
model = models.City
name = _('New city')
fields = '__all__'
class CityMixin:
def dispatch(self, request, pk):
self.city = get_object_or_404(models.City, pk=pk)
return super().dispatch(request, pk=pk)
class CityView(CityMixin, ListView):
template_name = 'chrono/manager_ants_hub_city.html'
model = models.Place
def get_queryset(self):
return super().get_queryset().filter(city=self.city)
class CityEditView(UpdateView):
template_name = 'chrono/manager_ants_hub_add_form.html'
model = models.City
fields = '__all__'
class CityDeleteView(DeleteView):
template_name = 'chrono/manager_confirm_delete.html'
model = models.City
success_url = '../../../'
class PlaceForm(forms.ModelForm):
class Meta:
model = models.Place
exclude = ['city']
class PlaceAddView(CityMixin, CreateView):
template_name = 'chrono/manager_ants_hub_add_form.html'
model = models.Place
form_class = PlaceForm
@property
def name(self):
return _('New place in %s') % self.city
def dispatch(self, request, pk):
self.city = get_object_or_404(models.City, pk=pk)
return super().dispatch(request, pk)
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['instance'] = models.Place(city=self.city)
return kwargs
class PlaceMixin:
def dispatch(self, request, city_pk, pk):
self.place = get_object_or_404(models.Place, pk=pk, city_id=city_pk)
self.city = self.place.city
return super().dispatch(request, pk=pk)
class PlaceView(PlaceMixin, ListView):
template_name = 'chrono/manager_ants_hub_place.html'
model = models.PlaceAgenda
def get_queryset(self):
return super().get_queryset().filter(place=self.place)
class PlaceEditForm(PlaceForm):
class Meta:
model = models.Place
fields = ['name', 'address', 'zipcode', 'city_name', 'longitude', 'latitude']
class PlaceEditView(UpdateView):
template_name = 'chrono/manager_ants_hub_place_edit_form.html'
model = models.Place
fields = ['zipcode', 'city_name', 'address', 'longitude', 'latitude']
class PlaceDeleteView(DeleteView):
template_name = 'chrono/manager_confirm_delete.html'
model = models.Place
success_url = '../../../../../'
class PlaceUrlEditView(UpdateView):
template_name = 'chrono/manager_ants_hub_add_form.html'
model = models.Place
fields = ['url', 'logo_url', 'meeting_url', 'management_url', 'cancel_url']
class PlaceAgendaAddForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# hide agendas already linked to the place
self.fields['agenda'].queryset = self.fields['agenda'].queryset.exclude(
ants_place__place=self.instance.place
)
class Meta:
model = models.PlaceAgenda
exclude = ['place', 'setting']
class PlaceAgendaAddView(PlaceMixin, CreateView):
template_name = 'chrono/manager_ants_hub_add_form.html'
model = models.PlaceAgenda
form_class = PlaceAgendaAddForm
@property
def name(self):
return _('New agenda for %s') % self.place
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['instance'] = models.PlaceAgenda(place=self.place)
return kwargs
def get_success_url(self):
return f'../../#open-place-agenda-{self.object.pk}'
class PlaceAgendaEditForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.meeting_types = self.instance.agenda.meetingtype_set.order_by('label')
for meeting_type in self.instance.agenda.iter_meetingtypes():
field_meeting_type = forms.TypedMultipleChoiceField(
label=_('%(mt_label)s (%(mt_duration)s minutes)')
% {'mt_label': meeting_type.label, 'mt_duration': meeting_type.duration},
choices=models.ANTSMeetingType.choices,
widget=forms.CheckboxSelectMultiple(attrs={'class': 'inline'}),
required=False,
initial=self.instance.get_meeting_type_setting(meeting_type, 'ants_meeting_type'),
)
field_meeting_type.meeting_type = meeting_type
field_meeting_type.key = 'ants_meeting_type'
field_persons_number = forms.TypedMultipleChoiceField(
label=_('%(mt_label)s (%(mt_duration)s minutes)')
% {'mt_label': meeting_type.label, 'mt_duration': meeting_type.duration},
choices=models.ANTSPersonsNumber.choices,
widget=forms.CheckboxSelectMultiple(attrs={'class': 'inline'}),
required=False,
initial=self.instance.get_meeting_type_setting(meeting_type, 'ants_persons_number'),
)
field_persons_number.meeting_type = meeting_type
field_persons_number.key = 'ants_persons_number'
self.fields[f'mt_{meeting_type.slug}_1'] = field_meeting_type
self.fields[f'mt_{meeting_type.slug}_2'] = field_persons_number
def field_by_labels(self):
d = {}
for bound_field in self:
d.setdefault(bound_field.label, []).append(bound_field)
return list(d.items())
def clean(self):
for key, field in self.fields.items():
value = self.cleaned_data.get(key, [])
self.instance.set_meeting_type_setting(field.meeting_type, field.key, value)
return self.cleaned_data
class Meta:
model = models.PlaceAgenda
fields = []
class PlaceAgendaEditView(UpdateView):
template_name = 'chrono/manager_ants_hub_agenda_edit_form.html'
model = models.PlaceAgenda
form_class = PlaceAgendaEditForm
success_url = '../../../'
class PlaceAgendaDeleteView(DeleteView):
template_name = 'chrono/manager_confirm_delete.html'
model = models.PlaceAgenda
success_url = '../../../'
class Synchronize(TemplateView):
template_name = 'chrono/manager_ants_hub_synchronize.html'
def post(self, request):
self.synchronize()
messages.info(request, _('Synchronization has been launched.'))
return redirect('chrono-manager-ants-hub')
@classmethod
def synchronize(cls):
if 'uwsgi' in sys.modules:
from django.db import connection
from chrono.utils.spooler import ants_hub_city_push
tenant = getattr(connection, 'tenant', None)
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

View File

@ -0,0 +1,464 @@
# chrono - content management system
# Copyright (C) 2016-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/>.
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
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.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,
'singular': klass.application_label_singular,
'urls': {
'list': request.build_absolute_uri(
reverse(
'api-export-import-components-list',
kwargs={'component_type': klass.application_component_type},
)
),
},
}
if klass not in [Agenda]:
component_type['minor'] = True
data.append(component_type)
return Response({'data': data})
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,
'type': component.application_component_type,
'urls': {
'export': request.build_absolute_uri(
reverse(
'api-export-import-component-export',
kwargs={
'component_type': component.application_component_type,
'slug': str(component.slug),
},
)
),
'dependencies': request.build_absolute_uri(
reverse(
'api-export-import-component-dependencies',
kwargs={
'component_type': component.application_component_type,
'slug': str(component.slug),
},
)
),
'redirect': request.build_absolute_uri(
reverse(
'api-export-import-component-redirect',
kwargs={
'component_type': component.application_component_type,
'slug': str(component.slug),
},
)
),
},
}
class ListComponents(GenericAPIView):
permission_classes = (permissions.IsAdminUser,)
def get(self, request, *args, **kwargs):
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})
list_components = ListComponents.as_view()
class ExportComponent(GenericAPIView):
permission_classes = (permissions.IsAdminUser,)
def get(self, request, slug, *args, **kwargs):
klass = get_klass_from_component_type(kwargs['component_type'])
serialisation = get_object_or_404(klass, slug=slug).export_json()
return Response({'data': serialisation})
export_component = ExportComponent.as_view()
class ComponentDependencies(GenericAPIView):
permission_classes = (permissions.IsAdminUser,)
def get(self, request, slug, *args, **kwargs):
klass = get_klass_from_component_type(kwargs['component_type'])
component = get_object_or_404(klass, slug=slug)
def dependency_dict(element):
return get_component_bundle_entry(request, element)
dependencies = [dependency_dict(x) for x in component.get_dependencies() if x]
return Response({'err': 0, 'data': dependencies})
component_dependencies = ComponentDependencies.as_view()
def component_redirect(request, component_type, slug):
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.IsAdminUser,)
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.IsAdminUser,)
install = True
def post(self, request, *args, **kwargs):
bundle = request.FILES['bundle']
components = {}
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,
)
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
self.do_something(components)
# create application elements
self.link_objects(components)
# remove obsolete application elements
self.unlink_obsolete_objects()
return Response({'err': 0})
def do_something(self, components):
if components:
import_site(components)
def link_objects(self, components):
for component_type, component_list in components.items():
component_type = klasses_translation_reverse.get(component_type, component_type)
klass = klasses[component_type]
for component in component_list:
try:
existing_component = klass.objects.get(slug=component['slug'])
except klass.DoesNotExist:
pass
else:
element = ApplicationElement.update_or_create_for_object(
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)
for element in known_elements:
if element.content_object not in self.application_elements:
element.delete()
bundle_import = BundleImport.as_view()
class BundleDeclare(BundleImport):
install = False
def do_something(self, components):
# no installation on declare
pass
bundle_declare = BundleDeclare.as_view()
class BundleUnlink(GenericAPIView):
permission_classes = (permissions.IsAdminUser,)
def post(self, request, *args, **kwargs):
if request.POST.get('application'):
try:
application = Application.objects.get(slug=request.POST['application'])
except Application.DoesNotExist:
pass
else:
application.delete()
return Response({'err': 0})
bundle_unlink = BundleUnlink.as_view()

View File

@ -0,0 +1,62 @@
# Generated by Django 3.2.18 on 2023-10-13 09:19
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
]
operations = [
migrations.CreateModel(
name='Application',
fields=[
(
'id',
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
('name', models.CharField(max_length=100)),
('slug', models.SlugField(max_length=100, unique=True)),
('icon', models.FileField(blank=True, null=True, upload_to='applications/icons/')),
('description', models.TextField(blank=True)),
('documentation_url', models.URLField(blank=True)),
('version_number', models.CharField(max_length=100)),
('version_notes', models.TextField(blank=True)),
('editable', models.BooleanField(default=True)),
('visible', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
),
migrations.CreateModel(
name='ApplicationElement',
fields=[
(
'id',
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
('object_id', models.PositiveIntegerField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
(
'application',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to='export_import.application'
),
),
(
'content_type',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype'
),
),
],
options={
'unique_together': {('application', 'content_type', 'object_id')},
},
),
]

View File

@ -0,0 +1,134 @@
# chrono - content management system
# Copyright (C) 2016-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/>.
import collections
from django.contrib.contenttypes.fields import GenericForeignKey
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)
icon = models.FileField(
upload_to='applications/icons/',
blank=True,
null=True,
)
description = models.TextField(blank=True)
documentation_url = models.URLField(blank=True)
version_number = models.CharField(max_length=100)
version_notes = models.TextField(blank=True)
editable = models.BooleanField(default=True)
visible = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return str(self.name)
@classmethod
def update_or_create_from_manifest(cls, manifest, tar, editable=False):
application, dummy = cls.objects.get_or_create(
slug=manifest.get('slug'), defaults={'editable': editable}
)
application.name = manifest.get('application')
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') or ''
if not editable:
application.editable = editable
application.visible = manifest.get('visible', True)
application.save()
icon = manifest.get('icon')
if icon:
application.icon.save(icon, tar.extractfile(icon), save=True)
else:
application.icon.delete()
return application
@classmethod
def select_for_object_class(cls, object_class):
content_type = ContentType.objects.get_for_model(object_class)
elements = ApplicationElement.objects.filter(content_type=content_type)
return cls.objects.filter(pk__in=elements.values('application'), visible=True).order_by('name')
@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, application__visible=True
).prefetch_related('application')
elements_by_objects = collections.defaultdict(list)
for element in elements:
elements_by_objects[element.object_id].append(element)
for obj in objects:
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, 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):
content_type = ContentType.objects.get_for_model(object_class)
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)
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
content_object = GenericForeignKey('content_type', 'object_id')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ['application', 'content_type', 'object_id']
@classmethod
def update_or_create_for_object(cls, application, obj):
content_type = ContentType.objects.get_for_model(obj.__class__)
element, created = cls.objects.get_or_create(
application=application,
content_type=content_type,
object_id=obj.pk,
)
if not created:
element.save()
return element

View File

@ -0,0 +1,47 @@
# chrono - content management system
# Copyright (C) 2016-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 api_views
urlpatterns = [
path('export-import/', api_views.index, name='api-export-import'),
path('export-import/bundle-check/', api_views.bundle_check),
path('export-import/bundle-declare/', api_views.bundle_declare),
path('export-import/bundle-import/', api_views.bundle_import),
path('export-import/unlink/', api_views.bundle_unlink),
path(
'export-import/<slug:component_type>/',
api_views.list_components,
name='api-export-import-components-list',
),
path(
'export-import/<slug:component_type>/<slug:slug>/',
api_views.export_component,
name='api-export-import-component-export',
),
path(
'export-import/<slug:component_type>/<slug:slug>/dependencies/',
api_views.component_dependencies,
name='api-export-import-component-dependencies',
),
path(
'export-import/<slug:component_type>/<slug:slug>/redirect/',
api_views.component_redirect,
name='api-export-import-component-redirect',
),
]

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

@ -26,7 +26,6 @@ import django_filters
from dateutil.relativedelta import relativedelta
from django import forms
from django.conf import settings
from django.contrib.auth.models import Group
from django.contrib.humanize.templatetags.humanize import ordinal
from django.core.exceptions import FieldDoesNotExist
from django.core.validators import URLValidator
@ -38,6 +37,7 @@ from django.utils.encoding import force_str
from django.utils.formats import date_format
from django.utils.html import format_html, mark_safe
from django.utils.translation import gettext_lazy as _
from django.utils.translation import pgettext
from chrono.agendas.models import (
WEEK_CHOICES,
@ -47,6 +47,8 @@ from chrono.agendas.models import (
AgendaNotificationsSettings,
AgendaReminderSettings,
Booking,
BookingCheck,
Category,
Desk,
Event,
EventsType,
@ -70,21 +72,31 @@ from chrono.utils.lingo import get_agenda_check_types
from chrono.utils.timezone import localtime, make_aware, now
from . import widgets
from .utils import get_role_queryset
from .widgets import SplitDateTimeField, WeekdaysWidget
class AgendaAddForm(forms.ModelForm):
edit_role = forms.ModelChoiceField(
label=_('Edit Role'), required=False, queryset=Group.objects.all().order_by('name')
)
view_role = forms.ModelChoiceField(
label=_('View Role'), required=False, queryset=Group.objects.all().order_by('name')
)
edit_role = forms.ModelChoiceField(label=_('Edit Role'), required=False, queryset=get_role_queryset())
view_role = forms.ModelChoiceField(label=_('View Role'), required=False, queryset=get_role_queryset())
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if 'kind' in self.fields and settings.PARTIAL_BOOKINGS_ENABLED:
self.fields['kind'].choices += [('partial-bookings', _('Partial bookings'))]
class Meta:
model = Agenda
fields = ['label', 'kind', 'category', 'edit_role', 'view_role']
def clean(self):
super().clean()
if self.cleaned_data.get('kind') == 'partial-bookings':
self.cleaned_data['kind'] = 'events'
self.instance.partial_bookings = True
self.instance.default_view = 'day'
self.instance.enable_check_for_future_events = True
def save(self, *args, **kwargs):
create = self.instance.pk is None
super().save()
@ -120,6 +132,10 @@ class AgendaEditForm(forms.ModelForm):
else:
if not EventsType.objects.exists():
del self.fields['events_type']
if kwargs['instance'].partial_bookings:
self.fields['default_view'].choices = [
(k, v) for k, v in self.fields['default_view'].choices if k not in ('open_events', 'week')
]
class AgendaBookingDelaysForm(forms.ModelForm):
@ -131,6 +147,9 @@ class AgendaBookingDelaysForm(forms.ModelForm):
'maximal_booking_delay',
'minimal_booking_time',
]
widgets = {
'minimal_booking_time': widgets.TimeWidget,
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@ -155,12 +174,8 @@ class UnavailabilityCalendarAddForm(forms.ModelForm):
model = UnavailabilityCalendar
fields = ['label', 'edit_role', 'view_role']
edit_role = forms.ModelChoiceField(
label=_('Edit Role'), required=False, queryset=Group.objects.all().order_by('name')
)
view_role = forms.ModelChoiceField(
label=_('View Role'), required=False, queryset=Group.objects.all().order_by('name')
)
edit_role = forms.ModelChoiceField(label=_('Edit Role'), required=False, queryset=get_role_queryset())
view_role = forms.ModelChoiceField(label=_('View Role'), required=False, queryset=get_role_queryset())
class UnavailabilityCalendarEditForm(UnavailabilityCalendarAddForm):
@ -193,6 +208,7 @@ class NewEventForm(forms.ModelForm):
fields = [
'label',
'start_datetime',
'end_time',
'frequency',
'recurrence_days',
'recurrence_week_interval',
@ -205,14 +221,35 @@ class NewEventForm(forms.ModelForm):
}
widgets = {
'recurrence_end_date': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
'end_time': widgets.TimeWidget,
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.agenda.partial_bookings:
del self.fields['duration']
del self.fields['recurrence_week_interval']
else:
del self.fields['end_time']
def clean(self):
super().clean()
if self.cleaned_data.get('frequency') == 'unique':
self.cleaned_data['recurrence_days'] = None
self.cleaned_data['recurrence_end_date'] = None
end_time = self.cleaned_data.get('end_time')
if end_time and self.cleaned_data['start_datetime'].time() > end_time:
self.add_error('end_time', _('End time must be greater than start time.'))
if self.instance.agenda.partial_bookings and self.instance.agenda.event_overlaps(
start_datetime=self.cleaned_data['start_datetime'],
recurrence_days=self.cleaned_data['recurrence_days'],
recurrence_end_date=self.cleaned_data['recurrence_end_date'],
instance=self.instance,
):
raise ValidationError(_('There can only be one event per day.'))
def clean_start_datetime(self):
start_datetime = self.cleaned_data['start_datetime']
if start_datetime.year < 2000:
@ -248,6 +285,7 @@ class EventForm(NewEventForm):
protected_fields = (
'slug',
'start_datetime',
'end_time',
'frequency',
'recurrence_days',
'recurrence_week_interval',
@ -257,11 +295,13 @@ class EventForm(NewEventForm):
model = Event
widgets = {
'recurrence_end_date': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
'end_time': widgets.TimeWidget,
}
fields = [
'label',
'slug',
'start_datetime',
'end_time',
'frequency',
'recurrence_days',
'recurrence_week_interval',
@ -290,6 +330,8 @@ class EventForm(NewEventForm):
)
if self.instance.recurrence_days and self.instance.has_recurrences_booked():
for field in self.protected_fields:
if field not in self.fields:
continue
self.fields[field].disabled = True
self.fields[field].help_text = _(
'This field cannot be modified because some recurrences have bookings attached to them.'
@ -377,7 +419,7 @@ class EventDuplicateForm(forms.ModelForm):
def save(self, *args, **kwargs):
with transaction.atomic():
self.instance = self.instance.duplicate(
label=self.cleaned_data["label"], start_datetime=self.cleaned_data["start_datetime"]
label=self.cleaned_data['label'], start_datetime=self.cleaned_data['start_datetime']
)
if self.instance.recurrence_days:
self.instance.create_all_recurrences()
@ -480,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()
@ -489,15 +545,15 @@ class BookingCheckFilterSet(django_filters.FilterSet):
if value == 'booked':
return queryset
if value == 'not-checked':
return queryset.filter(user_was_present__isnull=True)
return queryset.filter(user_checks__isnull=True)
if value == 'presence':
return queryset.filter(user_was_present=True)
return queryset.filter(user_checks__presence=True)
if value == 'absence':
return queryset.filter(user_was_present=False)
return queryset.filter(user_checks__presence=False)
if value.startswith('absence::'):
return queryset.filter(user_was_present=False, user_check_type_slug=value.split('::')[1])
return queryset.filter(user_checks__presence=False, user_checks__type_slug=value.split('::')[1])
if value.startswith('presence::'):
return queryset.filter(user_was_present=True, user_check_type_slug=value.split('::')[1])
return queryset.filter(user_checks__presence=True, user_checks__type_slug=value.split('::')[1])
return queryset
def do_nothing(self, queryset, name, value):
@ -533,12 +589,129 @@ 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(
choices=(
(None, _('Not checked')),
(True, _('Present')),
(False, _('Absent')),
)
),
required=False,
)
presence_check_type = forms.ChoiceField(label=_('Type'), required=False)
absence_check_type = forms.ChoiceField(label=_('Type'), required=False)
class Meta:
model = BookingCheck
fields = ['presence', 'start_time', 'end_time', 'type_label', 'type_slug']
widgets = {
'start_time': widgets.TimeWidgetWithButton(
step=60, button_label=_('Fill with booking start time')
),
'end_time': widgets.TimeWidgetWithButton(step=60, button_label=_('Fill with booking end time')),
'type_label': forms.HiddenInput(),
'type_slug': forms.HiddenInput(),
}
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']
if presence_check_types:
self.fields['presence_check_type'].choices = [(None, '---------')] + presence_check_types
self.fields['presence_check_type'].initial = self.instance.type_slug
else:
del self.fields['presence_check_type']
if absence_check_types:
self.fields['absence_check_type'].choices = [(None, '---------')] + absence_check_types
self.fields['absence_check_type'].initial = self.instance.type_slug
else:
del self.fields['absence_check_type']
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)
def clean(self):
if self.cleaned_data.get('presence') is None:
return
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'
if f'{kind}_check_type' in self.cleaned_data:
self.cleaned_data['type_slug'] = self.cleaned_data[f'{kind}_check_type']
self.cleaned_data['type_label'] = dict(self.fields[f'{kind}_check_type'].choices).get(
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:
if self.instance.pk:
self.instance.delete()
booking.refresh_computed_times(commit=True)
return self.instance
super().save()
booking.refresh_computed_times(commit=True)
return self.instance
class EventsTimesheetForm(forms.Form):
@ -604,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(',')
@ -682,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(
@ -731,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:
@ -834,7 +1033,7 @@ class MeetingTypeForm(forms.ModelForm):
and mt.duration == self.instance.duration
):
raise ValidationError(
_('This meetingtype is used by a virtual agenda: %s' % virtual_agenda)
_('This meetingtype is used by a virtual agenda: %s') % virtual_agenda
)
@ -1107,15 +1306,12 @@ class ImportEventsForm(forms.Form):
)
events = None
def __init__(self, agenda_pk, **kwargs):
self.agenda_pk = agenda_pk
def __init__(self, agenda, **kwargs):
self.agenda = agenda
super().__init__(**kwargs)
def clean_events_csv_file(self):
class ValidationErrorWithOrdinal(ValidationError):
def __init__(self, message, event_no):
super().__init__(message)
self.message = format_html(message, event_no=mark_safe(ordinal(event_no + 1)))
self.exclude_from_validation = ['desk', 'meeting_type', 'primary_event']
content = self.cleaned_data['events_csv_file'].read()
if b'\0' in content:
@ -1136,48 +1332,118 @@ class ImportEventsForm(forms.Form):
except csv.Error:
dialect = None
events = []
warnings = {}
events_by_slug = {x.slug: x for x in Event.objects.filter(agenda=self.agenda_pk)}
event_ids_with_bookings = set(
errors = []
self.events = []
self.warnings = {}
self.events_by_slug = {x.slug: x for x in Event.objects.filter(agenda=self.agenda.pk)}
self.event_ids_with_bookings = set(
Booking.objects.filter(
event__agenda=self.agenda_pk, cancellation_datetime__isnull=True
event__agenda=self.agenda.pk, cancellation_datetime__isnull=True
).values_list('event_id', flat=True)
)
seen_slugs = set(events_by_slug.keys())
self.seen_slugs = set(self.events_by_slug.keys())
line_offset = 1
for i, csvline in enumerate(csv.reader(StringIO(content), dialect=dialect)):
if not csvline:
continue
if len(csvline) < 3:
raise ValidationErrorWithOrdinal(_('Invalid file format. ({event_no} event)'), i)
if i == 0 and csvline[0].strip('#') in ('date', 'Date', _('date'), _('Date')):
line_offset = 0
continue
# label needed to generate a slug
label = None
if len(csvline) >= 5:
label = force_str(csvline[4])
try:
event = self.parse_csvline(csvline)
except ValidationError as e:
for error in getattr(e, 'error_list', [e]):
errors.append(
format_html(
'{message} ({event_no} event)',
message=error.message,
event_no=mark_safe(ordinal(i + line_offset)),
)
)
else:
self.events.append(event)
# get or create event
event = None
slug = None
if len(csvline) >= 6:
slug = force_str(csvline[5]) if csvline[5] else None
# get existing event if relevant
if slug and slug in seen_slugs:
event = events_by_slug[slug]
# update label
event.label = label
if event is None:
# new event
event = Event(agenda_id=self.agenda_pk, label=label)
# generate a slug if not provided
event.slug = slug or generate_slug(event, seen_slugs=seen_slugs, agenda=self.agenda_pk)
# maintain caches
seen_slugs.add(event.slug)
events_by_slug[event.slug] = event
if errors:
errors = [_('Invalid file format:')] + errors
raise ValidationError(errors)
def parse_csvline(self, csvline):
if len(csvline) < 3:
raise ValidationError(_('Not enough columns.'))
# label needed to generate a slug
label = None
if len(csvline) >= 5:
label = force_str(csvline[4])
# get or create event
event = None
slug = None
if len(csvline) >= 6:
slug = force_str(csvline[5]) if csvline[5] else None
# get existing event if relevant
if slug and slug in self.seen_slugs:
event = self.events_by_slug[slug]
# update label
event.label = label
if event is None:
# new event
event = Event(agenda_id=self.agenda.pk, label=label)
# generate a slug if not provided
event.slug = slug or generate_slug(event, seen_slugs=self.seen_slugs, agenda=self.agenda.pk)
# maintain caches
self.seen_slugs.add(event.slug)
self.events_by_slug[event.slug] = event
for datetime_fmt in (
'%Y-%m-%d %H:%M',
'%d/%m/%Y %H:%M',
'%d/%m/%Y %Hh%M',
'%Y-%m-%d %H:%M:%S',
'%d/%m/%Y %H:%M:%S',
):
try:
event_datetime = make_aware(
datetime.datetime.strptime('%s %s' % tuple(csvline[:2]), datetime_fmt)
)
except ValueError:
continue
if (
event.pk is not None
and event.start_datetime != event_datetime
and event.start_datetime > now()
and event.pk in self.event_ids_with_bookings
and event.pk not in self.warnings
):
# event start datetime has changed, event is not past and has not cancelled bookings
# => warn the user
self.warnings[event.pk] = event
event.start_datetime = event_datetime
break
else:
raise ValidationError(_('Wrong start date/time format.'))
try:
event.places = int(csvline[2])
except ValueError:
raise ValidationError(_('Number of places must be an integer.'))
if len(csvline) >= 4:
try:
event.waiting_list_places = int(csvline[3])
except ValueError:
raise ValidationError(_('Number of places in waiting list must be an integer.'))
column_index = 7
for more_attr in ('description', 'pricing', 'url'):
if len(csvline) >= column_index:
setattr(event, more_attr, csvline[column_index - 1])
column_index += 1
if len(csvline) >= 10 and csvline[9]: # publication date is optional
for datetime_fmt in (
'%Y-%m-%d',
'%d/%m/%Y',
'%Y-%m-%d %H:%M',
'%d/%m/%Y %H:%M',
'%d/%m/%Y %Hh%M',
@ -1185,87 +1451,39 @@ class ImportEventsForm(forms.Form):
'%d/%m/%Y %H:%M:%S',
):
try:
event_datetime = make_aware(
datetime.datetime.strptime('%s %s' % tuple(csvline[:2]), datetime_fmt)
event.publication_datetime = make_aware(
datetime.datetime.strptime(csvline[9], datetime_fmt)
)
break
except ValueError:
continue
if (
event.pk is not None
and event.start_datetime != event_datetime
and event.start_datetime > now()
and event.pk in event_ids_with_bookings
and event.pk not in warnings
):
# event start datetime has changed, event is not past and has not cancelled bookings
# => warn the user
warnings[event.pk] = event
event.start_datetime = event_datetime
break
else:
raise ValidationErrorWithOrdinal(
_('Invalid file format. (date/time format, {event_no} event)'), i
)
try:
event.places = int(csvline[2])
except ValueError:
raise ValidationError(_('Invalid file format. (number of places, {event_no} event)'), i)
if len(csvline) >= 4:
try:
event.waiting_list_places = int(csvline[3])
except ValueError:
raise ValidationError(
_('Invalid file format. (number of places in waiting list, {event_no} event)'), i
)
column_index = 7
for more_attr in ('description', 'pricing', 'url'):
if len(csvline) >= column_index:
setattr(event, more_attr, csvline[column_index - 1])
column_index += 1
if len(csvline) >= 10 and csvline[9]: # publication date is optional
for datetime_fmt in (
'%Y-%m-%d',
'%d/%m/%Y',
'%Y-%m-%d %H:%M',
'%d/%m/%Y %H:%M',
'%d/%m/%Y %Hh%M',
'%Y-%m-%d %H:%M:%S',
'%d/%m/%Y %H:%M:%S',
):
try:
event.publication_datetime = make_aware(
datetime.datetime.strptime(csvline[9], datetime_fmt)
)
break
except ValueError:
continue
else:
raise ValidationError(_('Invalid file format. (date/time format, {event_no} event)'), i)
raise ValidationError(_('Wrong publication date/time format.'))
if self.agenda.partial_bookings:
if len(csvline) < 11 or not csvline[10]:
raise ValidationError(_('Missing end_time.'))
event.end_time = csvline[10]
else:
self.exclude_from_validation.append('end_time')
if len(csvline) >= 11 and csvline[10]: # duration is optional
try:
event.duration = int(csvline[10])
except ValueError:
raise ValidationError(_('Invalid file format. (duration, {event_no} event)'), i)
raise ValidationError(_('Duration must be an integer.'))
try:
event.full_clean(exclude=['desk', 'meeting_type', 'primary_event'])
except ValidationError as e:
errors = [_('Invalid file format:\n')]
for label, field_errors in e.message_dict.items():
label_name = self.get_verbose_name(label)
msg = _('%s: ') % label_name if label_name else ''
msg += _('%(errors)s (line %(line)d)') % {
'errors': ', '.join(field_errors),
'line': i + 1,
}
errors.append(msg)
raise ValidationError(errors)
events.append(event)
self.events = events
self.warnings = warnings
try:
event.full_clean(exclude=self.exclude_from_validation)
except ValidationError as e:
errors = []
for label, field_errors in e.message_dict.items():
label_name = self.get_verbose_name(label)
msg = _('%s: ') % label_name if label_name else ''
msg += ', '.join(field_errors)
errors.append(msg)
raise ValidationError(errors)
return event
@staticmethod
def get_verbose_name(field_name):
@ -1399,9 +1617,12 @@ class AgendaDisplaySettingsForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if kwargs['instance'].kind == 'events':
self.fields['booking_user_block_template'].help_text = (
_('Displayed for each booking in event page and check page'),
)
if self.instance.partial_bookings:
del self.fields['booking_user_block_template']
else:
self.fields['booking_user_block_template'].help_text = (
_('Displayed for each booking in event page and check page'),
)
else:
self.fields['booking_user_block_template'].help_text = (
_('Displayed for each booking in agenda view pages'),
@ -1421,6 +1642,26 @@ class AgendaBookingCheckSettingsForm(forms.ModelForm):
]
widgets = {'booking_extra_user_block_template': forms.Textarea(attrs={'rows': 3})}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.partial_bookings:
del self.fields['enable_check_for_future_events']
del self.fields['booking_extra_user_block_template']
class AgendaInvoicingSettingsForm(forms.ModelForm):
class Meta:
model = Agenda
fields = [
'invoicing_unit',
'invoicing_tolerance',
]
def save(self):
super().save()
self.instance.async_refresh_booking_computed_times()
return self.instance
class AgendaNotificationsForm(forms.ModelForm):
class Meta:
@ -1513,7 +1754,7 @@ class AgendaReminderTestForm(forms.Form):
class AgendasExportForm(forms.Form):
agendas = forms.BooleanField(label=_('Agendas'), required=False, initial=True)
agendas = forms.ChoiceField(label=_('Agendas'), required=True)
resources = forms.BooleanField(label=_('Resources'), required=False, initial=True)
unavailability_calendars = forms.BooleanField(
label=_('Unavailability calendars'), required=False, initial=True
@ -1528,6 +1769,10 @@ class AgendasExportForm(forms.Form):
self.fields['shared_custody'].initial = False
self.fields['shared_custody'].widget = forms.HiddenInput()
self.fields['agendas'].choices = [('all', pgettext('agendas', 'All')), ('none', _('None'))] + [
(x.id, x.label) for x in Category.objects.all()
]
class SharedCustodyRuleForm(forms.ModelForm):
guardian = forms.ModelChoiceField(label=_('Guardian'), queryset=Person.objects.none())
@ -1639,7 +1884,7 @@ class SharedCustodyPeriodForm(forms.ModelForm):
class SharedCustodySettingsForm(forms.ModelForm):
management_role = forms.ModelChoiceField(
label=_('Management role'), required=False, queryset=Group.objects.all().order_by('name')
label=_('Management role'), required=False, queryset=get_role_queryset()
)
class Meta:

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;
@ -320,9 +332,6 @@ span.buttons-group {
}
div#appbar > h2.date-nav {
display: inline-block;
margin: 0;
padding: 0;
font-size: 100%;
position: static;
.date-title {
@ -423,7 +432,8 @@ div.event-title-meta span.tag {
color: white;
}
div.ui-dialog form p span.datetime input {
div.ui-dialog form p span.datetime input,
div.ui-dialog form input[type=time] {
width: auto;
}
@ -564,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 {
@ -595,3 +617,399 @@ span.togglable {
.extra-user-block {
padding-left: 2em;
}
div#appbar a.active {
background: #386ede;
color: white;
}
// Partial booking view
div#main-content.partial-booking-dayview {
// change default overflow to allow sticky hours list
overflow: visible;
}
.partial-booking {
--registrant-name-width: 15rem;
--zebra-color: hsla(0,0%,0%,0.05);
--separator-color: white;
--separator-size: 2px;
--padding: 0.5rem;
position: relative;
background: white;
padding: var(--padding);
&--hours-list {
background: white;
position: sticky;
z-index: 2;
top: 0;
display: grid;
grid-template-columns: repeat(var(--nb-hours), 1fr);
font-size: 80%;
@media (min-width: 761px) {
padding-left: var(--registrant-name-width);
}
}
&--hour {
text-align: center;
transform: translateX(-50%);
&:first-child {
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;
flex-wrap: wrap;
&:nth-child(odd) {
background-color: var(--zebra-color);
}
&:nth-child(even) {
--separator-color: var(--zebra-color);
}
.registrant {
&--name {
box-sizing: border-box;
margin: 0;
padding: .66rem;
font-size: 130%;
color: #505050;
font-weight: normal;
@media (min-width: 761px) {
flex: 0 0 var(--registrant-name-width);
text-align: right;
}
}
&--datas {
box-sizing: border-box;
flex: 1 0 100%;
padding: .33rem 0;
@media (min-width: 761px) {
flex-basis: auto;
}
background: linear-gradient(
to left,
var(--separator-color) var(--separator-size),
transparent var(--separator-size),
transparent 100%
);
background-size: calc(100% / var(--nb-hours)) 100%;
@media (min-width: 761px) {
border-left: var(--separator-size) solid var(--separator-color);
}
}
&--bar-container {
position: relative;
margin: 0.33rem 0;
}
&--bar {
box-sizing: border-box;
display: inline-block;
margin: 0;
position: relative;
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;
}
&.booking {
--bar-color: #1066bc;
.occasional {
font-style: italic;
font-size: 90%;
}
}
&.check.present, &.computed.present {
--bar-color: var(--green);
}
&.check.absent, &.computed.absent {
--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);
}
}
}
}
.partial-booking--check-icon {
border: 0;
&::before {
content: "\f017"; /* clock */
font-family: FontAwesome;
padding-left: 1ex;
}
}
/* ants-hub */
ul.objects-list.single-links li.ants-setting-not-configured a.edit {
color: red;
}
.ants-setting {
.meeting-type {
vertical-align: top;
text-align: center;
}
ul.inline {
display: flex;
width: 40em;
margin: 1ex;
margin-block-start: 0em;
padding-inline-start: 0em;
}
ul.inline li {
flex: 1;
text-align: center;
border: 1px solid grey;
border-width: 1px 0px 1px 1px;
list-style: none;
}
ul.inline li:first-child {
border-radius: 5px 0px 0px 5px;
}
li label {
width: 100%;
display: inline-block;
}
ul.inline li:last-child {
border-radius: 0px 5px 5px 0px;
border-width: 1px 1px 1px 1px;
}
ul.inline input {
display: none;
}
label.checked {
background: lightblue;
}
}
/* 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,14 +1,43 @@
$(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');
$(this).find('.occupation-bar').css('max-width', 100 * booked / total + '%');
});
$('.date-title').on('click', function() {
$(this).parent().find('.date-picker').toggle();
const $datePicker = $(this).parent().find('.date-picker');
$datePicker.toggle();
if ($datePicker.css("display") !== "none" && document.body.classList.contains("dayview")) {
const dateInput = document.querySelector('.date-picker--input');
dateInput.focus();
if (dateInput.showPicker) dateInput.showPicker();
}
});
$('.date-picker-opener').on('click', function() { $('.date-title').trigger('click'); });
$('.date-picker button').on('click', function() {
$('.date-picker button').on('click', function() {
if (document.body.classList.contains("dayview")) {
window.location = '../../../' + $('.date-picker--input').val().replaceAll("-", '/') + '/';
return false;
}
if ($('[name=day]').val()) {
window.location = '../../../' + $('[name=year]').val() + '/' + $('[name=month]').val() + '/' + $('[name=day]').val() + '/';
} else if ($('[name=month]').val()) {

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

@ -0,0 +1,19 @@
{% extends "chrono/manager_agenda_view.html" %}
{% load i18n %}
{% block appbar %}
{% block navigation %}{% endblock %}
<span class="actions">
<a class="extra-actions-menu-opener"></a>
<ul class="extra-actions-menu">
{% if user_can_manage %}
<li><a href="{{ agenda.get_settings_url }}">{% trans 'Settings' %}</a></li>
{% endif %}
{% block agenda-extra-menu-actions %}{% endblock %}
<li><a href="" onclick="window.print()">{% trans 'Print' %}</a></li>
</ul>
{% include "chrono/manager_agenda_view_buttons_fragment.html" with active=kind %}
</span>
{% endblock %}

View File

@ -1,4 +1,4 @@
{% extends "chrono/manager_agenda_view.html" %}
{% extends "chrono/manager_agenda_date_view.html" %}
{% load i18n %}
{% block bodyargs %}class="dayview"{% endblock %}
@ -8,29 +8,20 @@
<a>{{ day|date:"SHORT_DATE_FORMAT" }}</a>
{% endblock %}
{% block appbar %}
{% block navigation %}
<span class="buttons-group">
<a class="date-prev pk-button" href="{{ view.get_previous_day_url }}"><span class="sr-only">{% trans "Previous day" %}</span></a>
<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">
<select name="day">{% for day in view.get_days %}<option value="{{ day }}" {% if selected_day == day %}selected{% endif %}>{{day}}</option>{% endfor %}</select>
<select name="month">{% for month, month_label in view.get_months %}<option value="{{ month }}" {% if selected_month == month %}selected{% endif %}>{{ month_label }}</option>{% endfor %}</select>
<select name="year">{% for year in view.get_years %}<option value="{{ year }}" {% if selected_year == year %}selected{% endif %}>{{year}}</option>{% endfor %}</select>
<input type="date" class="date-picker--input" value="{{ view.date|date:"Y-m-d" }}" />
<button>{% trans 'Set Date' %}</button>
</div>
{% endwith %}
{% block extra_date_title %}{% endblock %}
</h2>
<span class="actions">
{% block actions %}
{% if user_can_manage %}
<a href="{% url 'chrono-manager-agenda-settings' pk=agenda.id %}">{% trans 'Settings' %}</a>
{% endif %}
<a href="" onclick="window.print()">{% trans 'Print' %}</a>
{% endblock %}
</span>
{% endblock %}

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

@ -1,4 +1,4 @@
{% extends "chrono/manager_agenda_view.html" %}
{% extends "chrono/manager_agenda_date_view.html" %}
{% load i18n %}
{% block bodyargs %}class="monthview"{% endblock %}
@ -8,7 +8,7 @@
<a>{{ view.date|date:"F Y" }}</a>
{% endblock %}
{% block appbar %}
{% block navigation %}
<span class="buttons-group">
<a class="date-prev pk-button" href="{{ view.get_previous_month_url }}"><span class="sr-only">{% trans "Previous month" %}</span></a>
<a class="date-next pk-button" href="{{ view.get_next_month_url }}"><span class="sr-only">{% trans "Next month" %}</span></a>
@ -24,12 +24,4 @@
</div>
{% endwith %}
</h2>
<span class="actions">
{% block actions %}
{% if user_can_manage %}
<a href="{{ agenda.get_settings_url }}">{% trans 'Settings' %}</a>
{% endif %}
<a href="" onclick="window.print()">{% trans 'Print' %}</a>
{% endblock %}
</span>
{% endblock %}

View File

@ -16,12 +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 %}
<li><a rel="popup" class="action-duplicate" href="{% url 'chrono-manager-agenda-duplicate' pk=object.pk %}">{% trans 'Duplicate' %}</a></li>
<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>
@ -39,7 +35,7 @@
<div class="pk-tabs">
<div class="pk-tabs--tab-list" role="tablist">
{% block agenda-settings-extra-tab-buttons %}{% endblock %}
{% if object.kind != 'virtual' %}
{% if object.kind != 'virtual' and not object.partial_bookings %}
<button aria-controls="panel-reminders" aria-selected="false" id="tab-reminders" role="tab" tabindex="-1">{% trans "Booking reminders" %}</button>
{% endif %}
<button aria-controls="panel-delays" aria-selected="false" id="tab-delays" role="tab" tabindex="-1">{% trans "Booking Delays" %}</button>
@ -49,7 +45,7 @@
{% block agenda-settings-extra-tab-list %}{% endblock %}
{% if object.kind != 'virtual' %}
{% if object.kind != 'virtual' and not object.partial_bookings %}
<div aria-labelledby="tab-reminders" id="panel-reminders" role="tabpanel" tabindex="0" hidden="">
{% for info in agenda.reminder_settings.display_info %}
<p>{{ info }}</p>
@ -113,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

@ -2,14 +2,17 @@
{% now "Y" as today_year %}
{% now "m" as today_month %}
{% now "j" as today_day %}
{% if not no_opened and agenda.kind == 'events' %}
{% now "Ymj" as today %}
{% if not no_opened and agenda.kind == 'events' and not agenda.partial_bookings %}
<a href="{% url 'chrono-manager-agenda-open-events-view' pk=agenda.pk %}">{% trans 'Open events' %}</a>
{% endif %}
{% if not no_today %}
<a href="{% url 'chrono-manager-agenda-day-view' pk=agenda.pk year=today_year month=today_month day=today_day %}">{% trans 'Today' %}</a>
{% endif %}
<span class="buttons-group">
<a {% if active == 'day' %}class="active"{% endif%} href="{% url 'chrono-manager-agenda-day-view' pk=agenda.pk year=view.date|date:"Y"|default:today_year|default:today_year month=view.date|date:"m"|default:today_month day=view.date|date:"d"|default:today_day %}">{% trans 'Day' %}</a>
<a {% if active == 'week' %}class="active"{% endif%} href="{% url 'chrono-manager-agenda-week-view' pk=agenda.pk year=view.date|date:"Y"|default:today_year|default:today_year month=view.date|date:"m"|default:today_month day=view.date|date:"d"|default:today_day %}">{% trans 'Week' %}</a>
{% if not agenda.partial_bookings %}
<a {% if active == 'week' %}class="active"{% endif%} href="{% url 'chrono-manager-agenda-week-view' pk=agenda.pk year=view.date|date:"Y"|default:today_year|default:today_year month=view.date|date:"m"|default:today_month day=view.date|date:"d"|default:today_day %}">{% trans 'Week' %}</a>
{% endif %}
<a {% if active == 'month' %}class="active"{% endif%} href="{% url 'chrono-manager-agenda-month-view' pk=agenda.pk year=view.date|date:"Y"|default:today_year|default:today_year month=view.date|date:"m"|default:today_month day=view.date|date:"d"|default:today_day %}">{% trans 'Month' %}</a>
</span>
{% if not no_today %}
<a {% if active == 'day' and view.date|date:"Ymj" == today %}class="active"{% endif%} href="{% url 'chrono-manager-agenda-day-view' pk=agenda.pk year=today_year month=today_month day=today_day %}">{% trans 'Today' %}</a>
{% endif %}

View File

@ -1,5 +1,5 @@
{% extends "chrono/manager_agenda_view.html" %}
{% load i18n %}
{% extends "chrono/manager_agenda_date_view.html" %}
{% load i18n chrono %}
{% block bodyargs %}class="weekview"{% endblock %}
@ -35,13 +35,13 @@
<a>{{ view.first_day|date:"F Y" }}</a>
{% endblock %}
{% block appbar %}
{% block navigation %}
<span class="buttons-group">
<a class="date-prev pk-button" href="{{ view.get_previous_week_url }}"><span class="sr-only">{% trans "Previous week" %}</span></a>
<a class="date-next pk-button" href="{{ view.get_next_week_url }}"><span class="sr-only">{% trans "Next week" %}</span></a>
</span>
<h2 class="date-nav">
<span class="date-title">{{ view.first_day|date:_("Y \w\e\e\k W") }}</span>
<span class="date-title">{{ view.first_day|human_date_range:view.last_day }}</span>
<button class="date-picker-opener"><span class="sr-only">{% trans "Pick a week" %}</span></button>
{% with selected_week=view.first_day|date:"W" selected_year=view.first_day|date:"Y" weeks=view.get_week_dates|get:view.first_day.year %}
<div class="date-picker" style="display: none">
@ -51,12 +51,4 @@
</div>
{% endwith %}
</h2>
<span class="actions">
{% block actions %}
{% if user_can_manage %}
<a href="{{ agenda.get_settings_url }}">{% trans 'Settings' %}</a>
{% endif %}
<a href="" onclick="window.print()">{% trans 'Print' %}</a>
{% endblock %}
</span>
{% endblock %}

View File

@ -1,9 +1,9 @@
{% extends "gadjo/base.html" %}
{% load static i18n %}
{% load gadjo static i18n %}
{% block extrascripts %}
{{ block.super }}
<script src="{% static 'js/chrono.manager.js' %}"></script>
<script src="{% static 'js/chrono.manager.js' %}?{% start_timestamp %}"></script>
{% endblock %}
{% block page-title %}{% block page-title-extra-label %}{% trans 'Agendas' %}{% endblock %} | {% trans 'Agendas' as default_site_title %}{% firstof global_title default_site_title %}{% endblock %}
@ -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 %}

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