From f859f82b9cb97ff84b009fc92a43d8b5f7dee731 Mon Sep 17 00:00:00 2001 From: Thomas Steen Rasmussen Date: Wed, 17 Jun 2020 21:38:07 +0200 Subject: [PATCH] fixup facility maps a bit and add backoffice management of facilities --- .../templates/facility_detail_backoffice.html | 137 +++++++ src/backoffice/templates/facility_form.html | 28 ++ .../templates/facility_list_backoffice.html | 52 +++ .../facility_opening_hours_delete.html | 19 + .../facility_opening_hours_form.html | 22 ++ .../templates/facility_type_delete.html | 18 + .../templates/facility_type_form.html | 19 + .../facility_type_list_backoffice.html | 52 +++ .../facilityfeedback_backoffice.html | 2 +- src/backoffice/templates/index.html | 31 +- src/backoffice/urls.py | 102 ++++- src/backoffice/views.py | 353 +++++++++++++++--- src/bornhack/settings.py | 3 + src/facilities/admin.py | 14 +- .../migrations/0005_facilityopeninghours.py | 55 +++ .../migrations/0006_auto_20200616_2330.py | 24 ++ ...lt_location_and_hours_overlap_contraint.py | 35 ++ src/facilities/models.py | 61 ++- src/facilities/templates/facility_detail.html | 59 ++- src/facilities/templates/facility_list.html | 4 +- src/facilities/views.py | 9 +- src/static_src/css/bornhack.css | 5 + src/static_src/js/kfmap.js | 33 ++ 23 files changed, 1056 insertions(+), 81 deletions(-) create mode 100644 src/backoffice/templates/facility_detail_backoffice.html create mode 100644 src/backoffice/templates/facility_form.html create mode 100644 src/backoffice/templates/facility_list_backoffice.html create mode 100644 src/backoffice/templates/facility_opening_hours_delete.html create mode 100644 src/backoffice/templates/facility_opening_hours_form.html create mode 100644 src/backoffice/templates/facility_type_delete.html create mode 100644 src/backoffice/templates/facility_type_form.html create mode 100644 src/backoffice/templates/facility_type_list_backoffice.html create mode 100644 src/facilities/migrations/0005_facilityopeninghours.py create mode 100644 src/facilities/migrations/0006_auto_20200616_2330.py create mode 100644 src/facilities/migrations/0007_default_location_and_hours_overlap_contraint.py diff --git a/src/backoffice/templates/facility_detail_backoffice.html b/src/backoffice/templates/facility_detail_backoffice.html new file mode 100644 index 00000000..24ee8cd0 --- /dev/null +++ b/src/backoffice/templates/facility_detail_backoffice.html @@ -0,0 +1,137 @@ +{% extends 'base.html' %} +{% load leaflet_tags %} +{% load static %} +{% load commonmark %} + +{% block extra_head %} + {% leaflet_css %} + + + + +{% endblock extra_head %} + +{% block title %} +{{ facility.name }} | Facilities | BackOffice | {{ block.super }} +{% endblock %} + +{% block content %} +
+
+

{{ facility.name }} | Facilities | BackOffice

+
+
+

+ Update Facility + Delete Facility + Facility List +

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Facility Name{{ facility.name }}
Facility Type {{ facility.facility_type.name }}

+
Description{{ facility.description }}
Opening Hours + {% if facility.opening_hours.exists %} + + + + + + + + + + + + {% for opening in facility.opening_hours.all %} + + + + + + + + {% endfor %} + +
OpensClosesDurationNotesActions
{{ opening.when.lower }}{{ opening.when.upper }}{{ opening.duration }}{{ opening.notes|trustedcommonmark|default:"N/A" }} + +
+ {% else %} + This facility does not have opening hours, it is always open. + {% endif %} +

+ Add opening hours +

Feedback + + + + + + + + + + + + + + {% for feedback in facility.feedbacks.all %} + + + + + + + + + + {% endfor %} + +
UsernameCreatedFacilityQuick FeedbackCommentUrgentHandled
{{ feedback.user|default:"N/A" }}{{ feedback.created }}{{ feedback.facility }} {{ feedback.quick_feedback }}{{ feedback.comment|default:"N/A" }}{{ feedback.urgent|yesno }}{{ feedback.handled|yesno }}
+
Location + Lat {{ facility.location.y }} Long {{ facility.location.x }}
+
+
+
+
+ + + + + +{% endblock %} + diff --git a/src/backoffice/templates/facility_form.html b/src/backoffice/templates/facility_form.html new file mode 100644 index 00000000..8ebb60be --- /dev/null +++ b/src/backoffice/templates/facility_form.html @@ -0,0 +1,28 @@ +{% extends 'base.html' %} +{% load leaflet_tags %} +{% load bootstrap3 %} +{% load static %} + +{% block extra_head %} + {{ form.media }} + {% leaflet_css plugins="forms" %} + {% leaflet_js plugins="forms" %} +{% endblock extra_head %} + +{% block content %} +
+
+

