diff --git a/docs/pr-3-edit-booking-dates.md b/docs/pr-3-edit-booking-dates.md new file mode 100644 index 000000000..b56ec8a17 --- /dev/null +++ b/docs/pr-3-edit-booking-dates.md @@ -0,0 +1,53 @@ +# PR 3 - Edición de Fechas de Reserva + +## Objetivo de negocio +Habilitar la edición de fechas de una reserva ya creada, manteniendo reglas de disponibilidad del inventario y evitando conflictos operativos por solapamientos. + +## Alcance implementado +- Se agregó un nuevo acceso en Home para **Editar fechas** por reserva. +- Se implementó una nueva pantalla dedicada para editar `checkin` y `checkout`. +- Se agregó validación de disponibilidad sobre la habitación actual antes de persistir cambios. +- Se incorporó recálculo automático del total de la reserva tras modificar fechas. +- Se incluyó validación de rango máximo permitido para reservas. + +## Cambios funcionales y técnicos +1. **Navegación y routing** +- Nueva ruta para edición de fechas: + - `booking//edit-dates`. +- Integración del nuevo acceso desde la lista de reservas. + +2. **Lógica de aplicación** +- Se creó una vista específica para editar fechas, separada del flujo de edición de datos de contacto. +- Validaciones principales: + - `checkout > checkin`. + - límite de fechas permitido hasta `31/12/2026`. + - bloqueo por solape en la misma habitación excluyendo la reserva en edición. +- Mensaje de negocio mostrado cuando no hay disponibilidad: + - **“No hay disponibilidad para las fechas seleccionadas”**. + +3. **Form y consistencia de datos** +- Se creó un formulario específico para edición de fechas. +- Se asegura render ISO en inputs de fecha (`YYYY-MM-DD`) para coherencia entre textbox y datepicker. + +4. **Calidad** +- Se agregaron pruebas automatizadas para cubrir: + - edición exitosa con disponibilidad, + - render correcto de valores de fecha, + - rechazo por solape, + - rechazo por rango inválido. + +## Archivos modificados +- `pms/forms.py` +- `pms/views.py` +- `pms/urls.py` +- `pms/templates/home.html` +- `pms/templates/edit_booking_dates.html` +- `pms/tests.py` + +## Pruebas ejecutadas +- `python manage.py test pms.tests.EditBookingDatesTests` + +## Resultado esperado para el producto +- Mayor flexibilidad operativa para atención al cliente post-reserva. +- Menor riesgo de sobreventa por validación consistente de disponibilidad. +- Mejora de confiabilidad del dato financiero al recalcular importe tras cambios de fechas. diff --git a/pms/forms.py b/pms/forms.py index f1bc68d08..820c7f99b 100644 --- a/pms/forms.py +++ b/pms/forms.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import date, datetime from django import forms from django.forms import ModelForm @@ -56,3 +56,46 @@ class Meta: 'total': forms.HiddenInput(), 'state': forms.HiddenInput(), } + + +class BookingDatesEditForm(forms.Form): + checkin = forms.DateField( + label="Fecha de entrada", + input_formats=["%Y-%m-%d"], + widget=forms.DateInput( + format="%Y-%m-%d", + attrs={ + "type": "date", + "max": date(2026, 12, 31).strftime("%Y-%m-%d"), + "class": "form-control", + } + ), + ) + checkout = forms.DateField( + label="Fecha de salida", + input_formats=["%Y-%m-%d"], + widget=forms.DateInput( + format="%Y-%m-%d", + attrs={ + "type": "date", + "max": date(2026, 12, 31).strftime("%Y-%m-%d"), + "class": "form-control", + } + ), + ) + + def clean(self): + cleaned_data = super().clean() + checkin = cleaned_data.get("checkin") + checkout = cleaned_data.get("checkout") + if not checkin or not checkout: + return cleaned_data + + if checkout <= checkin: + raise forms.ValidationError("La fecha de salida debe ser posterior a la fecha de entrada") + + max_allowed_date = date(2026, 12, 31) + if checkin > max_allowed_date or checkout > max_allowed_date: + raise forms.ValidationError("Solo se permiten reservas hasta el 31/12/2026") + + return cleaned_data diff --git a/pms/templates/edit_booking_dates.html b/pms/templates/edit_booking_dates.html new file mode 100644 index 000000000..18cdd00c8 --- /dev/null +++ b/pms/templates/edit_booking_dates.html @@ -0,0 +1,38 @@ +{% extends "main.html"%} + +{% block content %} +
+
+

Editar fechas de reserva

+

Reserva {{ booking.code }} - {{ booking.room.name }}

+
+ +
+ {% if form.non_field_errors %} +
+ {% for error in form.non_field_errors %} +
{{ error }}
+ {% endfor %} +
+ {% endif %} + +
+ {% csrf_token %} + {% for field in form %} +
+ + {{ field }} + {% for error in field.errors %} +
{{ error }}
+ {% endfor %} +
+ {% endfor %} + +
+ Volver + +
+
+
+
+{% endblock content %} diff --git a/pms/templates/home.html b/pms/templates/home.html index 1e61b8024..e57e55ac0 100644 --- a/pms/templates/home.html +++ b/pms/templates/home.html @@ -68,7 +68,7 @@

Reservas Realizadas

