diff --git a/src/backoffice/mixins.py b/src/backoffice/mixins.py index fcf368ba..ee43b700 100644 --- a/src/backoffice/mixins.py +++ b/src/backoffice/mixins.py @@ -35,5 +35,5 @@ class ContentTeamPermissionMixin(RaisePermissionRequiredMixin): permission_required = ( "camps.backoffice_permission", - "program.contentteam_permission", + "camps.contentteam_permission", ) diff --git a/src/bornhack/settings.py b/src/bornhack/settings.py index 74339cb3..40c5de96 100644 --- a/src/bornhack/settings.py +++ b/src/bornhack/settings.py @@ -53,6 +53,7 @@ INSTALLED_APPS = [ "economy", "wishlist", "facilities", + "phonebook", "allauth", "allauth.account", "allauth_2fa", @@ -63,6 +64,7 @@ INSTALLED_APPS = [ "django_extensions", "reversion", "leaflet", + "oauth2_provider", ] # MEDIA_URL = '/media/' @@ -98,6 +100,7 @@ TEMPLATES = [ ] AUTHENTICATION_BACKENDS = ( + "oauth2_provider.backends.OAuth2Backend", "django.contrib.auth.backends.ModelBackend", # Handles login to admin with username "allauth.account.auth_backends.AuthenticationBackend", # Handles regular logins ) @@ -127,6 +130,7 @@ MIDDLEWARE = [ "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "utils.middleware.RedirectExceptionMiddleware", + "oauth2_provider.middleware.OAuth2TokenMiddleware", ] CORS_ORIGIN_ALLOW_ALL = True diff --git a/src/bornhack/urls.py b/src/bornhack/urls.py index 8bbda5d7..97e01eb3 100644 --- a/src/bornhack/urls.py +++ b/src/bornhack/urls.py @@ -25,6 +25,7 @@ from villages.views import ( admin.site.login = login_required(admin.site.login) urlpatterns = [ + path("o/", include("oauth2_provider.urls", namespace="oauth2_provider")), path("profile/", include("allauth.urls")), path("profile/", include("allauth_2fa.urls")), path("profile/", include("profiles.urls", namespace="profiles")), @@ -95,6 +96,12 @@ urlpatterns = [ kwargs={"page": "backoffice:index"}, name="backoffice_redirect", ), + path( + "phonebook/", + CampRedirectView.as_view(), + kwargs={"page": "phonebook:list"}, + name="phone_book_redirect", + ), path("people/", PeopleView.as_view(), name="people"), # camp specific urls below here path( @@ -142,6 +149,7 @@ urlpatterns = [ path("economy/", include("economy.urls", namespace="economy")), path("wishlist/", include("wishlist.urls", namespace="wishlist")), path("facilities/", include("facilities.urls", namespace="facilities")), + path("phonebook/", include("phonebook.urls", namespace="phonebook")), ] ), ), diff --git a/src/phonebook/__init__.py b/src/phonebook/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/phonebook/admin.py b/src/phonebook/admin.py new file mode 100644 index 00000000..e204d91d --- /dev/null +++ b/src/phonebook/admin.py @@ -0,0 +1,17 @@ +from django.contrib import admin + +from .models import DectRegistration + + +@admin.register(DectRegistration) +class ProfileAdmin(admin.ModelAdmin): + list_display = [ + "camp", + "user", + "number", + "letters", + "description", + "activation_code", + "publish_in_phonebook", + ] + list_filter = ["camp", "publish_in_phonebook", "user"] diff --git a/src/phonebook/apps.py b/src/phonebook/apps.py new file mode 100644 index 00000000..72a6b79d --- /dev/null +++ b/src/phonebook/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class PhonebookConfig(AppConfig): + name = "phonebook" diff --git a/src/phonebook/dectutils.py b/src/phonebook/dectutils.py new file mode 100644 index 00000000..7be4eff4 --- /dev/null +++ b/src/phonebook/dectutils.py @@ -0,0 +1,58 @@ +import logging + +logger = logging.getLogger("bornhack.%s" % __name__) + + +class DectUtils: + """ + This class contains dect number <> letter related utilities + """ + + DECT_MATRIX = { + "2": ["A", "B", "C"], + "3": ["D", "E", "F"], + "4": ["G", "H", "I"], + "5": ["J", "K", "L"], + "6": ["M", "N", "O"], + "7": ["P", "Q", "R", "S"], + "8": ["T", "U", "V"], + "9": ["W", "X", "Y", "Z"], + } + + def __init__(self): + """ + Build a reverse lookup matrix based on self.DECT_MATRIX + """ + self.REVERSE_DECT_MATRIX = {} + for digit in self.DECT_MATRIX.keys(): + for letter in self.DECT_MATRIX[digit]: + self.REVERSE_DECT_MATRIX[letter] = digit + + def get_dect_letter_combinations(self, numbers): + """ + Generator to recursively get all combinations of letters for this number + """ + if "0" in numbers or "1" in numbers: + logger.error( + "Numbers with 0 or 1 in them are not expressible as letters, bail out" + ) + return False + # loop over the possible letters for the first digit + for letter in self.DECT_MATRIX[numbers[0]]: + # if we have more digits.. + if len(numbers) > 1: + # call recursively with the remaining digits, and loop over the result + for nextletter in self.get_dect_letter_combinations(numbers[1:]): + yield letter + nextletter + else: + # no more digits left, just yield the current letter + yield letter + + def letters_to_number(self, letters): + """ + Coverts "TYKL" to "8955" + """ + result = "" + for letter in letters: + result += self.REVERSE_DECT_MATRIX[letter] + return result diff --git a/src/phonebook/migrations/0001_initial.py b/src/phonebook/migrations/0001_initial.py new file mode 100644 index 00000000..5a5b6477 --- /dev/null +++ b/src/phonebook/migrations/0001_initial.py @@ -0,0 +1,87 @@ +# Generated by Django 3.0.3 on 2020-02-25 18:39 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("camps", "0034_add_team_permission_sets"), + ] + + operations = [ + migrations.CreateModel( + name="DectRegistration", + 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)), + ( + "number", + models.CharField( + help_text="The DECT number, numeric or as letters", max_length=9 + ), + ), + ( + "letters", + models.CharField( + blank=True, + help_text="The letters chosen to represent this DECT number in the phonebook. Optional.", + max_length=9, + ), + ), + ( + "description", + models.TextField( + blank=True, + help_text="Description of this registration, like a name or a location or a service.", + ), + ), + ( + "activation_code", + models.CharField( + blank=True, + help_text="The 10 digit numeric activation code", + max_length=10, + ), + ), + ( + "publish_in_phonebook", + models.BooleanField( + default=True, + help_text="Check to list this registration in the phonebook", + ), + ), + ( + "camp", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="dect_registrations", + to="camps.Camp", + ), + ), + ( + "user", + models.ForeignKey( + help_text="The django user who created this DECT registration", + on_delete=django.db.models.deletion.PROTECT, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={"unique_together": {("camp", "number")},}, + ), + ] diff --git a/src/phonebook/migrations/__init__.py b/src/phonebook/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/phonebook/mixins.py b/src/phonebook/mixins.py new file mode 100644 index 00000000..15d89487 --- /dev/null +++ b/src/phonebook/mixins.py @@ -0,0 +1,8 @@ +from camps.mixins import CampViewMixin + +# from .models import DectRegistration + + +class DectRegistrationViewMixin(CampViewMixin): + def get_object(self, *args, **kwargs): + return self.model.objects.get(camp=self.camp, number=self.kwargs["dect_number"]) diff --git a/src/phonebook/models.py b/src/phonebook/models.py new file mode 100644 index 00000000..79f78911 --- /dev/null +++ b/src/phonebook/models.py @@ -0,0 +1,130 @@ +import logging + +from django.contrib.auth.models import User +from django.core.exceptions import ValidationError +from django.db import models +from utils.models import CampRelatedModel + +from .dectutils import DectUtils + +logger = logging.getLogger("bornhack.%s" % __name__) + + +class DectRegistration(CampRelatedModel): + """ + This model contains DECT registrations for users and services + """ + + class Meta: + unique_together = [("camp", "number")] + + camp = models.ForeignKey( + "camps.Camp", related_name="dect_registrations", on_delete=models.PROTECT, + ) + + user = models.ForeignKey( + User, + on_delete=models.PROTECT, + help_text="The django user who created this DECT registration", + ) + + number = models.CharField( + max_length=9, help_text="The DECT number, numeric or as letters", + ) + + letters = models.CharField( + max_length=9, + blank=True, + help_text="The letters chosen to represent this DECT number in the phonebook. Optional.", + ) + + description = models.TextField( + blank=True, + help_text="Description of this registration, like a name or a location or a service.", + ) + + activation_code = models.CharField( + max_length=10, blank=True, help_text="The 10 digit numeric activation code", + ) + + publish_in_phonebook = models.BooleanField( + default=True, help_text="Check to list this registration in the phonebook", + ) + + def save(self, *args, **kwargs): + """ + This is just here so we get the validation in the admin as well. + """ + self.clean_number() + self.clean_letters() + super().save(*args, **kwargs) + + def clean_number(self): + """ + We call this from the views form_valid() so we have a Camp object available for the validation. + This code really belongs in model.clean(), but that gets called before form_valid() + which is where we set the Camp object for the model instance. + """ + # check for conflicts with the same number + if ( + DectRegistration.objects.filter(camp=self.camp, number=self.number) + .exclude(pk=self.pk) + .exists() + ): + raise ValidationError("This DECT number is in use") + + # check for conflicts with a longer number + if ( + DectRegistration.objects.filter( + camp=self.camp, number__startswith=self.number + ) + .exclude(pk=self.pk) + .exists() + ): + raise ValidationError( + "This DECT number is not available, it conflicts with a longer number." + ) + + # check if a shorter number is blocking + i = len(self.number) - 1 + while i: + if ( + DectRegistration.objects.filter(camp=self.camp, number=self.number[:i]) + .exclude(pk=self.pk) + .exists() + ): + raise ValidationError( + "This DECT number is not available, it conflicts with a shorter number." + ) + i -= 1 + + def clean_letters(self): + """ + We call this from the views form_valid() so we have a Camp object available for the validation. + This code really belongs in model.clean(), but that gets called before form_valid() + which is where we set the Camp object for the model instance. + """ + # if we have a letter representation of this number they should have the same length + if self.letters: + if len(self.letters) != len(self.number): + raise ValidationError( + f"Wrong number of letters ({len(self.letters)}) - should be {len(self.number)}" + ) + + # loop over the digits in the phonenumber + dectutil = DectUtils() + combinations = list(dectutil.get_dect_letter_combinations(self.number)) + if not combinations: + raise ValidationError( + "Numbers with 0 and 1 in them can not be expressed as letters" + ) + + if self.letters not in list(combinations): + # something is fucky, loop over letters to give a better error message + i = 0 + for digit in self.number: + if self.letters[i].upper() not in dectutil.DECT_MATRIX[digit]: + raise ValidationError( + f"The digit '{digit}' does not match the letter '{self.letters[i]}'. Valid letters for the digit '{digit}' are: {dectutil.DECT_MATRIX[digit]}" + ) + i += 1 diff --git a/src/phonebook/templates/dectregistration_delete.html b/src/phonebook/templates/dectregistration_delete.html new file mode 100644 index 00000000..6a70528a --- /dev/null +++ b/src/phonebook/templates/dectregistration_delete.html @@ -0,0 +1,26 @@ +{% extends 'base.html' %} +{% load bootstrap3 %} + +{% block title %} +Delete DECT Registration | {{ block.super }} +{% endblock %} + +{% block content %} +
+
+ Really delete DECT registration {{ dectregistration.number }}? +
+
+

Are you sure you want to delete the registration of DECT number {{ dectregistration.number }}? +

+
+ {% csrf_token %} + + Cancel +
+
+
+{% endblock %} diff --git a/src/phonebook/templates/dectregistration_form.html b/src/phonebook/templates/dectregistration_form.html new file mode 100644 index 00000000..cb3a0823 --- /dev/null +++ b/src/phonebook/templates/dectregistration_form.html @@ -0,0 +1,24 @@ +{% extends 'program_base.html' %} +{% load bootstrap3 %} + +{% block title %} +{% if request.resolver_match.url_name == "dectregistration_update" %}Update{% else %}Create{% endif %} DECT Registration | {{ block.super }} +{% endblock %} + +{% block content %} +
+
+ {% if request.resolver_match.url_name == "dectregistration_update" %}Update{% else %}Create New{% endif %} DECT Registration +
+
+
+ {% csrf_token %} + {% bootstrap_form form %} + + Cancel +
+
+
+{% endblock content %} diff --git a/src/phonebook/templates/dectregistration_list.html b/src/phonebook/templates/dectregistration_list.html new file mode 100644 index 00000000..f8e8661f --- /dev/null +++ b/src/phonebook/templates/dectregistration_list.html @@ -0,0 +1,58 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %} +Your DECT Registrations | {{ block.super }} +{% endblock %} + + +{% block content %} +
+
+ Your {{ camp.title }} DECT Registrations +
+
+

