diff --git a/.sanitizerconfig b/.sanitizerconfig index fc48ac74b..b2de98372 100644 --- a/.sanitizerconfig +++ b/.sanitizerconfig @@ -372,7 +372,7 @@ strategy: name_fi: null name_sv: null need_manual_confirmation: null - people_capacity: null + people_capacity_lower: null public: null reservable: null reservable_days_in_advance: null diff --git a/locale/fi/LC_MESSAGES/django.po b/locale/fi/LC_MESSAGES/django.po index 025e92b8b..5871022bd 100644 --- a/locale/fi/LC_MESSAGES/django.po +++ b/locale/fi/LC_MESSAGES/django.po @@ -1180,6 +1180,9 @@ msgstr "Tunnistautuminen" msgid "People capacity" msgstr "Henkilömäärä" +msgid "People capacity upper limit" +msgstr "Maksimihenkilömäärä" + msgid "Area (m2)" msgstr "Pinta-ala (m2)" @@ -2202,3 +2205,21 @@ msgstr "Takaisin" #, python-brace-format msgid "Missing information required by SAP: {}" msgstr "SAP:n vaatimia tietoja puuttuu: {}" + +msgid "Reservation reminder" +msgstr "Muistutus varauksesta" + +msgid "Inactive purposes are not shown in the Respa admin." +msgstr "Jos käyttötarkoitus ei ole aktiivinen, sitä ei näytetä Respan adminissa." + +msgid "Inactive terms are not shown in the Respa admin." +msgstr "Jos ehto ei ole aktiivinen, sitä ei näytetä Respan adminissa." + +msgid "Inactive resource types are not shown in the Respa admin." +msgstr "Jos resurssityyppi ei ole aktiivinen, sitä ei näytetä Respan adminissa." + +msgid "Inactive metadata sets are not shown in the resource form in Respa admin." +msgstr "Jos metatietojoukko ei ole aktiivinen, sitä ei näytetä resurssin lomakkeessa Respan adminissa." + +msgid "Inactive equipment is not shown in the Respa admin." +msgstr "Jos varuste ei ole aktiivinen, sitä ei näytetä Respan adminissa." diff --git a/locale/sv/LC_MESSAGES/django.po b/locale/sv/LC_MESSAGES/django.po index e9b50336a..c3d04105f 100644 --- a/locale/sv/LC_MESSAGES/django.po +++ b/locale/sv/LC_MESSAGES/django.po @@ -1050,6 +1050,9 @@ msgstr "Autentisering" msgid "People capacity" msgstr "Kapacitet" +msgid "People capacity upper limit" +msgstr "Maximal kapacitet" + msgid "Area (m2)" msgstr "Yta (m2)" @@ -1789,3 +1792,21 @@ msgid "" msgstr "" "anger om användaren är en General Administrator med särskilda behörigheter " "för många objekt inom Respa" + +msgid "Reservation reminder" +msgstr "Bokningspåminnelse" + +msgid "Inactive purposes are not shown in the Respa admin." +msgstr "Inaktiva syften visas inte i Respa administratören." + +msgid "Inactive terms are not shown in the Respa admin." +msgstr "Inaktiva villkor visas inte i Respa administratören." + +msgid "Inactive resource types are not shown in the Respa admin." +msgstr "Inaktiva resurstyper visas inte i Respa administratören." + +msgid "Inactive metadata sets are not shown in the resource form in Respa admin." +msgstr "Inaktiva metadatauppsättningar visas inte i resursformuläret i Respa administratören." + +msgid "Inactive equipment is not shown in the Respa admin." +msgstr "Inaktiva utrustningar visas inte i Respa administratören." diff --git a/notifications/management/__init__.py b/notifications/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/notifications/management/commands/__init__.py b/notifications/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/notifications/management/commands/send_reservation_reminders.py b/notifications/management/commands/send_reservation_reminders.py new file mode 100644 index 000000000..c1797890c --- /dev/null +++ b/notifications/management/commands/send_reservation_reminders.py @@ -0,0 +1,35 @@ +import logging +from datetime import timedelta + +from django.core.management import BaseCommand +from django.utils import timezone + +from resources.models import Reservation + +logger = logging.getLogger(__name__) + + +def send_reservation_reminder(reservation): + reservation.send_reservation_mail(notification_type="reservation_reminder") + reservation.reminder_sent = True + reservation.save() + + +class Command(BaseCommand): + help = "Send reservation reminders" + + def handle(self, *args, **options): + """ + Send reservation reminders for reservations that start in the next 24 hours. + """ + + reservations = Reservation.objects.filter( + begin__gte=timezone.now(), + begin__lte=timezone.now() + timedelta(days=1), + reminder_sent=False, + ) + + logger.info(f"Sending reminders for {reservations.count()} reservations") + + for reservation in reservations: + send_reservation_reminder(reservation) diff --git a/notifications/migrations/0012_add_reservation_reminder_type.py b/notifications/migrations/0012_add_reservation_reminder_type.py new file mode 100644 index 000000000..4a83285d2 --- /dev/null +++ b/notifications/migrations/0012_add_reservation_reminder_type.py @@ -0,0 +1,71 @@ +# Generated by Django 2.2.11 on 2024-12-19 08:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("notifications", "0011_create_invoice_requested_notifications"), + ] + + operations = [ + migrations.AlterField( + model_name="notificationtemplate", + name="type", + field=models.CharField( + choices=[ + ("reservation_requested", "Reservation requested"), + ( + "reservation_requested_official", + "Reservation requested official", + ), + ("reservation_requested", "Reservation invoice requested"), + ( + "reservation_requested_official", + "Reservation invoice requested official", + ), + ("reservation_cancelled", "Reservation cancelled"), + ( + "reservation_cancelled_official", + "Reservation cancelled official", + ), + ("reservation_confirmed", "Reservation confirmed"), + ( + "reservation_confirmed_official", + "Reservation confirmed official", + ), + ("reservation_created", "Reservation created"), + ("reservation_changed", "Reservation changed"), + ("reservation_denied", "Reservation denied"), + ("reservation_denied_official", "Reservation denied official"), + ( + "reservation_created_with_access_code", + "Reservation created with access code", + ), + ( + "reservation_access_code_created", + "Access code was created for a reservation", + ), + ("paid_reservation_approved", "Paid reservation approved"), + ( + "paid_reservation_approved_official", + "Paid reservation approved official", + ), + ("catering_order_created", "Catering order created"), + ("catering_order_modified", "Catering order modified"), + ("catering_order_deleted", "Catering order deleted"), + ("reservation_comment_created", "Reservation comment created"), + ( + "catering_order_comment_created", + "Catering order comment created", + ), + ("reservation_reminder", "Reservation reminder"), + ], + db_index=True, + max_length=100, + unique=True, + verbose_name="Type", + ), + ), + ] diff --git a/notifications/migrations/0013_create_reservation_reminder_notification.py b/notifications/migrations/0013_create_reservation_reminder_notification.py new file mode 100644 index 000000000..3283f8b55 --- /dev/null +++ b/notifications/migrations/0013_create_reservation_reminder_notification.py @@ -0,0 +1,92 @@ +# Generated by Django 2.2.11 on 2024-12-19 08:35 + +from django.db import migrations +from django.template import Context, Template + +BODY_TEMPLATES = { + "en": """ +Hello, + +{{ message }} + +{% verbatim %} +Resource: {{ resource }} +Unit: {{ unit }} +Starts: {{ begin }} +Ends: {{ end }} +{% endverbatim %} + +This is an automated message, please don't reply to this. + +Best regards, +Varaamo +""", + "fi": """ +Hei, + +{{ message }} + +{% verbatim %} +Tila: {{ resource }} +Toimipiste: {{ unit }} +Alkaa: {{ begin }} +Päättyy: {{ end }} +{% endverbatim %} + +Tämä on automaattinen viesti, joten ethän vastaa tähän. + +Ystävällisin terveisin, +Varaamo +""", +} + + +def forwards(apps, schema_editor): + NotificationTemplate = apps.get_model("notifications", "NotificationTemplate") + NotificationTemplateTranslation = apps.get_model( + "notifications", + "NotificationTemplateTranslation", + ) + + notification, created = NotificationTemplate.objects.get_or_create( + type="reservation_reminder" + ) + if created: + translations = [] + for language, subject, message in [ + ( + "en", + "Upcoming reservation", + "You have a reservation coming up soon:", + ), + ( + "fi", + "Muistathan lähestyvän varauksesi", + "Sinulla on pian varaus:", + ), + ]: + body = Template(BODY_TEMPLATES[language]).render( + Context( + { + "message": message, + } + ) + ) + translations.append( + NotificationTemplateTranslation( + master_id=notification.id, + language_code=language, + subject=subject, + body=body, + ) + ) + NotificationTemplateTranslation.objects.bulk_create(translations) + + +class Migration(migrations.Migration): + + dependencies = [ + ("notifications", "0012_add_reservation_reminder_type"), + ] + + operations = [migrations.RunPython(forwards, migrations.RunPython.noop)] diff --git a/notifications/models.py b/notifications/models.py index 025424998..f4085486f 100644 --- a/notifications/models.py +++ b/notifications/models.py @@ -46,6 +46,8 @@ class NotificationType: RESERVATION_COMMENT_CREATED = "reservation_comment_created" CATERING_ORDER_COMMENT_CREATED = "catering_order_comment_created" + RESERVATION_REMINDER = "reservation_reminder" + class NotificationTemplateException(Exception): pass @@ -107,6 +109,7 @@ class NotificationTemplate(TranslatableModel): NotificationType.CATERING_ORDER_COMMENT_CREATED, _("Catering order comment created"), ), + (NotificationType.RESERVATION_REMINDER, _("Reservation reminder")), ) type = models.CharField( diff --git a/notifications/tests/test_send_reservation_reminders.py b/notifications/tests/test_send_reservation_reminders.py new file mode 100644 index 000000000..1a67ed2ad --- /dev/null +++ b/notifications/tests/test_send_reservation_reminders.py @@ -0,0 +1,69 @@ +from datetime import timedelta +from unittest import mock + +import pytest +from django.core.management import call_command +from django.utils import timezone + +from resources.models import Reservation, Resource, ResourceType, Unit + + +@pytest.fixture +def reservation(): + resource_type = ResourceType.objects.get_or_create(main_type="space", name="space")[0] + unit = Unit.objects.create(name="Test unit") + resource = Resource.objects.create( + name="Test resource", + type=resource_type, + unit=unit, + ) + reservation = Reservation.objects.create( + resource=resource, + begin=timezone.now() + timedelta(hours=23), + end=timezone.now() + timedelta(hours=24), + reminder_sent=False, + ) + return reservation + + +@pytest.mark.django_db +def test_send_reservation_reminder(reservation): + """ + Test that the reminder is sent and the reminder_sent field is updated. + """ + with mock.patch( + "resources.models.Reservation.send_reservation_mail" + ) as mock_send_mail: + call_command("send_reservation_reminders") + reservation.refresh_from_db() + assert reservation.reminder_sent is True + mock_send_mail.assert_called_once_with(notification_type="reservation_reminder") + + +@pytest.mark.django_db +def test_no_reminder_if_already_sent(reservation): + """ + Test that the reminder is not sent if it has already been sent. + """ + reservation.reminder_sent = True + reservation.save() + with mock.patch( + "resources.models.Reservation.send_reservation_mail" + ) as mock_send_mail: + call_command("send_reservation_reminders") + mock_send_mail.assert_not_called() + + +@pytest.mark.django_db +def test_no_reminder_for_reservation_not_within_24_hours(reservation): + """ + Test that the reminder is not sent if the reservation is not in the next 24 hours. + """ + reservation.begin = timezone.now() + timedelta(days=2) + reservation.end = timezone.now() + timedelta(days=2, hours=1) + reservation.save() + with mock.patch( + "notifications.management.commands.send_reservation_reminders.send_reservation_reminder" + ) as mock_send_reminder: + call_command("send_reservation_reminders") + mock_send_reminder.assert_not_called() diff --git a/openapi.yaml b/openapi.yaml index 3b28da599..21d4fd633 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -526,7 +526,7 @@ paths: in: query description: Order queryset by given resource fields, accepted values are `resource_name_fi`, `resource_name_en`, `resource_name_sv`, `unit_name_fi`, - `unit_name_en`, `unit_name_sv`, `type`, `people_capacity`. Prefix parameter + `unit_name_en`, `unit_name_sv`, `type`, `people_capacity_lower`. Prefix parameter value with `-` to get reverse ordering. schema: type: string @@ -1043,7 +1043,7 @@ components: authentication: type: string description: The type of authentication required to reserve the resource - people_capacity: + people_capacity_lower: type: number description: The maximum number of people for the resource area: diff --git a/payments/api/reservation.py b/payments/api/reservation.py index 5357be2f4..c86b20ef9 100644 --- a/payments/api/reservation.py +++ b/payments/api/reservation.py @@ -52,10 +52,10 @@ def create(self, validated_data): order_line_data["total_price"] = total_price order_line_data["unit_price"] = Decimal(order_line_price_info["amount"]) + order_line_data["tax_percentage"] = order_line_price_info["tax_percentage"] price_source = order_line_price_info["price_source"] - order_line_data["tax_percentage"] = price_source.tax_percentage order_line_data["price_period"] = price_source.price_period order_line_data["price_type"] = price_source.price_type diff --git a/resources/api/resource.py b/resources/api/resource.py index 906011019..d990094e2 100644 --- a/resources/api/resource.py +++ b/resources/api/resource.py @@ -198,6 +198,7 @@ class ResourceSerializer( ExtraDataMixin, TranslatedModelSerializer, munigeo_api.GeoModelSerializer ): purposes = PurposeSerializer(many=True) + people_capacity = serializers.IntegerField(source='people_capacity_lower') images = NestedResourceImageSerializer(many=True) equipment = ResourceEquipmentSerializer( many=True, read_only=True, source="resource_equipment" @@ -676,9 +677,7 @@ def __init__(self, *args, **kwargs): type = django_filters.Filter( field_name="type__id", lookup_expr="in", widget=django_filters.widgets.CSVWidget ) - people = django_filters.NumberFilter( - field_name="people_capacity", lookup_expr="gte" - ) + people = django_filters.NumberFilter(method='filter_people_capacity') need_manual_confirmation = django_filters.BooleanFilter( field_name="need_manual_confirmation", widget=DRFFilterBooleanWidget ) @@ -721,11 +720,16 @@ def __init__(self, *args, **kwargs): ("type__name_fi", "type_name_fi"), ("type__name_en", "type_name_en"), ("type__name_sv", "type_name_sv"), - ("people_capacity", "people_capacity"), + ("people_capacity_lower", "people_capacity_lower"), ("accessibility_priority", "accessibility"), ), ) + def filter_people_capacity(self, queryset, name, value): + return queryset.filter( + Q(people_capacity_lower__gte=value) | Q(people_capacity_upper__gte=value) + ) + def filter_is_favorite(self, queryset, name, value): if not self.user.is_authenticated: if value: diff --git a/resources/importer/kirjasto10.py b/resources/importer/kirjasto10.py index 80101d9f1..38ea16571 100644 --- a/resources/importer/kirjasto10.py +++ b/resources/importer/kirjasto10.py @@ -94,9 +94,9 @@ def import_resources(self): except ValueError: area = None try: - people_capacity = int(res_data['Max henkilömäärä']) + people_capacity_lower = int(res_data['Max henkilömäärä']) except ValueError: - people_capacity = None + people_capacity_lower = None try: min_period = datetime.timedelta(minutes=int(60 * float(res_data['Varausaika min'].replace(',', '.')))) except ValueError: @@ -119,7 +119,7 @@ def import_resources(self): data = dict( unit_id=unit.pk, - people_capacity=people_capacity, + people_capacity_lower=people_capacity_lower, area=area, need_manual_confirmation=confirm, min_period=min_period, diff --git a/resources/migrations/0122_auto_20241218_1457.py b/resources/migrations/0122_auto_20241218_1457.py new file mode 100644 index 000000000..11c0990b5 --- /dev/null +++ b/resources/migrations/0122_auto_20241218_1457.py @@ -0,0 +1,17 @@ +# Generated by Django 2.2.11 on 2024-12-18 12:57 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('resources', '0121_auto_20231218_1250'), + ] + + operations = [ + migrations.AlterModelOptions( + name='resourceaccess', + options={'verbose_name': 'Resource access method', 'verbose_name_plural': 'Resource access methods'}, + ), + ] diff --git a/resources/migrations/0123_reservation_reminder_sent.py b/resources/migrations/0123_reservation_reminder_sent.py new file mode 100644 index 000000000..5c0fd115a --- /dev/null +++ b/resources/migrations/0123_reservation_reminder_sent.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.11 on 2024-12-18 13:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('resources', '0122_auto_20241218_1457'), + ] + + operations = [ + migrations.AddField( + model_name='reservation', + name='reminder_sent', + field=models.BooleanField(default=False, verbose_name='Reminder sent'), + ), + ] diff --git a/resources/migrations/0124_purpose_active.py b/resources/migrations/0124_purpose_active.py new file mode 100644 index 000000000..93de82ace --- /dev/null +++ b/resources/migrations/0124_purpose_active.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.11 on 2025-01-07 13:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('resources', '0123_reservation_reminder_sent'), + ] + + operations = [ + migrations.AddField( + model_name='purpose', + name='active', + field=models.BooleanField(default=True, help_text='Inactive purposes are not shown in the Respa admin.', verbose_name='Active'), + ), + ] diff --git a/resources/migrations/0125_termsofuse_active.py b/resources/migrations/0125_termsofuse_active.py new file mode 100644 index 000000000..40546f1f1 --- /dev/null +++ b/resources/migrations/0125_termsofuse_active.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.11 on 2025-01-08 07:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('resources', '0124_purpose_active'), + ] + + operations = [ + migrations.AddField( + model_name='termsofuse', + name='active', + field=models.BooleanField(default=True, help_text='Inactive terms are not shown in the Respa admin.', verbose_name='Active'), + ), + ] diff --git a/resources/migrations/0126_resourcetype_active.py b/resources/migrations/0126_resourcetype_active.py new file mode 100644 index 000000000..72dfb67e1 --- /dev/null +++ b/resources/migrations/0126_resourcetype_active.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.11 on 2025-01-08 08:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('resources', '0125_termsofuse_active'), + ] + + operations = [ + migrations.AddField( + model_name='resourcetype', + name='active', + field=models.BooleanField(default=True, help_text='Inactive resource types are not shown in the Respa admin.', verbose_name='Active'), + ), + ] diff --git a/resources/migrations/0127_reservationmetadataset_active.py b/resources/migrations/0127_reservationmetadataset_active.py new file mode 100644 index 000000000..218ab2517 --- /dev/null +++ b/resources/migrations/0127_reservationmetadataset_active.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.11 on 2025-01-08 08:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('resources', '0126_resourcetype_active'), + ] + + operations = [ + migrations.AddField( + model_name='reservationmetadataset', + name='active', + field=models.BooleanField(default=True, help_text='Inactive metadata sets are not shown in the resource form in Respa admin.', verbose_name='Active'), + ), + ] diff --git a/resources/migrations/0128_equipment_active.py b/resources/migrations/0128_equipment_active.py new file mode 100644 index 000000000..c2af0bec9 --- /dev/null +++ b/resources/migrations/0128_equipment_active.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.11 on 2025-01-08 11:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('resources', '0127_reservationmetadataset_active'), + ] + + operations = [ + migrations.AddField( + model_name='equipment', + name='active', + field=models.BooleanField(default=True, help_text='Inactive equipment is not shown in the Respa admin.', verbose_name='Active'), + ), + ] diff --git a/resources/migrations/0129_rename_people_capacity.py b/resources/migrations/0129_rename_people_capacity.py new file mode 100644 index 000000000..0aca609e7 --- /dev/null +++ b/resources/migrations/0129_rename_people_capacity.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.11 on 2025-01-09 10:15 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('resources', '0128_equipment_active'), + ] + + operations = [ + migrations.RenameField( + model_name='resource', + old_name='people_capacity', + new_name='people_capacity_lower', + ), + ] diff --git a/resources/migrations/0130_resource_people_capacity_upper.py b/resources/migrations/0130_resource_people_capacity_upper.py new file mode 100644 index 000000000..b0dc3b614 --- /dev/null +++ b/resources/migrations/0130_resource_people_capacity_upper.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.11 on 2025-01-09 11:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('resources', '0129_rename_people_capacity'), + ] + + operations = [ + migrations.AddField( + model_name='resource', + name='people_capacity_upper', + field=models.PositiveIntegerField(blank=True, null=True, verbose_name='People capacity upper limit'), + ), + ] diff --git a/resources/models/equipment.py b/resources/models/equipment.py index 659622560..87f04fbfc 100644 --- a/resources/models/equipment.py +++ b/resources/models/equipment.py @@ -24,6 +24,11 @@ class Equipment(ModifiableModel, AutoIdentifiedModel): name = models.CharField(verbose_name=_('Name'), max_length=200) category = models.ForeignKey(EquipmentCategory, verbose_name=_('Category'), related_name='equipment', on_delete=models.CASCADE) + active = models.BooleanField( + default=True, + verbose_name=_('Active'), + help_text=_('Inactive equipment is not shown in the Respa admin.') + ) class Meta: verbose_name = pgettext_lazy('singular', 'equipment') diff --git a/resources/models/reservation.py b/resources/models/reservation.py index df14a0dac..3b4e42a54 100644 --- a/resources/models/reservation.py +++ b/resources/models/reservation.py @@ -193,6 +193,7 @@ class Reservation(ModifiableModel): state = models.CharField( max_length=32, choices=STATE_CHOICES, verbose_name=_("State"), default=CREATED ) + reminder_sent = models.BooleanField(default=False, verbose_name=_("Reminder sent")) approver = models.ForeignKey( settings.AUTH_USER_MODEL, verbose_name=_("Approver"), @@ -709,7 +710,7 @@ def clean(self, **kwargs): valid_reservations_for_same_unit = reservations_for_same_unit.exclude( state=Reservation.CANCELLED ) - # Exclude current reservation from the valid_reservations_for_same_unit list to allow user to + # Exclude current reservation from the valid_reservations_for_same_unit list to allow user to # update reservation if new times overlaps only with the original times of the same reservation original_reservation = kwargs.get("original_reservation", None) if original_reservation: @@ -1026,6 +1027,11 @@ class ReservationMetadataSet(ModifiableModel): related_name="metadata_sets_required", blank=True, ) + active = models.BooleanField( + default=True, + verbose_name=_("Active"), + help_text=_("Inactive metadata sets are not shown in the resource form in Respa admin."), + ) class Meta: verbose_name = _("Reservation metadata set") diff --git a/resources/models/resource.py b/resources/models/resource.py index 7d15a83e2..3e9f4737c 100644 --- a/resources/models/resource.py +++ b/resources/models/resource.py @@ -93,6 +93,11 @@ class ResourceType(ModifiableModel, AutoIdentifiedModel): verbose_name=_("Main type"), max_length=20, choices=MAIN_TYPES ) name = models.CharField(verbose_name=_("Name"), max_length=200) + active = models.BooleanField( + default=True, + verbose_name=_("Active"), + help_text=_("Inactive resource types are not shown in the Respa admin.") + ) class Meta: verbose_name = _("resource type") @@ -115,6 +120,10 @@ class Purpose(ModifiableModel, NameIdentifiedModel): ) name = models.CharField(verbose_name=_("Name"), max_length=200) public = models.BooleanField(default=True, verbose_name=_("Public")) + active = models.BooleanField( + default=True, + verbose_name=_("Active"), + help_text=_("Inactive purposes are not shown in the Respa admin.")) class Meta: verbose_name = _("purpose") @@ -144,6 +153,11 @@ class TermsOfUse(ModifiableModel, AutoIdentifiedModel): choices=TERMS_TYPES, default=TERMS_TYPE_GENERIC, ) + active = models.BooleanField( + default=True, + verbose_name=_("Active"), + help_text=_("Inactive terms are not shown in the Respa admin.") + ) class Meta: verbose_name = pgettext_lazy("singular", "terms of use") @@ -318,9 +332,12 @@ class Resource(ModifiableModel, AutoIdentifiedModel): max_length=20, choices=AUTHENTICATION_TYPES, ) - people_capacity = models.PositiveIntegerField( + people_capacity_lower = models.PositiveIntegerField( verbose_name=_("People capacity"), null=True, blank=True ) + people_capacity_upper = models.PositiveIntegerField( + verbose_name=_("People capacity upper limit"), null=True, blank=True + ) area = models.PositiveIntegerField( verbose_name=_("Area (m2)"), null=True, blank=True ) diff --git a/resources/tests/test_resource_api.py b/resources/tests/test_resource_api.py index f1f5d0581..877b2fcf0 100644 --- a/resources/tests/test_resource_api.py +++ b/resources/tests/test_resource_api.py @@ -1487,34 +1487,34 @@ def test_order_by_filter(list_url, api_client, resource_in_unit, resource_in_uni assert response.data["results"][0]["type"]["id"] == resource_in_unit2.type.id # test resource people capacity - resource_in_unit.people_capacity = 1 + resource_in_unit.people_capacity_lower = 1 resource_in_unit.save() - resource_in_unit2.people_capacity = 50 + resource_in_unit2.people_capacity_lower = 50 resource_in_unit2.save() - response = api_client.get("%s?order_by=people_capacity" % list_url) + response = api_client.get("%s?order_by=people_capacity_lower" % list_url) assert response.status_code == 200 assert_response_objects(response, [resource_in_unit, resource_in_unit2]) assert ( - response.data["results"][0]["people_capacity"] - == resource_in_unit.people_capacity + response.data["results"][0]["people_capacity_lower"] + == resource_in_unit.people_capacity_lower ) assert ( - response.data["results"][1]["people_capacity"] - == resource_in_unit2.people_capacity + response.data["results"][1]["people_capacity_lower"] + == resource_in_unit2.people_capacity_lower ) - response = api_client.get("%s?order_by=-people_capacity" % list_url) + response = api_client.get("%s?order_by=-people_capacity_lower" % list_url) assert response.status_code == 200 assert_response_objects(response, [resource_in_unit, resource_in_unit2]) assert ( - response.data["results"][1]["people_capacity"] - == resource_in_unit.people_capacity + response.data["results"][1]["people_capacity_lower"] + == resource_in_unit.people_capacity_lower ) assert ( - response.data["results"][0]["people_capacity"] - == resource_in_unit2.people_capacity + response.data["results"][0]["people_capacity_lower"] + == resource_in_unit2.people_capacity_lower ) @@ -1645,3 +1645,30 @@ def test_query_counts( with django_assert_max_num_queries(MAX_QUERIES): staff_api_client.get(list_url) + +@pytest.mark.django_db +def test_filter_people_capacity(api_client, resource_in_unit, resource_in_unit2, resource_in_unit3): + resource_in_unit.people_capacity_lower = 10 + resource_in_unit.people_capacity_upper = 20 + resource_in_unit.save() + + resource_in_unit2.people_capacity_lower = 15 + resource_in_unit2.people_capacity_upper = 25 + resource_in_unit2.save() + + resource_in_unit3.people_capacity_lower = 20 + resource_in_unit3.people_capacity_upper = None + resource_in_unit3.save() + + response = api_client.get(reverse("resource-list") + "?people=15") + assert response.status_code == 200 + assert len(response.data["results"]) == 3 + + response = api_client.get(reverse("resource-list") + "?people=22") + assert response.status_code == 200 + assert len(response.data["results"]) == 1 + assert response.data["results"][0]["id"] == resource_in_unit2.id + + response = api_client.get(reverse("resource-list") + "?people=50") + assert response.status_code == 200 + assert len(response.data["results"]) == 0 diff --git a/respa_admin/forms.py b/respa_admin/forms.py index 06ef9fc21..12f44bfd8 100644 --- a/respa_admin/forms.py +++ b/respa_admin/forms.py @@ -10,10 +10,12 @@ Equipment, Period, Purpose, + ReservationMetadataSet, Resource, ResourceAccess, ResourceAccessibility, ResourceImage, + ResourceType, TermsOfUse, Unit, UnitAuthorization, @@ -173,14 +175,14 @@ class Meta: class ResourceForm(forms.ModelForm): purposes = forms.ModelMultipleChoiceField( widget=RespaCheckboxSelect, - queryset=Purpose.objects.all(), + queryset=Purpose.objects.filter(active=True), required=True, ) equipment = forms.ModelMultipleChoiceField( required=False, widget=RespaCheckboxSelect, - queryset=Equipment.objects.all(), + queryset=Equipment.objects.filter(active=True), ) name_fi = forms.CharField( @@ -238,7 +240,8 @@ class Meta: "equipment", "access_methods", "external_reservation_url", - "people_capacity", + "people_capacity_lower", + "people_capacity_upper", "area", "min_period", "max_period", @@ -296,11 +299,14 @@ class Meta: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self.fields["type"].queryset = ResourceType.objects.filter(active=True) self.fields["generic_terms"].queryset = TermsOfUse.objects.filter( - terms_type=TermsOfUse.TERMS_TYPE_GENERIC + terms_type=TermsOfUse.TERMS_TYPE_GENERIC, + active=True, ) self.fields["payment_terms"].queryset = TermsOfUse.objects.filter( - terms_type=TermsOfUse.TERMS_TYPE_PAYMENT + terms_type=TermsOfUse.TERMS_TYPE_PAYMENT, + active=True, ) self.fields["authentication"].choices = [ choice @@ -308,6 +314,9 @@ def __init__(self, *args, **kwargs): if choice[0] not in ["", "none"] ] self.fields["authentication"].initial = ["weak"] + self.fields["reservation_metadata_set"].queryset = ( + ReservationMetadataSet.objects.filter(active=True) + ) def clean_notification_email_addresses(self): notification_email_addresses = self.cleaned_data["notification_email_addresses"] diff --git a/respa_admin/templates/respa_admin/resources/form/_booking.html b/respa_admin/templates/respa_admin/resources/form/_booking.html index 1cc9b7934..336f7ef2f 100644 --- a/respa_admin/templates/respa_admin/resources/form/_booking.html +++ b/respa_admin/templates/respa_admin/resources/form/_booking.html @@ -6,7 +6,7 @@