Editar datos de contacto
- + Editar fechas
@@ -84,4 +84,4 @@

Reservas Realizadas

-{% endblock content%} \ No newline at end of file +{% endblock content%} diff --git a/pms/tests.py b/pms/tests.py index 7ce503c2d..c26c9fc6d 100644 --- a/pms/tests.py +++ b/pms/tests.py @@ -1,3 +1,77 @@ +from datetime import date, timedelta + from django.test import TestCase +from django.test.utils import override_settings +from django.urls import reverse + +from .models import Booking, Room, Room_type + + +@override_settings(STATICFILES_STORAGE="django.contrib.staticfiles.storage.StaticFilesStorage") +class EditBookingDatesTests(TestCase): + @classmethod + def setUpTestData(cls): + room_type = Room_type.objects.create(name="Simple", price=20, max_guests=1) + cls.room = Room.objects.create(room_type=room_type, name="Room 1.1", description="Desc") + cls.booking = Booking.objects.create( + state="NEW", + checkin=date.today() + timedelta(days=5), + checkout=date.today() + timedelta(days=7), + room=cls.room, + guests=1, + total=40, + code="EDIT0001", + ) + + def test_edit_dates_view_updates_booking_when_room_is_available(self): + response = self.client.post( + reverse("edit_booking_dates", kwargs={"pk": self.booking.id}), + data={ + "checkin": date.today() + timedelta(days=10), + "checkout": date.today() + timedelta(days=12), + }, + ) + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, "/") + + self.booking.refresh_from_db() + self.assertEqual(self.booking.checkin, date.today() + timedelta(days=10)) + self.assertEqual(self.booking.checkout, date.today() + timedelta(days=12)) + self.assertEqual(self.booking.total, 40) + + def test_edit_dates_view_renders_iso_values_for_date_inputs(self): + response = self.client.get(reverse("edit_booking_dates", kwargs={"pk": self.booking.id})) + self.assertEqual(response.status_code, 200) + self.assertContains(response, f'value="{self.booking.checkin.strftime("%Y-%m-%d")}"') + self.assertContains(response, f'value="{self.booking.checkout.strftime("%Y-%m-%d")}"') + + def test_edit_dates_view_displays_availability_error_when_dates_overlap(self): + Booking.objects.create( + state="NEW", + checkin=date.today() + timedelta(days=8), + checkout=date.today() + timedelta(days=11), + room=self.room, + guests=1, + total=60, + code="OVLP0001", + ) + response = self.client.post( + reverse("edit_booking_dates", kwargs={"pk": self.booking.id}), + data={ + "checkin": date.today() + timedelta(days=9), + "checkout": date.today() + timedelta(days=10), + }, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "No hay disponibilidad para las fechas seleccionadas") -# Create your tests here. + def test_edit_dates_view_displays_range_error_for_dates_after_2026_12_31(self): + response = self.client.post( + reverse("edit_booking_dates", kwargs={"pk": self.booking.id}), + data={ + "checkin": date(2026, 12, 30), + "checkout": date(2027, 1, 2), + }, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Solo se permiten reservas hasta el 31/12/2026") diff --git a/pms/urls.py b/pms/urls.py index c18714abf..2acbd0a22 100644 --- a/pms/urls.py +++ b/pms/urls.py @@ -8,6 +8,7 @@ path("search/booking/", views.BookingSearchView.as_view(), name="booking_search"), path("booking//", views.BookingView.as_view(), name="booking"), path("booking//edit", views.EditBookingView.as_view(), name="edit_booking"), + path("booking//edit-dates", views.EditBookingDatesView.as_view(), name="edit_booking_dates"), path("booking//delete", views.DeleteBookingView.as_view(), name="delete_booking"), path("rooms/", views.RoomsView.as_view(), name="rooms"), path("room//", views.RoomDetailsView.as_view(), name="room_details"), diff --git a/pms/views.py b/pms/views.py index f38563933..b4a11edd0 100644 --- a/pms/views.py +++ b/pms/views.py @@ -174,6 +174,51 @@ def post(self, request, pk): return redirect("/") +class EditBookingDatesView(View): + def get(self, request, pk): + booking = Booking.objects.get(id=pk) + form = BookingDatesEditForm( + initial={ + "checkin": booking.checkin, + "checkout": booking.checkout, + } + ) + context = { + "booking": booking, + "form": form, + } + return render(request, "edit_booking_dates.html", context) + + def post(self, request, pk): + booking = Booking.objects.get(id=pk) + form = BookingDatesEditForm(request.POST) + if form.is_valid(): + checkin = form.cleaned_data["checkin"] + checkout = form.cleaned_data["checkout"] + + has_overlap = (Booking.objects + .filter(room=booking.room, state="NEW") + .exclude(id=booking.id) + .filter(checkin__lte=checkout, checkout__gte=checkin) + .exists()) + + if has_overlap: + form.add_error(None, "No hay disponibilidad para las fechas seleccionadas") + else: + total_days = (checkout - checkin).days + booking.checkin = checkin + booking.checkout = checkout + booking.total = total_days * booking.room.room_type.price + booking.save(update_fields=["checkin", "checkout", "total"]) + return redirect("/") + + context = { + "booking": booking, + "form": form, + } + return render(request, "edit_booking_dates.html", context) + + class DashboardView(View): def get(self, request): from datetime import date, time, datetime