Compare commits

..

126 Commits

Author SHA1 Message Date
Lauréline Guérin c38f8e9280
manager: use gadjo select multiple widget for agendas in report (#75417)
gitea/chrono/pipeline/head This commit looks good Details
2024-03-11 18:27:29 +01:00
Lauréline Guérin a9d1287726
manager: report for not checked or not invoiced events (#75417) 2024-03-11 18:27:27 +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
126 changed files with 9637 additions and 1461 deletions

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

@ -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),
),
]

View File

@ -76,6 +76,16 @@ from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from django.utils.translation import ngettext, pgettext_lazy
from chrono.apps.export_import.models import WithApplicationMixin
from chrono.apps.snapshot.models import (
AgendaSnapshot,
CategorySnapshot,
EventsTypeSnapshot,
ResourceSnapshot,
UnavailabilityCalendarSnapshot,
WithSnapshotManager,
WithSnapshotMixin,
)
from chrono.utils.date import get_weekday_index
from chrono.utils.db import ArraySubquery, SumCardinality
from chrono.utils.interval import Interval, IntervalSet
@ -173,7 +183,12 @@ TimeSlot = collections.namedtuple(
)
class Agenda(models.Model):
class Agenda(WithSnapshotMixin, WithApplicationMixin, models.Model):
# mark temporarily restored snapshots
snapshot = models.ForeignKey(
AgendaSnapshot, on_delete=models.CASCADE, null=True, related_name='temporary_instance'
)
label = models.CharField(_('Label'), max_length=150)
slug = models.SlugField(_('Identifier'), max_length=160, unique=True)
kind = models.CharField(_('Kind'), max_length=20, choices=AGENDA_KINDS, default='events')
@ -279,7 +294,7 @@ class Agenda(models.Model):
events_type = models.ForeignKey(
'agendas.EventsType',
verbose_name=_('Events type'),
on_delete=models.CASCADE,
on_delete=models.SET_NULL,
related_name='agendas',
null=True,
blank=True,
@ -309,6 +324,16 @@ class Agenda(models.Model):
validators=[MaxValueValidator(59)],
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
application_component_type = 'agendas'
application_label_singular = _('Agenda')
application_label_plural = _('Agendas')
objects = WithSnapshotManager()
snapshots = WithSnapshotManager(snapshots=True)
class Meta:
ordering = ['label']
@ -397,7 +422,7 @@ class Agenda(models.Model):
.filter(total=real_agendas.count())
)
return [
MeetingType(duration=mt['duration'], label=mt['label'], slug=mt['slug'])
MeetingType(duration=mt['duration'], label=mt['label'], slug=mt['slug'], agenda=self)
for mt in queryset.order_by('slug')
]
@ -439,6 +464,20 @@ class Agenda(models.Model):
raise ValueError()
return gcd
def get_dependencies(self):
yield self.view_role
yield self.edit_role
yield self.category
if self.kind == 'virtual':
yield from self.real_agendas.all()
if self.kind == 'meetings':
yield from self.resources.all()
for desk in self.desk_set.all():
yield from desk.get_dependencies()
if self.kind == 'events':
yield self.events_type
yield from self.desk_set.get().get_dependencies()
def export_json(self):
agenda = {
'label': self.label,
@ -447,6 +486,7 @@ class Agenda(models.Model):
'category': self.category.slug if self.category else None,
'minimal_booking_delay': self.minimal_booking_delay,
'maximal_booking_delay': self.maximal_booking_delay,
'anonymize_delay': self.anonymize_delay,
'permissions': {
'view': self.view_role.name if self.view_role else None,
'edit': self.edit_role.name if self.edit_role else None,
@ -467,7 +507,14 @@ class Agenda(models.Model):
agenda['booking_check_filters'] = self.booking_check_filters
agenda['event_display_template'] = self.event_display_template
agenda['mark_event_checked_auto'] = self.mark_event_checked_auto
agenda['disable_check_update'] = self.disable_check_update
agenda['enable_check_for_future_events'] = self.enable_check_for_future_events
agenda['booking_extra_user_block_template'] = self.booking_extra_user_block_template
agenda['events_type'] = self.events_type.slug if self.events_type else None
agenda['partial_bookings'] = self.partial_bookings
if self.partial_bookings:
agenda['invoicing_tolerance'] = self.invoicing_tolerance
agenda['invoicing_unit'] = self.invoicing_unit
elif self.kind == 'meetings':
agenda['meetingtypes'] = [x.export_json() for x in self.meetingtype_set.filter(deleted=False)]
agenda['desks'] = [desk.export_json() for desk in self.desk_set.all()]
@ -478,7 +525,7 @@ class Agenda(models.Model):
return agenda
@classmethod
def import_json(cls, data, overwrite=False):
def import_json(cls, data, overwrite=False, snapshot=None):
data = copy.deepcopy(data)
permissions = data.pop('permissions') or {}
reminder_settings = data.pop('reminder_settings', None)
@ -512,7 +559,13 @@ class Agenda(models.Model):
data['events_type'] = EventsType.objects.get(slug=data['events_type'])
except EventsType.DoesNotExist:
raise AgendaImportError(_('Missing "%s" events type') % data['events_type'])
agenda, created = cls.objects.update_or_create(slug=data['slug'], defaults=data)
slug = data.pop('slug')
qs_kwargs = {}
if snapshot:
qs_kwargs = {'snapshot': snapshot}
else:
qs_kwargs = {'slug': slug}
agenda, created = cls.objects.update_or_create(defaults=data, **qs_kwargs)
if overwrite:
AgendaReminderSettings.objects.filter(agenda=agenda).delete()
if reminder_settings:
@ -1192,6 +1245,7 @@ class Agenda(models.Model):
start_datetime=None,
end_datetime=None,
user_external_id=None,
lock_code=None,
):
"""Get all occupation state of all possible slots for the given agenda (of
its real agendas for a virtual agenda) and the given meeting_type.
@ -1210,6 +1264,7 @@ class Agenda(models.Model):
and bookings sets.
If it is excluded, ignore it completely.
It if is booked, report the slot as full.
If it is booked but match the lock code, report the slot as open.
"""
resources = resources or []
# virtual agendas have one constraint :
@ -1313,6 +1368,8 @@ class Agenda(models.Model):
.order_by('desk_id', 'start_datetime', 'meeting_type__duration')
.values_list('desk_id', 'start_datetime', 'meeting_type__duration')
)
if lock_code:
booked_events = booked_events.exclude(booking__lease__lock_code=lock_code)
# compute exclusion set by desk from all bookings, using
# itertools.groupby() to group them by desk_id
bookings.update(
@ -1346,6 +1403,8 @@ class Agenda(models.Model):
.order_by('start_datetime', 'meeting_type__duration')
.values_list('start_datetime', 'meeting_type__duration')
)
if lock_code:
booked_events = booked_events.exclude(booking__lease__lock_code=lock_code)
# compute exclusion set
resources_bookings = IntervalSet.from_ordered(
(event_start_datetime, event_start_datetime + datetime.timedelta(minutes=event_duration))
@ -1371,6 +1430,8 @@ class Agenda(models.Model):
.order_by('start_datetime', 'meeting_type__duration')
.values_list('start_datetime', 'meeting_type__duration')
)
if lock_code:
booked_events = booked_events.exclude(booking__lease__lock_code=lock_code)
# compute exclusion set by desk from all bookings, using
# itertools.groupby() to group them by desk_id
user_bookings = IntervalSet.from_ordered(
@ -1587,6 +1648,47 @@ class Agenda(models.Model):
free_time += desk_free_time
return free_time
def async_refresh_booking_computed_times(self):
if self.kind != 'events' or not self.partial_bookings:
return
if 'uwsgi' in sys.modules:
from chrono.utils.spooler import refresh_booking_computed_times_from_agenda
tenant = getattr(connection, 'tenant', None)
transaction.on_commit(
lambda: refresh_booking_computed_times_from_agenda.spool(
agenda_id=str(self.pk), domain=getattr(tenant, 'domain_url', None)
)
)
return
self.refresh_booking_computed_times()
def refresh_booking_computed_times(self):
bookings_queryset = (
Booking.objects.filter(
event__agenda__kind='events',
event__agenda__partial_bookings=True,
event__agenda=self,
event__check_locked=False,
event__invoiced=False,
event__cancelled=False,
cancellation_datetime__isnull=True,
)
.prefetch_related('user_checks')
.select_related('event__agenda')
)
to_update = []
for booking in bookings_queryset:
to_update += booking.refresh_computed_times()
if to_update:
BookingCheck.objects.bulk_update(to_update, ['computed_start_time', 'computed_end_time'])
def get_datetimes_url(self):
assert self.kind == 'events'
return reverse('api-agenda-datetimes', kwargs={'agenda_identifier': self.slug})
class VirtualMember(models.Model):
"""Trough model to link virtual agendas to their real agendas.
@ -1944,6 +2046,10 @@ class MeetingType(models.Model):
class Meta:
ordering = ['duration', 'label']
unique_together = ['agenda', 'slug']
verbose_name = _('Meeting type')
def __str__(self):
return '%s - %s' % (self._meta.verbose_name, self.label)
def save(self, *args, **kwargs):
assert self.agenda.kind != 'virtual', "a meetingtype can't reference a virtual agenda"
@ -1978,6 +2084,15 @@ class MeetingType(models.Model):
return new_meeting_type
def get_datetimes_url(self):
return reverse(
'api-agenda-meeting-datetimes',
kwargs={
'agenda_identifier': self.agenda.slug,
'meeting_identifier': self.slug,
},
)
class Event(models.Model):
id = models.BigAutoField(primary_key=True)
@ -2039,6 +2154,12 @@ class Event(models.Model):
full_notification_timestamp = models.DateTimeField(null=True, blank=True)
cancelled_notification_timestamp = models.DateTimeField(null=True, blank=True)
# Store alternate version of booked_places and booked_waiting_list_places,
# when an Event queryset is annotated with
# Event.annotate_queryset_for_lock_code()
unlocked_booked_places = None
unlocked_booked_waiting_list_places = None
class Meta:
ordering = ['agenda', 'start_datetime', 'duration', 'label']
unique_together = ('agenda', 'slug')
@ -2104,7 +2225,8 @@ class Event(models.Model):
booking_qs = self.booking_set.filter(
cancellation_datetime__isnull=True,
in_waiting_list=False,
user_was_present__isnull=True,
user_checks__isnull=True,
primary_booking__isnull=True,
)
if booking_qs.exists():
return
@ -2127,28 +2249,80 @@ class Event(models.Model):
self.notify_checked()
def notify_checked(self):
for booking in self.booking_set.filter(user_was_present__isnull=False):
if booking.user_was_present is True and booking.presence_callback_url:
url = booking.presence_callback_url
elif booking.user_was_present is False and booking.absence_callback_url:
url = booking.absence_callback_url
partial_bookings = self.agenda.partial_bookings
for user_check in BookingCheck.objects.filter(booking__event=self).select_related('booking'):
if user_check.presence is True and user_check.booking.presence_callback_url:
url = user_check.booking.presence_callback_url
elif user_check.presence is False and user_check.booking.absence_callback_url:
url = user_check.booking.absence_callback_url
else:
continue
payload = {
'user_was_present': booking.user_was_present,
'user_check_type_slug': booking.user_check_type_slug,
'user_check_type_label': booking.user_check_type_label,
'user_was_present': user_check.presence,
'user_check_type_slug': user_check.type_slug,
'user_check_type_label': user_check.type_label,
}
if partial_bookings:
payload.update(
{
'start_time': user_check.start_time.isoformat() if user_check.start_time else None,
'end_time': user_check.end_time.isoformat() if user_check.end_time else None,
'computed_start_time': user_check.computed_start_time.isoformat()
if user_check.computed_start_time
else None,
'computed_end_time': user_check.computed_end_time.isoformat()
if user_check.computed_end_time
else None,
}
)
try:
response = requests_wrapper.post(url, json=payload, remote_service='auto', timeout=15)
if response and not response.ok:
logging.error(
'error (HTTP %s) notifying checked booking (%s)', response.status_code, booking.id
'error (HTTP %s) notifying checked booking (%s)',
response.status_code,
user_check.booking_id,
)
except requests.Timeout:
logging.error('error (timeout) notifying checked booking (%s)', booking.id)
logging.error('error (timeout) notifying checked booking (%s)', user_check.booking_id)
except Exception as e: # noqa pylint: disable=broad-except
logging.error('error (%s) notifying checked booking (%s)', e, booking.id)
logging.error('error (%s) notifying checked booking (%s)', e, user_check.booking_id)
def async_refresh_booking_computed_times(self):
if self.agenda.kind != 'events' or not self.agenda.partial_bookings:
return
if self.check_locked or self.invoiced or self.cancelled:
return
if 'uwsgi' in sys.modules:
from chrono.utils.spooler import refresh_booking_computed_times_from_event
tenant = getattr(connection, 'tenant', None)
transaction.on_commit(
lambda: refresh_booking_computed_times_from_event.spool(
event_id=str(self.pk), domain=getattr(tenant, 'domain_url', None)
)
)
return
self.refresh_booking_computed_times()
def refresh_booking_computed_times(self):
bookings_queryset = Booking.objects.filter(
event__agenda__kind='events',
event__agenda__partial_bookings=True,
event=self,
event__check_locked=False,
event__invoiced=False,
event__cancelled=False,
cancellation_datetime__isnull=True,
).prefetch_related('user_checks')
to_update = []
for booking in bookings_queryset:
booking.event = self # to avoid lots of querysets
to_update += booking.refresh_computed_times()
if to_update:
BookingCheck.objects.bulk_update(to_update, ['computed_start_time', 'computed_end_time'])
def in_bookable_period(self, bypass_delays=False):
if self.publication_datetime and now() < self.publication_datetime:
@ -2201,7 +2375,7 @@ class Event(models.Model):
'booking',
filter=Q(
booking__cancellation_datetime__isnull=True,
booking__user_was_present=False,
booking__user_checks__presence=False,
booking__user_external_id=user_external_id,
),
),
@ -2215,6 +2389,26 @@ class Event(models.Model):
)
return qs
@staticmethod
def annotate_queryset_for_lock_code(qs, lock_code):
qs = qs.annotate(
unlocked_booked_places=Count(
'booking',
filter=Q(
booking__cancellation_datetime__isnull=True,
)
& ~Q(booking__lease__lock_code=lock_code),
),
unlocked_booked_waiting_list_places=Count(
'booking',
filter=Q(
booking__cancellation_datetime__isnull=False,
)
& ~Q(booking__lease__lock_code=lock_code),
),
)
return qs
@staticmethod
def annotate_queryset_with_overlaps(qs, other_events=None):
if not other_events:
@ -2320,10 +2514,14 @@ class Event(models.Model):
.order_by()
.values('event')
)
present_count = bookings.filter(user_was_present=True).annotate(count=Count('event')).values('count')
absent_count = bookings.filter(user_was_present=False).annotate(count=Count('event')).values('count')
present_count = (
bookings.filter(user_checks__presence=True).annotate(count=Count('event')).values('count')
)
absent_count = (
bookings.filter(user_checks__presence=False).annotate(count=Count('event')).values('count')
)
notchecked_count = (
bookings.filter(user_was_present__isnull=True).annotate(count=Count('event')).values('count')
bookings.filter(user_checks__isnull=True).annotate(count=Count('event')).values('count')
)
return qs.annotate(
present_count=Coalesce(Subquery(present_count, output_field=IntegerField()), Value(0)),
@ -2331,13 +2529,36 @@ class Event(models.Model):
notchecked_count=Coalesce(Subquery(notchecked_count, output_field=IntegerField()), Value(0)),
)
def get_booked_places(self):
if self.unlocked_booked_places is None:
return self.booked_places
else:
return self.unlocked_booked_places
def get_booked_waiting_list_places(self):
if self.unlocked_booked_waiting_list_places is None:
return self.booked_waiting_list_places
else:
return self.unlocked_booked_waiting_list_places
def get_full(self):
if self.agenda.partial_bookings:
return False
elif self.unlocked_booked_places is None:
return self.full
else:
if self.waiting_list_places == 0:
return self.get_booked_places() >= self.places
else:
return self.get_booked_waiting_list_places() >= self.waiting_list_places
@property
def remaining_places(self):
return max(0, self.places - self.booked_places)
return max(0, self.places - self.get_booked_places())
@property
def remaining_waiting_list_places(self):
return max(0, self.waiting_list_places - self.booked_waiting_list_places)
return max(0, self.waiting_list_places - self.get_booked_waiting_list_places())
@property
def end_datetime(self):
@ -2625,11 +2846,26 @@ class Event(models.Model):
return custom_fields
class EventsType(models.Model):
class EventsType(WithSnapshotMixin, WithApplicationMixin, models.Model):
# mark temporarily restored snapshots
snapshot = models.ForeignKey(
EventsTypeSnapshot, on_delete=models.CASCADE, null=True, related_name='temporary_instance'
)
slug = models.SlugField(_('Identifier'), max_length=160, unique=True)
label = models.CharField(_('Label'), max_length=150)
custom_fields = models.JSONField(blank=True, default=list)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
application_component_type = 'events_types'
application_label_singular = _('Events type')
application_label_plural = _('Events types')
objects = WithSnapshotManager()
snapshots = WithSnapshotManager(snapshots=True)
def __str__(self):
return self.label
@ -2661,11 +2897,19 @@ class EventsType(models.Model):
custom_fields.append(values)
return custom_fields
def get_dependencies(self):
return []
@classmethod
def import_json(cls, data, overwrite=False):
def import_json(cls, data, overwrite=False, snapshot=None):
data = clean_import_data(cls, data)
slug = data.pop('slug')
events_type, created = cls.objects.update_or_create(slug=slug, defaults=data)
qs_kwargs = {}
if snapshot:
qs_kwargs = {'snapshot': snapshot}
else:
qs_kwargs = {'slug': slug}
events_type, created = cls.objects.update_or_create(defaults=data, **qs_kwargs)
return created, events_type
def export_json(self):
@ -2709,6 +2953,7 @@ class Booking(models.Model):
primary_booking = models.ForeignKey(
'self', null=True, on_delete=models.CASCADE, related_name='secondary_booking_set'
)
from_recurring_fillslots = models.BooleanField(default=False)
label = models.CharField(max_length=250, blank=True)
user_display_label = models.CharField(
@ -2719,23 +2964,21 @@ class Booking(models.Model):
user_first_name = models.CharField(max_length=250, blank=True)
user_email = models.EmailField(blank=True)
user_phone_number = models.CharField(max_length=30, blank=True)
user_was_present = models.BooleanField(null=True)
user_check_type_slug = models.CharField(max_length=160, blank=True, null=True)
user_check_type_label = models.CharField(max_length=150, blank=True, null=True)
user_check_start_time = models.TimeField(_('Arrival'), null=True)
user_check_end_time = models.TimeField(_('Departure'), null=True)
out_of_min_delay = models.BooleanField(default=False)
extra_emails = ArrayField(models.EmailField(), default=list)
extra_phone_numbers = ArrayField(models.CharField(max_length=16), default=list)
form_url = models.URLField(blank=True)
backoffice_url = models.URLField(blank=True)
cancel_callback_url = models.URLField(blank=True)
presence_callback_url = models.URLField(blank=True)
absence_callback_url = models.URLField(blank=True)
form_url = models.URLField(blank=True, max_length=500)
backoffice_url = models.URLField(blank=True, max_length=500)
cancel_callback_url = models.URLField(blank=True, max_length=500)
presence_callback_url = models.URLField(blank=True, max_length=500)
absence_callback_url = models.URLField(blank=True, max_length=500)
color = models.ForeignKey(BookingColor, null=True, on_delete=models.SET_NULL, related_name='bookings')
request_uuid = models.UUIDField(editable=False, null=True)
previous_state = models.CharField(max_length=10, null=True)
start_time = models.TimeField(null=True)
end_time = models.TimeField(null=True)
@ -2743,6 +2986,15 @@ class Booking(models.Model):
def user_name(self):
return ('%s %s' % (self.user_first_name, self.user_last_name)).strip()
@cached_property
def user_check(self): # pylint: disable=method-hidden
user_checks = list(self.user_checks.all())
if len(user_checks) > 1:
raise AttributeError('booking has multiple checks')
return user_checks[0] if user_checks else None
@cached_property
def emails(self):
emails = set(self.extra_emails)
@ -2757,6 +3009,11 @@ class Booking(models.Model):
phone_numbers.add(self.user_phone_number)
return list(phone_numbers)
def refresh_from_db(self, *args, **kwargs):
if hasattr(self, 'user_check'):
del self.user_check
return super().refresh_from_db(*args, **kwargs)
def cancel(self, trigger_callback=False):
timestamp = now()
with transaction.atomic():
@ -2780,43 +3037,79 @@ class Booking(models.Model):
self.save()
def reset_user_was_present(self):
self.user_check_type_slug = None
self.user_check_type_label = None
self.user_was_present = None
with transaction.atomic():
self.secondary_booking_set.update(user_check_type_slug=None)
self.secondary_booking_set.update(user_check_type_label=None)
self.secondary_booking_set.update(user_was_present=None)
self.save()
if self.user_check:
self.user_check.delete()
self.user_check = None
self.event.checked = False
self.event.save(update_fields=['checked'])
def mark_user_absence(self, check_type_slug=None, check_type_label=None):
self.user_check_type_slug = check_type_slug
self.user_check_type_label = check_type_label
self.user_was_present = False
def mark_user_absence(self, check_type_slug=None, check_type_label=None, start_time=None, end_time=None):
if not self.user_check:
self.user_check = BookingCheck(booking=self)
self.user_check.presence = False
self.user_check.type_slug = check_type_slug
self.user_check.type_label = check_type_label
self.user_check.start_time = start_time
self.user_check.end_time = end_time
self.cancellation_datetime = None
with transaction.atomic():
self.secondary_booking_set.update(user_check_type_slug=check_type_slug)
self.secondary_booking_set.update(user_check_type_label=check_type_label)
self.secondary_booking_set.update(user_was_present=False)
self.user_check.save()
self.secondary_booking_set.update(cancellation_datetime=None)
self.save()
self.event.set_is_checked()
def mark_user_presence(self, check_type_slug=None, check_type_label=None):
self.user_check_type_slug = check_type_slug
self.user_check_type_label = check_type_label
self.user_was_present = True
def mark_user_presence(self, check_type_slug=None, check_type_label=None, start_time=None, end_time=None):
if not self.user_check:
self.user_check = BookingCheck(booking=self)
self.user_check.presence = True
self.user_check.type_slug = check_type_slug
self.user_check.type_label = check_type_label
self.user_check.start_time = start_time
self.user_check.end_time = end_time
self.cancellation_datetime = None
with transaction.atomic():
self.secondary_booking_set.update(user_check_type_slug=check_type_slug)
self.secondary_booking_set.update(user_check_type_label=check_type_label)
self.secondary_booking_set.update(user_was_present=True)
self.user_check.save()
self.secondary_booking_set.update(cancellation_datetime=None)
self.save()
self.event.set_is_checked()
def refresh_computed_times(self, commit=False):
to_update = []
user_checks = self.user_checks.all()
if len(user_checks) == 1:
user_check = user_checks[0]
changed = user_check._refresh_computed_times()
if changed:
to_update.append(user_check)
elif len(user_checks) == 2:
user_check1, user_check2 = user_checks
if user_check1.presence is True:
# first check is presence, compute it first
changed = user_check1._refresh_computed_times(adjust_end_to_booking=False)
if changed:
to_update.append(user_check1)
changed = user_check2._refresh_computed_times(other_user_check=user_check1)
if changed:
to_update.append(user_check2)
else:
# second check is presence, compute it first
changed = user_check2._refresh_computed_times(adjust_start_to_booking=False)
if changed:
to_update.append(user_check2)
changed = user_check1._refresh_computed_times(other_user_check=user_check2)
if changed:
to_update.append(user_check1)
if commit and to_update:
BookingCheck.objects.bulk_update(to_update, ['computed_start_time', 'computed_end_time'])
return to_update
def get_user_block(self):
template_vars = Context(settings.TEMPLATE_VARS, autoescape=False)
template_vars.update(
@ -2908,12 +3201,32 @@ class Booking(models.Model):
def get_backoffice_url(self):
return translate_from_publik_url(self.backoffice_url)
class BookingCheck(models.Model):
booking = models.ForeignKey(Booking, on_delete=models.CASCADE, related_name='user_checks')
presence = models.BooleanField()
start_time = models.TimeField(_('Arrival'), null=True, blank=True)
end_time = models.TimeField(_('Departure'), null=True, blank=True)
computed_start_time = models.TimeField(null=True)
computed_end_time = models.TimeField(null=True)
type_slug = models.CharField(max_length=160, blank=True, null=True)
type_label = models.CharField(max_length=150, blank=True, null=True)
class Meta:
ordering = ['start_time']
constraints = [
models.UniqueConstraint(fields=['booking', 'presence'], name='max_2_checks_on_booking')
]
def _get_previous_and_next_slots(self, _time):
minutes = {
'hour': 60,
'half_hour': 30,
'quarter': 15,
}[self.event.agenda.invoicing_unit]
}[self.booking.event.agenda.invoicing_unit]
time_minutes = _time.hour * 60 + _time.minute
previous_slot_minutes = math.trunc(time_minutes / minutes) * minutes
@ -2921,17 +3234,23 @@ class Booking(models.Model):
next_slot = datetime.time(*divmod(previous_slot_minutes + minutes, 60))
return previous_slot, next_slot
def get_computed_start_time(self):
if self.user_check_start_time is None:
def get_computed_start_time(self, other_user_check=None, adjust_to_booking=True):
if self.start_time is None:
return None
start_time = self.user_check_start_time
if self.start_time:
start_time = min(start_time, self.start_time)
if self.event.agenda.invoicing_unit == 'minute':
start_time = self.start_time
if self.booking.start_time and adjust_to_booking:
# adjust start_time to the start of the booking if requested
start_time = min(self.start_time, self.booking.start_time)
if other_user_check and other_user_check.computed_start_time and other_user_check.computed_end_time:
# if other user_check exists and is completely computed
if other_user_check.start_time < self.start_time:
# if other user_check is the first of the day, start_time is the end of other user_check
start_time = other_user_check.computed_end_time
if self.booking.event.agenda.invoicing_unit == 'minute':
return start_time
tolerance = self.event.agenda.invoicing_tolerance
tolerance = self.booking.event.agenda.invoicing_tolerance
# compute previous and next slot
previous_slot, next_slot = self._get_previous_and_next_slots(start_time)
@ -2943,17 +3262,23 @@ class Booking(models.Model):
# else take previous_slot
return previous_slot
def get_computed_end_time(self):
if self.user_check_end_time is None:
def get_computed_end_time(self, other_user_check=None, adjust_to_booking=True):
if self.end_time is None:
return None
end_time = self.user_check_end_time
if self.end_time:
end_time = max(end_time, self.end_time)
if self.event.agenda.invoicing_unit == 'minute':
end_time = self.end_time
if self.booking.end_time and adjust_to_booking:
# adjust end_time to the end of the booking if requested
end_time = max(self.end_time, self.booking.end_time)
if other_user_check and other_user_check.computed_start_time and other_user_check.computed_end_time:
# if other user_check exists and is completely computed
if other_user_check.start_time > self.start_time:
# if other user_check is the second of the day, end_time is the start of other user_check
end_time = other_user_check.computed_start_time
if self.booking.event.agenda.invoicing_unit == 'minute':
return end_time
tolerance = self.event.agenda.invoicing_tolerance
tolerance = self.booking.event.agenda.invoicing_tolerance
# compute previous and next slot
previous_slot, next_slot = self._get_previous_and_next_slots(end_time)
@ -2965,11 +3290,24 @@ class Booking(models.Model):
# else take next_slot
return next_slot
def get_partial_bookings_check_url(self, agenda, event=None):
return reverse(
'chrono-manager-partial-booking-check',
kwargs={'pk': agenda.pk, 'booking_pk': self.pk},
def _refresh_computed_times(
self, other_user_check=None, adjust_start_to_booking=True, adjust_end_to_booking=True
):
old_computed_start_time = self.computed_start_time
old_computed_end_time = self.computed_end_time
self.computed_start_time = self.get_computed_start_time(
other_user_check=other_user_check, adjust_to_booking=adjust_start_to_booking
)
self.computed_end_time = self.get_computed_end_time(
other_user_check=other_user_check, adjust_to_booking=adjust_end_to_booking
)
# return True if changed, else False
if (
old_computed_start_time == self.computed_start_time
and old_computed_end_time == self.computed_end_time
):
return False
return True
OpeningHour = collections.namedtuple('OpeningHour', ['begin', 'end'])
@ -2997,6 +3335,9 @@ class Desk(models.Model):
def base_slug(self):
return slugify(self.label)
def get_dependencies(self):
yield from self.unavailability_calendars.all()
@classmethod
def import_json(cls, data):
timeperiods = data.pop('timeperiods', [])
@ -3136,11 +3477,26 @@ class Desk(models.Model):
).delete() # source was not in settings anymore
class Resource(models.Model):
class Resource(WithSnapshotMixin, WithApplicationMixin, models.Model):
# mark temporarily restored snapshots
snapshot = models.ForeignKey(
ResourceSnapshot, on_delete=models.CASCADE, null=True, related_name='temporary_instance'
)
slug = models.SlugField(_('Identifier'), max_length=160, unique=True)
label = models.CharField(_('Label'), max_length=150)
description = models.TextField(_('Description'), blank=True, help_text=_('Optional description.'))
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
application_component_type = 'resources'
application_label_singular = _('Resource')
application_label_plural = _('Resources')
objects = WithSnapshotManager()
snapshots = WithSnapshotManager(snapshots=True)
def __str__(self):
return self.label
@ -3162,11 +3518,19 @@ class Resource(models.Model):
group_ids = [x.id for x in user.groups.all()]
return self.agenda_set.filter(edit_role_id__in=group_ids).exists()
def get_dependencies(self):
return []
@classmethod
def import_json(cls, data, overwrite=False):
def import_json(cls, data, overwrite=False, snapshot=None):
data = clean_import_data(cls, data)
slug = data.pop('slug')
resource, created = cls.objects.update_or_create(slug=slug, defaults=data)
qs_kwargs = {}
if snapshot:
qs_kwargs = {'snapshot': snapshot}
else:
qs_kwargs = {'slug': slug}
resource, created = cls.objects.update_or_create(defaults=data, **qs_kwargs)
return created, resource
def export_json(self):
@ -3177,10 +3541,25 @@ class Resource(models.Model):
}
class Category(models.Model):
class Category(WithSnapshotMixin, WithApplicationMixin, models.Model):
# mark temporarily restored snapshots
snapshot = models.ForeignKey(
CategorySnapshot, on_delete=models.CASCADE, null=True, related_name='temporary_instance'
)
slug = models.SlugField(_('Identifier'), max_length=160, unique=True)
label = models.CharField(_('Label'), max_length=150)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
application_component_type = 'agendas_categories'
application_label_singular = _('Category (agendas)')
application_label_plural = _('Categories (agendas)')
objects = WithSnapshotManager()
snapshots = WithSnapshotManager(snapshots=True)
def __str__(self):
return self.label
@ -3196,11 +3575,19 @@ class Category(models.Model):
def base_slug(self):
return slugify(self.label)
def get_dependencies(self):
return []
@classmethod
def import_json(cls, data, overwrite=False):
def import_json(cls, data, overwrite=False, snapshot=None):
data = clean_import_data(cls, data)
slug = data.pop('slug')
category, created = cls.objects.update_or_create(slug=slug, defaults=data)
qs_kwargs = {}
if snapshot:
qs_kwargs = {'snapshot': snapshot}
else:
qs_kwargs = {'slug': slug}
category, created = cls.objects.update_or_create(defaults=data, **qs_kwargs)
return created, category
def export_json(self):
@ -3486,7 +3873,12 @@ class TimePeriodExceptionSource(models.Model):
}
class UnavailabilityCalendar(models.Model):
class UnavailabilityCalendar(WithSnapshotMixin, WithApplicationMixin, models.Model):
# mark temporarily restored snapshots
snapshot = models.ForeignKey(
UnavailabilityCalendarSnapshot, on_delete=models.CASCADE, null=True, related_name='temporary_instance'
)
label = models.CharField(_('Label'), max_length=150)
slug = models.SlugField(_('Identifier'), max_length=160, unique=True)
desks = models.ManyToManyField(Desk, related_name='unavailability_calendars')
@ -3509,6 +3901,16 @@ class UnavailabilityCalendar(models.Model):
on_delete=models.SET_NULL,
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
application_component_type = 'unavailability_calendars'
application_label_singular = _('Unavailability calendar')
application_label_plural = _('Unavailability calendars')
objects = WithSnapshotManager()
snapshots = WithSnapshotManager(snapshots=True)
class Meta:
ordering = ['label']
@ -3539,6 +3941,10 @@ class UnavailabilityCalendar(models.Model):
def get_absolute_url(self):
return reverse('chrono-manager-unavailability-calendar-view', kwargs={'pk': self.id})
def get_dependencies(self):
yield self.view_role
yield self.edit_role
def export_json(self):
unavailability_calendar = {
'label': self.label,
@ -3552,7 +3958,7 @@ class UnavailabilityCalendar(models.Model):
return unavailability_calendar
@classmethod
def import_json(cls, data, overwrite=False):
def import_json(cls, data, overwrite=False, snapshot=None):
data = data.copy()
permissions = data.pop('permissions', {})
exceptions = data.pop('exceptions', [])
@ -3560,7 +3966,13 @@ class UnavailabilityCalendar(models.Model):
if permissions.get(permission):
data[permission + '_role'] = Group.objects.get(name=permissions[permission])
data = clean_import_data(cls, data)
unavailability_calendar, created = cls.objects.update_or_create(slug=data['slug'], defaults=data)
slug = data.pop('slug')
qs_kwargs = {}
if snapshot:
qs_kwargs = {'snapshot': snapshot}
else:
qs_kwargs = {'slug': slug}
unavailability_calendar, created = cls.objects.update_or_create(defaults=data, **qs_kwargs)
if overwrite:
TimePeriodException.objects.filter(unavailability_calendar=unavailability_calendar).delete()
for exception in exceptions:
@ -4000,12 +4412,6 @@ class Subscription(models.Model):
except (VariableDoesNotExist, TemplateSyntaxError):
return
def get_partial_bookings_check_url(self, agenda, event):
return reverse(
'chrono-manager-partial-booking-subscription-check',
kwargs={'pk': agenda.pk, 'event_pk': event.pk, 'subscription_pk': self.pk},
)
class Person(models.Model):
user_external_id = models.CharField(max_length=250, unique=True)
@ -4378,3 +4784,29 @@ class SharedCustodySettings(models.Model):
return cls.objects.get()
except cls.DoesNotExist:
return cls()
def get_lease_expiration():
return now() + datetime.timedelta(seconds=settings.CHRONO_LOCK_DURATION)
class Lease(models.Model):
booking = models.OneToOneField(Booking, on_delete=models.CASCADE, verbose_name=_('Booking'))
lock_code = models.CharField(verbose_name=_('Lock code'), max_length=64, blank=False)
expiration_datetime = models.DateTimeField(
verbose_name=_('Lease expiration time'), default=get_lease_expiration
)
class Meta:
verbose_name = _('Lease')
verbose_name_plural = _('Leases')
@classmethod
def clean(cls):
'''Clean objects linked to leases.'''
# Delete expired meeting's events, bookings and leases.'''
Event.objects.filter(agenda__kind='meetings', booking__lease__expiration_datetime__lt=now()).delete()
# Delete expired event's bookings and leases'''
Booking.objects.filter(event__agenda__kind='events', lease__expiration_datetime__lt=now()).delete()

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,12 +101,14 @@ 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)
end_time = serializers.TimeField(required=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)
@ -229,7 +241,60 @@ class RecurringFillslotsSerializer(MultipleAgendasEventsFillSlotsSerializer):
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')
@ -260,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
@ -278,16 +347,34 @@ 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.computed_start_time = self.instance.get_computed_start_time()
self.instance.computed_end_time = self.instance.get_computed_end_time()
for key in ['', 'user_check_', 'computed_']:
self.instance.user_check_start_time = self.user_check.start_time if self.user_check else None
self.instance.user_check_end_time = self.user_check.end_time if self.user_check else None
self.instance.computed_start_time = (
self.user_check.computed_start_time if self.user_check else None
)
self.instance.computed_end_time = self.user_check.computed_end_time if self.user_check else None
# adjust start_time (in case of multi checks)
self.instance.adjusted_start_time = self.instance.start_time
if (
self.instance.start_time
and self.instance.computed_start_time
and self.instance.start_time < self.instance.computed_start_time
):
self.instance.adjusted_start_time = self.instance.computed_start_time
# and end_time
self.instance.adjusted_end_time = self.instance.end_time
if (
self.instance.end_time
and self.instance.computed_end_time
and self.instance.end_time > self.instance.computed_end_time
):
self.instance.adjusted_end_time = self.instance.computed_end_time
for key in ['', 'user_check_', 'computed_', 'adjusted_']:
start_key, end_key, minutes_key = (
'%sstart_time' % key,
'%send_time' % key,
@ -359,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'])
@ -380,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)
@ -518,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']]
@ -595,6 +689,7 @@ class AgendaSerializer(serializers.ModelSerializer):
'slug',
'label',
'kind',
'partial_bookings',
'minimal_booking_delay',
'minimal_booking_delay_in_working_days',
'maximal_booking_delay',
@ -645,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,
@ -123,6 +133,11 @@ urlpatterns = [
views.subscription,
name='api-agenda-subscription',
),
path(
'agenda/<slug:agenda_identifier>/partial-bookings-check/',
views.partial_bookings_check,
name='api-partial-bookings-check',
),
path('bookings/', views.bookings, name='api-bookings'),
path('bookings/ics/', views.bookings_ics, name='api-bookings-ics'),
path('booking/<int:booking_pk>/', views.booking, name='api-booking'),
@ -140,4 +155,5 @@ 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')),
]

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -22,6 +22,7 @@ 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
@ -223,6 +224,7 @@ class Place(models.Model):
event__desk__agenda__in=agendas,
event__start_datetime__gt=now(),
extra_data__ants_identifiant_predemande__isnull=False,
lease__isnull=True,
)
.exclude(extra_data__ants_identifiant_predemande='')
.values_list(
@ -247,8 +249,8 @@ class Place(models.Model):
yield rdv
class Meta:
verbose_name = _('place')
verbose_name_plural = _('places')
verbose_name = pgettext_lazy('location', 'place')
verbose_name_plural = pgettext_lazy('location', 'places')
unique_together = [
('city', 'name'),
]

View File

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

View File

View File

@ -0,0 +1,300 @@
# 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 io
import json
import tarfile
from django.contrib.auth.models import Group
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.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()}
class Index(GenericAPIView):
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
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.IsAuthenticatedOrReadOnly,)
def get(self, request, *args, **kwargs):
klass = klasses[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.IsAuthenticatedOrReadOnly,)
def get(self, request, slug, *args, **kwargs):
klass = klasses[kwargs['component_type']]
serialisation = klass.objects.get(slug=slug).export_json()
return Response({'data': serialisation})
export_component = ExportComponent.as_view()
class ComponentDependencies(GenericAPIView):
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
def get(self, request, slug, *args, **kwargs):
klass = klasses[kwargs['component_type']]
component = klass.objects.get(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 = klasses[component_type]
component = get_object_or_404(klass, slug=slug)
if klass == Agenda:
return redirect(reverse('chrono-manager-agenda-view', kwargs={'pk': component.pk}))
if klass == Category:
return redirect(reverse('chrono-manager-category-list'))
if klass == EventsType:
return redirect(reverse('chrono-manager-events-type-list'))
if klass == Resource:
return redirect(reverse('chrono-manager-resource-view', kwargs={'pk': component.pk}))
if klass == UnavailabilityCalendar:
return redirect(reverse('chrono-manager-unavailability-calendar-view', kwargs={'pk': component.pk}))
raise Http404
class BundleCheck(GenericAPIView):
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
def put(self, request, *args, **kwargs):
return Response({'err': 0, 'data': {}})
bundle_check = BundleCheck.as_view()
class BundleImport(GenericAPIView):
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
install = True
def put(self, request, *args, **kwargs):
tar_io = io.BytesIO(request.read())
components = {}
with tarfile.open(fileobj=tar_io) as tar:
manifest = json.loads(tar.extractfile('manifest.json').read().decode())
self.application = Application.update_or_create_from_manifest(
manifest,
tar,
editable=not self.install,
)
for element in manifest.get('elements'):
component_type = element['type']
if component_type not in klasses or element['type'] == 'roles':
continue
component_type = klasses_translation.get(component_type, component_type)
if component_type not in components:
components[component_type] = []
component_content = (
tar.extractfile('%s/%s' % (element['type'], element['slug'])).read().decode()
)
components[component_type].append(json.loads(component_content).get('data'))
# 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)
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.IsAuthenticatedOrReadOnly,)
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')
application.documentation_url = manifest.get('documentation_url')
application.version_number = manifest.get('version_number') or 'unknown'
application.version_notes = manifest.get('version_notes')
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.CASCADE, 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.CASCADE, 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.CASCADE, 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.CASCADE, 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.CASCADE, to=settings.AUTH_USER_MODEL
),
),
],
options={
'ordering': ('-timestamp',),
'abstract': False,
},
),
]

View File

@ -0,0 +1,131 @@
# 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):
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.CASCADE, 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()
def get_instance(self):
try:
# try reusing existing instance
return self.get_instance_model().snapshots.get(snapshot=self)
except self.get_instance_model().DoesNotExist:
return self.load_instance(self.serialization, snapshot=self)
def load_instance(self, json_instance, snapshot=None):
return self.get_instance_model().import_json(json_instance, snapshot=snapshot)[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',
)

File diff suppressed because it is too large Load Diff

View File

@ -48,6 +48,7 @@ from chrono.agendas.models import (
AgendaNotificationsSettings,
AgendaReminderSettings,
Booking,
BookingCheck,
Category,
Desk,
Event,
@ -82,7 +83,7 @@ class AgendaAddForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if settings.PARTIAL_BOOKINGS_ENABLED:
if 'kind' in self.fields and settings.PARTIAL_BOOKINGS_ENABLED:
self.fields['kind'].choices += [('partial-bookings', _('Partial bookings'))]
class Meta:
@ -95,6 +96,7 @@ class AgendaAddForm(forms.ModelForm):
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
@ -521,6 +523,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()
@ -530,15 +546,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):
@ -583,7 +599,7 @@ class BookingCheckPresenceForm(forms.Form):
class PartialBookingCheckForm(forms.ModelForm):
user_was_present = forms.NullBooleanField(
presence = forms.NullBooleanField(
label=_('Status'),
widget=forms.RadioSelect(
choices=(
@ -598,19 +614,21 @@ class PartialBookingCheckForm(forms.ModelForm):
absence_check_type = forms.ChoiceField(label=_('Type'), required=False)
class Meta:
model = Booking
fields = ['user_check_start_time', 'user_check_end_time', 'user_was_present']
model = BookingCheck
fields = ['presence', 'start_time', 'end_time', 'type_label', 'type_slug']
widgets = {
'user_check_start_time': widgets.TimeWidgetWithButton(
'start_time': widgets.TimeWidgetWithButton(
step=60, button_label=_('Fill with booking start time')
),
'user_check_end_time': widgets.TimeWidgetWithButton(
step=60, button_label=_('Fill with booking end 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, **kwargs):
def __init__(self, *args, first_check_form=None, **kwargs):
agenda = kwargs.pop('agenda')
self.event = kwargs.pop('event')
self.first_check_form = first_check_form
super().__init__(*args, **kwargs)
self.check_types = get_agenda_check_types(agenda)
absence_check_types = [(ct.slug, ct.label) for ct in self.check_types if ct.kind == 'absence']
@ -618,39 +636,78 @@ class PartialBookingCheckForm(forms.ModelForm):
if presence_check_types:
self.fields['presence_check_type'].choices = [(None, '---------')] + presence_check_types
self.fields['presence_check_type'].initial = self.instance.user_check_type_slug
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.user_check_type_slug
self.fields['absence_check_type'].initial = self.instance.type_slug
else:
del self.fields['absence_check_type']
if not self.instance.start_time:
self.fields['user_check_start_time'].widget = widgets.TimeWidget(step=60)
self.fields['user_check_end_time'].widget = widgets.TimeWidget(step=60)
self.fields['user_was_present'].widget.choices = ((None, _('Not checked')), (True, _('Present')))
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['user_check_end_time'] <= self.cleaned_data['user_check_start_time']:
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['user_was_present'] is not None:
kind = 'presence' if self.cleaned_data['user_was_present'] else 'absence'
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.check_type_slug = self.cleaned_data[f'{kind}_check_type']
self.check_type_label = dict(self.fields[f'{kind}_check_type'].choices).get(
self.check_type_slug
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):
if hasattr(self, 'check_type_slug'):
self.instance.user_check_type_slug = self.check_type_slug
self.instance.user_check_type_label = self.check_type_label
return super().save()
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):
@ -716,17 +773,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(',')
@ -794,20 +863,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(
@ -843,6 +913,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:
@ -1598,9 +1681,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'),
@ -1620,6 +1706,12 @@ 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:
@ -1629,6 +1721,11 @@ class AgendaInvoicingSettingsForm(forms.ModelForm):
'invoicing_tolerance',
]
def save(self):
super().save()
self.instance.async_refresh_booking_computed_times()
return self.instance
class AgendaNotificationsForm(forms.ModelForm):
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;
@ -562,6 +574,18 @@ div.agenda-settings .pk-tabs--container {
#event_details {
margin: 1em 0;
.objects-list .lease {
background:
repeating-linear-gradient(
135deg,
hsla(10, 10%, 75%, 0.7) 0,
hsla(10, 10%, 80%, 0.55) 10px,
transparent 11px,
transparent 20px);
}
.objects-list .lease span {
padding: 0 0.5ex 0 2ex;
}
}
@media print {
@ -609,10 +633,11 @@ div#main-content.partial-booking-dayview {
--zebra-color: hsla(0,0%,0%,0.05);
--separator-color: white;
--separator-size: 2px;
--padding: 0.5rem;
position: relative;
background: white;
padding: 0.5rem;
padding: var(--padding);
&--hours-list {
background: white;
@ -634,8 +659,88 @@ div#main-content.partial-booking-dayview {
visibility: hidden;
}
}
&--occupation-rate-list {
position: static;
display: grid;
grid-template-rows: 40px auto;
align-items: end;
margin-top: 0.33rem;
margin-bottom: 1rem;
border-top: 3px solid var(--zebra-color);
grid-template-columns: repeat(var(--nb-hours), 1fr);
@media (min-width: 761px) {
grid-template-columns: var(--registrant-name-width) repeat(var(--nb-hours), 1fr);
}
}
.occupation-rate-list--title {
margin: 0;
font-size: 1rem;
font-weight: normal;
justify-self: end;
align-self: end;
padding: .66rem;
padding-bottom: 0;
@media (max-width: 760px) {
grid-column: 1/-1;
grid-row: 2/3;
}
}
.occupation-rate {
@function linear-progress($from, $to) {
$ratio: #{($to - $from) / 100};
@return "calc(#{$ratio} * var(--rate-percent) + #{$from})";
}
--hue: #{linear-progress(40, 10)};
--saturation: #{linear-progress(50%, 75%)};
--luminosity: #{linear-progress(65%, 50%)};
background-color: hsl(var(--hue), var(--saturation), var(--luminosity));
height: calc(1% * var(--rate-percent));
margin: 0;
opacity: 80%;
position: relative;
&--info {
display: block;
position: absolute;
z-index: 5;
padding: .33em .66em;
text-align: center;
background-color: var(--font-color);
color: white;
top: 100%;
left: 50%;
transform: translateX(-50%);
margin-top: .5em;
font-weight: bold;
filter: drop-shadow(0 0 3px white);
&::before {
content: "";
display: block;
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border: .5em solid transparent;
border-bottom-color: var(--font-color);
}
}
&:not(:hover) .occupation-rate--info {
display: none;
}
&:hover {
opacity: 100%;
z-index: 4;
}
&.overbooked {
--hue: 0;
--saturation: 95%;
--luminosity: 40%;
}
}
&--registrant-items {
margin-top: 0.5rem;
position: relative;
}
&--registrant {
display: flex;
@ -682,36 +787,89 @@ div#main-content.partial-booking-dayview {
margin: 0.33rem 0;
}
&--bar {
--color: white;
box-sizing: border-box;
display: inline-block;
margin: 0;
position: relative;
padding: 0.33em 0.66em;
background-color: var(--background);
color: var(--color);
background-color: var(--bar-color);
color: white;
border: none;
&:not(:first-child) {
position: absolute;
top: 0;
}
.start-time, .end-time {
display: inline-block;
padding: 0.33em 0.66em;
}
.end-time {
float: right;
margin-left: .66em;
}
&.booking {
--background: #1066bc;
--bar-color: #1066bc;
.occasional {
font-style: italic;
font-size: 90%;
}
}
&.check.present, &.computed.present {
--background: var(--green);
--bar-color: var(--green);
}
&.check.absent, &.computed.absent {
--background: var(--red);
--bar-color: var(--red);
}
&.computed {
opacity: 0.6;
}
&.end-only, &.start-only {
background-color: transparent;
.end-time, .start-time {
background-color: var(--bar-color);
position: relative;
&::before {
content:"?";
color: var(--bar-color);
font-weight: 800;
line-height: 0;
position: absolute;
border: 0.75em solid transparent;
width: 0;
height: 0;
top: 0;
bottom: 0;
margin: auto;
}
}
.start-time::before {
left: 100%;
border-left-color: var(--bar-color);
text-indent: 0.25em;
}
.end-time::before {
right: 100%;
border-right-color: var(--bar-color);
text-indent: -0.75em;
}
}
}
}
}
&--hour-indicator-wrapper {
position: absolute;
inset: 0 var(--padding) 0 var(--padding);
@media (min-width: 761px) {
margin-left: var(--registrant-name-width);
}
}
&--hour-indicator {
position: absolute;
top: 0;
left: 0;
width: 1px;
height: 100%;
background-color: var(--red);
z-index: 3;
}
}
.agenda-table.partial-bookings .booking {
@ -729,6 +887,15 @@ div#main-content.partial-booking-dayview {
}
}
.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;
@ -774,3 +941,18 @@ ul.objects-list.single-links li.ants-setting-not-configured a.edit {
/* used for the city-edit link */
.icon-edit::before { content: "\f044"; }
a.button.button-paragraph {
text-align: left;
box-sizing: border-box;
display: block;
max-width: 100%;
margin-bottom: 1rem;
line-height: 150%;
padding-top: 0.8em;
padding-bottom: 0.8em;
}
.application-logo, .application-icon {
vertical-align: middle;
}

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

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

View File

@ -16,14 +16,8 @@
</h2>
<span class="actions">
<a class="extra-actions-menu-opener"></a>
{% block agenda-extra-management-actions %}
{% endblock %}
<ul class="extra-actions-menu">
<li><a rel="popup" href="{% url 'chrono-manager-agenda-edit' pk=object.id %}">{% trans 'Options' %}</a></li>
{% block agenda-extra-menu-actions %}{% endblock %}
{% if user.is_staff %}
<li><a rel="popup" class="action-duplicate" href="{% url 'chrono-manager-agenda-duplicate' pk=object.pk %}">{% trans 'Duplicate' %}</a></li>
{% endif %}
<li><a download href="{% url 'chrono-manager-agenda-export' pk=object.id %}">{% trans 'Export Configuration (JSON)' %}</a></li>
{% if object.kind == 'events' %}
<li><a download href="{% url 'chrono-manager-agenda-export-events' pk=object.pk %}">{% trans 'Export Events (CSV)' %}</a></li>
@ -41,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>
@ -51,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>
@ -115,3 +109,20 @@
</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 %}
{% block agenda-extra-navigation-actions %}{% endblock %}
{% url 'chrono-manager-homepage' as object_list_url %}
{% include 'chrono/includes/application_detail_fragment.html' %}
</aside>
{% endblock %}

View File

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

View File

@ -1,6 +1,19 @@
{% 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>
@ -20,3 +33,6 @@
</div>
</form>
{% endblock %}
{% block sidebar %}
{% endblock %}

View File

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

View File

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

View File

@ -15,71 +15,71 @@
</h3>
<div>
<form class="check-bookings-filters">
{{ filterset.form.as_p }}
<script>
$(function() {
$('form.check-bookings-filters input').on('change',
function() {
$(this).parents('form').submit();
});
});
</script>
<fieldset class="gadjo-foldable gadjo-folded" id="filters">
<legend class="gadjo-foldable-widget">{% trans "Filtering options" %}</legend>
<div class="gadjo-folding">
{{ filterset.form.as_p }}
<button class="submit-button">{% trans "Apply" context 'form filtering action' %}</button>
</div>
</fieldset>
</form>
<table class="main check-bookings">
<tbody>
{% if results and not event.checked and not event.check_locked %}
<tr class="booking">
</div>
</div>
<div>
<table class="main check-bookings">
<tbody>
{% if results and not event.checked and not event.check_locked %}
<tr class="booking">
<td class="booking-actions" colspan="3">
<form method="post" action="{% url 'chrono-manager-event-checked' pk=agenda.pk event_pk=object.pk %}">
{% csrf_token %}
<button class="submit-button">{% trans "Mark the event as checked" %}</button>
</form>
</td>
</tr>
{% endif %}
{% if booked_without_status %}
{% if not event.checked or not agenda.disable_check_update %}
<tr class="booking all-bookings">
<td colspan="2"><b>{% trans "Mark all bookings without status:" %}</b></td>
<td class="booking-actions">
<form method="post" action="{% url 'chrono-manager-event-checked' pk=agenda.pk event_pk=object.pk %}">
<form method="post" action="{% url 'chrono-manager-event-presence' pk=agenda.pk event_pk=object.pk %}" id="all-bookings-presence">
{% csrf_token %}
<button class="submit-button">{% trans "Mark the event as checked" %}</button>
<button class="submit-button">{% trans "Presence" %}</button>
{% if presence_form.check_type.field.choices.1 %}{{ presence_form.check_type }}{% endif %}
<script>
$(function() {
$('#all-bookings-presence select').on('change',
function() {
$('#all-bookings-presence').submit();
});
});
</script>
</form>
<form method="post" action="{% url 'chrono-manager-event-absence' pk=agenda.pk event_pk=object.pk %}" id="all-bookings-absence">
{% csrf_token %}
<button class="submit-button">{% trans "Absence" %}</button>
{% if absence_form.check_type.field.choices.1 %}{{ absence_form.check_type }}{% endif %}
<script>
$(function() {
$('#all-bookings-absence select').on('change',
function() {
$('#all-bookings-absence').submit();
});
});
</script>
</form>
</td>
</tr>
{% endif %}
{% if booked_without_status %}
{% if not event.checked or not agenda.disable_check_update %}
<tr class="booking all-bookings">
<td colspan="2"><b>{% trans "Mark all bookings without status:" %}</b></td>
<td class="booking-actions">
<form method="post" action="{% url 'chrono-manager-event-presence' pk=agenda.pk event_pk=object.pk %}" id="all-bookings-presence">
{% csrf_token %}
<button class="submit-button">{% trans "Presence" %}</button>
{% if presence_form.check_type.field.choices.1 %}{{ presence_form.check_type }}{% endif %}
<script>
$(function() {
$('#all-bookings-presence select').on('change',
function() {
$('#all-bookings-presence').submit();
});
});
</script>
</form>
<form method="post" action="{% url 'chrono-manager-event-absence' pk=agenda.pk event_pk=object.pk %}" id="all-bookings-absence">
{% csrf_token %}
<button class="submit-button">{% trans "Absence" %}</button>
{% if absence_form.check_type.field.choices.1 %}{{ absence_form.check_type }}{% endif %}
<script>
$(function() {
$('#all-bookings-absence select').on('change',
function() {
$('#all-bookings-absence').submit();
});
});
</script>
</form>
</td>
</tr>
{% endif %}
{% endif %}
{% for result in results %}
<tr class="booking {% if agenda.booking_extra_user_block_template %}untoggled{% endif %}">
{% include "chrono/manager_event_check_booking_fragment.html" with booking=result %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% for result in results %}
<tr class="booking {% if agenda.booking_extra_user_block_template %}untoggled{% endif %}">
{% include "chrono/manager_event_check_booking_fragment.html" with booking=result %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if object.waiting_list_places %}

View File

@ -4,19 +4,19 @@
{% if agenda.booking_extra_user_block_template %}<span class="togglable"></span>{% endif %}
{{ booking.get_user_block }}{% if booking.places_count > 1 %} ({{ booking.places_count }} {% trans "places" %}){% endif %}
</td>
<td class="booking-status {% if booking.kind != "subscription" and booking.cancellation_datetime is None and booking.user_was_present is None %}without-status{% endif %}" data-{{ booking.kind }}-id="{{ booking.id }}">
<td class="booking-status {% if booking.kind != "subscription" and booking.cancellation_datetime is None and booking.user_check %}without-status{% endif %}" data-{{ booking.kind }}-id="{{ booking.id }}">
{% if booking.kind == "subscription" %}
({% trans "Not booked" %})
{% elif booking.cancellation_datetime is None %}
{{ booking.user_was_present|yesno:_('Present,Absent,-') }}
{% if booking.user_was_present is not None and booking.user_check_type_label %}
({{ booking.user_check_type_label }})
{% if booking.user_check %}{{ booking.user_check.presence|yesno:_('Present,Absent') }}{% else %}-{% endif %}
{% if booking.user_check.type_label %}
({{ booking.user_check.type_label }})
{% endif %}
{% else %}
({% trans "Cancelled" %})
{% endif %}
{% if not event.checked or not agenda.disable_check_update %}
{% if booking.user_was_present is not None and not event.check_locked %}
{% if booking.user_check and not event.check_locked %}
<form method="post" action="{% url 'chrono-manager-booking-reset' pk=agenda.pk booking_pk=booking.pk %}" class="with-ajax reset">
{% csrf_token %}
<a href="#">{% trans "Reset" context "check" %}</a>
@ -43,7 +43,7 @@
{% endif %}
{% csrf_token %}
<button class="submit-button"
{% if booking.user_was_present is True %}disabled{% endif %}
{% if booking.user_check.presence %}disabled{% endif %}
>{% trans "Presence" %}</button>
{% if booking.presence_form.check_type.field.choices.1 %}{{ booking.presence_form.check_type }}{% endif %}
<script>
@ -62,7 +62,7 @@
{% endif %}
{% csrf_token %}
<button class="submit-button"
{% if booking.user_was_present is False %}disabled{% endif %}
{% if booking.user_check.presence is False %}disabled{% endif %}
>{% trans "Absence" %}</button>
{% if booking.absence_form.check_type.field.choices.1 %}{{ booking.absence_form.check_type }}{% endif %}
<script>

View File

@ -7,7 +7,7 @@
{% endblock %}
{% block page-title-extra-label %}
- {% firstof agenda.label event.label %}
{% firstof agenda.label event.label %}
{% endblock %}
{% block breadcrumb %}

View File

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

View File

@ -2,13 +2,14 @@
{% load i18n %}
{% block agenda-extra-management-actions %}
<a rel="popup" href="{% url 'chrono-manager-agenda-import-events' pk=object.id %}">{% trans 'Import Events' %}</a>
<a rel="popup" href="{% url 'chrono-manager-agenda-add-event' pk=object.id %}">{% trans 'New Event' %}</a>
<a class="button button-paragraph" rel="popup" href="{% url 'chrono-manager-agenda-import-events' pk=object.id %}">{% trans 'Import Events' %}</a>
<a class="button button-paragraph" rel="popup" href="{% url 'chrono-manager-agenda-add-event' pk=object.id %}">{% trans 'New Event' %}</a>
{% endblock %}
{% block agenda-extra-menu-actions %}
{% block agenda-extra-navigation-actions %}
{% with lingo_url=object.get_lingo_url %}{% if lingo_url %}
<li><a href="{{ lingo_url }}">{% trans 'Pricing' context 'pricing' %}</a></li>
<h3>{% trans "Navigation" %}</h3>
<a class="button button-paragraph" href="{{ lingo_url }}">{% trans 'Pricing' context 'pricing' %}</a>
{% endif %}{% endwith %}
{% endblock %}
@ -21,8 +22,9 @@
<button aria-controls="panel-booking-check-options" aria-selected="false" id="tab-booking-check-options" role="tab" tabindex="-1">{% trans "Booking check options" %}</button>
{% if agenda.partial_bookings %}
<button aria-controls="panel-invoicing-options" aria-selected="false" id="tab-invoicing-options" role="tab" tabindex="-1">{% trans "Invoicing options" %}</button>
{% else %}
<button aria-controls="panel-notifications" aria-selected="false" id="tab-notifications" role="tab" tabindex="-1">{% trans "Management notifications" %}</button>
{% endif %}
<button aria-controls="panel-notifications" aria-selected="false" id="tab-notifications" role="tab" tabindex="-1">{% trans "Management notifications" %}</button>
{% endblock %}
{% block agenda-settings-extra-tab-list %}
@ -89,10 +91,12 @@
{% trans "No event display template configured for this agenda." %}
{% endif %}
</li>
<li>
{% trans "Booking display template:" %}
{% if not agenda.partial_bookings %}
<li>
{% trans "Booking display template:" %}
<pre>{{ agenda.get_booking_user_block_template }}</pre>
</li>
</li>
{% endif %}
</ul>
<div class="panel--buttons">
<a rel="popup" class="button" href="{% url 'chrono-manager-agenda-display-settings' pk=object.pk %}">{% trans 'Configure' %}</a>
@ -116,11 +120,13 @@
{% endwith %}
<li>{% trans "Automatically mark event as checked when all bookings have been checked:" %} {{ agenda.mark_event_checked_auto|yesno }}</li>
<li>{% trans "Prevent the check of bookings when event was marked as checked:" %} {{ agenda.disable_check_update|yesno }}</li>
<li>{% trans "Enable the check of bookings when event has not passed:" %} {{ agenda.enable_check_for_future_events|yesno }}</li>
<li>
{% trans "Extra user block template:" %}
{% if not agenda.partial_bookings %}
<li>{% trans "Enable the check of bookings when event has not passed:" %} {{ agenda.enable_check_for_future_events|yesno }}</li>
<li>
{% trans "Extra user block template:" %}
<pre>{{ agenda.booking_extra_user_block_template }}</pre>
</li>
</li>
{% endif %}
</ul>
<div class="panel--buttons">
<a rel="popup" class="button" href="{% url 'chrono-manager-agenda-booking-check-settings' pk=object.pk %}">{% trans 'Configure' %}</a>
@ -139,21 +145,23 @@
</div>
{% endif %}
<div aria-labelledby="tab-notifications" hidden="" id="panel-notifications" role="tabpanel" tabindex="0">
{% for notification_type in object.notifications_settings.get_notification_types %}
{% if forloop.first %}<ul>{% endif %}
<li>
{% blocktrans trimmed with display_value=notification_type.display_value label=notification_type.label %}
{{ label }}: {{ display_value }} will be notified.
{% endblocktrans %}
</li>
{% if forloop.last %}</ul>{% endif %}
{% empty %}
<p>{% trans "Notifications are disabled for this agenda." %}</p>
{% endfor %}
<div class="panel--buttons">
<a rel="popup" class="button" href="{% url 'chrono-manager-agenda-notifications-settings' pk=object.id %}">{% trans 'Configure' %}</a>
{% if not agenda.partial_bookings %}
<div aria-labelledby="tab-notifications" hidden="" id="panel-notifications" role="tabpanel" tabindex="0">
{% for notification_type in object.notifications_settings.get_notification_types %}
{% if forloop.first %}<ul>{% endif %}
<li>
{% blocktrans trimmed with display_value=notification_type.display_value label=notification_type.label %}
{{ label }}: {{ display_value }} will be notified.
{% endblocktrans %}
</li>
{% if forloop.last %}</ul>{% endif %}
{% empty %}
<p>{% trans "Notifications are disabled for this agenda." %}</p>
{% endfor %}
<div class="panel--buttons">
<a rel="popup" class="button" href="{% url 'chrono-manager-agenda-notifications-settings' pk=object.id %}">{% trans 'Configure' %}</a>
</div>
</div>
</div>
{% endif %}
{% endblock %}

View File

@ -1,6 +1,10 @@
{% extends "chrono/manager_events_type_list.html" %}
{% load i18n %}
{% block page-title-extra-label %}
{% if object.pk %}{{ object.label }}{% else %}{{ block.super }}{% endif %}
{% endblock %}
{% block breadcrumb %}
{{ block.super }}
{% if object.pk %}
@ -55,3 +59,6 @@
</div>
</form>
{% endblock %}
{% block sidebar %}
{% endblock %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -19,95 +19,157 @@
<p>{% trans "No opening hours this day." %}</p>
</div>
{% else %}
<form class="check-bookings-filters">
{{ filterset.form.as_p }}
<script>
$(function() {
$('form.check-bookings-filters input').on('change',
function() {
$(this).parents('form').submit();
});
});
</script>
</form>
<div class="section">
<div>
<form class="check-bookings-filters">
<fieldset class="gadjo-foldable gadjo-folded" id="filters">
<legend class="gadjo-foldable-widget">{% trans "Filtering options" %}</legend>
<div class="gadjo-folding">
{{ filterset.form.as_p }}
<button class="submit-button">{% trans "Apply" context 'form filtering action' %}</button>
</div>
</fieldset>
</form>
</div>
</div>
{% if results and not event.checked and not event.check_locked %}
<form method="post" action="{% url 'chrono-manager-event-checked' pk=agenda.pk event_pk=event.pk %}">
{% csrf_token %}
<button class="submit-button">{% trans "Mark the event as checked" %}</button>
</form>
<div class="section">
<div>
<form method="post" action="{% url 'chrono-manager-event-checked' pk=agenda.pk event_pk=event.pk %}">
{% csrf_token %}
<button class="submit-button">{% trans "Mark the event as checked" %}</button>
</form>
</div>
</div>
{% endif %}
<div class="partial-booking" style="--nb-hours: {{ hours|length }}">
<div class="partial-booking--hours-list" aria-hidden="true">
<div
class="partial-booking"
style="--nb-hours: {{ hours|length }}"
data-start-datetime="{{ start_datetime.isoformat }}"
data-end-datetime="{{ end_datetime.isoformat }}"
>
{% if view.date.date == today %}
<div class="partial-booking--hour-indicator-wrapper" aria-hidden="true">
<div class="partial-booking--hour-indicator" hidden></div>
<script>
const hour_indicator = (function() {
const indicator = document.querySelector('.partial-booking--hour-indicator')
const div_container = document.querySelector('.partial-booking')
const start = new Date(div_container.dataset.startDatetime).getTime()
const end = new Date(div_container.dataset.endDatetime).getTime() + 3600000 - start
const indicator_position = function() {
const now = Date.now() - start
indicator.style.left = now * 100 / end + "%"
}
indicator_position();
setInterval(indicator_position, 60000)
indicator.hidden = false;
})();
</script>
</div>
{% endif %}
<div class="partial-booking--hours-list">
{% for hour in hours %}
<div class="partial-booking--hour">{{ hour|time:"H" }}&#x202Fh</div>
{% endfor %}
</div>
<div class="partial-booking--occupation-rate-list">
<h3 class="occupation-rate-list--title">{% trans "Occupation rate" %}</h3>
{% for rate in occupation_rates %}
<p
class="occupation-rate {% if rate.overbooked %}overbooked{% endif %}"
style="--rate-percent: {{ rate.height_percent }};"
aria-label="{% blocktrans trimmed with start=rate.start_time|time:"H:i" end=rate.end_time|time:"H:i" %}
From {{ start }} to {{ end }}:
{% endblocktrans %}
{{ rate.percent }}% ({{ rate.booked_places }}/{{ event.places }})"
>
<span class="occupation-rate--info">
{{ rate.percent }}% <br> ({{ rate.booked_places }}/{{ event.places }})
</span>
</p>
{% endfor %}
</div>
<div class="partial-booking--registrant-items">
{% for user in users %}
<section class="partial-booking--registrant">
{% spaceless %}
<h3 class="registrant--name">
<span class="registrant--name-label">{{ user.name }}</span>
{% if allow_check %}
<a
class="partial-booking--check-icon"
rel="popup"
{% if user.bookings|length > 1 %}data-selector=".pk-tabs"{% endif %}
href="{{ user.check_url }}"
>{{ user.name }}</a>
{% else %}
<span>{{ user.name }}</span>
><span class="sr-only">{% trans "Check" %}</span></a>
{% endif %}
</h3>
{% endspaceless %}
<div class="registrant--datas">
<div class="registrant--bar-container">
{% for booking in user.bookings %}
{% if booking.start_time %}
<p
class="registrant--bar clearfix booking"
title="{% trans "Booked period" %}"
style="left: {{ booking.css_left }}%; width: {{ booking.css_width }}%;"
>
<strong class="sr-only">{% trans "Booked period:" %}</strong>
<time class="start-time" datetime="{{ booking.start_time|time:"H:i" }}">{{ booking.start_time|time:"H:i" }}</time>
<time class="end-time" datetime="{{ booking.end_time|time:"H:i" }}">{{ booking.end_time|time:"H:i" }}</time>
</p>
{% endif %}
{% endfor %}
</div>
{% if user.bookings %}
{% if not filterset.form.cleaned_data or 'booked' in filterset.form.cleaned_data.display %}
<div class="registrant--bar-container">
{% for booking in user.bookings %}
{% if booking.user_was_present is not None %}
{% if booking.start_time %}
<p
class="registrant--bar clearfix check {{ booking.check_css_class }}"
title="{% trans "Checked period:" %}"
style="left: {{ booking.check_css_left }}%; width: {{ booking.check_css_width }}%;"
class="registrant--bar booking"
title="{% trans "Booked period" %}"
style="left: {{ booking.css_left }}%; width: {{ booking.css_width }}%;"
>
<strong class="sr-only">{% trans "Booked period:" %}</strong>
<time class="start-time" datetime="{{ booking.start_time|time:"H:i" }}">{{ booking.start_time|time:"H:i" }}</time>
<time class="end-time" datetime="{{ booking.end_time|time:"H:i" }}">{{ booking.end_time|time:"H:i" }}</time>
{% if not booking.from_recurring_fillslots %}
<span class="occasional">{% trans "occasional" %}</span>
{% endif %}
</p>
{% endif %}
{% endfor %}
</div>
{% endif %}
{% if user.bookings %}
{% if not filterset.form.cleaned_data or 'checked' in filterset.form.cleaned_data.display %}
<div class="registrant--bar-container">
{% for check in user.booking_checks %}
<p
class="registrant--bar check {{ check.css_class }}"
title="{% trans "Checked period" %}"
style="left: {{ check.css_left }}%;{% if check.css_width %} width: {{ check.css_width }}%;{% endif %}"
>
<strong class="sr-only">{% trans "Checked period:" %}</strong>
<time class="start-time" datetime="{{ booking.user_check_start_time|time:"H:i" }}">{{ booking.user_check_start_time|time:"H:i" }}</time>
<time class="end-time" datetime="{{ booking.user_check_end_time|time:"H:i" }}">{{ booking.user_check_end_time|time:"H:i" }}</time>
{% if booking.user_check_type_label %}<span>{{ booking.user_check_type_label }}</span>{% endif %}
{% if check.start_time %}
<time class="start-time" datetime="{{ check.start_time|time:"H:i" }}">{{ check.start_time|time:"H:i" }}</time>
{% endif %}
{% if check.end_time %}
<time class="end-time" datetime="{{ check.end_time|time:"H:i" }}">{{ check.end_time|time:"H:i" }}</time>
{% endif %}
{% if check.type_label %}<span>{{ check.type_label }}</span>{% endif %}
</p>
{% endif %}
{% endfor %}
</div>
<div class="registrant--bar-container">
{% for booking in user.bookings %}
{% if booking.user_was_present is not None and booking.computed_start_time != None and booking.computed_end_time != None %}
<p
class="registrant--bar clearfix computed {{ booking.check_css_class }}"
title="{% trans "Computed period" %}"
style="left: {{ booking.computed_css_left }}%; width: {{ booking.computed_css_width }}%;"
>
<strong class="sr-only">{% trans "Computed period:" %}</strong>
<time class="start-time" datetime="{{ booking.computed_start_time|time:"H:i" }}">{{ booking.computed_start_time|time:"H:i" }}</time>
<time class="end-time" datetime="{{ booking.computed_end_time|time:"H:i" }}">{{ booking.computed_end_time|time:"H:i" }}</time>
</p>
{% endif %}
{% endfor %}
</div>
{% endfor %}
</div>
{% endif %}
{% if not filterset.form.cleaned_data or 'computed' in filterset.form.cleaned_data.display %}
<div class="registrant--bar-container">
{% for check in user.booking_checks %}
{% if check.computed_start_time and check.computed_end_time %}
<p
class="registrant--bar computed {{ check.css_class }}"
title="{% trans "Computed period" %}"
style="left: {{ check.computed_css_left }}%; width: {{ check.computed_css_width }}%;"
>
<strong class="sr-only">{% trans "Computed period:" %}</strong>
<time class="start-time" datetime="{{ check.computed_start_time|time:"H:i" }}">{{ check.computed_start_time|time:"H:i" }}</time>
<time class="end-time" datetime="{{ check.computed_end_time|time:"H:i" }}">{{ check.computed_end_time|time:"H:i" }}</time>
</p>
{% endif %}
{% endfor %}
</div>
{% endif %}
{% endif %}
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,7 +7,16 @@
{% endblock %}
{% block appbar %}
<h2>{% trans "Settings" %}</h2>
<h2>
{% trans "Settings" %}
<span class="identifier">
[
{% trans "guardians identifiers:" %} {{ agenda.first_guardian.user_external_id }}, {{ agenda.second_guardian.user_external_id }}
/
{% trans "child identifier:" %} {{ agenda.child.user_external_id }}
]
</span>
</h2>
<span class="actions">
{% if user.is_staff %}
<a class="extra-actions-menu-opener"></a>

View File

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

View File

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

View File

@ -2,7 +2,7 @@
{% load i18n %}
{% block page-title-extra-label %}
- {{ unavailability_calendar.label }}
{{ unavailability_calendar.label }}
{% endblock %}
{% block breadcrumb %}
@ -49,3 +49,6 @@
</div>
{% endblock %}
{% block sidebar %}
{% endblock %}

View File

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

View File

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

View File

@ -11,16 +11,8 @@
</h2>
<span class="actions">
<a class="extra-actions-menu-opener"></a>
{% block agenda-extra-management-actions %}
<a rel="popup" href="{% url 'chrono-manager-unavailability-calendar-import-unavailabilities' pk=unavailability_calendar.id %}">{% trans 'Manage unavailabilities from ICS' %}</a>
<a rel="popup" href="{% url 'chrono-manager-unavailability-calendar-add-unavailability' pk=unavailability_calendar.id %}">{% trans 'Add Unavailability' %}</a>
{% endblock %}
<ul class="extra-actions-menu">
<li><a rel="popup" href="{% url 'chrono-manager-unavailability-calendar-edit' pk=unavailability_calendar.id %}">{% trans 'Options' %}</a></li>
<li><a download href="{% url 'chrono-manager-unavailability-calendar-export' pk=unavailability_calendar.id %}">{% trans 'Export Configuration (JSON)' %}</a></li>
{% if user.is_staff %}
<li><a rel="popup" href="{% url 'chrono-manager-unavailability-calendar-delete' pk=unavailability_calendar.id %}">{% trans 'Delete' %}</a></li>
{% endif %}
</ul>
</span>
{% endblock %}
@ -50,5 +42,19 @@
</div>
{% endblock %}
{% endblock %}
{% block sidebar %}
<aside id="sidebar">
<h3>{% trans "Actions" %}</h3>
<a class="button button-paragraph" rel="popup" href="{% url 'chrono-manager-unavailability-calendar-import-unavailabilities' pk=unavailability_calendar.id %}">{% trans 'Manage unavailabilities from ICS' %}</a>
<a class="button button-paragraph" rel="popup" href="{% url 'chrono-manager-unavailability-calendar-add-unavailability' pk=unavailability_calendar.id %}">{% trans 'Add Unavailability' %}</a>
<a class="button button-paragraph" rel="popup" href="{% url 'chrono-manager-unavailability-calendar-edit' pk=unavailability_calendar.id %}">{% trans 'Options' %}</a>
{% if user.is_staff %}
<a class="button button-paragraph" rel="popup" href="{% url 'chrono-manager-unavailability-calendar-delete' pk=unavailability_calendar.id %}">{% trans 'Delete' %}</a>
{% endif %}
{% url 'chrono-manager-unavailability-calendar-list' as object_list_url %}
{% include 'chrono/includes/application_detail_fragment.html' %}
</aside>
{% endblock %}

View File

@ -2,8 +2,8 @@
{% load i18n %}
{% block agenda-extra-management-actions %}
<a rel="popup" href="{% url 'chrono-manager-virtual-agenda-add-time-period' pk=object.id %}">{% trans 'Add Excluded Period' %}</a>
<a rel="popup" href="{% url 'chrono-manager-agenda-add-virtual-member' pk=object.id %}">{% trans 'Include Agenda' %}</a>
<a class="button button-paragraph" rel="popup" href="{% url 'chrono-manager-virtual-agenda-add-time-period' pk=object.id %}">{% trans 'Add Excluded Period' %}</a>
<a class="button button-paragraph" rel="popup" href="{% url 'chrono-manager-agenda-add-virtual-member' pk=object.id %}">{% trans 'Include Agenda' %}</a>
{% endblock %}
{% block agenda-settings-extra-tab-buttons %}

View File

@ -435,15 +435,10 @@ urlpatterns = [
name='chrono-manager-booking-extra-user-block',
),
path(
'agendas/<int:pk>/bookings/<int:booking_pk>/check',
'agendas/<int:pk>/events/<int:event_pk>/check-bookings/<str:user_external_id>',
views.partial_booking_check_view,
name='chrono-manager-partial-booking-check',
),
path(
'agendas/<int:pk>/subscriptions/<int:subscription_pk>/check/<int:event_pk>',
views.partial_booking_subscription_check_view,
name='chrono-manager-partial-booking-subscription-check',
),
path(
'agendas/<int:pk>/subscriptions/<int:subscription_pk>/extra-user-block',
views.subscription_extra_user_block,

View File

@ -106,6 +106,10 @@ def import_site(data, if_empty=False, clean=False, overwrite=False):
existing_roles_names = set(existing_roles.values_list('name', flat=True))
raise AgendaImportError('Missing roles: "%s"' % ', '.join(role_names - existing_roles_names))
# sort agendas to import virtual agendas first
if data.get('agendas'):
data['agendas'] = sorted(data['agendas'], key=lambda a: a['kind'] == 'virtual')
with transaction.atomic():
for cls, key in (
(Category, 'categories'),

File diff suppressed because it is too large Load Diff

View File

@ -54,12 +54,15 @@ INSTALLED_APPS = (
'django.contrib.staticfiles',
'django.contrib.humanize',
'gadjo',
'rest_framework',
'django_filters',
'sorl.thumbnail',
'chrono.agendas',
'chrono.api',
'chrono.manager',
'chrono.apps.ants_hub',
'rest_framework',
'django_filters',
'chrono.apps.export_import',
'chrono.apps.snapshot',
)
MIDDLEWARE = (
@ -177,6 +180,9 @@ MELLON_IDENTITY_PROVIDERS = []
# (see http://docs.python-requests.org/en/master/user/advanced/#proxies)
REQUESTS_PROXIES = None
# default in seconds
CHRONO_LOCK_DURATION = 10 * 60
# timeout used in python-requests call, in seconds
# we use 28s by default: timeout just before web server, which is usually 30s
REQUESTS_TIMEOUT = 28
@ -203,6 +209,10 @@ PARTIAL_BOOKINGS_ENABLED = False
CHRONO_ANTS_HUB_URL = None
# from solr.thumbnail -- https://sorl-thumbnail.readthedocs.io/en/latest/reference/settings.html
THUMBNAIL_PRESERVE_FORMAT = True
THUMBNAIL_FORCE_OVERWRITE = False
local_settings_file = os.environ.get(
'CHRONO_SETTINGS_FILE', os.path.join(os.path.dirname(__file__), 'local_settings.py')
)

View File

@ -20,6 +20,8 @@ from django.contrib.auth.decorators import login_required
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
from django.urls import include, path, re_path
from chrono.apps.export_import import urls as export_import_urls
from .api.urls import urlpatterns as chrono_api_urls
from .manager.urls import urlpatterns as chrono_manager_urls
from .urls_utils import decorated_includes
@ -29,6 +31,7 @@ urlpatterns = [
path('', homepage, name='home'),
re_path(r'^manage/', decorated_includes(login_required, include(chrono_manager_urls))),
path('api/', include(chrono_api_urls)),
path('api/', include(export_import_urls)),
path('logout/', LogoutView.as_view(), name='auth_logout'),
path('login/', LoginView.as_view(), name='auth_login'),
]

View File

@ -203,8 +203,7 @@ class IntervalSet:
break
if not c2:
yield c1
for c1 in l1:
yield c1
yield from l1
break
if c1[1] <= c2[0]:
yield c1

View File

@ -17,7 +17,7 @@
from django.db import connection
from uwsgidecorators import spool # pylint: disable=import-error
from chrono.agendas.models import Event, ICSError, TimePeriodExceptionSource
from chrono.agendas.models import Agenda, Event, ICSError, TimePeriodExceptionSource
def set_connection(domain):
@ -83,3 +83,31 @@ def ants_hub_city_push(args):
City.push()
except Exception: # noqa pylint: disable=broad-except
pass
@spool
def refresh_booking_computed_times_from_agenda(args):
if args.get('domain'):
# multitenant installation
set_connection(args['domain'])
try:
agenda = Agenda.objects.get(pk=args['agenda_id'])
except Agenda.DoesNotExist:
return
agenda.refresh_booking_computed_times()
@spool
def refresh_booking_computed_times_from_event(args):
if args.get('domain'):
# multitenant installation
set_connection(args['domain'])
try:
event = Event.objects.get(pk=args['event_id'])
except Event.DoesNotExist:
return
event.refresh_booking_computed_times()

View File

@ -4,6 +4,7 @@ After=network.target postgresql.service
Wants=postgresql.service
[Service]
SyslogIdentifier=uwsgi/%p
Environment=CHRONO_SETTINGS_FILE=/usr/lib/%p/debian_config.py
Environment=LANG=C.UTF-8
User=%p

1
debian/control vendored
View File

@ -34,6 +34,7 @@ Depends: libcairo-gobject2,
python3-django-tenant-schemas,
python3-hobo (>= 1.34),
python3-psycopg2,
python3-sorl-thumbnail,
python3-vobject,
uwsgi,
uwsgi-plugin-python3,

2
debian/uwsgi.ini vendored
View File

@ -21,6 +21,7 @@ spooler-max-tasks = 20
cron2 = minute=-5,unique=1 /usr/bin/chrono-manage tenant_command cancel_events --all-tenants -v0
cron2 = minute=-5,unique=1 /usr/bin/chrono-manage tenant_command send_email_notifications --all-tenants -v0
cron2 = minute=-5,unique=1 /usr/bin/chrono-manage tenant_command update_event_recurrences --all-tenants -v0
cron2 = minute=-5,unique=1 /usr/bin/chrono-manage tenant_command clean_leases --all-tenants -v0
# every fifteen minutes
cron2 = minute=-15,unique=1 /usr/bin/chrono-manage tenant_command sync-ants-hub --all-tenants
# hourly
@ -61,6 +62,7 @@ buffer-size = 32768
py-tracebacker = /run/chrono/py-tracebacker.sock.
stats = /run/chrono/stats.sock
memory-report = true
ignore-sigpipe = true

View File

@ -48,7 +48,7 @@ def get_version():
real_number, commit_count, commit_hash = result.split('-', 2)
version = '%s.post%s+%s' % (real_number, commit_count, commit_hash)
else:
version = result
version = result.replace('.dirty', '+dirty')
return version
else:
return '0.0.post%s' % len(subprocess.check_output(['git', 'rev-list', 'HEAD']).splitlines())
@ -161,13 +161,14 @@ setup(
install_requires=[
'django>=3.2, <3.3',
'gadjo',
'djangorestframework>=3.4,<3.14',
'django-filter',
'djangorestframework>=3.4,<3.15',
'django-filter<23.2',
'vobject',
'python-dateutil',
'requests',
'workalendar',
'weasyprint',
'sorl-thumbnail',
],
zip_safe=False,
cmdclass={

View File

@ -0,0 +1,85 @@
# 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 unittest import mock
import pytest
import requests
import responses
from django.contrib.auth import get_user_model
from chrono.apps.ants_hub.hub import AntsHubException, check_duplicate, ping, push_rendez_vous_disponibles
User = get_user_model()
def test_authorization(app, user):
app.post('/api/ants/check-duplicate/', status=401)
@pytest.fixture
def user(db):
user = User(username='john.doe', first_name='John', last_name='Doe', email='john.doe@example.net')
user.set_password('password')
user.save()
return user
@pytest.fixture
def auth_app(user, app):
app.authorization = ('Basic', ('john.doe', 'password'))
return app
class TestCheckDuplicateAPI:
def test_not_configured(self, auth_app):
resp = auth_app.post('/api/ants/check-duplicate/', status=400)
assert resp.json == {
'err': 1,
'err_class': 'CHRONO_ANTS_HUB_URL is not configured',
'err_desc': 'CHRONO_ANTS_HUB_URL is not configured',
'reason': 'CHRONO_ANTS_HUB_URL is not configured',
}
def test_input_empty(self, hub, auth_app):
resp = auth_app.post('/api/ants/check-duplicate/')
assert resp.json == {'data': {'accept_rdv': True}, 'err': 0}
@mock.patch('chrono.apps.ants_hub.hub.check_duplicate')
def test_proxy(self, check_duplicate_mock, hub, auth_app):
# do not care about output
check_duplicate_mock.return_value = {'err': 0, 'data': {'xyz': '1234'}}
# GET param
resp = auth_app.post('/api/ants/check-duplicate/?identifiant_predemande= ABCdE12345, ,1234567890 ')
assert resp.json == {'err': 0, 'data': {'xyz': '1234'}}
assert check_duplicate_mock.call_args[0][0] == ['ABCDE12345', '1234567890']
# JSON payload as string
resp = auth_app.post_json(
'/api/ants/check-duplicate/?identifiant_predemande=XYZ',
params={'identifiant_predemande': ' XBCdE12345, ,1234567890 '},
)
assert resp.json == {'err': 0, 'data': {'xyz': '1234'}}
assert check_duplicate_mock.call_args[0][0] == ['XBCDE12345', '1234567890']
# JSON payload as list
resp = auth_app.post_json(
'/api/ants/check-duplicate/?identifiant_predemande=XYZ',
params={'identifiant_predemande': [' YBCdE12345', ' ', '1234567890 ']},
)
assert resp.json == {'err': 0, 'data': {'xyz': '1234'}}
assert check_duplicate_mock.call_args[0][0] == ['YBCDE12345', '1234567890']

View File

@ -18,7 +18,7 @@ import pytest
import requests
import responses
from chrono.apps.ants_hub.hub import AntsHubException, ping, push_rendez_vous_disponibles
from chrono.apps.ants_hub.hub import AntsHubException, check_duplicate, ping, push_rendez_vous_disponibles
def test_ping_timeout(hub):
@ -67,3 +67,37 @@ def test_push_rendez_vous_disponibles_application_error(hub):
)
with pytest.raises(AntsHubException, match='overload'):
push_rendez_vous_disponibles({})
class TestCheckDuplicate:
def test_status_500(self, hub):
hub.add(responses.GET, 'https://toto:@ants-hub.example.com/api/chrono/rdv-status/', status=500)
assert check_duplicate(['A' * 10, '1' * 10]) == {
'err': 1,
'err_desc': "ANTS hub is unavailable: HTTPError('500 Server Error: Internal Server Error for url: https://toto:@ants-hub.example.com/api/chrono/rdv-status/?identifiant_predemande=AAAAAAAAAA&identifiant_predemande=1111111111')",
}
def test_timeout(self, hub):
hub.add(
responses.GET,
'https://toto:@ants-hub.example.com/api/chrono/rdv-status/',
body=requests.Timeout('boom!'),
)
assert check_duplicate(['A' * 10, '1' * 10]) == {
'err': 1,
'err_desc': "ANTS hub is unavailable: Timeout('boom!')",
}
def test_ok(self, hub):
hub.add(
responses.GET,
'https://toto:@ants-hub.example.com/api/chrono/rdv-status/?identifiant_predemande=AAAAAAAAAA&identifiant_predemande=1111111111',
json={
'err': 0,
'data': {},
},
)
assert check_duplicate(['A' * 10, '1' * 10]) == {
'err': 0,
'data': {},
}

View File

@ -1494,38 +1494,33 @@ def test_datetimes_multiple_agendas_with_status(app):
places=5,
agenda=agenda,
)
Booking.objects.create(event=event_absence, user_external_id='xxx', user_was_present=False)
booking = Booking.objects.create(event=event_absence, user_external_id='xxx')
booking.mark_user_absence()
event_absence_with_reason = Event.objects.create(
slug='event-absence_with_reason',
start_datetime=now() - datetime.timedelta(days=11),
places=5,
agenda=agenda,
)
Booking.objects.create(
event=event_absence_with_reason,
user_external_id='xxx',
user_was_present=False,
user_check_type_slug='foo-reason',
)
booking = Booking.objects.create(event=event_absence_with_reason, user_external_id='xxx')
booking.mark_user_absence(check_type_slug='foo-reason')
event_presence = Event.objects.create(
slug='event-presence',
start_datetime=now() - datetime.timedelta(days=10),
places=5,
agenda=agenda,
)
Booking.objects.create(event=event_presence, user_external_id='xxx', user_was_present=True)
booking = Booking.objects.create(event=event_presence, user_external_id='xxx')
booking.mark_user_presence()
event_presence_with_reason = Event.objects.create(
slug='event-presence_with_reason',
start_datetime=now() - datetime.timedelta(days=9),
places=5,
agenda=agenda,
)
Booking.objects.create(
event=event_presence_with_reason,
user_external_id='xxx',
user_was_present=True,
user_check_type_slug='foo-reason',
)
booking = Booking.objects.create(event=event_presence_with_reason, user_external_id='xxx')
booking.mark_user_presence(check_type_slug='foo-reason')
event_booked_future = Event.objects.create(
slug='event-booked-future',
start_datetime=now() + datetime.timedelta(days=1),

View File

@ -871,14 +871,14 @@ def test_meetings_and_virtual_datetimes_date_filter(app):
virtual_agenda.save()
# exclude weekday6 through date_end, 4 slots each day * 5 days
params = {'date_end': localtime(now() + datetime.timedelta(days=6)).date().isoformat()}
params = {'date_end': (localtime(now()).date() + datetime.timedelta(days=6)).isoformat()}
resp = app.get(foo_api_url, params=params)
assert len(resp.json['data']) == 20
resp = app.get(virtual_api_url, params=params)
assert len(resp.json['data']) == 20
params = {
'date_end': localtime(now() + datetime.timedelta(days=6))
'date_end': (localtime(now()) + datetime.timedelta(days=6))
.replace(hour=11, minute=0, second=0, microsecond=0)
.isoformat()
}

View File

@ -18,7 +18,7 @@ from chrono.agendas.models import (
TimePeriod,
VirtualMember,
)
from chrono.utils.timezone import localtime, now
from chrono.utils.timezone import localtime, make_aware, now
pytestmark = pytest.mark.django_db
@ -52,12 +52,14 @@ def test_booking_api(app, user):
assert 'cancel_url' in resp.json['api']
assert 'ics_url' in resp.json['api']
assert 'anonymize_url' in resp.json['api']
assert 'resize_url' in resp.json['api']
assert urlparse.urlparse(resp.json['api']['booking_url']).netloc
assert urlparse.urlparse(resp.json['api']['accept_url']).netloc
assert urlparse.urlparse(resp.json['api']['suspend_url']).netloc
assert urlparse.urlparse(resp.json['api']['cancel_url']).netloc
assert urlparse.urlparse(resp.json['api']['ics_url']).netloc
assert urlparse.urlparse(resp.json['api']['anonymize_url']).netloc
assert urlparse.urlparse(resp.json['api']['resize_url']).netloc
assert Booking.objects.count() == 1
# access by slug
@ -79,7 +81,7 @@ def test_booking_api(app, user):
'user_phone_number': '+33 (0) 6 12 34 56 78', # long phone number
'form_url': 'http://example.net/',
'extra_emails': ['baz@baz.com', 'hop@hop.com'],
'extra_phone_numbers': ['+33123456789', '+33123456789'],
'extra_phone_numbers': ['+33123456789', '+33 1 23 45 67 89'],
'presence_callback_url': 'http://example.net/jump/trigger2/',
'absence_callback_url': 'http://example.net/jump/trigger3/',
},
@ -95,6 +97,7 @@ def test_booking_api(app, user):
assert booking.user_email == 'bar@bar.com'
assert booking.user_phone_number == '+33 (0) 6 12 34 56 78'
assert booking.form_url == 'http://example.net/'
assert booking.from_recurring_fillslots is False
resp = app.post_json(
'/api/agenda/%s/fillslot/%s/' % (agenda.id, event.id),
@ -292,7 +295,7 @@ def test_booking_api_extra_emails(app, user):
fillslot_url,
params={
'extra_emails': 'bar.com',
'extra_phone_numbers': 'too loooooooooong',
'extra_phone_numbers': 'too loooooooooooong',
},
status=400,
)
@ -2033,9 +2036,10 @@ def test_booking_api_partial_booking(app, user):
agenda = Agenda.objects.create(label='Foo bar', kind='events', partial_bookings=True)
event = Event.objects.create(
label='Event',
start_datetime=now() + datetime.timedelta(days=5),
start_datetime=make_aware(datetime.datetime(2021, 3, 1, 8, 0)),
end_time=datetime.time(18, 00),
duration=120,
places=1,
places=2,
agenda=agenda,
)
@ -2067,6 +2071,17 @@ def test_booking_api_partial_booking(app, user):
== 'must include start_time and end_time for partial bookings agenda'
)
# null end_time
resp = app.post_json(
'/api/agenda/%s/fillslot/%s/' % (agenda.slug, event.id),
params={'start_time': '10:00', 'end_time': None},
status=400,
)
assert (
resp.json['errors']['non_field_errors'][0]
== 'must include start_time and end_time for partial bookings agenda'
)
# end before start
resp = app.post(
'/api/agenda/%s/fillslot/%s/' % (agenda.slug, event.id),
@ -2074,3 +2089,17 @@ def test_booking_api_partial_booking(app, user):
status=400,
)
assert resp.json['errors']['non_field_errors'][0] == 'start_time must be before end_time'
# start before opening time
resp = app.post(
'/api/agenda/%s/fillslot/%s/' % (agenda.slug, event.id),
params={'start_time': '07:59', 'end_time': '18:00'},
)
assert resp.json['err_desc'] == 'booking start must be after opening time'
# end after closing time
resp = app.post(
'/api/agenda/%s/fillslot/%s/' % (agenda.slug, event.id),
params={'start_time': '08:00', 'end_time': '18:01'},
)
assert resp.json['err_desc'] == 'booking end must be before closing time'

View File

@ -1,11 +1,13 @@
import datetime
import pytest
from django.core.management import call_command
from django.db import connection
from django.test.utils import CaptureQueriesContext
from chrono.agendas.models import Agenda, Booking, Event, EventsType
from chrono.utils.timezone import localtime, now
from chrono.agendas.models import Agenda, Booking, Event, EventsType, Lease
from chrono.utils.timezone import localtime, make_aware, now
from tests.utils import build_event_agenda
pytestmark = pytest.mark.django_db
@ -52,6 +54,7 @@ def test_api_events_fillslots(app, user):
resp.json['bookings_ics_url']
== 'http://testserver/api/bookings/ics/?user_external_id=user_id&agenda=foo-bar'
)
assert 'revert_url' not in resp.json
events = Event.objects.all()
assert events.filter(booked_places=1).count() == 2
@ -567,7 +570,8 @@ def test_api_events_fillslots_partial_bookings(app, user):
agenda = Agenda.objects.create(label='Foo bar', kind='events', partial_bookings=True)
Event.objects.create(
label='Event',
start_datetime=now() + datetime.timedelta(days=5),
start_datetime=make_aware(datetime.datetime(2021, 3, 1, 8, 0)),
end_time=datetime.time(18, 00),
duration=120,
places=1,
agenda=agenda,
@ -588,3 +592,270 @@ def test_api_events_fillslots_partial_bookings(app, user):
resp.json['errors']['non_field_errors'][0]
== 'must include start_time and end_time for partial bookings agenda'
)
# start before opening time
params['user_external_id'] = 'user_id_2'
params['start_time'] = '07:59'
params['end_time'] = '18:00'
resp = app.post_json(fillslots_url, params=params)
assert resp.json['err_desc'] == 'booking start must be after opening time'
# end after closing time
params['start_time'] = '08:00'
params['end_time'] = '18:01'
resp = app.post_json(fillslots_url, params=params)
assert resp.json['err_desc'] == 'booking end must be before closing time'
def test_lock_code(app, user, freezer):
agenda = build_event_agenda(
events={
'Event 1': {
'start_datetime': now() + datetime.timedelta(days=1),
'places': 1,
}
}
)
# list events
resp = app.get(agenda.get_datetimes_url())
slot = resp.json['data'][0]
assert slot['places']['available'] == 1
assert slot['places']['full'] is False
# book first one
fillslot_url = slot['api']['fillslot_url']
app.authorization = ('Basic', ('john.doe', 'password'))
resp = app.post_json(fillslot_url, params={'lock_code': 'MYLOCK'})
assert resp.json['err'] == 0
# list events without lock code
resp = app.get(agenda.get_datetimes_url())
slot = resp.json['data'][0]
assert slot['places']['available'] == 0
assert slot['places']['full'] is True
# list events with lock code
resp = app.get(agenda.get_datetimes_url(), params={'lock_code': 'MYLOCK', 'hide_disabled': 'true'})
slot = resp.json['data'][0]
assert slot['places']['available'] == 1
assert slot['places']['full'] is False
# re-book first one without lock code
app.authorization = ('Basic', ('john.doe', 'password'))
resp = app.post_json(fillslot_url)
assert resp.json['err'] == 1
# rebook first one with lock code
app.authorization = ('Basic', ('john.doe', 'password'))
resp = app.post_json(fillslot_url, params={'lock_code': 'MYLOCK'})
assert resp.json['err'] == 0
# confirm booking
app.authorization = ('Basic', ('john.doe', 'password'))
resp = app.post_json(fillslot_url, params={'lock_code': 'MYLOCK', 'confirm_after_lock': True})
assert resp.json['err'] == 0
# list events without lock code, after 30 minutes slot is still booked
freezer.move_to(datetime.timedelta(minutes=30))
resp = app.get(agenda.get_datetimes_url())
slot = resp.json['data'][0]
assert slot['places']['available'] == 0
assert slot['places']['full'] is True
def test_lock_code_expiration(app, user, freezer):
agenda = build_event_agenda(
events={
'Event 1': {
'start_datetime': now() + datetime.timedelta(days=1),
'places': 1,
}
}
)
# list events
resp = app.get(agenda.get_datetimes_url())
slot = resp.json['data'][0]
# book first one
fillslot_url = slot['api']['fillslot_url']
app.authorization = ('Basic', ('john.doe', 'password'))
resp = app.post_json(fillslot_url, params={'lock_code': 'MYLOCK'})
assert resp.json['err'] == 0
# list events without lock code
resp = app.get(agenda.get_datetimes_url())
slot = resp.json['data'][0]
assert slot['places']['available'] == 0
assert slot['places']['full'] is True
# list events without lock code, after 30 minutes slot is available
freezer.move_to(datetime.timedelta(minutes=30))
call_command('clean_leases')
resp = app.get(agenda.get_datetimes_url())
slot = resp.json['data'][0]
assert slot['places']['available'] == 1
assert slot['places']['full'] is False
def test_api_events_fillslots_with_lock_code(app, user, freezer):
freezer.move_to('2021-09-06 12:00')
app.authorization = ('Basic', ('john.doe', 'password'))
agenda = build_event_agenda(
events={
'Event': {
'start_datetime': now() + datetime.timedelta(days=1),
'places': 2,
'waiting_list_places': 1,
},
'Event 2': {
'start_datetime': now() + datetime.timedelta(days=1),
'places': 2,
'waiting_list_places': 1,
},
}
)
fillslots_url = '/api/agenda/%s/events/fillslots/' % agenda.slug
params = {
'user_external_id': 'user_id',
'check_overlaps': True,
'slots': 'event,event-2',
'lock_code': 'MYLOCK',
}
with CaptureQueriesContext(connection) as ctx:
resp = app.post_json(fillslots_url, params=params)
assert len(ctx.captured_queries) == 14
assert resp.json['booking_count'] == 2
assert len(resp.json['booked_events']) == 2
assert resp.json['booked_events'][0]['id'] == 'event'
assert (
resp.json['booked_events'][0]['booking']['id']
== Booking.objects.filter(event=agenda._event).latest('pk').pk
)
assert resp.json['booked_events'][1]['id'] == 'event-2'
assert (
resp.json['booked_events'][1]['booking']['id']
== Booking.objects.filter(event=agenda._event_2).latest('pk').pk
)
assert len(resp.json['waiting_list_events']) == 0
assert resp.json['cancelled_booking_count'] == 0
assert len(resp.json['cancelled_events']) == 0
events = Event.objects.all()
assert events.filter(booked_places=1).count() == 2
assert Booking.objects.count() == 2
assert Lease.objects.count() == 2
response = app.get(agenda.get_datetimes_url())
assert response.json['data'][0]['places']['available'] == 1
assert response.json['data'][0]['places']['reserved'] == 1
assert response.json['data'][1]['places']['available'] == 1
assert response.json['data'][1]['places']['reserved'] == 1
response = app.get(agenda.get_datetimes_url() + '?lock_code=MYLOCK')
assert response.json['data'][0]['places']['available'] == 2
assert response.json['data'][0]['places']['reserved'] == 0
assert response.json['data'][1]['places']['available'] == 2
assert response.json['data'][1]['places']['reserved'] == 0
# rebooking, nothing change
resp = app.post_json(fillslots_url, params=params)
assert Booking.objects.count() == 2
assert Lease.objects.count() == 2
response = app.get(agenda.get_datetimes_url())
assert response.json['data'][0]['places']['available'] == 1
assert response.json['data'][0]['places']['reserved'] == 1
assert response.json['data'][1]['places']['available'] == 1
assert response.json['data'][1]['places']['reserved'] == 1
response = app.get(agenda.get_datetimes_url() + '?lock_code=MYLOCK')
assert response.json['data'][0]['places']['available'] == 2
assert response.json['data'][0]['places']['reserved'] == 0
assert response.json['data'][1]['places']['available'] == 2
assert response.json['data'][1]['places']['reserved'] == 0
params['confirm_after_lock'] = 'true'
resp = app.post_json(fillslots_url, params=params)
assert Booking.objects.count() == 2
assert Lease.objects.count() == 0
response = app.get(agenda.get_datetimes_url())
assert response.json['data'][0]['places']['available'] == 1
assert response.json['data'][0]['places']['reserved'] == 1
assert response.json['data'][1]['places']['available'] == 1
assert response.json['data'][1]['places']['reserved'] == 1
response = app.get(agenda.get_datetimes_url() + '?lock_code=MYLOCK')
assert response.json['data'][0]['places']['available'] == 1
assert response.json['data'][0]['places']['reserved'] == 1
assert response.json['data'][1]['places']['available'] == 1
assert response.json['data'][1]['places']['reserved'] == 1
def test_api_events_fillslots_with_lock_code_expiration(app, user, freezer):
freezer.move_to('2021-09-06 12:00')
app.authorization = ('Basic', ('john.doe', 'password'))
agenda = build_event_agenda(
events={
'Event': {
'start_datetime': now() + datetime.timedelta(days=1),
'places': 2,
'waiting_list_places': 1,
},
'Event 2': {
'start_datetime': now() + datetime.timedelta(days=1),
'places': 2,
'waiting_list_places': 1,
},
}
)
fillslots_url = '/api/agenda/%s/events/fillslots/' % agenda.slug
params = {
'user_external_id': 'user_id',
'check_overlaps': True,
'slots': 'event,event-2',
'lock_code': 'MYLOCK',
}
with CaptureQueriesContext(connection) as ctx:
resp = app.post_json(fillslots_url, params=params)
assert len(ctx.captured_queries) == 14
assert resp.json['booking_count'] == 2
assert len(resp.json['booked_events']) == 2
assert resp.json['booked_events'][0]['id'] == 'event'
assert (
resp.json['booked_events'][0]['booking']['id']
== Booking.objects.filter(event=agenda._event).latest('pk').pk
)
assert resp.json['booked_events'][1]['id'] == 'event-2'
assert (
resp.json['booked_events'][1]['booking']['id']
== Booking.objects.filter(event=agenda._event_2).latest('pk').pk
)
assert len(resp.json['waiting_list_events']) == 0
assert resp.json['cancelled_booking_count'] == 0
assert len(resp.json['cancelled_events']) == 0
events = Event.objects.all()
assert events.filter(booked_places=1).count() == 2
assert Booking.objects.count() == 2
assert Lease.objects.count() == 2
response = app.get(agenda.get_datetimes_url())
assert response.json['data'][0]['places']['available'] == 1
assert response.json['data'][0]['places']['reserved'] == 1
assert response.json['data'][1]['places']['available'] == 1
assert response.json['data'][1]['places']['reserved'] == 1
freezer.move_to('2021-09-06 13:00')
call_command('clean_leases')
response = app.get(agenda.get_datetimes_url())
assert response.json['data'][0]['places']['available'] == 2
assert response.json['data'][0]['places']['reserved'] == 0
assert response.json['data'][1]['places']['available'] == 2
assert response.json['data'][1]['places']['reserved'] == 0

View File

@ -1,4 +1,5 @@
import datetime
import uuid
import pytest
from django.db import connection
@ -163,7 +164,7 @@ def test_api_events_fillslots_multiple_agendas(app, user):
params = {'user_external_id': 'user_id', 'check_overlaps': True, 'slots': event_slugs}
with CaptureQueriesContext(connection) as ctx:
resp = app.post_json('/api/agendas/events/fillslots/?agendas=%s' % agenda_slugs, params=params)
assert len(ctx.captured_queries) == 17
assert len(ctx.captured_queries) == 18
assert resp.json['booking_count'] == 2
assert len(resp.json['booked_events']) == 2
assert resp.json['booked_events'][0]['id'] == 'first-agenda@event'
@ -179,6 +180,14 @@ def test_api_events_fillslots_multiple_agendas(app, user):
assert first_event.booking_set.filter(cancellation_datetime__isnull=True).count() == 1
assert second_event.booking_set.filter(cancellation_datetime__isnull=True).count() == 1
assert resp.json['bookings_ics_url'] == 'http://testserver/api/bookings/ics/?user_external_id=user_id'
request_uuid = first_event.booking_set.get().request_uuid
assert request_uuid is not None
assert second_event.booking_set.get().request_uuid == request_uuid
assert first_event.booking_set.get().previous_state == 'unbooked'
assert second_event.booking_set.get().previous_state == 'unbooked'
assert (
resp.json['revert_url'] == 'http://testserver/api/agendas/events/fillslots/%s/revert/' % request_uuid
)
# booking modification
params = {'user_external_id': 'user_id', 'slots': 'first-agenda@event'}
@ -188,6 +197,12 @@ def test_api_events_fillslots_multiple_agendas(app, user):
assert resp.json['cancelled_booking_count'] == 1
assert first_event.booking_set.filter(cancellation_datetime__isnull=True).count() == 1
assert second_event.booking_set.filter(cancellation_datetime__isnull=True).count() == 0
request_uuid = second_event.booking_set.get().request_uuid
assert request_uuid is not None
assert second_event.booking_set.get().previous_state == 'booked'
assert (
resp.json['revert_url'] == 'http://testserver/api/agendas/events/fillslots/%s/revert/' % request_uuid
)
params = {'user_external_id': 'user_id_2', 'slots': event_slugs}
resp = app.post_json('/api/agendas/events/fillslots/?agendas=%s' % agenda_slugs, params=params)
@ -331,6 +346,16 @@ def test_api_events_fillslots_multiple_agendas_with_cancelled(app, user):
)
assert Booking.objects.filter(user_external_id='user_id').count() == 3
assert Booking.objects.filter(user_external_id='user_id', cancellation_datetime__isnull=True).count() == 3
new_booking_1 = Booking.objects.get(event__agenda=agenda_1, event=event_1, user_external_id='user_id')
request_uuid = new_booking_1.request_uuid
assert request_uuid is not None
booking_3 = Booking.objects.get(event__agenda=agenda_2, event=event_3, user_external_id='user_id')
assert booking_3.request_uuid == request_uuid
assert new_booking_1.previous_state == 'cancelled'
assert booking_3.previous_state == 'unbooked'
assert (
resp.json['revert_url'] == 'http://testserver/api/agendas/events/fillslots/%s/revert/' % request_uuid
)
assert Booking.objects.filter(pk=booking_1.pk).exists() is False # cancelled booking deleted
booking_2.refresh_from_db()
@ -803,7 +828,8 @@ def test_api_events_fillslots_multiple_agendas_partial_bookings(app, user):
agenda = Agenda.objects.create(label='Foo bar', kind='events', partial_bookings=True)
Event.objects.create(
label='Event',
start_datetime=now() + datetime.timedelta(days=5),
start_datetime=make_aware(datetime.datetime(2021, 3, 1, 8, 0)),
end_time=datetime.time(18, 00),
duration=120,
places=1,
agenda=agenda,
@ -829,3 +855,324 @@ def test_api_events_fillslots_multiple_agendas_partial_bookings(app, user):
resp.json['errors']['non_field_errors'][0]
== 'must include start_time and end_time for partial bookings agenda'
)
# start before opening time
params['user_external_id'] = 'user_id_2'
params['start_time'] = '07:59'
params['end_time'] = '18:00'
resp = app.post_json(fillslots_url, params=params)
assert resp.json['err_desc'] == 'booking start must be after opening time'
# end after closing time
params['start_time'] = '08:00'
params['end_time'] = '18:01'
resp = app.post_json(fillslots_url, params=params)
assert resp.json['err_desc'] == 'booking end must be before closing time'
@pytest.mark.freeze_time('2021-02-23 14:00')
def test_api_events_fillslots_multiple_agendas_revert(app, user):
agenda = Agenda.objects.create(label='Foo bar', kind='events')
event = Event.objects.create(
label='Event',
start_datetime=now() + datetime.timedelta(days=5),
duration=120,
places=1,
agenda=agenda,
)
app.authorization = ('Basic', ('john.doe', 'password'))
request_uuid = uuid.uuid4()
# no corresponding booking
revert_url = '/api/agendas/events/fillslots/%s/revert/' % request_uuid
resp = app.post(revert_url)
assert resp.json == {
'err': 0,
'cancelled_booking_count': 0,
'cancelled_events': [],
'deleted_booking_count': 0,
'deleted_events': [],
'booked_booking_count': 0,
'booked_events': [],
}
booking1 = Booking.objects.create(event=event, request_uuid=uuid.uuid4())
resp = app.post(revert_url)
assert resp.json == {
'err': 0,
'cancelled_booking_count': 0,
'cancelled_events': [],
'deleted_booking_count': 0,
'deleted_events': [],
'booked_booking_count': 0,
'booked_events': [],
}
booking1.refresh_from_db()
assert booking1.cancellation_datetime is None
booking2 = Booking.objects.create(event=event, request_uuid=request_uuid)
resp = app.post(revert_url)
assert resp.json == {
'err': 0,
'cancelled_booking_count': 0,
'cancelled_events': [],
'deleted_booking_count': 0,
'deleted_events': [],
'booked_booking_count': 0,
'booked_events': [],
}
booking1.refresh_from_db()
assert booking1.cancellation_datetime is None
booking2.refresh_from_db()
assert booking2.cancellation_datetime is None
# booking was previously cancelled
booking = Booking.objects.create(event=event, request_uuid=request_uuid, previous_state='cancelled')
resp = app.post(revert_url)
assert resp.json == {
'err': 0,
'cancelled_booking_count': 1,
'cancelled_events': [
{
'agenda_label': 'Foo bar',
'check_locked': False,
'checked': False,
'date': '2021-02-28',
'datetime': '2021-02-28 15:00:00',
'description': None,
'duration': 120,
'end_datetime': '2021-02-28 17:00:00',
'id': 'foo-bar@event',
'invoiced': False,
'label': 'Event',
'pricing': None,
'slug': 'event',
'text': 'Event',
'url': None,
}
],
'deleted_booking_count': 0,
'deleted_events': [],
'booked_booking_count': 0,
'booked_events': [],
}
booking1.refresh_from_db()
assert booking1.cancellation_datetime is None
booking2.refresh_from_db()
assert booking2.cancellation_datetime is None
booking.refresh_from_db()
assert booking.cancellation_datetime is not None
# again, but with a cancelled booking
resp = app.post(revert_url)
assert resp.json == {
'err': 0,
'cancelled_booking_count': 1,
'cancelled_events': [
{
'agenda_label': 'Foo bar',
'check_locked': False,
'checked': False,
'date': '2021-02-28',
'datetime': '2021-02-28 15:00:00',
'description': None,
'duration': 120,
'end_datetime': '2021-02-28 17:00:00',
'id': 'foo-bar@event',
'invoiced': False,
'label': 'Event',
'pricing': None,
'slug': 'event',
'text': 'Event',
'url': None,
}
],
'deleted_booking_count': 0,
'deleted_events': [],
'booked_booking_count': 0,
'booked_events': [],
}
booking1.refresh_from_db()
assert booking1.cancellation_datetime is None
booking2.refresh_from_db()
assert booking2.cancellation_datetime is None
booking.refresh_from_db()
assert booking.cancellation_datetime is not None
# booking was previously not cancelled
booking.previous_state = 'booked'
booking.save()
resp = app.post(revert_url)
assert resp.json == {
'err': 0,
'cancelled_booking_count': 0,
'cancelled_events': [],
'deleted_booking_count': 0,
'deleted_events': [],
'booked_booking_count': 1,
'booked_events': [
{
'agenda_label': 'Foo bar',
'check_locked': False,
'checked': False,
'date': '2021-02-28',
'datetime': '2021-02-28 15:00:00',
'description': None,
'duration': 120,
'end_datetime': '2021-02-28 17:00:00',
'id': 'foo-bar@event',
'invoiced': False,
'label': 'Event',
'pricing': None,
'slug': 'event',
'text': 'Event',
'url': None,
}
],
}
booking1.refresh_from_db()
assert booking1.cancellation_datetime is None
booking2.refresh_from_db()
assert booking2.cancellation_datetime is None
booking.refresh_from_db()
assert booking.cancellation_datetime is None
# again, but with a not cancelled booking
resp = app.post(revert_url)
assert resp.json == {
'err': 0,
'cancelled_booking_count': 0,
'cancelled_events': [],
'deleted_booking_count': 0,
'deleted_events': [],
'booked_booking_count': 1,
'booked_events': [
{
'agenda_label': 'Foo bar',
'check_locked': False,
'checked': False,
'date': '2021-02-28',
'datetime': '2021-02-28 15:00:00',
'description': None,
'duration': 120,
'end_datetime': '2021-02-28 17:00:00',
'id': 'foo-bar@event',
'invoiced': False,
'label': 'Event',
'pricing': None,
'slug': 'event',
'text': 'Event',
'url': None,
}
],
}
booking1.refresh_from_db()
assert booking1.cancellation_datetime is None
booking2.refresh_from_db()
assert booking2.cancellation_datetime is None
booking.refresh_from_db()
assert booking.cancellation_datetime is None
# booking was previously unbooked
booking.previous_state = 'unbooked'
booking.save()
resp = app.post(revert_url)
assert resp.json == {
'err': 0,
'cancelled_booking_count': 0,
'cancelled_events': [],
'deleted_booking_count': 1,
'deleted_events': [
{
'agenda_label': 'Foo bar',
'check_locked': False,
'checked': False,
'date': '2021-02-28',
'datetime': '2021-02-28 15:00:00',
'description': None,
'duration': 120,
'end_datetime': '2021-02-28 17:00:00',
'id': 'foo-bar@event',
'invoiced': False,
'label': 'Event',
'pricing': None,
'slug': 'event',
'text': 'Event',
'url': None,
}
],
'booked_booking_count': 0,
'booked_events': [],
}
booking1.refresh_from_db()
assert booking1.cancellation_datetime is None
booking2.refresh_from_db()
assert booking2.cancellation_datetime is None
assert Booking.objects.filter(pk=booking.pk).exists() is False
# again, but with a cancelled booking
booking = Booking.objects.create(
event=event, request_uuid=request_uuid, previous_state='unbooked', cancellation_datetime=now()
)
resp = app.post(revert_url)
assert resp.json == {
'err': 0,
'cancelled_booking_count': 0,
'cancelled_events': [],
'deleted_booking_count': 1,
'deleted_events': [
{
'agenda_label': 'Foo bar',
'check_locked': False,
'checked': False,
'date': '2021-02-28',
'datetime': '2021-02-28 15:00:00',
'description': None,
'duration': 120,
'end_datetime': '2021-02-28 17:00:00',
'id': 'foo-bar@event',
'invoiced': False,
'label': 'Event',
'pricing': None,
'slug': 'event',
'text': 'Event',
'url': None,
}
],
'booked_booking_count': 0,
'booked_events': [],
}
booking1.refresh_from_db()
assert booking1.cancellation_datetime is None
booking2.refresh_from_db()
assert booking2.cancellation_datetime is None
assert Booking.objects.filter(pk=booking.pk).exists() is False
# check num queries
Booking.objects.create(
event=event, request_uuid=request_uuid, previous_state='cancelled', cancellation_datetime=now()
)
event = Event.objects.create(
label='Event',
start_datetime=now() + datetime.timedelta(days=5),
duration=120,
places=1,
agenda=agenda,
)
Booking.objects.create(
event=event, request_uuid=request_uuid, previous_state='booked', cancellation_datetime=now()
)
event = Event.objects.create(
label='Event',
start_datetime=now() + datetime.timedelta(days=5),
duration=120,
places=1,
agenda=agenda,
)
Booking.objects.create(
event=event, request_uuid=request_uuid, previous_state='unbooked', cancellation_datetime=now()
)
with CaptureQueriesContext(connection) as ctx:
resp = app.post(revert_url)
assert len(ctx.captured_queries) == 14

View File

@ -0,0 +1,274 @@
# 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 datetime
import pytest
from django.core.management import call_command
from chrono.agendas.models import Booking, Lease
from tests.utils import build_meetings_agenda, build_virtual_agenda
pytestmark = pytest.mark.django_db
def test_meetings_agenda(app, user):
'''Test fillslot on meetings agenda with lock_code'''
agenda = build_meetings_agenda(
'Agenda',
resources=['Re1'],
meeting_types=(30,),
desks={'desk-1': ['monday-friday 9:00-12:00 14:00-17:00']},
)
datetimes_url = agenda._mt_30.get_datetimes_url()
# list free slots, with or without a lock
resp = app.get(datetimes_url + '?lock_code=MYLOCK')
free_slots = len(resp.json['data'])
resp = app.get(datetimes_url + '?lock_code=OTHERLOCK')
assert free_slots == len(resp.json['data'])
resp = app.get(datetimes_url)
assert free_slots == len(resp.json['data'])
# lock a slot
fillslot_url = resp.json['data'][2]['api']['fillslot_url']
app.authorization = ('Basic', ('john.doe', 'password'))
app.post_json(fillslot_url, params={'lock_code': 'MYLOCK'})
assert Booking.objects.count() == 1
assert Lease.objects.get().lock_code == 'MYLOCK'
# list free slots: one is locked ...
resp = app.get(datetimes_url)
assert len([x for x in resp.json['data'] if x.get('disabled')]) == 1
resp = app.get(datetimes_url, params={'lock_code': 'OTHERLOCK'})
assert len([x for x in resp.json['data'] if x.get('disabled')]) == 1
# ... unless it's MYLOCK
resp = app.get(datetimes_url, params={'lock_code': 'MYLOCK'})
assert len([x for x in resp.json['data'] if x.get('disabled')]) == 0
# can't lock the same timeslot ...
resp_booking = app.post_json(fillslot_url, params={'lock_code': 'OTHERLOCK'})
assert resp_booking.json['err'] == 1
assert resp_booking.json['reason'] == 'no more desk available'
# ... unless with MYLOCK (aka "relock")
resp_booking = app.post_json(fillslot_url, params={'lock_code': 'MYLOCK'})
assert resp_booking.json['err'] == 0
assert Booking.objects.count() == 1
assert Lease.objects.get().lock_code == 'MYLOCK'
# can't book the slot ...
resp_booking = app.post_json(fillslot_url)
assert resp_booking.json['err'] == 1
assert resp_booking.json['reason'] == 'no more desk available'
resp_booking = app.post_json(fillslot_url, params={'confirm_after_lock': True})
assert resp_booking.json['err'] == 1
assert resp_booking.json['reason'] == 'no more desk available'
resp_booking = app.post_json(fillslot_url, params={'lock_code': 'OTHERLOCK', 'confirm_after_lock': True})
assert resp_booking.json['err'] == 1
assert resp_booking.json['reason'] == 'no more desk available'
# ... unless with MYLOCK (aka "confirm")
resp_booking = app.post_json(fillslot_url, params={'lock_code': 'MYLOCK', 'confirm_after_lock': True})
assert resp_booking.json['err'] == 0
assert Booking.objects.count() == 1
assert Lease.objects.count() == 0
def test_meetings_agenda_expiration(app, user, freezer):
'''Test fillslot on meetings agenda with lock_code'''
agenda = build_meetings_agenda(
'Agenda',
resources=['Re1'],
meeting_types=(30,),
desks={'desk-1': ['monday-friday 9:00-12:00 14:00-17:00']},
)
datetimes_url = agenda._mt_30.get_datetimes_url()
# list free slots
resp = app.get(datetimes_url)
# lock a slot
fillslot_url = resp.json['data'][2]['api']['fillslot_url']
app.authorization = ('Basic', ('john.doe', 'password'))
app.post_json(fillslot_url, params={'lock_code': 'MYLOCK'})
assert Booking.objects.count() == 1
assert Lease.objects.get().lock_code == 'MYLOCK'
# list free slots: one is locked ...
resp = app.get(datetimes_url)
assert len([x for x in resp.json['data'] if x.get('disabled')]) == 1
# after 30 minutes it is not locked anymore
freezer.move_to(datetime.timedelta(minutes=30))
call_command('clean_leases')
resp = app.get(datetimes_url)
assert len([x for x in resp.json['data'] if x.get('disabled')]) == 0
def test_meetings_agenda_with_resource_exclusion(app, user):
'''Test fillslot on meetings agenda with lock_code and ressources'''
agenda1 = build_meetings_agenda(
'Agenda 1',
resources=['Re1'],
meeting_types=(30,),
desks={'desk-1': ['monday-friday 9:00-12:00 14:00-17:00']},
)
agenda2 = build_meetings_agenda(
'Agenda 2',
resources=['Re1'],
meeting_types=(30,),
desks={'desk-1': ['monday-friday 9:00-12:00 14:00-17:00']},
)
resource = agenda2._re_re1
agenda1_datetimes_url = agenda1._mt_30.get_datetimes_url()
agenda2_datetimes_url = agenda2._mt_30.get_datetimes_url()
# list free slots, with or without a lock
resp = app.get(agenda1_datetimes_url, params={'lock_code': 'OTHERLOCK', 'resources': resource.slug})
free_slots = len(resp.json['data'])
resp = app.get(agenda1_datetimes_url, params={'lock_code': 'MYLOCK', 'resources': resource.slug})
assert free_slots == len(resp.json['data'])
resp = app.get(agenda1_datetimes_url, params={'resources': resource.slug})
assert free_slots == len(resp.json['data'])
resp = app.get(agenda1_datetimes_url)
assert free_slots == len(resp.json['data'])
# lock a slot
fillslot_url = resp.json['data'][2]['api']['fillslot_url']
app.authorization = ('Basic', ('john.doe', 'password'))
app.post_json(fillslot_url + '?resources=re1', params={'lock_code': 'MYLOCK'})
assert Booking.objects.count() == 1
assert Lease.objects.get().lock_code == 'MYLOCK'
# list free slots: one is locked ...
resp = app.get(agenda1_datetimes_url, params={'resources': resource.slug})
assert free_slots == len(resp.json['data'])
assert len([x for x in resp.json['data'] if x.get('disabled')]) == 1
resp = app.get(agenda1_datetimes_url, params={'lock_code': 'OTHERLOCK', 'resources': resource.slug})
assert len([x for x in resp.json['data'] if x.get('disabled')]) == 1
# ... unless it's MYLOCK
resp = app.get(agenda1_datetimes_url, params={'lock_code': 'MYLOCK', 'resources': resource.slug})
assert free_slots == len(resp.json['data'])
assert len([x for x in resp.json['data'] if x.get('disabled')]) == 0
# check slot is also disabled on another agenda with same resource
resp = app.get(agenda2_datetimes_url)
assert len([x for x in resp.json['data'] if x.get('disabled')]) == 0
resp = app.get(agenda2_datetimes_url, params={'resources': resource.slug})
assert len([x for x in resp.json['data'] if x.get('disabled')]) == 1
# can't lock the same timeslot ...
resp_booking = app.post_json(fillslot_url + '?resources=re1', params={'lock_code': 'OTHERLOCK'})
assert resp_booking.json['err'] == 1
assert resp_booking.json['reason'] == 'no more desk available'
# ... unless with MYLOCK (aka "relock")
resp_booking = app.post_json(fillslot_url + '?resources=re1', params={'lock_code': 'MYLOCK'})
assert resp_booking.json['err'] == 0
assert Booking.objects.count() == 1
assert Lease.objects.get().lock_code == 'MYLOCK'
# can't book the slot ...
resp_booking = app.post_json(fillslot_url + '?resources=re1')
assert resp_booking.json['err'] == 1
assert resp_booking.json['reason'] == 'no more desk available'
resp_booking = app.post_json(fillslot_url + '?resources=re1', params={'confirm_after_lock': True})
assert resp_booking.json['err'] == 1
assert resp_booking.json['reason'] == 'no more desk available'
resp_booking = app.post_json(
fillslot_url + '?resources=re1', params={'lock_code': 'OTHERLOCK', 'confirm_after_lock': True}
)
assert resp_booking.json['err'] == 1
assert resp_booking.json['reason'] == 'no more desk available'
# unless with MYLOCK (aka "confirm")
resp_booking = app.post_json(
fillslot_url + '?resources=re1', params={'lock_code': 'MYLOCK', 'confirm_after_lock': True}
)
assert resp_booking.json['err'] == 0
assert Booking.objects.count() == 1
assert Lease.objects.count() == 0
def test_virtual_agenda_with_external_user_id_exclusion(app, user):
'''Test lock_code use when excluding an external_user_id'''
agenda = build_virtual_agenda(
meeting_types=(30,),
agendas={
'Agenda 1': {
'desks': {
'desk': 'monday-friday 08:00-12:00 14:00-17:00',
},
},
'Agenda 2': {
'desks': {
'desk': 'monday-friday 09:00-12:00',
},
},
'Agenda 3': {
'desks': {
'desk': 'monday-friday 15:00-17:00',
},
},
},
)
datetimes_url = agenda._mt_30.get_datetimes_url()
resp = app.get(datetimes_url)
slots = resp.json['data']
# get first slot between 11 and 11:30
slot = [slot for slot in slots if ' 11:00:00' in slot['datetime']][0]
fillslot_url = slot['api']['fillslot_url']
app.authorization = ('Basic', ('john.doe', 'password'))
resp = app.post_json(fillslot_url, params={'lock_code': 'MYLOCK', 'user_external_id': 'abcd'})
assert resp.json['err'] == 0
# check the lease was created
assert Booking.objects.filter(user_external_id='abcd', lease__lock_code='MYLOCK').count() == 1
# check 11:00 slot is still available
resp = app.get(datetimes_url)
slots = resp.json['data']
assert any(
s['datetime'] == slot['datetime'] for s in slots if not s['disabled']
), f"slot {slot['datetime']} should be available"
# check 11:00 slot is unavailable when tested with user_external_id
resp = app.get(datetimes_url, params={'user_external_id': 'abcd'})
slots = resp.json['data']
assert not any(
s['datetime'] == slot['datetime'] for s in slots if not s['disabled']
), f"slot {slot['datetime']} should not be available"
# check 11:00 slot is available if tested with user_external_id *AND* lock_code
resp = app.get(datetimes_url, params={'lock_code': 'MYLOCK', 'user_external_id': 'abcd'})
slots = resp.json['data']
assert any(
s['datetime'] == slot['datetime'] for s in slots if not s['disabled']
), f"slot {slot['datetime']} should be available"

View File

@ -67,6 +67,7 @@ def test_recurring_events_api_fillslots(app, user, freezer, action):
assert 'booked_events' not in resp.json
assert Booking.objects.count() == 3
assert Booking.objects.filter(from_recurring_fillslots=True).count() == 3
assert Booking.objects.filter(event__primary_event=event).count() == 2
assert Booking.objects.filter(event__primary_event=sunday_event).count() == 1
@ -1790,6 +1791,18 @@ def test_recurring_events_api_fillslots_partial_bookings(app, user):
resp = app.post_json(fillslots_url, params=params, status=400)
assert resp.json['errors']['non_field_errors'][0] == 'start_time must be before end_time'
# start before opening time
params['start_time'] = '07:59'
params['end_time'] = '18:00'
resp = app.post_json(fillslots_url, params=params)
assert resp.json['err_desc'] == 'booking start must be after opening time'
# end after closing time
params['start_time'] = '08:00'
params['end_time'] = '18:01'
resp = app.post_json(fillslots_url, params=params)
assert resp.json['err_desc'] == 'booking end must be before closing time'
@pytest.mark.freeze_time('2023-05-01 10:00')
def test_recurring_events_api_fillslots_partial_bookings_update(app, user):
@ -1859,3 +1872,121 @@ def test_recurring_events_api_fillslots_partial_bookings_update(app, user):
).count()
== 5
)
@pytest.mark.freeze_time('2023-05-01 10:00')
def test_recurring_events_api_fillslots_by_days_partial_bookings(app, user):
agenda = Agenda.objects.create(label='Foo bar', kind='events', partial_bookings=True)
Desk.objects.create(agenda=agenda, slug='_exceptions_holder')
start_datetime = make_aware(datetime.datetime(2023, 5, 2, 8, 0))
event = Event.objects.create(
label='Event Mon-Wed',
start_datetime=start_datetime,
end_time=datetime.time(18, 00),
places=2,
recurrence_end_date=start_datetime + datetime.timedelta(days=30),
recurrence_days=[1, 2, 3],
agenda=agenda,
)
event.create_all_recurrences()
event = Event.objects.create(
label='Event Thu-Sat',
start_datetime=start_datetime,
end_time=datetime.time(18, 00),
places=2,
recurrence_end_date=start_datetime + datetime.timedelta(days=30),
recurrence_days=[4, 5, 6],
agenda=agenda,
)
event.create_all_recurrences()
app.authorization = ('Basic', ('john.doe', 'password'))
params = {
'user_external_id': 'user_id',
'tuesday': '09:00,12:00',
'friday': '08:00,18:00',
}
fillslots_url = '/api/agendas/recurring-events/fillslots-by-day/?action=update&agendas=%s' % agenda.slug
resp = app.post_json(fillslots_url, params=params)
assert resp.json['booking_count'] == 9
assert Booking.objects.count() == 9
assert Booking.objects.filter(from_recurring_fillslots=True).count() == 9
assert (
Booking.objects.filter(
event__slug__startswith='event-mon-wed--2023-05-',
start_time=datetime.time(9, 00),
end_time=datetime.time(12, 00),
event__start_datetime__iso_week_day=2,
).count()
== 5
)
assert (
Booking.objects.filter(
event__slug__startswith='event-thu-sat--2023-05-',
start_time=datetime.time(8, 00),
end_time=datetime.time(18, 00),
event__start_datetime__iso_week_day=5,
).count()
== 4
)
# change bookings
params = {
'user_external_id': 'user_id',
'wednesday': '10:00,14:00',
'thursday': None, # null values are allowed and ignored
'sunday': '12:00,16:00', # unbookable day will be ignored
'slots': 'xxx', # parameter of normal API, ignored
'start_time': None, # parameter of normal API, ignored
}
resp = app.post_json(fillslots_url, params=params)
assert resp.json['booking_count'] == 5
assert resp.json['cancelled_booking_count'] == 9
assert Booking.objects.count() == 5
assert (
Booking.objects.filter(
event__slug__startswith='event-mon-wed--2023-05-',
start_time=datetime.time(10, 00),
end_time=datetime.time(14, 00),
event__start_datetime__iso_week_day=3,
).count()
== 5
)
agenda2 = Agenda.objects.create(label='Foo Bar 2', kind='events', partial_bookings=True)
resp = app.post_json(
'/api/agendas/recurring-events/fillslots-by-day/?action=update&agendas=%s,%s'
% (agenda.slug, agenda2.slug),
params=params,
status=400,
)
assert resp.json['err'] == 1
assert resp.json['errors']['non_field_errors'][0] == 'Multiple agendas are not supported.'
agenda_events = Agenda.objects.create(label='Not partial bookings', kind='events')
resp = app.post_json(
'/api/agendas/recurring-events/fillslots-by-day/?action=update&agendas=%s' % agenda_events.slug,
params=params,
status=400,
)
assert resp.json['err'] == 1
assert resp.json['errors']['non_field_errors'][0] == 'Agenda kind must be partial bookings.'
params = {'user_external_id': 'user_id', 'wednesday': '11:00,10:00'}
resp = app.post_json(fillslots_url, params=params, status=400)
assert resp.json['err'] == 1
assert resp.json['errors']['wednesday'][0] == 'Start hour must be before end hour.'
params = {'user_external_id': 'user_id', 'wednesday': '11:00,xxx'}
resp = app.post_json(fillslots_url, params=params, status=400)
assert resp.json['err'] == 1
assert resp.json['errors']['wednesday']['1'][0].startswith('Time has wrong format')
params = {'user_external_id': 'user_id', 'wednesday': '11:00'}
resp = app.post_json(fillslots_url, params=params, status=400)
assert resp.json['errors']['wednesday'][0] == 'Ensure this field has at least 2 elements.'
params = {'user_external_id': 'user_id', 'wednesday': '11:00,13:00,15:00'}
resp = app.post_json(fillslots_url, params=params, status=400)
assert resp.json['errors']['wednesday'][0] == 'Ensure this field has no more than 2 elements.'

View File

@ -15,6 +15,7 @@ from chrono.agendas.models import (
Resource,
TimePeriodException,
)
from chrono.apps.snapshot.models import AgendaSnapshot
from chrono.utils.timezone import localtime, now
pytestmark = pytest.mark.django_db
@ -449,6 +450,7 @@ def test_agenda_api_delete(app, user):
resp = app.delete('/api/agenda/%s/' % agenda.slug)
assert resp.json['err'] == 0
assert not Agenda.objects.exists()
assert AgendaSnapshot.objects.count() == 1
def test_agenda_api_delete_busy(app, user):
@ -561,6 +563,8 @@ def test_add_agenda(app, user, settings):
assert len(resp.json['data']) == 1
agenda = Agenda.objects.get(slug='my-agenda')
assert agenda.kind == 'events'
assert agenda.partial_bookings is False
assert AgendaSnapshot.objects.count() == 1
settings.WORKING_DAY_CALENDAR = 'workalendar.europe.France'
edit_group = Group.objects.create(name='Edit')
@ -641,10 +645,37 @@ def test_add_agenda(app, user, settings):
assert 'data' in resp.json
def test_add_agenda_partial_bookings(app, user, settings):
app.authorization = ('Basic', ('john.doe', 'password'))
params = {
'label': 'My Agenda',
'slug': 'my-agenda',
'partial_bookings': True,
}
resp = app.post_json('/api/agenda/', params=params)
agenda = Agenda.objects.get(slug='my-agenda')
assert agenda.kind == 'events'
assert agenda.partial_bookings is True
# partial bookings meetings agenda is forbidden
params = {
'label': 'My Agenda 2',
'slug': 'my-agenda-2',
'partial_bookings': True,
'kind': 'meetings',
}
resp = app.post_json('/api/agenda/', params=params, status=400)
assert resp.json['errors'] == {'partial_bookings': ['Option not available on meetings agenda']}
@pytest.mark.freeze_time('2021-07-09T08:00:00.0+02:00')
def test_patch_agenda(app, user):
Category.objects.create(label='Category A')
agenda = Agenda.objects.create(label='Foo bar', kind='events')
Desk.objects.create(agenda=agenda, slug='_exceptions_holder')
app.authorization = ('Basic', ('john.doe', 'password'))
@ -653,6 +684,7 @@ def test_patch_agenda(app, user):
assert resp.json['data']['text'] == 'Foo bar'
assert resp.json['data']['kind'] == 'events'
assert resp.json['data']['category'] is None
assert AgendaSnapshot.objects.count() == 1
resp = app.patch_json('/api/agenda/%s/' % agenda.slug, params={'label': 'Test', 'kind': 'events'})
assert resp.json['data']['id'] == 'foo-bar'

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