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 %} +
Are you sure you want to delete the registration of DECT number {{ dectregistration.number }}? +
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 +
+Number | +Letters | +Description | +Publish in Phonebook | +Activation Code | +Created | +Modified | +Actions | +
---|---|---|---|---|---|---|---|
{{ 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 + | +
No DECT registrations found. Go create one!
+ + {% endif %} ++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 %} +Number | +Letters | +Description | +Created | +
---|---|---|---|
{{ entry.number }} | +{{ entry.letters|default:"N/A" }} | +{{ entry.description|default:"N/A" }} | +{{ entry.created }} | +
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( + "