Compare commits

...

110 Commits
v3.23 ... main

Author SHA1 Message Date
Frédéric Péters cfeb7c7ae8 debian: add python3-recurring-ical-events (#90457)
gitea/chrono/pipeline/head This commit looks good Details
2024-05-10 11:07:34 +02:00
Yann Weber 6c2c412cfc api: add 'max_places' argument to API (#89848)
gitea/chrono/pipeline/head This commit looks good Details
2024-04-30 14:50:47 +02:00
Yann Weber 1aca9c2a66 agendas: replace vobject by icalendar & recurring_ical_events (#88806)
gitea/chrono/pipeline/head This commit looks good Details
2024-04-30 14:40:56 +02:00
Frédéric Péters 1ef0ba26af journal: check request.user class when creating journal entry (#90162)
gitea/chrono/pipeline/head This commit looks good Details
2024-04-29 18:22:51 +02:00
Frédéric Péters 4c14a32d82 translation update
gitea/chrono/pipeline/head This commit looks good Details
2024-04-29 16:07:49 +02:00
Frédéric Péters 32d0a0c44b general: add journal app (#86632)
gitea/chrono/pipeline/head This commit looks good Details
2024-04-29 16:00:00 +02:00
Valentin Deniaud 733cdfc9a9 agendas: handle admin role in export file (#89990)
gitea/chrono/pipeline/head This commit looks good Details
2024-04-29 09:45:33 +02:00
Lauréline Guérin 77f3373820
agendas: fix custom fields export/import and display in inspect (#89485)
gitea/chrono/pipeline/head This commit looks good Details
2024-04-26 14:41:51 +02:00
Valentin Deniaud 169dc0a69a agendas: ignore exception source on import if file is missing (#89873)
gitea/chrono/pipeline/head This commit looks good Details
2024-04-23 13:42:32 +02:00
Lauréline Guérin 88d8feacd8
translation update
gitea/chrono/pipeline/head This commit looks good Details
2024-04-18 09:37:51 +02:00
Thomas NOËL 6ee8fbf78d agendas: use URLField for event url (#89447)
gitea/chrono/pipeline/head This commit looks good Details
2024-04-16 18:01:42 +02:00
Lauréline Guérin 3403295d3d snapshots: json diff, use gadjo to collapse lines between changes (#89484)
gitea/chrono/pipeline/head This commit looks good Details
2024-04-16 10:14:25 +02:00
Yann Weber 0563e0642d tests: fix event order in api fillslot tests (#89598)
gitea/chrono/pipeline/head This commit looks good Details
2024-04-15 16:24:51 +02:00
Yann Weber 5a90c4851b tests: add callback to clear timezone cache on settings update (#89097)
gitea/chrono/pipeline/head This commit looks good Details
2024-04-15 15:08:00 +02:00
Yann Weber d03e1e7940 tests: replace legacy Brazil/East timezone in fixture (#89097)
Replacing Brazil/East (legacy, not present in defaults zoneinfo anymore)
with America/Sao_Paulo
2024-04-15 15:08:00 +02:00
Yann Weber b0f956c223 tests: remove --dist loadfile option introducing a bug (#89097) 2024-04-15 15:08:00 +02:00
Yann Weber 570cf81c8e manager: make agenda's groups foldable (#85616)
gitea/chrono/pipeline/head This commit looks good Details
2024-04-15 14:44:09 +02:00
Benjamin Dauvergne 5fa96e62a8 agendas: fix counting of unlocked bookings with respect to waiting lists (#89266)
gitea/chrono/pipeline/head This commit looks good Details
2024-04-10 10:12:15 +02:00
Lauréline Guérin 7c91b91d89 export_import: post bundle (#89035)
gitea/chrono/pipeline/head This commit looks good Details
2024-04-04 15:49:05 +02:00
Lauréline Guérin a167a91cde export_import: replace APIError by APIErrorBadRequest (#88593) 2024-04-04 15:49:05 +02:00
Lauréline Guérin 56b794468f export_import: remove authent on redirect test (#88593) 2024-04-04 15:49:05 +02:00
Lauréline Guérin 184cf83dd7
translation update
gitea/chrono/pipeline/head This commit looks good Details
2024-04-04 15:41:00 +02:00
Yann Weber 4e6f41c4de tests: remove transactional_db fixture for migrations tests (#89040)
gitea/chrono/pipeline/head This commit looks good Details
2024-04-04 10:29:02 +02:00
Benjamin Dauvergne 42f73e2626 ants_hub: push Place.logo_url to ants-hub (#89020)
gitea/chrono/pipeline/head This commit looks good Details
2024-04-03 14:58:35 +02:00
Valentin Deniaud 3576928b2c agendas: import/export end time event field (#88615)
gitea/chrono/pipeline/head This commit looks good Details
2024-04-03 11:05:31 +02:00
Frédéric Péters ae55827939 api: add agenda slug to event details (#88764)
gitea/chrono/pipeline/head This commit looks good Details
2024-03-29 08:28:58 +01:00
Lauréline Guérin d733e91135 api: add primary_event in event details (#88559)
gitea/chrono/pipeline/head This commit looks good Details
2024-03-29 08:21:04 +01:00
Lauréline Guérin 4b8c3412e4
snapshot: do not delete snapshots on user deletion (#88623)
gitea/chrono/pipeline/head This commit looks good Details
2024-03-26 13:32:51 +01:00
Valentin Deniaud be975cfa29 ci: do not run tests in parallel by default (#88626)
gitea/chrono/pipeline/head This commit looks good Details
2024-03-25 14:13:08 +01:00
Lauréline Guérin f7e224ba9b
misc: fix failing test due to dst change (#88568)
gitea/chrono/pipeline/head This commit looks good Details
2024-03-23 10:30:24 +01:00
Thomas Jund 41cadbcfa9 manager: improve html & CSS of partial booking month view (#79863)
gitea/chrono/pipeline/head There was a failure building this commit Details
2024-03-22 09:40:49 +01:00
Lauréline Guérin a34d55879e
translation update
gitea/chrono/pipeline/head This commit looks good Details
2024-03-21 13:41:06 +01:00
Lauréline Guérin 43c42c507c
export_import: missing component in bundle (#88068)
gitea/chrono/pipeline/head This commit looks good Details
2024-03-21 13:35:50 +01:00
Lauréline Guérin 886afb206e
export_import: unknown component_type in urls (#88068) 2024-03-21 13:35:50 +01:00
Lauréline Guérin 2c30eec6ac
export_import: invalid bundle (#88068) 2024-03-21 13:35:49 +01:00
Lauréline Guérin 1896c33f29
manager: get snapshots to compare from application version (#87653)
gitea/chrono/pipeline/head This commit looks good Details
2024-03-21 11:49:48 +01:00
Lauréline Guérin 1f23f85b3d
export_import: redirect to compare view if compare in GET params (#87653) 2024-03-21 11:49:48 +01:00
Lauréline Guérin 393a20b87b
export_import: bundle-check endpoint (#87653) 2024-03-21 11:49:48 +01:00
Lauréline Guérin df0e356e75
export_import: snapshots on application import (#87653) 2024-03-21 10:10:48 +01:00
Frédéric Péters 07512150e8 api: limit export/import APIs to admin users (#88439)
gitea/chrono/pipeline/head This commit looks good Details
2024-03-21 09:50:18 +01:00
Lauréline Guérin 2576350aae
translation update
gitea/chrono/pipeline/head This commit looks good Details
2024-03-21 08:36:35 +01:00
Lauréline Guérin 2187bf3dde export_import: unknown component in urls (#88085)
gitea/chrono/pipeline/head This commit looks good Details
2024-03-21 08:31:19 +01:00
Lauréline Guérin 4add868dd9 agendas: fix import of incorrect ics file (#88090)
gitea/chrono/pipeline/head This commit looks good Details
2024-03-21 08:30:58 +01:00
Valentin Deniaud 9c19321fb9 tests: fix typo in partial bookings feature flag (#88098)
gitea/chrono/pipeline/head This commit looks good Details
2024-03-18 16:25:34 +01:00
Lauréline Guérin a88db00e04
misc: add pyquery in dependencies (#88222)
gitea/chrono/pipeline/head This commit looks good Details
2024-03-15 12:11:59 +01:00
Lauréline Guérin eecbb80809
translation update
gitea/chrono/pipeline/head This commit looks good Details
2024-03-15 10:40:59 +01:00
Lauréline Guérin e0f1d9541d
manager: prefill presence check form with unexpected presence (#88039)
gitea/chrono/pipeline/head This commit looks good Details
2024-03-15 08:38:14 +01:00
Lauréline Guérin 701733da57
misc: tests with --dist loadfile options (#87751)
gitea/chrono/pipeline/head This commit looks good Details
2024-03-11 16:15:34 +01:00
Lauréline Guérin d709fa9bc7
misc: verbose tests (#87751) 2024-03-11 16:15:34 +01:00
Lauréline Guérin 1d00c5fce8
snapshots: compare inspect (#87751) 2024-03-11 16:15:34 +01:00
Lauréline Guérin bd06f2b82f
manager: inspect views (#87751) 2024-03-08 14:14:04 +01:00
Lauréline Guérin ecf0ffd96e
agendas: fix permissions for agenda history views (#87751) 2024-03-07 16:45:37 +01:00
Lauréline Guérin 1b1bc13c82
agendas: fix snapshot on role update (#87751) 2024-03-07 16:45:37 +01:00
Lauréline Guérin 06ab6f12b7
agendas: export resources only for meetings agenda (#87751) 2024-03-07 16:45:37 +01:00
Lauréline Guérin 024b34b34f
agendas: object history and compare (#87316)
gitea/chrono/pipeline/head This commit looks good Details
2024-03-07 16:44:19 +01:00
Lauréline Guérin 0ea056dcd5
agendas: fix missing options in agenda import/export (#87679)
gitea/chrono/pipeline/head This commit looks good Details
2024-03-04 17:32:20 +01:00
Lauréline Guérin 3cef873ce4
export_import: fix event agenda dependencies (#87627)
gitea/chrono/pipeline/head This commit looks good Details
2024-02-29 15:44:34 +01:00
Lauréline Guérin 966d93829f
translation update
gitea/chrono/pipeline/head This commit looks good Details
2024-02-27 16:39:52 +01:00
Lauréline Guérin 03f9172c98
api: take snapshots (#87498)
gitea/chrono/pipeline/head This commit looks good Details
2024-02-27 15:33:48 +01:00
Lauréline Guérin 176d23aa4b
agendas: take snapshots (#86634)
gitea/chrono/pipeline/head This commit looks good Details
2024-02-27 15:33:14 +01:00
Lauréline Guérin 9331b06e04
snapshot: command to clear instances from snapshot (#86634) 2024-02-27 15:33:14 +01:00
Lauréline Guérin 3f8146c092
snapshot: init models (#86634) 2024-02-27 11:50:37 +01:00
Lauréline Guérin f6a0b58167
agendas: fix missing options in agenda import/export (#86634) 2024-02-27 11:50:37 +01:00
Lauréline Guérin e6db17f145
misc: move tests (#86634) 2024-02-27 11:50:36 +01:00
Lauréline Guérin 84581ed02e
misc: fix typos (#86634) 2024-02-27 11:50:36 +01:00
Lauréline Guérin 4f13f936e2
misc: fix missing migration (#86634) 2024-02-27 11:50:36 +01:00
Frédéric Péters 7df4de695d misc: use yield from (#87441)
gitea/chrono/pipeline/head This commit looks good Details
2024-02-25 19:16:27 +01:00
Yann Weber 095057839a tests: unpin pytest version (#86300)
gitea/chrono/pipeline/head This commit looks good Details
2024-02-15 10:38:18 +01:00
Frédéric Péters 69f9877ba5 translation update
gitea/chrono/pipeline/head This commit looks good Details
2024-02-01 09:50:22 +01:00
Lauréline Guérin 895758c70c
manager: display applications (#86148)
gitea/chrono/pipeline/head This commit looks good Details
2024-01-30 16:57:15 +01:00
Lauréline Guérin 3071fab8f8 manager: fix page-title-extra-label (#85941)
gitea/chrono/pipeline/head This commit looks good Details
2024-01-30 16:56:26 +01:00
Lauréline Guérin a4e5721dad manager: move buttons and links in sidebar (#85941) 2024-01-30 16:56:26 +01:00
Lauréline Guérin 068e5fe467 manager: fix base template and breadcrumb (#85941) 2024-01-30 16:56:26 +01:00
Yann Weber a36369ae1c manager: fix agenda's role edition when partial booking enabled (#85999)
gitea/chrono/pipeline/head This commit looks good Details
2024-01-30 15:27:35 +01:00
Yann Weber 3bfa450f97 notifications: move email recipients from To to Bcc (#81860)
gitea/chrono/pipeline/head This commit looks good Details
2024-01-30 15:18:29 +01:00
Yann Weber 917c918422 tests: pin pytest version to 7.4.4 (#86321)
gitea/chrono/pipeline/head This commit looks good Details
2024-01-30 15:13:21 +01:00
Frédéric Péters 9a1b37a5f7 translation update
gitea/chrono/pipeline/head This commit looks good Details
2024-01-23 14:21:30 +01:00
Frédéric Péters 5204fcda47 trivial: adjust spelling and typography (#85974) 2024-01-23 14:21:14 +01:00
Yann Weber 9945568a57 manager: add error when deleting an EventType linked to an Agenda (#85974)
gitea/chrono/pipeline/head This commit looks good Details
2024-01-23 14:14:46 +01:00
Yann Weber d428ef8385 agendas: change on_delete for Agenda -> EventsType to SET_NULL (#85974) 2024-01-23 14:14:46 +01:00
Frédéric Péters 9c660e7a1e misc: adjust title of meeting type deletion confirmation dialog (#85773)
gitea/chrono/pipeline/head This commit looks good Details
2024-01-18 20:26:38 +01:00
Yann Weber 47e7558298 manager: add __str__ to MeetingType, translating deletion popup (#85718)
gitea/chrono/pipeline/head This commit looks good Details
2024-01-17 11:23:18 +01:00
Yann Weber f2285f7880 api: add places_reserved field in booking API response (#84523)
gitea/chrono/pipeline/head This commit looks good Details
2024-01-17 09:56:00 +01:00
Benjamin Dauvergne 5a9379a7b8 api: allow modifying booking's data in waiting list (#85121)
gitea/chrono/pipeline/head This commit looks good Details
When presence informations are not modified.
2024-01-15 15:49:02 +01:00
Benjamin Dauvergne f749c5e9cb api: add explicit checks to DELETE /api/booking/<id>/ (#85121) 2024-01-15 15:49:02 +01:00
Benjamin Dauvergne f61d07f586 api: remove check on GET /api/booking/<id>/ (#85121) 2024-01-15 15:49:02 +01:00
Yann Weber 154fe0ccea test: add allowlist_externals for pylint.sh & getlasso3.sh (#85448)
gitea/chrono/pipeline/head This commit looks good Details
2024-01-15 11:43:57 +01:00
Yann Weber 14e7998895 api: add resize endpoint when reserving an event slot (#85190)
gitea/chrono/pipeline/head This commit looks good Details
2024-01-09 16:26:37 +01:00
Lauréline Guérin 8e35a25ad9
api: add adjusted values in cas of multi checks (#85088)
gitea/chrono/pipeline/head This commit looks good Details
2024-01-08 10:59:45 +01:00
Pierre Ducroquet 5db20c9434 views: do not use OR in join paths (#85107)
gitea/chrono/pipeline/head This commit looks good Details
2023-12-24 11:57:30 +01:00
Frédéric Péters eeca5783dd translation update
gitea/chrono/pipeline/head This commit looks good Details
2023-12-22 11:50:18 +01:00
Lauréline Guérin 3c052b467b export_import: add roles with minor=True (#85021)
gitea/chrono/pipeline/head This commit looks good Details
2023-12-22 11:42:23 +01:00
Frédéric Péters 888c0638d0 misc: increase allowed length for formdata related URLs (#85048)
gitea/chrono/pipeline/head This commit looks good Details
2023-12-22 09:47:54 +01:00
Lauréline Guérin 05aa65e72a export_import: complete redirect view for all components (#85010)
gitea/chrono/pipeline/head This commit looks good Details
2023-12-21 15:00:33 +01:00
Valentin Deniaud e83bfee4c3 setup: allow django-filter 23.1 (#82023)
gitea/chrono/pipeline/head This commit looks good Details
2023-12-18 11:09:39 +01:00
Valentin Deniaud 7bea1c912b translation update
gitea/chrono/pipeline/head This commit looks good Details
2023-12-18 10:09:29 +01:00
Valentin Deniaud 526f255ee5 manager: add styles and improve a11y for occupation rate graph (#78083)
gitea/chrono/pipeline/head This commit looks good Details
2023-12-18 10:05:49 +01:00
Valentin Deniaud 1740ebe572 manager: display occupation rate in partial bookings day view (#78083) 2023-12-18 10:05:49 +01:00
Lauréline Guérin 698bbfc7a4 manager: filter timesheet by booking status (#84260)
gitea/chrono/pipeline/head This commit looks good Details
2023-12-18 09:43:45 +01:00
Valentin Deniaud d02210ab66 api: add endpoint to check partial bookings (#84122)
gitea/chrono/pipeline/head This commit looks good Details
2023-12-18 09:41:20 +01:00
Nicolas Roche c4ecd1900a misc: remove copyright line from footer (#84813)
gitea/chrono/pipeline/head This commit looks good Details
2023-12-15 17:49:59 +01:00
Valentin Deniaud 7096938cda translation update
gitea/chrono/pipeline/head This commit looks good Details
2023-12-07 17:50:52 +01:00
Lauréline Guérin ee557adbcc
manager: filter partial bookings periods in day view (#84417)
gitea/chrono/pipeline/head This commit looks good Details
2023-12-07 15:54:25 +01:00
Valentin Deniaud ce96e674c2 manager: differentiate occasional partial bookings (#84140)
gitea/chrono/pipeline/head This commit looks good Details
2023-12-06 11:00:40 +01:00
Valentin Deniaud 5501b88c34 api: allow creating partial bookings agenda (#84121)
gitea/chrono/pipeline/head This commit looks good Details
2023-12-04 17:03:36 +01:00
Valentin Deniaud 440d02d505 api: forbid partial booking check outside of opening hours (#84211) 2023-12-04 17:03:32 +01:00
Valentin Deniaud f8748710bc manager: forbid partial booking check outside of opening hours (#84211) 2023-12-04 17:03:32 +01:00
Valentin Deniaud 6804b08cc6 manager: hide incomplete checks in partial bookings month view (#84124)
gitea/chrono/pipeline/head Build queued... Details
2023-12-04 14:49:52 +01:00
Benjamin Dauvergne aad10c71ee translation update
gitea/chrono/pipeline/head This commit looks good Details
2023-11-28 12:32:12 +01:00
Benjamin Dauvergne 2831272e56 manager: display placeholder for leased bookings (#82774) 2023-11-28 12:32:12 +01:00
148 changed files with 9771 additions and 946 deletions

2
Jenkinsfile vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -89,11 +89,11 @@ class FillSlotSerializer(serializers.Serializer):
exclude_user = serializers.BooleanField(default=False)
events = serializers.CharField(max_length=16, allow_blank=True)
bypass_delays = serializers.BooleanField(default=False)
form_url = serializers.CharField(max_length=250, allow_blank=True)
backoffice_url = serializers.URLField(allow_blank=True)
cancel_callback_url = serializers.URLField(allow_blank=True)
presence_callback_url = serializers.URLField(allow_blank=True)
absence_callback_url = serializers.URLField(allow_blank=True)
form_url = serializers.CharField(max_length=500, allow_blank=True)
backoffice_url = serializers.URLField(allow_blank=True, max_length=500)
cancel_callback_url = serializers.URLField(allow_blank=True, max_length=500)
presence_callback_url = serializers.URLField(allow_blank=True, max_length=500)
absence_callback_url = serializers.URLField(allow_blank=True, max_length=500)
count = serializers.IntegerField(min_value=1)
cancel_booking_id = serializers.CharField(max_length=250, allow_blank=True, allow_null=True)
force_waiting_list = serializers.BooleanField(default=False)
@ -358,7 +358,23 @@ class BookingSerializer(serializers.ModelSerializer):
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
for key in ['', 'user_check_', 'computed_']:
# 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,
@ -430,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'])
@ -446,6 +467,7 @@ class DateRangeSerializer(DateRangeMixin, serializers.Serializer):
class DatetimesSerializer(DateRangeSerializer):
min_places = serializers.IntegerField(min_value=1, default=1)
max_places = serializers.IntegerField(min_value=1, default=None)
user_external_id = serializers.CharField(required=False, max_length=250, allow_blank=True)
exclude_user_external_id = serializers.CharField(required=False, max_length=250, allow_blank=True)
events = serializers.CharField(required=False, max_length=32, allow_blank=True)
@ -668,6 +690,7 @@ class AgendaSerializer(serializers.ModelSerializer):
'slug',
'label',
'kind',
'partial_bookings',
'minimal_booking_delay',
'minimal_booking_delay_in_working_days',
'maximal_booking_delay',
@ -718,6 +741,10 @@ class AgendaSerializer(serializers.ModelSerializer):
)
if attrs.get('events_type') and attrs.get('kind', 'events') != 'events':
raise ValidationError({'events_type': _('Option not available on %s agenda') % attrs['kind']})
if attrs.get('partial_bookings') and attrs.get('kind', 'events') != 'events':
raise ValidationError(
{'partial_bookings': _('Option not available on %s agenda') % attrs['kind']}
)
return attrs

View File

@ -133,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'),
@ -151,4 +156,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')),
path('user-preferences/', include('chrono.apps.user_preferences.api_urls')),
]

View File

@ -20,7 +20,7 @@ import datetime
import json
import uuid
import vobject
import icalendar
from django.conf import settings
from django.db import IntegrityError, transaction
from django.db.models import BooleanField, Case, Count, ExpressionWrapper, F, Func, Prefetch, Q, When
@ -57,6 +57,7 @@ from chrono.agendas.models import (
)
from chrono.api import serializers
from chrono.api.utils import APIError, APIErrorBadRequest, Response
from chrono.apps.journal.utils import audit
from chrono.utils.publik_urls import translate_to_publik_url
from chrono.utils.timezone import localtime, make_aware, now
@ -149,6 +150,7 @@ def get_event_places(event):
def is_event_disabled(
event,
min_places=1,
max_places=None,
disable_booked=True,
bookable_events=None,
bypass_delays=False,
@ -170,7 +172,9 @@ def is_event_disabled(
):
# event is out of minimal delay and we don't want to bypass delays
return True
if event.remaining_places < min_places and event.remaining_waiting_list_places < min_places:
if max_places and max_places <= event.remaining_places:
return True
elif event.remaining_places < min_places and event.remaining_waiting_list_places < min_places:
if enable_full_when_booked and getattr(event, 'user_places_count', 0) > 0:
return False
return True
@ -210,6 +214,7 @@ def get_event_detail(
booking=None,
agenda=None,
min_places=1,
max_places=None,
booked_user_external_id=None,
bookable_events=None,
multiple_agendas=False,
@ -264,6 +269,7 @@ def get_event_detail(
'disabled': is_event_disabled(
event,
min_places=min_places,
max_places=max_places,
disable_booked=disable_booked,
bookable_events=bookable_events,
bypass_delays=bypass_delays,
@ -331,9 +337,11 @@ def get_short_event_detail(
details = {
'id': '%s@%s' % (agenda.slug, event.slug) if multiple_agendas else event.slug,
'slug': event.slug, # kept for compatibility
'primary_event': None,
'text': get_event_text(event, agenda),
'label': event.label or '',
'agenda_label': agenda.label,
'agenda_slug': agenda.slug,
'date': format_response_date(event.start_datetime),
'datetime': format_response_datetime(event.start_datetime),
'end_datetime': format_response_datetime(event.end_datetime) if event.end_datetime else '',
@ -345,6 +353,12 @@ def get_short_event_detail(
'check_locked': event.check_locked,
'invoiced': event.invoiced,
}
if event.primary_event:
details['primary_event'] = (
'%s@%s' % (agenda.slug, event.primary_event.slug)
if multiple_agendas
else event.primary_event.slug
)
for key, value in event.get_custom_fields().items():
details['custom_field_%s' % key] = value
return details
@ -355,6 +369,7 @@ def get_events_meta_detail(
events,
agenda=None,
min_places=1,
max_places=0,
bookable_events=None,
multiple_agendas=False,
bypass_delays=False,
@ -365,7 +380,11 @@ def get_events_meta_detail(
for event in events:
bookable_datetimes_number_total += 1
if not is_event_disabled(
event, min_places=min_places, bookable_events=bookable_events, bypass_delays=bypass_delays
event,
min_places=min_places,
max_places=max_places,
bookable_events=bookable_events,
bypass_delays=bypass_delays,
):
bookable_datetimes_number_available += 1
if not first_bookable_slot:
@ -374,6 +393,7 @@ def get_events_meta_detail(
event,
agenda=agenda,
min_places=min_places,
max_places=max_places,
bookable_events=bookable_events,
multiple_agendas=multiple_agendas,
bypass_delays=bypass_delays,
@ -470,7 +490,13 @@ def make_booking(
color=None,
request_uuid=None,
previous_state=None,
from_recurring_fillslots=False,
):
if 'start_time' in payload and payload['start_time'] < localtime(event.start_datetime).time():
raise APIError(N_('booking start must be after opening time'))
if 'end_time' in payload and payload['end_time'] > event.end_time:
raise APIError(N_('booking end must be before closing time'))
out_of_min_delay = False
if event.agenda.min_booking_datetime and event.start_datetime < event.agenda.min_booking_datetime:
out_of_min_delay = True
@ -499,6 +525,7 @@ def make_booking(
color=color,
request_uuid=request_uuid,
previous_state=previous_state,
from_recurring_fillslots=from_recurring_fillslots,
)
@ -564,6 +591,7 @@ class Agendas(APIView):
if agenda.kind == 'events':
desk = Desk.objects.create(agenda=agenda, slug='_exceptions_holder')
desk.import_timeperiod_exceptions_from_settings()
agenda.take_snapshot(request=self.request, comment=pgettext('snapshot', 'created'))
return Response({'err': 0, 'data': [get_agenda_detail(request, agenda)]})
@ -588,6 +616,7 @@ class AgendaAPI(APIView):
if has_bookings:
raise APIError(_('This cannot be removed as there are bookings for a future date.'))
agenda.take_snapshot(request=self.request, deletion=True)
agenda.delete()
return Response({'err': 0})
@ -601,7 +630,8 @@ class AgendaAPI(APIView):
if 'kind' in serializer.validated_data and serializer.validated_data['kind'] != agenda.kind:
raise APIErrorBadRequest(N_('it is not possible to change kind value'))
serializer.save()
agenda = serializer.save()
agenda.take_snapshot(request=self.request)
return self.get(request, agenda_identifier)
@ -653,6 +683,7 @@ class Datetimes(APIView):
entries = Event.annotate_queryset_for_user(entries, user_external_id)
if lock_code:
entries = Event.annotate_queryset_for_lock_code(entries, lock_code=lock_code)
entries = entries.prefetch_related(Prefetch('primary_event', queryset=Event.objects.all().order_by()))
entries = entries.order_by('start_datetime', 'duration', 'label')
if payload['hide_disabled']:
@ -661,7 +692,8 @@ class Datetimes(APIView):
for e in entries
if not is_event_disabled(
e,
payload['min_places'],
min_places=payload['min_places'],
max_places=payload['max_places'],
disable_booked=disable_booked,
bookable_events=bookable_events,
bypass_delays=payload.get('bypass_delays'),
@ -675,6 +707,7 @@ class Datetimes(APIView):
x,
agenda=agenda,
min_places=payload['min_places'],
max_places=payload['max_places'],
booked_user_external_id=payload.get('user_external_id'),
bookable_events=bookable_events_raw,
disable_booked=disable_booked,
@ -687,6 +720,7 @@ class Datetimes(APIView):
entries,
agenda=agenda,
min_places=payload['min_places'],
max_places=payload['max_places'],
bookable_events=bookable_events_raw,
),
}
@ -733,6 +767,9 @@ class MultipleAgendasDatetimes(APIView):
entries = Event.annotate_queryset_for_user(entries, user_external_id, with_status=with_status)
if lock_code:
Event.annotate_queryset_for_lock_code(entries, lock_code)
entries = entries.prefetch_related(
Prefetch('primary_event', queryset=Event.objects.all().order_by())
)
if check_overlaps:
entries = Event.annotate_queryset_with_overlaps(entries)
@ -781,6 +818,7 @@ class MultipleAgendasDatetimes(APIView):
request,
x,
min_places=payload['min_places'],
max_places=payload['max_places'],
booked_user_external_id=payload.get('user_external_id'),
multiple_agendas=True,
disable_booked=disable_booked,
@ -791,7 +829,11 @@ class MultipleAgendasDatetimes(APIView):
for x in entries
],
'meta': get_events_meta_detail(
request, entries, min_places=payload['min_places'], multiple_agendas=True
request,
entries,
min_places=payload['min_places'],
max_places=payload['max_places'],
multiple_agendas=True,
),
}
return Response(response)
@ -1296,7 +1338,7 @@ class EventsAgendaFillslot(APIView):
if to_cancel_booking:
cancelled_booking_id = to_cancel_booking.pk
to_cancel_booking.cancel()
to_cancel_booking.cancel(request=request)
# now we have a list of events, book them.
primary_booking = None
@ -1309,6 +1351,17 @@ class EventsAgendaFillslot(APIView):
in_waiting_list=in_waiting_list,
)
new_booking.save()
audit(
'booking:create',
request=request,
agenda=event.agenda,
extra_data={
'booking': new_booking,
'event': event,
'primary_booking_id': primary_booking.id if primary_booking else None,
},
)
if lock_code and not confirm_after_lock:
Lease.objects.create(
booking=new_booking,
@ -1346,6 +1399,9 @@ class EventsAgendaFillslot(APIView):
'suspend_url': request.build_absolute_uri(
reverse('api-suspend-booking', kwargs={'booking_pk': primary_booking.pk})
),
'resize_url': request.build_absolute_uri(
reverse('api-resize-booking', kwargs={'booking_pk': primary_booking.pk})
),
},
}
if to_cancel_booking:
@ -1530,7 +1586,7 @@ class MeetingsAgendaFillslot(APIView):
).delete()
if to_cancel_booking:
cancelled_booking_id = to_cancel_booking.pk
to_cancel_booking.cancel()
to_cancel_booking.cancel(request=request)
# book event
event.save()
@ -1543,6 +1599,16 @@ class MeetingsAgendaFillslot(APIView):
color=color,
)
booking.save()
audit(
'booking:create',
request=request,
agenda=event.agenda,
extra_data={
'booking': booking,
'event': event,
},
)
if lock_code and not confirm_after_lock:
Lease.objects.create(
booking=booking,
@ -1832,6 +1898,7 @@ class RecurringFillslots(APIView):
min_start=start_datetime,
max_start=end_datetime,
)
events = events.prefetch_related(Prefetch('primary_event', queryset=Event.objects.all().order_by()))
return events
@ -1875,7 +1942,7 @@ class RecurringFillslots(APIView):
)
def make_bookings(self, events, payload, extra_data):
return [make_booking(event, payload, extra_data) for event in events]
return [make_booking(event, payload, extra_data, from_recurring_fillslots=True) for event in events]
recurring_fillslots = RecurringFillslots.as_view()
@ -1890,7 +1957,7 @@ class RecurringFillslotsByDay(RecurringFillslots):
payload['start_time'], payload['end_time'] = payload['hours_by_days'][
event.start_datetime.isoweekday()
]
bookings.append(make_booking(event, payload, extra_data))
bookings.append(make_booking(event, payload, extra_data, from_recurring_fillslots=True))
return bookings
@ -1990,6 +2057,7 @@ class EventsFillslots(APIView):
output_field=BooleanField(),
)
)
events = events.prefetch_related(Prefetch('primary_event', queryset=Event.objects.all().order_by()))
waiting_list_event_ids = [event.pk for event in events if event.in_waiting_list]
extra_data = get_extra_data(request, payload)
@ -2214,9 +2282,13 @@ class MultipleAgendasEventsFillslotsRevert(APIView):
if booking.previous_state == 'cancelled':
bookings_to_cancel.append(booking)
events = Event.objects.filter(
pk__in=[b.event_id for b in bookings_to_cancel + bookings_to_book + bookings_to_delete]
).prefetch_related('agenda')
events = (
Event.objects.filter(
pk__in=[b.event_id for b in bookings_to_cancel + bookings_to_book + bookings_to_delete]
)
.prefetch_related('agenda')
.prefetch_related(Prefetch('primary_event', queryset=Event.objects.all().order_by()))
)
events_by_id = {x.id: x for x in events}
with transaction.atomic():
cancellation_datetime = now()
@ -2435,6 +2507,16 @@ class MultipleAgendasEventsCheckLock(APIView):
for event in events:
event.async_refresh_booking_computed_times()
for event in events:
audit(
'check:lock' if check_locked else 'check:unlock',
request=request,
agenda=event.agenda,
extra_data={
'event': event,
},
)
return Response({'err': 0})
@ -2464,6 +2546,16 @@ class MultipleAgendasEventsInvoiced(APIView):
)
events.update(invoiced=invoiced)
for event in events:
audit(
'invoice:mark' if invoiced else 'invoice:unmark',
request=request,
agenda=event.agenda,
extra_data={
'event': event,
},
)
return Response({'err': 0})
@ -2740,10 +2832,12 @@ class BookingsAPI(ListAPIView):
return Response({'err': 0, 'data': data})
def get_queryset(self):
event_queryset = Event.objects.all().prefetch_related(
'agenda', 'desk', Prefetch('primary_event', queryset=Event.objects.all().order_by())
)
return (
Booking.objects.filter(primary_booking__isnull=True, cancellation_datetime__isnull=True)
.select_related('event', 'event__agenda', 'event__desk')
.prefetch_related('user_checks')
.prefetch_related('user_checks', Prefetch('event', queryset=event_queryset))
.order_by('event__start_datetime', 'event__slug', 'event__agenda__slug', 'pk')
)
@ -2761,14 +2855,14 @@ class BookingsICS(BookingsAPI):
except ValidationError as e:
raise APIErrorBadRequest(N_('invalid payload'), errors=e.detail)
ics = vobject.iCalendar()
ics.add('prodid').value = '-//Entr\'ouvert//NON SGML Publik'
cal = icalendar.Calendar()
cal['propid'] = '-//Entr\'ouvert//NON SGML Publik'
for booking in bookings:
vevent = booking.get_vevent_ics()
ics.add(vevent)
cal.add_component(vevent)
return HttpResponse(ics.serialize(), content_type='text/calendar')
return HttpResponse(cal.to_ical(), content_type='text/calendar')
bookings_ics = BookingsICS.as_view()
@ -2782,19 +2876,10 @@ class BookingAPI(APIView):
super().initial(request, *args, **kwargs)
self.booking = get_object_or_404(Booking, pk=kwargs.get('booking_pk'))
def check_booking(self, check_waiting_list=False):
if self.booking.cancellation_datetime:
raise APIError(N_('booking is cancelled'))
def get(self, request, *args, **kwargs):
if self.booking.primary_booking is not None:
raise APIError(N_('secondary booking'), err=2)
if check_waiting_list and self.booking.in_waiting_list:
raise APIError(N_('booking is in waiting list'), err=3)
def get(self, request, *args, **kwargs):
self.check_booking()
user_checks = self.booking.user_checks.all()
user_check = None
if len(user_checks) == 1:
@ -2806,18 +2891,31 @@ class BookingAPI(APIView):
{
'err': 0,
'booking_id': self.booking.pk,
'places_count': self.booking.secondary_booking_set.count() + 1,
}
)
return Response(response)
def patch(self, request, *args, **kwargs):
self.check_booking(check_waiting_list=True)
if self.booking.cancellation_datetime:
raise APIError(N_('booking is cancelled'))
if self.booking.primary_booking is not None:
raise APIError(N_('secondary booking'), err=2)
serializer = self.serializer_class(self.booking, data=request.data, partial=True)
if not serializer.is_valid():
raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors, err=4)
if set(serializer.validated_data) & {
'user_was_present',
'user_absence_reason',
'user_presence_reason',
}:
if self.booking.in_waiting_list:
raise APIError(N_('booking is in waiting list'), err=3)
if self.booking.event.agenda.kind != 'events' and (
'user_was_present' in request.data
or 'user_absence_reason' in request.data
@ -2889,9 +2987,13 @@ class BookingAPI(APIView):
return Response(response)
def delete(self, request, *args, **kwargs):
self.check_booking()
if self.booking.cancellation_datetime:
raise APIError(N_('booking is cancelled'))
self.booking.cancel()
if self.booking.primary_booking is not None:
raise APIError(N_('secondary booking'), err=2)
self.booking.cancel(request=request)
response = {'err': 0, 'booking_id': self.booking.pk}
return Response(response)
@ -2915,7 +3017,7 @@ class CancelBooking(APIView):
raise APIError(N_('already cancelled'))
if booking.primary_booking is not None:
raise APIError(N_('secondary booking'), err=2)
booking.cancel()
booking.cancel(request=request)
response = {'err': 0, 'booking_id': booking.id}
return Response(response)
@ -2944,6 +3046,16 @@ class AcceptBooking(APIView):
raise APIError(N_('booking is not in waiting list'), err=3)
booking.accept()
event = booking.event
audit(
'booking:accept',
request=request,
agenda=event.agenda,
extra_data={
'booking_id': booking.id,
'event': event,
},
)
response = {
'err': 0,
'booking_id': booking.pk,
@ -2975,6 +3087,17 @@ class SuspendBooking(APIView):
if booking.in_waiting_list:
raise APIError(N_('booking is already in waiting list'), err=3)
booking.suspend()
event = booking.event
audit(
'booking:suspend',
request=request,
agenda=event.agenda,
extra_data={
'booking': booking,
'event': event,
},
)
response = {'err': 0, 'booking_id': booking.pk}
return Response(response)
@ -3091,6 +3214,84 @@ class ResizeBooking(APIView):
resize_booking = ResizeBooking.as_view()
class PartialBookingsCheckAPI(APIView):
permission_classes = (permissions.IsAuthenticated,)
serializer_class = serializers.PartialBookingsCheckSerializer
def post(self, request, agenda_identifier):
agenda = get_object_or_404(Agenda, slug=agenda_identifier, kind='events', partial_bookings=True)
serializer = self.serializer_class(data=request.data)
if not serializer.is_valid():
raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors)
check_timestamp = serializer.validated_data['timestamp']
event = get_object_or_404(
Event,
Q(checked=False) | Q(agenda__disable_check_update=False),
start_datetime__date=check_timestamp.date(),
recurrence_days__isnull=True,
agenda=agenda,
check_locked=False,
)
if not (event.start_datetime.time() < check_timestamp.time() < event.end_time):
raise APIError(N_('check time outside of opening hours'))
bookings = (
Booking.objects.filter(
event=event, user_external_id=serializer.validated_data['user_external_id']
)
.prefetch_related(
# ignore absence checks
Prefetch('user_checks', queryset=BookingCheck.objects.filter(presence=True))
)
.order_by('start_time')
)
if not bookings:
subscription = get_object_or_404(
Subscription,
agenda=agenda,
user_external_id=serializer.validated_data['user_external_id'],
date_start__lte=event.start_datetime,
date_end__gt=event.start_datetime,
)
# create dummy booking to allow check
booking = event.booking_set.create(
user_external_id=subscription.user_external_id,
user_last_name=subscription.user_last_name,
user_first_name=subscription.user_first_name,
user_email=subscription.user_email,
user_phone_number=subscription.user_phone_number,
extra_data=subscription.extra_data,
)
bookings = [booking]
for booking in bookings:
user_checks = booking.user_checks.all()
# create partial check for unchecked booking
if not user_checks:
BookingCheck.objects.create(booking=booking, presence=True, start_time=check_timestamp)
break
# complete existing partial check
user_check = user_checks[0]
if not user_check.end_time:
user_check.end_time = check_timestamp
user_check.save()
break
else:
raise APIError(N_('no booking to check'))
return Response({'err': 0})
partial_bookings_check = PartialBookingsCheckAPI.as_view()
class EventsAPI(APIView):
permission_classes = (permissions.IsAuthenticated,)
serializer_class = serializers.EventSerializer
@ -3105,6 +3306,7 @@ class EventsAPI(APIView):
event = serializer.save()
if event.recurrence_days:
event.create_all_recurrences()
agenda.take_snapshot(request=self.request, comment=_('added event (%s)') % event)
return Response({'err': 0, 'data': get_event_detail(request, event)})
@ -3163,6 +3365,7 @@ class EventAPI(APIView):
changed_data, payload, protected_fields, protected_fields + ['recurrence_end_date']
):
event = serializer.save()
event.agenda.take_snapshot(request=self.request, comment=_('changed event (%s)') % event)
return Response({'err': 0, 'data': get_event_detail(request, event)})
def delete(self, request, agenda_identifier, event_identifier):
@ -3178,6 +3381,7 @@ class EventAPI(APIView):
raise APIError(_('This cannot be removed as there are bookings for a future date.'))
event.delete()
event.agenda.take_snapshot(request=self.request, comment=_('removed event (%s)') % event)
return Response({'err': 0})
@ -3232,6 +3436,14 @@ class EventCheck(APIView):
if not event.checked:
event.checked = True
event.save(update_fields=['checked'])
audit(
'check:mark',
request=request,
agenda=event.agenda,
extra_data={
'event': event,
},
)
event.async_notify_checked()
response = {
'err': 0,
@ -3495,12 +3707,20 @@ class BookingsStatistics(APIView):
)
def get_subfilters(self, agendas):
extra_data_keys = (
Booking.objects.filter(
Q(event__agenda__in=agendas) | Q(event__agenda__virtual_agendas__in=agendas)
)
agenda_bookings = (
Booking.objects.filter(event__agenda__in=agendas)
.annotate(extra_data_keys=Func('extra_data', function='jsonb_object_keys'))
.distinct('extra_data_keys')
.values_list('extra_data_keys', flat=True)
)
virtual_bookings = (
Booking.objects.filter(event__agenda__virtual_agendas__in=agendas)
.annotate(extra_data_keys=Func('extra_data', function='jsonb_object_keys'))
.distinct('extra_data_keys')
.values_list('extra_data_keys', flat=True)
)
extra_data_keys = (
agenda_bookings.union(virtual_bookings)
.order_by('extra_data_keys')
.values_list('extra_data_keys', flat=True)
)

View File

@ -208,6 +208,7 @@ class Place(models.Model):
'annulation_url': self.cancel_url,
'plages': list(self.iter_open_dates()),
'rdvs': list(self.iter_predemandes()),
'logo_url': self.logo_url,
}
return payload

View File

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

View File

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

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -82,7 +82,7 @@ class AgendaAddForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if settings.PARTIAL_BOOKINGS_ENABLED:
if 'kind' in self.fields and settings.PARTIAL_BOOKINGS_ENABLED:
self.fields['kind'].choices += [('partial-bookings', _('Partial bookings'))]
class Meta:
@ -522,6 +522,20 @@ class BookingCheckFilterSet(django_filters.FilterSet):
)
self.filters['booking-status'].parent = self
if self.agenda.partial_bookings:
self.filters['display'] = django_filters.MultipleChoiceFilter(
label=_('Display'),
choices=[
('booked', _('Booked periods')),
('checked', _('Checked periods')),
('computed', _('Computed periods')),
],
widget=forms.CheckboxSelectMultiple,
method='do_nothing',
initial=['booked', 'checked', 'computed'],
)
self.filters['display'].parent = self
def filter_booking_status(self, queryset, name, value):
if value == 'not-booked':
return queryset.none()
@ -575,12 +589,17 @@ class BookingCheckPresenceForm(forms.Form):
def __init__(self, *args, **kwargs):
agenda = kwargs.pop('agenda')
subscription = kwargs.pop('subscription', False)
super().__init__(*args, **kwargs)
check_types = get_agenda_check_types(agenda)
self.presence_check_types = [ct for ct in check_types if ct.kind == 'presence']
self.fields['check_type'].choices = [('', '---------')] + [
(ct.slug, ct.label) for ct in self.presence_check_types
]
if not self.initial and subscription:
unexpected_presences = [ct for ct in check_types if ct.unexpected_presence]
if unexpected_presences:
self.initial['check_type'] = unexpected_presences[0].slug
class PartialBookingCheckForm(forms.ModelForm):
@ -612,6 +631,7 @@ class PartialBookingCheckForm(forms.ModelForm):
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)
@ -667,6 +687,20 @@ class PartialBookingCheckForm(forms.ModelForm):
return self.cleaned_data['presence']
def clean_start_time(self):
start_time = self.cleaned_data['start_time']
if start_time and start_time < localtime(self.event.start_datetime).time():
raise ValidationError(_('Arrival must be after opening time.'))
return start_time
def clean_end_time(self):
end_time = self.cleaned_data['end_time']
if end_time and end_time > self.event.end_time:
raise ValidationError(_('Departure must be before closing time.'))
return end_time
def save(self):
booking = self.instance.booking
if self.cleaned_data['presence'] is None:
@ -743,17 +777,29 @@ class EventsTimesheetForm(forms.Form):
],
initial='portrait',
)
booking_filter = forms.ChoiceField(
label=_('Filter by status'),
choices=[
('all', _('All')),
('with_booking', _('With booking')),
('without_booking', _('Without booking')),
],
initial='all',
)
def __init__(self, *args, **kwargs):
self.agenda = kwargs.pop('agenda')
self.event = kwargs.pop('event', None)
super().__init__(*args, **kwargs)
self.with_subscriptions = self.agenda.subscriptions.exists()
if self.event is not None:
del self.fields['date_start']
del self.fields['date_end']
del self.fields['date_display']
del self.fields['custom_nb_dates_per_page']
del self.fields['activity_display']
if not self.with_subscriptions:
del self.fields['booking_filter']
def get_slots(self):
extra_data = self.cleaned_data['extra_data'].split(',')
@ -821,20 +867,21 @@ class EventsTimesheetForm(forms.Form):
)
users = {}
subscriptions = self.agenda.subscriptions.filter(date_start__lt=max_start, date_end__gt=min_start)
for subscription in subscriptions:
if subscription.user_external_id in users:
continue
users[subscription.user_external_id] = {
'user_id': subscription.user_external_id,
'user_first_name': subscription.user_first_name,
'user_last_name': subscription.user_last_name,
'extra_data': {k: (subscription.extra_data or {}).get(k) or '' for k in all_extra_data},
'events': copy.deepcopy(event_slots),
}
if self.with_subscriptions:
subscriptions = self.agenda.subscriptions.filter(date_start__lt=max_start, date_end__gt=min_start)
for subscription in subscriptions:
if subscription.user_external_id in users:
continue
users[subscription.user_external_id] = {
'user_id': subscription.user_external_id,
'user_first_name': subscription.user_first_name,
'user_last_name': subscription.user_last_name,
'extra_data': {k: (subscription.extra_data or {}).get(k) or '' for k in all_extra_data},
'events': copy.deepcopy(event_slots),
}
booking_qs_kwargs = {}
if not self.agenda.subscriptions.exists():
if not self.with_subscriptions:
booking_qs_kwargs = {'cancellation_datetime__isnull': True}
booked_qs = (
Booking.objects.filter(
@ -870,6 +917,19 @@ class EventsTimesheetForm(forms.Form):
participants += 1
break
if self.cleaned_data.get('booking_filter') == 'with_booking':
# remove subscribed users without booking
users = {
k: user for k, user in users.items() if any(any(e['dates'].values()) for e in user['events'])
}
elif self.cleaned_data.get('booking_filter') == 'without_booking':
# remove subscribed users with booking
users = {
k: user
for k, user in users.items()
if not any(any(e['dates'].values()) for e in user['events'])
}
if self.cleaned_data['sort'] == 'lastname,firstname':
sort_fields = ['user_last_name', 'user_first_name']
else:

View File

@ -201,6 +201,18 @@ table.agenda-table {
text-align: center;
}
&.booking {
&.lease {
background:
repeating-linear-gradient(
135deg,
hsla(10, 10%, 75%, 0.7) 0,
hsla(10, 10%, 80%, 0.55) 10px,
transparent 11px,
transparent 20px);
// color: currentColor;
color: hsla(0, 0%, 0%, 0.7);
}
left: 0;
color: hsl(210, 84%, 40%);
padding: 1ex;
@ -562,6 +574,18 @@ div.agenda-settings .pk-tabs--container {
#event_details {
margin: 1em 0;
.objects-list .lease {
background:
repeating-linear-gradient(
135deg,
hsla(10, 10%, 75%, 0.7) 0,
hsla(10, 10%, 80%, 0.55) 10px,
transparent 11px,
transparent 20px);
}
.objects-list .lease span {
padding: 0 0.5ex 0 2ex;
}
}
@media print {
@ -635,6 +659,85 @@ 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;
@ -704,6 +807,10 @@ div#main-content.partial-booking-dayview {
}
&.booking {
--bar-color: #1066bc;
.occasional {
font-style: italic;
font-size: 90%;
}
}
&.check.present, &.computed.present {
--bar-color: var(--green);
@ -763,20 +870,73 @@ div#main-content.partial-booking-dayview {
background-color: var(--red);
z-index: 3;
}
}
.agenda-table.partial-bookings .booking {
height: 70%;
width: 100%;
position: absolute;
right: 0;
top: 15%;
background: #1066bc;
&.present {
background: hsl(120, 57%, 35%);
}
&.absent {
background: hsl(355, 80%, 45%);
// Month view, table element
&-month {
width: 100%;
border-spacing: 0;
& col.we {
background-color: var(--zebra-color);
}
& col.today {
background-image: linear-gradient(
135deg,
hsl(65, 65%, 94%) 20%,
hsl(65, 55%, 92%) 70%,
hsl(65, 50%, 90%) 90%);
}
&--day {
padding: .33em;
a {
color: var(--font-color);
font-weight: normal;
text-decoration: none;
}
&.today a {
font-weight: bold;
}
}
& .registrant {
&--name {
box-sizing: border-box;
text-align: right;
padding: .66rem;
font-size: 130%;
color: #505050;
font-weight: normal;
width: var(--registrant-name-width);
}
&--day-cell {
border-left: var(--separator-size) solid var(--separator-color);
text-align: center;
vertical-align: middle;
padding: .33em;
line-height: 0;
& .booking {
display: inline-block;
width: Min(100%, 1.75em);
height: 1.75em;
--booking-color: #1066bc;
background-color: var(--booking-color);
&.present {
background: var(--green);
}
&.absent {
background: var(--red);
}
}
}
}
&--registrant:nth-child(odd) {
& th, & td {
background-color: var(--zebra-color);
}
}
&--registrant:nth-child(even) {
& th, & td {
--separator-color: var(--zebra-color);
}
}
}
}
@ -834,3 +994,22 @@ ul.objects-list.single-links li.ants-setting-not-configured a.edit {
/* used for the city-edit link */
.icon-edit::before { content: "\f044"; }
a.button.button-paragraph {
text-align: left;
box-sizing: border-box;
display: block;
max-width: 100%;
margin-bottom: 1rem;
line-height: 150%;
padding-top: 0.8em;
padding-bottom: 0.8em;
}
.application-logo, .application-icon {
vertical-align: middle;
}
.snapshots-list .collapsed {
display: none;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -72,11 +72,28 @@
</script>
</div>
{% endif %}
<div class="partial-booking--hours-list" aria-hidden="true">
<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 %}
@ -95,56 +112,65 @@
</h3>
{% endspaceless %}
<div class="registrant--datas">
<div class="registrant--bar-container">
{% for booking in user.bookings %}
{% if booking.start_time %}
<p
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>
</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 check in user.booking_checks %}
<p
class="registrant--bar check {{ check.css_class }}"
title="{% trans "Checked period" %}"
style="left: {{ check.css_left }}%;{% if check.css_width %} width: {{ check.css_width }}%;{% endif %}"
>
<strong class="sr-only">{% trans "Checked period:" %}</strong>
{% if check.start_time %}
<time class="start-time" datetime="{{ check.start_time|time:"H:i" }}">{{ check.start_time|time:"H:i" }}</time>
{% endif %}
{% if check.end_time %}
<time class="end-time" datetime="{{ check.end_time|time:"H:i" }}">{{ check.end_time|time:"H:i" }}</time>
{% endif %}
{% if check.type_label %}<span>{{ check.type_label }}</span>{% endif %}
</p>
{% endfor %}
</div>
<div class="registrant--bar-container">
{% for check in user.booking_checks %}
{% if check.computed_start_time and check.computed_end_time %}
{% for booking in user.bookings %}
{% if booking.start_time %}
<p
class="registrant--bar computed {{ check.css_class }}"
title="{% trans "Computed period" %}"
style="left: {{ check.computed_css_left }}%; width: {{ check.computed_css_width }}%;"
class="registrant--bar booking"
title="{% trans "Booked period" %}"
style="left: {{ booking.css_left }}%; width: {{ booking.css_width }}%;"
>
<strong class="sr-only">{% trans "Computed period:" %}</strong>
<time class="start-time" datetime="{{ check.computed_start_time|time:"H:i" }}">{{ check.computed_start_time|time:"H:i" }}</time>
<time class="end-time" datetime="{{ check.computed_end_time|time:"H:i" }}">{{ check.computed_end_time|time:"H:i" }}</time>
<strong class="sr-only">{% trans "Booked period:" %}</strong>
<time class="start-time" datetime="{{ booking.start_time|time:"H:i" }}">{{ booking.start_time|time:"H:i" }}</time>
<time class="end-time" datetime="{{ booking.end_time|time:"H:i" }}">{{ booking.end_time|time:"H:i" }}</time>
{% if not booking.from_recurring_fillslots %}
<span class="occasional">{% trans "occasional" %}</span>
{% endif %}
</p>
{% endif %}
{% endfor %}
</div>
{% endif %}
{% if user.bookings %}
{% if not filterset.form.cleaned_data or 'checked' in filterset.form.cleaned_data.display %}
<div class="registrant--bar-container">
{% for check in user.booking_checks %}
<p
class="registrant--bar check {{ check.css_class }}"
title="{% trans "Checked period" %}"
style="left: {{ check.css_left }}%;{% if check.css_width %} width: {{ check.css_width }}%;{% endif %}"
>
<strong class="sr-only">{% trans "Checked period:" %}</strong>
{% if check.start_time %}
<time class="start-time" datetime="{{ check.start_time|time:"H:i" }}">{{ check.start_time|time:"H:i" }}</time>
{% endif %}
{% if check.end_time %}
<time class="end-time" datetime="{{ check.end_time|time:"H:i" }}">{{ check.end_time|time:"H:i" }}</time>
{% endif %}
{% if check.type_label %}<span>{{ check.type_label }}</span>{% endif %}
</p>
{% endfor %}
</div>
{% endif %}
{% if not filterset.form.cleaned_data or 'computed' in filterset.form.cleaned_data.display %}
<div class="registrant--bar-container">
{% for check in user.booking_checks %}
{% if check.computed_start_time and check.computed_end_time %}
<p
class="registrant--bar computed {{ check.css_class }}"
title="{% trans "Computed period" %}"
style="left: {{ check.computed_css_left }}%; width: {{ check.computed_css_width }}%;"
>
<strong class="sr-only">{% trans "Computed period:" %}</strong>
<time class="start-time" datetime="{{ check.computed_start_time|time:"H:i" }}">{{ check.computed_start_time|time:"H:i" }}</time>
<time class="end-time" datetime="{{ check.computed_end_time|time:"H:i" }}">{{ check.computed_end_time|time:"H:i" }}</time>
</p>
{% endif %}
{% endfor %}
</div>
{% endif %}
{% endif %}
</div>
</section>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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,25 @@
</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 %}
<h3>{% trans "Navigation" %}</h3>
{% if show_history %}
<a class="button button-paragraph" href="{% url 'chrono-manager-unavailability-calendar-history' pk=unavailability_calendar.pk %}">{% trans 'History' %}</a>
{% endif %}
<a class="button button-paragraph" href="{% url 'chrono-manager-unavailability-calendar-inspect' pk=unavailability_calendar.pk %}">{% trans 'Inspect' %}</a>
{% url 'chrono-manager-unavailability-calendar-list' as object_list_url %}
{% include 'chrono/includes/application_detail_fragment.html' %}
</aside>
{% endblock %}

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