diff --git a/pms/templates/rooms.html b/pms/templates/rooms.html
index c30929f1f..503e80749 100644
--- a/pms/templates/rooms.html
+++ b/pms/templates/rooms.html
@@ -2,6 +2,25 @@
{% block content %}
diff --git a/pms/tests.py b/pms/tests.py
index 7ce503c2d..c41f74e44 100644
--- a/pms/tests.py
+++ b/pms/tests.py
@@ -1,3 +1,304 @@
-from django.test import TestCase
+from datetime import date, timedelta
-# Create your tests here.
+from django.test import TestCase, override_settings
+from django.urls import reverse
+
+from .forms import MAX_BOOKING_DATE
+from .models import Booking, Customer, Room, Room_type
+
+
+@override_settings(
+ STATICFILES_STORAGE="django.contrib.staticfiles.storage.StaticFilesStorage"
+)
+class RoomsFilterTests(TestCase):
+ def setUp(self):
+ self.room_type = Room_type.objects.create(
+ name="TestType",
+ price=10.0,
+ max_guests=2,
+ )
+ Room.objects.create(
+ room_type=self.room_type,
+ name="Room 1.1",
+ description="Test room 1.1",
+ )
+ Room.objects.create(
+ room_type=self.room_type,
+ name="Room 1.2",
+ description="Test room 1.2",
+ )
+ Room.objects.create(
+ room_type=self.room_type,
+ name="Room 2.1",
+ description="Test room 2.1",
+ )
+
+ def test_rooms_without_filter_lists_all_rooms(self):
+ url = reverse("rooms")
+ resp = self.client.get(url)
+ self.assertEqual(resp.status_code, 200)
+ self.assertContains(resp, "Room 1.1")
+ self.assertContains(resp, "Room 1.2")
+ self.assertContains(resp, "Room 2.1")
+
+ def test_rooms_filter_partial_name(self):
+ url = reverse("rooms")
+ resp = self.client.get(url, {"name": "Room 1"})
+ self.assertEqual(resp.status_code, 200)
+ self.assertContains(resp, "Room 1.1")
+ self.assertContains(resp, "Room 1.2")
+ self.assertNotContains(resp, "Room 2.1")
+
+ def test_rooms_filter_is_case_insensitive(self):
+ url = reverse("rooms")
+ resp = self.client.get(url, {"name": "room 1"})
+ self.assertEqual(resp.status_code, 200)
+ self.assertContains(resp, "Room 1.1")
+ self.assertContains(resp, "Room 1.2")
+ self.assertNotContains(resp, "Room 2.1")
+
+ def test_rooms_filter_empty_or_whitespace_behaves_like_no_filter(self):
+ url = reverse("rooms")
+ resp = self.client.get(url, {"name": " "})
+ self.assertEqual(resp.status_code, 200)
+ self.assertContains(resp, "Room 1.1")
+ self.assertContains(resp, "Room 1.2")
+ self.assertContains(resp, "Room 2.1")
+
+
+@override_settings(
+ STATICFILES_STORAGE="django.contrib.staticfiles.storage.StaticFilesStorage"
+)
+class DashboardOccupancyTests(TestCase):
+ def setUp(self):
+ self.room_type = Room_type.objects.create(
+ name="TestType",
+ price=10.0,
+ max_guests=2,
+ )
+ self.rooms = [
+ Room.objects.create(
+ room_type=self.room_type,
+ name=f"Room {i}",
+ description=f"Test room {i}",
+ )
+ for i in range(1, 5)
+ ]
+ self.customer = Customer.objects.create(
+ name="Test Customer",
+ email="test@example.com",
+ phone="123",
+ )
+
+ def test_dashboard_occupancy_pct_counts_only_new_bookings_occupying_today(self):
+ today = date.today()
+
+ # Counts: state NEW and checkin <= today < checkout
+ Booking.objects.create(
+ state="NEW",
+ checkin=today - timedelta(days=1),
+ checkout=today + timedelta(days=1),
+ room=self.rooms[0],
+ guests=1,
+ customer=self.customer,
+ total=10.0,
+ code="ABCDEFG1",
+ )
+
+ # Does not count: outside of today range
+ Booking.objects.create(
+ state="NEW",
+ checkin=today + timedelta(days=5),
+ checkout=today + timedelta(days=6),
+ room=self.rooms[1],
+ guests=1,
+ customer=self.customer,
+ total=10.0,
+ code="ABCDEFG2",
+ )
+
+ # Does not count: cancelled
+ Booking.objects.create(
+ state="DEL",
+ checkin=today - timedelta(days=1),
+ checkout=today + timedelta(days=1),
+ room=self.rooms[2],
+ guests=1,
+ customer=self.customer,
+ total=10.0,
+ code="ABCDEFG3",
+ )
+
+ url = reverse("dashboard")
+ resp = self.client.get(url)
+ self.assertEqual(resp.status_code, 200)
+
+ occupancy_pct = resp.context["dashboard"]["occupancy_pct"]
+ self.assertAlmostEqual(occupancy_pct, 25.0, places=2)
+
+ def test_dashboard_occupancy_pct_is_zero_when_no_rooms_exist(self):
+ Room.objects.all().delete()
+
+ url = reverse("dashboard")
+ resp = self.client.get(url)
+ self.assertEqual(resp.status_code, 200)
+
+ occupancy_pct = resp.context["dashboard"]["occupancy_pct"]
+ self.assertEqual(occupancy_pct, 0)
+
+
+@override_settings(
+ STATICFILES_STORAGE="django.contrib.staticfiles.storage.StaticFilesStorage"
+)
+class EditBookingDatesTests(TestCase):
+ def setUp(self):
+ self.room_type = Room_type.objects.create(
+ name="TestType",
+ price=20.0,
+ max_guests=2,
+ )
+ self.room = Room.objects.create(
+ room_type=self.room_type,
+ name="Room 1.1",
+ description="Test room",
+ )
+ self.customer = Customer.objects.create(
+ name="Test Customer",
+ email="test@example.com",
+ phone="123",
+ )
+
+ def test_get_edit_dates_page_renders(self):
+ today = date.today()
+ booking = Booking.objects.create(
+ state="NEW",
+ checkin=today + timedelta(days=1),
+ checkout=today + timedelta(days=3),
+ room=self.room,
+ guests=1,
+ customer=self.customer,
+ total=40.0,
+ code="BOOKTEST",
+ )
+
+ url = reverse("edit_booking_dates", kwargs={"pk": booking.id})
+ resp = self.client.get(url)
+ self.assertEqual(resp.status_code, 200)
+ self.assertContains(resp, "Editar fechas")
+
+ def test_edit_dates_success_updates_dates_and_total(self):
+ today = date.today()
+ booking = Booking.objects.create(
+ state="NEW",
+ checkin=today + timedelta(days=1),
+ checkout=today + timedelta(days=3),
+ room=self.room,
+ guests=1,
+ customer=self.customer,
+ total=40.0,
+ code="BOOKTES1",
+ )
+
+ new_checkin = today + timedelta(days=2)
+ new_checkout = today + timedelta(days=5)
+ url = reverse("edit_booking_dates", kwargs={"pk": booking.id})
+ resp = self.client.post(
+ url,
+ data={
+ "checkin": new_checkin.strftime("%Y-%m-%d"),
+ "checkout": new_checkout.strftime("%Y-%m-%d"),
+ },
+ )
+ self.assertEqual(resp.status_code, 302)
+
+ booking.refresh_from_db()
+ self.assertEqual(booking.checkin, new_checkin)
+ self.assertEqual(booking.checkout, new_checkout)
+ self.assertAlmostEqual(booking.total, 60.0, places=2) # 3 nights * 20.0
+
+ def test_edit_dates_rejects_when_room_not_available(self):
+ today = date.today()
+ booking = Booking.objects.create(
+ state="NEW",
+ checkin=today + timedelta(days=1),
+ checkout=today + timedelta(days=3),
+ room=self.room,
+ guests=1,
+ customer=self.customer,
+ total=40.0,
+ code="BOOKTES2",
+ )
+ # Another confirmed booking occupying the desired new range
+ Booking.objects.create(
+ state="NEW",
+ checkin=today + timedelta(days=4),
+ checkout=today + timedelta(days=6),
+ room=self.room,
+ guests=1,
+ customer=self.customer,
+ total=40.0,
+ code="BOOKCON1",
+ )
+
+ url = reverse("edit_booking_dates", kwargs={"pk": booking.id})
+ resp = self.client.post(
+ url,
+ data={
+ "checkin": (today + timedelta(days=5)).strftime("%Y-%m-%d"),
+ "checkout": (today + timedelta(days=7)).strftime("%Y-%m-%d"),
+ },
+ )
+ self.assertEqual(resp.status_code, 200)
+ self.assertContains(resp, "No hay disponibilidad para las fechas seleccionadas")
+
+ booking.refresh_from_db()
+ self.assertEqual(booking.checkin, today + timedelta(days=1))
+ self.assertEqual(booking.checkout, today + timedelta(days=3))
+
+ def test_edit_dates_rejects_invalid_range(self):
+ today = date.today()
+ booking = Booking.objects.create(
+ state="NEW",
+ checkin=today + timedelta(days=1),
+ checkout=today + timedelta(days=3),
+ room=self.room,
+ guests=1,
+ customer=self.customer,
+ total=40.0,
+ code="BOOKTES3",
+ )
+
+ url = reverse("edit_booking_dates", kwargs={"pk": booking.id})
+ resp = self.client.post(
+ url,
+ data={
+ "checkin": (today + timedelta(days=5)).strftime("%Y-%m-%d"),
+ "checkout": (today + timedelta(days=5)).strftime("%Y-%m-%d"),
+ },
+ )
+ self.assertEqual(resp.status_code, 200)
+ self.assertContains(resp, "La fecha de salida debe ser posterior a la fecha de entrada.")
+
+ def test_edit_dates_rejects_after_max_date(self):
+ today = date.today()
+ booking = Booking.objects.create(
+ state="NEW",
+ checkin=today + timedelta(days=1),
+ checkout=today + timedelta(days=3),
+ room=self.room,
+ guests=1,
+ customer=self.customer,
+ total=40.0,
+ code="BOOKTES4",
+ )
+
+ url = reverse("edit_booking_dates", kwargs={"pk": booking.id})
+ resp = self.client.post(
+ url,
+ data={
+ "checkin": MAX_BOOKING_DATE.strftime("%Y-%m-%d"),
+ "checkout": (MAX_BOOKING_DATE + timedelta(days=1)).strftime("%Y-%m-%d"),
+ },
+ )
+ self.assertEqual(resp.status_code, 200)
+ self.assertContains(resp, "La fecha de salida no puede ser posterior")
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..a9074e5fa 100644
--- a/pms/views.py
+++ b/pms/views.py
@@ -174,6 +174,53 @@ def post(self, request, pk):
return redirect("/")
+class EditBookingDatesView(View):
+ def get(self, request, pk):
+ booking = Booking.objects.get(id=pk)
+ if booking.state == "DEL":
+ return redirect("/")
+ form = BookingDatesForm(instance=booking)
+ context = {
+ "booking": booking,
+ "form": form,
+ }
+ return render(request, "edit_booking_dates.html", context)
+
+ @method_decorator(ensure_csrf_cookie)
+ def post(self, request, pk):
+ booking = Booking.objects.get(id=pk)
+ if booking.state == "DEL":
+ return redirect("/")
+
+ form = BookingDatesForm(request.POST, instance=booking)
+ if form.is_valid():
+ checkin = form.cleaned_data["checkin"]
+ checkout = form.cleaned_data["checkout"]
+
+ if not booking.room_id:
+ form.add_error(None, "La reserva no tiene habitación asignada.")
+ else:
+ has_conflict = (Booking.objects
+ .filter(room_id=booking.room_id, state="NEW")
+ .exclude(id=booking.id)
+ .filter(checkin__lt=checkout, checkout__gt=checkin)
+ .exists())
+ if has_conflict:
+ form.add_error(None, "No hay disponibilidad para las fechas seleccionadas")
+ else:
+ updated_booking = form.save(commit=False)
+ total_days = (checkout - checkin).days
+ updated_booking.total = total_days * booking.room.room_type.price
+ updated_booking.save()
+ 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
@@ -209,12 +256,22 @@ def get(self, request):
.aggregate(Sum('total'))
)
+ total_rooms = Room.objects.count()
+ occupied_today = (Booking.objects
+ .filter(state="NEW", checkin__lte=today, checkout__gt=today)
+ .values("id")
+ ).count()
+ occupancy_pct = 0
+ if total_rooms:
+ occupancy_pct = occupied_today / total_rooms * 100
+
# preparing context data
dashboard = {
'new_bookings': new_bookings,
'incoming_guests': incoming,
'outcoming_guests': outcoming,
- 'invoiced': invoiced
+ 'invoiced': invoiced,
+ 'occupancy_pct': occupancy_pct,
}
@@ -239,8 +296,13 @@ def get(self, request, pk):
class RoomsView(View):
def get(self, request):
# renders a list of rooms
- rooms = Room.objects.all().values("name", "room_type__name", "id")
+ name = (request.GET.get("name") or "").strip()
+ rooms_qs = Room.objects.all()
+ if name:
+ rooms_qs = rooms_qs.filter(name__icontains=name)
+ rooms = rooms_qs.order_by("name").values("name", "room_type__name", "id")
context = {
- 'rooms': rooms
+ 'rooms': rooms,
+ 'name': name,
}
return render(request, "rooms.html", context)
diff --git a/requermientos.md b/requermientos.md
new file mode 100644
index 000000000..9e28d0081
--- /dev/null
+++ b/requermientos.md
@@ -0,0 +1,85 @@
+**Prueba de selección**
+
+*Buscador de reservas*
+
+El proyecto consiste en una aplicación Django de un motor de reservas para un hotel que ya ha sido desarrollada y alojada en Github.
+
+**Enlace al repositorio**: https://github.com/oscarchapp/testbookingengine
+
+En el *README* del repositorio se encuentra la información para desplegar la aplicación en local. También se incluye un backup de SQLite con la configuración inicial.
+
+**Descripción del proyecto actual**
+
+La pantalla principal de la aplicación es un listado de reservas.
+
+Una reserva consiste de:
+
+● fecha entrada
+
+● fecha salida
+
+● tipo de habitación
+
+● nº huéspedes
+
+● datos de contacto (nombre, email, teléfono)
+
+● precio total de la reserva
+
+● localizador
+
+● nº de habitación (opcional)
+
+La funcionalidad principal es la de crear una nueva reserva. En la parte superior hay un botón de “Nueva reserva” que lleva a una pantalla que permite introducir 2 fechas (entrada y salida) y el número de huéspedes. Al buscar entre 2 fechas se muestra la siguiente información: ● Tipo de habitación
+
+● Nº de habitaciones disponibles para este rango de fechas
+
+● Precio total de la estancia
+
+● Un botón para seleccionar esa habitación
+
+Tras seleccionar la habitación deseada, se muestra un formulario donde se debe introducir los datos de contacto para la reserva (nombre, email, teléfono). Al finalizar el formulario se creará la reserva (con un localizador alfanumérico único) y se mostrará la pantalla con el listado de reservas.
+Algunos detalles a tener en cuenta:
+
+● Hay 4 tipos de habitaciones: 10 individuales, 5 dobles, 4 triples, 6 cuádruples. ● El precio diario de cada tipo de habitación es: individual=20€/día, doble=30€/día, triple=40€/día, cuádruple=50€/día
+
+● En una habitación individual sólo cabe 1 persona (huésped). En una doble caben 1 o 2 personas. En una Triple caben 1, 2, o 3 personas. En una cuádruple caben 1, 2, 3 o 4 personas. Por lo tanto si el usuario hace una búsqueda para 3 personas, solo deberá mostrar habitaciones triples y cuádruples (siempre y cuando estén disponibles para esas fechas) .
+
+● A medida que se vayan creando reservas, debe descontarse del número de habitaciones disponibles de ese tipo para ese rango de fechas. Solo se podrán crear reservas desde la fecha actual hasta el 31/12/2026.
+
+**Requisitos de la prueba:**
+
+A continuación, se describen algunos ejercicios sobre cambios de funcionalidad en la aplicación existente. Se requiere que el candidato cree pull-request por cada uno y se valorará la calidad del código, buenas prácticas y la inclusión de tests.
+
+Cualquier cambio añadido sobre lo aquí explicado que suponga una mejora en la aplicación será bienvenida y tenida en cuenta.
+
+1\. **Filtrar panel de habitaciones**:
+
+En la sección de Habitaciones, actualmente se muestra una lista completa de todas las habitaciones existentes, con su nombre (ej Room 1.1).
+
+Se debe añadir un formulario en la parte superior para poder filtrar los resultados de la lista, permitiendo buscar únicamente por el nombre de la habitación. Dicho filtro debe buscar habitaciones cuyo campo “name” contenga el texto introducido en el formulario. Por ejemplo, si se introduce “Room 1”, se mostrarán las habitaciones “Room 1.1”, “Room 1.2”, etc, pero no otras como “Room 2.1”.
+
+*Nota*: se puede realizar de cualquier forma, bien por un formulario HTML que envíe un POST a la view, con Javascript y AJAX o cualquier otra que el candidato considere mejor. El diseño de la interfaz no es importante, puede ser un formulario básico sin estilo.
+
+**2\. Añadir porcentaje de ocupación**
+
+En la sección de Dashboard, se muestran varios “widgets” con datos de las reservas actuales.
+Se requiere añadir un nuevo widget llamado “% ocupación”, cuyo cálculo es el siguiente: *Cantidad total de reservas en estado confirmada / número de habitaciones existentes.*
+
+Nota: Adaptar el diseño del Dashboard para mostrar este nuevo valor en la interfaz, alineado al resto de elementos o de la forma que considere mejor el candidato.
+
+**3\. Edición de reservas**
+
+En la pantalla principal se muestran todas las reservas realizadas y se permite actualmente poder editar los datos de contacto.
+
+Se requiere añadir una nueva funcionalidad para editar las fechas de la reserva, añadiendo un enlace a cada reserva similar al de “Editar datos de contacto”. Dicho enlace debe llevar una nueva página con un formulario similar al de crear nueva reserva, pero solo con los campos de fecha de entrada y salida y un botón de “Guardar”.
+
+Además de editar los campos de la reserva, se debe realizar la misma validación que al crear una nueva reserva, es decir, comprobar que la habitación actual está disponible en las nuevas fechas seleccionadas. Si existen otras reservas ocupando en las mismas fechas, se debe mostrar un error: “No hay disponibilidad para las fechas seleccionadas”.
+
+**Instrucciones para la prueba técnica**
+
+En el repositorio mencionado encontrarás el punto de partida para la prueba donde debes crear los pull requests.
+
+Tu objetivo es completar las 3 tareas que indicamos en este documento. No hay una única forma correcta de abordarla. Nos interesa ver cómo trabajas, cómo estructuras tus cambios y cómo los presentas al equipo.
+
+Piensa en esta prueba como una pequeña simulación del día a día en el trabajo. Cuando hayas terminado, recuerda indicarnos tu nombre de usuario en Github.
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index 148e2c607..1537a31e1 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,5 @@
asgiref==3.5.0
Django==4.0.2
+gunicorn==20.1.0
sqlparse==0.4.2
-whitenoise
+whitenoise==6.0.0