{% if request.resolver_match.url_name == "facility_update" %}Update{% else %}Create new{% endif %} Facility

+
+
+
+ {% csrf_token %} + {% bootstrap_form form %} + {% buttons %} + + Cancel + {% endbuttons %} +
+
+
+{% endblock content %} diff --git a/src/backoffice/templates/facility_list_backoffice.html b/src/backoffice/templates/facility_list_backoffice.html new file mode 100644 index 00000000..e5182a27 --- /dev/null +++ b/src/backoffice/templates/facility_list_backoffice.html @@ -0,0 +1,52 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %} +Facilities | BackOffice | {{ block.super }} +{% endblock %} + +{% block content %} +
+
+

Facilities - BackOffice

+
+
+

The following {{ facility_list.count }} facilities are defined for {{ camp.title }}

+

+ Create Facility + Backoffice +

+ + + + + + + + + + + + + + {% for facility in facility_list %} + + + + + + + + + + {% endfor %} +
NameTypeTeamDescriptionLocationFeedback / UnhandledActions
{{ facility.name }} {{ facility.facility_type.name }}{{ facility.team.name }} Team{{ facility.description|default:"N/A" }}{{ facility.location }}{{ facility.feedbacks.count }} / {{ facility.unhandled_feedbacks.count }} + +
+
+
+{% endblock %} diff --git a/src/backoffice/templates/facility_opening_hours_delete.html b/src/backoffice/templates/facility_opening_hours_delete.html new file mode 100644 index 00000000..2c3a8fa7 --- /dev/null +++ b/src/backoffice/templates/facility_opening_hours_delete.html @@ -0,0 +1,19 @@ +{% extends 'base.html' %} +{% load bootstrap3 %} + +{% block content %} +
+
+

Delete Facility Opening Hours for {{ object.facility.name }}?

+
+
+

This object specifies that {{ object.facility.name }} opens at {{ object.when.lower }} and closes at {{ object.when.upper }}.

+

Really delete it?

+
+ {% csrf_token %} + + Cancel +
+
+
+{% endblock content %} diff --git a/src/backoffice/templates/facility_opening_hours_form.html b/src/backoffice/templates/facility_opening_hours_form.html new file mode 100644 index 00000000..3f25e900 --- /dev/null +++ b/src/backoffice/templates/facility_opening_hours_form.html @@ -0,0 +1,22 @@ +{% extends 'base.html' %} +{% load leaflet_tags %} +{% load bootstrap3 %} +{% load static %} + +{% block content %} +
+
+

{% if request.resolver_match.url_name == "facility_opening_hours_update" %}Update{% else %}Create new{% endif %} Facility Opening Hours for {{ facility.name }}

+
+
+
+ {% csrf_token %} + {% bootstrap_form form %} + {% buttons %} + + Cancel + {% endbuttons %} +
+
+
+{% endblock content %} diff --git a/src/backoffice/templates/facility_type_delete.html b/src/backoffice/templates/facility_type_delete.html new file mode 100644 index 00000000..f6b705b2 --- /dev/null +++ b/src/backoffice/templates/facility_type_delete.html @@ -0,0 +1,18 @@ +{% extends 'base.html' %} +{% load bootstrap3 %} + +{% block content %} +
+
+

Delete FacilityType {{ facility_type.name }}?

+
+
+

This FacilityType has {{ facility_type.facilities.count }} Facilities which will also be deleted.

+
+ {% csrf_token %} + + Cancel +
+
+
+{% endblock content %} diff --git a/src/backoffice/templates/facility_type_form.html b/src/backoffice/templates/facility_type_form.html new file mode 100644 index 00000000..6b3dc4c7 --- /dev/null +++ b/src/backoffice/templates/facility_type_form.html @@ -0,0 +1,19 @@ +{% extends 'base.html' %} +{% load bootstrap3 %} + +{% block content %} +
+
+

{% if form.instance.pk %}Update{% else %}Create new{% endif %} FacilityType

+
+
+

{% if form.instance.pk %}Update{% else %}Create{% endif %} FacilityType

+
+ {% csrf_token %} + {% bootstrap_form form %} + + Cancel +
+
+
+{% endblock content %} diff --git a/src/backoffice/templates/facility_type_list_backoffice.html b/src/backoffice/templates/facility_type_list_backoffice.html new file mode 100644 index 00000000..e303fc4e --- /dev/null +++ b/src/backoffice/templates/facility_type_list_backoffice.html @@ -0,0 +1,52 @@ +{% extends 'base.html' %} +{% load leaflet_tags %} +{% load static %} + +{% block title %} +Facility Types | BackOffice | {{ block.super }} +{% endblock %} + +{% block content %} +
+
+

Facility Types - BackOffice

+
+
+

The following {{ facility_type_list.count }} facility types are defined for {{ camp.title }}

+

+ Create Facility Type + Backoffice +