This is a list of all the registered DECT numbers you have for {{ camp.title }}. To activate a handset just dial the activation code from the handset and follow instructions.

+ {% if dectregistration_list %} +

+ Phonebook + Create DECT Registration +

+ + + + + + + + + + + + {% for entry in dectregistration_list %} + + + + + + + + + + + {% endfor %} +
NumberLettersDescriptionPublish in PhonebookActivation CodeCreatedModifiedActions
{{ entry.number }}{{ entry.letters|default:"N/A" }}{{ entry.description|default:"N/A" }}{{ entry.publish_in_phonebook|yesno }}{{ entry.activation_code }}{{ entry.created }}{{ entry.updated }} + Update + Delete +
+ {% else %} +

No DECT registrations found. Go create one!

+

+ Phonebook + Create DECT Registration +

+ {% endif %} +
+
+{% endblock %} + diff --git a/src/phonebook/templates/phonebook.html b/src/phonebook/templates/phonebook.html new file mode 100644 index 00000000..b8fc24f1 --- /dev/null +++ b/src/phonebook/templates/phonebook.html @@ -0,0 +1,53 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %} +Phonebook | {{ block.super }} +{% endblock %} + +{% block extra_head %} + + +{% endblock extra_head %} + +{% block content %} + +

{{ camp.title }} Phonebook