{% trans "Booking information" %}

{% trans "*Mandatory fields" %}
- {% include "respa_admin/forms/_input.html" with field=form.people_capacity %} + {% include "respa_admin/forms/_input.html" with field=form.people_capacity_lower %} {% include "respa_admin/forms/_input.html" with field=form.area %}
-
-

{% trans "Purposes" %}

- {% include "respa_admin/forms/_errors.html" with field=form.purposes %} - {{ form.purposes }} -

{% trans "Name and description" %}

{% include "respa_admin/forms/_input.html" with field=form.name_fi %} @@ -24,6 +19,11 @@

{% trans "Name and description" %}

{% include "respa_admin/forms/_textarea_input.html" with field=form.description_en %} {% include "respa_admin/forms/_textarea_input.html" with field=form.description_sv %}
+
+

{% trans "Purposes" %}

+ {% include "respa_admin/forms/_errors.html" with field=form.purposes %} + {{ form.purposes }} +

{% trans "Contact person" %}

{% include "respa_admin/forms/_textarea_input.html" with field=form.responsible_contact_info_fi %} diff --git a/respa_admin/tests/conftest.py b/respa_admin/tests/conftest.py index 8260e23ce..9cc20deea 100644 --- a/respa_admin/tests/conftest.py +++ b/respa_admin/tests/conftest.py @@ -90,7 +90,7 @@ "purposes": "", "equipment": "", "responsible_contact_info": "", - "people_capacity": "", + "people_capacity_lower": "", "area": "", "min_period": "", "max_period": "", diff --git a/respa_admin/tests/test_resource_forms.py b/respa_admin/tests/test_resource_forms.py index 253963ff6..574b14468 100644 --- a/respa_admin/tests/test_resource_forms.py +++ b/respa_admin/tests/test_resource_forms.py @@ -6,9 +6,11 @@ from django.utils import translation from freezegun import freeze_time -from resources.models import Resource +from resources.models import ( + Equipment, EquipmentCategory, Purpose, ReservationMetadataSet, Resource, ResourceType, TermsOfUse +) -from ..forms import get_period_formset +from ..forms import ResourceForm, get_period_formset NEW_RESOURCE_URL = reverse_lazy("respa_admin:new-resource") @@ -236,3 +238,101 @@ def test_editing_resource_via_form_view(admin_client, valid_resource_form_data): edited_resource = Resource.objects.first() assert edited_resource.name_fi == "Edited name" assert resource.name_fi != edited_resource.name + + +@pytest.mark.django_db +def test_only_active_purposes_are_visible(): + active_purpose = Purpose.objects.create(name="Active Purpose", active=True) + inactive_purpose = Purpose.objects.create(name="Inactive Purpose", active=False) + + form = ResourceForm() + purposes_field = form.fields['purposes'] + + assert list(purposes_field.queryset) == [active_purpose] + assert inactive_purpose not in purposes_field.queryset + + +@pytest.mark.django_db +def test_only_active_terms_of_use_are_visible(): + active_generic_terms = TermsOfUse.objects.create( + name="Active Generic Terms", + terms_type=TermsOfUse.TERMS_TYPE_GENERIC, + active=True) + active_payment_terms = TermsOfUse.objects.create( + name="Active Payment Terms", + terms_type=TermsOfUse.TERMS_TYPE_PAYMENT, + active=True) + inactive_generic_terms = TermsOfUse.objects.create( + name="Inactive Generic Terms", + terms_type=TermsOfUse.TERMS_TYPE_GENERIC, + active=False) + inactive_payment_terms = TermsOfUse.objects.create( + name="Inactive Payment Terms", + terms_type=TermsOfUse.TERMS_TYPE_PAYMENT, + active=False) + + form = ResourceForm() + generic_terms_field = form.fields['generic_terms'] + payment_terms_field = form.fields['payment_terms'] + + assert list(generic_terms_field.queryset) == [active_generic_terms] + assert inactive_generic_terms not in generic_terms_field.queryset + + assert list(payment_terms_field.queryset) == [active_payment_terms] + assert inactive_payment_terms not in payment_terms_field.queryset + + +@pytest.mark.django_db +def test_only_active_resource_types_are_visible(): + active_resource_type = ResourceType.objects.create( + name="Active space", + main_type="space", + active=True) + inactive_resource_type = ResourceType.objects.create( + name="Inactive space", + main_type="space", + active=False) + + form = ResourceForm() + resource_type_field = form.fields['type'] + + assert list(resource_type_field.queryset) == [active_resource_type] + assert inactive_resource_type not in resource_type_field.queryset + + +@pytest.mark.django_db +def test_only_reservation_metadata_sets_are_visible(): + active_metadata_set = ReservationMetadataSet.objects.create( + name="Active metadata set", + active=True) + inactive_metadata_set = ReservationMetadataSet.objects.create( + name="Inactive metadata set", + active=False) + + form = ResourceForm() + metadata_set_field = form.fields['reservation_metadata_set'] + + assert list(metadata_set_field.queryset) == [active_metadata_set] + assert inactive_metadata_set not in metadata_set_field.queryset + + +@pytest.mark.django_db +def test_only_active_equipments_are_visible(): + category = EquipmentCategory.objects.create( + id="category", + name="Category" + ) + active_equipment = Equipment.objects.create( + name="Active equipment", + category=category, + active=True) + inactive_equipment = Equipment.objects.create( + name="Inactive equipment", + category=category, + active=False) + + form = ResourceForm() + equipment_field = form.fields['equipment'] + + assert list(equipment_field.queryset) == [active_equipment] + assert inactive_equipment not in equipment_field.queryset