+ + + + + + + + + + + + + + + {% for ft in facility_type_list %} + + + + + + + + + + + {% endfor %} +
NameDescriptionTeamIconMarkerQuickFeedbacksFacilitiesActions
{{ ft.name }}{{ ft.description|default:"N/A" }}{{ ft.responsible_team.name }} Team{{ ft.quickfeedback_options.count }}{{ ft.facilities.count }} + Update + Delete +
+
+
+{% endblock %} diff --git a/src/backoffice/templates/facilityfeedback_backoffice.html b/src/backoffice/templates/facilityfeedback_backoffice.html index 0405c30a..561723eb 100644 --- a/src/backoffice/templates/facilityfeedback_backoffice.html +++ b/src/backoffice/templates/facilityfeedback_backoffice.html @@ -60,7 +60,7 @@ Facility Feedback for {{ team.name }} Team | {{ block.super }} {% else %}

No unhandled feedback found for any Facilities managed by {{ team.name }} Team. Good job!

- Backoffice + Backoffice {% endif %} diff --git a/src/backoffice/templates/index.html b/src/backoffice/templates/index.html index 06729643..0df1db01 100644 --- a/src/backoffice/templates/index.html +++ b/src/backoffice/templates/index.html @@ -16,27 +16,36 @@

Welcome to the promised land! Please select your desired action below:

+

Facilities

+ {% if perms.camps.orgateam_permission %} + +

+ Facility Types +

+

+ See and manage facility types +

+
+ +

+ Facilities +

+

+ See and manage facilites +

+
+ {% endif %} {% for team in facilityfeedback_teams %} {% if "camps."|add:team.permission_set in perms %} - {% if forloop.first %} -

Facility Feedback

- {% endif %}

- {{ team.name }} Team + Feedback for {{ team.name }} Team

See unhandled feedback for facilities managed by {{ team.name }} Team

{% endif %} - {% empty %} -
-

N/A

-

- No unhandled Facility Feedback found! -

