From 33bbdd69f98c0442d75dee5f813c3fd038ca1434 Mon Sep 17 00:00:00 2001 From: Job Doesburg Date: Thu, 29 Jun 2023 11:56:00 +0200 Subject: [PATCH 1/8] Add __init__.py files --- website/announcements/templatetags/__init__.py | 0 website/associations/api/__init__.py | 0 website/borrel/templatetags/__init__.py | 0 website/orders/templatetags/__init__.py | 0 website/thaliedje/api/__init__.py | 0 website/thaliedje/api/v1/__init__.py | 0 website/thaliedje/templatetags/__init__.py | 0 website/tosti/api/v1/__init__.py | 0 website/tosti/management/__init__.py | 0 website/tosti/management/commands/__init__.py | 0 website/tosti/templatetags/__init__.py | 0 website/users/api/__init__.py | 0 website/users/api/v1/__init__.py | 0 website/venues/templatetags/__init__.py | 0 14 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 website/announcements/templatetags/__init__.py create mode 100644 website/associations/api/__init__.py create mode 100644 website/borrel/templatetags/__init__.py create mode 100644 website/orders/templatetags/__init__.py create mode 100644 website/thaliedje/api/__init__.py create mode 100644 website/thaliedje/api/v1/__init__.py create mode 100644 website/thaliedje/templatetags/__init__.py create mode 100644 website/tosti/api/v1/__init__.py create mode 100644 website/tosti/management/__init__.py create mode 100644 website/tosti/management/commands/__init__.py create mode 100644 website/tosti/templatetags/__init__.py create mode 100644 website/users/api/__init__.py create mode 100644 website/users/api/v1/__init__.py create mode 100644 website/venues/templatetags/__init__.py diff --git a/website/announcements/templatetags/__init__.py b/website/announcements/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/website/associations/api/__init__.py b/website/associations/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/website/borrel/templatetags/__init__.py b/website/borrel/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/website/orders/templatetags/__init__.py b/website/orders/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/website/thaliedje/api/__init__.py b/website/thaliedje/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/website/thaliedje/api/v1/__init__.py b/website/thaliedje/api/v1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/website/thaliedje/templatetags/__init__.py b/website/thaliedje/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/website/tosti/api/v1/__init__.py b/website/tosti/api/v1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/website/tosti/management/__init__.py b/website/tosti/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/website/tosti/management/commands/__init__.py b/website/tosti/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/website/tosti/templatetags/__init__.py b/website/tosti/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/website/users/api/__init__.py b/website/users/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/website/users/api/v1/__init__.py b/website/users/api/v1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/website/venues/templatetags/__init__.py b/website/venues/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b From 1eb504c05c00701b9ed39488c0d544f3db31379c Mon Sep 17 00:00:00 2001 From: Job Doesburg Date: Thu, 29 Jun 2023 11:56:40 +0200 Subject: [PATCH 2/8] Add fridges app --- website/fridges/__init__.py | 0 website/fridges/admin.py | 50 +++++++++ website/fridges/api/__init__.py | 0 website/fridges/api/v1/__init__.py | 0 website/fridges/api/v1/urls.py | 10 ++ website/fridges/api/v1/views.py | 47 ++++++++ website/fridges/apps.py | 8 ++ website/fridges/converters.py | 18 ++++ website/fridges/migrations/0001_initial.py | 118 +++++++++++++++++++++ website/fridges/migrations/__init__.py | 0 website/fridges/models.py | 112 +++++++++++++++++++ website/fridges/services.py | 41 +++++++ website/fridges/tests.py | 3 + website/fridges/views.py | 3 + website/tosti/api/v1/urls.py | 1 + website/tosti/settings/base.py | 1 + 16 files changed, 412 insertions(+) create mode 100644 website/fridges/__init__.py create mode 100644 website/fridges/admin.py create mode 100644 website/fridges/api/__init__.py create mode 100644 website/fridges/api/v1/__init__.py create mode 100644 website/fridges/api/v1/urls.py create mode 100644 website/fridges/api/v1/views.py create mode 100644 website/fridges/apps.py create mode 100644 website/fridges/converters.py create mode 100644 website/fridges/migrations/0001_initial.py create mode 100644 website/fridges/migrations/__init__.py create mode 100644 website/fridges/models.py create mode 100644 website/fridges/services.py create mode 100644 website/fridges/tests.py create mode 100644 website/fridges/views.py diff --git a/website/fridges/__init__.py b/website/fridges/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/website/fridges/admin.py b/website/fridges/admin.py new file mode 100644 index 00000000..db6bb3c7 --- /dev/null +++ b/website/fridges/admin.py @@ -0,0 +1,50 @@ +from django.contrib import admin +from django.contrib.admin import register +from guardian.admin import GuardedModelAdmin + +from fridges.models import Fridge, GeneralOpeningHours, AccessLog, BlacklistEntry + + +class GeneralOpeningHoursInline(admin.TabularInline): + """Inline for the GeneralOpeningHours model.""" + + model = GeneralOpeningHours + extra = 0 + + +@register(Fridge) +class FridgeAdmin(GuardedModelAdmin): + """Admin for the Fridge model.""" + + inlines = [GeneralOpeningHoursInline] + prepopulated_fields = {"slug": ("name",)} + list_display = ["name", "venue", "is_active", "last_opened", "last_opened_by"] + + +@register(AccessLog) +class AccessLogAdmin(admin.ModelAdmin): + """Admin for the AccessLog model.""" + + list_display = ["user", "fridge", "timestamp"] + list_filter = ["fridge", "timestamp"] + search_fields = ["user__username", "user__first_name", "user__last_name"] + ordering = ["-timestamp"] + + def has_add_permission(self, request): + """Disable the add permission.""" + return False + + def has_change_permission(self, request, obj=None): + """Disable the change permission.""" + return False + + def has_delete_permission(self, request, obj=None): + """Disable the delete permission.""" + return False + + +@register(BlacklistEntry) +class BlacklistEntryAdmin(admin.ModelAdmin): + """Admin for the BlacklistEntry model.""" + + autocomplete_fields = ["user"] diff --git a/website/fridges/api/__init__.py b/website/fridges/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/website/fridges/api/v1/__init__.py b/website/fridges/api/v1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/website/fridges/api/v1/urls.py b/website/fridges/api/v1/urls.py new file mode 100644 index 00000000..991e6c39 --- /dev/null +++ b/website/fridges/api/v1/urls.py @@ -0,0 +1,10 @@ +from django.urls import path, register_converter + +from fridges.api.v1.views import FridgeUnlockAPIView +from fridges.converters import FridgeConverter + +register_converter(FridgeConverter, "fridge") + +urlpatterns = [ + path("/", FridgeUnlockAPIView.as_view(), name="fridge_unlock"), +] diff --git a/website/fridges/api/v1/views.py b/website/fridges/api/v1/views.py new file mode 100644 index 00000000..cc3ccb58 --- /dev/null +++ b/website/fridges/api/v1/views.py @@ -0,0 +1,47 @@ +from django.contrib.auth import get_user_model +from django.core.signing import SignatureExpired, BadSignature +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from fridges.services import user_can_open_fridge, log_access +from users.services import get_user_from_identification_token + +User = get_user_model() + + +class FridgeUnlockAPIView(APIView): + def post(self, request, fridge): + """ + Unlock a fridge. + """ + + user_token = request.data.get("user_token", None) + if user_token is None: + return Response( + {"detail": "Missing user_token"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + user = get_user_from_identification_token(user_token) + except (User.DoesNotExist, BadSignature, SignatureExpired): + return Response( + {"detail": "Invalid user_token"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + user_can_open, how_long = user_can_open_fridge(user, fridge) + + if not user_can_open: + return Response( + {"detail": "User cannot open fridge"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + log_access(user, fridge) + + return Response( + {"user": user.username, "unlock_for": how_long}, + status=status.HTTP_200_OK, + ) diff --git a/website/fridges/apps.py b/website/fridges/apps.py new file mode 100644 index 00000000..f16cd074 --- /dev/null +++ b/website/fridges/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class FridgesConfig(AppConfig): + """Configuration for the fridges app.""" + + default_auto_field = "django.db.models.BigAutoField" + name = "fridges" diff --git a/website/fridges/converters.py b/website/fridges/converters.py new file mode 100644 index 00000000..aa2a5a66 --- /dev/null +++ b/website/fridges/converters.py @@ -0,0 +1,18 @@ +from django.urls.converters import SlugConverter + +from fridges.models import Fridge + + +class FridgeConverter(SlugConverter): + """SlugConverter for Fridge model.""" + + def to_python(self, value): + """Convert slug to Fridge model.""" + try: + return Fridge.objects.get(slug=value) + except Fridge.DoesNotExist: + raise ValueError + + def to_url(self, obj): + """Convert Fridge model to slug.""" + return obj.slug diff --git a/website/fridges/migrations/0001_initial.py b/website/fridges/migrations/0001_initial.py new file mode 100644 index 00000000..98bba1d1 --- /dev/null +++ b/website/fridges/migrations/0001_initial.py @@ -0,0 +1,118 @@ +# Generated by Django 4.1.9 on 2023-06-29 09:56 + +import datetime +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ("venues", "0005_venue_automatically_accept_first_reservation"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Fridge", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=255)), + ("slug", models.SlugField(max_length=255, unique=True)), + ( + "is_active", + models.BooleanField( + default=True, + help_text="If the fridge is active, it will be shown on the website and can be opened by users within the set opening hours. People with 'open always' permissions can always open the fridge, regardless of whether it is active.", + ), + ), + ( + "unlock_for_how_long", + models.DurationField( + default=datetime.timedelta(seconds=60), + help_text="How long to unlock the fridge for by default (HH:MM:SS).", + ), + ), + ( + "venue", + models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to="venues.venue" + ), + ), + ], + options={ + "verbose_name": "fridge", + "verbose_name_plural": "fridges", + "ordering": ["name"], + "permissions": [("open_always", "Can always open fridges")], + }, + ), + migrations.CreateModel( + name="BlacklistEntry", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("fridge", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="fridges.fridge")), + ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + "verbose_name": "blacklist entry", + "verbose_name_plural": "blacklist entries", + }, + ), + migrations.CreateModel( + name="AccessLog", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("timestamp", models.DateTimeField(auto_now_add=True)), + ("fridge", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="fridges.fridge")), + ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + "verbose_name": "access log", + "verbose_name_plural": "access logs", + "ordering": ["-timestamp"], + "get_latest_by": "timestamp", + }, + ), + migrations.CreateModel( + name="GeneralOpeningHours", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "weekday", + models.IntegerField( + choices=[ + (0, "Monday"), + (1, "Tuesday"), + (2, "Wednesday"), + (3, "Thursday"), + (4, "Friday"), + (5, "Saturday"), + (6, "Sunday"), + ] + ), + ), + ("start_time", models.TimeField()), + ("end_time", models.TimeField()), + ("fridge", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="fridges.fridge")), + ( + "restrict_to_groups", + models.ManyToManyField( + blank=True, + help_text="Only allow members of these groups to open the fridge during these opening hours.", + to="auth.group", + ), + ), + ], + options={ + "verbose_name": "general opening hours", + "verbose_name_plural": "general opening hours", + "ordering": ["weekday", "start_time"], + "unique_together": {("weekday", "start_time", "end_time", "fridge")}, + }, + ), + ] diff --git a/website/fridges/migrations/__init__.py b/website/fridges/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/website/fridges/models.py b/website/fridges/models.py new file mode 100644 index 00000000..fd207970 --- /dev/null +++ b/website/fridges/models.py @@ -0,0 +1,112 @@ +from datetime import timedelta + +from django.db import models + + +class Fridge(models.Model): + """A fridge.""" + + name = models.CharField(max_length=255) + slug = models.SlugField(max_length=255, unique=True) + venue = models.ForeignKey("venues.Venue", on_delete=models.PROTECT, null=True, blank=True) + is_active = models.BooleanField( + default=True, + help_text="If the fridge is active, it will be shown on the website and can be opened by users within the set " + "opening hours. People with 'open always' permissions can always open the fridge, regardless of " + "whether it is active.", + ) + unlock_for_how_long = models.DurationField( + default=timedelta(minutes=1), help_text="How long to unlock the fridge for by default (HH:MM:SS)." + ) + + def __str__(self): + return self.name + + @property + def last_opened(self): + """Return the last time the fridge was opened.""" + try: + return self.accesslog_set.latest().timestamp + except AccessLog.DoesNotExist: + return None + + @property + def last_opened_by(self): + """Return the last user to open the fridge.""" + try: + return self.accesslog_set.latest().user + except AccessLog.DoesNotExist: + return None + + class Meta: + verbose_name = "fridge" + verbose_name_plural = "fridges" + ordering = ["name"] + permissions = [ + ("open_always", "Can always open fridges"), + ] + + +class GeneralOpeningHours(models.Model): + """General opening hours for a fridge.""" + + DAY_CHOICES = ( + (0, "Monday"), + (1, "Tuesday"), + (2, "Wednesday"), + (3, "Thursday"), + (4, "Friday"), + (5, "Saturday"), + (6, "Sunday"), + ) + + weekday = models.IntegerField(choices=DAY_CHOICES) + start_time = models.TimeField() + end_time = models.TimeField() + fridge = models.ForeignKey(Fridge, on_delete=models.CASCADE) + + restrict_to_groups = models.ManyToManyField( + "auth.Group", + blank=True, + help_text="Only allow members of these groups to open the fridge during these opening hours.", + ) + + def __str__(self): + return f"{self.DAY_CHOICES[self.weekday][1]} {self.start_time} - {self.end_time}" + + class Meta: + verbose_name = "general opening hours" + verbose_name_plural = "general opening hours" + ordering = ["weekday", "start_time"] + unique_together = ["weekday", "start_time", "end_time", "fridge"] + + +class BlacklistEntry(models.Model): + """A blacklist entry.""" + + user = models.ForeignKey("users.User", on_delete=models.CASCADE) + fridge = models.ForeignKey(Fridge, on_delete=models.CASCADE) + + def __str__(self): + return f"{self.user} blacklisted from {self.fridge}" + + class Meta: + verbose_name = "blacklist entry" + verbose_name_plural = "blacklist entries" + + +class AccessLog(models.Model): + """A log of when a user accessed a fridge.""" + + user = models.ForeignKey("users.User", on_delete=models.CASCADE) + fridge = models.ForeignKey(Fridge, on_delete=models.CASCADE) + timestamp = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"{self.user} accessed {self.fridge} at {self.timestamp:%Y-%m-%d %H:%M}" + + class Meta: + verbose_name = "access log" + verbose_name_plural = "access logs" + ordering = ["-timestamp"] + get_latest_by = "timestamp" diff --git a/website/fridges/services.py b/website/fridges/services.py new file mode 100644 index 00000000..8a01c906 --- /dev/null +++ b/website/fridges/services.py @@ -0,0 +1,41 @@ +from datetime import timedelta + +from django.db.models import Q +from django.utils import timezone + +from fridges.models import AccessLog + + +def user_is_blacklisted(user, fridge): + """Return whether a user is blacklisted from opening a fridge.""" + return fridge.blacklist.filter(user=user).exists() + + +def user_can_open_fridge(user, fridge): + """Return whether a user can open a fridge, and for how long.""" + if user.has_perm("fridges.open_always", fridge): + return True, fridge.unlock_for_how_long + + if not fridge.is_active: + return False, None + + if user_is_blacklisted(user, fridge): + return False, None + + current_time = timezone.now() + weekday = current_time.weekday() + opening_hours = fridge.generalopeninghours_set.filter( + weekday=weekday, start_time__lte=current_time.time(), end_time__gte=current_time.time() + ) + if not opening_hours.exists(): + return False, None + + if opening_hours.filter(Q(restrict_to_groups__in=user.groups.all()) | Q(restrict_to_groups__isnull=True)).exists(): + return True, fridge.unlock_for_how_long + + return False, None + + +def log_access(user, fridge): + """Log a user opening a fridge.""" + AccessLog.objects.create(user=user, fridge=fridge) diff --git a/website/fridges/tests.py b/website/fridges/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/website/fridges/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/website/fridges/views.py b/website/fridges/views.py new file mode 100644 index 00000000..91ea44a2 --- /dev/null +++ b/website/fridges/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/website/tosti/api/v1/urls.py b/website/tosti/api/v1/urls.py index aa397bb2..036c96c7 100644 --- a/website/tosti/api/v1/urls.py +++ b/website/tosti/api/v1/urls.py @@ -11,6 +11,7 @@ path("venues/", include("venues.api.v1.urls")), path("associations/", include("associations.api.v1.urls")), path("users/", include("users.api.v1.urls")), + path("fridges/", include("fridges.api.v1.urls")), path( "schema", get_schema_view( diff --git a/website/tosti/settings/base.py b/website/tosti/settings/base.py index 2c7bbafa..7de588e9 100644 --- a/website/tosti/settings/base.py +++ b/website/tosti/settings/base.py @@ -41,6 +41,7 @@ "silvasoft", "oauth2_provider", "corsheaders", + "fridges", ] AUTH_USER_MODEL = 'users.User' From 577843f3b54d8d57d423c3684e31ccb45d83b5bc Mon Sep 17 00:00:00 2001 From: Job Doesburg Date: Thu, 29 Jun 2023 17:30:00 +0200 Subject: [PATCH 3/8] Only allow fridge applications --- website/fridges/api/v1/views.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/website/fridges/api/v1/views.py b/website/fridges/api/v1/views.py index cc3ccb58..279d1e56 100644 --- a/website/fridges/api/v1/views.py +++ b/website/fridges/api/v1/views.py @@ -1,5 +1,6 @@ from django.contrib.auth import get_user_model from django.core.signing import SignatureExpired, BadSignature +from oauth2_provider.views.mixins import ClientProtectedResourceMixin from rest_framework import status from rest_framework.response import Response from rest_framework.views import APIView @@ -10,12 +11,18 @@ User = get_user_model() -class FridgeUnlockAPIView(APIView): +class FridgeUnlockAPIView(ClientProtectedResourceMixin, APIView): def post(self, request, fridge): """ Unlock a fridge. """ + if "fridge" not in request.auth.application.name.lower(): + return Response( + {"detail": "Invalid application"}, + status=status.HTTP_400_BAD_REQUEST, + ) + user_token = request.data.get("user_token", None) if user_token is None: return Response( From 72195426eccc9fffc602450872536deb2116d1dd Mon Sep 17 00:00:00 2001 From: Job Doesburg Date: Thu, 29 Jun 2023 17:38:40 +0200 Subject: [PATCH 4/8] Enforce oauth client --- website/fridges/api/v1/views.py | 2 +- website/fridges/migrations/0001_initial.py | 15 +++++++++++++-- website/fridges/models.py | 9 +++++++++ website/tosti/settings/base.py | 1 + 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/website/fridges/api/v1/views.py b/website/fridges/api/v1/views.py index 279d1e56..1bad9173 100644 --- a/website/fridges/api/v1/views.py +++ b/website/fridges/api/v1/views.py @@ -17,7 +17,7 @@ def post(self, request, fridge): Unlock a fridge. """ - if "fridge" not in request.auth.application.name.lower(): + if fridge.oauth_client is not None and fridge.oauth_client != request.auth.application: return Response( {"detail": "Invalid application"}, status=status.HTTP_400_BAD_REQUEST, diff --git a/website/fridges/migrations/0001_initial.py b/website/fridges/migrations/0001_initial.py index 98bba1d1..3b2278f6 100644 --- a/website/fridges/migrations/0001_initial.py +++ b/website/fridges/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.1.9 on 2023-06-29 09:56 +# Generated by Django 4.1.9 on 2023-06-29 15:38 import datetime from django.conf import settings @@ -11,9 +11,10 @@ class Migration(migrations.Migration): initial = True dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ("auth", "0012_alter_user_first_name_max_length"), + migrations.swappable_dependency(settings.OAUTH2_PROVIDER_APPLICATION_MODEL), ("venues", "0005_venue_automatically_accept_first_reservation"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ @@ -37,6 +38,16 @@ class Migration(migrations.Migration): help_text="How long to unlock the fridge for by default (HH:MM:SS).", ), ), + ( + "oauth_client", + models.ForeignKey( + blank=True, + help_text="The OAuth2 client to use for opening the fridge. If left blank, any client will be accepted.", + null=True, + on_delete=django.db.models.deletion.PROTECT, + to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL, + ), + ), ( "venue", models.ForeignKey( diff --git a/website/fridges/models.py b/website/fridges/models.py index fd207970..125fe175 100644 --- a/website/fridges/models.py +++ b/website/fridges/models.py @@ -1,6 +1,7 @@ from datetime import timedelta from django.db import models +from oauth2_provider.models import Application class Fridge(models.Model): @@ -19,6 +20,14 @@ class Fridge(models.Model): default=timedelta(minutes=1), help_text="How long to unlock the fridge for by default (HH:MM:SS)." ) + oauth_client = models.ForeignKey( + Application, + on_delete=models.PROTECT, + null=True, + blank=True, + help_text="The OAuth2 client to use for opening the fridge. If left blank, any client will be accepted.", + ) + def __str__(self): return self.name diff --git a/website/tosti/settings/base.py b/website/tosti/settings/base.py index 7de588e9..bb0f0998 100644 --- a/website/tosti/settings/base.py +++ b/website/tosti/settings/base.py @@ -159,6 +159,7 @@ "thaliedje:manage": "Manage music players on your behalf", }, } +OAUTH2_PROVIDER_APPLICATION_MODEL = 'oauth2_provider.Application' LOGIN_URL = '/login/' LOGIN_REDIRECT_URL = '/' From f3e1ed72e9391d7e85a1eaab8c2ea7481a4463fd Mon Sep 17 00:00:00 2001 From: Job Doesburg Date: Fri, 30 Jun 2023 13:45:12 +0200 Subject: [PATCH 5/8] Allow multiple fridges per endpoint --- website/fridges/api/v1/urls.py | 7 ++---- website/fridges/api/v1/views.py | 28 ++++++++++------------ website/fridges/converters.py | 18 -------------- website/fridges/migrations/0001_initial.py | 11 ++++----- website/fridges/models.py | 5 ++-- 5 files changed, 22 insertions(+), 47 deletions(-) delete mode 100644 website/fridges/converters.py diff --git a/website/fridges/api/v1/urls.py b/website/fridges/api/v1/urls.py index 991e6c39..19a64820 100644 --- a/website/fridges/api/v1/urls.py +++ b/website/fridges/api/v1/urls.py @@ -1,10 +1,7 @@ -from django.urls import path, register_converter +from django.urls import path from fridges.api.v1.views import FridgeUnlockAPIView -from fridges.converters import FridgeConverter - -register_converter(FridgeConverter, "fridge") urlpatterns = [ - path("/", FridgeUnlockAPIView.as_view(), name="fridge_unlock"), + path("unlock/", FridgeUnlockAPIView.as_view(), name="fridge_unlock"), ] diff --git a/website/fridges/api/v1/views.py b/website/fridges/api/v1/views.py index 1bad9173..8defd46f 100644 --- a/website/fridges/api/v1/views.py +++ b/website/fridges/api/v1/views.py @@ -12,15 +12,16 @@ class FridgeUnlockAPIView(ClientProtectedResourceMixin, APIView): - def post(self, request, fridge): + def post(self, request, *args, **kwargs): """ - Unlock a fridge. + Process a request to unlock. """ + fridge_candidates = request.auth.application.fridges.all() - if fridge.oauth_client is not None and fridge.oauth_client != request.auth.application: + if fridge_candidates.count() == 0: return Response( - {"detail": "Invalid application"}, - status=status.HTTP_400_BAD_REQUEST, + {"detail": "No fridges available"}, + status=status.HTTP_401_UNAUTHORIZED, ) user_token = request.data.get("user_token", None) @@ -38,17 +39,14 @@ def post(self, request, fridge): status=status.HTTP_400_BAD_REQUEST, ) - user_can_open, how_long = user_can_open_fridge(user, fridge) - - if not user_can_open: - return Response( - {"detail": "User cannot open fridge"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - log_access(user, fridge) + response = [] + for fridge in fridge_candidates: + user_can_open, how_long = user_can_open_fridge(user, fridge) + if user_can_open: + log_access(user, fridge) + response.append({"fridge": fridge.slug, "unlock_for": how_long}) return Response( - {"user": user.username, "unlock_for": how_long}, + {"user": user.username, "unlock": response}, status=status.HTTP_200_OK, ) diff --git a/website/fridges/converters.py b/website/fridges/converters.py deleted file mode 100644 index aa2a5a66..00000000 --- a/website/fridges/converters.py +++ /dev/null @@ -1,18 +0,0 @@ -from django.urls.converters import SlugConverter - -from fridges.models import Fridge - - -class FridgeConverter(SlugConverter): - """SlugConverter for Fridge model.""" - - def to_python(self, value): - """Convert slug to Fridge model.""" - try: - return Fridge.objects.get(slug=value) - except Fridge.DoesNotExist: - raise ValueError - - def to_url(self, obj): - """Convert Fridge model to slug.""" - return obj.slug diff --git a/website/fridges/migrations/0001_initial.py b/website/fridges/migrations/0001_initial.py index 3b2278f6..96e42bb5 100644 --- a/website/fridges/migrations/0001_initial.py +++ b/website/fridges/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.1.9 on 2023-06-29 15:38 +# Generated by Django 4.1.9 on 2023-06-29 17:17 import datetime from django.conf import settings @@ -11,9 +11,9 @@ class Migration(migrations.Migration): initial = True dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("auth", "0012_alter_user_first_name_max_length"), migrations.swappable_dependency(settings.OAUTH2_PROVIDER_APPLICATION_MODEL), + ("auth", "0012_alter_user_first_name_max_length"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ("venues", "0005_venue_automatically_accept_first_reservation"), ] @@ -41,10 +41,9 @@ class Migration(migrations.Migration): ( "oauth_client", models.ForeignKey( - blank=True, - help_text="The OAuth2 client to use for opening the fridge. If left blank, any client will be accepted.", - null=True, + help_text="The OAuth2 client that may request opening the fridge.", on_delete=django.db.models.deletion.PROTECT, + related_name="fridges", to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL, ), ), diff --git a/website/fridges/models.py b/website/fridges/models.py index 125fe175..bd2f3419 100644 --- a/website/fridges/models.py +++ b/website/fridges/models.py @@ -23,9 +23,8 @@ class Fridge(models.Model): oauth_client = models.ForeignKey( Application, on_delete=models.PROTECT, - null=True, - blank=True, - help_text="The OAuth2 client to use for opening the fridge. If left blank, any client will be accepted.", + help_text="The OAuth2 client that may request opening the fridge.", + related_name="fridges", ) def __str__(self): From c59524c05a5fdeca7203a0ee0aeea3ab5e66dbac Mon Sep 17 00:00:00 2001 From: Job Doesburg Date: Sat, 1 Jul 2023 16:35:01 +0200 Subject: [PATCH 6/8] Add frontend view --- website/fridges/models.py | 37 ++++++++++++++++ website/fridges/services.py | 10 +---- website/fridges/templates/fridges/index.html | 45 ++++++++++++++++++++ website/fridges/tests.py | 3 -- website/fridges/urls.py | 7 +++ website/fridges/views.py | 15 ++++++- website/tosti/templates/tosti/base.html | 5 +++ website/tosti/urls.py | 4 ++ 8 files changed, 113 insertions(+), 13 deletions(-) create mode 100644 website/fridges/templates/fridges/index.html delete mode 100644 website/fridges/tests.py create mode 100644 website/fridges/urls.py diff --git a/website/fridges/models.py b/website/fridges/models.py index bd2f3419..4a76a68e 100644 --- a/website/fridges/models.py +++ b/website/fridges/models.py @@ -1,6 +1,7 @@ from datetime import timedelta from django.db import models +from django.utils import timezone from oauth2_provider.models import Application @@ -46,6 +47,42 @@ def last_opened_by(self): except AccessLog.DoesNotExist: return None + @property + def current_opening_hours(self): + current_time = timezone.now().astimezone() + weekday = current_time.weekday() + opening_hours = self.generalopeninghours_set.filter( + weekday=weekday, start_time__lte=current_time.time(), end_time__gte=current_time.time() + ) + return opening_hours + + @property + def can_be_opened(self): + return self.current_opening_hours.filter( + restrict_to_groups__isnull=True, + ).exists() + + def opens_today_at(self): + """Return the time the fridge opens today, or None if it doesn't open today.""" + current_time = timezone.now().astimezone() + + if self.can_be_opened: + return current_time.time() + + next_opening_times_today = ( + self.generalopeninghours_set.filter( + weekday=current_time.weekday(), + start_time__gte=current_time.time(), + restrict_to_groups__isnull=True, + ) + .order_by("start_time") + .first() + ) + + if next_opening_times_today: + return next_opening_times_today.start_time + return None + class Meta: verbose_name = "fridge" verbose_name_plural = "fridges" diff --git a/website/fridges/services.py b/website/fridges/services.py index 8a01c906..4f59c435 100644 --- a/website/fridges/services.py +++ b/website/fridges/services.py @@ -1,7 +1,4 @@ -from datetime import timedelta - from django.db.models import Q -from django.utils import timezone from fridges.models import AccessLog @@ -22,11 +19,8 @@ def user_can_open_fridge(user, fridge): if user_is_blacklisted(user, fridge): return False, None - current_time = timezone.now() - weekday = current_time.weekday() - opening_hours = fridge.generalopeninghours_set.filter( - weekday=weekday, start_time__lte=current_time.time(), end_time__gte=current_time.time() - ) + opening_hours = fridge.current_opening_hours + if not opening_hours.exists(): return False, None diff --git a/website/fridges/templates/fridges/index.html b/website/fridges/templates/fridges/index.html new file mode 100644 index 00000000..ed629a0b --- /dev/null +++ b/website/fridges/templates/fridges/index.html @@ -0,0 +1,45 @@ +{% extends 'tosti/base.html' %} +{% load %} + +{% block page %} +
+
+

F.r.i.d.g.e.s.

+
Framework Restricting Inventory of Drinks for Guaranteed Enjoyment to Students
+
+

Fancy a beer? Perhaps, the beer fridge is open!

+ {% if user.is_authenticated %} +

+ Use your personal QR code at the top to identify yourself and open the fridge (only during opening hours). +

+ {% else %} +

+ Login and use your personal QR code to identify yourself and open the fridge (only during opening hours). +

+ {% endif %} +
+ {% for fridge in fridges %} +
+
+
+

{{ fridge.name }}

+
+
+ {% if fridge.can_be_opened %} +


The fridge is open!

+ {% else %} +

+
+ The fridge is closed.
+ {% if fridge.opens_today_at %} + Opens today at {{ fridge.opens_today_at }} + {% endif %} +

+ {% endif %} +
+
+
+ {% endfor %} +
+
+{% endblock %} \ No newline at end of file diff --git a/website/fridges/tests.py b/website/fridges/tests.py deleted file mode 100644 index 7ce503c2..00000000 --- a/website/fridges/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/website/fridges/urls.py b/website/fridges/urls.py new file mode 100644 index 00000000..02b02975 --- /dev/null +++ b/website/fridges/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from fridges import views + +urlpatterns = [ + path("", views.IndexView.as_view(), name="index"), +] diff --git a/website/fridges/views.py b/website/fridges/views.py index 91ea44a2..8e713ac8 100644 --- a/website/fridges/views.py +++ b/website/fridges/views.py @@ -1,3 +1,14 @@ -from django.shortcuts import render +from django.views.generic import ListView -# Create your views here. +from fridges.models import Fridge + + +class IndexView(ListView): + """View for showing all fridges.""" + + template_name = "fridges/index.html" + model = Fridge + context_object_name = "fridges" + + def get_queryset(self): + return Fridge.objects.filter(is_active=True).order_by("name") diff --git a/website/tosti/templates/tosti/base.html b/website/tosti/templates/tosti/base.html index bba5c3bc..be778801 100644 --- a/website/tosti/templates/tosti/base.html +++ b/website/tosti/templates/tosti/base.html @@ -87,6 +87,11 @@ Thaliedje + {% if not user.is_authenticated %}