+ +

+This is a list of all the registered DECT numbers in our phonebook for {{ camp.title }}. +

+ +{% if request.user.is_authenticated %} +

+ Your DECT Registrations + Create DECT Registration +

+{% endif %} + +{% if dectregistration_list %} + + + + + + + + + + + {% for entry in dectregistration_list %} + + + + + + + {% endfor %} + +
NumberLettersDescriptionCreated
{{ entry.number }}{{ entry.letters|default:"N/A" }}{{ entry.description|default:"N/A" }}{{ entry.created }}
+{% else %} +

No DECT registrations found

+{% endif %} +{% endblock %} + diff --git a/src/phonebook/urls.py b/src/phonebook/urls.py new file mode 100644 index 00000000..cfe8c4a9 --- /dev/null +++ b/src/phonebook/urls.py @@ -0,0 +1,48 @@ +from django.urls import include, path + +from .views import ( + DectExportView, + DectRegistrationCreateView, + DectRegistrationDeleteView, + DectRegistrationListView, + DectRegistrationUpdateView, + PhonebookListView, +) + +app_name = "phonebook" +urlpatterns = [ + path("", PhonebookListView.as_view(), name="list"), + path("csv/", DectExportView.as_view(), name="csv"), + path( + "dectregistrations/", + include( + [ + path( + "", DectRegistrationListView.as_view(), name="dectregistration_list" + ), + path( + "create/", + DectRegistrationCreateView.as_view(), + name="dectregistration_create", + ), + path( + "/", + include( + [ + path( + "update/", + DectRegistrationUpdateView.as_view(), + name="dectregistration_update", + ), + path( + "delete/", + DectRegistrationDeleteView.as_view(), + name="dectregistration_delete", + ), + ] + ), + ), + ] + ), + ), +] diff --git a/src/phonebook/views.py b/src/phonebook/views.py new file mode 100644 index 00000000..9f5f0f18 --- /dev/null +++ b/src/phonebook/views.py @@ -0,0 +1,168 @@ +import csv +import logging +import secrets +import string + +from camps.mixins import CampViewMixin +from django.contrib import messages +from django.contrib.auth.mixins import LoginRequiredMixin +from django.core.exceptions import ValidationError +from django.http import HttpResponse +from django.shortcuts import redirect +from django.urls import reverse +from django.utils import timezone +from django.views.generic import CreateView, DeleteView, ListView, UpdateView +from oauth2_provider.views.generic import ProtectedResourceView +from utils.mixins import RaisePermissionRequiredMixin, UserIsObjectOwnerMixin + +from .mixins import DectRegistrationViewMixin +from .models import DectRegistration + +logger = logging.getLogger("bornhack.%s" % __name__) + + +class DectExportView( + CampViewMixin, RaisePermissionRequiredMixin, ProtectedResourceView +): + """ + CSV export for the POC team / DECT system + """ + + permission_required = "camps.pocteam_permission" + + def get(self, request, *args, **kwargs): + response = HttpResponse(content_type="text/csv") + response[ + "Content-Disposition" + ] = f'attachment; filename="{self.camp.slug}-dect-export-{timezone.now()}.csv"' + writer = csv.writer(response) + for dect in DectRegistration.objects.filter( + camp=self.camp, publish_in_phonebook=True + ): + writer.writerow( + [dect.number, dect.letters, dect.description, dect.activation_code] + ) + return response + + +class PhonebookListView(CampViewMixin, ListView): + """ + Our phonebook view currently only shows DectRegistration entries, + but could later be extended to show maybe GSM or other kinds of + phone numbers. + """ + + model = DectRegistration + template_name = "phonebook.html" + + def get_queryset(self, *args, **kwargs): + qs = super().get_queryset(*args, **kwargs) + return qs.filter(publish_in_phonebook=True) + + +class DectRegistrationListView(LoginRequiredMixin, CampViewMixin, ListView): + model = DectRegistration + template_name = "dectregistration_list.html" + + def get_queryset(self, *args, **kwargs): + """ + Show only DectRegistration entries belonging to the current user + """ + qs = super().get_queryset(*args, **kwargs) + return qs.filter(user=self.request.user) + + +class DectRegistrationCreateView(LoginRequiredMixin, CampViewMixin, CreateView): + model = DectRegistration + fields = ["number", "letters", "description", "publish_in_phonebook"] + template_name = "dectregistration_form.html" + + def form_valid(self, form): + dect = form.save(commit=False) + dect.camp = self.camp + dect.user = self.request.user + + # this check needs to be in this form, but not in model.save(), because then we cant save service numbers from the admin + if len(dect.number) < 4: + form.add_error( + "number", + ValidationError( + "Numbers with fewer than 4 digits are reserved for special use" + ), + ) + return super().form_invalid(form) + + try: + dect.clean_number() + except ValidationError as E: + form.add_error("number", E) + return super().form_invalid(form) + + try: + dect.clean_letters() + except ValidationError as E: + form.add_error("letters", E) + return super().form_invalid(form) + + # generate a 10 digit activation code for this dect registration? + if not dect.activation_code: + dect.activation_code = "".join( + secrets.choice(string.digits) for i in range(10) + ) + + # all good, save and return to list + dect.save() + messages.success( + self.request, + "New DECT registration created successfully. Call the activation number from your handset to activate it!", + ) + return redirect( + reverse( + "phonebook:dectregistration_list", kwargs={"camp_slug": self.camp.slug} + ) + ) + + +class DectRegistrationUpdateView( + LoginRequiredMixin, DectRegistrationViewMixin, UserIsObjectOwnerMixin, UpdateView +): + model = DectRegistration + fields = ["letters", "description", "publish_in_phonebook"] + template_name = "dectregistration_form.html" + + def form_valid(self, form): + dect = form.save(commit=False) + + # check if the letters match the DECT number + try: + dect.clean_letters() + except ValidationError as E: + form.add_error("letters", E) + return super().form_invalid(form) + + # save and return + dect.save() + messages.success( + self.request, "Your DECT registration has been updated successfully" + ) + return redirect( + reverse( + "phonebook:dectregistration_list", kwargs={"camp_slug": self.camp.slug} + ) + ) + + +class DectRegistrationDeleteView( + LoginRequiredMixin, DectRegistrationViewMixin, UserIsObjectOwnerMixin, DeleteView +): + model = DectRegistration + template_name = "dectregistration_delete.html" + + def get_success_url(self): + messages.success( + self.request, + f"Your DECT registration for number {self.get_object().number} has been deleted successfully", + ) + return reverse( + "phonebook:dectregistration_list", kwargs={"camp_slug": self.camp.slug} + ) diff --git a/src/profiles/admin.py b/src/profiles/admin.py index c7a6d474..fc351658 100644 --- a/src/profiles/admin.py +++ b/src/profiles/admin.py @@ -4,7 +4,7 @@ from .models import Profile @admin.register(Profile) -class OrderAdmin(admin.ModelAdmin): +class ProfileAdmin(admin.ModelAdmin): actions = ["approve_public_credit_names"] list_display = [ diff --git a/src/requirements/production.txt b/src/requirements/production.txt index 175bb2c5..c268e35d 100644 --- a/src/requirements/production.txt +++ b/src/requirements/production.txt @@ -13,6 +13,7 @@ django-cors-headers==3.2.1 django-extensions==2.2.8 django-filter==2.2.0 django-leaflet==0.26.0 +django-oauth-toolkit==1.2.0 django-reversion==3.0.7 django-wkhtmltopdf==3.3.0 future==0.18.2 diff --git a/src/templates/includes/menuitems.html b/src/templates/includes/menuitems.html index f0cff41d..cf601e7c 100644 --- a/src/templates/includes/menuitems.html +++ b/src/templates/includes/menuitems.html @@ -6,21 +6,27 @@ Sponsors Teams - {% if request.user.is_authenticated %} - Rideshare - Feedback - {% endif %} -
- diff --git a/src/utils/mixins.py b/src/utils/mixins.py index f9571a6d..57243505 100644 --- a/src/utils/mixins.py +++ b/src/utils/mixins.py @@ -1,6 +1,10 @@ +import logging + from django.contrib import messages from django.contrib.auth.mixins import PermissionRequiredMixin, UserPassesTestMixin -from django.http import HttpResponseForbidden +from django.core.exceptions import PermissionDenied + +logger = logging.getLogger("bornhack.%s" % __name__) class StaffMemberRequiredMixin(object): @@ -12,7 +16,7 @@ class StaffMemberRequiredMixin(object): # only permit staff users if not request.user.is_staff: messages.error(request, "No thanks") - return HttpResponseForbidden() + raise PermissionDenied() # continue with the request return super().dispatch(request, *args, **kwargs) diff --git a/src/utils/templatetags/menubutton.py b/src/utils/templatetags/menubutton.py index fad651d4..f60f3007 100644 --- a/src/utils/templatetags/menubutton.py +++ b/src/utils/templatetags/menubutton.py @@ -12,3 +12,12 @@ def menubuttonclass(context, appname): return "btn-primary" else: return "btn-default" + + +@register.simple_tag(takes_context=True) +def menudropdownclass(context, appname): + if ( + appname + == context["request"].resolver_match.func.view_class.__module__.split(".")[0] + ): + return "active"