-
{% endfor %} {% if perms.camps.infoteam_permission %} diff --git a/src/backoffice/urls.py b/src/backoffice/urls.py index 04c6b8e0..b8a3d688 100644 --- a/src/backoffice/urls.py +++ b/src/backoffice/urls.py @@ -43,7 +43,19 @@ from .views import ( EventUpdateView, ExpenseDetailView, ExpenseListView, + FacilityCreateView, + FacilityDeleteView, + FacilityDetailView, FacilityFeedbackView, + FacilityListView, + FacilityOpeningHoursCreateView, + FacilityOpeningHoursDeleteView, + FacilityOpeningHoursUpdateView, + FacilityTypeCreateView, + FacilityTypeDeleteView, + FacilityTypeListView, + FacilityTypeUpdateView, + FacilityUpdateView, MerchandiseOrdersView, MerchandiseToOrderView, PendingProposalsView, @@ -77,11 +89,99 @@ urlpatterns = [ # proxy view path("proxy/", BackofficeProxyView.as_view(), name="proxy"), path("proxy//", BackofficeProxyView.as_view(), name="proxy"), - # facility feedback + # facilities path( "feedback/facilities//", include([path("", FacilityFeedbackView.as_view(), name="facilityfeedback")]), ), + path( + "facility_types/", + include( + [ + path("", FacilityTypeListView.as_view(), name="facility_type_list"), + path( + "create/", + FacilityTypeCreateView.as_view(), + name="facility_type_create", + ), + path( + "/", + include( + [ + path( + "update/", + FacilityTypeUpdateView.as_view(), + name="facility_type_update", + ), + path( + "delete/", + FacilityTypeDeleteView.as_view(), + name="facility_type_delete", + ), + ] + ), + ), + ] + ), + ), + path( + "facilities/", + include( + [ + path("", FacilityListView.as_view(), name="facility_list"), + path("create/", FacilityCreateView.as_view(), name="facility_create"), + path( + "/", + include( + [ + path( + "", FacilityDetailView.as_view(), name="facility_detail" + ), + path( + "update/", + FacilityUpdateView.as_view(), + name="facility_update", + ), + path( + "delete/", + FacilityDeleteView.as_view(), + name="facility_delete", + ), + path( + "opening_hours/", + include( + [ + path( + "create/", + FacilityOpeningHoursCreateView.as_view(), + name="facility_opening_hours_create", + ), + path( + "/", + include( + [ + path( + "update/", + FacilityOpeningHoursUpdateView.as_view(), + name="facility_opening_hours_update", + ), + path( + "delete/", + FacilityOpeningHoursDeleteView.as_view(), + name="facility_opening_hours_delete", + ), + ] + ), + ), + ] + ), + ), + ] + ), + ), + ] + ), + ), # infodesk path( "infodesk/", diff --git a/src/backoffice/views.py b/src/backoffice/views.py index 08e3f235..ffea3203 100644 --- a/src/backoffice/views.py +++ b/src/backoffice/views.py @@ -21,7 +21,13 @@ from django.utils.safestring import mark_safe from django.views.generic import DetailView, ListView, TemplateView from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView from economy.models import Chain, Credebtor, Expense, Reimbursement, Revenue -from facilities.models import FacilityFeedback +from facilities.models import ( + Facility, + FacilityFeedback, + FacilityOpeningHours, + FacilityType, +) +from leaflet.forms.widgets import LeafletWidget from profiles.models import Profile from program.autoscheduler import AutoScheduler from program.mixins import AvailabilityMatrixViewMixin @@ -81,59 +87,6 @@ class BackofficeIndexView(CampViewMixin, RaisePermissionRequiredMixin, TemplateV return context -class FacilityFeedbackView(CampViewMixin, RaisePermissionRequiredMixin, FormView): - template_name = "facilityfeedback_backoffice.html" - - def get_permission_required(self): - """ - This view requires two permissions, camps.backoffice_permission and - the permission_set for the team in question. - """ - if not self.team.permission_set: - raise PermissionDenied("No permissions set defined for this team") - return ["camps.backoffice_permission", self.team.permission_set] - - def setup(self, *args, **kwargs): - super().setup(*args, **kwargs) - self.team = get_object_or_404( - Team, camp=self.camp, slug=self.kwargs["team_slug"] - ) - self.queryset = FacilityFeedback.objects.filter( - facility__facility_type__responsible_team=self.team, handled=False - ) - self.form_class = modelformset_factory( - FacilityFeedback, - fields=("handled",), - min_num=self.queryset.count(), - validate_min=True, - max_num=self.queryset.count(), - validate_max=True, - extra=0, - ) - - def get_context_data(self, *args, **kwargs): - context = super().get_context_data(*args, **kwargs) - context["team"] = self.team - context["feedback_list"] = self.queryset - context["formset"] = self.form_class(queryset=self.queryset) - return context - - def form_valid(self, form): - form.save() - if form.changed_objects: - messages.success( - self.request, - f"Marked {len(form.changed_objects)} FacilityFeedbacks as handled!", - ) - return redirect(self.get_success_url()) - - def get_success_url(self, *args, **kwargs): - return reverse( - "backoffice:facilityfeedback", - kwargs={"camp_slug": self.camp.slug, "team_slug": self.team.slug}, - ) - - class ProductHandoutView(CampViewMixin, InfoTeamPermissionMixin, ListView): template_name = "product_handout.html" @@ -227,6 +180,298 @@ class ApproveFeedbackView(CampViewMixin, ContentTeamPermissionMixin, FormView): ) +########################## +# MANAGE FACILITIES VIEWS + + +class FacilityTypeListView(CampViewMixin, OrgaTeamPermissionMixin, ListView): + model = FacilityType + template_name = "facility_type_list_backoffice.html" + context_object_name = "facility_type_list" + + +class FacilityTypeCreateView(CampViewMixin, OrgaTeamPermissionMixin, CreateView): + model = FacilityType + template_name = "facility_type_form.html" + fields = [ + "name", + "description", + "icon", + "marker", + "responsible_team", + "quickfeedback_options", + ] + + def get_context_data(self, **kwargs): + """ + Do not show teams that are not part of the current camp in the dropdown + """ + context = super().get_context_data(**kwargs) + context["form"].fields["responsible_team"].queryset = Team.objects.filter( + camp=self.camp + ) + return context + + def get_success_url(self): + return reverse( + "backoffice:facility_type_list", kwargs={"camp_slug": self.camp.slug} + ) + + +class FacilityTypeUpdateView(CampViewMixin, OrgaTeamPermissionMixin, UpdateView): + model = FacilityType + template_name = "facility_type_form.html" + fields = [ + "name", + "description", + "icon", + "marker", + "responsible_team", + "quickfeedback_options", + ] + + def get_context_data(self, **kwargs): + """ + Do not show teams that are not part of the current camp in the dropdown + """ + context = super().get_context_data(**kwargs) + context["form"].fields["responsible_team"].queryset = Team.objects.filter( + camp=self.camp + ) + return context + + def get_success_url(self): + return reverse( + "backoffice:facility_type_list", kwargs={"camp_slug": self.camp.slug} + ) + + +class FacilityTypeDeleteView(CampViewMixin, OrgaTeamPermissionMixin, DeleteView): + model = FacilityType + template_name = "facility_type_delete.html" + context_object_name = "facility_type" + + def delete(self, *args, **kwargs): + for facility in self.get_object().facilities.all(): + facility.feedbacks.all().delete() + facility.opening_hours.all().delete() + facility.delete() + return super().delete(*args, **kwargs) + + def get_success_url(self): + messages.success(self.request, "The FacilityType has been deleted") + return reverse( + "backoffice:facility_type_list", kwargs={"camp_slug": self.camp.slug} + ) + + +class FacilityListView(CampViewMixin, OrgaTeamPermissionMixin, ListView): + model = Facility + template_name = "facility_list_backoffice.html" + + +class FacilityDetailView(CampViewMixin, OrgaTeamPermissionMixin, DetailView): + model = Facility + template_name = "facility_detail_backoffice.html" + pk_url_kwarg = "facility_uuid" + + def get_queryset(self, *args, **kwargs): + qs = super().get_queryset(*args, **kwargs) + return qs.prefetch_related("opening_hours") + + +class FacilityCreateView(CampViewMixin, OrgaTeamPermissionMixin, CreateView): + model = Facility + template_name = "facility_form.html" + fields = ["facility_type", "name", "description", "location"] + + def get_form(self, *args, **kwargs): + form = super().get_form(*args, **kwargs) + form.fields["location"].widget = LeafletWidget(attrs={"display_raw": "true",}) + return form + + def get_context_data(self, **kwargs): + """ + Do not show types that are not part of the current camp in the dropdown + """ + context = super().get_context_data(**kwargs) + context["form"].fields["facility_type"].queryset = FacilityType.objects.filter( + responsible_team__camp=self.camp + ) + return context + + def get_success_url(self): + messages.success(self.request, "The Facility has been created") + return reverse("backoffice:facility_list", kwargs={"camp_slug": self.camp.slug}) + + +class FacilityUpdateView(CampViewMixin, OrgaTeamPermissionMixin, UpdateView): + model = Facility + template_name = "facility_form.html" + pk_url_kwarg = "facility_uuid" + fields = ["facility_type", "name", "description", "location"] + + def get_form(self, *args, **kwargs): + form = super().get_form(*args, **kwargs) + form.fields["location"].widget = LeafletWidget(attrs={"display_raw": "true",}) + return form + + def get_success_url(self): + messages.success(self.request, "The Facility has been updated") + return reverse( + "backoffice:facility_detail", + kwargs={ + "camp_slug": self.camp.slug, + "facility_uuid": self.get_object().uuid, + }, + ) + + +class FacilityDeleteView(CampViewMixin, OrgaTeamPermissionMixin, DeleteView): + model = Facility + template_name = "facility_delete.html" + pk_url_kwarg = "facility_uuid" + + def delete(self, *args, **kwargs): + self.get_object().feedbacks.all().delete() + self.get_object().opening_hours.all().delete() + return super().delete(*args, **kwargs) + + def get_success_url(self): + messages.success(self.request, "The Facility has been deleted") + return reverse("backoffice:facility_list", kwargs={"camp_slug": self.camp.slug}) + + +class FacilityFeedbackView(CampViewMixin, RaisePermissionRequiredMixin, FormView): + template_name = "facilityfeedback_backoffice.html" + + def get_permission_required(self): + """ + This view requires two permissions, camps.backoffice_permission and + the permission_set for the team in question. + """ + if not self.team.permission_set: + raise PermissionDenied("No permissions set defined for this team") + return ["camps.backoffice_permission", self.team.permission_set] + + def setup(self, *args, **kwargs): + super().setup(*args, **kwargs) + self.team = get_object_or_404( + Team, camp=self.camp, slug=self.kwargs["team_slug"] + ) + self.queryset = FacilityFeedback.objects.filter( + facility__facility_type__responsible_team=self.team, handled=False + ) + self.form_class = modelformset_factory( + FacilityFeedback, + fields=("handled",), + min_num=self.queryset.count(), + validate_min=True, + max_num=self.queryset.count(), + validate_max=True, + extra=0, + ) + + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(*args, **kwargs) + context["team"] = self.team + context["feedback_list"] = self.queryset + context["formset"] = self.form_class(queryset=self.queryset) + return context + + def form_valid(self, form): + form.save() + if form.changed_objects: + messages.success( + self.request, + f"Marked {len(form.changed_objects)} FacilityFeedbacks as handled!", + ) + return redirect(self.get_success_url()) + + def get_success_url(self, *args, **kwargs): + return reverse( + "backoffice:facilityfeedback", + kwargs={"camp_slug": self.camp.slug, "team_slug": self.team.slug}, + ) + + +class FacilityMixin(CampViewMixin): + def setup(self, *args, **kwargs): + super().setup(*args, **kwargs) + self.facility = get_object_or_404(Facility, uuid=kwargs["facility_uuid"]) + + def get_form(self, *args, **kwargs): + """ + The default range widgets are a bit shit because they eat the help_text and + have no indication of which field is for what. So we add a nice placeholder. + """ + form = super().get_form(*args, **kwargs) + form.fields["when"].widget.widgets[0].attrs = { + "placeholder": f"Open Date and Time (YYYY-MM-DD HH:MM). Active time zone is {settings.TIME_ZONE}.", + } + form.fields["when"].widget.widgets[1].attrs = { + "placeholder": f"Close Date and Time (YYYY-MM-DD HH:MM). Active time zone is {settings.TIME_ZONE}.", + } + return form + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["facility"] = self.facility + return context + + +class FacilityOpeningHoursCreateView( + FacilityMixin, OrgaTeamPermissionMixin, CreateView +): + model = FacilityOpeningHours + template_name = "facility_opening_hours_form.html" + fields = ["when", "notes"] + + def form_valid(self, form): + """ + Set facility before saving + """ + hours = form.save(commit=False) + hours.facility = self.facility + hours.save() + messages.success(self.request, f"New opening hours created successfully!") + return redirect( + reverse( + "backoffice:facility_detail", + kwargs={"camp_slug": self.camp.slug, "facility_uuid": self.facility.pk}, + ) + ) + + +class FacilityOpeningHoursUpdateView( + FacilityMixin, OrgaTeamPermissionMixin, UpdateView +): + model = FacilityOpeningHours + template_name = "facility_opening_hours_form.html" + fields = ["when", "notes"] + + def get_success_url(self): + messages.success(self.request, "Opening hours have been updated successfully") + return reverse( + "backoffice:facility_detail", + kwargs={"camp_slug": self.camp.slug, "facility_uuid": self.facility.pk}, + ) + + +class FacilityOpeningHoursDeleteView( + FacilityMixin, OrgaTeamPermissionMixin, DeleteView +): + model = FacilityOpeningHours + template_name = "facility_opening_hours_delete.html" + + def get_success_url(self): + messages.success(self.request, "Opening hours have been deleted successfully") + return reverse( + "backoffice:facility_detail", + kwargs={"camp_slug": self.camp.slug, "facility_uuid": self.facility.pk}, + ) + + ####################################### # MANAGE SPEAKER/EVENT PROPOSAL VIEWS diff --git a/src/bornhack/settings.py b/src/bornhack/settings.py index 14a6f8d9..4a3c2861 100644 --- a/src/bornhack/settings.py +++ b/src/bornhack/settings.py @@ -189,3 +189,6 @@ LOGGING = { } GRAPHENE = {"SCHEMA": "bornhack.schema.schema"} +LEAFLET_CONFIG = { + "PLUGINS": {"forms": {"auto-include": True}}, +} diff --git a/src/facilities/admin.py b/src/facilities/admin.py index ec00cff3..177a830a 100644 --- a/src/facilities/admin.py +++ b/src/facilities/admin.py @@ -2,7 +2,13 @@ from django.contrib import admin from django.utils.html import format_html from leaflet.admin import LeafletGeoAdmin -from .models import Facility, FacilityFeedback, FacilityQuickFeedback, FacilityType +from .models import ( + Facility, + FacilityFeedback, + FacilityOpeningHours, + FacilityQuickFeedback, + FacilityType, +) @admin.register(FacilityQuickFeedback) @@ -63,3 +69,9 @@ class FacilityFeedbackAdmin(admin.ModelAdmin): "facility__facility_type__responsible_team", "facility", ] + + +@admin.register(FacilityOpeningHours) +class FacilityOpeningHoursAdmin(admin.ModelAdmin): + list_display = ["facility", "when", "notes"] + list_filter = ["facility"] diff --git a/src/facilities/migrations/0005_facilityopeninghours.py b/src/facilities/migrations/0005_facilityopeninghours.py new file mode 100644 index 00000000..55c885f8 --- /dev/null +++ b/src/facilities/migrations/0005_facilityopeninghours.py @@ -0,0 +1,55 @@ +# Generated by Django 3.0.3 on 2020-06-06 12:06 + +import django.contrib.postgres.fields.ranges +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("facilities", "0004_facilitytype_marker"), + ] + + operations = [ + migrations.CreateModel( + name="FacilityOpeningHours", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ( + "when", + django.contrib.postgres.fields.ranges.DateTimeRangeField( + db_index=True, + help_text="The period when this facility is open.", + ), + ), + ( + "notes", + models.TextField( + blank=True, + help_text="Any notes for this period like 'no hot food after 20' or 'no alcohol sale after 02'. Optional.", + ), + ), + ( + "facility", + models.ForeignKey( + help_text="The Facility to which these opening hours belong.", + on_delete=django.db.models.deletion.PROTECT, + related_name="opening_hours", + to="facilities.Facility", + ), + ), + ], + options={"abstract": False,}, + ), + ] diff --git a/src/facilities/migrations/0006_auto_20200616_2330.py b/src/facilities/migrations/0006_auto_20200616_2330.py new file mode 100644 index 00000000..381d2c92 --- /dev/null +++ b/src/facilities/migrations/0006_auto_20200616_2330.py @@ -0,0 +1,24 @@ +# Generated by Django 3.0.3 on 2020-06-16 21:30 + +import django.contrib.gis.db.models.fields +import django.contrib.gis.geos.point +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("facilities", "0005_facilityopeninghours"), + ] + + operations = [ + migrations.AlterField( + model_name="facility", + name="location", + field=django.contrib.gis.db.models.fields.PointField( + default=django.contrib.gis.geos.point.Point(9.93891, 55.38562), + help_text="The location of this facility", + srid=4326, + ), + ), + ] diff --git a/src/facilities/migrations/0007_default_location_and_hours_overlap_contraint.py b/src/facilities/migrations/0007_default_location_and_hours_overlap_contraint.py new file mode 100644 index 00000000..b61fd55d --- /dev/null +++ b/src/facilities/migrations/0007_default_location_and_hours_overlap_contraint.py @@ -0,0 +1,35 @@ +# Generated by Django 3.0.3 on 2020-06-17 15:48 + +import django.contrib.gis.db.models.fields +import django.contrib.gis.geos.point +import django.contrib.postgres.constraints +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("facilities", "0006_auto_20200616_2330"), + ] + + operations = [ + migrations.AlterModelOptions( + name="facilityopeninghours", options={"ordering": ["when"]}, + ), + migrations.AlterField( + model_name="facility", + name="location", + field=django.contrib.gis.db.models.fields.PointField( + default=django.contrib.gis.geos.point.Point(9.93891, 55.38562), + help_text="The location of this facility.", + srid=4326, + ), + ), + migrations.AddConstraint( + model_name="facilityopeninghours", + constraint=django.contrib.postgres.constraints.ExclusionConstraint( + expressions=[("when", "&&"), ("facility", "=")], + name="prevent_facility_opening_hours_overlaps", + ), + ), + ] diff --git a/src/facilities/models.py b/src/facilities/models.py index df0930b2..ef1fc556 100644 --- a/src/facilities/models.py +++ b/src/facilities/models.py @@ -4,6 +4,9 @@ import logging import qrcode from django.contrib.gis.db.models import PointField +from django.contrib.gis.geos import Point +from django.contrib.postgres.constraints import ExclusionConstraint +from django.contrib.postgres.fields import DateTimeRangeField, RangeOperators from django.db import models from django.shortcuts import reverse from maps.utils import LeafletMarkerChoices @@ -37,7 +40,7 @@ class FacilityType(CampRelatedModel): """ Facility types are used to group similar facilities, like Toilets, Showers, Thrashcans... facilities.Type has a m2m relationship with FeedbackChoice which determines which choices - are presented for giving feedback for this Facility + are presented for giving feedback for facilities of this type """ class Meta: @@ -113,7 +116,10 @@ class Facility(CampRelatedModel, UUIDModel): description = models.TextField(help_text="Description of this facility") - location = PointField(help_text="The location of this facility") + # default to near the workshop rooms / cabins + location = PointField( + default=Point(9.93891, 55.38562), help_text="The location of this facility." + ) @property def team(self): @@ -151,6 +157,9 @@ class Facility(CampRelatedModel, UUIDModel): qrcode_base64 = base64.b64encode(file_like.getvalue()).decode("utf-8") return f"data:image/png;base64,{qrcode_base64}" + def unhandled_feedbacks(self): + return self.feedbacks.filter(handled=False) + class FacilityFeedback(CampRelatedModel): """ @@ -210,3 +219,51 @@ class FacilityFeedback(CampRelatedModel): return self.facility.camp camp_filter = "facility__facility_type__responsible_team__camp" + + +class FacilityOpeningHours(CampRelatedModel): + """ + This model contains opening hours for facilities which are not always open. + If a facility has zero entries in this model it means is always open. + If a facility has one or more periods of opening hours defined in this model + it is considered closed outside of the period(s) defined in this model. + """ + + class Meta: + ordering = ["when"] + constraints = [ + # we do not want overlapping hours for the same Facility + ExclusionConstraint( + name="prevent_facility_opening_hours_overlaps", + expressions=[ + ("when", RangeOperators.OVERLAPS), + ("facility", RangeOperators.EQUAL), + ], + ), + ] + + facility = models.ForeignKey( + "facilities.Facility", + related_name="opening_hours", + on_delete=models.PROTECT, + help_text="The Facility to which these opening hours belong.", + ) + + when = DateTimeRangeField( + db_index=True, help_text="The period when this facility is open.", + ) + + notes = models.TextField( + blank=True, + help_text="Any notes for this period like 'no hot food after 20' or 'no alcohol sale after 02'. Optional.", + ) + + @property + def camp(self): + return self.facility.camp + + camp_filter = "facility__facility_type__responsible_team__camp" + + @property + def duration(self): + return self.when.upper - self.when.lower diff --git a/src/facilities/templates/facility_detail.html b/src/facilities/templates/facility_detail.html index c84bccda..d783fd0d 100644 --- a/src/facilities/templates/facility_detail.html +++ b/src/facilities/templates/facility_detail.html @@ -1,6 +1,7 @@ {% extends 'program_base.html' %} {% load leaflet_tags %} {% load static %} +{% load commonmark %} {% block extra_head %} {% leaflet_css %} @@ -20,13 +21,61 @@

{{ facility.facility_type.name }}: {{ facility.name }}

-

- {{ facility.name }} {{ facility.description }}

+ + + + + + + + + + + + + + + + + + + + + + +
Facility Name{{ facility.name }}
Facility Type {{ facility.facility_type.name }}

+
Description{{ facility.description }}
LocationLat {{ facility.location.y }} Long {{ facility.location.x }}
Opening Hours + {% if facility.opening_hours.exists %} + + + + + + + + + + + {% for opening in facility.opening_hours.all %} + + + + + + + {% endfor %} + +
OpensClosesDurationNotes
{{ opening.when.lower }}{{ opening.when.upper }}{{ opening.duration }}{{ opening.notes|trustedcommonmark|default:"N/A" }}
+ {% else %} + This facility does not have opening hours, it is always open. + {% endif %} +
+

{% if request.user.is_authenticated %} Submit Feedback {% endif %} - Back to {{ facilitytype.name }} list -

+ Back to {{ facilitytype.name }} list +

@@ -35,7 +84,7 @@ function MapReadyCallback() { // add a marker for this facility {% url "facilities:facility_feedback" camp_slug=facility.camp.slug facility_type_slug=facility.facility_type.slug facility_uuid=facility.uuid as feedback %} - var marker = L.marker([{{ facility.location.y }}, {{ facility.location.x }}]) + var marker = L.marker([{{ facility.location.y }}, {{ facility.location.x }}], {icon: {{ facility.facility_type.marker }}}) marker.bindPopup("{{ facility.name }}

{{ facility.description }}

Responsible team: {{ facility.facility_type.responsible_team.name }} Team

{% if request.user.is_authenticated %}

Feedback

{% endif %}").addTo(this); // max zoom since we have only one marker this.setView(marker.getLatLng(), 13); diff --git a/src/facilities/templates/facility_list.html b/src/facilities/templates/facility_list.html index 05bd97da..997026ed 100644 --- a/src/facilities/templates/facility_list.html +++ b/src/facilities/templates/facility_list.html @@ -17,7 +17,7 @@ Facilities of type {{ facilitytype }} | {{ block.super }} {% block content %}
-

Facilities of type {{ facilitytype }}

+

Facilities of type {{ facilitytype }}

@@ -42,7 +42,7 @@ Facilities of type {{ facilitytype }} | {{ block.super }}

- Back to facility type list + Back to facility type list
diff --git a/src/facilities/views.py b/src/facilities/views.py index cde399ad..40d79fb4 100644 --- a/src/facilities/views.py +++ b/src/facilities/views.py @@ -29,18 +29,19 @@ class FacilityDetailView(FacilityTypeViewMixin, DetailView): template_name = "facility_detail.html" pk_url_kwarg = "facility_uuid" + def get_queryset(self, *args, **kwargs): + qs = super().get_queryset(*args, **kwargs) + return qs.prefetch_related("opening_hours") + class FacilityFeedbackView(FacilityViewMixin, CreateView): model = FacilityFeedback template_name = "facility_feedback.html" fields = ["quick_feedback", "comment", "urgent"] - def get_initial(self, *args, **kwargs): - initial = super().get_initial(*args, **kwargs) - return initial - def get_form(self, form_class=None): """ + - Add quick feedback field to the form - Add anon option to the form """ form = super().get_form(form_class) diff --git a/src/static_src/css/bornhack.css b/src/static_src/css/bornhack.css index a6195c9b..863a775b 100644 --- a/src/static_src/css/bornhack.css +++ b/src/static_src/css/bornhack.css @@ -339,3 +339,8 @@ body.bar-menu { .no-js .hide-for-no-js-users { display: none; } + +/* hide map in forms until we can show a real leaflet map */ +div #id_location-map { + display: none; +} diff --git a/src/static_src/js/kfmap.js b/src/static_src/js/kfmap.js index 7343004e..0fd2c3bc 100644 --- a/src/static_src/js/kfmap.js +++ b/src/static_src/js/kfmap.js @@ -96,6 +96,39 @@ // Add scale line to map, disable imperial units L.control.scale({imperial: false}).addTo(map); + + var Position = L.Control.extend({ + _container: null, + options: { + position: 'bottomright' + }, + + onAdd: function (map) { + var latlng = L.DomUtil.create('div', 'mouseposition'); + latlng.style = 'background: rgba(255, 255, 255, 0.7);'; + this._latlng = latlng; + return latlng; + }, + + updateHTML: function(lat, lng) { + this._latlng.innerHTML = " Lat: " + lat + " Lng: " + lng + "
 Right click to copy coordinates "; + } + }); + position = new Position(); + map.addControl(position); + var lat; + var lng; + map.addEventListener('mousemove', (event) => { + lat = Math.round(event.latlng.lat * 100000) / 100000; + lng = Math.round(event.latlng.lng * 100000) / 100000; + this.position.updateHTML(lat, lng); + }); + + map.addEventListener("contextmenu", (event) => { + alert("Lat: " + lat + " Lng: " + lng + '\n\nGeoJSON:\n{ "type": "Point", "coordinates": [ ' + lng + ', ' + lat + ' ] }'); + return false; // To disable default popup. + }); + // fire our callback when ready map.whenReady(MapReadyCallback); })();