diff --git a/erpnext/public/scss/website.scss b/erpnext/public/scss/website.scss index e57caab18d0939bb789729c14e91e1ea0e1b1007..0e6a1bc5f433ceea0d61032bbb2968ab60f236b7 100644 --- a/erpnext/public/scss/website.scss +++ b/erpnext/public/scss/website.scss @@ -39,7 +39,8 @@ border-radius: var(--border-radius-md); @media screen and (max-width: 567px) { - margin-left: -2rem; + margin-left: -1rem; + margin-right: -1rem; } &.result { diff --git a/erpnext/templates/includes/item_booking/item_booking_list_action.html b/erpnext/templates/includes/item_booking/item_booking_list_action.html index fc0e04d4d0f88ff8921080e4860d545824fcd2d6..af9f3c13b446ebcf25eec05a3fc59a18221c6a49 100644 --- a/erpnext/templates/includes/item_booking/item_booking_list_action.html +++ b/erpnext/templates/includes/item_booking/item_booking_list_action.html @@ -1,4 +1,4 @@ -
+
{{ _("Book Resources") }} @@ -11,6 +11,7 @@ frappe.ready(function() { const content = document.querySelector(".page_content") const listview = content.querySelector(".website-list") + if (!listview) return; const calendar = document.createElement("div") listview.parentNode.insertBefore(calendar, listview.nextSibling); const viewSwitcher = document.querySelector(".view-switcher"); diff --git a/erpnext/templates/includes/item_booking/item_booking_list_footer.html b/erpnext/templates/includes/item_booking/item_booking_list_footer.html index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..f43793dc07b826eef3cac9055a0f3cae9f33b294 100644 --- a/erpnext/templates/includes/item_booking/item_booking_list_footer.html +++ b/erpnext/templates/includes/item_booking/item_booking_list_footer.html @@ -0,0 +1,52 @@ + diff --git a/erpnext/templates/includes/item_booking/item_booking_row.html b/erpnext/templates/includes/item_booking/item_booking_row.html index c83188b6049ba082e400f6acb3aef3b615a5accd..e0757784304d6add787265d5337189405f280dbf 100644 --- a/erpnext/templates/includes/item_booking/item_booking_row.html +++ b/erpnext/templates/includes/item_booking/item_booking_row.html @@ -1,64 +1,52 @@ -
+
+ {% set now = frappe.utils.now_datetime() %} + {% set indicator = "grey" %} + {% if doc.ends_on >= now %} + {% if doc.status == "Cancelled" %} + {% set indicator = "red" %} + {% elif doc.status == "Confirmed" %} + {% set indicator = "green" %} + {% else %} + {% set indicator = "orange" %} + {% endif %} + {% else %} + {% if doc.status == "Cancelled" %} + {% set indicator = "darkgrey" %} + {% endif %} + {% endif %}
-
- {% set indicator = "darkgrey" if doc.ends_on < frappe.utils.now_datetime() else ("red" if doc.status=="Cancelled" else ("blue" if doc.status=="Confirmed" else "orange")) %} -
{{ doc.item_name }}
+
+
{{ doc.item_name |e }}
- {{ _(doc.status) }} + {{ _(doc.status) |e }}
-
-
+
+
{{ frappe.utils.global_date_format(doc.starts_on) }}
- {{ frappe.utils.get_time(doc.starts_on).strftime("%H:%M") }}-{{ frappe.utils.get_time(doc.ends_on).strftime("%H:%M") }} + {{ frappe.utils.get_time(doc.starts_on).strftime("%H:%M") }}—{{ frappe.utils.get_time(doc.ends_on).strftime("%H:%M") }}
{% if doc.repeat_this_event %}
- {{ _("Repeated event") }} + {{ _("Repeated event") }} +
{% endif %}
-
- {% if doc.status != "Cancelled" and doc.starts_on > frappe.utils.add_to_date(frappe.utils.now_datetime(), minutes=cancellation_delay) and can_cancel %}{% endif %} + {% if doc.status != "In cart" %} +
+ {% if doc.status != "Cancelled" and doc.starts_on <= now <= doc.ends_on %} + + {% elif doc.status != "Cancelled" and doc.starts_on > frappe.utils.add_to_date(now, minutes=cancellation_delay) and can_cancel %} + + {% endif %}
+ {% endif %}
-
- - +
\ No newline at end of file diff --git a/erpnext/venue/doctype/item_booking/item_booking.js b/erpnext/venue/doctype/item_booking/item_booking.js index 7cf76adb865364a1615fdf49b794100e5c2b57a9..30132e707fdc35075fa13c4f1ead209b1b14e2db 100644 --- a/erpnext/venue/doctype/item_booking/item_booking.js +++ b/erpnext/venue/doctype/item_booking/item_booking.js @@ -86,6 +86,8 @@ frappe.ui.form.on('Item Booking', { } frm.trigger('add_repeat_text'); + + frm.trigger('add_end_booking_button'); }, set_cart_countdown(frm) { frappe.db.get_single_value("Venue Settings", "clear_item_booking_draft_duration") @@ -192,6 +194,21 @@ frappe.ui.form.on('Item Booking', { ends_on: function(frm) { frm.trigger('get_booking_count') }, + add_end_booking_button: function(frm) { + if (frm.doc.status === "Cancelled" || !frm.has_perm("write")) { + return + } + if (frappe.datetime.get_minute_diff(frm.doc.starts_on) > 0) { + return // Not yet started + } + if (frappe.datetime.get_minute_diff(frm.doc.ends_on) < 1) { + return // Already ended + } + frm.page.add_action_item(__("Set as completed"), async () => { + await frm.call("end_now"); + frm.save(); + }) + } }); const add_to_transaction = (frm, transaction_type) => { diff --git a/erpnext/venue/doctype/item_booking/item_booking.json b/erpnext/venue/doctype/item_booking/item_booking.json index b9ec7a0b99a5cf7361485bb085183a90e6b798e0..6c049a9526c37863db5f2ade55e358927c37a06a 100644 --- a/erpnext/venue/doctype/item_booking/item_booking.json +++ b/erpnext/venue/doctype/item_booking/item_booking.json @@ -54,7 +54,8 @@ "in_standard_filter": 1, "label": "Item", "options": "Item", - "reqd": 1 + "reqd": 1, + "search_index": 1 }, { "fieldname": "column_break_2", @@ -261,7 +262,8 @@ "fieldname": "parent_item_booking", "fieldtype": "Link", "label": "Parent Item Booking", - "options": "Item Booking" + "options": "Item Booking", + "search_index": 1 }, { "fieldname": "google_calendar_tab", @@ -291,7 +293,7 @@ "link_fieldname": "item_booking" } ], - "modified": "2024-01-23 17:40:02.178550", + "modified": "2024-02-13 18:35:54.430371", "modified_by": "Administrator", "module": "Venue", "name": "Item Booking", @@ -345,21 +347,6 @@ "role": "Sales User", "share": 1, "write": 1 - }, - { - "email": 1, - "export": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "All", - "share": 1 - }, - { - "create": 1, - "read": 1, - "role": "Customer", - "write": 1 } ], "quick_entry": 1, diff --git a/erpnext/venue/doctype/item_booking/item_booking.py b/erpnext/venue/doctype/item_booking/item_booking.py index 7a2aa43dde7caabc9a162fae71abf19d0175af21..70f11c2578588298e69922acfa3e0ac0f98db5cf 100644 --- a/erpnext/venue/doctype/item_booking/item_booking.py +++ b/erpnext/venue/doctype/item_booking/item_booking.py @@ -32,10 +32,10 @@ from frappe.utils import ( sbool, time_diff_in_minutes, ) -from frappe.utils.user import is_system_user, is_website_user from googleapiclient.errors import HttpError from erpnext.accounts.party import get_party_account_currency +from erpnext.controllers.website_list_for_contact import get_customers_suppliers from erpnext.setup.utils import get_exchange_rate from erpnext.venue.doctype.booking_credit.booking_credit import get_booking_credit_types_for_item from erpnext.venue.doctype.item_booking_calendar.item_booking_calendar import ( @@ -484,6 +484,30 @@ class ItemBooking(Document): self.set_status("Cancelled") + @frappe.whitelist() + def end_now(self): + new_end = now_datetime() + old_start = get_datetime(self.starts_on) + old_end = get_datetime(self.ends_on) + assert old_start and old_end # for typechecking + + if old_start <= new_end <= old_end: + self.ends_on = new_end + return + else: + frappe.throw( + _("Date must be between {0} and {1}").format( + self.get_formatted("starts_on"), + self.get_formatted("ends_on"), + ) + ) + + def has_website_permission(self, ptype, user, verbose=False): + if ptype == "read": + # Read-only, user has to use the website to cancel the bookings. + return has_booking_permission(self, ptype=ptype, user=user, raise_exception=False) + return False + def get_list_context(context=None): allow_event_cancellation = frappe.db.get_single_value( @@ -520,19 +544,25 @@ def get_bookings_list(doctype, txt, filters, limit_start, limit_page_length=20, from frappe.www.list import get_list user = frappe.session.user - contact = frappe.db.get_value("Contact", {"user": user}, "name") - customer = None + + if user == "Guest": + return [] + + customers = set() or_filters = [] + contact = frappe.db.get_value("Contact", {"user": user}, "name") if contact: contact_doc = frappe.get_doc("Contact", contact) - customer = contact_doc.get_link_for("Customer") + if customer := contact_doc.get_link_for("Customer"): + customers.add(customer) - if is_website_user() or is_system_user(user): - if not filters: - filters = [] + all_customers, _ = get_customers_suppliers("Customer", user) + customers.update(all_customers or []) - or_filters.append({"user": user, "party_name": customer}) + or_filters.append(["user", "=", user]) + if customers: + or_filters.append(["party_name", "in", customers]) return get_list( doctype, @@ -540,13 +570,13 @@ def get_bookings_list(doctype, txt, filters, limit_start, limit_page_length=20, filters, limit_start, limit_page_length, - ignore_permissions=False, + ignore_permissions=True, or_filters=or_filters, order_by="starts_on desc", ) -@frappe.whitelist() +@frappe.whitelist(allow_guest=True) def get_bookings_list_for_map(start, end): bookings_list = _get_events(getdate(start), getdate(end), item=None, user=frappe.session.user) @@ -585,11 +615,13 @@ def get_bookings_list_for_map(start, end): @frappe.whitelist() def update_linked_transaction(transaction_type, line_item, item_booking): + has_booking_permission(item_booking, raise_exception=True) return frappe.db.set_value(f"{transaction_type} Item", line_item, "item_booking", item_booking) @frappe.whitelist() def get_transactions_items(transaction_type, transactions): + frappe.has_permission(transaction_type, "read", throw=True) transactions = frappe.parse_json(transactions) output = [] for transaction in transactions: @@ -599,10 +631,58 @@ def get_transactions_items(transaction_type, transactions): return output +def has_booking_permission(doc: ItemBooking | str, ptype="write", user="", raise_exception=False): + from frappe.permissions import has_permission + + if raise_exception: + if not has_booking_permission(doc, ptype, user, raise_exception=False): + frappe.throw("Not allowed", frappe.PermissionError) + + user = user or frappe.session.user + if isinstance(doc, str): + doc: ItemBooking = frappe.get_doc("Item Booking", str) # type: ignore + + if has_permission(doc.doctype, ptype, doc=doc, print_logs=False, user=user): + return True + + if doc.user == user: + return True + + customers, _ = get_customers_suppliers("Customer", user) + if customers: + for c in customers: + if doc.party_name == c: + return True + + return False + + @frappe.whitelist() -def cancel_appointment(id, force=False): +def cancel_appointment(id, force=False, render_row=False): booking: ItemBooking = frappe.get_doc("Item Booking", id) # type: ignore + has_booking_permission(booking, raise_exception=True) + booking.flags.ignore_permissions = True booking.cancel_appointment(ignore_links=force) + booking.save() + if render_row: + return do_render_row(booking) + + +@frappe.whitelist() +def end_booking_now(id, render_row=False): + booking: ItemBooking = frappe.get_doc("Item Booking", id) # type: ignore + has_booking_permission(booking, raise_exception=True) + booking.flags.ignore_permissions = True + booking.end_now() + booking.save() + if render_row: + return do_render_row(booking) + + +def do_render_row(doc: ItemBooking): + ctx = {"doc": doc} + get_list_context(ctx) + return frappe.render_template(ctx["row_template"], ctx) @frappe.whitelist(allow_guest=True) @@ -647,6 +727,7 @@ def book_new_slot(**kwargs): @frappe.whitelist() def remove_booked_slot(name): + has_booking_permission(name, raise_exception=True) try: for dt in ["Quotation", "Sales Order"]: linked_docs = frappe.get_all( @@ -1043,6 +1124,9 @@ def _get_events( from pypika import Criterion from pypika import functions as fn + if user == "Guest": + return [] + assert (not fields) or isinstance( fields, (list, tuple, set) ), "`fields` parameters must be a list, tuple, set, or None" @@ -1075,7 +1159,11 @@ def _get_events( item_name = item if isinstance(item, str) else item.name extra_conditions.append(IB.item == item_name) if user: - extra_conditions.append(IB.user == user) + customers, _ = get_customers_suppliers("Customer", user) + cond = IB.user == user + if customers: + cond |= IB.party_name.isin(customers) + extra_conditions.append(cond) query = ( frappe.qb.get_query("Item Booking", filters=filters) @@ -1188,23 +1276,12 @@ def _get_booking_subscriptions_between( else: all_filters.append(item_field.isnotnull()) # Must be a booking subscription - # if user: - # Contact = frappe.qb.DocType("Contact") - # DynamicLink = frappe.qb.DocType("Dynamic Link") - # query_for_customers = ( - # frappe.qb.select(DynamicLink.link_name) - # .from_(Contact) - # .join(DynamicLink) - # .on( - # (Contact.name == DynamicLink.parent) - # (DynamicLink.parenttype == "Contact") - # & (DynamicLink.link_doctype == "Customer") - # & (Contact.user == user) - # ) - # ) - # all_customers: set = { res[0] for res in query_for_customers.run() } - # customer_field: Field = Subscription.customer - # all_filters.append(customer_field.isin(all_customers)) + if user: + customers, _ = get_customers_suppliers("Customer", user) + if customers: + all_filters.append(Subscription.customer.isin(customers)) + else: + return [] all_fields.extend( ( @@ -1789,6 +1866,7 @@ def move_booking_with_event(doc, method): doc = frappe.get_doc("Item Booking", booking.name) doc.starts_on = add_days(booking.starts_on, days) doc.ends_on = add_days(booking.ends_on, days) + doc.flags.ignore_permissions = True doc.save() diff --git a/erpnext/venue/doctype/item_booking/item_booking_list.js b/erpnext/venue/doctype/item_booking/item_booking_list.js index eeed65eccdc270ca4be0eb56c5f081e7d63e87e9..c168b705bac6bccb9b336b6b907b6a17a4f27e9f 100644 --- a/erpnext/venue/doctype/item_booking/item_booking_list.js +++ b/erpnext/venue/doctype/item_booking/item_booking_list.js @@ -1,4 +1,5 @@ frappe.listview_settings['Item Booking'] = { + hide_name_column: true, get_indicator: function(doc) { if (doc.status == "Confirmed") { return [__("Confirmed"), "green", "status,=,Confirmed"]; diff --git a/erpnext/venue/doctype/item_booking/test_item_booking.py b/erpnext/venue/doctype/item_booking/test_item_booking.py index b36b81bd372367b7b4e03ce5f7405495f207f473..87a8ef88a7035e69122f36020be15af61b3c41bc 100644 --- a/erpnext/venue/doctype/item_booking/test_item_booking.py +++ b/erpnext/venue/doctype/item_booking/test_item_booking.py @@ -2,12 +2,13 @@ # See license.txt from collections import Counter +from contextlib import contextmanager from datetime import date, datetime from operator import itemgetter import frappe from frappe.tests.utils import FrappeTestCase, change_settings -from frappe.utils import add_to_date, getdate +from frappe.utils import add_to_date, get_datetime, getdate from erpnext import get_default_company from erpnext.venue.doctype.item_booking.item_booking import get_availabilities @@ -440,6 +441,108 @@ class TestItemBooking(BaseTestWithBookableItem): ) self.assertEqual(len(availabilities), 10) # 10 hours between 8:00-18:00, normal, after + @change_settings("Venue Settings", {"minute_uom": "Minute", "enable_simultaneous_booking": 1}) + def test_booking_cancellation(self): + from erpnext.venue.doctype.item_booking.item_booking import cancel_appointment, end_booking_now + + @contextmanager + def rollbacker(): + frappe.db.savepoint("TestItemBooking_test_booking_cancellation") + frappe.set_user("test@example.com") + yield + frappe.set_user("Administrator") + frappe.db.rollback(save_point="TestItemBooking_test_booking_cancellation") + + past1 = add_to_date(getdate(), days=-4, hours=7) + past2 = add_to_date(past1, hours=5) + present1 = add_to_date(get_datetime(), hours=-3) + present2 = add_to_date(get_datetime(), hours=3) + future1 = add_to_date(getdate(), days=4, hours=7) + future2 = add_to_date(future1, hours=5) + + frappe.set_user("Administrator") + past = self.makeBookingWithAutocleanup( + self.ITEM_BOOKABLE_1.name, past1, past2, user="test@example.com" + ) + present = self.makeBookingWithAutocleanup( + self.ITEM_BOOKABLE_1.name, present1, present2, user="test@example.com" + ) + future = self.makeBookingWithAutocleanup( + self.ITEM_BOOKABLE_1.name, future1, future2, user="test@example.com" + ) + + with rollbacker(): + self.assertRaises(frappe.ValidationError, end_booking_now, past.name) + end_booking_now(present.name) + self.assertRaises(frappe.ValidationError, end_booking_now, future.name) + + with rollbacker(): + frappe.set_user("test2@example.com") + self.assertRaises(frappe.PermissionError, end_booking_now, present.name) + + with change_settings("Venue Settings", {"allow_event_cancellation": 0, "cancellation_delay": 0}): + with rollbacker(): + self.assertRaises(frappe.ValidationError, cancel_appointment, past.name) + self.assertRaises(frappe.ValidationError, cancel_appointment, present.name) + self.assertRaises(frappe.ValidationError, cancel_appointment, future.name) + + with change_settings("Venue Settings", {"allow_event_cancellation": 1, "cancellation_delay": 0}): + with rollbacker(): + self.assertRaises(frappe.ValidationError, cancel_appointment, past.name) + self.assertRaises(frappe.ValidationError, cancel_appointment, present.name) + cancel_appointment(future.name) + with rollbacker(): + frappe.set_user("test2@example.com") + self.assertRaises(frappe.PermissionError, cancel_appointment, future.name) + + with change_settings( + "Venue Settings", {"allow_event_cancellation": 1, "cancellation_delay": 9999999999999} + ): + with rollbacker(): + self.assertRaises(frappe.ValidationError, cancel_appointment, past.name) + self.assertRaises(frappe.ValidationError, cancel_appointment, present.name) + self.assertRaises(frappe.ValidationError, cancel_appointment, future.name) + + frappe.set_user("Administrator") + + @change_settings("Venue Settings", {"minute_uom": "Minute", "enable_simultaneous_booking": 1}) + def test_booking_web_list(self): + from erpnext.venue.doctype.item_booking.item_booking import ( + get_bookings_list, + get_bookings_list_for_map, + ) + + def check_results(user: str, n1: int, n2: int): + frappe.set_user(user) + res1 = get_bookings_list( + "Item Booking", "", {}, limit_start=0, limit_page_length=20, order_by=None + ) + self.assertEqual(len(res1), n1) + res2 = get_bookings_list_for_map(getdate(), add_to_date(getdate(), days=7)) + self.assertEqual(len(res2), n2) + + frappe.db.delete("Item Booking", {}) # Clear all bookings + check_results("Administrator", 0, 0) + check_results("test@example.com", 0, 0) + + frappe.set_user("Administrator") + dt_start = add_to_date(getdate(), days=4, hours=7) + dt_end = add_to_date(dt_start, hours=5) + # Can be seen in the web list, only by test@example.com + booking1 = self.makeBookingWithAutocleanup( + self.ITEM_BOOKABLE_1.name, dt_start, dt_end, user="test@example.com" + ) + # Cannot be seen in the web list + booking2 = self.makeBookingWithAutocleanup(self.ITEM_BOOKABLE_1.name, dt_start, dt_end) + + check_results("Administrator", 0, 0) # Even Administrator cannot see these results + check_results("Guest", 0, 0) + check_results("test@example.com", 1, 1) + check_results("test2@example.com", 0, 0) + + frappe.set_user("Administrator") + frappe.db.rollback() + class TestItemBookingWithSubscription(BaseTestWithSubscriptionForBookableItem): @change_settings("Venue Settings", {"minute_uom": "Minute", "enable_simultaneous_booking": 1})