diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..98927951 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,28 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/frontend" + schedule: + interval: "daily" + reviewers: + - "n00bS-oWn-m3" + open-pull-requests-limit: 10 + target-branch: "develop" + ignore: + - dependency-name: "*" + update-types: ["version-update:semver-major"] + + - package-ecosystem: "pip" + directory: "/backend" + schedule: + interval: "daily" + reviewers: + - "n00bS-oWn-m3" + open-pull-requests-limit: 10 + target-branch: "develop" + diff --git a/.github/workflows/formatting.yml b/.github/workflows/formatting.yml new file mode 100644 index 00000000..6bf2c40a --- /dev/null +++ b/.github/workflows/formatting.yml @@ -0,0 +1,37 @@ +name: Format Code Base + +on: + pull_request: + branches: + - main + - develop + +jobs: + format: + name: Format Code Base + runs-on: ubuntu-latest + # don't format the Dependabot PR's, for security reasons (can't access secrets.GITHUB_TOKEN) + if: ${{ github.actor != 'dependabot[bot]' }} + + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + + # using `black` for Python + - name: Format Python code + uses: rickstaa/action-black@v1.3.1 + id: action_black + with: + black_args: ". --line-length 120" + + # using `prettier` for JavaScript + - name: Format JavaScript code + uses: creyD/prettier_action@v4.3 + with: + prettier_options: --print-width 120 --tab-width 4 --write **/*.{js,tsx} + commit_message: "Auto formatted code" + only_changed: true + github_token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..f8614f5c --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,40 @@ +name: Test Code Base + +on: + pull_request: + branches: + - main + - develop + +jobs: + test: + name: Test Code Base + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Build Docker + run: docker-compose build + + - name: Run Tests + run: | + docker-compose run -e "DJANGO_SECRET_KEY=${{ secrets.DJANGO_SECRET_KEY }}" -e "SECRET_EMAIL_USER=${{ secrets.SECRET_EMAIL_USER }}" -e "SECRET_EMAIL_USER_PSWD=${{ secrets.SECRET_EMAIL_USER_PSWD }}" --name backend_test backend python manage.py test picture_of_remark/tests.py users/tests.py remark_at_building/tests.py lobby/tests.py email_template/tests.py building/tests.py role/tests.py building_comment/tests.py building_on_tour/tests.py garbage_collection/tests.py manual/tests.py region/tests.py tour/tests.py --with-coverage --cover-package=lobby,email_template,building,building_on_tour,garbage_collection,manual,region,tour,building_comment,role,remark_at_building,picture_of_remark,users --with-xunit --xunit-file=/app/coverage.xml + docker cp backend_test:/app/coverage.xml coverage.xml + docker-compose down + + - name: Publish Test Results + uses: EnricoMi/publish-unit-test-result-action@v2.6.1 + if: always() + with: + files: | + coverage.xml + + + - name: Clean up + if: always() + run: | + docker compose down --rmi all + docker compose rm -sfv + rm -rf coverage.xml \ No newline at end of file diff --git a/README.md b/README.md index 7bb6a194..c65a2120 100644 --- a/README.md +++ b/README.md @@ -1 +1,43 @@ -# Dr-Trottoir-4 \ No newline at end of file +# Dr-Trottoir-4 + +## How to get started +This repository contains our solution for [Dr. Trottoir's](https://drtrottoir.be/) web application that will +be used to facilitate their workflow for both the employers and employees. To that end, we have decided to +use the following software stack: +* Database: [PostgreSQL](https://www.postgresql.org/) +* Backend: [Django](https://www.djangoproject.com/) +* Frontend: [Next.js](https://nextjs.org/) + +Our full-stack app is also containerized with [Docker](https://www.docker.com/). This means that you will have to +install [Docker](https://docs.docker.com/get-docker/) and [docker-compose](https://docs.docker.com/compose/install/) first. +Another useful tool worth considering is [Docker Desktop](https://www.docker.com/products/docker-desktop/). This will +provide a GUI with lots of extra features that will make the docker experience more pleasant. + +To run the docker container you can use the following command: +```bash +docker-compose up +``` +Whenever you need to rebuild your containers, use: +```bash +docker-compose build +``` + +Or if you want to rebuild and then run at the same time, use: +```bash +docker-compose up --build -d +``` + +To stop the containers, run `docker-compose down` or press `Ctrl+C` if the process is running in the foreground. +Alternatively, you can use the stop button in Docker Desktop. + +This covers the basics of how to run our code. For more detailed instructions and information about our implementations, +please check out our [wiki](https://github.com/SELab-2/Dr-Trottoir-4/wiki/) + +## Members of Team 4 +* [Emma Neirinck](https://github.com/emneirin) +* [Jonathan Casters](https://github.com/jonathancasters) +* [Sebastiaan de Oude](https://github.com/n00bS-oWn-m3) +* [Seppe Van Rijsselberghe](https://github.com/sevrijss) +* [Sheng Tao Tian](https://github.com/GashinRS) +* [Simon Van den Bussche](https://github.com/simvadnbu) +* [Tibo Stroo](https://github.com/TiboStr) \ No newline at end of file diff --git a/backend/.dockerignore b/backend/.dockerignore index 25f89efd..1c977c4c 100644 --- a/backend/.dockerignore +++ b/backend/.dockerignore @@ -2,4 +2,4 @@ venv env .env Dockerfile - +.coverage diff --git a/backend/authentication/apps.py b/backend/authentication/apps.py deleted file mode 100644 index 8bab8df0..00000000 --- a/backend/authentication/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class AuthenticationConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'authentication' diff --git a/backend/authentication/forms.py b/backend/authentication/forms.py new file mode 100644 index 00000000..b7021ae4 --- /dev/null +++ b/backend/authentication/forms.py @@ -0,0 +1,45 @@ +from allauth.account.adapter import get_adapter +from allauth.account.forms import default_token_generator +from allauth.account.utils import user_pk_to_url_str +from allauth.utils import build_absolute_uri +from dj_rest_auth.forms import AllAuthPasswordResetForm +from django.contrib.sites.shortcuts import get_current_site +from django.urls import reverse + +from config import settings + + +class CustomAllAuthPasswordResetForm(AllAuthPasswordResetForm): + def save(self, request, **kwargs): + current_site = get_current_site(request) + email = self.cleaned_data["email"] + token_generator = kwargs.get("token_generator", default_token_generator) + + for user in self.users: + temp_key = token_generator.make_token(user) + + # save it to the password reset model + # password_reset = PasswordReset(user=user, temp_key=temp_key) + # password_reset.save() + + # send the password reset email + path = reverse( + "password_reset_confirm", + args=[user_pk_to_url_str(user), temp_key], + ) + + if settings.REST_AUTH["PASSWORD_RESET_USE_SITES_DOMAIN"]: + url = build_absolute_uri(None, path) + else: + url = build_absolute_uri(request, path) + + url = url.replace("%3F", "?") + context = { + "current_site": current_site, + "user": user, + "first_name": user.first_name, + "password_reset_url": url, + "request": request, + } + get_adapter(request).send_mail("account/email/password_reset_key", email, context) + return self.cleaned_data["email"] diff --git a/backend/authentication/serializers.py b/backend/authentication/serializers.py new file mode 100644 index 00000000..0f92f9a6 --- /dev/null +++ b/backend/authentication/serializers.py @@ -0,0 +1,188 @@ +from allauth.account.adapter import get_adapter +from allauth.account.utils import setup_user_email +from allauth.utils import email_address_exists +from dj_rest_auth import serializers as auth_serializers +from dj_rest_auth.jwt_auth import unset_jwt_cookies +from dj_rest_auth.serializers import PasswordResetSerializer +from django.utils.translation import gettext_lazy as _ +from phonenumber_field.serializerfields import PhoneNumberField +from rest_framework import serializers, status +from rest_framework.exceptions import ValidationError +from rest_framework.response import Response +from rest_framework.serializers import Serializer +from rest_framework_simplejwt.exceptions import TokenError +from rest_framework_simplejwt.token_blacklist.models import BlacklistedToken +from rest_framework_simplejwt.tokens import RefreshToken + +from authentication.forms import CustomAllAuthPasswordResetForm +from base.models import User, Lobby +from config import settings +from users.views import TRANSLATE +from util.request_response_util import set_keys_of_instance, try_full_clean_and_save + + +class CustomSignUpSerializer(Serializer): + email = serializers.EmailField(required=True) + first_name = serializers.CharField(required=True) + last_name = serializers.CharField(required=True) + phone_number = PhoneNumberField(required=True) + password1 = serializers.CharField(required=True, write_only=True) + password2 = serializers.CharField(required=True, write_only=True) + verification_code = serializers.CharField(required=True, write_only=True) + + def validate_password1(self, password): + return get_adapter().clean_password(password) + + def validate_email(self, email): + email = get_adapter().clean_email(email) + if email and email_address_exists(email): + raise serializers.ValidationError( + _("a user is already registered with this e-mail address"), + ) + return email + + def validate(self, data): + # check if the email address is in the lobby + lobby_instance = Lobby.objects.filter(email=data["email"]).first() + if not lobby_instance: + raise auth_serializers.ValidationError( + { + "email": _( + f"{data['email']} has no entry in the lobby, you must contact an admin to gain access to the platform" + ), + } + ) + # check if the verification code is valid + if lobby_instance.verification_code != data["verification_code"]: + raise auth_serializers.ValidationError({"verification_code": _(f"invalid verification code")}) + # add role to the validated data + data["role"] = lobby_instance.role_id + # check if passwords match + if data["password1"] != data["password2"]: + raise serializers.ValidationError({"message": _("the two password fields didn't match.")}) + # add password to the validated data + data["password"] = data["password1"] + + return data + + def create(self, validated_data): + user_instance = User() + + set_keys_of_instance(user_instance, validated_data, TRANSLATE) + + if r := try_full_clean_and_save(user_instance): + raise auth_serializers.ValidationError(r.data) + + user_instance.set_password(validated_data["password"]) + + user_instance.save() + + return user_instance + + def update(self, instance, validated_data): + instance.first_name = validated_data.get("first_name", instance.first_name) + instance.last_name = validated_data.get("last_name", instance.last_name) + instance.phone_number = validated_data.get("phone_number", instance.phone_number) + return instance + + def save(self, request): + user = self.create(self.validated_data) + setup_user_email(request, user, []) + return user + + +class CustomTokenRefreshSerializer(Serializer): + def validate(self, incoming_data): + # extract the request + request = self.context["request"] + # get the cookie name of the refresh token + cookie_name = settings.REST_AUTH["JWT_AUTH_REFRESH_COOKIE"] + if not cookie_name or cookie_name not in request.COOKIES: + from rest_framework_simplejwt.exceptions import InvalidToken + + raise InvalidToken(_("no valid refresh token found")) + + # get the refresh token + refresh = RefreshToken(request.COOKIES.get(cookie_name)) + # rotate the token if needed + if settings.SIMPLE_JWT["ROTATE_REFRESH_TOKENS"]: + if settings.SIMPLE_JWT["BLACKLIST_AFTER_ROTATION"]: + try: + # attempt to blacklist the given refresh token + refresh.blacklist() + except AttributeError: + # if blacklist app not installed, `blacklist` method will + # not be present + pass + + refresh.set_jti() + refresh.set_exp() + refresh.set_iat() + + return {"access": str(refresh.access_token), "refresh": str(refresh)} + + +class CustomTokenVerifySerializer(Serializer): + def validate(self, incoming_data): + # extract the request + request = self.context["request"] + # get the cookie name of the refresh token + cookie_name = settings.REST_AUTH["JWT_AUTH_REFRESH_COOKIE"] + if not cookie_name or cookie_name not in request.COOKIES: + from rest_framework_simplejwt.exceptions import InvalidToken + + raise InvalidToken(_("no valid refresh token found")) + + # get the refresh token + refresh = RefreshToken(request.COOKIES.get(cookie_name)) + + if settings.SIMPLE_JWT["BLACKLIST_AFTER_ROTATION"]: + jti = refresh.get(settings.SIMPLE_JWT["JTI_CLAIM"]) + if BlacklistedToken.objects.filter(token__jti=jti).exists(): + raise ValidationError("token is blacklisted") + + return {} + + +class CustomPasswordResetSerializer(PasswordResetSerializer): + def validate_email(self, value): + # use the custom reset form + self.reset_form = CustomAllAuthPasswordResetForm(data=self.initial_data) + if not self.reset_form.is_valid(): + raise serializers.ValidationError(self.reset_form.errors) + + return value + + +class CustomLogoutSerializer(serializers.Serializer): + message = serializers.CharField() + + def logout_user(self, request): + response = Response( + {"message": _("successfully logged out")}, + status=status.HTTP_200_OK, + ) + + cookie_name = getattr(settings, "JWT_AUTH_REFRESH_COOKIE", None) + try: + if cookie_name and cookie_name in request.COOKIES: + token = RefreshToken(request.COOKIES.get(cookie_name)) + token.blacklist() + except KeyError: + response.data = {"message": _("refresh token was not included in request cookies")} + response.status_code = status.HTTP_401_UNAUTHORIZED + except (TokenError, AttributeError, TypeError) as error: + if hasattr(error, "args"): + if "Token is blacklisted" in error.args or "Token is invalid or expired" in error.args: + response.data = {"message": _(error.args[0].lower())} + response.status_code = status.HTTP_401_UNAUTHORIZED + else: + response.data = {"message": _("an error has occurred.")} + response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + else: + response.data = {"message": _("an error has occurred.")} + response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + + unset_jwt_cookies(response) + + return response diff --git a/backend/authentication/tests.py b/backend/authentication/tests.py index 7ce503c2..a39b155a 100644 --- a/backend/authentication/tests.py +++ b/backend/authentication/tests.py @@ -1,3 +1 @@ -from django.test import TestCase - # Create your tests here. diff --git a/backend/authentication/urls.py b/backend/authentication/urls.py index 3bc1d5d8..8f34f6ce 100644 --- a/backend/authentication/urls.py +++ b/backend/authentication/urls.py @@ -1,26 +1,27 @@ -from dj_rest_auth.registration.views import VerifyEmailView -from dj_rest_auth.views import PasswordResetView, PasswordResetConfirmView, PasswordChangeView -from django.urls import path, include -from rest_framework_simplejwt.views import TokenVerifyView +from dj_rest_auth.views import ( + PasswordResetView, + PasswordResetConfirmView, +) +from django.urls import path -from authentication.views import LoginViewWithHiddenTokens, RefreshViewHiddenTokens, LogoutViewWithBlacklisting +from authentication.views import ( + CustomLoginView, + CustomTokenVerifyView, + CustomTokenRefreshView, + CustomLogoutView, + CustomPasswordChangeView, + CustomSignUpView, +) urlpatterns = [ # URLs that do not require a session or valid token - - path('signup/', include('dj_rest_auth.registration.urls')), - path('password/reset/', PasswordResetView.as_view()), - path('password/reset/confirm///', PasswordResetConfirmView.as_view(), name='password_reset_confirm'), - path('login/', LoginViewWithHiddenTokens.as_view(), name='rest_login'), - path('token/verify/', TokenVerifyView.as_view(), name='token_verify'), - path('token/refresh/', RefreshViewHiddenTokens.as_view(), name='token_refresh'), - + path("signup/", CustomSignUpView.as_view()), + path("password/reset/", PasswordResetView.as_view(), name="password_reset"), + path("password/reset/confirm///", PasswordResetConfirmView.as_view(), name="password_reset_confirm"), + path("login/", CustomLoginView.as_view()), + path("token/verify/", CustomTokenVerifyView.as_view()), + path("token/refresh/", CustomTokenRefreshView.as_view()), # URLs that require a user to be logged in with a valid session / token. - - path('logout/', LogoutViewWithBlacklisting.as_view(), name='rest_logout'), - path('password/change/', PasswordChangeView.as_view(), name='rest_password_change'), - path('verify-email/', VerifyEmailView.as_view(), name="rest_verify_email"), - path('account-confirm-email/', VerifyEmailView.as_view(), name='account_confirm_email_sent', ), - path('account-confirm-email//', VerifyEmailView.as_view(), name='account_confirm_email', ) - + path("logout/", CustomLogoutView.as_view()), + path("password/change/", CustomPasswordChangeView.as_view()), ] diff --git a/backend/authentication/views.py b/backend/authentication/views.py index 18c7b45c..00c6e0be 100644 --- a/backend/authentication/views.py +++ b/backend/authentication/views.py @@ -1,81 +1,110 @@ -from dj_rest_auth.jwt_auth import unset_jwt_cookies, CookieTokenRefreshSerializer, set_jwt_access_cookie, \ - set_jwt_refresh_cookie -from dj_rest_auth.views import LogoutView, LoginView +from dj_rest_auth.jwt_auth import ( + set_jwt_access_cookie, + set_jwt_refresh_cookie, + set_jwt_cookies, +) +from dj_rest_auth.views import LoginView, PasswordChangeView from django.utils.translation import gettext_lazy as _ +from drf_spectacular.utils import extend_schema from rest_framework import status +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from rest_framework_simplejwt.exceptions import TokenError -from rest_framework_simplejwt.tokens import RefreshToken -from rest_framework_simplejwt.views import TokenRefreshView -from drf_spectacular.utils import extend_schema +from rest_framework.views import APIView +from rest_framework_simplejwt.exceptions import TokenError, InvalidToken +from rest_framework_simplejwt.views import TokenRefreshView, TokenVerifyView -from config import settings +from authentication.serializers import ( + CustomTokenRefreshSerializer, + CustomTokenVerifySerializer, + CustomSignUpSerializer, + CustomLogoutSerializer, +) +from base.models import Lobby +from base.serializers import UserSerializer -class LogoutViewWithBlacklisting(LogoutView): - serializer_class = CookieTokenRefreshSerializer +class CustomSignUpView(APIView): + @extend_schema(request={None: CustomSignUpSerializer}, responses={201: UserSerializer}) + def post(self, request): + """ + Register a new user + """ + # validate signup + signup = CustomSignUpSerializer(data=request.data) + signup.is_valid(raise_exception=True) + # create new user + user = signup.save(request) + # delete the lobby entry with the user email + lobby_instance = Lobby.objects.filter(email=user.email) + lobby_instance.delete() + # create the response + response = Response(UserSerializer(user).data, status=status.HTTP_201_CREATED) + return response - @extend_schema( - responses={200: None, - 401: None, - 500: None} - ) - def logout(self, request): - response = Response( - {'detail': _('Successfully logged out.')}, - status=status.HTTP_200_OK, - ) - cookie_name = getattr(settings, 'JWT_AUTH_REFRESH_COOKIE', None) +class CustomLoginView(LoginView): + def get_response(self): + data = { + "message": _("successful login"), + "user": UserSerializer(self.user).data, + } + response = Response(data, status=status.HTTP_200_OK) + set_jwt_cookies(response, self.access_token, self.refresh_token) + return response - unset_jwt_cookies(response) + +class CustomLogoutView(APIView): + permission_classes = [IsAuthenticated] + serializer_class = CustomLogoutSerializer + + @extend_schema(request={}, responses={200: serializer_class, 401: serializer_class, 500: serializer_class}) + def post(self, request): + response = self.serializer_class().logout_user(request) + return response + + +class CustomTokenRefreshView(TokenRefreshView): + serializer_class = CustomTokenRefreshSerializer + + @extend_schema(responses={200: None, 401: None}) + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) try: - if cookie_name and cookie_name in request.COOKIES: - token = RefreshToken(request.COOKIES.get(cookie_name)) - token.blacklist() - except KeyError: - response.data = {'detail': _( - 'Refresh token was not included in request cookies.')} - response.status_code = status.HTTP_401_UNAUTHORIZED - except (TokenError, AttributeError, TypeError) as error: - if hasattr(error, 'args'): - if 'Token is blacklisted' in error.args or 'Token is invalid or expired' in error.args: - response.data = {'detail': _(error.args[0])} - response.status_code = status.HTTP_401_UNAUTHORIZED - else: - response.data = {'detail': _('An error has occurred.')} - response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR - else: - response.data = {'detail': _('An error has occurred.')} - response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + serializer.is_valid(raise_exception=True) + except TokenError as e: + raise InvalidToken(e.args[0]) + + # get new access and refresh token + data = dict(serializer.validated_data) + # construct the response + response = Response({"message": _("refresh of tokens successful")}, status=status.HTTP_200_OK) + set_jwt_access_cookie(response, data["access"]) + set_jwt_refresh_cookie(response, data["refresh"]) return response -class RefreshViewHiddenTokens(TokenRefreshView): - serializer_class = CookieTokenRefreshSerializer +class CustomTokenVerifyView(TokenVerifyView): + serializer_class = CustomTokenVerifySerializer + + @extend_schema(responses={200: None, 401: None}) + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) - def finalize_response(self, request, response, *args, **kwargs): - if response.status_code == 200 and 'access' in response.data: - set_jwt_access_cookie(response, response.data["access"]) - response.data['access-token-refresh'] = _('success') - # we don't want this info to be in the body for security reasons (HTTPOnly!) - del response.data['access'] - if response.status_code == 200 and 'refresh' in response.data: - set_jwt_refresh_cookie(response, response.data['refresh']) - response.data['refresh-token-rotation'] = _('success') - # we don't want this info to be in the body for security reasons (HTTPOnly!) - del response.data['refresh'] - return super().finalize_response(request, response, *args, **kwargs) + try: + serializer.is_valid(raise_exception=True) + except TokenError as e: + raise InvalidToken(e.args[0]) + return Response({"message": _("refresh token validation successful")}, status=status.HTTP_200_OK) -class LoginViewWithHiddenTokens(LoginView): - # serializer_class = CookieTokenRefreshSerializer - def finalize_response(self, request, response, *args, **kwargs): - if response.status_code == 200 and 'access_token' in response.data: - response.data['access_token'] = _('set successfully') - if response.status_code == 200 and 'refresh_token' in response.data: - response.data['refresh_token'] = _('set successfully') +class CustomPasswordChangeView(PasswordChangeView): + permission_classes = [IsAuthenticated] - return super().finalize_response(request, response, *args, **kwargs) + @extend_schema(responses={200: None, 400: None, 401: None}) + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response({"message": _("new password has been saved")}, status=status.HTTP_200_OK) diff --git a/backend/base/admin.py b/backend/base/admin.py index ede5fca8..46bdcc5b 100644 --- a/backend/base/admin.py +++ b/backend/base/admin.py @@ -1,15 +1,18 @@ from django.contrib import admin + from .models import * admin.site.register(User) admin.site.register(Region) admin.site.register(Building) -admin.site.register(BuildingURL) admin.site.register(GarbageCollection) admin.site.register(Tour) admin.site.register(BuildingOnTour) -admin.site.register(StudentAtBuildingOnTour) -admin.site.register(PictureBuilding) +admin.site.register(StudentOnTour) +admin.site.register(RemarkAtBuilding) +admin.site.register(PictureOfRemark) admin.site.register(Manual) admin.site.register(BuildingComment) admin.site.register(Role) +admin.site.register(Lobby) +admin.site.register(EmailTemplate) diff --git a/backend/base/apps.py b/backend/base/apps.py index 05011e82..bca3fb07 100644 --- a/backend/base/apps.py +++ b/backend/base/apps.py @@ -2,5 +2,5 @@ class BaseConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'base' + default_auto_field = "django.db.models.BigAutoField" + name = "base" diff --git a/backend/base/migrations/0001_initial.py b/backend/base/migrations/0001_initial.py index b3396fec..41879dca 100644 --- a/backend/base/migrations/0001_initial.py +++ b/backend/base/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.1.7 on 2023-03-15 17:17 +# Generated by Django 4.1.7 on 2023-04-19 18:40 from django.conf import settings from django.db import migrations, models @@ -8,234 +8,416 @@ class Migration(migrations.Migration): - initial = True dependencies = [ - ('auth', '0012_alter_user_first_name_max_length'), + ("auth", "0012_alter_user_first_name_max_length"), ] operations = [ migrations.CreateModel( - name='User', + name="User", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('password', models.CharField(max_length=128, verbose_name='password')), - ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), - ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), - ('email', models.EmailField(error_messages={'unique': 'A user already exists with this email.'}, max_length=254, unique=True, verbose_name='email address')), - ('is_staff', models.BooleanField(default=False)), - ('is_active', models.BooleanField(default=True)), - ('first_name', models.CharField(max_length=40)), - ('last_name', models.CharField(max_length=40)), - ('phone_number', phonenumber_field.modelfields.PhoneNumberField(max_length=128, region='BE')), + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("password", models.CharField(max_length=128, verbose_name="password")), + ("last_login", models.DateTimeField(blank=True, null=True, verbose_name="last login")), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ( + "email", + models.EmailField( + error_messages={"unique": "A user already exists with this email."}, + max_length=254, + unique=True, + verbose_name="email address", + ), + ), + ("is_staff", models.BooleanField(default=False)), + ("is_active", models.BooleanField(default=True)), + ("first_name", models.CharField(max_length=40)), + ("last_name", models.CharField(max_length=40)), + ("phone_number", phonenumber_field.modelfields.PhoneNumberField(max_length=128, region="BE")), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.CreateModel( - name='Building', + name="Building", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('city', models.CharField(max_length=40)), - ('postal_code', models.CharField(max_length=10)), - ('street', models.CharField(max_length=60)), - ('house_number', models.CharField(max_length=10)), - ('client_number', models.CharField(blank=True, max_length=40, null=True)), - ('duration', models.TimeField(default='00:00')), - ('name', models.CharField(blank=True, max_length=100, null=True)), + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("city", models.CharField(max_length=40)), + ("postal_code", models.CharField(max_length=10)), + ("street", models.CharField(max_length=60)), + ("house_number", models.PositiveIntegerField()), + ("bus", models.CharField(blank=True, default="No bus", max_length=10)), + ("client_number", models.CharField(blank=True, max_length=40, null=True)), + ("duration", models.TimeField(default="00:00")), + ("name", models.CharField(blank=True, max_length=100, null=True)), + ("public_id", models.CharField(blank=True, max_length=32, null=True)), ], ), migrations.CreateModel( - name='BuildingComment', + name="BuildingComment", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('comment', models.TextField()), - ('date', models.DateTimeField()), + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("comment", models.TextField()), + ("date", models.DateTimeField()), ], ), migrations.CreateModel( - name='BuildingOnTour', + name="BuildingOnTour", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('index', models.PositiveIntegerField()), + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("index", models.PositiveIntegerField()), ], ), migrations.CreateModel( - name='BuildingURL', + name="EmailTemplate", fields=[ - ('id', models.BigIntegerField(primary_key=True, serialize=False)), - ('first_name_resident', models.CharField(max_length=40)), - ('last_name_resident', models.CharField(max_length=40)), + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=40)), + ("template", models.TextField()), + ], + ), + migrations.CreateModel( + name="GarbageCollection", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("date", models.DateField()), + ( + "garbage_type", + models.CharField( + choices=[ + ("GFT", "GFT"), + ("GLS", "Glas"), + ("GRF", "Grof vuil"), + ("KER", "Kerstbomen"), + ("PAP", "Papier"), + ("PMD", "PMD"), + ("RES", "Restafval"), + ], + max_length=3, + ), + ), ], - options={ - 'abstract': False, - }, ), migrations.CreateModel( - name='GarbageCollection', + name="Lobby", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('date', models.DateField()), - ('garbage_type', models.CharField(choices=[('GFT', 'GFT'), ('GLS', 'Glas'), ('GRF', 'Grof vuil'), ('KER', 'Kerstbomen'), ('PAP', 'Papier'), ('PMD', 'PMD'), ('RES', 'Restafval')], max_length=3)), + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "email", + models.EmailField( + error_messages={"unique": "This email is already in the lobby."}, + max_length=254, + unique=True, + verbose_name="email address", + ), + ), + ( + "verification_code", + models.CharField( + error_messages={"unique": "This verification code already exists."}, max_length=128, unique=True + ), + ), ], ), migrations.CreateModel( - name='Manual', + name="Manual", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('version_number', models.PositiveIntegerField(default=0)), - ('file', models.FileField(blank=True, null=True, upload_to='building_manuals/')), + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("version_number", models.PositiveIntegerField(default=0)), + ("file", models.FileField(upload_to="building_manuals/")), ], ), migrations.CreateModel( - name='PictureBuilding', + name="PictureOfRemark", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('picture', models.ImageField(blank=True, null=True, upload_to='building_pictures/')), - ('description', models.TextField(blank=True, null=True)), - ('timestamp', models.DateTimeField()), - ('type', models.CharField(choices=[('AA', 'Aankomst'), ('BI', 'Binnen'), ('VE', 'Vertrek'), ('OP', 'Opmerking')], max_length=2)), + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("picture", models.ImageField(upload_to="building_pictures/")), + ("hash", models.TextField(blank=True, null=True)), ], ), migrations.CreateModel( - name='Region', + name="Region", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('region', models.CharField(error_messages={'unique': 'Deze regio bestaat al.'}, max_length=40, unique=True)), + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "region", + models.CharField( + error_messages={"unique": "This region already exists"}, max_length=40, unique=True + ), + ), ], ), migrations.CreateModel( - name='Role', + name="RemarkAtBuilding", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=20)), - ('rank', models.PositiveIntegerField()), - ('description', models.TextField(blank=True, null=True)), + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("timestamp", models.DateTimeField(blank=True)), + ("remark", models.TextField(blank=True, null=True)), + ( + "type", + models.CharField( + choices=[("AA", "Aankomst"), ("BI", "Binnen"), ("VE", "Vertrek"), ("OP", "Opmerking")], + max_length=2, + ), + ), ], ), migrations.CreateModel( - name='Tour', + name="Role", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=40)), - ('modified_at', models.DateTimeField(blank=True, null=True)), - ('region', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='base.region')), + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=20)), + ("rank", models.PositiveIntegerField()), + ("description", models.TextField(blank=True, null=True)), ], ), migrations.CreateModel( - name='StudentAtBuildingOnTour', + name="Tour", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('date', models.DateField()), - ('building_on_tour', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='base.buildingontour')), - ('student', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=40)), + ("modified_at", models.DateTimeField(blank=True, null=True)), + ( + "region", + models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to="base.region" + ), + ), + ], + ), + migrations.CreateModel( + name="StudentOnTour", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("date", models.DateField()), + ( + "student", + models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL + ), + ), + ("tour", models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to="base.tour")), ], ), migrations.AddConstraint( - model_name='role', - constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('name'), name='role_unique', violation_error_message='This role name already exists.'), + model_name="role", + constraint=models.UniqueConstraint( + django.db.models.functions.text.Lower("name"), + name="role_unique", + violation_error_message="This role name already exists.", + ), + ), + migrations.AddField( + model_name="remarkatbuilding", + name="building", + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to="base.building"), ), migrations.AddField( - model_name='picturebuilding', - name='building', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='base.building'), + model_name="remarkatbuilding", + name="student_on_tour", + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to="base.studentontour"), ), migrations.AddField( - model_name='manual', - name='building', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='base.building'), + model_name="pictureofremark", + name="remark_at_building", + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, to="base.remarkatbuilding" + ), ), migrations.AddField( - model_name='garbagecollection', - name='building', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='base.building'), + model_name="manual", + name="building", + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="base.building"), ), migrations.AddField( - model_name='buildingurl', - name='building', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='base.building'), + model_name="lobby", + name="role", + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to="base.role"), ), migrations.AddField( - model_name='buildingontour', - name='building', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='base.building'), + model_name="garbagecollection", + name="building", + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="base.building"), + ), + migrations.AddConstraint( + model_name="emailtemplate", + constraint=models.UniqueConstraint( + models.F("name"), + name="unique_template_name", + violation_error_message="The name for this template already exists.", + ), ), migrations.AddField( - model_name='buildingontour', - name='tour', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='base.tour'), + model_name="buildingontour", + name="building", + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="base.building"), ), migrations.AddField( - model_name='buildingcomment', - name='building', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='base.building'), + model_name="buildingontour", + name="tour", + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="base.tour"), ), migrations.AddField( - model_name='building', - name='region', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='base.region'), + model_name="buildingcomment", + name="building", + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="base.building" + ), ), migrations.AddField( - model_name='building', - name='syndic', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), + model_name="building", + name="region", + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to="base.region" + ), ), migrations.AddField( - model_name='user', - name='groups', - field=models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups'), + model_name="building", + name="syndic", + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL + ), ), migrations.AddField( - model_name='user', - name='region', - field=models.ManyToManyField(to='base.region'), + model_name="user", + name="groups", + field=models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.group", + verbose_name="groups", + ), ), migrations.AddField( - model_name='user', - name='role', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='base.role'), + model_name="user", + name="region", + field=models.ManyToManyField(to="base.region"), ), migrations.AddField( - model_name='user', - name='user_permissions', - field=models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions'), + model_name="user", + name="role", + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to="base.role"), + ), + migrations.AddField( + model_name="user", + name="user_permissions", + field=models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.permission", + verbose_name="user permissions", + ), + ), + migrations.AddConstraint( + model_name="tour", + constraint=models.UniqueConstraint( + django.db.models.functions.text.Lower("name"), + models.F("region"), + name="unique_tour", + violation_error_message="There is already a tour with the same name in the region.", + ), ), migrations.AddConstraint( - model_name='tour', - constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('name'), models.F('region'), name='unique_tour', violation_error_message='There is already a tour with the same name in the region.'), + model_name="studentontour", + constraint=models.UniqueConstraint( + models.F("tour"), + models.F("date"), + models.F("student"), + name="unique_student_on_tour", + violation_error_message="The student is already assigned to this tour on this date.", + ), ), migrations.AddConstraint( - model_name='studentatbuildingontour', - constraint=models.UniqueConstraint(models.F('building_on_tour'), models.F('date'), models.F('student'), name='unique_student_at_building_on_tour', violation_error_message='The student is already assigned to this tour on this date.'), + model_name="remarkatbuilding", + constraint=models.UniqueConstraint( + django.db.models.functions.text.Lower("remark"), + models.F("building"), + models.F("student_on_tour"), + models.F("timestamp"), + name="unique_remark_for_building", + violation_error_message="This remark was already uploaded to this building by this student on the tour.", + ), ), migrations.AddConstraint( - model_name='picturebuilding', - constraint=models.UniqueConstraint(models.F('building'), django.db.models.functions.text.Lower('picture'), django.db.models.functions.text.Lower('description'), models.F('timestamp'), name='unique_picture_building', violation_error_message='The building already has the upload.'), + model_name="pictureofremark", + constraint=models.UniqueConstraint( + models.F("hash"), + models.F("remark_at_building"), + name="unique_picture_with_remark", + violation_error_message="The building already has this upload.", + ), ), migrations.AddConstraint( - model_name='manual', - constraint=models.UniqueConstraint(models.F('building_id'), models.F('version_number'), name='unique_manual', violation_error_message='The building already has a manual with the same version number'), + model_name="manual", + constraint=models.UniqueConstraint( + models.F("building"), + models.F("version_number"), + name="unique_manual", + violation_error_message="The building already has a manual with the same version number", + ), ), migrations.AddConstraint( - model_name='garbagecollection', - constraint=models.UniqueConstraint(models.F('building'), django.db.models.functions.text.Lower('garbage_type'), models.F('date'), name='garbage_collection_unique', violation_error_message='This type of garbage is already being collected on the same day for this building.'), + model_name="garbagecollection", + constraint=models.UniqueConstraint( + models.F("building"), + django.db.models.functions.text.Lower("garbage_type"), + models.F("date"), + name="garbage_collection_unique", + violation_error_message="This type of garbage is already being collected on the same day for this building.", + ), ), migrations.AddConstraint( - model_name='buildingontour', - constraint=models.UniqueConstraint(models.F('index'), models.F('tour'), name='unique_index_on_tour', violation_error_message='The tour has already a building on this index.'), + model_name="buildingontour", + constraint=models.UniqueConstraint( + models.F("building"), + models.F("tour"), + name="unique_building_on_tour", + violation_error_message="This building is already on this tour.", + ), ), migrations.AddConstraint( - model_name='buildingontour', - constraint=models.UniqueConstraint(models.F('building'), models.F('tour'), name='unique_building_on_tour', violation_error_message='This building is already on this tour.'), + model_name="buildingontour", + constraint=models.UniqueConstraint( + models.F("index"), + models.F("tour"), + name="unique_index_on_tour", + violation_error_message="This index is already in use.", + ), ), migrations.AddConstraint( - model_name='buildingcomment', - constraint=models.UniqueConstraint(models.F('building'), django.db.models.functions.text.Lower('comment'), models.F('date'), name='building_comment_unique', violation_error_message='This comment already exists, and was posted at the exact same time.'), + model_name="buildingcomment", + constraint=models.UniqueConstraint( + models.F("building"), + django.db.models.functions.text.Lower("comment"), + models.F("date"), + name="building_comment_unique", + violation_error_message="This comment already exists, and was posted at the exact same time.", + ), ), migrations.AddConstraint( - model_name='building', - constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('city'), django.db.models.functions.text.Lower('street'), django.db.models.functions.text.Lower('postal_code'), django.db.models.functions.text.Lower('house_number'), name='address_unique', violation_error_message='A building with this address already exists.'), + model_name="building", + constraint=models.UniqueConstraint( + django.db.models.functions.text.Lower("city"), + django.db.models.functions.text.Lower("street"), + django.db.models.functions.text.Lower("postal_code"), + models.F("house_number"), + django.db.models.functions.text.Lower("bus"), + name="address_unique", + violation_error_message="A building with this address already exists.", + ), ), ] diff --git a/backend/base/models.py b/backend/base/models.py index 33eb854a..a55cf6ea 100644 --- a/backend/base/models.py +++ b/backend/base/models.py @@ -1,41 +1,28 @@ -from datetime import date +from datetime import date, datetime from django.contrib.auth.base_user import AbstractBaseUser from django.contrib.auth.models import PermissionsMixin from django.core.exceptions import ValidationError from django.db import models -from django.db.models import UniqueConstraint +from django.db.models import UniqueConstraint, Q from django.db.models.functions import Lower from django.utils.translation import gettext_lazy as _ -from django_random_id_model import RandomIDModel from phonenumber_field.modelfields import PhoneNumberField from users.managers import UserManager # sys.maxsize throws psycopg2.errors.NumericValueOutOfRange: integer out of range # Set the max int manually -MAX_INT = 2 ** 31 - 1 - - -def _check_for_present_keys(instance, keys_iterable): - for key in keys_iterable: - if not vars(instance)[key]: - raise ValidationError(f"Tried to access {key}, but it was not found in object") +MAX_INT = 2**31 - 1 class Region(models.Model): - region = models.CharField(max_length=40, unique=True, error_messages={'unique': "Deze regio bestaat al."}) + region = models.CharField(max_length=40, unique=True, error_messages={"unique": _("This region already exists")}) def __str__(self): return self.region -# Catches the post_save signal (in signals.py) and creates a user token if not yet created -# @receiver(post_save, sender=settings.AUTH_USER_MODEL) -# def create_auth_token(sender, instance=None, created=False, **kwargs): -# if created: -# Token.objects.create(user=instance) - class Role(models.Model): name = models.CharField(max_length=20) rank = models.PositiveIntegerField() @@ -47,16 +34,18 @@ def __str__(self): def clean(self): super().clean() if Role.objects.count() != 0 and self.rank != MAX_INT: - highest_rank = Role.objects.order_by('-rank').first().rank + highest_rank = Role.objects.order_by("-rank").first().rank if self.rank > highest_rank + 1: - raise ValidationError(f"The maximum rank allowed is {highest_rank + 1}.") + raise ValidationError( + _("The maximum rank allowed is {highest_rank}.").format(highest_rank=highest_rank + 1) + ) class Meta: constraints = [ UniqueConstraint( - Lower('name'), - name='role_unique', - violation_error_message='This role name already exists.' + Lower("name"), + name="role_unique", + violation_error_message=_("This role name already exists."), ), ] @@ -64,21 +53,24 @@ class Meta: class User(AbstractBaseUser, PermissionsMixin): username = None # extra fields for authentication - email = models.EmailField(_('email address'), unique=True, - error_messages={'unique': "A user already exists with this email."}) + email = models.EmailField( + "email address", + unique=True, + error_messages={"unique": _("A user already exists with this email.")}, + ) is_staff = models.BooleanField(default=False) is_active = models.BooleanField(default=True) - USERNAME_FIELD = 'email' # there is a username field and a password field - REQUIRED_FIELDS = ['first_name', 'last_name', 'phone_number', 'role'] + USERNAME_FIELD = "email" # there is a username field and a password field + REQUIRED_FIELDS = ["first_name", "last_name", "phone_number", "role"] first_name = models.CharField(max_length=40) last_name = models.CharField(max_length=40) - phone_number = PhoneNumberField(region='BE') + phone_number = PhoneNumberField(region="BE") region = models.ManyToManyField(Region) # This is the new role model - role = models.ForeignKey(Role, on_delete=models.SET_NULL, null=True, blank=True) + role = models.ForeignKey(Role, on_delete=models.SET_NULL, null=True) objects = UserManager() @@ -86,41 +78,83 @@ def __str__(self): return f"{self.email} ({self.role})" +class Lobby(models.Model): + email = models.EmailField( + "email address", unique=True, error_messages={"unique": _("This email is already in the lobby.")} + ) + # The verification code, preferably hashed + verification_code = models.CharField( + max_length=128, unique=True, error_messages={"unique": _("This verification code already exists.")} + ) + + role = models.ForeignKey(Role, on_delete=models.SET_NULL, null=True) + + def clean(self): + super().clean() + + users = User.objects.filter(email=self.email) + if users: + user = users[0] + is_inactive = not user.is_active + addendum = "" + if is_inactive: + addendum = _( + " This email belongs to an INACTIVE user. Instead of trying to register this user, you can simply reactivate the account." + ) + raise ValidationError( + _("Email already exists in database for a user (id: {user_id}).{addendum}").format( + user_id=user.id, addendum=addendum + ) + ) + + class Building(models.Model): city = models.CharField(max_length=40) postal_code = models.CharField(max_length=10) street = models.CharField(max_length=60) - house_number = models.CharField(max_length=10) + house_number = models.PositiveIntegerField() + bus = models.CharField(max_length=10, blank=True, null=False, default=_("No bus")) client_number = models.CharField(max_length=40, blank=True, null=True) - duration = models.TimeField(default='00:00') + duration = models.TimeField(default="00:00") syndic = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True) region = models.ForeignKey(Region, on_delete=models.SET_NULL, blank=True, null=True) name = models.CharField(max_length=100, blank=True, null=True) + public_id = models.CharField(max_length=32, blank=True, null=True) - ''' + """ Only a syndic can own a building, not a student. - ''' + """ def clean(self): super().clean() - # If this is not checked, `self.syndic` will cause an internal server error 500 - _check_for_present_keys(self, {"syndic_id"}) + if self.house_number == 0: + raise ValidationError(_("The house number of the building must be positive and not zero.")) - user = self.syndic + # With this if, a building is not required to have a syndic. If a syndic should be required, blank has to be False + # If this if is removed, an internal server error will be thrown since youll try to access a non existing attribute of type 'NoneType' + if self.syndic: + user = self.syndic + if user.role.name.lower() != "syndic": + raise ValidationError(_('Only a user with role "syndic" can own a building.')) - if user.role.name.lower() != 'syndic': - raise ValidationError("Only a user with role \"syndic\" can own a building.") + # If a public_id exists, it should be unique + if self.public_id: + if Building.objects.filter(public_id=self.public_id).filter(~Q(id=self.id)): + raise ValidationError( + _("{public_id} already exists as public_id of another building").format(public_id=self.public_id) + ) class Meta: constraints = [ UniqueConstraint( - Lower('city'), - Lower('street'), - Lower('postal_code'), - Lower('house_number'), - name='address_unique', - violation_error_message='A building with this address already exists.' + Lower("city"), + Lower("street"), + Lower("postal_code"), + "house_number", + Lower("bus"), + name="address_unique", + violation_error_message=_("A building with this address already exists."), ), ] @@ -128,19 +162,10 @@ def __str__(self): return f"{self.street} {self.house_number}, {self.city} {self.postal_code}" -class BuildingURL(RandomIDModel): - first_name_resident = models.CharField(max_length=40) - last_name_resident = models.CharField(max_length=40) - building = models.ForeignKey(Building, on_delete=models.CASCADE) - - def __str__(self): - return f"{self.first_name_resident} {self.last_name_resident} : {self.id}" - - class BuildingComment(models.Model): comment = models.TextField() date = models.DateTimeField() - building = models.ForeignKey(Building, on_delete=models.CASCADE) + building = models.ForeignKey(Building, on_delete=models.CASCADE, blank=True, null=True) def __str__(self): return f"Comment: {self.comment} ({self.date}) for {self.building}" @@ -148,11 +173,11 @@ def __str__(self): class Meta: constraints = [ UniqueConstraint( - 'building', - Lower('comment'), - 'date', - name='building_comment_unique', - violation_error_message='This comment already exists, and was posted at the exact same time.' + "building", + Lower("comment"), + "date", + name="building_comment_unique", + violation_error_message=_("This comment already exists, and was posted at the exact same time."), ), ] @@ -161,25 +186,23 @@ class GarbageCollection(models.Model): building = models.ForeignKey(Building, on_delete=models.CASCADE) date = models.DateField() - GFT = 'GFT' - GLAS = 'GLS' - GROF_VUIL = 'GRF' - KERSTBOMEN = 'KER' - PAPIER = 'PAP' - PMD = 'PMD' - RESTAFVAL = 'RES' + GFT = "GFT" + GLAS = "GLS" + GROF_VUIL = "GRF" + KERSTBOMEN = "KER" + PAPIER = "PAP" + PMD = "PMD" + RESTAFVAL = "RES" GARBAGE = [ - (GFT, 'GFT'), - (GLAS, 'Glas'), - (GROF_VUIL, 'Grof vuil'), - (KERSTBOMEN, 'Kerstbomen'), - (PAPIER, 'Papier'), - (PMD, 'PMD'), - (RESTAFVAL, 'Restafval') + (GFT, "GFT"), + (GLAS, "Glas"), + (GROF_VUIL, "Grof vuil"), + (KERSTBOMEN, "Kerstbomen"), + (PAPIER, "Papier"), + (PMD, "PMD"), + (RESTAFVAL, "Restafval"), ] - garbage_type = models.CharField( - max_length=3, - choices=GARBAGE) + garbage_type = models.CharField(max_length=3, choices=GARBAGE) def __str__(self): return f"{self.garbage_type} on {self.date} at {self.building}" @@ -187,12 +210,13 @@ def __str__(self): class Meta: constraints = [ UniqueConstraint( - 'building', - Lower('garbage_type'), - 'date', - name='garbage_collection_unique', - violation_error_message='This type of garbage is already being collected on the same day for this ' - 'building.' + "building", + Lower("garbage_type"), + "date", + name="garbage_collection_unique", + violation_error_message=_( + "This type of garbage is already being collected on the same day for this building." + ), ), ] @@ -205,8 +229,6 @@ class Tour(models.Model): def clean(self): super().clean() - _check_for_present_keys(self, {"name", "region_id"}) - if not self.modified_at: self.modified_at = str(date.today()) @@ -216,37 +238,36 @@ def __str__(self): class Meta: constraints = [ UniqueConstraint( - Lower('name'), - 'region', - name='unique_tour', - violation_error_message='There is already a tour with the same name in the region.' + Lower("name"), + "region", + name="unique_tour", + violation_error_message=_("There is already a tour with the same name in the region."), ), ] class BuildingOnTour(models.Model): - tour = models.ForeignKey(Tour, on_delete=models.CASCADE) - building = models.ForeignKey(Building, on_delete=models.CASCADE) - index = models.PositiveIntegerField() + tour = models.ForeignKey(Tour, on_delete=models.CASCADE, blank=False, null=False) + building = models.ForeignKey(Building, on_delete=models.CASCADE, blank=False, null=False) + index = models.PositiveIntegerField(blank=False, null=False) - ''' + """ The region of a tour and of a building needs to be the same. - ''' + """ def clean(self): super().clean() - _check_for_present_keys(self, {"tour_id", "building_id", "index"}) - - tour_region = self.tour.region - building_region = self.building.region - if tour_region != building_region: - raise ValidationError(f"The regions for tour ({tour_region}) en building ({building_region}) " - f"are different.") - - nr_of_buildings = BuildingOnTour.objects.filter(tour=self.tour).count() - if self.index > nr_of_buildings: - raise ValidationError(f"The maximum allowed index for this building is {nr_of_buildings}") + # If the if statement fails, django will handle the errors correctly in a consistent way + if self.tour_id and self.building_id: + tour_region = self.tour.region + building_region = self.building.region + if tour_region != building_region: + raise ValidationError( + _("The regions for tour ({tour_region}) and building ({building_region}) are different.").format( + tour_region=tour_region, building_region=building_region + ), + ) def __str__(self): return f"{self.building} on tour {self.tour}, index: {self.index}" @@ -254,109 +275,136 @@ def __str__(self): class Meta: constraints = [ UniqueConstraint( - 'index', - 'tour', - name='unique_index_on_tour', - violation_error_message='The tour has already a building on this index.' + "building", + "tour", + name="unique_building_on_tour", + violation_error_message=_("This building is already on this tour."), ), UniqueConstraint( - 'building', - 'tour', - name='unique_building_on_tour', - violation_error_message='This building is already on this tour.' - ) + "index", + "tour", + name="unique_index_on_tour", + violation_error_message=_("This index is already in use."), + ), ] -class StudentAtBuildingOnTour(models.Model): - building_on_tour = models.ForeignKey(BuildingOnTour, on_delete=models.SET_NULL, null=True) +""" +Links student to tours on a date +""" + + +class StudentOnTour(models.Model): + tour = models.ForeignKey(Tour, on_delete=models.SET_NULL, null=True) date = models.DateField() student = models.ForeignKey(User, on_delete=models.SET_NULL, null=True) - ''' + """ A syndic can't do tours, so we need to check that a student assigned to the building on the tour is not a syndic. Also, the student that does the tour needs to have selected the region where the building is located. - ''' + """ def clean(self): super().clean() - _check_for_present_keys(self, {"student_id", "building_on_tour_id", "date"}) - user = self.student - if user.role.name.lower() == 'syndic': - raise ValidationError("A syndic can't do tours") - building_on_tour_region = self.building_on_tour.tour.region - if not self.student.region.all().filter(region=building_on_tour_region).exists(): - raise ValidationError( - f"Student ({user.email}) doesn't do tours in this region ({building_on_tour_region}).") + + if self.student_id and self.tour_id: + user = self.student + if user.role.name.lower() == "syndic": + raise ValidationError(_("A syndic can't do tours")) + tour_region = self.tour.region + if not self.student.region.all().filter(region=tour_region).exists(): + raise ValidationError( + _("Student ({user_email}) doesn't do tours in this region ({tour_region}).").format( + user_email=user.email, tour_region=tour_region + ) + ) class Meta: constraints = [ UniqueConstraint( - 'building_on_tour', - 'date', - 'student', - name='unique_student_at_building_on_tour', - violation_error_message='The student is already assigned to this tour on this date.' + "tour", + "date", + "student", + name="unique_student_on_tour", + violation_error_message=_("The student is already assigned to this tour on this date."), ), ] def __str__(self): - return f"{self.student} at {self.building_on_tour} on {self.date}" + return f"{self.student} at {self.tour} on {self.date}" -class PictureBuilding(models.Model): - building = models.ForeignKey(Building, on_delete=models.CASCADE) - picture = models.ImageField(upload_to='building_pictures/', blank=True, null=True) - description = models.TextField(blank=True, null=True) - timestamp = models.DateTimeField() - - AANKOMST = 'AA' - BINNEN = 'BI' - VERTREK = 'VE' - OPMERKING = 'OP' +class RemarkAtBuilding(models.Model): + student_on_tour = models.ForeignKey(StudentOnTour, on_delete=models.SET_NULL, null=True) + building = models.ForeignKey(Building, on_delete=models.SET_NULL, null=True) + timestamp = models.DateTimeField(blank=True) + remark = models.TextField(blank=True, null=True) + AANKOMST = "AA" + BINNEN = "BI" + VERTREK = "VE" + OPMERKING = "OP" TYPE = [ - (AANKOMST, 'Aankomst'), - (BINNEN, 'Binnen'), - (VERTREK, 'Vertrek'), - (OPMERKING, 'Opmerking') + (AANKOMST, "Aankomst"), + (BINNEN, "Binnen"), + (VERTREK, "Vertrek"), + (OPMERKING, "Opmerking"), ] - type = models.CharField( - max_length=2, - choices=TYPE) + type = models.CharField(max_length=2, choices=TYPE) def clean(self): super().clean() - _check_for_present_keys(self, {"building_id", "picture", "description", "timestamp"}) + if not self.timestamp: + self.timestamp = datetime.now() + + def __str__(self): + return f"{self.type} for {self.building}" class Meta: constraints = [ UniqueConstraint( - 'building', - Lower('picture'), - Lower('description'), - 'timestamp', - name='unique_picture_building', - violation_error_message='The building already has the upload.' + Lower("remark"), + "building", + "student_on_tour", + "timestamp", + name="unique_remark_for_building", + violation_error_message=_( + "This remark was already uploaded to this building by this student on the tour." + ), ), ] + +class PictureOfRemark(models.Model): + picture = models.ImageField(upload_to="building_pictures/") + remark_at_building = models.ForeignKey(RemarkAtBuilding, on_delete=models.SET_NULL, null=True) + hash = models.TextField(blank=True, null=True) + def __str__(self): - return f"{self.type} = {str(self.picture).split('/')[-1]} at {self.building} ({self.timestamp}): {self.description}" + return f"PictureOfRemark for {self.remark_at_building} (id:{self.remark_at_building.id}) (file: {self.picture}\thash: {self.hash})" + + class Meta: + constraints = [ + UniqueConstraint( + "hash", + "remark_at_building", + name="unique_picture_with_remark", + violation_error_message=_("The building already has this upload."), + ), + ] class Manual(models.Model): building = models.ForeignKey(Building, on_delete=models.CASCADE) version_number = models.PositiveIntegerField(default=0) - file = models.FileField(upload_to='building_manuals/', blank=True, null=True) + file = models.FileField(upload_to="building_manuals/") def __str__(self): return f"Manual: {str(self.file).split('/')[-1]} (version {self.version_number}) for {self.building}" def clean(self): super().clean() - _check_for_present_keys(self, {"building_id", "file"}) # If no version number is given, the new version number should be the highest + 1 # If only version numbers 1, 2 and 3 are in the database, a version number of e.g. 3000 is not permitted @@ -365,15 +413,36 @@ def clean(self): version_numbers.add(-1) max_version_number = max(version_numbers) - if self.version_number == 0 or self.version_number > max_version_number + 1 or self.version_number in version_numbers: + if ( + self.version_number == 0 + or self.version_number > max_version_number + 1 + or self.version_number in version_numbers + ): self.version_number = max_version_number + 1 class Meta: constraints = [ UniqueConstraint( - 'building_id', - 'version_number', - name='unique_manual', - violation_error_message='The building already has a manual with the same version number' + "building", + "version_number", + name="unique_manual", + violation_error_message=_("The building already has a manual with the same version number"), + ), + ] + + +class EmailTemplate(models.Model): + name = models.CharField(max_length=40) + template = models.TextField() + + def __str__(self): + return f"Email Template: {self.name}" + + class Meta: + constraints = [ + UniqueConstraint( + "name", + name="unique_template_name", + violation_error_message=_("The name for this template already exists."), ), ] diff --git a/backend/base/permissions.py b/backend/base/permissions.py new file mode 100644 index 00000000..a815f3cc --- /dev/null +++ b/backend/base/permissions.py @@ -0,0 +1,239 @@ +from rest_framework.permissions import BasePermission + +from base.models import Building, User, Role, Manual +from util.request_response_util import request_to_dict +from django.utils.translation import gettext_lazy as _ + + +SAFE_METHODS = ["GET", "HEAD", "OPTIONS"] + + +# ---------------------- +# ROLE BASED PERMISSIONS +# ---------------------- +class IsAdmin(BasePermission): + """ + Global permission that only grants access to admin users + """ + + message = _("Admin permission required") + + def has_permission(self, request, view): + return request.user.role.name.lower() == "admin" + + +class IsSuperStudent(BasePermission): + """ + Global permission that grants access to super students + """ + + message = _("Super student permission required") + + def has_permission(self, request, view): + return request.user.role.name.lower() == "superstudent" + + +class IsStudent(BasePermission): + """ + Global permission that grants access to students + """ + + message = _("Student permission required") + + def has_permission(self, request, view): + return request.user.role.name.lower() == "student" + + +class ReadOnlyStudent(BasePermission): + """ + Global permission that only grants read access for students + """ + + message = _("Students are only allowed to read") + + def has_permission(self, request, view): + if request.method in SAFE_METHODS: + return request.user.role.name.lower() == "student" + + +class IsSyndic(BasePermission): + """ + Global permission that grants access to syndicates + """ + + message = _("Syndic permission required") + + def has_permission(self, request, view): + return request.user.role.name.lower() == "syndic" + + +# ------------------ +# ACTION PERMISSIONS +# ------------------ +class ReadOnly(BasePermission): + def has_permission(self, request, view): + return request.method in SAFE_METHODS + + +# ------------------ +# OBJECT PERMISSIONS +# ------------------ + + +class OwnerOfBuilding(BasePermission): + """ + Check if the user owns the building + """ + + message = _("You can only access/edit the buildings that you own") + + def has_permission(self, request, view): + return request.user.role.name.lower() == "syndic" + + def has_object_permission(self, request, view, obj: Building): + return request.user.id == obj.syndic.id + + +class ReadOnlyOwnerOfBuilding(BasePermission): + """ + Checks if the user owns the building and only tries to read from it + """ + + message = _("You can only read the building that you own") + + def has_permission(self, request, view): + return request.user.role.name.lower() == "syndic" + + def has_object_permission(self, request, view, obj: Building): + if request.method in SAFE_METHODS: + return request.user.id == obj.syndic.id + return False + + +class OwnerWithLimitedPatch(BasePermission): + """ + Checks if the syndic patches + """ + + message = _("You can only patch the building public id and the name of the building that you own") + + def has_permission(self, request, view): + return request.user.role.name.lower() == "syndic" + + def has_object_permission(self, request, view, obj: Building): + # should only be able to perform actions on own building + if request.user.id != obj.syndic.id: + return False + + if request.method in SAFE_METHODS: + return True + + if request.method == "PATCH": + data = request_to_dict(request.data) + for k in data.keys(): + if k not in ["public_id", "name"]: + return False + return True + else: + return False + + +class OwnerAccount(BasePermission): + """ + Checks if the user owns the user account + """ + + message = _("You can only access/edit your own account") + + def has_object_permission(self, request, view, obj: User): + return request.user.id == obj.id + + +class ReadOnlyOwnerAccount(BasePermission): + """ + Checks if the user owns the user account and only tries to read information + """ + + message = _("You can only access your own account") + + def has_object_permission(self, request, view, obj: User): + if request.method in SAFE_METHODS: + return request.user.id == obj.id + return False + + +class CanCreateUser(BasePermission): + """ + Checks if the user has the right permissions to create the user + """ + + message = _("You can't create a user of a higher role") + + def has_object_permission(self, request, view, obj: User): + if request.method in ["POST"]: + data = request_to_dict(request.data) + if "role" in data.keys(): + role_instance = Role.objects.filter(id=data["role"]).first() + if not role_instance: + return False + return request.user.role.rank == 1 or request.user.role.rank <= role_instance.rank + return True + + +class CanDeleteUser(BasePermission): + """ + Checks if the user has the right permissions to delete a user + """ + + message = _("You don't have the right permissions to delete this user") + + def has_object_permission(self, request, view, obj: User): + if request.method in ["DELETE"]: + return request.user.role.rank < obj.role.rank + return True + + +class CanEditUser(BasePermission): + """ + Checks if the user has the right permissions to edit + """ + + message = _("You don't have the right permissions to edit this user") + + def has_object_permission(self, request, view, obj: User): + if request.method in ["PATCH"]: + return request.user.id == obj.id or request.user.role.rank < obj.role.rank + return True + + +class CanEditRole(BasePermission): + """ + Checks if the user has the right permissions to edit the role of a user + """ + + message = _("You can't assign a role to yourself or assign a role that is higher than your own") + + def has_object_permission(self, request, view, obj: User): + if request.method in ["PATCH"]: + data = request_to_dict(request.data) + if "role" in data.keys(): + if request.user.id == obj.id: + # you aren't allowed to change your own role + return False + role_instance = Role.objects.filter(id=data["role"])[0] + return request.user.role.rank <= role_instance.rank + return True + + +class ReadOnlyManualFromSyndic(BasePermission): + """ + Checks if the manual belongs to a building from the syndic + """ + + message = _("You can only view manuals that are linked to one of your buildings") + + def has_permission(self, request, view): + return request.user.role.name == "syndic" and request.method in SAFE_METHODS + + def has_object_permission(self, request, view, obj: Manual): + return request.user.id == obj.building.syndic_id diff --git a/backend/base/serializers.py b/backend/base/serializers.py index f15751a3..f1950250 100644 --- a/backend/base/serializers.py +++ b/backend/base/serializers.py @@ -1,4 +1,6 @@ +from drf_spectacular.utils import extend_schema_serializer from rest_framework import serializers +import uuid from .models import * @@ -6,8 +8,16 @@ class UserSerializer(serializers.ModelSerializer): class Meta: model = User - fields = ["id", "is_active", "email", "first_name", "last_name", - "phone_number", "region", "role"] + fields = [ + "id", + "is_active", + "email", + "first_name", + "last_name", + "phone_number", + "region", + "role", + ] read_only_fields = ["id", "email"] @@ -21,8 +31,20 @@ class Meta: class BuildingSerializer(serializers.ModelSerializer): class Meta: model = Building - fields = ["id", "city", "postal_code", "street", "house_number", "client_number", - "duration", "syndic", "region", "name"] + fields = [ + "id", + "city", + "postal_code", + "street", + "house_number", + "bus", + "client_number", + "duration", + "syndic", + "region", + "name", + "public_id", + ] read_only_fields = ["id"] @@ -33,22 +55,39 @@ class Meta: read_only_fields = ["id"] -class PictureBuildingSerializer(serializers.ModelSerializer): +class EmailTemplateSerializer(serializers.ModelSerializer): class Meta: - model = PictureBuilding - fields = ["id", "building", "picture", "description", "timestamp", "type"] + model = EmailTemplate + fields = ["id", "name", "template"] + read_only_fields = ["id"] + + +@extend_schema_serializer(exclude_fields=["verification_code"]) +class LobbySerializer(serializers.ModelSerializer): + class Meta: + model = Lobby + fields = ["id", "email", "verification_code", "role"] + read_only_fields = ["id"] -class StudBuildTourSerializer(serializers.ModelSerializer): +class RemarkAtBuildingSerializer(serializers.ModelSerializer): class Meta: - model = StudentAtBuildingOnTour - fields = ["id", "building_on_tour", "date", "student"] + model = RemarkAtBuilding + fields = ["id", "building", "timestamp", "remark", "student_on_tour", "type"] + read_only_fields = ["id"] -class BuildingUrlSerializer(serializers.ModelSerializer): +class PictureOfRemarkSerializer(serializers.ModelSerializer): class Meta: - model = BuildingURL - fields = ["id", "first_name_resident", "last_name_resident", "building"] + model = PictureOfRemark + fields = ["id", "picture", "remark_at_building", "hash"] + read_only_fields = ["id"] + + +class StudOnTourSerializer(serializers.ModelSerializer): + class Meta: + model = StudentOnTour + fields = ["id", "tour", "date", "student"] read_only_fields = ["id"] @@ -63,21 +102,43 @@ class ManualSerializer(serializers.ModelSerializer): class Meta: model = Manual fields = ["id", "building", "version_number", "file"] + read_only_fields = ["id"] class BuildingTourSerializer(serializers.ModelSerializer): class Meta: model = BuildingOnTour fields = ["id", "building", "tour", "index"] + read_only_fields = ["id"] class TourSerializer(serializers.ModelSerializer): class Meta: model = Tour fields = ["id", "name", "region", "modified_at"] + read_only_fields = ["id"] class RegionSerializer(serializers.ModelSerializer): class Meta: model = Region fields = ["id", "region"] + read_only_fields = ["id"] + + +class SuccessSerializer(serializers.Serializer): + data = serializers.CharField(max_length=255) + + +class BuildingSwapRequestSerializer(serializers.Serializer): + buildingID1 = serializers.IntegerField() + + buildingID2 = serializers.IntegerField() + + +class PublicIdSerializer(serializers.Serializer): + public_id = serializers.UUIDField(format="hex") + + def create(self, validated_data): + public_id = uuid.uuid4() + return {"public_id": public_id} diff --git a/backend/base/test_settings.py b/backend/base/test_settings.py new file mode 100644 index 00000000..407cf7ea --- /dev/null +++ b/backend/base/test_settings.py @@ -0,0 +1,2 @@ +backend_url = "http://localhost:2002" +roles = ["Default", "Admin", "Superstudent", "Student", "Syndic"] diff --git a/backend/building/apps.py b/backend/building/apps.py deleted file mode 100644 index f795a650..00000000 --- a/backend/building/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class BuildingConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'building' diff --git a/backend/building/tests.py b/backend/building/tests.py index 7ce503c2..f6d90087 100644 --- a/backend/building/tests.py +++ b/backend/building/tests.py @@ -1,3 +1,181 @@ -from django.test import TestCase +from base.models import Building +from base.serializers import BuildingSerializer +from util.data_generators import insert_dummy_region, insert_dummy_syndic, insert_dummy_building +from util.test_tools import BaseTest, BaseAuthTest -# Create your tests here. + +class BuildingTests(BaseTest): + def __init__(self, methodName="runTest"): + super().__init__(methodName) + + def test_empty_building_list(self): + self.empty_list("building/") + + def test_insert_building(self): + r_id = insert_dummy_region() + s_id = insert_dummy_syndic() + self.data1 = { + "city": "Gent", + "postal_code": "9000", + "street": "Overpoort", + "house_number": 10, + "client_number": "1234567890abcdef", + "duration": "01:00:00", + "region": r_id, + "syndic": s_id, + "name": "CB", + } + self.insert("building/") + + def test_insert_empty(self): + self.insert_empty("building/") + + def test_insert_dupe_building(self): + r_id = insert_dummy_region() + s_id = insert_dummy_syndic() + self.data1 = { + "city": "Gent", + "postal_code": "9000", + "street": "Overpoort", + "house_number": 10, + "client_number": "1234567890abcdef", + "duration": "01:00:00", + "region": r_id, + "syndic": s_id, + "name": "CB", + } + self.insert_dupe("building/") + + def test_get_building(self): + b_id = insert_dummy_building() + data = BuildingSerializer(Building.objects.get(id=b_id)).data + self.get(f"building/{b_id}", data) + + def test_get_non_existing(self): + self.get_non_existent(f"building/") + + def test_patch_building(self): + b_id = insert_dummy_building() + + r_id = insert_dummy_region() + s_id = insert_dummy_syndic() + self.data1 = { + "city": "Gent", + "postal_code": "9000", + "street": "De Zuid", + "house_number": 10, + "client_number": "1234567890abcdef", + "duration": "01:00:00", + "region": r_id, + "syndic": s_id, + "name": "CB", + } + self.patch(f"building/{b_id}") + + def test_patch_invalid_building(self): + r_id = insert_dummy_region() + s_id = insert_dummy_syndic() + self.data1 = { + "city": "Gent", + "postal_code": "9000", + "street": "Overpoort", + "house_number": 10, + "client_number": "1234567890abcdef", + "duration": "01:00:00", + "region": r_id, + "syndic": s_id, + "name": "CB", + } + self.patch_invalid("building/") + + def test_patch_error_building(self): + r_id = insert_dummy_region() + s_id = insert_dummy_syndic() + self.data1 = { + "city": "Gent", + "postal_code": "9000", + "street": "Overpoort", + "house_number": 10, + "client_number": "1234567890abcdef", + "duration": "01:00:00", + "region": r_id, + "syndic": s_id, + "name": "CB", + } + self.data2 = { + "city": "Gent", + "postal_code": "9000", + "street": "De Zuid", + "house_number": 10, + "client_number": "1234567890abcdef", + "duration": "01:00:00", + "region": r_id, + "syndic": s_id, + "name": "CB", + } + self.patch_error("building/") + + def test_remove_building(self): + b_id = insert_dummy_building() + self.remove(f"building/{b_id}") + + def test_remove_nonexistent_building(self): + self.remove_invalid("building/") + + +class BuildingAuthorizationTests(BaseAuthTest): + def __init__(self, methodName="runTest"): + super().__init__(methodName) + + def test_building_list(self): + codes = {"Default": 403, "Admin": 200, "Superstudent": 200, "Student": 403, "Syndic": 403} + self.list_view("building/", codes) + + def test_insert_building(self): + codes = {"Default": 403, "Admin": 201, "Superstudent": 201, "Student": 403, "Syndic": 403} + r_id = insert_dummy_region() + s_id = insert_dummy_syndic() + self.data1 = { + "city": "Gent", + "postal_code": 9000, + "street": "Overpoort", + "house_number": 10, # unique (local) number to avoid collision errors + "client_number": "1234567890abcdef", + "duration": "01:00:00", + "region": r_id, + "syndic": s_id, + "name": "CB", + } + self.insert_view("building/", codes) + + def test_get_building(self): + codes = {"Default": 403, "Admin": 200, "Superstudent": 200, "Student": 200, "Syndic": 403} + b_id = insert_dummy_building() + s_id = Building.objects.get(id=b_id).syndic.id + self.get_view(f"building/{b_id}", codes, special=[(s_id, 200)]) + + def test_patch_building(self): + codes = {"Default": 403, "Admin": 200, "Superstudent": 200, "Student": 403, "Syndic": 403} + b_id = insert_dummy_building() + owner_id = Building.objects.get(id=b_id).syndic.id + r_id = insert_dummy_region() + s_id = insert_dummy_syndic() + self.data1 = { + "city": "Gent", + "postal_code": 9000, + "street": "De Zuid", + "house_number": 10, + "client_number": "1234567890abcdef", + "duration": "01:00:00", + "region": r_id, + "syndic": s_id, + "name": "CB", + } + self.patch_view(f"building/{b_id}", codes, special=[(owner_id, 403)]) + + def test_remove_building(self): + def create(): + return insert_dummy_building() + + codes = {"Default": 403, "Admin": 204, "Superstudent": 204, "Student": 403, "Syndic": 403} + self.remove_view("building/", codes, create=create) diff --git a/backend/building/urls.py b/backend/building/urls.py index ccc6383e..c15bd318 100644 --- a/backend/building/urls.py +++ b/backend/building/urls.py @@ -4,12 +4,20 @@ BuildingIndividualView, BuildingOwnerView, AllBuildingsView, - DefaultBuilding + DefaultBuilding, + BuildingPublicView, + BuildingNewPublicId, + AllBuildingsInRegionView, + BuildingGetNewPublicId, ) urlpatterns = [ - path('/', BuildingIndividualView.as_view()), - path('all/', AllBuildingsView.as_view()), - path('owner//', BuildingOwnerView.as_view()), - path('', DefaultBuilding.as_view()), + path("/", BuildingIndividualView.as_view()), + path("all/", AllBuildingsView.as_view()), + path("region//", AllBuildingsInRegionView.as_view()), + path("owner//", BuildingOwnerView.as_view()), + path("public//", BuildingPublicView.as_view()), + path("new-public-id//", BuildingNewPublicId.as_view()), + path("random-new-public-id/", BuildingGetNewPublicId.as_view()), + path("", DefaultBuilding.as_view()), ] diff --git a/backend/building/views.py b/backend/building/views.py index 2682e2af..9f92512d 100644 --- a/backend/building/views.py +++ b/backend/building/views.py @@ -1,24 +1,27 @@ -from rest_framework import permissions +from drf_spectacular.utils import extend_schema +from rest_framework.permissions import IsAuthenticated from rest_framework.views import APIView from base.models import Building -from base.serializers import BuildingSerializer +from base.permissions import ( + ReadOnlyOwnerOfBuilding, + IsAdmin, + IsSuperStudent, + ReadOnlyStudent, + OwnerWithLimitedPatch, + OwnerOfBuilding, +) +from base.serializers import BuildingSerializer, PublicIdSerializer from util.request_response_util import * -from drf_spectacular.utils import extend_schema -# TODO: we don't actually have to work with 'syndic' key, we can also require 'syndic_id' as parameter in body -# however, de automatic documentation might be a bit harder? -TRANSLATE = {"syndic": "syndic_id"} +TRANSLATE = {"syndic": "syndic_id", "region": "region_id"} class DefaultBuilding(APIView): - permission_classes = [permissions.IsAuthenticated] + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent] serializer_class = BuildingSerializer - @extend_schema( - responses={201: BuildingSerializer, - 400: None} - ) + @extend_schema(responses=post_docs(BuildingSerializer)) def post(self, request): """ Create a new building @@ -37,13 +40,10 @@ def post(self, request): class BuildingIndividualView(APIView): - permission_classes = [permissions.IsAuthenticated] + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent | ReadOnlyStudent | OwnerWithLimitedPatch] serializer_class = BuildingSerializer - @extend_schema( - responses={200: BuildingSerializer, - 400: None} - ) + @extend_schema(responses=get_docs(BuildingSerializer)) def get(self, request, building_id): """ Get info about building with given id @@ -51,41 +51,39 @@ def get(self, request, building_id): building_instance = Building.objects.filter(id=building_id) if not building_instance: - return bad_request(object_name="Building") + return not_found(object_name="Building") building_instance = building_instance[0] + self.check_object_permissions(request, building_instance) serializer = BuildingSerializer(building_instance) return get_success(serializer) - @extend_schema( - responses={204: None, - 400: None} - ) + @extend_schema(responses=delete_docs()) def delete(self, request, building_id): """ Delete building with given id """ building_instance = Building.objects.filter(id=building_id) if not building_instance: - return bad_request(object_name="Building") + return not_found(object_name="Building") building_instance = building_instance[0] + self.check_object_permissions(request, building_instance) + building_instance.delete() return delete_success() - @extend_schema( - responses={200: BuildingSerializer, - 400: None} - ) + @extend_schema(responses=patch_docs(BuildingSerializer)) def patch(self, request, building_id): """ Edit building with given ID """ building_instance = Building.objects.filter(id=building_id) if not building_instance: - return bad_request(object_name="Building") + return not_found(object_name="Building") building_instance = building_instance[0] + self.check_object_permissions(request, building_instance) data = request_to_dict(request.data) set_keys_of_instance(building_instance, data, TRANSLATE) @@ -96,7 +94,69 @@ def patch(self, request, building_id): return patch_success(BuildingSerializer(building_instance)) +# The building url you can access without account +class BuildingPublicView(APIView): + serializer_class = BuildingSerializer + + @extend_schema(responses=get_docs(BuildingSerializer)) + def get(self, request, building_public_id): + """ + Get building with the public id + """ + building_instance = Building.objects.filter(public_id=building_public_id) + + if not building_instance: + return not_found("Building") + + building_instance = building_instance[0] + + return get_success(BuildingSerializer(building_instance)) + + +class BuildingNewPublicId(APIView): + serializer_class = BuildingSerializer + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent | OwnerOfBuilding] + + @extend_schema( + description="Generate a new unique uuid as public id for the building.", + responses=post_docs(BuildingSerializer), + ) + def post(self, request, building_id): + """ + Generate a new public_id for the building with given id + """ + building_instance = Building.objects.filter(id=building_id) + + if not building_instance: + return not_found("Building") + + building_instance = building_instance[0] + + self.check_object_permissions(request, building_instance) + + building_instance.public_id = get_unique_uuid() + + if r := try_full_clean_and_save(building_instance): + return r + + return post_success(BuildingSerializer(building_instance)) + + +class BuildingGetNewPublicId(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent | OwnerOfBuilding] + serializer_class = PublicIdSerializer + + def get(self, request): + """ + Get a random unique uuid as public id that is still available. + Returns a json object with the public_id as key and the uuid as value. + """ + unique_id = get_unique_uuid(lambda x: Building.objects.filter(public_id=x).exists()) + return Response({"public_id": unique_id}, status=status.HTTP_200_OK) + + class AllBuildingsView(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent] serializer_class = BuildingSerializer def get(self, request): @@ -109,19 +169,35 @@ def get(self, request): return get_success(serializer) +class AllBuildingsInRegionView(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent] + serializer_class = BuildingSerializer + + @extend_schema(responses=get_docs(BuildingSerializer)) + def get(self, request, region_id): + """ + Get all buildings in region with given id + """ + building_instances = Building.objects.filter(region_id=region_id) + + serializer = BuildingSerializer(building_instances, many=True) + return get_success(serializer) + + class BuildingOwnerView(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent | ReadOnlyOwnerOfBuilding] serializer_class = BuildingSerializer - @extend_schema( - responses={200: BuildingSerializer, - 400: None} - ) + @extend_schema(responses=get_docs(BuildingSerializer)) def get(self, request, owner_id): """ Get all buildings owned by syndic with given id """ building_instance = Building.objects.filter(syndic=owner_id) + for b in building_instance: + self.check_object_permissions(request, b) + if not building_instance: return bad_request_relation("building", "syndic") diff --git a/backend/building_comment/apps.py b/backend/building_comment/apps.py deleted file mode 100644 index 13fa8e06..00000000 --- a/backend/building_comment/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class BuildingCommentConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'building_comment' diff --git a/backend/building_comment/tests.py b/backend/building_comment/tests.py index 7ce503c2..217ac66d 100644 --- a/backend/building_comment/tests.py +++ b/backend/building_comment/tests.py @@ -1,3 +1,93 @@ -from django.test import TestCase +from base.models import BuildingComment +from base.serializers import BuildingCommentSerializer +from util.data_generators import insert_dummy_building, insert_dummy_building_comment +from util.test_tools import BaseTest, BaseAuthTest -# Create your tests here. + +class BuildingCommentTests(BaseTest): + def __init__(self, methodName="runTest"): + super().__init__(methodName) + + def test_empty_comment_list(self): + self.empty_list("building-comment/") + + def test_insert_comment(self): + b_id = insert_dummy_building() + self.data1 = {"comment": "<3 python", "date": "2023-03-08T12:08:29+01:00", "building": b_id} + self.insert("building-comment/") + + def test_insert_empty(self): + self.insert_empty("building-comment/") + + def test_insert_dupe_comment(self): + b_id = insert_dummy_building() + self.data1 = {"comment": "<3 python", "date": "2023-03-08T12:08:29+01:00", "building": b_id} + self.insert_dupe("building-comment/") + + def test_get_comment(self): + bc_id = insert_dummy_building_comment() + data = BuildingCommentSerializer(BuildingComment.objects.get(id=bc_id)).data + self.get(f"building-comment/{bc_id}", data) + + def test_get_non_existing(self): + self.get_non_existent("building-comment/") + + def test_patch_comment(self): + bc_id = insert_dummy_building_comment() + b_id = insert_dummy_building() + self.data1 = {"comment": "<3 python and Typescript", "date": "2023-03-08T12:08:29+01:00", "building": b_id} + self.patch(f"building-comment/{bc_id}") + + def test_patch_invalid_comment(self): + b_id = insert_dummy_building() + self.data1 = {"comment": "<3 python and Typescript", "date": "2023-03-08T12:08:29+01:00", "building": b_id} + self.patch_invalid(f"building-comment/") + + def test_patch_error_comment(self): + b_id = insert_dummy_building() + self.data1 = {"comment": "<3 python", "date": "2023-03-08T12:08:29+01:00", "building": b_id} + self.data2 = {"comment": "<3 python and Typescript", "date": "2023-03-08T12:08:29+01:00", "building": b_id} + self.patch_error("building-comment/") + + def test_remove_comment(self): + bc_id = insert_dummy_building_comment() + self.remove(f"building-comment/{bc_id}") + + def test_remove_nonexistent_comment(self): + self.remove_invalid("building-comment/") + + +class BuildingCommentAuthorizationTests(BaseAuthTest): + def __init__(self, methodName="runTest"): + super().__init__(methodName) + + def test_building_comment_list(self): + codes = {"Default": 403, "Admin": 200, "Superstudent": 200, "Student": 403, "Syndic": 403} + self.list_view("building-comment/", codes) + + def test_insert_building_comment(self): + codes = {"Default": 403, "Admin": 201, "Superstudent": 201, "Student": 403, "Syndic": 403} + b_id = insert_dummy_building() + self.data1 = {"comment": f"<3 python", "date": "2023-03-08T12:08:29+01:00", "building": b_id} + self.insert_view("building-comment/", codes) + + def test_get_building_comment(self): + codes = {"Default": 403, "Admin": 200, "Superstudent": 200, "Student": 200, "Syndic": 403} + bc_id = insert_dummy_building_comment() + s_id = BuildingComment.objects.get(id=bc_id).building.syndic.id + self.get_view(f"building-comment/{bc_id}", codes, special=[(s_id, 200)]) + + def test_patch_building_comment(self): + codes = {"Default": 403, "Admin": 200, "Superstudent": 200, "Student": 403, "Syndic": 403} + bc_id = insert_dummy_building_comment() + owner_id = BuildingComment.objects.get(id=bc_id).building.syndic.id + b_id = insert_dummy_building() + self.data1 = {"comment": "<3 python and Typescript", "date": "2023-03-08T12:08:29+01:00", "building": b_id} + self.patch_view(f"building-comment/{bc_id}", codes, special=[(owner_id, 403)]) + + def test_remove_building_comment(self): + def create(): + return insert_dummy_building_comment() + + codes = {"Default": 403, "Admin": 204, "Superstudent": 204, "Student": 403, "Syndic": 403} + self.remove_view("building-comment/", codes, create=create) diff --git a/backend/building_comment/urls.py b/backend/building_comment/urls.py index fb96cbe0..0ac242c5 100644 --- a/backend/building_comment/urls.py +++ b/backend/building_comment/urls.py @@ -4,12 +4,12 @@ DefaultBuildingComment, BuildingCommentIndividualView, BuildingCommentAllView, - BuildingCommentBuildingView + BuildingCommentBuildingView, ) urlpatterns = [ - path('/', BuildingCommentIndividualView.as_view()), - path('building//', BuildingCommentBuildingView.as_view()), - path('all/', BuildingCommentAllView.as_view()), - path('', DefaultBuildingComment.as_view()) + path("/", BuildingCommentIndividualView.as_view()), + path("building//", BuildingCommentBuildingView.as_view()), + path("all/", BuildingCommentAllView.as_view()), + path("", DefaultBuildingComment.as_view()), ] diff --git a/backend/building_comment/views.py b/backend/building_comment/views.py index 66d5d928..27d8e589 100644 --- a/backend/building_comment/views.py +++ b/backend/building_comment/views.py @@ -1,20 +1,20 @@ +from drf_spectacular.utils import extend_schema +from rest_framework.permissions import IsAuthenticated from rest_framework.views import APIView from base.models import BuildingComment +from base.permissions import IsAdmin, IsSuperStudent, OwnerOfBuilding, ReadOnlyStudent, ReadOnlyOwnerOfBuilding from base.serializers import BuildingCommentSerializer from util.request_response_util import * -from drf_spectacular.utils import extend_schema TRANSLATE = {"building": "building_id"} class DefaultBuildingComment(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent | OwnerOfBuilding] serializer_class = BuildingCommentSerializer - @extend_schema( - responses={201: BuildingCommentSerializer, - 400: None} - ) + @extend_schema(responses=post_docs(BuildingCommentSerializer)) def post(self, request): """ Create a new BuildingComment @@ -25,6 +25,11 @@ def post(self, request): set_keys_of_instance(building_comment_instance, data, TRANSLATE) + if building_comment_instance.building is None: + return bad_request("BuildingComment") + + self.check_object_permissions(request, building_comment_instance.building) + if r := try_full_clean_and_save(building_comment_instance): return r @@ -32,43 +37,42 @@ def post(self, request): class BuildingCommentIndividualView(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent | ReadOnlyOwnerOfBuilding | ReadOnlyStudent] serializer_class = BuildingCommentSerializer - @extend_schema( - responses={200: BuildingCommentSerializer, - 400: None} - ) + @extend_schema(responses=get_docs(BuildingCommentSerializer)) def get(self, request, building_comment_id): """ Get an invividual BuildingComment with given id """ - building_comment_instance = BuildingComment.objects.filter(id=building_comment_id) + building_comment_instances = BuildingComment.objects.filter(id=building_comment_id) - if not building_comment_instance: - return bad_request("BuildingComment") + if not building_comment_instances: + return not_found("BuildingComment") - return get_success(BuildingCommentSerializer(building_comment_instance[0])) + building_comment_instance = building_comment_instances[0] - @extend_schema( - responses={204: None, - 400: None} - ) + self.check_object_permissions(request, building_comment_instance.building) + return get_success(BuildingCommentSerializer(building_comment_instance)) + + @extend_schema(responses=delete_docs()) def delete(self, request, building_comment_id): """ Delete a BuildingComment with given id """ - building_comment_instance = BuildingComment.objectts.filter(id=building_comment_id) + building_comment_instances = BuildingComment.objects.filter(id=building_comment_id) - if not building_comment_instance: - return bad_request("BuildingComment") + if not building_comment_instances: + return not_found("BuildingComment") + + building_comment_instance = building_comment_instances[0] - building_comment_instance[0].delete() + self.check_object_permissions(request, building_comment_instance.building) + + building_comment_instance.delete() return delete_success() - @extend_schema( - responses={200: BuildingCommentSerializer, - 400: None} - ) + @extend_schema(responses=patch_docs(BuildingCommentSerializer)) def patch(self, request, building_comment_id): """ Edit BuildingComment with given id @@ -76,9 +80,11 @@ def patch(self, request, building_comment_id): building_comment_instance = BuildingComment.objects.filter(id=building_comment_id) if not building_comment_instance: - return bad_request("BuildingComment") + return not_found("BuildingComment") building_comment_instance = building_comment_instance[0] + self.check_object_permissions(request, building_comment_instance.building) + data = request_to_dict(request.data) set_keys_of_instance(building_comment_instance, data, TRANSLATE) @@ -90,12 +96,10 @@ def patch(self, request, building_comment_id): class BuildingCommentBuildingView(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent | OwnerOfBuilding | ReadOnlyStudent] serializer_class = BuildingCommentSerializer - @extend_schema( - responses={200: BuildingCommentSerializer, - 400: None} - ) + @extend_schema(responses=get_docs(BuildingCommentSerializer)) def get(self, request, building_id): """ Get all BuildingComments of building with given building id @@ -110,6 +114,7 @@ def get(self, request, building_id): class BuildingCommentAllView(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent] serializer_class = BuildingCommentSerializer def get(self, request): diff --git a/backend/building_on_tour/apps.py b/backend/building_on_tour/apps.py deleted file mode 100644 index d702dbbc..00000000 --- a/backend/building_on_tour/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class BuildingOnTourConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'building_on_tour' diff --git a/backend/building_on_tour/tests.py b/backend/building_on_tour/tests.py index 7ce503c2..10f64b17 100644 --- a/backend/building_on_tour/tests.py +++ b/backend/building_on_tour/tests.py @@ -1,3 +1,101 @@ -from django.test import TestCase +from base.models import BuildingOnTour +from base.serializers import BuildingTourSerializer +from util.data_generators import insert_dummy_tour, insert_dummy_building, insert_dummy_building_on_tour +from util.test_tools import BaseTest, BaseAuthTest -# Create your tests here. + +class BuildingOnTourTests(BaseTest): + def __init__(self, methodName="runTest"): + super().__init__(methodName) + + def test_empty_building_on_tour_list(self): + self.empty_list("building-on-tour/") + + def test_insert_building_on_tour(self): + t_id = insert_dummy_tour() + b_id = insert_dummy_building() + self.data1 = {"tour": t_id, "building": b_id, "index": 0} + self.insert("building-on-tour/") + + def test_insert_empty(self): + self.insert_empty("building-on-tour/") + + def test_insert_dupe_building_on_tour(self): + t_id = insert_dummy_tour() + b_id = insert_dummy_building() + self.data1 = {"tour": t_id, "building": b_id, "index": 0} + + self.insert_dupe(f"building-on-tour/") + + def test_get_building_on_tour(self): + BoT_id = insert_dummy_building_on_tour() + data = BuildingTourSerializer(BuildingOnTour.objects.get(id=BoT_id)).data + + self.get(f"building-on-tour/{BoT_id}", data) + + def test_get_non_existing(self): + self.get_non_existent("building-on-tour/") + + def test_patch_building_on_tour(self): + BoT_id = insert_dummy_building_on_tour() + t_id = insert_dummy_tour() + b_id = insert_dummy_building() + self.data1 = {"tour": t_id, "building": b_id, "index": 0} + self.patch(f"building-on-tour/{BoT_id}") + + def test_patch_invalid_building_on_tour(self): + t_id = insert_dummy_tour() + b_id = insert_dummy_building() + self.data1 = {"tour": t_id, "building": b_id, "index": 0} + self.patch_invalid("building-on-tour/") + + def test_patch_error_building_on_tour(self): + t_id = insert_dummy_tour() + b_id1 = insert_dummy_building() + b_id2 = insert_dummy_building(street="Zuid") + self.data1 = {"tour": t_id, "building": b_id1, "index": 0} + self.data2 = {"tour": t_id, "building": b_id2, "index": 1} + self.patch_error("building-on-tour/") + + def test_remove_building_on_tour(self): + BoT_id = insert_dummy_building_on_tour() + self.remove(f"building-on-tour/{BoT_id}") + + def test_remove_nonexistent_building_on_tour(self): + self.remove_invalid("building-on-tour/") + + +class BuildingOnTourAuthorizationTests(BaseAuthTest): + def __init__(self, methodName="runTest"): + super().__init__(methodName) + + def test_building_on_tour_list(self): + codes = {"Default": 403, "Admin": 200, "Superstudent": 200, "Student": 200, "Syndic": 403} + self.list_view("building-on-tour/", codes) + + def test_insert_building_on_tour(self): + codes = {"Default": 403, "Admin": 201, "Superstudent": 201, "Student": 403, "Syndic": 403} + t_id = insert_dummy_tour() + b_id = insert_dummy_building() + self.data1 = {"tour": t_id, "building": b_id, "index": 0} + self.insert_view("building-on-tour/", codes) + + def test_get_building_on_tour(self): + codes = {"Default": 403, "Admin": 200, "Superstudent": 200, "Student": 200, "Syndic": 403} + BoT_id = insert_dummy_building_on_tour() + self.get_view(f"building-on-tour/{BoT_id}", codes) + + def test_patch_building_on_tour(self): + codes = {"Default": 403, "Admin": 200, "Superstudent": 200, "Student": 403, "Syndic": 403} + BoT_id = insert_dummy_building_on_tour() + t_id = insert_dummy_tour() + b_id = insert_dummy_building(street="Zuid") + self.data1 = {"tour": t_id, "building": b_id, "index": 1} + self.patch_view(f"building-on-tour/{BoT_id}", codes) + + def test_remove_building_on_tour(self): + def create(): + return insert_dummy_building_on_tour() + + codes = {"Default": 403, "Admin": 204, "Superstudent": 204, "Student": 403, "Syndic": 403} + self.remove_view("building-on-tour/", codes, create=create) diff --git a/backend/building_on_tour/urls.py b/backend/building_on_tour/urls.py index 89cab323..c93d10dd 100644 --- a/backend/building_on_tour/urls.py +++ b/backend/building_on_tour/urls.py @@ -1,13 +1,10 @@ from django.urls import path -from .views import ( - BuildingTourIndividualView, - AllBuildingToursView, - Default -) +from .views import BuildingTourIndividualView, AllBuildingToursView, Default, AllBuilingsOnTourInTourView urlpatterns = [ - path('/', BuildingTourIndividualView.as_view()), - path('all/', AllBuildingToursView.as_view()), - path('', Default.as_view()) + path("/", BuildingTourIndividualView.as_view()), + path("tour//", AllBuilingsOnTourInTourView.as_view()), + path("all/", AllBuildingToursView.as_view()), + path("", Default.as_view()), ] diff --git a/backend/building_on_tour/views.py b/backend/building_on_tour/views.py index f1f86924..8603b755 100644 --- a/backend/building_on_tour/views.py +++ b/backend/building_on_tour/views.py @@ -1,7 +1,9 @@ -from rest_framework.views import APIView from drf_spectacular.utils import extend_schema +from rest_framework.permissions import IsAuthenticated +from rest_framework.views import APIView from base.models import BuildingOnTour +from base.permissions import IsAdmin, IsSuperStudent, ReadOnlyStudent from base.serializers import BuildingTourSerializer from util.request_response_util import * @@ -9,12 +11,10 @@ class Default(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent] serializer_class = BuildingTourSerializer - @extend_schema( - responses={201: BuildingTourSerializer, - 400: None} - ) + @extend_schema(responses=post_docs(BuildingTourSerializer)) def post(self, request): """ Create a new BuildingOnTour with data from post @@ -32,12 +32,10 @@ def post(self, request): class BuildingTourIndividualView(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent | ReadOnlyStudent] serializer_class = BuildingTourSerializer - @extend_schema( - responses={200: BuildingTourSerializer, - 400: None} - ) + @extend_schema(responses=get_docs(BuildingTourSerializer)) def get(self, request, building_tour_id): """ Get info about a BuildingOnTour with given id @@ -45,15 +43,12 @@ def get(self, request, building_tour_id): building_on_tour_instance = BuildingOnTour.objects.filter(id=building_tour_id) if not building_on_tour_instance: - return bad_request("BuildingOnTour") + return not_found("BuildingOnTour") serializer = BuildingTourSerializer(building_on_tour_instance[0]) return get_success(serializer) - @extend_schema( - responses={200: BuildingTourSerializer, - 400: None} - ) + @extend_schema(responses=patch_docs(BuildingTourSerializer)) def patch(self, request, building_tour_id): """ edit info about a BuildingOnTour with given id @@ -61,7 +56,7 @@ def patch(self, request, building_tour_id): building_on_tour_instance = BuildingOnTour.objects.filter(id=building_tour_id) if not building_on_tour_instance: - return bad_request("BuildingOnTour") + return not_found("BuildingOnTour") building_on_tour_instance = building_on_tour_instance[0] @@ -74,10 +69,7 @@ def patch(self, request, building_tour_id): return patch_success(BuildingTourSerializer(building_on_tour_instance)) - @extend_schema( - responses={204: None, - 400: None} - ) + @extend_schema(responses=delete_docs()) def delete(self, request, building_tour_id): """ delete a BuildingOnTour from the database @@ -85,13 +77,28 @@ def delete(self, request, building_tour_id): building_on_tour_instance = BuildingOnTour.objects.filter(id=building_tour_id) if not building_on_tour_instance: - return bad_request("BuildingOnTour") + return not_found("BuildingOnTour") building_on_tour_instance[0].delete() return delete_success() +class AllBuilingsOnTourInTourView(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent | ReadOnlyStudent] + serializer_class = BuildingTourSerializer + + @extend_schema(responses=get_docs(BuildingTourSerializer)) + def get(self, request, tour_id): + """ + Get all BuildingsOnTour with given tour id + """ + building_on_tour_instances = BuildingOnTour.objects.filter(tour_id=tour_id) + serializer = BuildingTourSerializer(building_on_tour_instances, many=True) + return get_success(serializer) + + class AllBuildingToursView(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent | ReadOnlyStudent] serializer_class = BuildingTourSerializer def get(self, request): diff --git a/backend/buildingurl/apps.py b/backend/buildingurl/apps.py deleted file mode 100644 index cee2b369..00000000 --- a/backend/buildingurl/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class BuildingurlConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'buildingurl' diff --git a/backend/buildingurl/tests.py b/backend/buildingurl/tests.py deleted file mode 100644 index 7ce503c2..00000000 --- a/backend/buildingurl/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/backend/buildingurl/urls.py b/backend/buildingurl/urls.py deleted file mode 100644 index dbb9a43a..00000000 --- a/backend/buildingurl/urls.py +++ /dev/null @@ -1,17 +0,0 @@ -from django.urls import path - -from .views import ( - BuildingUrlAllView, - BuildingUrlIndividualView, - BuildingUrlSyndicView, - BuildingUrlBuildingView, - BuildingUrlDefault -) - -urlpatterns = [ - path('all/', BuildingUrlAllView.as_view()), - path('/', BuildingUrlIndividualView.as_view()), - path('syndic//', BuildingUrlSyndicView.as_view()), - path('building//', BuildingUrlBuildingView.as_view()), - path('', BuildingUrlDefault.as_view()) -] diff --git a/backend/buildingurl/views.py b/backend/buildingurl/views.py deleted file mode 100644 index b612d287..00000000 --- a/backend/buildingurl/views.py +++ /dev/null @@ -1,145 +0,0 @@ -from rest_framework.views import APIView - -from base.models import BuildingURL, Building -from base.serializers import BuildingUrlSerializer -from util.request_response_util import * -from drf_spectacular.utils import extend_schema - -TRANSLATE = {"building": "building_id"} - - -class BuildingUrlDefault(APIView): - serializer_class = BuildingUrlSerializer - - @extend_schema( - responses={201: BuildingUrlSerializer, - 400: None} - ) - def post(self, request): - """ - Create a new building url - """ - data = request_to_dict(request.data) - - building_url_instance = BuildingURL() - - # Below line is necessary since we use RandomIDModel - # Without this line, we would have a ValidationError because we do not have an id yet - # save() calls the function from the parent class RandomIDModel - # The try is needed because the save will fail because there is no building etc. given - # (It's a dirty fix, but it works) - try: - building_url_instance.save() - except IntegrityError: - pass - - set_keys_of_instance(building_url_instance, data, TRANSLATE) - - if r := try_full_clean_and_save(building_url_instance): - return r - - serializer = BuildingUrlSerializer(building_url_instance) - return post_success(serializer) - - -class BuildingUrlIndividualView(APIView): - serializer_class = BuildingUrlSerializer - - @extend_schema( - responses={200: BuildingUrlSerializer, - 400: None} - ) - def get(self, request, building_url_id): - """ - Get info about a buildingurl with given id - """ - building_url_instance = BuildingURL.objects.filter(id=building_url_id) - if not building_url_instance: - return bad_request("BuildingUrl") - - serializer = BuildingUrlSerializer(building_url_instance[0]) - return get_success(serializer) - - @extend_schema( - responses={204: None, - 400: None} - ) - def delete(self, request, building_url_id): - """ - Delete buildingurl with given id - """ - building_url_instance = BuildingURL.objects.filter(id=building_url_id) - if not building_url_instance: - return bad_request("BuildingUrl") - - building_url_instance[0].delete() - return delete_success() - - @extend_schema( - responses={200: BuildingUrlSerializer, - 400: None} - ) - def patch(self, request, building_url_id): - """ - Edit info about buildingurl with given id - """ - building_url_instance = BuildingURL.objects.filter(id=building_url_id) - if not building_url_instance: - return bad_request("BuildingUrl") - - building_url_instance = building_url_instance[0] - data = request_to_dict(request.data) - - set_keys_of_instance(building_url_instance, data, TRANSLATE) - - if r := try_full_clean_and_save(building_url_instance): - return r - - serializer = BuildingUrlSerializer(building_url_instance) - return patch_success(serializer) - - -class BuildingUrlSyndicView(APIView): - """ - /syndic/ - """ - serializer_class = BuildingUrlSerializer - - def get(self, request, syndic_id): - """ - Get all building urls of buildings where the user with given user id is syndic - """ - - # All building IDs where user is syndic - building_ids = [building.id for building in Building.objects.filter(syndic=syndic_id)] - - building_urls_instances = BuildingURL.objects.filter(building__in=building_ids) - serializer = BuildingUrlSerializer(building_urls_instances, many=True) - return get_success(serializer) - - -class BuildingUrlBuildingView(APIView): - """ - building/ - """ - serializer_class = BuildingUrlSerializer - - def get(self, request, building_id): - """ - Get all building urls of a given building - """ - building_url_instances = BuildingURL.objects.filter(building=building_id) - serializer = BuildingUrlSerializer(building_url_instances, many=True) - return get_success(serializer) - - -class BuildingUrlAllView(APIView): - serializer_class = BuildingUrlSerializer - - def get(self, request): - """ - Get all building urls - """ - building_url_instances = BuildingURL.objects.all() - serializer = BuildingUrlSerializer(building_url_instances, many=True) - return get_success(serializer) diff --git a/backend/config/asgi.py b/backend/config/asgi.py index 9502b7fd..0fdc25c6 100644 --- a/backend/config/asgi.py +++ b/backend/config/asgi.py @@ -11,6 +11,6 @@ from django.core.asgi import get_asgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") application = get_asgi_application() diff --git a/backend/config/middleware.py b/backend/config/middleware.py index 6adbe572..2f380471 100644 --- a/backend/config/middleware.py +++ b/backend/config/middleware.py @@ -6,5 +6,5 @@ class CommonMiddlewareAppendSlashWithoutRedirect(CommonMiddleware): # However, Django likes slashes # This code adds a slash to URLs without a slash def process_request(self, request): - if not request.path.endswith('/'): - request.path_info = request.path_info + '/' + if not request.path.endswith("/"): + request.path_info = request.path_info + "/" diff --git a/backend/config/secrets.sample.py b/backend/config/secrets.sample.py index 64b1d268..111f2646 100644 --- a/backend/config/secrets.sample.py +++ b/backend/config/secrets.sample.py @@ -1,5 +1,5 @@ -# This file is an example of how the secrets.py file should look like -DJANGO_SECRET_KEY = 'example-key-for-hashing' - -SECRET_EMAIL_USER = 'example@gmail.com' -SECRET_EMAIL_USER_PSWD = 'password' \ No newline at end of file +# This file is an example of how the secrets.py file should look like +DJANGO_SECRET_KEY = "example-key-for-hashing" + +SECRET_EMAIL_USER = "example@gmail.com" +SECRET_EMAIL_USER_PSWD = "password" diff --git a/backend/config/settings.py b/backend/config/settings.py index 845a74f6..91e37420 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -9,10 +9,19 @@ For the full list of settings and their values, see https://docs.djangoproject.com/en/4.1/ref/settings/ """ +import collections from datetime import timedelta from pathlib import Path -from .secrets import DJANGO_SECRET_KEY, SECRET_EMAIL_USER, SECRET_EMAIL_USER_PSWD +from django.utils.translation import gettext_lazy as _ +import os + +try: + from .secrets import DJANGO_SECRET_KEY, SECRET_EMAIL_USER, SECRET_EMAIL_USER_PSWD +except ImportError: + DJANGO_SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY") + SECRET_EMAIL_USER = os.environ.get("SECRET_EMAIL_USER") + SECRET_EMAIL_USER_PSWD = os.environ.get("SECRET_EMAIL_USER_PSWD") # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -25,144 +34,176 @@ # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True -ALLOWED_HOSTS = ['*', 'localhost', '127.0.0.1', '172.17.0.0'] +ALLOWED_HOSTS = ["*", "localhost", "127.0.0.1", "172.17.0.0"] # Application definition DJANGO_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'django.contrib.sites', + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "django.contrib.sites", + "django_nose", ] AUTHENTICATION = [ - 'rest_framework.authtoken', - 'allauth', - 'allauth.account', - 'allauth.socialaccount', - 'dj_rest_auth', - 'dj_rest_auth.registration', - 'rest_framework_simplejwt.token_blacklist' + "rest_framework.authtoken", + "allauth", + "allauth.account", + "dj_rest_auth", + "dj_rest_auth.registration", + "rest_framework_simplejwt.token_blacklist", ] THIRD_PARTY_APPS = AUTHENTICATION + [ - 'corsheaders', - 'rest_framework', - 'phonenumber_field', - 'drf_spectacular', + "corsheaders", + "rest_framework", + "phonenumber_field", + "drf_spectacular", ] -CREATED_APPS = [ - 'base' -] +CREATED_APPS = ["base"] INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + CREATED_APPS REST_FRAMEWORK = { - 'DEFAULT_PERMISSION_CLASSES': [ - 'rest_framework.permissions.IsAuthenticated', + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.AllowAny", ], - 'DEFAULT_AUTHENTICATION_CLASSES': ( - 'dj_rest_auth.jwt_auth.JWTCookieAuthentication', - ), - 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', + "DEFAULT_AUTHENTICATION_CLASSES": ("dj_rest_auth.jwt_auth.JWTCookieAuthentication",), + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", } +# drf-spectacular settings +# hack to make nose run +# this is needed because a lib was updated +# https://stackoverflow.com/a/70641487 +collections.Callable = collections.abc.Callable +# Use nose to run all tests +TEST_RUNNER = "django_nose.NoseTestSuiteRunner" + +NOSE_ARGS = ["--cover-xml", "--cover-xml-file=./coverage.xml"] + # drf-spectacular settings SPECTACULAR_SETTINGS = { - 'TITLE': 'Dr-Trottoir API', - 'DESCRIPTION': 'This is the documentation for the Dr-Trottoir API', - 'VERSION': '1.0.0', - 'SERVE_INCLUDE_SCHEMA': False, + "TITLE": "Dr-Trottoir API", + "DESCRIPTION": "This is the documentation for the Dr-Trottoir API", + "VERSION": "1.0.0", + "SERVE_INCLUDE_SCHEMA": False, + "SCHEMA_PATH_PREFIX_INSERT": "/api", # OTHER SETTINGS } # authentication settings -AUTH_USER_MODEL = 'base.User' +AUTH_USER_MODEL = "base.User" REST_AUTH = { - 'USE_JWT': True, - 'JWT_AUTH_HTTPONLY': True, - 'JWT_AUTH_SAMESITE': 'Strict', - 'JWT_AUTH_COOKIE': 'auth-access-token', - 'JWT_AUTH_REFRESH_COOKIE': 'auth-refresh-token', + "SESSION_LOGIN": False, + "USE_JWT": True, + "JWT_AUTH_HTTPONLY": True, + "JWT_AUTH_SAMESITE": "Strict", + "JWT_AUTH_COOKIE": "auth-access-token", + "JWT_AUTH_REFRESH_COOKIE": "auth-refresh-token", + "USER_DETAILS_SERIALIZER": "base.serializers.UserSerializer", + "PASSWORD_RESET_SERIALIZER": "authentication.serializers.CustomPasswordResetSerializer", + "PASSWORD_RESET_USE_SITES_DOMAIN": True, + "OLD_PASSWORD_FIELD_ENABLED": True, + "LOGOUT_ON_PASSWORD_CHANGE": False, } SIMPLE_JWT = { - 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=5), - 'REFRESH_TOKEN_LIFETIME': timedelta(days=14), - 'ROTATE_REFRESH_TOKENS': True, - 'BLACKLIST_AFTER_ROTATION': True, + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=5 if not DEBUG else 100), + "REFRESH_TOKEN_LIFETIME": timedelta(days=14), + "ROTATE_REFRESH_TOKENS": True, + "BLACKLIST_AFTER_ROTATION": True, + "JTI_CLAIM": "jti", } AUTHENTICATION_BACKENDS = [ - 'allauth.account.auth_backends.AuthenticationBackend', - 'django.contrib.auth.backends.ModelBackend', + "allauth.account.auth_backends.AuthenticationBackend", + "django.contrib.auth.backends.ModelBackend", ] # 'allauth' settings -ACCOUNT_AUTHENTICATION_METHOD = 'email' +ACCOUNT_AUTHENTICATION_METHOD = "email" ACCOUNT_EMAIL_REQUIRED = True ACCOUNT_UNIQUE_EMAIL = True ACCOUNT_USER_MODEL_USERNAME_FIELD = None ACCOUNT_USERNAME_REQUIRED = False -ACCOUNT_EMAIL_VERIFICATION = 'optional' if DEBUG else 'mandatory' -LOGIN_URL = 'http://localhost:2002/user/login' +ACCOUNT_EMAIL_VERIFICATION = None +LOGIN_URL = "http://localhost/api/authentication/login" -SITE_ID = 1 +SITE_ID = 1 if DEBUG else 2 MIDDLEWARE = [ - 'corsheaders.middleware.CorsMiddleware', - 'config.middleware.CommonMiddlewareAppendSlashWithoutRedirect', - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', + "corsheaders.middleware.CorsMiddleware", + "config.middleware.CommonMiddlewareAppendSlashWithoutRedirect", + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "django.middleware.locale.LocaleMiddleware", +] + +LOCALE_PATHS = [ + BASE_DIR / "locale/", ] CORS_ALLOW_ALL_ORIGINS = False CORS_ALLOW_CREDENTIALS = True -CORS_ALLOWED_ORIGINS = ['http://localhost:2002', 'http://localhost:443', 'http://localhost:80', "http://localhost"] -CSRF_TRUSTED_ORIGINS = ['http://localhost:2002', 'http://localhost:443', 'http://localhost:80', "http://localhost"] +CORS_ALLOWED_ORIGINS = [ + "http://localhost:2002", + "http://localhost:443", + "http://localhost:80", + "http://localhost", +] +CSRF_TRUSTED_ORIGINS = [ + "http://localhost:2002", + "http://localhost:443", + "http://localhost:80", + "http://localhost", +] -ROOT_URLCONF = 'config.urls' +ROOT_URLCONF = "config.urls" TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [BASE_DIR / "templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], }, }, ] -WSGI_APPLICATION = 'config.wsgi.application' +WSGI_APPLICATION = "config.wsgi.application" # Database # https://docs.djangoproject.com/en/4.1/ref/settings/#databases DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.postgresql_psycopg2', - 'NAME': 'drtrottoir', - 'USER': 'django', - 'PASSWORD': 'password', - # 'HOST': 'localhost', # If you want to run using python manage.py runserver - 'HOST': 'web', # If you want to use `docker-compose up` - 'PORT': '5432', + "default": { + "ENGINE": "django.db.backends.postgresql_psycopg2", + "NAME": "drtrottoir", + "USER": "django", + "PASSWORD": "password", + # since testing is run outside the docker, we need a localhost db + # the postgres docker port is exposed to it should be used as well + # this 'hack' is just to fix the name resolving of 'web' + # "HOST": "localhost" if "test" in sys.argv else "web", + "HOST": "web", + "PORT": "5432", } } @@ -171,51 +212,58 @@ AUTH_PASSWORD_VALIDATORS = [ { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", }, ] # Internationalization # https://docs.djangoproject.com/en/4.1/topics/i18n/ +USE_I18N = True -LANGUAGE_CODE = 'en-us' +LANGUAGE_COOKIE_AGE = 3600 +LANGUAGE_COOKIE_NAME = "language-cookie" -TIME_ZONE = 'CET' +LANGUAGE_CODE = "nl" -USE_I18N = True +LANGUAGES = [ + ("nl", _("Dutch")), + ("en", _("English")), +] + +TIME_ZONE = "CET" USE_TZ = True # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/4.1/howto/static-files/ -STATIC_URL = 'static/' +STATIC_URL = "static/" # Default primary key field type # https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" # Email -EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" EMAIL_USE_TLS = True -EMAIL_HOST = 'smtp.gmail.com' +EMAIL_HOST = "smtp.gmail.com" EMAIL_PORT = 587 EMAIL_HOST_USER = SECRET_EMAIL_USER EMAIL_HOST_PASSWORD = SECRET_EMAIL_USER_PSWD # Media -MEDIA_ROOT = '/app/media' -MEDIA_URL = '/media/' +MEDIA_ROOT = "/app/media" +MEDIA_URL = "/media/" # allow upload big file DATA_UPLOAD_MAX_MEMORY_SIZE = 1024 * 1024 * 20 # 20M diff --git a/backend/config/urls.py b/backend/config/urls.py index 731457a2..00be1948 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -23,33 +23,42 @@ from building import urls as building_urls from building_comment import urls as building_comment_urls from building_on_tour import urls as building_on_tour_urls -from buildingurl import urls as building_url_urls +from email_template import urls as email_template_urls from garbage_collection import urls as garbage_collection_urls +from lobby import urls as email_whitelist_urls from manual import urls as manual_urls -from picture_building import urls as picture_building_urls +from picture_of_remark import urls as picture_of_remark_urls + +# from picture_building import urls as picture_building_urls from region import urls as region_urls +from remark_at_building import urls as remark_at_building_urls from role import urls as role_urls -from student_at_building_on_tour import urls as stud_buil_tour_urls +from student_on_tour import urls as stud_tour_urls from tour import urls as tour_urls from users import urls as user_urls from .settings import MEDIA_URL, MEDIA_ROOT +from .views import RootDefault urlpatterns = [ - path('admin/', admin.site.urls), - path('docs/', SpectacularAPIView.as_view(), name='schema'), - path('docs/ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), - path('authentication/', include(authentication_urls)), - path('manual/', include(manual_urls)), - path('picture_building/', include(picture_building_urls)), - path('building/', include(building_urls)), - path('building_comment/', include(building_comment_urls)), - path('region/', include(region_urls)), - path('buildingurl/', include(building_url_urls)), - path('garbage_collection/', include(garbage_collection_urls)), - path('building_on_tour/', include(building_on_tour_urls)), - path('user/', include(user_urls)), - path('role/', include(role_urls)), - path('student_at_building_on_tour/', include(stud_buil_tour_urls)), - path('tour/', include(tour_urls)), - re_path(r'^$', RedirectView.as_view(url=reverse_lazy('api'), permanent=False)), - ] + static(MEDIA_URL, document_root=MEDIA_ROOT) + path("", RootDefault.as_view()), + path("admin/", admin.site.urls), + path("docs/", SpectacularAPIView.as_view(), name="schema"), + path("docs/ui/", SpectacularSwaggerView.as_view(url="/api/docs"), name="swagger-ui"), + path("authentication/", include(authentication_urls)), + path("manual/", include(manual_urls)), + # path("picture-building/", include(picture_building_urls)), + path("building/", include(building_urls)), + path("building-comment/", include(building_comment_urls)), + path("remark-at-building/", include(remark_at_building_urls)), + path("picture-of-remark/", include(picture_of_remark_urls)), + path("email-template/", include(email_template_urls)), + path("lobby/", include(email_whitelist_urls)), + path("region/", include(region_urls)), + path("garbage-collection/", include(garbage_collection_urls)), + path("building-on-tour/", include(building_on_tour_urls)), + path("user/", include(user_urls)), + path("role/", include(role_urls)), + path("student-on-tour/", include(stud_tour_urls)), + path("tour/", include(tour_urls)), + re_path(r"^$", RedirectView.as_view(url=reverse_lazy("api"), permanent=False)), +] + static(MEDIA_URL, document_root=MEDIA_ROOT) diff --git a/backend/config/views.py b/backend/config/views.py new file mode 100644 index 00000000..072ff735 --- /dev/null +++ b/backend/config/views.py @@ -0,0 +1,19 @@ +from drf_spectacular.utils import extend_schema +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + + +class RootDefault(APIView): + permission_classes = [IsAuthenticated] + + @extend_schema( + responses={200: None, 400: None, 403: None, 401: None}, + description='If you are logged in, you should see "Hello from the DrTrottoir API!". You should also be able to see your unique user id.', + ) + def get(self, request): + return Response( + {"message": "Hello from the DrTrottoir API!", "id": request.user.id}, + status=status.HTTP_200_OK, + ) diff --git a/backend/config/wsgi.py b/backend/config/wsgi.py index 3d2dc456..18a88dc6 100644 --- a/backend/config/wsgi.py +++ b/backend/config/wsgi.py @@ -11,6 +11,22 @@ from django.core.wsgi import get_wsgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myapp.settings") -application = get_wsgi_application() +_application = get_wsgi_application() + + +def application(environ, start_response): + # http://flask.pocoo.org/snippets/35/ + script_name = environ.get("HTTP_X_SCRIPT_NAME", "") + if script_name: + environ["SCRIPT_NAME"] = script_name + path_info = environ["PATH_INFO"] + if path_info.startswith(script_name): + environ["PATH_INFO"] = path_info[len(script_name) :] + + scheme = environ.get("HTTP_X_SCHEME", "") + if scheme: + environ["wsgi.url_scheme"] = scheme + + return _application(environ, start_response) diff --git a/backend/datadump.json b/backend/datadump.json index fea70dee..2e2f9690 100644 --- a/backend/datadump.json +++ b/backend/datadump.json @@ -1,33 +1,64 @@ [ { "model": "sessions.session", - "pk": "agkbb75z1jzznlehgnfqgxnkftmu2h65", + "pk": "89r833bgtx3n9noaftc1quiw22bx2u0y", "fields": { - "session_data": ".eJxVjMEOwiAQRP-FsyG2WGC96Y-QZdkNjQ1NBE7Gf7c1Pehx3sy8lwrYWw698jPMSV3VoE6_LCI9uOwFLsuONRKtvTT93Rx11bctcWkzYZvXcj9ef6qMNW8eGQAvZHGckiemJCSeIaHY0ft4tgCQRMxI0QEMyBDBiHGT4wnQW6PeH_JXPT8:1pZrZn:aofO3hoPSRwP3rzhXO-gnpWAA2ykYuOdsRHuX5zTVZ4", - "expire_date": "2023-03-22T11:03:19.294Z" + "session_data": ".eJxVjMEOgyAQRP-Fc2NYFpD2Vn-ELMsaTQ0mFU5N_73aeGiP82bmvVSkVqfYNnnGOaubMkZdfmEifkg5GlqWA3fEvLZSu-_mrLfuvicpdWaq81qG8_Wnmmibdk-S5IE0IztvHYI4CkDi0YaxR_BXKzKKlpS98yaYHEC73ENCZLYC6v0B45c8bA:1ppPnL:tLAddhOwZCbXQ6r2-_pI3czcckkQ1JvD0fLJTbUqaaU", + "expire_date": "2023-05-04T08:37:35.681Z" } }, { "model": "sessions.session", - "pk": "j021p17ab4dax6ex6axkiseb98hrwu47", + "pk": "vi0mcac2q2xamblpgc684mc9e7pa3taw", "fields": { - "session_data": ".eJxVjEEOwiAURO_C2hCk8AF39iLk84FAbGgidGW8u63pQpfz3sy8mMdtFL_19PQ1shuTkl1-YUB6pHYYXJYDcyRatzb4t3Pqzu97Sm1UwlHXNp-rv6uCvew_FoECSCVcSAooAiXtUpbZSpODimYCAqcdOm13JY0Vxlw1iCDiFKNi7w_sujxp:1pZxhm:zSprMzikHaWdPjNCuXbw1np1Z5NGjDr-mfABOH_t0xQ", - "expire_date": "2023-03-22T17:35:58.939Z" + "session_data": ".eJxVjEEOwiAURO_C2hCk8AF39iLk84FAbGgidGW8u63pQpfz3sy8mMdtFL_19PQ1shtTil1-YUB6pHYYXJYDcyRatzb4t3Pqzu97Sm1UwlHXNp-rv6uCvew_FoECSCVcSAooAiXtUpbZSpODimYCAqcdOm13JY0Vxlw1iCDiFKNi7w_vSDxt:1piaoS:yGQZ1-RBbvBElxeXrdlH2KidLSh-zOVQfL1IfzgOppc", + "expire_date": "2023-04-15T12:58:32.537Z" } }, { - "model": "sessions.session", - "pk": "k4own88w8gyx1hru7z4awm02mzm0bjp6", + "model": "sites.site", "fields": { - "session_data": ".eJxVjM0OwiAQhN-Fs2koPwt40xdpll0aiA1NhJ6M725retDjfN_MvMSEW8_T1tJzKiyuQo_i8gsj0iPVw-CyHHhAonWrffh2Tt2G255S7YWwl7Xez9XfVcaW9x-PQBGUkSEmA8RAyYY0q9krN0fDTgNBsAGD9btSzkvnRgsyStbMRrw_7Ls8aQ:1pZuvb:yMwFJC-JuHYDMGaXc_0E_-cyNrFJ5vQHdHyGCWplruI", - "expire_date": "2023-03-22T14:38:03.200Z" + "domain": "localhost", + "name": "localhost" } }, { "model": "sites.site", "fields": { - "domain": "example.com", - "name": "example.com" + "domain": "sel2-4.ugent.be", + "name": "sel2-4.ugent.be" + } +}, +{ + "model": "token_blacklist.blacklistedtoken", + "pk": 1, + "fields": { + "token": 34, + "blacklisted_at": "2023-04-19T20:30:21.420Z" + } +}, +{ + "model": "token_blacklist.blacklistedtoken", + "pk": 2, + "fields": { + "token": 36, + "blacklisted_at": "2023-04-19T22:12:35.202Z" + } +}, +{ + "model": "token_blacklist.blacklistedtoken", + "pk": 3, + "fields": { + "token": 37, + "blacklisted_at": "2023-04-20T08:01:20.146Z" + } +}, +{ + "model": "token_blacklist.blacklistedtoken", + "pk": 4, + "fields": { + "token": 39, + "blacklisted_at": "2023-04-20T11:13:48.490Z" } }, { @@ -43,56 +74,64 @@ "fields": { "region": "Antwerpen" } -}, { +}, +{ + "model": "base.region", + "pk": 3, + "fields": { + "region": "Brugge" + } +}, +{ "model": "base.role", "pk": 1, "fields": { - "name": "Default", - "rank": 2147483647, - "description": "The default role" + "name": "Default", + "rank": 2147483647, + "description": "The default role" } - }, - { +}, +{ "model": "base.role", "pk": 2, "fields": { - "name": "Admin", - "rank": 1, - "description": "The admin role" + "name": "Admin", + "rank": 1, + "description": "The admin role" } - }, - { +}, +{ "model": "base.role", "pk": 3, "fields": { - "name": "Superstudent", - "rank": 2, - "description": "The superstudent role" + "name": "Superstudent", + "rank": 2, + "description": "The superstudent role" } - }, - { +}, +{ "model": "base.role", "pk": 4, "fields": { - "name": "Student", - "rank": 3, - "description": "The student role" + "name": "Student", + "rank": 3, + "description": "The student role" } - }, - { +}, +{ "model": "base.role", "pk": 5, "fields": { - "name": "Syndic", - "rank": 3, - "description": "The syndic role" + "name": "Syndic", + "rank": 3, + "description": "The syndic role" } - }, +}, { "model": "base.user", "fields": { "password": "pbkdf2_sha256$390000$h8HANFi7B7Tdf2C83kS23G$2YBNlmr2Frrf0O5ZAK8oreR3NpRtahdoAK/d/r6pG1A=", - "last_login": "2023-03-08T14:26:10Z", + "last_login": "2023-03-21T21:17:46.173Z", "is_superuser": false, "email": "sylvie@test.com", "is_staff": false, @@ -171,7 +210,7 @@ { "model": "base.user", "fields": { - "password": "pbkdf2_sha256$390000$rrdKSp70K3wd0W7Wm9DB3z$OTQYuQ1MuYWr782bZ29wv2Hhfhf+HDh5edWsAxNahdw=", + "password": "pbkdf2_sha256$600000$P8azaB6ftFYdCB1sjJWt1c$NSF9FnmSjZpFerYGmVxA9+XQTD55iV5t8efdgZ47HXU=", "last_login": "2023-03-08T14:28:42Z", "is_superuser": false, "email": "sylvian@test.com", @@ -392,7 +431,7 @@ "model": "base.user", "fields": { "password": "pbkdf2_sha256$390000$A3PF4gDTzyhxBo0LhwktVK$csGfWiwbiPLWv+XbRlrSRB34wjfVJDZEQErhoyoZv1o=", - "last_login": "2023-03-08T14:34:22Z", + "last_login": "2023-03-21T22:16:40.718Z", "is_superuser": true, "email": "adam@test.com", "is_staff": true, @@ -499,7 +538,7 @@ "is_superuser": false, "email": "suffried@test.com", "is_staff": false, - "is_active": true, + "is_active": false, "first_name": "suffried", "last_name": "test", "phone_number": "+32485710347", @@ -514,8 +553,8 @@ { "model": "base.user", "fields": { - "password": "pbkdf2_sha256$390000$XIix7oaPLNdNBRo3mwywn1$kL4TIo2RxnAnLOfG95pfoc39X2/JQHADOTfd1oC9erg=", - "last_login": "2023-03-08T17:35:58.934Z", + "password": "pbkdf2_sha256$600000$b1a7I4XTFcwAp1HbW6ZcLB$DK5/usk4REL+kbR0iQfYlQ4Z/s46bCRkDTU627YchZM=", + "last_login": "2023-04-20T08:37:35.674Z", "is_superuser": true, "email": "admin@test.com", "is_staff": true, @@ -528,7 +567,7 @@ "user_permissions": [], "region": [ 1, - 2 + 3 ] } }, @@ -539,14 +578,16 @@ "city": "Antwerpen", "postal_code": "2000", "street": "Grote Markt", - "house_number": "1", + "house_number": 1, + "bus": "No bus", "client_number": "48943513", "duration": "00:30:00", "syndic": [ "sylke@test.com" ], "region": 2, - "name": null + "name": null, + "public_id": null } }, { @@ -556,14 +597,16 @@ "city": "Gent", "postal_code": "9000", "street": "Veldstraat", - "house_number": "1", + "house_number": 1, + "bus": "No bus", "client_number": null, "duration": "00:45:00", "syndic": [ "sylvano@test.com" ], "region": 1, - "name": null + "name": null, + "public_id": null } }, { @@ -573,14 +616,16 @@ "city": "Antwerpen", "postal_code": "2000", "street": "Universiteitsplein", - "house_number": "1", + "house_number": 1, + "bus": "No bus", "client_number": null, "duration": "01:00:00", "syndic": [ "sydney@test.com" ], "region": 2, - "name": null + "name": null, + "public_id": null } }, { @@ -590,14 +635,16 @@ "city": "Antwerpen", "postal_code": "2000", "street": "Groenenborgerlaan", - "house_number": "171", + "house_number": 171, + "bus": "No bus", "client_number": null, "duration": "01:00:00", "syndic": [ "sydney@test.com" ], "region": 2, - "name": null + "name": null, + "public_id": null } }, { @@ -607,14 +654,16 @@ "city": "Antwerpen", "postal_code": "2000", "street": "Middelheimlaan", - "house_number": "1", + "house_number": 1, + "bus": "No bus", "client_number": null, "duration": "01:00:00", "syndic": [ "sydney@test.com" ], "region": 2, - "name": null + "name": null, + "public_id": null } }, { @@ -624,14 +673,16 @@ "city": "Antwerpen", "postal_code": "2000", "street": "Prinsstraat", - "house_number": "13", + "house_number": 13, + "bus": "No bus", "client_number": null, "duration": "01:00:00", "syndic": [ "sydney@test.com" ], "region": 2, - "name": null + "name": null, + "public_id": null } }, { @@ -641,14 +692,16 @@ "city": "Gent", "postal_code": "9000", "street": "Krijgslaan", - "house_number": "281", + "house_number": 281, + "bus": "No bus", "client_number": null, "duration": "01:00:00", "syndic": [ "sylvian@test.com" ], "region": 1, - "name": null + "name": null, + "public_id": null } }, { @@ -658,14 +711,16 @@ "city": "Gent", "postal_code": "9000", "street": "Karel Lodewijk Ledeganckstraat", - "house_number": "35", + "house_number": 35, + "bus": "No bus", "client_number": null, "duration": "01:00:00", "syndic": [ "sylvian@test.com" ], "region": 1, - "name": null + "name": null, + "public_id": null } }, { @@ -675,14 +730,16 @@ "city": "Gent", "postal_code": "9000", "street": "Tweekerkenstraat", - "house_number": "2", + "house_number": 2, + "bus": "No bus", "client_number": null, "duration": "01:00:00", "syndic": [ "sylvian@test.com" ], "region": 1, - "name": null + "name": null, + "public_id": null } }, { @@ -692,14 +749,16 @@ "city": "Gent", "postal_code": "9000", "street": "Sint-Pietersnieuwstraat", - "house_number": "33", + "house_number": 33, + "bus": "No bus", "client_number": null, "duration": "01:00:00", "syndic": [ "sylvian@test.com" ], "region": 1, - "name": null + "name": null, + "public_id": null } }, { @@ -709,14 +768,16 @@ "city": "Gent", "postal_code": "9000", "street": "Veldstraat", - "house_number": "2", + "house_number": 2, + "bus": "No bus", "client_number": null, "duration": "01:00:00", "syndic": [ "sylvian@test.com" ], "region": 1, - "name": null + "name": null, + "public_id": null } }, { @@ -726,14 +787,16 @@ "city": "Gent", "postal_code": "9000", "street": "Veldstraat", - "house_number": "3", + "house_number": 3, + "bus": "No bus", "client_number": null, "duration": "01:00:00", "syndic": [ "sylvian@test.com" ], "region": 1, - "name": null + "name": null, + "public_id": null } }, { @@ -743,14 +806,16 @@ "city": "Gent", "postal_code": "9000", "street": "Veldstraat", - "house_number": "4", + "house_number": 4, + "bus": "No bus", "client_number": null, "duration": "01:00:00", "syndic": [ "sylvano@test.com" ], "region": 1, - "name": null + "name": null, + "public_id": null } }, { @@ -760,14 +825,16 @@ "city": "Antwerpen", "postal_code": "2000", "street": "Grote Markt", - "house_number": "2", + "house_number": 2, + "bus": "No bus", "client_number": null, "duration": "00:00:00", "syndic": [ "sydney@test.com" ], "region": 2, - "name": null + "name": null, + "public_id": null } }, { @@ -777,14 +844,16 @@ "city": "Antwerpen", "postal_code": "2000", "street": "Grote Markt", - "house_number": "3", + "house_number": 3, + "bus": "No bus", "client_number": null, "duration": "00:00:00", "syndic": [ "sydney@test.com" ], "region": 2, - "name": null + "name": null, + "public_id": null } }, { @@ -794,14 +863,173 @@ "city": "Antwerpen", "postal_code": "2000", "street": "Grote Markt", - "house_number": "4", + "house_number": 4, + "bus": "No bus", "client_number": null, "duration": "00:00:00", "syndic": [ "sydney@test.com" ], "region": 2, - "name": null + "name": "Markt Antwerpen", + "public_id": null + } +}, +{ + "model": "base.building", + "pk": 18, + "fields": { + "city": "Brugge", + "postal_code": "8000", + "street": "steenstraat", + "house_number": 6, + "bus": "No bus", + "client_number": null, + "duration": "00:00:00", + "syndic": [ + "sydney@test.com" + ], + "region": null, + "name": null, + "public_id": null + } +}, +{ + "model": "base.building", + "pk": 19, + "fields": { + "city": "Brugge", + "postal_code": "8310", + "street": "'t Zand", + "house_number": 2, + "bus": "No bus", + "client_number": null, + "duration": "00:00:00", + "syndic": [ + "sydney@test.com" + ], + "region": 3, + "name": null, + "public_id": null + } +}, +{ + "model": "base.building", + "pk": 20, + "fields": { + "city": "Brugge", + "postal_code": "8000", + "street": "Palingstraat", + "house_number": 42, + "bus": "", + "client_number": "648492H895420", + "duration": "00:10:00", + "syndic": [ + "sylvian@test.com" + ], + "region": 3, + "name": "Brugge - Palingstraat", + "public_id": null + } +}, +{ + "model": "base.building", + "pk": 21, + "fields": { + "city": "Brugge", + "postal_code": "8000", + "street": "Stropersgracht", + "house_number": 32, + "bus": "", + "client_number": "7463820H587392", + "duration": "00:20:00", + "syndic": [ + "sylvian@test.com" + ], + "region": 3, + "name": "Stropersgracht- Brugge", + "public_id": null + } +}, +{ + "model": "base.buildingcomment", + "pk": 2, + "fields": { + "comment": "De deur is moeilijk te openen.", + "date": "2023-04-20T09:00:08Z", + "building": 1 + } +}, +{ + "model": "base.buildingcomment", + "pk": 3, + "fields": { + "comment": "De code van de poort is 1234.", + "date": "2023-04-20T09:01:59Z", + "building": 2 + } +}, +{ + "model": "base.buildingcomment", + "pk": 4, + "fields": { + "comment": "De containers staan in verschillende ruimtes.", + "date": "2023-04-20T09:02:26Z", + "building": 3 + } +}, +{ + "model": "base.buildingcomment", + "pk": 5, + "fields": { + "comment": "Je moet langs de achterdeur van het gebouw binnen.", + "date": "2023-04-20T09:02:54Z", + "building": 4 + } +}, +{ + "model": "base.buildingcomment", + "pk": 6, + "fields": { + "comment": "Bel aan bij bewoner op nummer 3, deze laat je binnen.", + "date": "2023-04-20T09:03:44Z", + "building": 11 + } +}, +{ + "model": "base.buildingcomment", + "pk": 7, + "fields": { + "comment": "De code van de poort is 5395", + "date": "2023-04-20T09:04:09Z", + "building": 12 + } +}, +{ + "model": "base.buildingcomment", + "pk": 8, + "fields": { + "comment": "PMD en REST staan op het gelijkvloers, de rest in de kelder.", + "date": "2023-04-20T09:04:30Z", + "building": 13 + } +}, +{ + "model": "base.buildingcomment", + "pk": 9, + "fields": { + "comment": "Je moet langs de grote poort binnen.", + "date": "2023-04-20T09:04:49Z", + "building": 15 + } +}, +{ + "model": "base.buildingcomment", + "pk": 10, + "fields": { + "comment": "De containers hangen vast met een slot (code 7361)", + "date": "2023-04-20T09:05:37Z", + "building": 13 } }, { @@ -1093,136 +1321,307 @@ } }, { - "model": "base.tour", - "pk": 1, + "model": "base.garbagecollection", + "pk": 36, "fields": { - "name": "Centrum", - "region": 1, - "modified_at": "2023-03-08T11:08:29Z" + "building": 2, + "date": "2023-04-20", + "garbage_type": "GRF" } }, { - "model": "base.tour", - "pk": 2, + "model": "base.garbagecollection", + "pk": 37, "fields": { - "name": "Grote Markt", - "region": 2, - "modified_at": "2023-03-08T11:08:45Z" + "building": 2, + "date": "2023-04-20", + "garbage_type": "GLS" } }, { - "model": "base.tour", - "pk": 3, + "model": "base.garbagecollection", + "pk": 38, "fields": { - "name": "UGent Campussen", - "region": 1, - "modified_at": "2023-03-08T14:58:43Z" + "building": 2, + "date": "2023-04-21", + "garbage_type": "PAP" } }, { - "model": "base.tour", - "pk": 4, + "model": "base.garbagecollection", + "pk": 39, "fields": { - "name": "UAntwerpen Campussen", - "region": 2, - "modified_at": "2023-03-08T15:00:25Z" + "building": 2, + "date": "2023-04-21", + "garbage_type": "PMD" } }, { - "model": "base.buildingontour", - "pk": 1, + "model": "base.garbagecollection", + "pk": 40, "fields": { - "tour": 2, - "building": 1, - "index": 1 + "building": 11, + "date": "2023-04-21", + "garbage_type": "RES" } }, { - "model": "base.buildingontour", - "pk": 2, + "model": "base.garbagecollection", + "pk": 41, "fields": { - "tour": 1, - "building": 2, - "index": 1 + "building": 11, + "date": "2023-04-20", + "garbage_type": "GFT" } }, { - "model": "base.buildingontour", - "pk": 3, + "model": "base.garbagecollection", + "pk": 42, "fields": { - "tour": 4, - "building": 3, - "index": 1 + "building": 11, + "date": "2023-04-21", + "garbage_type": "PMD" } }, { - "model": "base.buildingontour", - "pk": 4, + "model": "base.garbagecollection", + "pk": 43, "fields": { - "tour": 4, - "building": 4, - "index": 2 + "building": 12, + "date": "2023-04-20", + "garbage_type": "GLS" } }, { - "model": "base.buildingontour", - "pk": 5, + "model": "base.garbagecollection", + "pk": 44, "fields": { - "tour": 4, - "building": 5, - "index": 3 + "building": 12, + "date": "2023-04-21", + "garbage_type": "PMD" } }, { - "model": "base.buildingontour", - "pk": 6, + "model": "base.garbagecollection", + "pk": 45, "fields": { - "tour": 4, - "building": 6, - "index": 4 + "building": 12, + "date": "2023-04-21", + "garbage_type": "RES" } }, { - "model": "base.buildingontour", - "pk": 7, + "model": "base.garbagecollection", + "pk": 46, "fields": { - "tour": 3, - "building": 7, - "index": 1 + "building": 13, + "date": "2023-04-21", + "garbage_type": "RES" } }, { - "model": "base.buildingontour", - "pk": 8, + "model": "base.garbagecollection", + "pk": 47, "fields": { - "tour": 3, - "building": 10, - "index": 2 + "building": 13, + "date": "2023-04-20", + "garbage_type": "PAP" } }, { - "model": "base.buildingontour", - "pk": 9, + "model": "base.garbagecollection", + "pk": 48, "fields": { - "tour": 3, - "building": 9, - "index": 3 + "building": 13, + "date": "2023-04-21", + "garbage_type": "GRF" } }, { - "model": "base.buildingontour", - "pk": 10, + "model": "base.garbagecollection", + "pk": 49, "fields": { - "tour": 3, - "building": 8, - "index": 4 + "building": 20, + "date": "2023-04-20", + "garbage_type": "GRF" } }, { - "model": "base.buildingontour", - "pk": 11, + "model": "base.garbagecollection", + "pk": 50, "fields": { - "tour": 1, + "building": 19, + "date": "2023-04-21", + "garbage_type": "RES" + } +}, +{ + "model": "base.garbagecollection", + "pk": 51, + "fields": { + "building": 20, + "date": "2023-04-21", + "garbage_type": "GLS" + } +}, +{ + "model": "base.garbagecollection", + "pk": 52, + "fields": { + "building": 21, + "date": "2023-04-21", + "garbage_type": "GFT" + } +}, +{ + "model": "base.garbagecollection", + "pk": 53, + "fields": { + "building": 21, + "date": "2023-04-20", + "garbage_type": "GLS" + } +}, +{ + "model": "base.garbagecollection", + "pk": 54, + "fields": { + "building": 20, + "date": "2023-04-21", + "garbage_type": "GFT" + } +}, +{ + "model": "base.tour", + "pk": 1, + "fields": { + "name": "Centrum", + "region": 1, + "modified_at": "2023-03-08T11:08:29Z" + } +}, +{ + "model": "base.tour", + "pk": 2, + "fields": { + "name": "Grote Markt", + "region": 2, + "modified_at": "2023-03-08T11:08:45Z" + } +}, +{ + "model": "base.tour", + "pk": 3, + "fields": { + "name": "UGent Campussen", + "region": 1, + "modified_at": "2023-03-08T14:58:43Z" + } +}, +{ + "model": "base.tour", + "pk": 4, + "fields": { + "name": "UAntwerpen Campussen", + "region": 2, + "modified_at": "2023-03-08T15:00:25Z" + } +}, +{ + "model": "base.buildingontour", + "pk": 1, + "fields": { + "tour": 2, + "building": 1, + "index": 1 + } +}, +{ + "model": "base.buildingontour", + "pk": 2, + "fields": { + "tour": 1, + "building": 2, + "index": 1 + } +}, +{ + "model": "base.buildingontour", + "pk": 3, + "fields": { + "tour": 4, + "building": 3, + "index": 1 + } +}, +{ + "model": "base.buildingontour", + "pk": 4, + "fields": { + "tour": 4, + "building": 4, + "index": 2 + } +}, +{ + "model": "base.buildingontour", + "pk": 5, + "fields": { + "tour": 4, + "building": 5, + "index": 3 + } +}, +{ + "model": "base.buildingontour", + "pk": 6, + "fields": { + "tour": 4, + "building": 6, + "index": 4 + } +}, +{ + "model": "base.buildingontour", + "pk": 7, + "fields": { + "tour": 3, + "building": 7, + "index": 1 + } +}, +{ + "model": "base.buildingontour", + "pk": 8, + "fields": { + "tour": 3, + "building": 10, + "index": 2 + } +}, +{ + "model": "base.buildingontour", + "pk": 9, + "fields": { + "tour": 3, + "building": 9, + "index": 3 + } +}, +{ + "model": "base.buildingontour", + "pk": 10, + "fields": { + "tour": 3, + "building": 8, + "index": 4 + } +}, +{ + "model": "base.buildingontour", + "pk": 11, + "fields": { + "tour": 1, "building": 11, "index": 2 } @@ -1273,197 +1672,411 @@ } }, { - "model": "base.studentatbuildingontour", + "model": "base.studentontour", + "pk": 1, + "fields": { + "tour": 1, + "date": "2023-04-01", + "student": [ + "stijn@test.com" + ] + } +}, +{ + "model": "base.studentontour", + "pk": 2, + "fields": { + "tour": 3, + "date": "2023-04-01", + "student": [ + "stef@test.com" + ] + } +}, +{ + "model": "base.studentontour", "pk": 3, "fields": { - "building_on_tour": 1, - "date": "2023-03-09", + "tour": 1, + "date": "2023-04-18", "student": [ - "stella@test.com" + "steven@test.com" ] } }, { - "model": "base.studentatbuildingontour", + "model": "base.studentontour", "pk": 4, "fields": { - "building_on_tour": 15, - "date": "2023-03-09", + "tour": 1, + "date": "2023-04-11", "student": [ - "stella@test.com" + "stephan@test.com" ] } }, { - "model": "base.studentatbuildingontour", + "model": "base.studentontour", "pk": 5, "fields": { - "building_on_tour": 14, - "date": "2023-03-09", + "tour": 1, + "date": "2023-04-11", "student": [ - "stella@test.com" + "sten@test.com" ] } }, { - "model": "base.studentatbuildingontour", + "model": "base.studentontour", "pk": 6, "fields": { - "building_on_tour": 16, - "date": "2023-03-09", + "tour": 1, + "date": "2023-04-24", "student": [ - "stella@test.com" + "stijn@test.com" ] } }, { - "model": "base.studentatbuildingontour", + "model": "base.studentontour", "pk": 7, "fields": { - "building_on_tour": 2, - "date": "2023-03-09", + "tour": 1, + "date": "2023-04-27", "student": [ - "stijn@test.com" + "steven@test.com" ] } }, { - "model": "base.studentatbuildingontour", + "model": "base.studentontour", "pk": 8, "fields": { - "building_on_tour": 11, - "date": "2023-03-09", + "tour": 1, + "date": "2023-04-22", "student": [ - "stijn@test.com" + "sten@test.com" ] } }, { - "model": "base.studentatbuildingontour", + "model": "base.studentontour", "pk": 9, "fields": { - "building_on_tour": 12, - "date": "2023-03-09", + "tour": 1, + "date": "2023-04-17", "student": [ "stijn@test.com" ] } }, { - "model": "base.studentatbuildingontour", + "model": "base.studentontour", "pk": 10, "fields": { - "building_on_tour": 13, - "date": "2023-03-09", + "tour": 2, + "date": "2023-04-10", "student": [ - "stijn@test.com" + "stella@test.com" ] } }, { - "model": "base.studentatbuildingontour", + "model": "base.studentontour", "pk": 11, "fields": { - "building_on_tour": 3, - "date": "2023-03-08", + "tour": 4, + "date": "2023-04-19", "student": [ - "stacey@test.com" + "stefanie@test.com" ] } }, { - "model": "base.studentatbuildingontour", + "model": "base.studentontour", "pk": 12, "fields": { - "building_on_tour": 4, - "date": "2023-03-08", + "tour": 2, + "date": "2023-04-19", "student": [ "stacey@test.com" ] } }, { - "model": "base.studentatbuildingontour", + "model": "base.studentontour", "pk": 13, "fields": { - "building_on_tour": 5, - "date": "2023-03-08", + "tour": 2, + "date": "2023-04-18", "student": [ - "stacey@test.com" + "stefanie@test.com" ] } }, { - "model": "base.studentatbuildingontour", + "model": "base.studentontour", "pk": 14, "fields": { - "building_on_tour": 6, - "date": "2023-03-08", + "tour": 4, + "date": "2023-04-26", "student": [ - "stacey@test.com" + "stella@test.com" ] } }, { - "model": "base.studentatbuildingontour", + "model": "base.studentontour", "pk": 15, "fields": { - "building_on_tour": 7, - "date": "2023-03-08", + "tour": 4, + "date": "2023-04-10", "student": [ - "stef@test.com" + "stacey@test.com" ] } }, { - "model": "base.studentatbuildingontour", + "model": "base.studentontour", "pk": 16, "fields": { - "building_on_tour": 8, - "date": "2023-03-08", + "tour": 1, + "date": "2023-04-04", "student": [ - "stef@test.com" + "stanford@test.com" ] } }, { - "model": "base.studentatbuildingontour", + "model": "base.studentontour", "pk": 17, "fields": { - "building_on_tour": 9, - "date": "2023-03-08", + "tour": 4, + "date": "2023-04-16", "student": [ - "stef@test.com" + "sterre@test.com" ] } }, { - "model": "base.studentatbuildingontour", + "model": "base.studentontour", "pk": 18, "fields": { - "building_on_tour": 10, - "date": "2023-03-08", + "tour": 1, + "date": "2023-04-19", "student": [ - "stef@test.com" + "stephan@test.com" ] } }, { - "model": "token_blacklist.outstandingtoken", - "pk": 1, + "model": "base.studentontour", + "pk": 19, "fields": { - "user": null, - "jti": "a195fc2e7bd2401ea22fda9d83850779", - "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTY3OTQ4Mjg3NCwiaWF0IjoxNjc4MjczMjc0LCJqdGkiOiJhMTk1ZmMyZTdiZDI0MDFlYTIyZmRhOWQ4Mzg1MDc3OSIsInVzZXJfaWQiOjJ9.IMc_u4M0O-eOhmj0VneNZV6rob68vbdzBDKTyvPtu38", - "created_at": "2023-03-08T11:01:14.548Z", - "expires_at": "2023-03-22T11:01:14Z" + "tour": 4, + "date": "2023-04-20", + "student": [ + "stefanie@test.com" + ] } }, { - "model": "token_blacklist.outstandingtoken", - "pk": 2, + "model": "base.studentontour", + "pk": 20, "fields": { - "user": null, + "tour": 2, + "date": "2023-04-20", + "student": [ + "stella@test.com" + ] + } +}, +{ + "model": "base.studentontour", + "pk": 21, + "fields": { + "tour": 1, + "date": "2023-04-20", + "student": [ + "stanford@test.com" + ] + } +}, +{ + "model": "base.studentontour", + "pk": 22, + "fields": { + "tour": 3, + "date": "2023-04-20", + "student": [ + "steven@test.com" + ] + } +}, +{ + "model": "base.studentontour", + "pk": 24, + "fields": { + "tour": 2, + "date": "2023-04-21", + "student": [ + "sterre@test.com" + ] + } +}, +{ + "model": "base.studentontour", + "pk": 25, + "fields": { + "tour": 4, + "date": "2023-04-21", + "student": [ + "stacey@test.com" + ] + } +}, +{ + "model": "base.studentontour", + "pk": 26, + "fields": { + "tour": 3, + "date": "2023-04-21", + "student": [ + "stef@test.com" + ] + } +}, +{ + "model": "base.studentontour", + "pk": 52, + "fields": { + "tour": 1, + "date": "2023-04-18", + "student": [ + "stanford@test.com" + ] + } +}, +{ + "model": "base.studentontour", + "pk": 53, + "fields": { + "tour": 2, + "date": "2023-04-17", + "student": [ + "stella@test.com" + ] + } +}, +{ + "model": "base.studentontour", + "pk": 54, + "fields": { + "tour": 3, + "date": "2023-04-19", + "student": [ + "sten@test.com" + ] + } +}, +{ + "model": "base.studentontour", + "pk": 55, + "fields": { + "tour": 2, + "date": "2023-04-18", + "student": [ + "stacey@test.com" + ] + } +}, +{ + "model": "base.remarkatbuilding", + "pk": 15, + "fields": { + "student_on_tour": 4, + "building": 2, + "timestamp": "2023-04-20T10:33:39Z", + "remark": "Aankomst", + "type": "AA" + } +}, +{ + "model": "base.remarkatbuilding", + "pk": 16, + "fields": { + "student_on_tour": 9, + "building": 12, + "timestamp": "2023-04-20T10:33:57Z", + "remark": "Binnen", + "type": "BI" + } +}, +{ + "model": "base.remarkatbuilding", + "pk": 17, + "fields": { + "student_on_tour": 13, + "building": 15, + "timestamp": "2023-04-24T10:34:17Z", + "remark": "Een bewoner zei me dat er niet gesorteerd wordt.", + "type": "OP" + } +}, +{ + "model": "base.remarkatbuilding", + "pk": 18, + "fields": { + "student_on_tour": 11, + "building": 4, + "timestamp": "2023-04-20T11:24:39Z", + "remark": "De deur is geblokkeerd.", + "type": "OP" + } +}, +{ + "model": "base.remarkatbuilding", + "pk": 19, + "fields": { + "student_on_tour": 3, + "building": 11, + "timestamp": "2023-04-20T11:25:16Z", + "remark": "Vertrek", + "type": "VE" + } +}, +{ + "model": "base.emailtemplate", + "pk": 1, + "fields": { + "name": "Containers 404", + "template": "Beste,\r\n\r\n\r\nDrTrottoir kon uw containers niet buitenzetten, omdat deze niet op de afgesproken plaats stonden.\r\n\r\nSurf naar de link van uw gebouw om de foto's te raadplegen.\r\n\r\n\r\nMet vriendelijke groeten,\r\nTeam DrTrottoir" + } +}, +{ + "model": "base.emailtemplate", + "pk": 2, + "fields": { + "name": "Staking IVAGO", + "template": "Beste,\r\n\r\n\r\nZoals u wellicht al vernomen hebt, staken de vuilnismannen -en vrouwen van IVAGO.\r\n\r\nDrTrottoir zal dus uitzonderlijk niet langskomen om uw containers buiten te zetten.\r\n\r\n\r\nMet vriendelijke groeten,\r\nDrTrottoir" + } +}, +{ + "model": "token_blacklist.outstandingtoken", + "pk": 1, + "fields": { + "user": null, + "jti": "a195fc2e7bd2401ea22fda9d83850779", + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTY3OTQ4Mjg3NCwiaWF0IjoxNjc4MjczMjc0LCJqdGkiOiJhMTk1ZmMyZTdiZDI0MDFlYTIyZmRhOWQ4Mzg1MDc3OSIsInVzZXJfaWQiOjJ9.IMc_u4M0O-eOhmj0VneNZV6rob68vbdzBDKTyvPtu38", + "created_at": "2023-03-08T11:01:14.548Z", + "expires_at": "2023-03-22T11:01:14Z" + } +}, +{ + "model": "token_blacklist.outstandingtoken", + "pk": 2, + "fields": { + "user": null, "jti": "124b7a3ac2654ad7b22d5dadee830693", "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTY3OTQ4Mjg5NCwiaWF0IjoxNjc4MjczMjk0LCJqdGkiOiIxMjRiN2EzYWMyNjU0YWQ3YjIyZDVkYWRlZTgzMDY5MyIsInVzZXJfaWQiOjN9._jd_jI10naYOQdIxGp3nSsacPaQN1CblJiMO_42MmNU", "created_at": "2023-03-08T11:01:34.702Z", @@ -1788,147 +2401,321 @@ } }, { - "model": "account.emailaddress", - "pk": 7, + "model": "token_blacklist.outstandingtoken", + "pk": 28, "fields": { "user": [ "sylvie@test.com" ], - "email": "sylvie@test.com", - "verified": false, - "primary": true + "jti": "ccdc94390f1d4550bcc2ad0c8f865cd1", + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTY4MDU2ODEzNSwiaWF0IjoxNjc5MzU4NTM1LCJqdGkiOiJjY2RjOTQzOTBmMWQ0NTUwYmNjMmFkMGM4Zjg2NWNkMSIsInVzZXJfaWQiOjF9.LenPBPEnciTrlXq1kAmfQXGRLB5FKufpWsjX_UZpaQ8", + "created_at": "2023-03-21T00:28:55.421Z", + "expires_at": "2023-04-04T00:28:55Z" } }, { - "model": "account.emailaddress", - "pk": 8, + "model": "token_blacklist.outstandingtoken", + "pk": 29, "fields": { "user": [ - "sydney@test.com" + "sylvie@test.com" ], - "email": "sydney@test.com", - "verified": false, - "primary": true + "jti": "c3e5abaa82054f04b5439a23267fc253", + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTY4MDY0MzA2NiwiaWF0IjoxNjc5NDMzNDY2LCJqdGkiOiJjM2U1YWJhYTgyMDU0ZjA0YjU0MzlhMjMyNjdmYzI1MyIsInVzZXJfaWQiOjF9.3XQU_aE62t3bmdibCK7WNlnwdjr7x4r_Q5RSX_og1sk", + "created_at": "2023-03-21T21:17:46.133Z", + "expires_at": "2023-04-04T21:17:46Z" } }, { - "model": "account.emailaddress", - "pk": 9, + "model": "token_blacklist.outstandingtoken", + "pk": 30, "fields": { "user": [ - "sylvano@test.com" + "adam@test.com" ], - "email": "sylvano@test.com", - "verified": false, - "primary": true + "jti": "df1ee19d62bf4198bb3c8452e25d2d76", + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTY4MDY0MzA4MCwiaWF0IjoxNjc5NDMzNDgwLCJqdGkiOiJkZjFlZTE5ZDYyYmY0MTk4YmIzYzg0NTJlMjVkMmQ3NiIsInVzZXJfaWQiOjE2fQ.-eO__dlpMIMmWhQvixeWRmuT9b5kIJBMb-erHLL_XD0", + "created_at": "2023-03-21T21:18:00.599Z", + "expires_at": "2023-04-04T21:18:00Z" } }, { - "model": "account.emailaddress", - "pk": 10, + "model": "token_blacklist.outstandingtoken", + "pk": 31, "fields": { "user": [ - "sylke@test.com" + "adam@test.com" ], - "email": "sylke@test.com", - "verified": false, - "primary": true + "jti": "d612262953224410813b5a09741ded52", + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTY4MDY0MzQ3NiwiaWF0IjoxNjc5NDMzODc2LCJqdGkiOiJkNjEyMjYyOTUzMjI0NDEwODEzYjVhMDk3NDFkZWQ1MiIsInVzZXJfaWQiOjE2fQ.dgwAs6gdTLDBBzFJvinX8UL8IfnV3q4e9uyDp3m21_A", + "created_at": "2023-03-21T21:24:36.630Z", + "expires_at": "2023-04-04T21:24:36Z" } }, { - "model": "account.emailaddress", - "pk": 11, + "model": "token_blacklist.outstandingtoken", + "pk": 32, "fields": { "user": [ - "sylvian@test.com" + "adam@test.com" ], - "email": "synthia@test.com", - "verified": false, - "primary": true + "jti": "b56cac5faf034716b8279e108b191f66", + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTY4MDY0MzgzOCwiaWF0IjoxNjc5NDM0MjM4LCJqdGkiOiJiNTZjYWM1ZmFmMDM0NzE2YjgyNzllMTA4YjE5MWY2NiIsInVzZXJfaWQiOjE2fQ.lMPCJFeq6nhiHQKPqe6LAePADl3u0K1yvVUKVvdhHGA", + "created_at": "2023-03-21T21:30:38.025Z", + "expires_at": "2023-04-04T21:30:38Z" } }, { - "model": "account.emailaddress", - "pk": 12, + "model": "token_blacklist.outstandingtoken", + "pk": 33, "fields": { "user": [ - "stijn@test.com" + "adam@test.com" ], - "email": "stijn@test.com", - "verified": false, - "primary": true + "jti": "ea3f7105db9c4ae9b32d3b0ceecda88d", + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTY4MDY0NDkxMCwiaWF0IjoxNjc5NDM1MzEwLCJqdGkiOiJlYTNmNzEwNWRiOWM0YWU5YjMyZDNiMGNlZWNkYTg4ZCIsInVzZXJfaWQiOjE2fQ.2MZq4Ga59WAki1DhEE7zaSSdG3JpyWLM4HVU4hAdatM", + "created_at": "2023-03-21T21:48:30.315Z", + "expires_at": "2023-04-04T21:48:30Z" } }, { - "model": "account.emailaddress", - "pk": 13, + "model": "token_blacklist.outstandingtoken", + "pk": 34, "fields": { - "user": [ - "stef@test.com" - ], - "email": "stef@test.com", - "verified": false, - "primary": true + "user": null, + "jti": "9f80b37288c647ecbfb7b10186a1f089", + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTY4MzEyMzk1MCwiaWF0IjoxNjgxOTE0MzUwLCJqdGkiOiI5ZjgwYjM3Mjg4YzY0N2VjYmZiN2IxMDE4NmExZjA4OSIsInVzZXJfaWQiOjIyfQ.t_CR_oTjr7QtSheatUHZVSx6N86_6mUjCI0fDBOI4bA", + "created_at": null, + "expires_at": "2023-05-03T14:25:50Z" } }, { - "model": "account.emailaddress", - "pk": 14, + "model": "token_blacklist.outstandingtoken", + "pk": 35, "fields": { "user": [ - "stephan@test.com" + "admin@test.com" ], - "email": "stephan@test.com", - "verified": false, - "primary": true + "jti": "d266ea7deb4841c785e30671ab58305d", + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTY4MzE0ODM2MywiaWF0IjoxNjgxOTM4NzYzLCJqdGkiOiJkMjY2ZWE3ZGViNDg0MWM3ODVlMzA2NzFhYjU4MzA1ZCIsInVzZXJfaWQiOjIyfQ.xaQM7YuHnX351zrPUFDHbGnI3qPdSgjWiaF7RoFhttI", + "created_at": "2023-04-19T21:12:43.109Z", + "expires_at": "2023-05-03T21:12:43Z" } }, { - "model": "account.emailaddress", - "pk": 15, + "model": "token_blacklist.outstandingtoken", + "pk": 36, "fields": { - "user": [ - "steven@test.com" - ], - "email": "steven@test.com", - "verified": false, - "primary": true + "user": null, + "jti": "ad334926ec58460f95c3f5ed6bce4b70", + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTY4MzE0NTgyMSwiaWF0IjoxNjgxOTM2MjIxLCJqdGkiOiJhZDMzNDkyNmVjNTg0NjBmOTVjM2Y1ZWQ2YmNlNGI3MCIsInVzZXJfaWQiOjIyfQ.20pmj0RkoDXo-sdu_QkhHnxJmlZUwmkciWKrOgijmTU", + "created_at": null, + "expires_at": "2023-05-03T20:30:21Z" } }, { - "model": "account.emailaddress", - "pk": 16, + "model": "token_blacklist.outstandingtoken", + "pk": 37, "fields": { - "user": [ - "sten@test.com" - ], - "email": "sten@test.com", - "verified": false, - "primary": true + "user": null, + "jti": "ecaaa26ce5fd42b9b1f81b5998bd3a47", + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTY4MzE1MTk1NCwiaWF0IjoxNjgxOTQyMzU0LCJqdGkiOiJlY2FhYTI2Y2U1ZmQ0MmI5YjFmODFiNTk5OGJkM2E0NyIsInVzZXJfaWQiOjIyfQ.DrG81k6MCcHkrT9OhIpy__1JURJDbpf1gEusW67sNYY", + "created_at": null, + "expires_at": "2023-05-03T22:12:34Z" } }, { - "model": "account.emailaddress", - "pk": 17, + "model": "token_blacklist.outstandingtoken", + "pk": 38, "fields": { "user": [ - "sterre@test.com" + "admin@test.com" ], - "email": "sterre@test.com", - "verified": false, - "primary": true + "jti": "36e30e1f79644b258be2ef64d99cedf0", + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTY4MzE4Nzg5OSwiaWF0IjoxNjgxOTc4Mjk5LCJqdGkiOiIzNmUzMGUxZjc5NjQ0YjI1OGJlMmVmNjRkOTljZWRmMCIsInVzZXJfaWQiOjIyfQ.f7M6fSAJY_ysw4DJklVSKRLXwl1PZP7n2LcNgS_SnK4", + "created_at": "2023-04-20T08:11:39.511Z", + "expires_at": "2023-05-04T08:11:39Z" } }, { - "model": "account.emailaddress", - "pk": 18, + "model": "token_blacklist.outstandingtoken", + "pk": 39, "fields": { - "user": [ - "stella@test.com" - ], - "email": "stella@test.com", - "verified": false, - "primary": true + "user": null, + "jti": "145872994e244255a65a3f49b2df72a8", + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTY4MzE4NzI3OSwiaWF0IjoxNjgxOTc3Njc5LCJqdGkiOiIxNDU4NzI5OTRlMjQ0MjU1YTY1YTNmNDliMmRmNzJhOCIsInVzZXJfaWQiOjIyfQ.YN3kOfCZqPvP0QpaxhfCuurY_o5M7pJbWFW6BQz-MPQ", + "created_at": null, + "expires_at": "2023-05-04T08:01:19Z" + } +}, +{ + "model": "token_blacklist.outstandingtoken", + "pk": 40, + "fields": { + "user": [ + "sylvian@test.com" + ], + "jti": "a1b7f07b8dd54f3390e5b0de93935797", + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTY4MzE5ODg2MCwiaWF0IjoxNjgxOTg5MjYwLCJqdGkiOiJhMWI3ZjA3YjhkZDU0ZjMzOTBlNWIwZGU5MzkzNTc5NyIsInVzZXJfaWQiOjV9.RSIgMlFh2MPk0GBxo9j54fbE46swIYoPgZ87t-SOqBU", + "created_at": "2023-04-20T11:14:20.839Z", + "expires_at": "2023-05-04T11:14:20Z" + } +}, +{ + "model": "token_blacklist.outstandingtoken", + "pk": 41, + "fields": { + "user": [ + "sylvian@test.com" + ], + "jti": "df903eaa817843df91b41d86d0e587df", + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTY4MzE5OTU1NSwiaWF0IjoxNjgxOTg5OTU1LCJqdGkiOiJkZjkwM2VhYTgxNzg0M2RmOTFiNDFkODZkMGU1ODdkZiIsInVzZXJfaWQiOjV9.u3IaCXSI3a0u9-1RBL9BO8uY2N0E_HhrZYtLKMx6DVk", + "created_at": "2023-04-20T11:25:55.285Z", + "expires_at": "2023-05-04T11:25:55Z" + } +}, +{ + "model": "account.emailaddress", + "pk": 7, + "fields": { + "user": [ + "sylvie@test.com" + ], + "email": "sylvie@test.com", + "verified": false, + "primary": true + } +}, +{ + "model": "account.emailaddress", + "pk": 8, + "fields": { + "user": [ + "sydney@test.com" + ], + "email": "sydney@test.com", + "verified": false, + "primary": true + } +}, +{ + "model": "account.emailaddress", + "pk": 9, + "fields": { + "user": [ + "sylvano@test.com" + ], + "email": "sylvano@test.com", + "verified": false, + "primary": true + } +}, +{ + "model": "account.emailaddress", + "pk": 10, + "fields": { + "user": [ + "sylke@test.com" + ], + "email": "sylke@test.com", + "verified": false, + "primary": true + } +}, +{ + "model": "account.emailaddress", + "pk": 11, + "fields": { + "user": [ + "sylvian@test.com" + ], + "email": "synthia@test.com", + "verified": false, + "primary": true + } +}, +{ + "model": "account.emailaddress", + "pk": 12, + "fields": { + "user": [ + "stijn@test.com" + ], + "email": "stijn@test.com", + "verified": false, + "primary": true + } +}, +{ + "model": "account.emailaddress", + "pk": 13, + "fields": { + "user": [ + "stef@test.com" + ], + "email": "stef@test.com", + "verified": false, + "primary": true + } +}, +{ + "model": "account.emailaddress", + "pk": 14, + "fields": { + "user": [ + "stephan@test.com" + ], + "email": "stephan@test.com", + "verified": false, + "primary": true + } +}, +{ + "model": "account.emailaddress", + "pk": 15, + "fields": { + "user": [ + "steven@test.com" + ], + "email": "steven@test.com", + "verified": false, + "primary": true + } +}, +{ + "model": "account.emailaddress", + "pk": 16, + "fields": { + "user": [ + "sten@test.com" + ], + "email": "sten@test.com", + "verified": false, + "primary": true + } +}, +{ + "model": "account.emailaddress", + "pk": 17, + "fields": { + "user": [ + "sterre@test.com" + ], + "email": "sterre@test.com", + "verified": false, + "primary": true + } +}, +{ + "model": "account.emailaddress", + "pk": 18, + "fields": { + "user": [ + "stella@test.com" + ], + "email": "stella@test.com", + "verified": false, + "primary": true } }, { @@ -2151,1267 +2938,3067 @@ "model": "admin.logentry", "pk": 33, "fields": { - "action_time": "2023-03-08T14:43:29.528Z", + "action_time": "2023-03-08T14:43:29.528Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "user" + ], + "object_id": "25", + "object_repr": "adam@test.com (AD)", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Superuser status\", \"Is staff\", \"first_name\", \"last_name\", \"Phone number\", \"Role\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 34, + "fields": { + "action_time": "2023-03-08T14:43:59.200Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "user" + ], + "object_id": "24", + "object_repr": "stanford@test.com (ST)", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"first_name\", \"last_name\", \"Phone number\", \"Region\", \"Role\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 35, + "fields": { + "action_time": "2023-03-08T14:44:20.621Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "user" + ], + "object_id": "31", + "object_repr": "joe@test.com (AD)", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Region\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 36, + "fields": { + "action_time": "2023-03-08T14:45:12.079Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "user" + ], + "object_id": "30", + "object_repr": "suffried@test.com (SS)", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Email address\", \"first_name\", \"Region\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 37, + "fields": { + "action_time": "2023-03-08T14:45:18.068Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "user" + ], + "object_id": "29", + "object_repr": "suzy@test.com (SS)", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Region\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 38, + "fields": { + "action_time": "2023-03-08T14:45:24.900Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "user" + ], + "object_id": "28", + "object_repr": "suzanne@test.com (SS)", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Region\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 39, + "fields": { + "action_time": "2023-03-08T14:45:30.492Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "user" + ], + "object_id": "27", + "object_repr": "adelynn@test.com (AD)", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Region\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 40, + "fields": { + "action_time": "2023-03-08T14:45:34.892Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "user" + ], + "object_id": "26", + "object_repr": "adriana@test.com (AD)", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Region\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 41, + "fields": { + "action_time": "2023-03-08T14:45:39.096Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "user" + ], + "object_id": "25", + "object_repr": "adam@test.com (AD)", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Region\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 42, + "fields": { + "action_time": "2023-03-08T14:45:43.026Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "user" + ], + "object_id": "24", + "object_repr": "stanford@test.com (ST)", + "action_flag": 2, + "change_message": "[]" + } +}, +{ + "model": "admin.logentry", + "pk": 43, + "fields": { + "action_time": "2023-03-08T14:45:57.014Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "user" + ], + "object_id": "23", + "object_repr": "stacey@test.com (ST)", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"first_name\", \"last_name\", \"Phone number\", \"Region\", \"Role\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 44, + "fields": { + "action_time": "2023-03-08T14:46:12.485Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "user" + ], + "object_id": "22", + "object_repr": "stefanie@test.com (ST)", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"first_name\", \"last_name\", \"Phone number\", \"Region\", \"Role\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 45, + "fields": { + "action_time": "2023-03-08T14:46:29.160Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "user" + ], + "object_id": "21", + "object_repr": "stella@test.com (ST)", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"first_name\", \"last_name\", \"Phone number\", \"Region\", \"Role\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 46, + "fields": { + "action_time": "2023-03-08T14:46:37.207Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "user" + ], + "object_id": "20", + "object_repr": "sterre@test.com (ST)", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"first_name\", \"last_name\", \"Phone number\", \"Region\", \"Role\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 47, + "fields": { + "action_time": "2023-03-08T14:46:47.188Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "user" + ], + "object_id": "19", + "object_repr": "sten@test.com (ST)", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"first_name\", \"last_name\", \"Phone number\", \"Region\", \"Role\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 48, + "fields": { + "action_time": "2023-03-08T14:47:22.359Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "user" + ], + "object_id": "18", + "object_repr": "steven@test.com (ST)", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"first_name\", \"last_name\", \"Phone number\", \"Region\", \"Role\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 49, + "fields": { + "action_time": "2023-03-08T14:48:21.802Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "user" + ], + "object_id": "16", + "object_repr": "stef@test.com (ST)", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"first_name\", \"last_name\", \"Phone number\", \"Region\", \"Role\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 50, + "fields": { + "action_time": "2023-03-08T14:48:39.335Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "user" + ], + "object_id": "17", + "object_repr": "stephan@test.com (ST)", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"first_name\", \"last_name\", \"Phone number\", \"Region\", \"Role\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 51, + "fields": { + "action_time": "2023-03-08T14:49:35.174Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "user" + ], + "object_id": "14", + "object_repr": "sylvian@test.com (SY)", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Email address\", \"first_name\", \"last_name\", \"Phone number\", \"Region\", \"Role\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 52, + "fields": { + "action_time": "2023-03-08T14:49:45.732Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "user" + ], + "object_id": "15", + "object_repr": "stijn@test.com (ST)", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"first_name\", \"last_name\", \"Phone number\", \"Region\", \"Role\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 53, + "fields": { + "action_time": "2023-03-08T14:49:56.248Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "user" + ], + "object_id": "13", + "object_repr": "sylke@test.com (SY)", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"first_name\", \"last_name\", \"Phone number\", \"Region\", \"Role\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 54, + "fields": { + "action_time": "2023-03-08T14:50:12.704Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "user" + ], + "object_id": "12", + "object_repr": "sylvano@test.com (SY)", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"first_name\", \"last_name\", \"Phone number\", \"Region\", \"Role\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 55, + "fields": { + "action_time": "2023-03-08T14:50:25.896Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "user" + ], + "object_id": "11", + "object_repr": "sydney@test.com (SY)", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"first_name\", \"last_name\", \"Phone number\", \"Region\", \"Role\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 56, + "fields": { + "action_time": "2023-03-08T14:50:49.506Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "user" + ], + "object_id": "10", + "object_repr": "sylvie@test.com (SY)", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"first_name\", \"last_name\", \"Phone number\", \"Region\", \"Role\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 57, + "fields": { + "action_time": "2023-03-08T14:52:39.891Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "user" + ], + "object_id": "31", + "object_repr": "admin@test.com (AD)", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Email address\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 58, + "fields": { + "action_time": "2023-03-08T14:58:44.752Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "tour" + ], + "object_id": "3", + "object_repr": "UGent Campussen in regio Gent", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 59, + "fields": { + "action_time": "2023-03-08T15:00:27.928Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "tour" + ], + "object_id": "4", + "object_repr": "UAntwerpen Campussen in regio Antwerpen", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 60, + "fields": { + "action_time": "2023-03-08T15:01:59.960Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "building" + ], + "object_id": "3", + "object_repr": "Universiteitsplein 1, Antwerpen 2000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 61, + "fields": { + "action_time": "2023-03-08T15:02:48.962Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "building" + ], + "object_id": "4", + "object_repr": "Groenenborgerlaan 171, Antwerpen 2000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 62, + "fields": { + "action_time": "2023-03-08T15:03:39.204Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "building" + ], + "object_id": "5", + "object_repr": "Middelheimlaan 1, Antwerpen 2000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 63, + "fields": { + "action_time": "2023-03-08T15:04:14.499Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "building" + ], + "object_id": "6", + "object_repr": "Prinsstraat 13, Antwerpen 2000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 64, + "fields": { + "action_time": "2023-03-08T15:04:25.788Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "building" + ], + "object_id": "6", + "object_repr": "Prinsstraat 13, Antwerpen 2000", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Syndic\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 65, + "fields": { + "action_time": "2023-03-08T15:08:08.606Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "buildingontour" + ], + "object_id": "3", + "object_repr": "Universiteitsplein 1, Antwerpen 2000 op ronde UAntwerpen Campussen in regio Antwerpen, index: 1", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 66, + "fields": { + "action_time": "2023-03-08T15:08:19.377Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "buildingontour" + ], + "object_id": "4", + "object_repr": "Groenenborgerlaan 171, Antwerpen 2000 op ronde UAntwerpen Campussen in regio Antwerpen, index: 2", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 67, + "fields": { + "action_time": "2023-03-08T15:08:27.356Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "buildingontour" + ], + "object_id": "5", + "object_repr": "Middelheimlaan 1, Antwerpen 2000 op ronde UAntwerpen Campussen in regio Antwerpen, index: 3", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 68, + "fields": { + "action_time": "2023-03-08T15:08:35.739Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "buildingontour" + ], + "object_id": "6", + "object_repr": "Prinsstraat 13, Antwerpen 2000 op ronde UAntwerpen Campussen in regio Antwerpen, index: 4", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 69, + "fields": { + "action_time": "2023-03-08T15:17:18.139Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "building" + ], + "object_id": "7", + "object_repr": "Krijgslaan 281, Gent 9000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 70, + "fields": { + "action_time": "2023-03-08T16:00:24.947Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "building" + ], + "object_id": "8", + "object_repr": "Karel Lodewijk Ledeganckstraat 35, Gent 9000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 71, + "fields": { + "action_time": "2023-03-08T16:00:58.312Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "building" + ], + "object_id": "9", + "object_repr": "Tweekerkenstraat 2, Gent 9000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 72, + "fields": { + "action_time": "2023-03-08T16:01:24.381Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "building" + ], + "object_id": "10", + "object_repr": "Sint-Pietersnieuwstraat 33, Gent 9000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 73, + "fields": { + "action_time": "2023-03-08T16:01:35.072Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "building" + ], + "object_id": "7", + "object_repr": "Krijgslaan 281, Gent 9000", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Syndic\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 74, + "fields": { + "action_time": "2023-03-08T16:02:06.290Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "buildingontour" + ], + "object_id": "7", + "object_repr": "Krijgslaan 281, Gent 9000 op ronde UGent Campussen in regio Gent, index: 1", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 75, + "fields": { + "action_time": "2023-03-08T16:08:52.700Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "buildingontour" + ], + "object_id": "8", + "object_repr": "Sint-Pietersnieuwstraat 33, Gent 9000 op ronde UGent Campussen in regio Gent, index: 2", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 76, + "fields": { + "action_time": "2023-03-08T16:09:02.005Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "buildingontour" + ], + "object_id": "9", + "object_repr": "Tweekerkenstraat 2, Gent 9000 op ronde UGent Campussen in regio Gent, index: 3", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 77, + "fields": { + "action_time": "2023-03-08T16:09:12.402Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "buildingontour" + ], + "object_id": "10", + "object_repr": "Karel Lodewijk Ledeganckstraat 35, Gent 9000 op ronde UGent Campussen in regio Gent, index: 4", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 78, + "fields": { + "action_time": "2023-03-08T16:12:03.299Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "building" + ], + "object_id": "11", + "object_repr": "Velstraat 1, Gent 9000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 79, + "fields": { + "action_time": "2023-03-08T16:12:21.051Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "building" + ], + "object_id": "2", + "object_repr": "Veldstraat 1, Gent 9000", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"House number\", \"Client number\", \"Syndic\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 80, + "fields": { + "action_time": "2023-03-08T16:16:40.922Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "building" + ], + "object_id": "11", + "object_repr": "Velstraat 2, Gent 9000", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"House number\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 81, + "fields": { + "action_time": "2023-03-08T16:16:45.022Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "building" + ], + "object_id": "2", + "object_repr": "Veldstraat 2, Gent 9000", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"House number\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 82, + "fields": { + "action_time": "2023-03-08T16:20:59.890Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "building" + ], + "object_id": "2", + "object_repr": "Veldstraat 1, Gent 9000", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"House number\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 83, + "fields": { + "action_time": "2023-03-08T16:21:25.140Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "building" + ], + "object_id": "12", + "object_repr": "Veldstraat 3, Gent 9000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 84, + "fields": { + "action_time": "2023-03-08T16:21:52.660Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "building" + ], + "object_id": "13", + "object_repr": "Veldstraat 4, Gent 9000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 85, + "fields": { + "action_time": "2023-03-08T16:22:19.176Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "building" + ], + "object_id": "11", + "object_repr": "Veldstraat 2, Gent 9000", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Street\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 86, + "fields": { + "action_time": "2023-03-08T16:25:43.960Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "buildingontour" + ], + "object_id": "11", + "object_repr": "Veldstraat 2, Gent 9000 op ronde Centrum in regio Gent, index: 2", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 87, + "fields": { + "action_time": "2023-03-08T16:26:04.709Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "buildingontour" + ], + "object_id": "11", + "object_repr": "Veldstraat 2, Gent 9000 op ronde Centrum in regio Gent, index: 2", + "action_flag": 2, + "change_message": "[]" + } +}, +{ + "model": "admin.logentry", + "pk": 88, + "fields": { + "action_time": "2023-03-08T16:26:46.183Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "buildingontour" + ], + "object_id": "11", + "object_repr": "Veldstraat 2, Gent 9000 op ronde Centrum in regio Gent, index: 2", + "action_flag": 2, + "change_message": "[]" + } +}, +{ + "model": "admin.logentry", + "pk": 89, + "fields": { + "action_time": "2023-03-08T16:26:55.424Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "building" + ], + "object_id": "2", + "object_repr": "Veldstraat 1, Gent 9000", + "action_flag": 2, + "change_message": "[]" + } +}, +{ + "model": "admin.logentry", + "pk": 90, + "fields": { + "action_time": "2023-03-08T16:28:45.475Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "buildingontour" + ], + "object_id": "12", + "object_repr": "Veldstraat 3, Gent 9000 op ronde Centrum in regio Gent, index: 3", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 91, + "fields": { + "action_time": "2023-03-08T16:28:54.410Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "buildingontour" + ], + "object_id": "13", + "object_repr": "Veldstraat 4, Gent 9000 op ronde Centrum in regio Gent, index: 4", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 92, + "fields": { + "action_time": "2023-03-08T16:30:07.611Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "building" + ], + "object_id": "1", + "object_repr": "Grote Markt 1, Antwerpen 2000", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"House number\", \"Syndic\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 93, + "fields": { + "action_time": "2023-03-08T16:30:34.261Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "building" + ], + "object_id": "14", + "object_repr": "Grote Markt 2, Antwerpen 9000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 94, + "fields": { + "action_time": "2023-03-08T16:30:59.258Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "building" + ], + "object_id": "15", + "object_repr": "Grote Markt 3, Antwerpen 2000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 95, + "fields": { + "action_time": "2023-03-08T16:31:08.274Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "building" + ], + "object_id": "14", + "object_repr": "Grote Markt 2, Antwerpen 2000", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Postal code\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 96, + "fields": { + "action_time": "2023-03-08T16:31:44.676Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "building" + ], + "object_id": "16", + "object_repr": "Grote Markt 4, Antwerpen 2000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 97, + "fields": { + "action_time": "2023-03-08T16:31:55.261Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "buildingontour" + ], + "object_id": "14", + "object_repr": "Grote Markt 2, Antwerpen 2000 op ronde Grote Markt in regio Antwerpen, index: 2", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 98, + "fields": { + "action_time": "2023-03-08T16:32:01.898Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "buildingontour" + ], + "object_id": "15", + "object_repr": "Grote Markt 3, Antwerpen 2000 op ronde Grote Markt in regio Antwerpen, index: 3", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 99, + "fields": { + "action_time": "2023-03-08T16:32:09.892Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "buildingontour" + ], + "object_id": "16", + "object_repr": "Grote Markt 4, Antwerpen 2000 op ronde Grote Markt in regio Antwerpen, index: 4", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 100, + "fields": { + "action_time": "2023-03-08T16:32:37.550Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "garbagecollection" + ], + "object_id": "1", + "object_repr": "GFT op 2023-03-08 voor 1", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 101, + "fields": { + "action_time": "2023-03-08T16:32:49.793Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "garbagecollection" + ], + "object_id": "2", + "object_repr": "GLS op 2023-03-09 voor 12", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 102, + "fields": { + "action_time": "2023-03-08T16:33:14.208Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "garbagecollection" + ], + "object_id": "3", + "object_repr": "GRF op 2023-03-11 voor 3", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 103, + "fields": { + "action_time": "2023-03-08T16:33:21.994Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "garbagecollection" + ], + "object_id": "4", + "object_repr": "PMD op 2023-03-15 voor 10", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 104, + "fields": { + "action_time": "2023-03-08T16:33:56.022Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "garbagecollection" + ], + "object_id": "5", + "object_repr": "PAP op 2023-03-17 voor 16", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 105, + "fields": { + "action_time": "2023-03-08T17:01:50.343Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "garbagecollection" + ], + "object_id": "1", + "object_repr": "GFT op 2023-03-08 voor Grote Markt 1, Antwerpen 2000", + "action_flag": 2, + "change_message": "[]" + } +}, +{ + "model": "admin.logentry", + "pk": 106, + "fields": { + "action_time": "2023-03-08T17:22:53.560Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "garbagecollection" + ], + "object_id": "6", + "object_repr": "PMD op 2023-03-10 voor Grote Markt 1, Antwerpen 2000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 107, + "fields": { + "action_time": "2023-03-08T17:23:21.034Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "garbagecollection" + ], + "object_id": "7", + "object_repr": "RES op 2023-03-22 voor Grote Markt 3, Antwerpen 2000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 108, + "fields": { + "action_time": "2023-03-08T17:23:32.286Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "garbagecollection" + ], + "object_id": "8", + "object_repr": "PAP op 2023-03-19 voor Grote Markt 3, Antwerpen 2000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 109, + "fields": { + "action_time": "2023-03-08T17:23:48.861Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "garbagecollection" + ], + "object_id": "9", + "object_repr": "PAP op 2023-03-08 voor Grote Markt 2, Antwerpen 2000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 110, + "fields": { + "action_time": "2023-03-08T17:24:03.997Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "garbagecollection" + ], + "object_id": "10", + "object_repr": "KER op 2023-03-09 voor Grote Markt 2, Antwerpen 2000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 111, + "fields": { + "action_time": "2023-03-08T17:24:13.531Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "garbagecollection" + ], + "object_id": "8", + "object_repr": "PAP op 2023-03-09 voor Grote Markt 3, Antwerpen 2000", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Date\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 112, + "fields": { + "action_time": "2023-03-08T17:24:17.210Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "garbagecollection" + ], + "object_id": "7", + "object_repr": "RES op 2023-03-09 voor Grote Markt 3, Antwerpen 2000", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Date\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 113, + "fields": { + "action_time": "2023-03-08T17:24:22.493Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "garbagecollection" + ], + "object_id": "8", + "object_repr": "PAP op 2023-03-08 voor Grote Markt 3, Antwerpen 2000", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Date\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 114, + "fields": { + "action_time": "2023-03-08T17:24:45.436Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "garbagecollection" + ], + "object_id": "2", + "object_repr": "GLS op 2023-03-09 voor Veldstraat 3, Gent 9000", + "action_flag": 3, + "change_message": "" + } +}, +{ + "model": "admin.logentry", + "pk": 115, + "fields": { + "action_time": "2023-03-08T17:24:46.002Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "garbagecollection" + ], + "object_id": "3", + "object_repr": "GRF op 2023-03-11 voor Universiteitsplein 1, Antwerpen 2000", + "action_flag": 3, + "change_message": "" + } +}, +{ + "model": "admin.logentry", + "pk": 116, + "fields": { + "action_time": "2023-03-08T17:24:46.573Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "garbagecollection" + ], + "object_id": "4", + "object_repr": "PMD op 2023-03-15 voor Sint-Pietersnieuwstraat 33, Gent 9000", + "action_flag": 3, + "change_message": "" + } +}, +{ + "model": "admin.logentry", + "pk": 117, + "fields": { + "action_time": "2023-03-08T17:25:04.860Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "garbagecollection" + ], + "object_id": "5", + "object_repr": "PAP op 2023-03-08 voor Grote Markt 4, Antwerpen 2000", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Date\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 118, + "fields": { + "action_time": "2023-03-08T17:25:13.574Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "garbagecollection" + ], + "object_id": "11", + "object_repr": "GLS op 2023-03-09 voor Grote Markt 4, Antwerpen 2000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 119, + "fields": { + "action_time": "2023-03-08T17:25:32.768Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "garbagecollection" + ], + "object_id": "12", + "object_repr": "GFT op 2023-03-08 voor Veldstraat 1, Gent 9000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 120, + "fields": { + "action_time": "2023-03-08T17:25:39.351Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "garbagecollection" + ], + "object_id": "13", + "object_repr": "KER op 2023-03-09 voor Veldstraat 1, Gent 9000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 121, + "fields": { + "action_time": "2023-03-08T17:25:50.695Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "garbagecollection" + ], + "object_id": "14", + "object_repr": "PMD op 2023-03-08 voor Veldstraat 2, Gent 9000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 122, + "fields": { + "action_time": "2023-03-08T17:26:00.684Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "garbagecollection" + ], + "object_id": "15", + "object_repr": "GLS op 2023-03-09 voor Veldstraat 2, Gent 9000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 123, + "fields": { + "action_time": "2023-03-08T17:26:10.633Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "garbagecollection" + ], + "object_id": "16", + "object_repr": "PAP op 2023-03-08 voor Veldstraat 3, Gent 9000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 124, + "fields": { + "action_time": "2023-03-08T17:26:17.012Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "garbagecollection" + ], + "object_id": "17", + "object_repr": "PMD op 2023-03-09 voor Veldstraat 3, Gent 9000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 125, + "fields": { + "action_time": "2023-03-08T17:26:27.293Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "garbagecollection" + ], + "object_id": "18", + "object_repr": "GRF op 2023-03-08 voor Veldstraat 4, Gent 9000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 126, + "fields": { + "action_time": "2023-03-08T17:26:35.922Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "garbagecollection" + ], + "object_id": "19", + "object_repr": "KER op 2023-03-09 voor Veldstraat 4, Gent 9000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 127, + "fields": { + "action_time": "2023-03-08T17:26:52.792Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "garbagecollection" + ], + "object_id": "20", + "object_repr": "GFT op 2023-03-08 voor Krijgslaan 281, Gent 9000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 128, + "fields": { + "action_time": "2023-03-08T17:26:59.304Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "garbagecollection" + ], + "object_id": "21", + "object_repr": "GRF op 2023-03-09 voor Krijgslaan 281, Gent 9000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 129, + "fields": { + "action_time": "2023-03-08T17:27:07.374Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "garbagecollection" + ], + "object_id": "22", + "object_repr": "GRF op 2023-03-08 voor Sint-Pietersnieuwstraat 33, Gent 9000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 130, + "fields": { + "action_time": "2023-03-08T17:27:15.700Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "garbagecollection" + ], + "object_id": "23", + "object_repr": "PAP op 2023-03-09 voor Sint-Pietersnieuwstraat 33, Gent 9000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 131, + "fields": { + "action_time": "2023-03-08T17:27:21.716Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "garbagecollection" + ], + "object_id": "24", + "object_repr": "PMD op 2023-03-08 voor Tweekerkenstraat 2, Gent 9000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 132, + "fields": { + "action_time": "2023-03-08T17:27:27.642Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "garbagecollection" + ], + "object_id": "25", + "object_repr": "RES op 2023-03-09 voor Tweekerkenstraat 2, Gent 9000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 133, + "fields": { + "action_time": "2023-03-08T17:27:34.788Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "user" + "garbagecollection" ], - "object_id": "25", - "object_repr": "adam@test.com (AD)", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Superuser status\", \"Is staff\", \"first_name\", \"last_name\", \"Phone number\", \"Role\"]}}]" + "object_id": "26", + "object_repr": "PAP op 2023-03-08 voor Karel Lodewijk Ledeganckstraat 35, Gent 9000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 34, + "pk": 134, "fields": { - "action_time": "2023-03-08T14:43:59.200Z", + "action_time": "2023-03-08T17:27:39.461Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "user" + "garbagecollection" ], - "object_id": "24", - "object_repr": "stanford@test.com (ST)", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"first_name\", \"last_name\", \"Phone number\", \"Region\", \"Role\"]}}]" + "object_id": "27", + "object_repr": "GLS op 2023-03-08 voor Karel Lodewijk Ledeganckstraat 35, Gent 9000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 35, + "pk": 135, "fields": { - "action_time": "2023-03-08T14:44:20.621Z", + "action_time": "2023-03-08T17:27:45.463Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "user" + "garbagecollection" ], - "object_id": "31", - "object_repr": "joe@test.com (AD)", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Region\"]}}]" + "object_id": "28", + "object_repr": "GLS op 2023-03-08 voor Prinsstraat 13, Antwerpen 2000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 36, + "pk": 136, "fields": { - "action_time": "2023-03-08T14:45:12.079Z", + "action_time": "2023-03-08T17:27:53.937Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "user" + "garbagecollection" ], - "object_id": "30", - "object_repr": "suffried@test.com (SS)", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Email address\", \"first_name\", \"Region\"]}}]" + "object_id": "29", + "object_repr": "KER op 2023-03-09 voor Prinsstraat 13, Antwerpen 2000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 37, + "pk": 137, "fields": { - "action_time": "2023-03-08T14:45:18.068Z", + "action_time": "2023-03-08T17:28:00.222Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "user" + "garbagecollection" ], - "object_id": "29", - "object_repr": "suzy@test.com (SS)", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Region\"]}}]" + "object_id": "30", + "object_repr": "PMD op 2023-03-08 voor Middelheimlaan 1, Antwerpen 2000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 38, + "pk": 138, "fields": { - "action_time": "2023-03-08T14:45:24.900Z", + "action_time": "2023-03-08T17:30:56.771Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "user" + "garbagecollection" ], - "object_id": "28", - "object_repr": "suzanne@test.com (SS)", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Region\"]}}]" + "object_id": "31", + "object_repr": "GRF op 2023-03-09 voor Middelheimlaan 1, Antwerpen 2000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 39, + "pk": 139, "fields": { - "action_time": "2023-03-08T14:45:30.492Z", + "action_time": "2023-03-08T17:31:05.688Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "user" + "garbagecollection" ], - "object_id": "27", - "object_repr": "adelynn@test.com (AD)", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Region\"]}}]" + "object_id": "32", + "object_repr": "GLS op 2023-03-08 voor Groenenborgerlaan 171, Antwerpen 2000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 40, + "pk": 140, "fields": { - "action_time": "2023-03-08T14:45:34.892Z", + "action_time": "2023-03-08T17:31:11.664Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "user" + "garbagecollection" ], - "object_id": "26", - "object_repr": "adriana@test.com (AD)", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Region\"]}}]" + "object_id": "33", + "object_repr": "RES op 2023-03-09 voor Groenenborgerlaan 171, Antwerpen 2000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 41, + "pk": 141, "fields": { - "action_time": "2023-03-08T14:45:39.096Z", + "action_time": "2023-03-08T17:31:20.537Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "user" + "garbagecollection" ], - "object_id": "25", - "object_repr": "adam@test.com (AD)", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Region\"]}}]" + "object_id": "34", + "object_repr": "PAP op 2023-03-08 voor Universiteitsplein 1, Antwerpen 2000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 42, + "pk": 142, "fields": { - "action_time": "2023-03-08T14:45:43.026Z", + "action_time": "2023-03-08T17:31:26.439Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "user" + "garbagecollection" ], - "object_id": "24", - "object_repr": "stanford@test.com (ST)", - "action_flag": 2, - "change_message": "[]" + "object_id": "35", + "object_repr": "GFT op 2023-03-09 voor Universiteitsplein 1, Antwerpen 2000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 43, + "pk": 154, "fields": { - "action_time": "2023-03-08T14:45:57.014Z", + "action_time": "2023-03-09T12:36:55.073Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "user" + "garbagecollection" ], - "object_id": "23", - "object_repr": "stacey@test.com (ST)", + "object_id": "6", + "object_repr": "PMD op 2023-03-09 voor Grote Markt 1, Antwerpen 2000", "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"first_name\", \"last_name\", \"Phone number\", \"Region\", \"Role\"]}}]" + "change_message": "[{\"changed\": {\"fields\": [\"Date\"]}}]" } }, { "model": "admin.logentry", - "pk": 44, + "pk": 164, "fields": { - "action_time": "2023-03-08T14:46:12.485Z", + "action_time": "2023-03-21T22:18:39.554Z", "user": [ - "admin@test.com" + "adam@test.com" ], "content_type": [ "base", - "user" + "region" ], - "object_id": "22", - "object_repr": "stefanie@test.com (ST)", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"first_name\", \"last_name\", \"Phone number\", \"Region\", \"Role\"]}}]" + "object_id": "3", + "object_repr": "Brugge", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 45, + "pk": 165, "fields": { - "action_time": "2023-03-08T14:46:29.160Z", + "action_time": "2023-03-21T22:24:05.620Z", "user": [ - "admin@test.com" + "adam@test.com" ], "content_type": [ "base", - "user" + "manual" ], - "object_id": "21", - "object_repr": "stella@test.com (ST)", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"first_name\", \"last_name\", \"Phone number\", \"Region\", \"Role\"]}}]" + "object_id": "1", + "object_repr": "Manual: manual.pdf (version 0) for Veldstraat 1, Gent 9000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 46, + "pk": 166, "fields": { - "action_time": "2023-03-08T14:46:37.207Z", + "action_time": "2023-03-21T22:32:39.799Z", "user": [ - "admin@test.com" + "adam@test.com" ], "content_type": [ "base", - "user" + "manual" ], - "object_id": "20", - "object_repr": "sterre@test.com (ST)", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"first_name\", \"last_name\", \"Phone number\", \"Region\", \"Role\"]}}]" + "object_id": "2", + "object_repr": "Manual: manualXYZ.pdf (version 0) for Sint-Pietersnieuwstraat 33, Gent 9000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 47, + "pk": 167, "fields": { - "action_time": "2023-03-08T14:46:47.188Z", + "action_time": "2023-03-21T22:34:39.055Z", "user": [ - "admin@test.com" + "adam@test.com" ], "content_type": [ "base", - "user" + "manual" ], - "object_id": "19", - "object_repr": "sten@test.com (ST)", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"first_name\", \"last_name\", \"Phone number\", \"Region\", \"Role\"]}}]" + "object_id": "3", + "object_repr": "Manual: manual_Z9rE7NK.pdf (version 1) for Veldstraat 1, Gent 9000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 48, + "pk": 168, "fields": { - "action_time": "2023-03-08T14:47:22.359Z", + "action_time": "2023-03-21T22:36:01.119Z", "user": [ - "admin@test.com" + "adam@test.com" ], "content_type": [ "base", - "user" + "manual" ], - "object_id": "18", - "object_repr": "steven@test.com (ST)", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"first_name\", \"last_name\", \"Phone number\", \"Region\", \"Role\"]}}]" + "object_id": "4", + "object_repr": "Manual: manual_ZrabAvF.pdf (version 2) for Veldstraat 1, Gent 9000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 49, + "pk": 169, "fields": { - "action_time": "2023-03-08T14:48:21.802Z", + "action_time": "2023-03-21T22:37:43.102Z", "user": [ - "admin@test.com" + "adam@test.com" ], "content_type": [ "base", - "user" + "manual" ], - "object_id": "16", - "object_repr": "stef@test.com (ST)", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"first_name\", \"last_name\", \"Phone number\", \"Region\", \"Role\"]}}]" + "object_id": "5", + "object_repr": "Manual: manual_1rTYOnL.pdf (version 3) for Veldstraat 1, Gent 9000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 50, + "pk": 170, "fields": { - "action_time": "2023-03-08T14:48:39.335Z", + "action_time": "2023-03-21T22:42:00.338Z", "user": [ - "admin@test.com" + "adam@test.com" ], "content_type": [ "base", - "user" + "manual" ], - "object_id": "17", - "object_repr": "stephan@test.com (ST)", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"first_name\", \"last_name\", \"Phone number\", \"Region\", \"Role\"]}}]" + "object_id": "6", + "object_repr": "Manual: manual_Lis1No8.pdf (version 0) for Krijgslaan 281, Gent 9000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 51, + "pk": 171, "fields": { - "action_time": "2023-03-08T14:49:35.174Z", + "action_time": "2023-03-21T22:46:39.905Z", "user": [ - "admin@test.com" + "adam@test.com" ], "content_type": [ "base", - "user" + "manual" ], - "object_id": "14", - "object_repr": "sylvian@test.com (SY)", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Email address\", \"first_name\", \"last_name\", \"Phone number\", \"Region\", \"Role\"]}}]" + "object_id": "7", + "object_repr": "Manual: manual_KGPwTq7.pdf (version 0) for Grote Markt 1, Antwerpen 2000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 52, + "pk": 172, "fields": { - "action_time": "2023-03-08T14:49:45.732Z", + "action_time": "2023-03-21T22:47:07.799Z", "user": [ - "admin@test.com" + "adam@test.com" ], "content_type": [ "base", - "user" + "building" ], - "object_id": "15", - "object_repr": "stijn@test.com (ST)", + "object_id": "17", + "object_repr": "markt 5, Brugge 8000", "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"first_name\", \"last_name\", \"Phone number\", \"Region\", \"Role\"]}}]" + "change_message": "[{\"changed\": {\"fields\": [\"Street\"]}}]" } }, { "model": "admin.logentry", - "pk": 53, + "pk": 173, "fields": { - "action_time": "2023-03-08T14:49:56.248Z", + "action_time": "2023-03-21T22:47:43.442Z", "user": [ - "admin@test.com" + "adam@test.com" ], "content_type": [ "base", - "user" + "building" ], - "object_id": "13", - "object_repr": "sylke@test.com (SY)", + "object_id": "18", + "object_repr": "steenstraat 6, Brugge 8000", "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"first_name\", \"last_name\", \"Phone number\", \"Region\", \"Role\"]}}]" + "change_message": "[{\"changed\": {\"fields\": [\"Street\"]}}]" } }, { "model": "admin.logentry", - "pk": 54, + "pk": 174, "fields": { - "action_time": "2023-03-08T14:50:12.704Z", + "action_time": "2023-03-21T22:48:57.933Z", "user": [ - "admin@test.com" + "adam@test.com" ], "content_type": [ "base", - "user" + "building" ], - "object_id": "12", - "object_repr": "sylvano@test.com (SY)", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"first_name\", \"last_name\", \"Phone number\", \"Region\", \"Role\"]}}]" + "object_id": "19", + "object_repr": "'t Zand 2, Brugge 8310", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 55, + "pk": 175, "fields": { - "action_time": "2023-03-08T14:50:25.896Z", + "action_time": "2023-03-21T22:49:12.262Z", "user": [ - "admin@test.com" + "adam@test.com" ], "content_type": [ "base", - "user" + "building" ], - "object_id": "11", - "object_repr": "sydney@test.com (SY)", + "object_id": "16", + "object_repr": "Grote Markt 4, Antwerpen 2000", "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"first_name\", \"last_name\", \"Phone number\", \"Region\", \"Role\"]}}]" + "change_message": "[{\"changed\": {\"fields\": [\"Name\"]}}]" } }, { "model": "admin.logentry", - "pk": 56, + "pk": 176, "fields": { - "action_time": "2023-03-08T14:50:49.506Z", + "action_time": "2023-03-21T22:52:05.573Z", "user": [ - "admin@test.com" + "adam@test.com" ], "content_type": [ "base", - "user" + "emailtemplate" ], - "object_id": "10", - "object_repr": "sylvie@test.com (SY)", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"first_name\", \"last_name\", \"Phone number\", \"Region\", \"Role\"]}}]" + "object_id": "1", + "object_repr": "EmailTemplate object (1)", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 57, + "pk": 177, "fields": { - "action_time": "2023-03-08T14:52:39.891Z", + "action_time": "2023-03-21T22:54:13.985Z", "user": [ - "admin@test.com" + "adam@test.com" ], "content_type": [ "base", - "user" + "emailtemplate" ], - "object_id": "31", - "object_repr": "admin@test.com (AD)", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Email address\"]}}]" + "object_id": "2", + "object_repr": "EmailTemplate object (2)", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 58, + "pk": 178, "fields": { - "action_time": "2023-03-08T14:58:44.752Z", + "action_time": "2023-03-21T22:57:41.059Z", "user": [ - "admin@test.com" + "adam@test.com" ], "content_type": [ "base", - "tour" + "buildingcomment" ], - "object_id": "3", - "object_repr": "UGent Campussen in regio Gent", + "object_id": "1", + "object_repr": "Comment: De containers stonden niet in de kelder, waar ze normaal wel zouden moeten staan. (2023-03-21 23:57:00+01:00) for Veldstraat 1, Gent 9000", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 59, + "pk": 179, "fields": { - "action_time": "2023-03-08T15:00:27.928Z", + "action_time": "2023-04-01T12:58:51.201Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "tour" + "studentontour" ], - "object_id": "4", - "object_repr": "UAntwerpen Campussen in regio Antwerpen", + "object_id": "1", + "object_repr": "stijn@test.com (Student (rank: 3)) at Tour Centrum in region Gent on 2023-04-01", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 60, + "pk": 180, "fields": { - "action_time": "2023-03-08T15:01:59.960Z", + "action_time": "2023-04-01T12:59:01.377Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "building" + "studentontour" ], - "object_id": "3", - "object_repr": "Universiteitsplein 1, Antwerpen 2000", + "object_id": "2", + "object_repr": "stef@test.com (Student (rank: 3)) at Tour UGent Campussen in region Gent on 2023-04-01", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 61, + "pk": 181, "fields": { - "action_time": "2023-03-08T15:02:48.962Z", + "action_time": "2023-04-01T12:59:15.584Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "building" + "studentontour" ], - "object_id": "4", - "object_repr": "Groenenborgerlaan 171, Antwerpen 2000", + "object_id": "3", + "object_repr": "steven@test.com (Student (rank: 3)) at Tour Centrum in region Gent on 2023-04-18", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 62, + "pk": 182, "fields": { - "action_time": "2023-03-08T15:03:39.204Z", + "action_time": "2023-04-01T12:59:29.614Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "building" + "studentontour" ], - "object_id": "5", - "object_repr": "Middelheimlaan 1, Antwerpen 2000", + "object_id": "4", + "object_repr": "stephan@test.com (Student (rank: 3)) at Tour Centrum in region Gent on 2023-04-11", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 63, + "pk": 183, "fields": { - "action_time": "2023-03-08T15:04:14.499Z", + "action_time": "2023-04-01T12:59:49.127Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "building" + "studentontour" ], - "object_id": "6", - "object_repr": "Prinsstraat 13, Antwerpen 2000", + "object_id": "5", + "object_repr": "sten@test.com (Student (rank: 3)) at Tour Centrum in region Gent on 2023-04-11", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 64, + "pk": 184, "fields": { - "action_time": "2023-03-08T15:04:25.788Z", + "action_time": "2023-04-01T13:00:04.262Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "building" + "studentontour" ], "object_id": "6", - "object_repr": "Prinsstraat 13, Antwerpen 2000", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Syndic\"]}}]" + "object_repr": "stijn@test.com (Student (rank: 3)) at Tour Centrum in region Gent on 2023-04-24", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 65, + "pk": 185, "fields": { - "action_time": "2023-03-08T15:08:08.606Z", + "action_time": "2023-04-01T13:01:08.211Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "buildingontour" + "studentontour" ], - "object_id": "3", - "object_repr": "Universiteitsplein 1, Antwerpen 2000 op ronde UAntwerpen Campussen in regio Antwerpen, index: 1", + "object_id": "7", + "object_repr": "steven@test.com (Student (rank: 3)) at Tour Centrum in region Gent on 2023-04-27", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 66, + "pk": 186, "fields": { - "action_time": "2023-03-08T15:08:19.377Z", + "action_time": "2023-04-01T13:01:20.487Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "buildingontour" + "studentontour" ], - "object_id": "4", - "object_repr": "Groenenborgerlaan 171, Antwerpen 2000 op ronde UAntwerpen Campussen in regio Antwerpen, index: 2", + "object_id": "8", + "object_repr": "sten@test.com (Student (rank: 3)) at Tour Centrum in region Gent on 2023-04-22", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 67, + "pk": 187, "fields": { - "action_time": "2023-03-08T15:08:27.356Z", + "action_time": "2023-04-01T13:01:34.284Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "buildingontour" + "studentontour" ], - "object_id": "5", - "object_repr": "Middelheimlaan 1, Antwerpen 2000 op ronde UAntwerpen Campussen in regio Antwerpen, index: 3", + "object_id": "9", + "object_repr": "stijn@test.com (Student (rank: 3)) at Tour Centrum in region Gent on 2023-04-17", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 68, + "pk": 188, "fields": { - "action_time": "2023-03-08T15:08:35.739Z", + "action_time": "2023-04-01T13:01:54.436Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "buildingontour" + "studentontour" ], - "object_id": "6", - "object_repr": "Prinsstraat 13, Antwerpen 2000 op ronde UAntwerpen Campussen in regio Antwerpen, index: 4", + "object_id": "10", + "object_repr": "stella@test.com (Student (rank: 3)) at Tour Grote Markt in region Antwerpen on 2023-04-10", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 69, + "pk": 189, "fields": { - "action_time": "2023-03-08T15:17:18.139Z", + "action_time": "2023-04-01T13:02:04.221Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "building" + "studentontour" ], - "object_id": "7", - "object_repr": "Krijgslaan 281, Gent 9000", + "object_id": "11", + "object_repr": "stefanie@test.com (Student (rank: 3)) at Tour UAntwerpen Campussen in region Antwerpen on 2023-04-19", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 70, + "pk": 190, "fields": { - "action_time": "2023-03-08T16:00:24.947Z", + "action_time": "2023-04-01T13:02:35.265Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "building" + "studentontour" ], - "object_id": "8", - "object_repr": "Karel Lodewijk Ledeganckstraat 35, Gent 9000", + "object_id": "12", + "object_repr": "stacey@test.com (Student (rank: 3)) at Tour Grote Markt in region Antwerpen on 2023-04-19", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 71, + "pk": 191, "fields": { - "action_time": "2023-03-08T16:00:58.312Z", + "action_time": "2023-04-01T13:02:53.286Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "building" + "studentontour" ], - "object_id": "9", - "object_repr": "Tweekerkenstraat 2, Gent 9000", + "object_id": "13", + "object_repr": "stefanie@test.com (Student (rank: 3)) at Tour Grote Markt in region Antwerpen on 2023-04-18", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 72, + "pk": 192, "fields": { - "action_time": "2023-03-08T16:01:24.381Z", + "action_time": "2023-04-01T13:04:14.573Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "building" + "studentontour" ], - "object_id": "10", - "object_repr": "Sint-Pietersnieuwstraat 33, Gent 9000", + "object_id": "14", + "object_repr": "stella@test.com (Student (rank: 3)) at Tour UAntwerpen Campussen in region Antwerpen on 2023-04-26", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 73, + "pk": 193, "fields": { - "action_time": "2023-03-08T16:01:35.072Z", + "action_time": "2023-04-01T13:04:39.964Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "building" + "studentontour" ], - "object_id": "7", - "object_repr": "Krijgslaan 281, Gent 9000", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Syndic\"]}}]" + "object_id": "15", + "object_repr": "stacey@test.com (Student (rank: 3)) at Tour UAntwerpen Campussen in region Antwerpen on 2023-04-10", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 74, + "pk": 194, "fields": { - "action_time": "2023-03-08T16:02:06.290Z", + "action_time": "2023-04-01T13:05:11.380Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "buildingontour" + "studentontour" ], - "object_id": "7", - "object_repr": "Krijgslaan 281, Gent 9000 op ronde UGent Campussen in regio Gent, index: 1", + "object_id": "16", + "object_repr": "stanford@test.com (Student (rank: 3)) at Tour Centrum in region Gent on 2023-04-04", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 75, + "pk": 195, "fields": { - "action_time": "2023-03-08T16:08:52.700Z", + "action_time": "2023-04-01T13:05:27.691Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "buildingontour" + "studentontour" ], - "object_id": "8", - "object_repr": "Sint-Pietersnieuwstraat 33, Gent 9000 op ronde UGent Campussen in regio Gent, index: 2", + "object_id": "17", + "object_repr": "sterre@test.com (Student (rank: 3)) at Tour UAntwerpen Campussen in region Antwerpen on 2023-04-16", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 76, + "pk": 196, "fields": { - "action_time": "2023-03-08T16:09:02.005Z", + "action_time": "2023-04-01T13:06:08.144Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "buildingontour" + "remarkatbuilding" ], - "object_id": "9", - "object_repr": "Tweekerkenstraat 2, Gent 9000 op ronde UGent Campussen in regio Gent, index: 3", + "object_id": "1", + "object_repr": "AA for Veldstraat 1, Gent 9000 on tour Tour Centrum in region Gent, index: 1", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 77, + "pk": 197, "fields": { - "action_time": "2023-03-08T16:09:12.402Z", + "action_time": "2023-04-01T13:06:32.588Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "buildingontour" + "remarkatbuilding" ], - "object_id": "10", - "object_repr": "Karel Lodewijk Ledeganckstraat 35, Gent 9000 op ronde UGent Campussen in regio Gent, index: 4", + "object_id": "2", + "object_repr": "VE for Groenenborgerlaan 171, Antwerpen 2000 on tour Tour UAntwerpen Campussen in region Antwerpen, index: 2", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 78, + "pk": 198, "fields": { - "action_time": "2023-03-08T16:12:03.299Z", + "action_time": "2023-04-01T13:07:35.410Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "building" + "remarkatbuilding" ], - "object_id": "11", - "object_repr": "Velstraat 1, Gent 9000", + "object_id": "3", + "object_repr": "OP for Tweekerkenstraat 2, Gent 9000 on tour Tour UGent Campussen in region Gent, index: 3", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 79, + "pk": 199, "fields": { - "action_time": "2023-03-08T16:12:21.051Z", + "action_time": "2023-04-01T13:07:58.217Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "building" + "remarkatbuilding" ], - "object_id": "2", - "object_repr": "Veldstraat 1, Gent 9000", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"House number\", \"Client number\", \"Syndic\"]}}]" + "object_id": "4", + "object_repr": "BI for Veldstraat 1, Gent 9000 on tour Tour Centrum in region Gent, index: 1", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 80, + "pk": 200, "fields": { - "action_time": "2023-03-08T16:16:40.922Z", + "action_time": "2023-04-01T13:08:15.802Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "building" + "remarkatbuilding" ], - "object_id": "11", - "object_repr": "Velstraat 2, Gent 9000", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"House number\"]}}]" + "object_id": "5", + "object_repr": "VE for Krijgslaan 281, Gent 9000 on tour Tour UGent Campussen in region Gent, index: 1", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 81, + "pk": 201, "fields": { - "action_time": "2023-03-08T16:16:45.022Z", + "action_time": "2023-04-01T13:08:28.528Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "building" + "remarkatbuilding" ], - "object_id": "2", - "object_repr": "Veldstraat 2, Gent 9000", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"House number\"]}}]" + "object_id": "6", + "object_repr": "AA for Sint-Pietersnieuwstraat 33, Gent 9000 on tour Tour UGent Campussen in region Gent, index: 2", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 82, + "pk": 202, "fields": { - "action_time": "2023-03-08T16:20:59.890Z", + "action_time": "2023-04-01T13:08:47.842Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "building" + "remarkatbuilding" ], - "object_id": "2", - "object_repr": "Veldstraat 1, Gent 9000", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"House number\"]}}]" + "object_id": "7", + "object_repr": "AA for Veldstraat 2, Gent 9000 on tour Tour Centrum in region Gent, index: 2", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 83, + "pk": 203, "fields": { - "action_time": "2023-03-08T16:21:25.140Z", + "action_time": "2023-04-01T13:08:59.242Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "building" + "remarkatbuilding" ], - "object_id": "12", - "object_repr": "Veldstraat 3, Gent 9000", + "object_id": "8", + "object_repr": "OP for Grote Markt 3, Antwerpen 2000 on tour Tour Grote Markt in region Antwerpen, index: 3", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 84, + "pk": 204, "fields": { - "action_time": "2023-03-08T16:21:52.660Z", + "action_time": "2023-04-01T13:09:21.365Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "building" + "remarkatbuilding" ], - "object_id": "13", - "object_repr": "Veldstraat 4, Gent 9000", + "object_id": "9", + "object_repr": "BI for Sint-Pietersnieuwstraat 33, Gent 9000 on tour Tour UGent Campussen in region Gent, index: 2", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 85, + "pk": 205, "fields": { - "action_time": "2023-03-08T16:22:19.176Z", + "action_time": "2023-04-01T13:09:34.640Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "building" + "remarkatbuilding" ], - "object_id": "11", - "object_repr": "Veldstraat 2, Gent 9000", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Street\"]}}]" + "object_id": "10", + "object_repr": "OP for Grote Markt 2, Antwerpen 2000 on tour Tour Grote Markt in region Antwerpen, index: 2", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 86, + "pk": 206, "fields": { - "action_time": "2023-03-08T16:25:43.960Z", + "action_time": "2023-04-01T13:09:53.946Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "buildingontour" + "remarkatbuilding" ], "object_id": "11", - "object_repr": "Veldstraat 2, Gent 9000 op ronde Centrum in regio Gent, index: 2", + "object_repr": "AA for Veldstraat 4, Gent 9000 on tour Tour Centrum in region Gent, index: 4", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 87, + "pk": 207, "fields": { - "action_time": "2023-03-08T16:26:04.709Z", + "action_time": "2023-04-01T13:10:16.570Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "buildingontour" + "remarkatbuilding" ], - "object_id": "11", - "object_repr": "Veldstraat 2, Gent 9000 op ronde Centrum in regio Gent, index: 2", - "action_flag": 2, - "change_message": "[]" + "object_id": "12", + "object_repr": "BI for Prinsstraat 13, Antwerpen 2000 on tour Tour UAntwerpen Campussen in region Antwerpen, index: 4", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 88, + "pk": 208, "fields": { - "action_time": "2023-03-08T16:26:46.183Z", + "action_time": "2023-04-01T13:10:35.670Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "buildingontour" + "remarkatbuilding" ], - "object_id": "11", - "object_repr": "Veldstraat 2, Gent 9000 op ronde Centrum in regio Gent, index: 2", - "action_flag": 2, - "change_message": "[]" + "object_id": "13", + "object_repr": "OP for Grote Markt 4, Antwerpen 2000 on tour Tour Grote Markt in region Antwerpen, index: 4", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 89, + "pk": 209, "fields": { - "action_time": "2023-03-08T16:26:55.424Z", + "action_time": "2023-04-01T13:10:58.506Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "building" + "remarkatbuilding" ], - "object_id": "2", - "object_repr": "Veldstraat 1, Gent 9000", - "action_flag": 2, - "change_message": "[]" + "object_id": "14", + "object_repr": "VE for Middelheimlaan 1, Antwerpen 2000 on tour Tour UAntwerpen Campussen in region Antwerpen, index: 3", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 90, + "pk": 210, "fields": { - "action_time": "2023-03-08T16:28:45.475Z", + "action_time": "2023-04-19T18:41:05.524Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "buildingontour" + "studentontour" ], - "object_id": "12", - "object_repr": "Veldstraat 3, Gent 9000 op ronde Centrum in regio Gent, index: 3", + "object_id": "18", + "object_repr": "stephan@test.com (Student (rank: 3)) at Tour Centrum in region Gent on 2023-04-19", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 91, + "pk": 211, "fields": { - "action_time": "2023-03-08T16:28:54.410Z", + "action_time": "2023-04-20T09:00:33.526Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "buildingontour" + "buildingcomment" ], - "object_id": "13", - "object_repr": "Veldstraat 4, Gent 9000 op ronde Centrum in regio Gent, index: 4", + "object_id": "2", + "object_repr": "Comment: De deur is moeilijk te openen. (2023-04-20 11:00:08+02:00) for Grote Markt 1, Antwerpen 2000", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 92, + "pk": 212, "fields": { - "action_time": "2023-03-08T16:30:07.611Z", + "action_time": "2023-04-20T09:00:43.199Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "building" + "buildingcomment" ], "object_id": "1", - "object_repr": "Grote Markt 1, Antwerpen 2000", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"House number\", \"Syndic\"]}}]" + "object_repr": "Comment: De containers stonden niet in de kelder, waar ze normaal wel zouden moeten staan. (2023-03-21 22:57:00+00:00) for Veldstraat 1, Gent 9000", + "action_flag": 3, + "change_message": "" } }, { "model": "admin.logentry", - "pk": 93, + "pk": 213, "fields": { - "action_time": "2023-03-08T16:30:34.261Z", + "action_time": "2023-04-20T09:02:03.877Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "building" + "buildingcomment" ], - "object_id": "14", - "object_repr": "Grote Markt 2, Antwerpen 9000", + "object_id": "3", + "object_repr": "Comment: De code van de poort is 1234. (2023-04-20 11:01:59+02:00) for Veldstraat 1, Gent 9000", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 94, + "pk": 214, "fields": { - "action_time": "2023-03-08T16:30:59.258Z", + "action_time": "2023-04-20T09:02:29.752Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "building" + "buildingcomment" ], - "object_id": "15", - "object_repr": "Grote Markt 3, Antwerpen 2000", + "object_id": "4", + "object_repr": "Comment: De containers staan in verschillende ruimtes. (2023-04-20 11:02:26+02:00) for Universiteitsplein 1, Antwerpen 2000", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 95, + "pk": 215, "fields": { - "action_time": "2023-03-08T16:31:08.274Z", + "action_time": "2023-04-20T09:03:01.701Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "building" + "buildingcomment" ], - "object_id": "14", - "object_repr": "Grote Markt 2, Antwerpen 2000", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Postal code\"]}}]" + "object_id": "5", + "object_repr": "Comment: Je moet langs de achterdeur van het gebouw binnen. (2023-04-20 11:02:54+02:00) for Groenenborgerlaan 171, Antwerpen 2000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 96, + "pk": 216, "fields": { - "action_time": "2023-03-08T16:31:44.676Z", + "action_time": "2023-04-20T09:03:50.482Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "building" + "buildingcomment" ], - "object_id": "16", - "object_repr": "Grote Markt 4, Antwerpen 2000", + "object_id": "6", + "object_repr": "Comment: Bel aan bij bewoner op nummer 3, deze laat je binnen. (2023-04-20 11:03:44+02:00) for Veldstraat 2, Gent 9000", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 97, + "pk": 217, "fields": { - "action_time": "2023-03-08T16:31:55.261Z", + "action_time": "2023-04-20T09:04:13.174Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "buildingontour" + "buildingcomment" ], - "object_id": "14", - "object_repr": "Grote Markt 2, Antwerpen 2000 op ronde Grote Markt in regio Antwerpen, index: 2", + "object_id": "7", + "object_repr": "Comment: De code van de poort is 5395 (2023-04-20 11:04:09+02:00) for Veldstraat 3, Gent 9000", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 98, + "pk": 218, "fields": { - "action_time": "2023-03-08T16:32:01.898Z", + "action_time": "2023-04-20T09:04:41.617Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "buildingontour" + "buildingcomment" ], - "object_id": "15", - "object_repr": "Grote Markt 3, Antwerpen 2000 op ronde Grote Markt in regio Antwerpen, index: 3", + "object_id": "8", + "object_repr": "Comment: PMD en REST staan op het gelijkvloers, de rest in de kelder. (2023-04-20 11:04:30+02:00) for Veldstraat 4, Gent 9000", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 99, + "pk": 219, "fields": { - "action_time": "2023-03-08T16:32:09.892Z", + "action_time": "2023-04-20T09:05:08.828Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "buildingontour" + "buildingcomment" ], - "object_id": "16", - "object_repr": "Grote Markt 4, Antwerpen 2000 op ronde Grote Markt in regio Antwerpen, index: 4", + "object_id": "9", + "object_repr": "Comment: Je moet langs de grote poort binnen. (2023-04-20 11:04:49+02:00) for Grote Markt 3, Antwerpen 2000", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 100, + "pk": 220, "fields": { - "action_time": "2023-03-08T16:32:37.550Z", + "action_time": "2023-04-20T09:06:36.454Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "garbagecollection" + "buildingcomment" ], - "object_id": "1", - "object_repr": "GFT op 2023-03-08 voor 1", + "object_id": "10", + "object_repr": "Comment: De containers hangen vast met een slot (code 7361) (2023-04-20 11:05:37+02:00) for Veldstraat 4, Gent 9000", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 101, + "pk": 221, "fields": { - "action_time": "2023-03-08T16:32:49.793Z", + "action_time": "2023-04-20T09:13:43.607Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "garbagecollection" + "building" ], - "object_id": "2", - "object_repr": "GLS op 2023-03-09 voor 12", + "object_id": "20", + "object_repr": "Palingstraat 42, Brugge 8000", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 102, + "pk": 222, "fields": { - "action_time": "2023-03-08T16:33:14.208Z", + "action_time": "2023-04-20T09:15:45.511Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "garbagecollection" + "building" ], - "object_id": "3", - "object_repr": "GRF op 2023-03-11 voor 3", + "object_id": "21", + "object_repr": "Stropersgracht 32, Brugge 8000", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 103, + "pk": 223, "fields": { - "action_time": "2023-03-08T16:33:21.994Z", + "action_time": "2023-04-20T09:16:57.832Z", "user": [ "admin@test.com" ], @@ -3419,17 +6006,17 @@ "base", "garbagecollection" ], - "object_id": "4", - "object_repr": "PMD op 2023-03-15 voor 10", + "object_id": "36", + "object_repr": "GRF on 2023-04-20 at Veldstraat 1, Gent 9000", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 104, + "pk": 224, "fields": { - "action_time": "2023-03-08T16:33:56.022Z", + "action_time": "2023-04-20T09:17:06.104Z", "user": [ "admin@test.com" ], @@ -3437,17 +6024,17 @@ "base", "garbagecollection" ], - "object_id": "5", - "object_repr": "PAP op 2023-03-17 voor 16", + "object_id": "37", + "object_repr": "GLS on 2023-04-20 at Veldstraat 1, Gent 9000", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 105, + "pk": 225, "fields": { - "action_time": "2023-03-08T17:01:50.343Z", + "action_time": "2023-04-20T09:17:24.146Z", "user": [ "admin@test.com" ], @@ -3455,17 +6042,17 @@ "base", "garbagecollection" ], - "object_id": "1", - "object_repr": "GFT op 2023-03-08 voor Grote Markt 1, Antwerpen 2000", - "action_flag": 2, - "change_message": "[]" + "object_id": "38", + "object_repr": "PAP on 2023-04-21 at Veldstraat 1, Gent 9000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 106, + "pk": 226, "fields": { - "action_time": "2023-03-08T17:22:53.560Z", + "action_time": "2023-04-20T09:17:35.731Z", "user": [ "admin@test.com" ], @@ -3473,17 +6060,17 @@ "base", "garbagecollection" ], - "object_id": "6", - "object_repr": "PMD op 2023-03-10 voor Grote Markt 1, Antwerpen 2000", + "object_id": "39", + "object_repr": "PMD on 2023-04-21 at Veldstraat 1, Gent 9000", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 107, + "pk": 227, "fields": { - "action_time": "2023-03-08T17:23:21.034Z", + "action_time": "2023-04-20T09:17:48.776Z", "user": [ "admin@test.com" ], @@ -3491,17 +6078,17 @@ "base", "garbagecollection" ], - "object_id": "7", - "object_repr": "RES op 2023-03-22 voor Grote Markt 3, Antwerpen 2000", + "object_id": "40", + "object_repr": "RES on 2023-04-21 at Veldstraat 2, Gent 9000", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 108, + "pk": 228, "fields": { - "action_time": "2023-03-08T17:23:32.286Z", + "action_time": "2023-04-20T09:18:02.182Z", "user": [ "admin@test.com" ], @@ -3509,17 +6096,17 @@ "base", "garbagecollection" ], - "object_id": "8", - "object_repr": "PAP op 2023-03-19 voor Grote Markt 3, Antwerpen 2000", + "object_id": "41", + "object_repr": "GFT on 2023-04-20 at Veldstraat 2, Gent 9000", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 109, + "pk": 229, "fields": { - "action_time": "2023-03-08T17:23:48.861Z", + "action_time": "2023-04-20T09:18:14.098Z", "user": [ "admin@test.com" ], @@ -3527,17 +6114,17 @@ "base", "garbagecollection" ], - "object_id": "9", - "object_repr": "PAP op 2023-03-08 voor Grote Markt 2, Antwerpen 2000", + "object_id": "42", + "object_repr": "PMD on 2023-04-21 at Veldstraat 2, Gent 9000", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 110, + "pk": 230, "fields": { - "action_time": "2023-03-08T17:24:03.997Z", + "action_time": "2023-04-20T09:18:25.335Z", "user": [ "admin@test.com" ], @@ -3545,17 +6132,17 @@ "base", "garbagecollection" ], - "object_id": "10", - "object_repr": "KER op 2023-03-09 voor Grote Markt 2, Antwerpen 2000", + "object_id": "43", + "object_repr": "GLS on 2023-04-20 at Veldstraat 3, Gent 9000", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 111, + "pk": 231, "fields": { - "action_time": "2023-03-08T17:24:13.531Z", + "action_time": "2023-04-20T09:18:43.221Z", "user": [ "admin@test.com" ], @@ -3563,17 +6150,17 @@ "base", "garbagecollection" ], - "object_id": "8", - "object_repr": "PAP op 2023-03-09 voor Grote Markt 3, Antwerpen 2000", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Date\"]}}]" + "object_id": "44", + "object_repr": "PMD on 2023-04-21 at Veldstraat 3, Gent 9000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 112, + "pk": 232, "fields": { - "action_time": "2023-03-08T17:24:17.210Z", + "action_time": "2023-04-20T09:18:53.567Z", "user": [ "admin@test.com" ], @@ -3581,17 +6168,17 @@ "base", "garbagecollection" ], - "object_id": "7", - "object_repr": "RES op 2023-03-09 voor Grote Markt 3, Antwerpen 2000", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Date\"]}}]" + "object_id": "45", + "object_repr": "RES on 2023-04-21 at Veldstraat 3, Gent 9000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 113, + "pk": 233, "fields": { - "action_time": "2023-03-08T17:24:22.493Z", + "action_time": "2023-04-20T09:19:21.874Z", "user": [ "admin@test.com" ], @@ -3599,17 +6186,17 @@ "base", "garbagecollection" ], - "object_id": "8", - "object_repr": "PAP op 2023-03-08 voor Grote Markt 3, Antwerpen 2000", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Date\"]}}]" + "object_id": "46", + "object_repr": "RES on 2023-04-21 at Veldstraat 4, Gent 9000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 114, + "pk": 234, "fields": { - "action_time": "2023-03-08T17:24:45.436Z", + "action_time": "2023-04-20T09:19:35.166Z", "user": [ "admin@test.com" ], @@ -3617,17 +6204,17 @@ "base", "garbagecollection" ], - "object_id": "2", - "object_repr": "GLS op 2023-03-09 voor Veldstraat 3, Gent 9000", - "action_flag": 3, - "change_message": "" + "object_id": "47", + "object_repr": "PAP on 2023-04-20 at Veldstraat 4, Gent 9000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 115, + "pk": 235, "fields": { - "action_time": "2023-03-08T17:24:46.002Z", + "action_time": "2023-04-20T09:19:52.000Z", "user": [ "admin@test.com" ], @@ -3635,17 +6222,17 @@ "base", "garbagecollection" ], - "object_id": "3", - "object_repr": "GRF op 2023-03-11 voor Universiteitsplein 1, Antwerpen 2000", - "action_flag": 3, - "change_message": "" + "object_id": "48", + "object_repr": "GRF on 2023-04-21 at Veldstraat 4, Gent 9000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 116, + "pk": 236, "fields": { - "action_time": "2023-03-08T17:24:46.573Z", + "action_time": "2023-04-20T09:20:34.267Z", "user": [ "admin@test.com" ], @@ -3653,17 +6240,17 @@ "base", "garbagecollection" ], - "object_id": "4", - "object_repr": "PMD op 2023-03-15 voor Sint-Pietersnieuwstraat 33, Gent 9000", - "action_flag": 3, - "change_message": "" + "object_id": "49", + "object_repr": "GRF on 2023-04-20 at Palingstraat 42, Brugge 8000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 117, + "pk": 237, "fields": { - "action_time": "2023-03-08T17:25:04.860Z", + "action_time": "2023-04-20T09:20:47.247Z", "user": [ "admin@test.com" ], @@ -3671,17 +6258,17 @@ "base", "garbagecollection" ], - "object_id": "5", - "object_repr": "PAP op 2023-03-08 voor Grote Markt 4, Antwerpen 2000", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Date\"]}}]" + "object_id": "50", + "object_repr": "RES on 2023-04-21 at 't Zand 2, Brugge 8310", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 118, + "pk": 238, "fields": { - "action_time": "2023-03-08T17:25:13.574Z", + "action_time": "2023-04-20T09:20:58.392Z", "user": [ "admin@test.com" ], @@ -3689,17 +6276,17 @@ "base", "garbagecollection" ], - "object_id": "11", - "object_repr": "GLS op 2023-03-09 voor Grote Markt 4, Antwerpen 2000", + "object_id": "51", + "object_repr": "GLS on 2023-04-21 at Palingstraat 42, Brugge 8000", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 119, + "pk": 239, "fields": { - "action_time": "2023-03-08T17:25:32.768Z", + "action_time": "2023-04-20T09:21:18.667Z", "user": [ "admin@test.com" ], @@ -3707,17 +6294,17 @@ "base", "garbagecollection" ], - "object_id": "12", - "object_repr": "GFT op 2023-03-08 voor Veldstraat 1, Gent 9000", + "object_id": "52", + "object_repr": "GFT on 2023-04-21 at Stropersgracht 32, Brugge 8000", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 120, + "pk": 240, "fields": { - "action_time": "2023-03-08T17:25:39.351Z", + "action_time": "2023-04-20T09:21:31.594Z", "user": [ "admin@test.com" ], @@ -3725,17 +6312,17 @@ "base", "garbagecollection" ], - "object_id": "13", - "object_repr": "KER op 2023-03-09 voor Veldstraat 1, Gent 9000", + "object_id": "53", + "object_repr": "GLS on 2023-04-20 at Stropersgracht 32, Brugge 8000", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 121, + "pk": 241, "fields": { - "action_time": "2023-03-08T17:25:50.695Z", + "action_time": "2023-04-20T09:21:49.005Z", "user": [ "admin@test.com" ], @@ -3743,764 +6330,782 @@ "base", "garbagecollection" ], - "object_id": "14", - "object_repr": "PMD op 2023-03-08 voor Veldstraat 2, Gent 9000", + "object_id": "54", + "object_repr": "GFT on 2023-04-21 at Palingstraat 42, Brugge 8000", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 122, + "pk": 242, "fields": { - "action_time": "2023-03-08T17:26:00.684Z", + "action_time": "2023-04-20T09:22:22.266Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "garbagecollection" + "building" ], - "object_id": "15", - "object_repr": "GLS op 2023-03-09 voor Veldstraat 2, Gent 9000", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" + "object_id": "17", + "object_repr": "markt 5, Brugge 8000", + "action_flag": 3, + "change_message": "" } }, { "model": "admin.logentry", - "pk": 123, + "pk": 243, "fields": { - "action_time": "2023-03-08T17:26:10.633Z", + "action_time": "2023-04-20T09:27:53.135Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "garbagecollection" + "manual" + ], + "object_id": "7", + "object_repr": "Manual: manual_KGPwTq7.pdf (version 0) for Grote Markt 1, Antwerpen 2000", + "action_flag": 3, + "change_message": "" + } +}, +{ + "model": "admin.logentry", + "pk": 244, + "fields": { + "action_time": "2023-04-20T09:27:53.148Z", + "user": [ + "admin@test.com" + ], + "content_type": [ + "base", + "manual" ], - "object_id": "16", - "object_repr": "PAP op 2023-03-08 voor Veldstraat 3, Gent 9000", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" + "object_id": "6", + "object_repr": "Manual: manual_Lis1No8.pdf (version 0) for Krijgslaan 281, Gent 9000", + "action_flag": 3, + "change_message": "" } }, { "model": "admin.logentry", - "pk": 124, + "pk": 245, "fields": { - "action_time": "2023-03-08T17:26:17.012Z", + "action_time": "2023-04-20T09:27:53.152Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "garbagecollection" + "manual" ], - "object_id": "17", - "object_repr": "PMD op 2023-03-09 voor Veldstraat 3, Gent 9000", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" + "object_id": "5", + "object_repr": "Manual: manual_1rTYOnL.pdf (version 3) for Veldstraat 1, Gent 9000", + "action_flag": 3, + "change_message": "" } }, { "model": "admin.logentry", - "pk": 125, + "pk": 246, "fields": { - "action_time": "2023-03-08T17:26:27.293Z", + "action_time": "2023-04-20T09:27:53.154Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "garbagecollection" + "manual" ], - "object_id": "18", - "object_repr": "GRF op 2023-03-08 voor Veldstraat 4, Gent 9000", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" + "object_id": "4", + "object_repr": "Manual: manual_ZrabAvF.pdf (version 2) for Veldstraat 1, Gent 9000", + "action_flag": 3, + "change_message": "" } }, { "model": "admin.logentry", - "pk": 126, + "pk": 247, "fields": { - "action_time": "2023-03-08T17:26:35.922Z", + "action_time": "2023-04-20T09:27:53.157Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "garbagecollection" + "manual" ], - "object_id": "19", - "object_repr": "KER op 2023-03-09 voor Veldstraat 4, Gent 9000", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" + "object_id": "3", + "object_repr": "Manual: manual_Z9rE7NK.pdf (version 1) for Veldstraat 1, Gent 9000", + "action_flag": 3, + "change_message": "" } }, { "model": "admin.logentry", - "pk": 127, + "pk": 248, "fields": { - "action_time": "2023-03-08T17:26:52.792Z", + "action_time": "2023-04-20T09:27:53.159Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "garbagecollection" + "manual" ], - "object_id": "20", - "object_repr": "GFT op 2023-03-08 voor Krijgslaan 281, Gent 9000", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" + "object_id": "2", + "object_repr": "Manual: manualXYZ.pdf (version 0) for Sint-Pietersnieuwstraat 33, Gent 9000", + "action_flag": 3, + "change_message": "" } }, { "model": "admin.logentry", - "pk": 128, + "pk": 249, "fields": { - "action_time": "2023-03-08T17:26:59.304Z", + "action_time": "2023-04-20T09:27:53.161Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "garbagecollection" + "manual" ], - "object_id": "21", - "object_repr": "GRF op 2023-03-09 voor Krijgslaan 281, Gent 9000", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" + "object_id": "1", + "object_repr": "Manual: manual.pdf (version 0) for Veldstraat 1, Gent 9000", + "action_flag": 3, + "change_message": "" } }, { "model": "admin.logentry", - "pk": 129, + "pk": 250, "fields": { - "action_time": "2023-03-08T17:27:07.374Z", + "action_time": "2023-04-20T09:28:36.399Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "garbagecollection" + "studentontour" ], - "object_id": "22", - "object_repr": "GRF op 2023-03-08 voor Sint-Pietersnieuwstraat 33, Gent 9000", + "object_id": "19", + "object_repr": "stefanie@test.com (Student (rank: 3)) at Tour UAntwerpen Campussen in region Antwerpen on 2023-04-20", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 130, + "pk": 251, "fields": { - "action_time": "2023-03-08T17:27:15.700Z", + "action_time": "2023-04-20T09:28:55.003Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "garbagecollection" + "tour" ], - "object_id": "23", - "object_repr": "PAP op 2023-03-09 voor Sint-Pietersnieuwstraat 33, Gent 9000", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" + "object_id": "7", + "object_repr": "Tour testSequence in region Gent", + "action_flag": 3, + "change_message": "" } }, { "model": "admin.logentry", - "pk": 131, + "pk": 252, "fields": { - "action_time": "2023-03-08T17:27:21.716Z", + "action_time": "2023-04-20T09:28:55.017Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "garbagecollection" + "tour" ], - "object_id": "24", - "object_repr": "PMD op 2023-03-08 voor Tweekerkenstraat 2, Gent 9000", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" + "object_id": "6", + "object_repr": "Tour hoihoi in region Gent", + "action_flag": 3, + "change_message": "" } }, { "model": "admin.logentry", - "pk": 132, + "pk": 253, "fields": { - "action_time": "2023-03-08T17:27:27.642Z", + "action_time": "2023-04-20T09:28:55.019Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "garbagecollection" + "tour" ], - "object_id": "25", - "object_repr": "RES op 2023-03-09 voor Tweekerkenstraat 2, Gent 9000", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" + "object_id": "5", + "object_repr": "Tour test in region Gent", + "action_flag": 3, + "change_message": "" } }, { "model": "admin.logentry", - "pk": 133, + "pk": 254, "fields": { - "action_time": "2023-03-08T17:27:34.788Z", + "action_time": "2023-04-20T09:29:30.486Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "garbagecollection" + "studentontour" ], - "object_id": "26", - "object_repr": "PAP op 2023-03-08 voor Karel Lodewijk Ledeganckstraat 35, Gent 9000", + "object_id": "20", + "object_repr": "stella@test.com (Student (rank: 3)) at Tour Grote Markt in region Antwerpen on 2023-04-20", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 134, + "pk": 255, "fields": { - "action_time": "2023-03-08T17:27:39.461Z", + "action_time": "2023-04-20T09:29:40.809Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "garbagecollection" + "studentontour" ], - "object_id": "27", - "object_repr": "GLS op 2023-03-08 voor Karel Lodewijk Ledeganckstraat 35, Gent 9000", + "object_id": "21", + "object_repr": "stanford@test.com (Student (rank: 3)) at Tour Centrum in region Gent on 2023-04-20", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 135, + "pk": 256, "fields": { - "action_time": "2023-03-08T17:27:45.463Z", + "action_time": "2023-04-20T09:29:51.526Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "garbagecollection" + "studentontour" ], - "object_id": "28", - "object_repr": "GLS op 2023-03-08 voor Prinsstraat 13, Antwerpen 2000", + "object_id": "22", + "object_repr": "steven@test.com (Student (rank: 3)) at Tour UGent Campussen in region Gent on 2023-04-20", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 136, + "pk": 257, "fields": { - "action_time": "2023-03-08T17:27:53.937Z", + "action_time": "2023-04-20T09:30:04.129Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "garbagecollection" + "studentontour" ], - "object_id": "29", - "object_repr": "KER op 2023-03-09 voor Prinsstraat 13, Antwerpen 2000", + "object_id": "23", + "object_repr": "stephan@test.com (Student (rank: 3)) at Tour Centrum in region Gent on 2023-04-21", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 137, + "pk": 258, "fields": { - "action_time": "2023-03-08T17:28:00.222Z", + "action_time": "2023-04-20T09:30:21.302Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "garbagecollection" + "studentontour" ], - "object_id": "30", - "object_repr": "PMD op 2023-03-08 voor Middelheimlaan 1, Antwerpen 2000", + "object_id": "24", + "object_repr": "sterre@test.com (Student (rank: 3)) at Tour Grote Markt in region Antwerpen on 2023-04-21", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 138, + "pk": 259, "fields": { - "action_time": "2023-03-08T17:30:56.771Z", + "action_time": "2023-04-20T09:30:59.908Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "garbagecollection" + "studentontour" ], - "object_id": "31", - "object_repr": "GRF op 2023-03-09 voor Middelheimlaan 1, Antwerpen 2000", + "object_id": "25", + "object_repr": "stacey@test.com (Student (rank: 3)) at Tour UAntwerpen Campussen in region Antwerpen on 2023-04-21", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 139, + "pk": 260, "fields": { - "action_time": "2023-03-08T17:31:05.688Z", + "action_time": "2023-04-20T09:31:16.385Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "garbagecollection" + "studentontour" ], - "object_id": "32", - "object_repr": "GLS op 2023-03-08 voor Groenenborgerlaan 171, Antwerpen 2000", + "object_id": "26", + "object_repr": "stef@test.com (Student (rank: 3)) at Tour UGent Campussen in region Gent on 2023-04-21", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 140, + "pk": 283, "fields": { - "action_time": "2023-03-08T17:31:11.664Z", + "action_time": "2023-04-20T10:32:27.463Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "garbagecollection" + "remarkatbuilding" ], - "object_id": "33", - "object_repr": "RES op 2023-03-09 voor Groenenborgerlaan 171, Antwerpen 2000", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" + "object_id": "14", + "object_repr": "VE for Veldstraat 1, Gent 9000", + "action_flag": 3, + "change_message": "" } }, { "model": "admin.logentry", - "pk": 141, + "pk": 284, "fields": { - "action_time": "2023-03-08T17:31:20.537Z", + "action_time": "2023-04-20T10:32:27.508Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "garbagecollection" + "remarkatbuilding" ], - "object_id": "34", - "object_repr": "PAP op 2023-03-08 voor Universiteitsplein 1, Antwerpen 2000", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" + "object_id": "13", + "object_repr": "OP for Universiteitsplein 1, Antwerpen 2000", + "action_flag": 3, + "change_message": "" } }, { "model": "admin.logentry", - "pk": 142, + "pk": 285, "fields": { - "action_time": "2023-03-08T17:31:26.439Z", + "action_time": "2023-04-20T10:32:27.511Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "garbagecollection" + "remarkatbuilding" ], - "object_id": "35", - "object_repr": "GFT op 2023-03-09 voor Universiteitsplein 1, Antwerpen 2000", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" + "object_id": "12", + "object_repr": "BI for Krijgslaan 281, Gent 9000", + "action_flag": 3, + "change_message": "" } }, { "model": "admin.logentry", - "pk": 143, + "pk": 286, "fields": { - "action_time": "2023-03-09T12:33:52.307Z", + "action_time": "2023-04-20T10:32:27.515Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "studentatbuildingontour" + "remarkatbuilding" ], - "object_id": "2", - "object_repr": "None bij Grote Markt 1, Antwerpen 2000 op ronde Grote Markt in regio Antwerpen, index: 1 op 2023-03-08", + "object_id": "11", + "object_repr": "AA for Prinsstraat 13, Antwerpen 2000", "action_flag": 3, "change_message": "" } }, { "model": "admin.logentry", - "pk": 144, + "pk": 287, "fields": { - "action_time": "2023-03-09T12:33:52.342Z", + "action_time": "2023-04-20T10:32:27.518Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "studentatbuildingontour" + "remarkatbuilding" ], - "object_id": "1", - "object_repr": "None bij Veldstraat 1, Gent 9000 op ronde Centrum in regio Gent, index: 1 op 2023-03-08", + "object_id": "10", + "object_repr": "OP for Middelheimlaan 1, Antwerpen 2000", "action_flag": 3, "change_message": "" } }, { "model": "admin.logentry", - "pk": 145, + "pk": 288, "fields": { - "action_time": "2023-03-09T12:34:17.008Z", + "action_time": "2023-04-20T10:32:27.521Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "studentatbuildingontour" + "remarkatbuilding" ], - "object_id": "3", - "object_repr": "stella@test.com (ST) bij Grote Markt 1, Antwerpen 2000 op ronde Grote Markt in regio Antwerpen, index: 1 op 2023-03-09", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" + "object_id": "9", + "object_repr": "BI for Universiteitsplein 1, Antwerpen 2000", + "action_flag": 3, + "change_message": "" } }, { "model": "admin.logentry", - "pk": 146, + "pk": 289, "fields": { - "action_time": "2023-03-09T12:34:49.451Z", + "action_time": "2023-04-20T10:32:27.524Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "studentatbuildingontour" + "remarkatbuilding" ], - "object_id": "4", - "object_repr": "stefanie@test.com (ST) bij Grote Markt 3, Antwerpen 2000 op ronde Grote Markt in regio Antwerpen, index: 3 op 2023-03-09", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" + "object_id": "8", + "object_repr": "OP for Veldstraat 1, Gent 9000", + "action_flag": 3, + "change_message": "" } }, { "model": "admin.logentry", - "pk": 147, + "pk": 290, "fields": { - "action_time": "2023-03-09T12:35:13.899Z", + "action_time": "2023-04-20T10:32:27.527Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "studentatbuildingontour" + "remarkatbuilding" ], - "object_id": "4", - "object_repr": "stella@test.com (ST) bij Grote Markt 3, Antwerpen 2000 op ronde Grote Markt in regio Antwerpen, index: 3 op 2023-03-09", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Student\"]}}]" + "object_id": "7", + "object_repr": "AA for Krijgslaan 281, Gent 9000", + "action_flag": 3, + "change_message": "" } }, { "model": "admin.logentry", - "pk": 148, + "pk": 291, "fields": { - "action_time": "2023-03-09T12:35:35.405Z", + "action_time": "2023-04-20T10:32:27.530Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "studentatbuildingontour" + "remarkatbuilding" ], - "object_id": "5", - "object_repr": "stella@test.com (ST) bij Grote Markt 2, Antwerpen 2000 op ronde Grote Markt in regio Antwerpen, index: 2 op 2023-03-09", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" + "object_id": "6", + "object_repr": "AA for Karel Lodewijk Ledeganckstraat 35, Gent 9000", + "action_flag": 3, + "change_message": "" } }, { "model": "admin.logentry", - "pk": 149, + "pk": 292, "fields": { - "action_time": "2023-03-09T12:35:44.530Z", + "action_time": "2023-04-20T10:32:27.533Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "studentatbuildingontour" + "remarkatbuilding" ], - "object_id": "6", - "object_repr": "stella@test.com (ST) bij Grote Markt 4, Antwerpen 2000 op ronde Grote Markt in regio Antwerpen, index: 4 op 2023-03-09", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" + "object_id": "5", + "object_repr": "VE for Krijgslaan 281, Gent 9000", + "action_flag": 3, + "change_message": "" } }, { "model": "admin.logentry", - "pk": 150, + "pk": 293, "fields": { - "action_time": "2023-03-09T12:36:00.017Z", + "action_time": "2023-04-20T10:32:27.536Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "studentatbuildingontour" + "remarkatbuilding" ], - "object_id": "7", - "object_repr": "stijn@test.com (ST) bij Veldstraat 1, Gent 9000 op ronde Centrum in regio Gent, index: 1 op 2023-03-09", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" + "object_id": "4", + "object_repr": "BI for Groenenborgerlaan 171, Antwerpen 2000", + "action_flag": 3, + "change_message": "" } }, { "model": "admin.logentry", - "pk": 151, + "pk": 294, "fields": { - "action_time": "2023-03-09T12:36:07.204Z", + "action_time": "2023-04-20T10:32:27.539Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "studentatbuildingontour" + "remarkatbuilding" ], - "object_id": "8", - "object_repr": "stijn@test.com (ST) bij Veldstraat 2, Gent 9000 op ronde Centrum in regio Gent, index: 2 op 2023-03-09", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" + "object_id": "3", + "object_repr": "OP for Universiteitsplein 1, Antwerpen 2000", + "action_flag": 3, + "change_message": "" } }, { "model": "admin.logentry", - "pk": 152, + "pk": 295, "fields": { - "action_time": "2023-03-09T12:36:25.455Z", + "action_time": "2023-04-20T10:32:27.542Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "studentatbuildingontour" + "remarkatbuilding" ], - "object_id": "9", - "object_repr": "stijn@test.com (ST) bij Veldstraat 3, Gent 9000 op ronde Centrum in regio Gent, index: 3 op 2023-03-09", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" + "object_id": "2", + "object_repr": "VE for Universiteitsplein 1, Antwerpen 2000", + "action_flag": 3, + "change_message": "" } }, { "model": "admin.logentry", - "pk": 153, + "pk": 296, "fields": { - "action_time": "2023-03-09T12:36:33.904Z", + "action_time": "2023-04-20T10:32:27.545Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "studentatbuildingontour" + "remarkatbuilding" ], - "object_id": "10", - "object_repr": "stijn@test.com (ST) bij Veldstraat 4, Gent 9000 op ronde Centrum in regio Gent, index: 4 op 2023-03-09", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" + "object_id": "1", + "object_repr": "AA for Veldstraat 1, Gent 9000", + "action_flag": 3, + "change_message": "" } }, { "model": "admin.logentry", - "pk": 154, + "pk": 297, "fields": { - "action_time": "2023-03-09T12:36:55.073Z", + "action_time": "2023-04-20T10:33:50.458Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "garbagecollection" + "remarkatbuilding" ], - "object_id": "6", - "object_repr": "PMD op 2023-03-09 voor Grote Markt 1, Antwerpen 2000", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Date\"]}}]" + "object_id": "15", + "object_repr": "AA for Veldstraat 1, Gent 9000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 155, + "pk": 298, "fields": { - "action_time": "2023-03-09T12:37:38.045Z", + "action_time": "2023-04-20T10:34:10.541Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "studentatbuildingontour" + "remarkatbuilding" ], - "object_id": "11", - "object_repr": "stacey@test.com (ST) bij Universiteitsplein 1, Antwerpen 2000 op ronde UAntwerpen Campussen in regio Antwerpen, index: 1 op 2023-03-08", + "object_id": "16", + "object_repr": "BI for Veldstraat 3, Gent 9000", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 156, + "pk": 299, "fields": { - "action_time": "2023-03-09T12:37:48.452Z", + "action_time": "2023-04-20T10:35:10.608Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "studentatbuildingontour" + "remarkatbuilding" ], - "object_id": "12", - "object_repr": "stacey@test.com (ST) bij Groenenborgerlaan 171, Antwerpen 2000 op ronde UAntwerpen Campussen in regio Antwerpen, index: 2 op 2023-03-08", + "object_id": "17", + "object_repr": "OP for Grote Markt 3, Antwerpen 2000", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 157, + "pk": 300, "fields": { - "action_time": "2023-03-09T12:38:01.522Z", + "action_time": "2023-04-20T11:23:36.121Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "studentatbuildingontour" + "studentontour" ], - "object_id": "13", - "object_repr": "stacey@test.com (ST) bij Groenenborgerlaan 171, Antwerpen 2000 op ronde UAntwerpen Campussen in regio Antwerpen, index: 2 op 2023-03-08", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" + "object_id": "23", + "object_repr": "stephan@test.com (Student (rank: 3)) at Tour Centrum in region Gent on 2023-04-21", + "action_flag": 3, + "change_message": "" } }, { "model": "admin.logentry", - "pk": 158, + "pk": 301, "fields": { - "action_time": "2023-03-09T12:41:07.229Z", + "action_time": "2023-04-20T11:24:52.818Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "studentatbuildingontour" + "remarkatbuilding" ], - "object_id": "13", - "object_repr": "stacey@test.com (ST) bij Middelheimlaan 1, Antwerpen 2000 op ronde UAntwerpen Campussen in regio Antwerpen, index: 3 op 2023-03-08", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Building on tour\"]}}]" + "object_id": "18", + "object_repr": "OP for Groenenborgerlaan 171, Antwerpen 2000", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 159, + "pk": 302, "fields": { - "action_time": "2023-03-09T12:41:50.298Z", + "action_time": "2023-04-20T11:25:25.317Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "studentatbuildingontour" + "remarkatbuilding" ], - "object_id": "14", - "object_repr": "stacey@test.com (ST) bij Prinsstraat 13, Antwerpen 2000 op ronde UAntwerpen Campussen in regio Antwerpen, index: 4 op 2023-03-08", + "object_id": "19", + "object_repr": "VE for Veldstraat 2, Gent 9000", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 160, + "pk": 303, "fields": { - "action_time": "2023-03-09T12:42:53.582Z", + "action_time": "2023-04-20T12:51:17.017Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "studentatbuildingontour" + "studentontour" ], - "object_id": "15", - "object_repr": "stef@test.com (ST) bij Krijgslaan 281, Gent 9000 op ronde UGent Campussen in regio Gent, index: 1 op 2023-03-08", + "object_id": "52", + "object_repr": "stanford@test.com (Student (rank: 3)) at Tour Centrum in region Gent on 2023-04-18", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 161, + "pk": 304, "fields": { - "action_time": "2023-03-09T12:43:03.669Z", + "action_time": "2023-04-20T12:51:30.328Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "studentatbuildingontour" + "studentontour" ], - "object_id": "16", - "object_repr": "stef@test.com (ST) bij Sint-Pietersnieuwstraat 33, Gent 9000 op ronde UGent Campussen in regio Gent, index: 2 op 2023-03-08", + "object_id": "53", + "object_repr": "stella@test.com (Student (rank: 3)) at Tour Grote Markt in region Antwerpen on 2023-04-17", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 162, + "pk": 305, "fields": { - "action_time": "2023-03-09T12:43:21.870Z", + "action_time": "2023-04-20T12:51:49.184Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "studentatbuildingontour" + "studentontour" ], - "object_id": "17", - "object_repr": "stef@test.com (ST) bij Tweekerkenstraat 2, Gent 9000 op ronde UGent Campussen in regio Gent, index: 3 op 2023-03-08", + "object_id": "54", + "object_repr": "sten@test.com (Student (rank: 3)) at Tour UGent Campussen in region Gent on 2023-04-19", "action_flag": 1, "change_message": "[{\"added\": {}}]" } }, { "model": "admin.logentry", - "pk": 163, + "pk": 306, "fields": { - "action_time": "2023-03-09T12:43:31.322Z", + "action_time": "2023-04-20T12:52:00.322Z", "user": [ "admin@test.com" ], "content_type": [ "base", - "studentatbuildingontour" + "studentontour" ], - "object_id": "18", - "object_repr": "stef@test.com (ST) bij Karel Lodewijk Ledeganckstraat 35, Gent 9000 op ronde UGent Campussen in regio Gent, index: 4 op 2023-03-08", + "object_id": "55", + "object_repr": "stacey@test.com (Student (rank: 3)) at Tour Grote Markt in region Antwerpen on 2023-04-18", "action_flag": 1, "change_message": "[{\"added\": {}}]" } diff --git a/backend/authentication/migrations/__init__.py b/backend/email_template/__init__.py similarity index 100% rename from backend/authentication/migrations/__init__.py rename to backend/email_template/__init__.py diff --git a/backend/email_template/tests.py b/backend/email_template/tests.py new file mode 100644 index 00000000..bfa78f77 --- /dev/null +++ b/backend/email_template/tests.py @@ -0,0 +1,85 @@ +from base.models import EmailTemplate +from base.serializers import EmailTemplateSerializer +from util.data_generators import insert_dummy_email_template +from util.test_tools import BaseTest, BaseAuthTest + + +class EmailTemplateTests(BaseTest): + def __init__(self, methodName="runTest"): + super().__init__(methodName) + + def test_empty_email_template_list(self): + self.empty_list("email-template/") + + def test_insert_email_template(self): + self.data1 = {"name": "testTemplate", "template": "

{{name}

"} + self.insert("email-template/") + + def test_insert_empty(self): + self.insert_empty("email-template/") + + def test_insert_dupe_email_template(self): + self.data1 = {"name": "testTemplate", "template": "

{{name}

"} + self.insert_dupe("email-template/") + + def test_get_email_template(self): + et_id = insert_dummy_email_template() + data = EmailTemplateSerializer(EmailTemplate.objects.get(id=et_id)).data + self.get(f"email-template/{et_id}", data) + + def test_get_non_existing(self): + self.get_non_existent("email-template/") + + def test_patch_email_template(self): + et_id = insert_dummy_email_template() + self.data1 = {"name": "testTemplate2", "template": "

{{name}

"} + self.patch(f"email-template/{et_id}") + + def test_patch_invalid_email_template(self): + self.data1 = {"name": "testTemplate", "template": "

{{name}

"} + self.patch_invalid("email-template/") + + def test_patch_error_email_template(self): + self.data1 = {"name": "testTemplate", "template": "

{{name}

"} + self.data2 = {"name": "testTemplate2", "template": "

{{name}

"} + self.patch_error("email-template/") + + def test_remove_email_template(self): + et_id = insert_dummy_email_template() + self.remove(f"email-template/{et_id}") + + def test_remove_nonexistent_email_template(self): + self.remove_invalid("email-template/") + + +class EmailTemplateAuthorizationTests(BaseAuthTest): + def __init__(self, methodName="runTest"): + super().__init__(methodName) + + def test_email_template_list(self): + codes = {"Default": 403, "Admin": 200, "Superstudent": 200, "Student": 403, "Syndic": 403} + self.list_view("building-comment/", codes) + + def test_insert_email_template(self): + codes = {"Default": 403, "Admin": 201, "Superstudent": 201, "Student": 403, "Syndic": 403} + self.data1 = {"name": "testTemplate", "template": "

{{name}

"} + self.insert_view("email-template/", codes) + + def test_get_email_template(self): + codes = {"Default": 403, "Admin": 200, "Superstudent": 200, "Student": 403, "Syndic": 403} + et_id = insert_dummy_email_template() + self.get_view(f"email-template/{et_id}", codes) + + def test_patch_email_template(self): + codes = {"Default": 403, "Admin": 200, "Superstudent": 200, "Student": 403, "Syndic": 403} + self.data1 = {"name": "testTemplate2", "template": "

{{name}

"} + + et_id = insert_dummy_email_template() + self.patch_view(f"email-template/{et_id}", codes) + + def test_remove_email_template(self): + def create(): + return insert_dummy_email_template() + + codes = {"Default": 403, "Admin": 204, "Superstudent": 204, "Student": 403, "Syndic": 403} + self.remove_view("email-template/", codes, create=create) diff --git a/backend/email_template/urls.py b/backend/email_template/urls.py new file mode 100644 index 00000000..e0bec128 --- /dev/null +++ b/backend/email_template/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .views import DefaultEmailTemplate, EmailTemplateIndividualView, EmailTemplateAllView + +urlpatterns = [ + path("all/", EmailTemplateAllView.as_view()), + path("/", EmailTemplateIndividualView.as_view()), + path("", DefaultEmailTemplate.as_view()), +] diff --git a/backend/email_template/views.py b/backend/email_template/views.py new file mode 100644 index 00000000..1d529cc0 --- /dev/null +++ b/backend/email_template/views.py @@ -0,0 +1,93 @@ +from drf_spectacular.utils import extend_schema +from rest_framework.permissions import IsAuthenticated +from rest_framework.views import APIView + +from base.models import EmailTemplate +from base.permissions import IsAdmin, IsSuperStudent +from base.serializers import EmailTemplateSerializer +from util.request_response_util import * + + +class DefaultEmailTemplate(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent] + serializer_class = EmailTemplateSerializer + + @extend_schema(responses=post_docs(EmailTemplateSerializer)) + def post(self, request): + """ + Create a new EmailTemplate + """ + data = request_to_dict(request.data) + + email_template_instance = EmailTemplate() + + set_keys_of_instance(email_template_instance, data) + + if r := try_full_clean_and_save(email_template_instance): + print(r) + return r + + return post_success(EmailTemplateSerializer(email_template_instance)) + + +class EmailTemplateIndividualView(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent] + serializer_class = EmailTemplateSerializer + + @extend_schema(responses=get_docs(EmailTemplateSerializer)) + def get(self, request, email_template_id): + """ + Get info about an EmailTemplate with given id + """ + email_template_instance = EmailTemplate.objects.filter(id=email_template_id) + + if not email_template_instance: + return not_found("EmailTemplate") + + return get_success(EmailTemplateSerializer(email_template_instance[0])) + + @extend_schema(responses=delete_docs()) + def delete(self, request, email_template_id): + """ + Delete EmailTemplate with given id + """ + email_template_instance = EmailTemplate.objects.filter(id=email_template_id) + + if not email_template_instance: + return not_found("EmailTemplate") + + email_template_instance[0].delete() + return delete_success() + + @extend_schema(responses=patch_docs(EmailTemplateSerializer)) + def patch(self, request, email_template_id): + """ + Edit EmailTemplate with given id + """ + email_template_instance = EmailTemplate.objects.filter(id=email_template_id) + + if not email_template_instance: + return not_found("EmailTemplate") + + email_template_instance = email_template_instance[0] + data = request_to_dict(request.data) + + set_keys_of_instance(email_template_instance, data) + + if r := try_full_clean_and_save(email_template_instance): + return r + + return patch_success(EmailTemplateSerializer(email_template_instance)) + + +class EmailTemplateAllView(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent] + serializer_class = EmailTemplateSerializer + + def get(self, request): + """ + Get all EmailTemplates in the database + """ + email_template_instances = EmailTemplate.objects.all() + serializer = EmailTemplateSerializer(email_template_instances, many=True) + return get_success(serializer) diff --git a/backend/garbage_collection/apps.py b/backend/garbage_collection/apps.py deleted file mode 100644 index 03373772..00000000 --- a/backend/garbage_collection/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class GarbageCollectionConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'garbage_collection' diff --git a/backend/garbage_collection/serializers.py b/backend/garbage_collection/serializers.py new file mode 100644 index 00000000..2cb3d1f4 --- /dev/null +++ b/backend/garbage_collection/serializers.py @@ -0,0 +1,24 @@ +from rest_framework import serializers + + +class GarbageCollectionDuplicateRequestSerializer(serializers.Serializer): + start_date_period = serializers.DateField( + required=True, + help_text="The start date of the period to copy. If this date would fall within a week, it would be " + "translated to the monday of that week.", + ) + end_date_period = serializers.DateField( + required=True, + help_text="The end date of the period to copy. If this date would fall within a week, it would be " + "translated to the sunday of that week.", + ) + start_date_copy = serializers.DateField( + required=True, + help_text="The start date to begin the copy. If this date would fall within a week, it would be " + "translated to the monday of that week.", + ) + building_ids = serializers.ListField( + child=serializers.IntegerField(), + required=False, + help_text="A list of buildings for which you want to copy the garbage collection", + ) diff --git a/backend/garbage_collection/tests.py b/backend/garbage_collection/tests.py index 7ce503c2..23a2ada2 100644 --- a/backend/garbage_collection/tests.py +++ b/backend/garbage_collection/tests.py @@ -1,3 +1,88 @@ -from django.test import TestCase +from base.models import GarbageCollection +from base.serializers import GarbageCollectionSerializer +from util.data_generators import insert_dummy_building, insert_dummy_garbage +from util.test_tools import BaseTest, BaseAuthTest -# Create your tests here. + +class GarbageCollectionTests(BaseTest): + def __init__(self, methodName="runTest"): + super().__init__(methodName) + + def test_empty_garbage_list(self): + self.empty_list("garbage-collection/") + + def test_insert_garbage(self): + b_id = insert_dummy_building() + self.data1 = {"building": b_id, "date": "2023-03-08", "garbage_type": "RES"} + self.insert("garbage-collection/") + + def test_insert_empty(self): + self.insert_empty("garbage-collection/") + + def test_insert_dupe_garbage(self): + b_id = insert_dummy_building() + self.data1 = {"building": b_id, "date": "2023-03-08", "garbage_type": "RES"} + self.insert_dupe("garbage-collection/") + + def test_get_garbage(self): + g_id = insert_dummy_garbage() + data = GarbageCollectionSerializer(GarbageCollection.objects.get(id=g_id)).data + self.get(f"garbage-collection/{g_id}", data) + + def test_get_non_existing(self): + self.get_non_existent("garbage-collection/") + + def test_patch_garbage(self): + b_id = insert_dummy_building() + self.data1 = {"building": b_id, "date": "2023-03-08", "garbage_type": "PMD"} + g_id = insert_dummy_garbage() + self.patch(f"garbage-collection/{g_id}") + + def test_patch_invalid_garbage(self): + b_id = insert_dummy_building() + self.data1 = {"building": b_id, "date": "2023-03-08", "garbage_type": "RES"} + self.patch_invalid("garbage-collection/") + + def test_patch_error_garbage(self): + b_id = insert_dummy_building() + self.data1 = {"building": b_id, "date": "2023-03-08", "garbage_type": "RES"} + self.data2 = {"building": b_id, "date": "2023-03-08", "garbage_type": "PMD"} + self.patch_error("garbage-collection/") + + def test_remove_garbage(self): + g_id = insert_dummy_garbage() + self.remove(f"garbage-collection/{g_id}") + + def test_remove_nonexistent_garbage(self): + self.remove_invalid("garbage-collection/") + + +class GarbageCollectionAuthorizationTests(BaseAuthTest): + def test_garbage_collection_list(self): + codes = {"Default": 403, "Admin": 200, "Superstudent": 200, "Student": 403, "Syndic": 403} + self.list_view("garbage-collection/", codes) + + def test_insert_garbage_collection(self): + codes = {"Default": 403, "Admin": 201, "Superstudent": 201, "Student": 403, "Syndic": 403} + b_id = insert_dummy_building() + self.data1 = {"building": b_id, "date": "2023-03-08", "garbage_type": "RES"} + self.insert_view("garbage-collection/", codes) + + def test_get_garbage_collection(self): + codes = {"Default": 403, "Admin": 200, "Superstudent": 200, "Student": 200, "Syndic": 403} + g_id = insert_dummy_garbage() + self.get_view(f"garbage-collection/{g_id}", codes) + + def test_patch_garbage_collection(self): + codes = {"Default": 403, "Admin": 200, "Superstudent": 200, "Student": 403, "Syndic": 403} + g_id = insert_dummy_garbage() + b_id = insert_dummy_building() + self.data1 = {"building": b_id, "date": "2023-03-08", "garbage_type": "PMD"} + self.patch_view(f"garbage-collection/{g_id}", codes) + + def test_remove_garbage_collection(self): + def create(): + return insert_dummy_garbage() + + codes = {"Default": 403, "Admin": 204, "Superstudent": 204, "Student": 403, "Syndic": 403} + self.remove_view("garbage-collection/", codes, create=create) diff --git a/backend/garbage_collection/urls.py b/backend/garbage_collection/urls.py index ba6dba1f..3a9ebf50 100644 --- a/backend/garbage_collection/urls.py +++ b/backend/garbage_collection/urls.py @@ -1,15 +1,11 @@ from django.urls import path -from .views import ( - GarbageCollectionIndividualView, - GarbageCollectionIndividualBuildingView, - DefaultGarbageCollection, - GarbageCollectionAllView -) +from .views import * urlpatterns = [ - path('all/', GarbageCollectionAllView.as_view()), - path('building//', GarbageCollectionIndividualBuildingView.as_view()), - path('/', GarbageCollectionIndividualView.as_view()), - path('', DefaultGarbageCollection.as_view()) + path("all/", GarbageCollectionAllView.as_view()), + path("building//", GarbageCollectionIndividualBuildingView.as_view()), + path("duplicate/", GarbageCollectionDuplicateView.as_view()), + path("/", GarbageCollectionIndividualView.as_view()), + path("", DefaultGarbageCollection.as_view()), ] diff --git a/backend/garbage_collection/views.py b/backend/garbage_collection/views.py index ef7a854e..e162d6c0 100644 --- a/backend/garbage_collection/views.py +++ b/backend/garbage_collection/views.py @@ -1,19 +1,24 @@ +from django.utils.translation import gettext_lazy as _ +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema, OpenApiResponse +from rest_framework.permissions import IsAuthenticated from rest_framework.views import APIView -from base.models import GarbageCollection +from base.models import GarbageCollection, Building +from base.permissions import IsSuperStudent, IsAdmin, ReadOnlyStudent, ReadOnlyOwnerOfBuilding from base.serializers import GarbageCollectionSerializer +from garbage_collection.serializers import GarbageCollectionDuplicateRequestSerializer from util.request_response_util import * -from drf_spectacular.utils import extend_schema +from util.util import get_monday_of_week, get_sunday_of_week TRANSLATE = {"building": "building_id"} + class DefaultGarbageCollection(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent] serializer_class = GarbageCollectionSerializer - @extend_schema( - responses={201: GarbageCollectionSerializer, - 400: None} - ) + @extend_schema(responses=post_docs(GarbageCollectionSerializer)) def post(self, request): """ Create new garbage collection @@ -32,49 +37,49 @@ def post(self, request): class GarbageCollectionIndividualView(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent | ReadOnlyStudent | ReadOnlyOwnerOfBuilding] serializer_class = GarbageCollectionSerializer - @extend_schema( - responses={200: GarbageCollectionSerializer, - 400: None} - ) + @extend_schema(responses=get_docs(GarbageCollectionSerializer)) def get(self, request, garbage_collection_id): """ Get info about a garbage collection with given id """ garbage_collection_instance = GarbageCollection.objects.filter(id=garbage_collection_id) if not garbage_collection_instance: - return bad_request("GarbageCollection") - serializer = GarbageCollectionSerializer(garbage_collection_instance[0]) + return not_found("GarbageCollection") + garbage_collection_instance = garbage_collection_instance[0] + + self.check_object_permissions(request, garbage_collection_instance.building) + + serializer = GarbageCollectionSerializer(garbage_collection_instance) return get_success(serializer) - @extend_schema( - responses={204: None, - 400: None} - ) + @extend_schema(responses=delete_docs()) def delete(self, request, garbage_collection_id): """ Delete garbage collection with given id """ garbage_collection_instance = GarbageCollection.objects.filter(id=garbage_collection_id) if not garbage_collection_instance: - return bad_request("GarbageCollection") + return not_found("GarbageCollection") + self.check_object_permissions(request, garbage_collection_instance[0].building) garbage_collection_instance[0].delete() return delete_success() - @extend_schema( - responses={200: GarbageCollectionSerializer, - 400: None} - ) + @extend_schema(responses=patch_docs(GarbageCollectionSerializer)) def patch(self, request, garbage_collection_id): """ Edit garbage collection with given id """ garbage_collection_instance = GarbageCollection.objects.filter(id=garbage_collection_id) if not garbage_collection_instance: - return bad_request("GarbageCollection") + return not_found("GarbageCollection") garbage_collection_instance = garbage_collection_instance[0] + + self.check_object_permissions(request, garbage_collection_instance.building) + data = request_to_dict(request.data) set_keys_of_instance(garbage_collection_instance, data, TRANSLATE) @@ -90,24 +95,139 @@ class GarbageCollectionIndividualBuildingView(APIView): """ /building/ """ + + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent | ReadOnlyStudent | ReadOnlyOwnerOfBuilding] serializer_class = GarbageCollectionSerializer + @extend_schema( + parameters=param_docs( + { + "start-date": ("Filter entries starting from start-date", False, OpenApiTypes.DATE), + "end-date": ("Filter entries up till end-date", False, OpenApiTypes.DATE), + } + ) + ) def get(self, request, building_id): """ Get info about all garbage collections of a building with given id """ + building_instance = Building.objects.filter(id=building_id) + if not building_instance: + return not_found("building") + + self.check_object_permissions(request, building_instance[0]) + + filters = { + "start-date": get_filter_object("date__gte"), + "end-date": get_filter_object("date__lte"), + } garbage_collection_instances = GarbageCollection.objects.filter(building=building_id) + + try: + garbage_collection_instances = filter_instances(request, garbage_collection_instances, filters) + except BadRequest as e: + return Response({"message": str(e)}, status=status.HTTP_400_BAD_REQUEST) + serializer = GarbageCollectionSerializer(garbage_collection_instances, many=True) return get_success(serializer) class GarbageCollectionAllView(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent] serializer_class = GarbageCollectionSerializer + @extend_schema( + parameters=param_docs( + { + "start-date": ("Filter entries starting from start-date", False, OpenApiTypes.DATE), + "end-date": ("Filter entries up till end-date", False, OpenApiTypes.DATE), + } + ) + ) def get(self, request): """ Get all garbage collections """ + filters = { + "start-date": get_filter_object("date__gte"), + "end-date": get_filter_object("date__lte"), + } garbage_collection_instances = GarbageCollection.objects.all() + + try: + garbage_collection_instances = filter_instances(request, garbage_collection_instances, filters) + except BadRequest as e: + return Response({"message": str(e)}, status=status.HTTP_400_BAD_REQUEST) + serializer = GarbageCollectionSerializer(garbage_collection_instances, many=True) return get_success(serializer) + + +def validate_duplication_period(start_period: datetime, end_period: datetime, start_copy: datetime) -> Response | None: + # validate period itself + if start_period > end_period: + return Response( + {"message": _("the start date of the period can't be in a later week than the week of the end date")}, + status=status.HTTP_400_BAD_REQUEST, + ) + # validate interaction with copy period + if start_copy <= end_period: + return Response( + { + "message": _( + "the start date of the period to which you want to copy must be, at a minimum, in the week " + "immediately following the end date of the original period" + ) + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + +class GarbageCollectionDuplicateView(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent] + serializer_class = GarbageCollectionDuplicateRequestSerializer + + @extend_schema( + description="POST body consists of a certain period", + request=GarbageCollectionDuplicateRequestSerializer, + responses={ + 200: OpenApiResponse( + description="The garbage collections were successfully copied.", + examples={"message": "successfully copied the garbage collections"}, + ), + 400: OpenApiResponse( + description="The request was invalid. The response will include an error message.", + examples={ + "message": "the start date of the period can't be in a later week than the week of the end date" + }, + ), + }, + ) + def post(self, request): + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + validated_data = serializer.validated_data + # transform them into the appropriate week-days: + start_date_period = get_monday_of_week(validated_data.get("start-date-period")) + end_date_period = get_sunday_of_week(validated_data.get("end-date-period")) + start_date_copy = get_monday_of_week(validated_data.get("start-date-copy")) + + if r := validate_duplication_period(start_date_period, end_date_period, start_date_copy): + return r + + # filter the GarbageCollections to duplicate + garbage_collections_to_duplicate = GarbageCollection.objects.filter( + date__range=[start_date_period, end_date_period] + ) + # retrieve and apply the optional filtering on buildings + building_ids = validated_data.get("building-ids", None) + if building_ids: + garbage_collections_to_duplicate = garbage_collections_to_duplicate.filter(building__id__in=building_ids) + + # loop through the GarbageCollections to duplicate and create a copy if it doesn't already exist + for gc in garbage_collections_to_duplicate: + # offset the date by the start date difference + copy_date = (datetime.combine(gc.date, datetime.min.time()) + (start_date_copy - start_date_period)).date() + if not GarbageCollection.objects.filter(date=copy_date, building=gc.building).exists(): + GarbageCollection.objects.create(date=copy_date, building=gc.building, garbage_type=gc.garbage_type) + return Response({"message": _("successfully copied the garbage collections")}, status=status.HTTP_200_OK) diff --git a/backend/buildingurl/__init__.py b/backend/lobby/__init__.py similarity index 100% rename from backend/buildingurl/__init__.py rename to backend/lobby/__init__.py diff --git a/backend/lobby/tests.py b/backend/lobby/tests.py new file mode 100644 index 00000000..6c1b0b10 --- /dev/null +++ b/backend/lobby/tests.py @@ -0,0 +1,92 @@ +from base.models import Lobby +from base.serializers import LobbySerializer +from util.data_generators import insert_dummy_role, insert_dummy_lobby +from util.test_tools import BaseTest, BaseAuthTest + + +class LobbyTests(BaseTest): + def __init__(self, methodName="runTest"): + super().__init__(methodName) + + def test_empty_lobby_list(self): + self.empty_list("lobby/") + + def test_insert_lobby(self): + r_id = insert_dummy_role("Student") + self.data1 = {"email": "test_lobby@example.com", "role": r_id} + self.insert("lobby/") + + def test_insert_empty(self): + self.insert_empty("lobby/") + + def test_insert_dupe_lobby(self): + r_id = insert_dummy_role("Student") + self.data1 = {"email": "test_lobby@example.com", "role": r_id} + self.insert_dupe("lobby/") + + def test_get_lobby(self): + l_id = insert_dummy_lobby() + data = LobbySerializer(Lobby.objects.get(id=l_id)).data + self.get(f"lobby/{l_id}", data) + + def test_get_non_existing(self): + self.get_non_existent("lobby/") + + def test_patch_lobby(self): + l_id = insert_dummy_lobby() + r_id = insert_dummy_role("Student") + self.data1 = {"email": "test_lobby@example.com", "role": r_id} + self.patch(f"lobby/{l_id}") + + def test_patch_invalid_lobby(self): + r_id = insert_dummy_role("Student") + self.data1 = {"email": "test_lobby@example.com", "role": r_id} + self.patch_invalid("lobby/") + + def test_patch_error_lobby(self): + r_id = insert_dummy_role("Student") + self.data1 = {"email": "test_lobby@example.com", "role": r_id} + self.data2 = {"email": "test_lobby_new@example.com", "role": r_id} + self.patch_error("lobby/") + + def test_remove_lobby(self): + l_id = insert_dummy_lobby() + self.remove(f"lobby/{l_id}") + + def test_remove_nonexistent_lobby(self): + self.remove_invalid("lobby/") + + +class LobbyAuthorizationTests(BaseAuthTest): + def __init__(self, methodName="runTest"): + super().__init__(methodName) + + def test_lobby_list(self): + codes = {"Default": 403, "Admin": 200, "Superstudent": 200, "Student": 403, "Syndic": 403} + self.list_view("lobby/", codes) + + def test_insert_lobby(self): + codes = {"Default": 403, "Admin": 201, "Superstudent": 201, "Student": 403, "Syndic": 403} + r_id = insert_dummy_role("Student") + self.data1 = {"email": "test_lobby@example.com", "role": r_id} + self.insert_view("lobby/", codes) + + def test_get_lobby(self): + codes = {"Default": 403, "Admin": 200, "Superstudent": 200, "Student": 403, "Syndic": 403} + l_id = insert_dummy_lobby() + self.get_view(f"lobby/{l_id}", codes) + + def test_patch_lobby(self): + codes = {"Default": 403, "Admin": 200, "Superstudent": 200, "Student": 403, "Syndic": 403} + l_id = insert_dummy_lobby() + + r_id = insert_dummy_role("Student") + self.data1 = {"email": "test_lobby_new@example.com", "role": r_id} + self.patch_view(f"lobby/{l_id}", codes) + + def test_remove_lobby(self): + def create(): + return insert_dummy_lobby() + + codes = {"Default": 403, "Admin": 204, "Superstudent": 204, "Student": 403, "Syndic": 403} + self.remove_view("lobby/", codes, create=create) diff --git a/backend/lobby/urls.py b/backend/lobby/urls.py new file mode 100644 index 00000000..8bce9444 --- /dev/null +++ b/backend/lobby/urls.py @@ -0,0 +1,15 @@ +from django.urls import path + +from .views import ( + DefaultLobby, + LobbyIndividualView, + LobbyAllView, + LobbyRefreshVerificationCodeView, +) + +urlpatterns = [ + path("all/", LobbyAllView.as_view()), + path("new-verification-code//", LobbyRefreshVerificationCodeView.as_view()), + path("/", LobbyIndividualView.as_view()), + path("", DefaultLobby.as_view()), +] diff --git a/backend/lobby/views.py b/backend/lobby/views.py new file mode 100644 index 00000000..b8b89575 --- /dev/null +++ b/backend/lobby/views.py @@ -0,0 +1,136 @@ +from drf_spectacular.utils import extend_schema +from rest_framework.permissions import IsAuthenticated +from rest_framework.views import APIView + +from base.models import Lobby +from base.permissions import IsAdmin, IsSuperStudent +from base.serializers import LobbySerializer +from util.request_response_util import * + +TRANSLATE = {"role": "role_id"} + +_VERIFICATION_CODE = "verification_code" + + +def _add_verification_code_to_req_data(data): + if _VERIFICATION_CODE not in data: + data[_VERIFICATION_CODE] = get_unique_uuid() + + +class DefaultLobby(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent] + serializer_class = LobbySerializer + + @extend_schema( + responses=post_docs(LobbySerializer), + ) + def post(self, request): + """ + Create a new whitelisted email + """ + data = request_to_dict(request.data) + + _add_verification_code_to_req_data(data) + + lobby_instance = Lobby() + + set_keys_of_instance(lobby_instance, data, TRANSLATE) + + if r := try_full_clean_and_save(lobby_instance): + return r + + return post_success(LobbySerializer(lobby_instance)) + + +class LobbyIndividualView(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent] + serializer_class = LobbySerializer + + @extend_schema(responses=get_docs(LobbySerializer)) + def get(self, request, email_whitelist_id): + """ + Get info about an EmailWhitelist with given id + """ + lobby_instance = Lobby.objects.filter(id=email_whitelist_id) + + if not lobby_instance: + return not_found("EmailWhitelist") + + return get_success(LobbySerializer(lobby_instance[0])) + + @extend_schema(responses=delete_docs()) + def delete(self, request, email_whitelist_id): + """ + Patch EmailWhitelist with given id + """ + lobby_instance = Lobby.objects.filter(id=email_whitelist_id) + + if not lobby_instance: + return not_found("EmailWhitelist") + + lobby_instance[0].delete() + + return delete_success() + + @extend_schema(responses={204: None, 400: None, 403: None}) + def patch(self, request, email_whitelist_id): + """ + Patch EmailWhitelist with given id + """ + email_whitelist_instance = Lobby.objects.filter(id=email_whitelist_id) + + if not email_whitelist_instance: + return not_found("EmailWhitelist") + + email_whitelist_instance = email_whitelist_instance[0] + data = request_to_dict(request.data) + + if _VERIFICATION_CODE in data: + return Response("Not permitted to change the verification code", status=403) + + set_keys_of_instance(email_whitelist_instance, data, TRANSLATE) + + if r := try_full_clean_and_save(email_whitelist_instance): + return r + + return patch_success(LobbySerializer(email_whitelist_instance)) + + +class LobbyRefreshVerificationCodeView(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent] + serializer_class = LobbySerializer + + @extend_schema( + description="Generate a new token. The body of the request is ignored.", + request=None, + responses=post_docs(LobbySerializer), + ) + def post(self, request, lobby_id): + """ + Do a POST with an empty body on `lobby/new_verification_code/ to generate a new verification code + """ + lobby_instance = Lobby.objects.filter(id=lobby_id) + + if not lobby_instance: + return not_found("EmailWhitelist") + + lobby_instance = lobby_instance[0] + lobby_instance.verification_code = get_unique_uuid() + + if r := try_full_clean_and_save(lobby_instance): + return r + + return post_success(LobbySerializer(lobby_instance)) + + +class LobbyAllView(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent] + serializer_class = LobbySerializer + + def get(self, request): + """ + Get info about the EmailWhiteList with given id + """ + lobby_instance = Lobby.objects.all() + serializer = LobbySerializer(lobby_instance, many=True) + return get_success(serializer) diff --git a/backend/locale/en/LC_MESSAGES/django.mo b/backend/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 00000000..79408d07 Binary files /dev/null and b/backend/locale/en/LC_MESSAGES/django.mo differ diff --git a/backend/locale/en/LC_MESSAGES/django.po b/backend/locale/en/LC_MESSAGES/django.po new file mode 100644 index 00000000..533d3966 --- /dev/null +++ b/backend/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,1528 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-04-20 15:19+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: authentication/serializers.py:40 +msgid "a user is already registered with this e-mail address" +msgstr "" + +#: authentication/serializers.py:51 +msgid "" +"{data['email']} has no entry in the lobby, you must contact an admin to gain " +"access to the platform" +msgstr "" + +#: authentication/serializers.py:57 +msgid "invalid verification code" +msgstr "" + +#: authentication/serializers.py:62 +msgid "the two password fields didn't match." +msgstr "" + +#: authentication/serializers.py:103 authentication/serializers.py:134 +msgid "no valid refresh token found" +msgstr "" + +#: authentication/serializers.py:162 +msgid "successfully logged out" +msgstr "" + +#: authentication/serializers.py:172 +msgid "refresh token was not included in request cookies" +msgstr "" + +#: authentication/serializers.py:180 authentication/serializers.py:183 +msgid "an error has occurred." +msgstr "" + +#: authentication/views.py:48 +msgid "successful login" +msgstr "" + +#: authentication/views.py:81 +msgid "refresh of tokens successful" +msgstr "" + +#: authentication/views.py:99 +msgid "refresh token validation successful" +msgstr "" + +#: authentication/views.py:110 +msgid "new password has been saved" +msgstr "" + +#: base/models.py:20 +msgid "This region already exists" +msgstr "" + +#: base/models.py:40 +#, python-brace-format +msgid "The maximum rank allowed is {highest_rank}." +msgstr "" + +#: base/models.py:48 +msgid "This role name already exists." +msgstr "" + +#: base/models.py:59 +msgid "A user already exists with this email." +msgstr "" + +#: base/models.py:83 +msgid "This email is already in the lobby." +msgstr "" + +#: base/models.py:87 +msgid "This verification code already exists." +msgstr "" + +#: base/models.py:102 +msgid "" +" This email belongs to an INACTIVE user. Instead of trying to register this " +"user, you can simply reactivate the account." +msgstr "" + +#: base/models.py:105 +#, python-brace-format +msgid "Email already exists in database for a user (id: {user_id}).{addendum}" +msgstr "" + +#: base/models.py:116 +msgid "No bus" +msgstr "" + +#: base/models.py:132 +msgid "The house number of the building must be positive and not zero." +msgstr "" + +#: base/models.py:139 +msgid "Only a user with role \"syndic\" can own a building." +msgstr "" + +#: base/models.py:145 +#, python-brace-format +msgid "{public_id} already exists as public_id of another building" +msgstr "" + +#: base/models.py:157 +msgid "A building with this address already exists." +msgstr "" + +#: base/models.py:180 +msgid "This comment already exists, and was posted at the exact same time." +msgstr "" + +#: base/models.py:218 +msgid "" +"This type of garbage is already being collected on the same day for this " +"building." +msgstr "" + +#: base/models.py:244 +msgid "There is already a tour with the same name in the region." +msgstr "" + +#: base/models.py:267 +#, python-brace-format +msgid "" +"The regions for tour ({tour_region}) and building ({building_region}) are " +"different." +msgstr "" + +#: base/models.py:281 +msgid "This building is already on this tour." +msgstr "" + +#: base/models.py:287 +msgid "This index is already in use." +msgstr "" + +#: base/models.py:313 +msgid "A syndic can't do tours" +msgstr "" + +#: base/models.py:317 +#, python-brace-format +msgid "Student ({user_email}) doesn't do tours in this region ({tour_region})." +msgstr "" + +#: base/models.py:329 +msgid "The student is already assigned to this tour on this date." +msgstr "" + +#: base/models.py:373 +msgid "" +"This remark was already uploaded to this building by this student on the " +"tour." +msgstr "" + +#: base/models.py:393 +msgid "The building already has this upload." +msgstr "" + +#: base/models.py:429 +msgid "The building already has a manual with the same version number" +msgstr "" + +#: base/models.py:446 +msgid "The name for this template already exists." +msgstr "" + +#: base/permissions.py:19 +msgid "Admin permission required" +msgstr "" + +#: base/permissions.py:30 +msgid "Super student permission required" +msgstr "" + +#: base/permissions.py:41 +msgid "Student permission required" +msgstr "" + +#: base/permissions.py:52 +msgid "Students are only allowed to read" +msgstr "" + +#: base/permissions.py:64 +msgid "Syndic permission required" +msgstr "" + +#: base/permissions.py:88 +msgid "You can only access/edit the buildings that you own" +msgstr "" + +#: base/permissions.py:102 +msgid "You can only read the building that you own" +msgstr "" + +#: base/permissions.py:118 +msgid "" +"You can only patch the building public id and the name of the building that " +"you own" +msgstr "" + +#: base/permissions.py:146 +msgid "You can only access/edit your own account" +msgstr "" + +#: base/permissions.py:157 +msgid "You can only access your own account" +msgstr "" + +#: base/permissions.py:170 +msgid "You can't create a user of a higher role" +msgstr "" + +#: base/permissions.py:188 +msgid "You don't have the right permissions to delete this user" +msgstr "" + +#: base/permissions.py:201 +msgid "You don't have the right permissions to edit this user" +msgstr "" + +#: base/permissions.py:214 +msgid "" +"You can't assign a role to yourself or assign a role that is higher than " +"your own" +msgstr "" + +#: base/permissions.py:233 +msgid "You can only view manuals that are linked to one of your buildings" +msgstr "" + +#: config/settings.py:236 +msgid "Dutch" +msgstr "Nederlands" + +#: config/settings.py:237 +msgid "English" +msgstr "Engels" + +#: garbage_collection/views.py:170 +msgid "" +"the start date of the period can't be in a later week than the week of the " +"end date" +msgstr "" + +#: garbage_collection/views.py:178 +msgid "" +"the start date of the period to which you want to copy must be, at a " +"minimum, in the week immediately following the end date of the original " +"period" +msgstr "" + +#: garbage_collection/views.py:233 +msgid "successfully copied the garbage collections" +msgstr "" + +#: users/managers.py:16 +msgid "Email is required" +msgstr "" + +#: users/managers.py:32 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: users/managers.py:34 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: util/request_response_util.py:17 +#, python-brace-format +msgid "The query parameter {name} should be an integer" +msgstr "" + +#: util/request_response_util.py:20 util/request_response_util.py:37 +#: util/request_response_util.py:45 util/request_response_util.py:64 +#, python-brace-format +msgid "The query parameter {name} is required" +msgstr "" + +#: util/request_response_util.py:31 +#, python-brace-format +msgid "" +"The date parameter '{name}': '{param}' hasn't the appropriate form (=YYYY-MM-" +"DD)." +msgstr "" + +#: util/request_response_util.py:54 +#, python-brace-format +msgid "" +"Invalid value for boolean parameter '{name}': '{param}' (true or false " +"expected)" +msgstr "" + +#: util/request_response_util.py:154 +msgid "{} was not found" +msgstr "" + +#: util/request_response_util.py:158 +msgid "bad input for {}" +msgstr "" + +#: util/request_response_util.py:164 +#, python-brace-format +msgid "There is no {object1} that is linked to {object2} with given id." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/contrib/messages/apps.py:15 +msgid "Messages" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/contrib/sitemaps/apps.py:8 +msgid "Site Maps" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/contrib/staticfiles/apps.py:9 +msgid "Static Files" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/contrib/syndication/apps.py:7 +msgid "Syndication" +msgstr "" + +#. Translators: String used to replace omitted page numbers in elided page +#. range generated by paginators, e.g. [1, 2, '…', 5, 6, 7, '…', 9, 10]. +#: venv/lib/python3.10/site-packages/django/core/paginator.py:30 +msgid "…" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/core/paginator.py:50 +msgid "That page number is not an integer" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/core/paginator.py:52 +msgid "That page number is less than 1" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/core/paginator.py:57 +msgid "That page contains no results" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/core/validators.py:22 +msgid "Enter a valid value." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/core/validators.py:104 +#: venv/lib/python3.10/site-packages/django/forms/fields.py:751 +msgid "Enter a valid URL." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/core/validators.py:164 +msgid "Enter a valid integer." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/core/validators.py:175 +msgid "Enter a valid email address." +msgstr "" + +#. Translators: "letters" means latin letters: a-z and A-Z. +#: venv/lib/python3.10/site-packages/django/core/validators.py:256 +msgid "" +"Enter a valid “slug” consisting of letters, numbers, underscores or hyphens." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/core/validators.py:264 +msgid "" +"Enter a valid “slug” consisting of Unicode letters, numbers, underscores, or " +"hyphens." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/core/validators.py:276 +#: venv/lib/python3.10/site-packages/django/core/validators.py:284 +#: venv/lib/python3.10/site-packages/django/core/validators.py:313 +msgid "Enter a valid IPv4 address." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/core/validators.py:293 +#: venv/lib/python3.10/site-packages/django/core/validators.py:314 +msgid "Enter a valid IPv6 address." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/core/validators.py:305 +#: venv/lib/python3.10/site-packages/django/core/validators.py:312 +msgid "Enter a valid IPv4 or IPv6 address." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/core/validators.py:348 +msgid "Enter only digits separated by commas." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/core/validators.py:354 +#, python-format +msgid "Ensure this value is %(limit_value)s (it is %(show_value)s)." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/core/validators.py:389 +#, python-format +msgid "Ensure this value is less than or equal to %(limit_value)s." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/core/validators.py:398 +#, python-format +msgid "Ensure this value is greater than or equal to %(limit_value)s." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/core/validators.py:407 +#, python-format +msgid "Ensure this value is a multiple of step size %(limit_value)s." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/core/validators.py:417 +#, python-format +msgid "" +"Ensure this value has at least %(limit_value)d character (it has " +"%(show_value)d)." +msgid_plural "" +"Ensure this value has at least %(limit_value)d characters (it has " +"%(show_value)d)." +msgstr[0] "" +msgstr[1] "" + +#: venv/lib/python3.10/site-packages/django/core/validators.py:435 +#, python-format +msgid "" +"Ensure this value has at most %(limit_value)d character (it has " +"%(show_value)d)." +msgid_plural "" +"Ensure this value has at most %(limit_value)d characters (it has " +"%(show_value)d)." +msgstr[0] "" +msgstr[1] "" + +#: venv/lib/python3.10/site-packages/django/core/validators.py:458 +#: venv/lib/python3.10/site-packages/django/forms/fields.py:347 +#: venv/lib/python3.10/site-packages/django/forms/fields.py:386 +msgid "Enter a number." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/core/validators.py:460 +#, python-format +msgid "Ensure that there are no more than %(max)s digit in total." +msgid_plural "Ensure that there are no more than %(max)s digits in total." +msgstr[0] "" +msgstr[1] "" + +#: venv/lib/python3.10/site-packages/django/core/validators.py:465 +#, python-format +msgid "Ensure that there are no more than %(max)s decimal place." +msgid_plural "Ensure that there are no more than %(max)s decimal places." +msgstr[0] "" +msgstr[1] "" + +#: venv/lib/python3.10/site-packages/django/core/validators.py:470 +#, python-format +msgid "" +"Ensure that there are no more than %(max)s digit before the decimal point." +msgid_plural "" +"Ensure that there are no more than %(max)s digits before the decimal point." +msgstr[0] "" +msgstr[1] "" + +#: venv/lib/python3.10/site-packages/django/core/validators.py:539 +#, python-format +msgid "" +"File extension “%(extension)s” is not allowed. Allowed extensions are: " +"%(allowed_extensions)s." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/core/validators.py:600 +msgid "Null characters are not allowed." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/base.py:1401 +#: venv/lib/python3.10/site-packages/django/forms/models.py:899 +msgid "and" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/base.py:1403 +#, python-format +msgid "%(model_name)s with this %(field_labels)s already exists." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/constraints.py:17 +#, python-format +msgid "Constraint “%(name)s” is violated." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:129 +#, python-format +msgid "Value %(value)r is not a valid choice." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:130 +msgid "This field cannot be null." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:131 +msgid "This field cannot be blank." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:132 +#, python-format +msgid "%(model_name)s with this %(field_label)s already exists." +msgstr "" + +#. Translators: The 'lookup_type' is one of 'date', 'year' or +#. 'month'. Eg: "Title must be unique for pub_date year" +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:136 +#, python-format +msgid "" +"%(field_label)s must be unique for %(date_field_label)s %(lookup_type)s." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:174 +#, python-format +msgid "Field of type: %(field_type)s" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:1065 +#, python-format +msgid "“%(value)s” value must be either True or False." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:1066 +#, python-format +msgid "“%(value)s” value must be either True, False, or None." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:1068 +msgid "Boolean (Either True or False)" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:1118 +#, python-format +msgid "String (up to %(max_length)s)" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:1222 +msgid "Comma-separated integers" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:1323 +#, python-format +msgid "" +"“%(value)s” value has an invalid date format. It must be in YYYY-MM-DD " +"format." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:1327 +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:1462 +#, python-format +msgid "" +"“%(value)s” value has the correct format (YYYY-MM-DD) but it is an invalid " +"date." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:1331 +msgid "Date (without time)" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:1458 +#, python-format +msgid "" +"“%(value)s” value has an invalid format. It must be in YYYY-MM-DD HH:MM[:ss[." +"uuuuuu]][TZ] format." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:1466 +#, python-format +msgid "" +"“%(value)s” value has the correct format (YYYY-MM-DD HH:MM[:ss[.uuuuuu]]" +"[TZ]) but it is an invalid date/time." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:1471 +msgid "Date (with time)" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:1595 +#, python-format +msgid "“%(value)s” value must be a decimal number." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:1597 +msgid "Decimal number" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:1754 +#, python-format +msgid "" +"“%(value)s” value has an invalid format. It must be in [DD] [[HH:]MM:]ss[." +"uuuuuu] format." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:1758 +msgid "Duration" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:1810 +msgid "Email address" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:1835 +msgid "File path" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:1913 +#, python-format +msgid "“%(value)s” value must be a float." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:1915 +msgid "Floating point number" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:1955 +#, python-format +msgid "“%(value)s” value must be an integer." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:1957 +msgid "Integer" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:2049 +msgid "Big (8 byte) integer" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:2066 +msgid "Small integer" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:2074 +msgid "IPv4 address" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:2105 +msgid "IP address" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:2198 +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:2199 +#, python-format +msgid "“%(value)s” value must be either None, True or False." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:2201 +msgid "Boolean (Either True, False or None)" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:2252 +msgid "Positive big integer" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:2267 +msgid "Positive integer" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:2282 +msgid "Positive small integer" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:2298 +#, python-format +msgid "Slug (up to %(max_length)s)" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:2334 +msgid "Text" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:2409 +#, python-format +msgid "" +"“%(value)s” value has an invalid format. It must be in HH:MM[:ss[.uuuuuu]] " +"format." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:2413 +#, python-format +msgid "" +"“%(value)s” value has the correct format (HH:MM[:ss[.uuuuuu]]) but it is an " +"invalid time." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:2417 +msgid "Time" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:2525 +msgid "URL" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:2549 +msgid "Raw binary data" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:2614 +#, python-format +msgid "“%(value)s” is not a valid UUID." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:2616 +msgid "Universally unique identifier" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/files.py:231 +msgid "File" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/files.py:391 +msgid "Image" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/json.py:18 +msgid "A JSON object" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/json.py:20 +msgid "Value must be valid JSON." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/related.py:918 +#, python-format +msgid "%(model)s instance with %(field)s %(value)r does not exist." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/related.py:920 +msgid "Foreign Key (type determined by related field)" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/related.py:1227 +msgid "One-to-one relationship" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/related.py:1284 +#, python-format +msgid "%(from)s-%(to)s relationship" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/related.py:1286 +#, python-format +msgid "%(from)s-%(to)s relationships" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/related.py:1334 +msgid "Many-to-many relationship" +msgstr "" + +#. Translators: If found as last label character, these punctuation +#. characters will prevent the default label_suffix to be appended to the label +#: venv/lib/python3.10/site-packages/django/forms/boundfield.py:176 +msgid ":?.!" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/fields.py:91 +msgid "This field is required." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/fields.py:298 +msgid "Enter a whole number." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/fields.py:467 +#: venv/lib/python3.10/site-packages/django/forms/fields.py:1240 +msgid "Enter a valid date." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/fields.py:490 +#: venv/lib/python3.10/site-packages/django/forms/fields.py:1241 +msgid "Enter a valid time." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/fields.py:517 +msgid "Enter a valid date/time." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/fields.py:551 +msgid "Enter a valid duration." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/fields.py:552 +#, python-brace-format +msgid "The number of days must be between {min_days} and {max_days}." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/fields.py:618 +msgid "No file was submitted. Check the encoding type on the form." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/fields.py:619 +msgid "No file was submitted." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/fields.py:620 +msgid "The submitted file is empty." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/fields.py:622 +#, python-format +msgid "Ensure this filename has at most %(max)d character (it has %(length)d)." +msgid_plural "" +"Ensure this filename has at most %(max)d characters (it has %(length)d)." +msgstr[0] "" +msgstr[1] "" + +#: venv/lib/python3.10/site-packages/django/forms/fields.py:627 +msgid "Please either submit a file or check the clear checkbox, not both." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/fields.py:693 +msgid "" +"Upload a valid image. The file you uploaded was either not an image or a " +"corrupted image." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/fields.py:856 +#: venv/lib/python3.10/site-packages/django/forms/fields.py:948 +#: venv/lib/python3.10/site-packages/django/forms/models.py:1572 +#, python-format +msgid "Select a valid choice. %(value)s is not one of the available choices." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/fields.py:950 +#: venv/lib/python3.10/site-packages/django/forms/fields.py:1069 +#: venv/lib/python3.10/site-packages/django/forms/models.py:1570 +msgid "Enter a list of values." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/fields.py:1070 +msgid "Enter a complete value." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/fields.py:1309 +msgid "Enter a valid UUID." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/fields.py:1339 +msgid "Enter a valid JSON." +msgstr "" + +#. Translators: This is the default suffix added to form field labels +#: venv/lib/python3.10/site-packages/django/forms/forms.py:98 +msgid ":" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/forms.py:248 +#: venv/lib/python3.10/site-packages/django/forms/forms.py:332 +#, python-format +msgid "(Hidden field %(name)s) %(error)s" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/formsets.py:63 +#, python-format +msgid "" +"ManagementForm data is missing or has been tampered with. Missing fields: " +"%(field_names)s. You may need to file a bug report if the issue persists." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/formsets.py:67 +#, python-format +msgid "Please submit at most %(num)d form." +msgid_plural "Please submit at most %(num)d forms." +msgstr[0] "" +msgstr[1] "" + +#: venv/lib/python3.10/site-packages/django/forms/formsets.py:72 +#, python-format +msgid "Please submit at least %(num)d form." +msgid_plural "Please submit at least %(num)d forms." +msgstr[0] "" +msgstr[1] "" + +#: venv/lib/python3.10/site-packages/django/forms/formsets.py:483 +#: venv/lib/python3.10/site-packages/django/forms/formsets.py:490 +msgid "Order" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/formsets.py:496 +msgid "Delete" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/models.py:892 +#, python-format +msgid "Please correct the duplicate data for %(field)s." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/models.py:897 +#, python-format +msgid "Please correct the duplicate data for %(field)s, which must be unique." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/models.py:904 +#, python-format +msgid "" +"Please correct the duplicate data for %(field_name)s which must be unique " +"for the %(lookup)s in %(date_field)s." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/models.py:913 +msgid "Please correct the duplicate values below." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/models.py:1344 +msgid "The inline value did not match the parent instance." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/models.py:1435 +msgid "Select a valid choice. That choice is not one of the available choices." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/models.py:1574 +#, python-format +msgid "“%(pk)s” is not a valid value." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/utils.py:226 +#, python-format +msgid "" +"%(datetime)s couldn’t be interpreted in time zone %(current_timezone)s; it " +"may be ambiguous or it may not exist." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/widgets.py:439 +msgid "Clear" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/widgets.py:440 +msgid "Currently" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/widgets.py:441 +msgid "Change" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/widgets.py:769 +msgid "Unknown" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/widgets.py:770 +msgid "Yes" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/widgets.py:771 +msgid "No" +msgstr "" + +#. Translators: Please do not add spaces around commas. +#: venv/lib/python3.10/site-packages/django/template/defaultfilters.py:853 +msgid "yes,no,maybe" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/template/defaultfilters.py:883 +#: venv/lib/python3.10/site-packages/django/template/defaultfilters.py:900 +#, python-format +msgid "%(size)d byte" +msgid_plural "%(size)d bytes" +msgstr[0] "" +msgstr[1] "" + +#: venv/lib/python3.10/site-packages/django/template/defaultfilters.py:902 +#, python-format +msgid "%s KB" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/template/defaultfilters.py:904 +#, python-format +msgid "%s MB" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/template/defaultfilters.py:906 +#, python-format +msgid "%s GB" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/template/defaultfilters.py:908 +#, python-format +msgid "%s TB" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/template/defaultfilters.py:910 +#, python-format +msgid "%s PB" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dateformat.py:77 +msgid "p.m." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dateformat.py:78 +msgid "a.m." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dateformat.py:83 +msgid "PM" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dateformat.py:84 +msgid "AM" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dateformat.py:155 +msgid "midnight" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dateformat.py:157 +msgid "noon" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:7 +msgid "Monday" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:8 +msgid "Tuesday" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:9 +msgid "Wednesday" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:10 +msgid "Thursday" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:11 +msgid "Friday" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:12 +msgid "Saturday" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:13 +msgid "Sunday" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:16 +msgid "Mon" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:17 +msgid "Tue" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:18 +msgid "Wed" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:19 +msgid "Thu" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:20 +msgid "Fri" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:21 +msgid "Sat" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:22 +msgid "Sun" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:25 +msgid "January" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:26 +msgid "February" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:27 +msgid "March" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:28 +msgid "April" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:29 +msgid "May" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:30 +msgid "June" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:31 +msgid "July" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:32 +msgid "August" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:33 +msgid "September" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:34 +msgid "October" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:35 +msgid "November" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:36 +msgid "December" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:39 +msgid "jan" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:40 +msgid "feb" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:41 +msgid "mar" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:42 +msgid "apr" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:43 +msgid "may" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:44 +msgid "jun" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:45 +msgid "jul" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:46 +msgid "aug" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:47 +msgid "sep" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:48 +msgid "oct" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:49 +msgid "nov" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:50 +msgid "dec" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:53 +msgctxt "abbrev. month" +msgid "Jan." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:54 +msgctxt "abbrev. month" +msgid "Feb." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:55 +msgctxt "abbrev. month" +msgid "March" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:56 +msgctxt "abbrev. month" +msgid "April" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:57 +msgctxt "abbrev. month" +msgid "May" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:58 +msgctxt "abbrev. month" +msgid "June" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:59 +msgctxt "abbrev. month" +msgid "July" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:60 +msgctxt "abbrev. month" +msgid "Aug." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:61 +msgctxt "abbrev. month" +msgid "Sept." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:62 +msgctxt "abbrev. month" +msgid "Oct." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:63 +msgctxt "abbrev. month" +msgid "Nov." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:64 +msgctxt "abbrev. month" +msgid "Dec." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:67 +msgctxt "alt. month" +msgid "January" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:68 +msgctxt "alt. month" +msgid "February" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:69 +msgctxt "alt. month" +msgid "March" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:70 +msgctxt "alt. month" +msgid "April" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:71 +msgctxt "alt. month" +msgid "May" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:72 +msgctxt "alt. month" +msgid "June" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:73 +msgctxt "alt. month" +msgid "July" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:74 +msgctxt "alt. month" +msgid "August" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:75 +msgctxt "alt. month" +msgid "September" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:76 +msgctxt "alt. month" +msgid "October" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:77 +msgctxt "alt. month" +msgid "November" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:78 +msgctxt "alt. month" +msgid "December" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/ipv6.py:8 +msgid "This is not a valid IPv6 address." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/text.py:76 +#, python-format +msgctxt "String to return when truncating text" +msgid "%(truncated_text)s…" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/text.py:252 +msgid "or" +msgstr "" + +#. Translators: This string is used as a separator between list elements +#: venv/lib/python3.10/site-packages/django/utils/text.py:271 +#: venv/lib/python3.10/site-packages/django/utils/timesince.py:94 +msgid ", " +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/timesince.py:9 +#, python-format +msgid "%(num)d year" +msgid_plural "%(num)d years" +msgstr[0] "" +msgstr[1] "" + +#: venv/lib/python3.10/site-packages/django/utils/timesince.py:10 +#, python-format +msgid "%(num)d month" +msgid_plural "%(num)d months" +msgstr[0] "" +msgstr[1] "" + +#: venv/lib/python3.10/site-packages/django/utils/timesince.py:11 +#, python-format +msgid "%(num)d week" +msgid_plural "%(num)d weeks" +msgstr[0] "" +msgstr[1] "" + +#: venv/lib/python3.10/site-packages/django/utils/timesince.py:12 +#, python-format +msgid "%(num)d day" +msgid_plural "%(num)d days" +msgstr[0] "" +msgstr[1] "" + +#: venv/lib/python3.10/site-packages/django/utils/timesince.py:13 +#, python-format +msgid "%(num)d hour" +msgid_plural "%(num)d hours" +msgstr[0] "" +msgstr[1] "" + +#: venv/lib/python3.10/site-packages/django/utils/timesince.py:14 +#, python-format +msgid "%(num)d minute" +msgid_plural "%(num)d minutes" +msgstr[0] "" +msgstr[1] "" + +#: venv/lib/python3.10/site-packages/django/views/csrf.py:111 +msgid "Forbidden" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/csrf.py:112 +msgid "CSRF verification failed. Request aborted." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/csrf.py:116 +msgid "" +"You are seeing this message because this HTTPS site requires a “Referer " +"header” to be sent by your web browser, but none was sent. This header is " +"required for security reasons, to ensure that your browser is not being " +"hijacked by third parties." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/csrf.py:122 +msgid "" +"If you have configured your browser to disable “Referer” headers, please re-" +"enable them, at least for this site, or for HTTPS connections, or for “same-" +"origin” requests." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/csrf.py:127 +msgid "" +"If you are using the tag or " +"including the “Referrer-Policy: no-referrer” header, please remove them. The " +"CSRF protection requires the “Referer” header to do strict referer checking. " +"If you’re concerned about privacy, use alternatives like for links to third-party sites." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/csrf.py:136 +msgid "" +"You are seeing this message because this site requires a CSRF cookie when " +"submitting forms. This cookie is required for security reasons, to ensure " +"that your browser is not being hijacked by third parties." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/csrf.py:142 +msgid "" +"If you have configured your browser to disable cookies, please re-enable " +"them, at least for this site, or for “same-origin” requests." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/csrf.py:148 +msgid "More information is available with DEBUG=True." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/generic/dates.py:44 +msgid "No year specified" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/generic/dates.py:64 +#: venv/lib/python3.10/site-packages/django/views/generic/dates.py:115 +#: venv/lib/python3.10/site-packages/django/views/generic/dates.py:214 +msgid "Date out of range" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/generic/dates.py:94 +msgid "No month specified" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/generic/dates.py:147 +msgid "No day specified" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/generic/dates.py:194 +msgid "No week specified" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/generic/dates.py:349 +#: venv/lib/python3.10/site-packages/django/views/generic/dates.py:380 +#, python-format +msgid "No %(verbose_name_plural)s available" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/generic/dates.py:652 +#, python-format +msgid "" +"Future %(verbose_name_plural)s not available because %(class_name)s." +"allow_future is False." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/generic/dates.py:692 +#, python-format +msgid "Invalid date string “%(datestr)s” given format “%(format)s”" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/generic/detail.py:56 +#, python-format +msgid "No %(verbose_name)s found matching the query" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/generic/list.py:70 +msgid "Page is not “last”, nor can it be converted to an int." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/generic/list.py:77 +#, python-format +msgid "Invalid page (%(page_number)s): %(message)s" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/generic/list.py:169 +#, python-format +msgid "Empty list and “%(class_name)s.allow_empty” is False." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/static.py:38 +msgid "Directory indexes are not allowed here." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/static.py:40 +#, python-format +msgid "“%(path)s” does not exist" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/static.py:79 +#, python-format +msgid "Index of %(directory)s" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/templates/default_urlconf.html:7 +#: venv/lib/python3.10/site-packages/django/views/templates/default_urlconf.html:221 +msgid "The install worked successfully! Congratulations!" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/templates/default_urlconf.html:207 +#, python-format +msgid "" +"View release notes for Django %(version)s" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/templates/default_urlconf.html:222 +#, python-format +msgid "" +"You are seeing this page because DEBUG=True is in your settings file and you have not " +"configured any URLs." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/templates/default_urlconf.html:230 +msgid "Django Documentation" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/templates/default_urlconf.html:231 +msgid "Topics, references, & how-to’s" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/templates/default_urlconf.html:239 +msgid "Tutorial: A Polling App" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/templates/default_urlconf.html:240 +msgid "Get started with Django" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/templates/default_urlconf.html:248 +msgid "Django Community" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/templates/default_urlconf.html:249 +msgid "Connect, get help, or contribute" +msgstr "" diff --git a/backend/locale/nl/LC_MESSAGES/django.mo b/backend/locale/nl/LC_MESSAGES/django.mo new file mode 100644 index 00000000..861ac8e8 Binary files /dev/null and b/backend/locale/nl/LC_MESSAGES/django.mo differ diff --git a/backend/locale/nl/LC_MESSAGES/django.po b/backend/locale/nl/LC_MESSAGES/django.po new file mode 100644 index 00000000..55be3290 --- /dev/null +++ b/backend/locale/nl/LC_MESSAGES/django.po @@ -0,0 +1,1550 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-04-20 15:19+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: authentication/serializers.py:40 +msgid "a user is already registered with this e-mail address" +msgstr "" + +#: authentication/serializers.py:51 +msgid "" +"{data['email']} has no entry in the lobby, you must contact an admin to gain " +"access to the platform" +msgstr "" + +#: authentication/serializers.py:57 +msgid "invalid verification code" +msgstr "ongeldige verificatiecode" + +#: authentication/serializers.py:62 +msgid "the two password fields didn't match." +msgstr "de twee wachtwoordvelden komen niet overeen" + +#: authentication/serializers.py:103 authentication/serializers.py:134 +msgid "no valid refresh token found" +msgstr "geen geldige refresh token gevonden" + +#: authentication/serializers.py:162 +msgid "successfully logged out" +msgstr "succesvol uitgelogd" + +#: authentication/serializers.py:172 +msgid "refresh token was not included in request cookies" +msgstr "refresh token was niet in de request cookies opgenomen" + +#: authentication/serializers.py:180 authentication/serializers.py:183 +msgid "an error has occurred." +msgstr "er is een fout opgetreden" + +#: authentication/views.py:48 +msgid "successful login" +msgstr "succesvol ingelogd" + +#: authentication/views.py:81 +msgid "refresh of tokens successful" +msgstr "tokens succesvol vernieuwd" + +#: authentication/views.py:99 +msgid "refresh token validation successful" +msgstr "refresh token validatie succesvol" + +#: authentication/views.py:110 +msgid "new password has been saved" +msgstr "nieuw wachtwoord is opgeslagen" + +#: base/models.py:20 +msgid "This region already exists" +msgstr "Deze regio bestaat al" + +#: base/models.py:40 +#, python-brace-format +msgid "The maximum rank allowed is {highest_rank}." +msgstr "De hoogste toegestane rang is {highest_rank}." + +#: base/models.py:48 +msgid "This role name already exists." +msgstr "Deze rolnaam bestaat al." + +#: base/models.py:59 +msgid "A user already exists with this email." +msgstr "Er bestaat al een gebruiker met deze e-mail" + +#: base/models.py:83 +msgid "This email is already in the lobby." +msgstr "Dit e-mailadres is al in de lobby aanwezig." + +#: base/models.py:87 +msgid "This verification code already exists." +msgstr "Deze verificatiecode bestaat al." + +#: base/models.py:102 +msgid "" +" This email belongs to an INACTIVE user. Instead of trying to register this " +"user, you can simply reactivate the account." +msgstr "" +" Dit e-mailadres hoort bij een INACTIEVE gebruiker. In plaats van deze " +"gebruiker te proberen te registreren, kunt u het account eenvoudig " +"reactiveren." + +#: base/models.py:105 +#, python-brace-format +msgid "Email already exists in database for a user (id: {user_id}).{addendum}" +msgstr "" +"E-mailadres bestaat al in de database voor een gebruiker (id: {user_id})." +"{addendum}" + +#: base/models.py:116 +msgid "No bus" +msgstr "Geen bus" + +#: base/models.py:132 +msgid "The house number of the building must be positive and not zero." +msgstr "" +"Het huisnummer van het gebouw moet een positief getal zijn, groter dan nul." + +#: base/models.py:139 +msgid "Only a user with role \"syndic\" can own a building." +msgstr "Alleen een gebruiker met de rol \"syndicus\" kan een gebouw bezitten." + +#: base/models.py:145 +#, python-brace-format +msgid "{public_id} already exists as public_id of another building" +msgstr "{public_id} bestaat al als public_id van een ander gebouw" + +#: base/models.py:157 +msgid "A building with this address already exists." +msgstr "Er bestaat al een gebouw met dit adres." + +#: base/models.py:180 +msgid "This comment already exists, and was posted at the exact same time." +msgstr "Deze opmerking bestaat al, en is op exact hetzelfde moment geplaatst." + +#: base/models.py:218 +msgid "" +"This type of garbage is already being collected on the same day for this " +"building." +msgstr "Dit soort afval wordt al op dezelfde dag voor dit gebouw opgehaald." + +#: base/models.py:244 +msgid "There is already a tour with the same name in the region." +msgstr "Er bestaat al een tour met dezelfde naam in de regio." + +#: base/models.py:267 +#, python-brace-format +msgid "" +"The regions for tour ({tour_region}) and building ({building_region}) are " +"different." +msgstr "" +"De regio's voor tour ({tour_region}) en gebouw ({building_region}) zijn " +"verschillend." + +#: base/models.py:281 +msgid "This building is already on this tour." +msgstr "Dit gebouw staat al op deze tour." + +#: base/models.py:287 +msgid "This index is already in use." +msgstr "Deze index is al in gebruik" + +#: base/models.py:313 +msgid "A syndic can't do tours" +msgstr "Een syndicus kan geen rondes doen" + +#: base/models.py:317 +#, python-brace-format +msgid "Student ({user_email}) doesn't do tours in this region ({tour_region})." +msgstr "Student ({user_email}) doet geen rondes in deze regio ({tour_region})." + +#: base/models.py:329 +msgid "The student is already assigned to this tour on this date." +msgstr "De student is al toegewezen aan deze tour op deze datum." + +#: base/models.py:373 +msgid "" +"This remark was already uploaded to this building by this student on the " +"tour." +msgstr "" +"Deze opmerking is al geüpload naar dit gebouw door deze student op de tour." + +#: base/models.py:393 +msgid "The building already has this upload." +msgstr "Het gebouw heeft al deze upload." + +#: base/models.py:429 +msgid "The building already has a manual with the same version number" +msgstr "Het gebouw heeft al een handleiding met hetzelfde versienummer" + +#: base/models.py:446 +msgid "The name for this template already exists." +msgstr "De naam van deze template bestaat al." + +#: base/permissions.py:19 +msgid "Admin permission required" +msgstr "Admin permissie vereist" + +#: base/permissions.py:30 +msgid "Super student permission required" +msgstr "Superstudent permisie vereist" + +#: base/permissions.py:41 +msgid "Student permission required" +msgstr "Student permissie vereist" + +#: base/permissions.py:52 +msgid "Students are only allowed to read" +msgstr "Studenten mogen alleen lezen" + +#: base/permissions.py:64 +msgid "Syndic permission required" +msgstr "Syndicus permissie vereist" + +#: base/permissions.py:88 +msgid "You can only access/edit the buildings that you own" +msgstr "Je kan alleen de gebouwen zien/bewerken die je zelf bezit" + +#: base/permissions.py:102 +msgid "You can only read the building that you own" +msgstr "Je kan enkel de gebouwen lezen die je zelf bezit" + +#: base/permissions.py:118 +msgid "" +"You can only patch the building public id and the name of the building that " +"you own" +msgstr "" +"Je kan alleen de public id en de naam van een gebouw bewerken dat je zelf " +"bezit" + +#: base/permissions.py:146 +msgid "You can only access/edit your own account" +msgstr "Je kan alleen je eigen account zien/bewerken" + +#: base/permissions.py:157 +msgid "You can only access your own account" +msgstr "Je kan alleen je eigen account raadplegen" + +#: base/permissions.py:170 +msgid "You can't create a user of a higher role" +msgstr "Je kan geen gebruiker met een hogere rol aanmaken" + +#: base/permissions.py:188 +msgid "You don't have the right permissions to delete this user" +msgstr "Je hebt niet de juiste permissies om deze gebruiker te verwijderen" + +#: base/permissions.py:201 +msgid "You don't have the right permissions to edit this user" +msgstr "Je hebt niet de juiste permissies om deze gebruiker te bewerken" + +#: base/permissions.py:214 +msgid "" +"You can't assign a role to yourself or assign a role that is higher than " +"your own" +msgstr "" +"Je kan geen rol toekennen aan jezelf of een rol toekennen die hoger is dan " +"je eigen rol" + +#: base/permissions.py:233 +msgid "You can only view manuals that are linked to one of your buildings" +msgstr "" +"Je kan enkel handleidingen bekijken die gelinkt zijn aan een van je eigen " +"gebouwen" + +#: config/settings.py:236 +msgid "Dutch" +msgstr "Nederlands" + +#: config/settings.py:237 +msgid "English" +msgstr "Engels" + +#: garbage_collection/views.py:170 +msgid "" +"the start date of the period can't be in a later week than the week of the " +"end date" +msgstr "" +"de start datum van de periode mag niet in een latere week zijn dan de week " +"van de eind datum" + +#: garbage_collection/views.py:178 +msgid "" +"the start date of the period to which you want to copy must be, at a " +"minimum, in the week immediately following the end date of the original " +"period" +msgstr "" +"de startdatum van de periode naar waar je wil kopiëren moet minstens de week " +"direct volgend op de einddatum van de oorspronkelijke periode zijn" + +#: garbage_collection/views.py:233 +msgid "successfully copied the garbage collections" +msgstr "de afvalophalingen werden succesvol gekopieerd" + +#: users/managers.py:16 +msgid "Email is required" +msgstr "E-mail is vereist" + +#: users/managers.py:32 +msgid "Superuser must have is_staff=True." +msgstr "Superuser moet is_staff=True hebben." + +#: users/managers.py:34 +msgid "Superuser must have is_superuser=True." +msgstr "Superuser moet is_superuser=True hebben." + +#: util/request_response_util.py:17 +#, python-brace-format +msgid "The query parameter {name} should be an integer" +msgstr "De query parameter {name} moet een getal zijn" + +#: util/request_response_util.py:20 util/request_response_util.py:37 +#: util/request_response_util.py:45 util/request_response_util.py:64 +#, python-brace-format +msgid "The query parameter {name} is required" +msgstr "De query parameter {name} is vereist" + +#: util/request_response_util.py:31 +#, python-brace-format +msgid "" +"The date parameter '{name}': '{param}' hasn't the appropriate form (=YYYY-MM-" +"DD)." +msgstr "" +"De datum parameter '{name}': '{param}' heeft niet het juiste formaat (=YYYY-" +"MM-DD)." + +#: util/request_response_util.py:54 +#, python-brace-format +msgid "" +"Invalid value for boolean parameter '{name}': '{param}' (true or false " +"expected)" +msgstr "" +"Ongeldige booleaanse parameter '{name}': '{param}' (true of false verwacht)" + +#: util/request_response_util.py:154 +msgid "{} was not found" +msgstr "{} werd niet gevonden" + +#: util/request_response_util.py:158 +msgid "bad input for {}" +msgstr "slechte input voor {}" + +#: util/request_response_util.py:164 +#, python-brace-format +msgid "There is no {object1} that is linked to {object2} with given id." +msgstr "Er is geen {object1} dat gelinkt is aan {object2} met de gegeven id." + +#: venv/lib/python3.10/site-packages/django/contrib/messages/apps.py:15 +msgid "Messages" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/contrib/sitemaps/apps.py:8 +msgid "Site Maps" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/contrib/staticfiles/apps.py:9 +msgid "Static Files" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/contrib/syndication/apps.py:7 +msgid "Syndication" +msgstr "" + +#. Translators: String used to replace omitted page numbers in elided page +#. range generated by paginators, e.g. [1, 2, '…', 5, 6, 7, '…', 9, 10]. +#: venv/lib/python3.10/site-packages/django/core/paginator.py:30 +msgid "…" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/core/paginator.py:50 +msgid "That page number is not an integer" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/core/paginator.py:52 +msgid "That page number is less than 1" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/core/paginator.py:57 +msgid "That page contains no results" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/core/validators.py:22 +msgid "Enter a valid value." +msgstr "Vul een geldige waarde in" + +#: venv/lib/python3.10/site-packages/django/core/validators.py:104 +#: venv/lib/python3.10/site-packages/django/forms/fields.py:751 +msgid "Enter a valid URL." +msgstr "Vul een geldige URL in" + +#: venv/lib/python3.10/site-packages/django/core/validators.py:164 +msgid "Enter a valid integer." +msgstr "Vul een geldig getal in" + +#: venv/lib/python3.10/site-packages/django/core/validators.py:175 +msgid "Enter a valid email address." +msgstr "Vul een geldig e-mailadres in" + +#. Translators: "letters" means latin letters: a-z and A-Z. +#: venv/lib/python3.10/site-packages/django/core/validators.py:256 +msgid "" +"Enter a valid “slug” consisting of letters, numbers, underscores or hyphens." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/core/validators.py:264 +msgid "" +"Enter a valid “slug” consisting of Unicode letters, numbers, underscores, or " +"hyphens." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/core/validators.py:276 +#: venv/lib/python3.10/site-packages/django/core/validators.py:284 +#: venv/lib/python3.10/site-packages/django/core/validators.py:313 +msgid "Enter a valid IPv4 address." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/core/validators.py:293 +#: venv/lib/python3.10/site-packages/django/core/validators.py:314 +msgid "Enter a valid IPv6 address." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/core/validators.py:305 +#: venv/lib/python3.10/site-packages/django/core/validators.py:312 +msgid "Enter a valid IPv4 or IPv6 address." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/core/validators.py:348 +msgid "Enter only digits separated by commas." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/core/validators.py:354 +#, python-format +msgid "Ensure this value is %(limit_value)s (it is %(show_value)s)." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/core/validators.py:389 +#, python-format +msgid "Ensure this value is less than or equal to %(limit_value)s." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/core/validators.py:398 +#, python-format +msgid "Ensure this value is greater than or equal to %(limit_value)s." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/core/validators.py:407 +#, python-format +msgid "Ensure this value is a multiple of step size %(limit_value)s." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/core/validators.py:417 +#, python-format +msgid "" +"Ensure this value has at least %(limit_value)d character (it has " +"%(show_value)d)." +msgid_plural "" +"Ensure this value has at least %(limit_value)d characters (it has " +"%(show_value)d)." +msgstr[0] "" +msgstr[1] "" + +#: venv/lib/python3.10/site-packages/django/core/validators.py:435 +#, python-format +msgid "" +"Ensure this value has at most %(limit_value)d character (it has " +"%(show_value)d)." +msgid_plural "" +"Ensure this value has at most %(limit_value)d characters (it has " +"%(show_value)d)." +msgstr[0] "" +msgstr[1] "" + +#: venv/lib/python3.10/site-packages/django/core/validators.py:458 +#: venv/lib/python3.10/site-packages/django/forms/fields.py:347 +#: venv/lib/python3.10/site-packages/django/forms/fields.py:386 +msgid "Enter a number." +msgstr "Vul een nummer in" + +#: venv/lib/python3.10/site-packages/django/core/validators.py:460 +#, python-format +msgid "Ensure that there are no more than %(max)s digit in total." +msgid_plural "Ensure that there are no more than %(max)s digits in total." +msgstr[0] "" +msgstr[1] "" + +#: venv/lib/python3.10/site-packages/django/core/validators.py:465 +#, python-format +msgid "Ensure that there are no more than %(max)s decimal place." +msgid_plural "Ensure that there are no more than %(max)s decimal places." +msgstr[0] "" +msgstr[1] "" + +#: venv/lib/python3.10/site-packages/django/core/validators.py:470 +#, python-format +msgid "" +"Ensure that there are no more than %(max)s digit before the decimal point." +msgid_plural "" +"Ensure that there are no more than %(max)s digits before the decimal point." +msgstr[0] "" +msgstr[1] "" + +#: venv/lib/python3.10/site-packages/django/core/validators.py:539 +#, python-format +msgid "" +"File extension “%(extension)s” is not allowed. Allowed extensions are: " +"%(allowed_extensions)s." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/core/validators.py:600 +msgid "Null characters are not allowed." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/base.py:1401 +#: venv/lib/python3.10/site-packages/django/forms/models.py:899 +msgid "and" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/base.py:1403 +#, python-format +msgid "%(model_name)s with this %(field_labels)s already exists." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/constraints.py:17 +#, python-format +msgid "Constraint “%(name)s” is violated." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:129 +#, python-format +msgid "Value %(value)r is not a valid choice." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:130 +msgid "This field cannot be null." +msgstr "Dit veld mag nie null zijn" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:131 +msgid "This field cannot be blank." +msgstr "dit veld mag niet leeg zijn" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:132 +#, python-format +msgid "%(model_name)s with this %(field_label)s already exists." +msgstr "" + +#. Translators: The 'lookup_type' is one of 'date', 'year' or +#. 'month'. Eg: "Title must be unique for pub_date year" +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:136 +#, python-format +msgid "" +"%(field_label)s must be unique for %(date_field_label)s %(lookup_type)s." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:174 +#, python-format +msgid "Field of type: %(field_type)s" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:1065 +#, python-format +msgid "“%(value)s” value must be either True or False." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:1066 +#, python-format +msgid "“%(value)s” value must be either True, False, or None." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:1068 +msgid "Boolean (Either True or False)" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:1118 +#, python-format +msgid "String (up to %(max_length)s)" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:1222 +msgid "Comma-separated integers" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:1323 +#, python-format +msgid "" +"“%(value)s” value has an invalid date format. It must be in YYYY-MM-DD " +"format." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:1327 +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:1462 +#, python-format +msgid "" +"“%(value)s” value has the correct format (YYYY-MM-DD) but it is an invalid " +"date." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:1331 +msgid "Date (without time)" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:1458 +#, python-format +msgid "" +"“%(value)s” value has an invalid format. It must be in YYYY-MM-DD HH:MM[:ss[." +"uuuuuu]][TZ] format." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:1466 +#, python-format +msgid "" +"“%(value)s” value has the correct format (YYYY-MM-DD HH:MM[:ss[.uuuuuu]]" +"[TZ]) but it is an invalid date/time." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:1471 +msgid "Date (with time)" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:1595 +#, python-format +msgid "“%(value)s” value must be a decimal number." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:1597 +msgid "Decimal number" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:1754 +#, python-format +msgid "" +"“%(value)s” value has an invalid format. It must be in [DD] [[HH:]MM:]ss[." +"uuuuuu] format." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:1758 +msgid "Duration" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:1810 +msgid "Email address" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:1835 +msgid "File path" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:1913 +#, python-format +msgid "“%(value)s” value must be a float." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:1915 +msgid "Floating point number" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:1955 +#, python-format +msgid "“%(value)s” value must be an integer." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:1957 +msgid "Integer" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:2049 +msgid "Big (8 byte) integer" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:2066 +msgid "Small integer" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:2074 +msgid "IPv4 address" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:2105 +msgid "IP address" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:2198 +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:2199 +#, python-format +msgid "“%(value)s” value must be either None, True or False." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:2201 +msgid "Boolean (Either True, False or None)" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:2252 +msgid "Positive big integer" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:2267 +msgid "Positive integer" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:2282 +msgid "Positive small integer" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:2298 +#, python-format +msgid "Slug (up to %(max_length)s)" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:2334 +msgid "Text" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:2409 +#, python-format +msgid "" +"“%(value)s” value has an invalid format. It must be in HH:MM[:ss[.uuuuuu]] " +"format." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:2413 +#, python-format +msgid "" +"“%(value)s” value has the correct format (HH:MM[:ss[.uuuuuu]]) but it is an " +"invalid time." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:2417 +msgid "Time" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:2525 +msgid "URL" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:2549 +msgid "Raw binary data" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:2614 +#, python-format +msgid "“%(value)s” is not a valid UUID." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/__init__.py:2616 +msgid "Universally unique identifier" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/files.py:231 +msgid "File" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/files.py:391 +msgid "Image" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/json.py:18 +msgid "A JSON object" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/json.py:20 +msgid "Value must be valid JSON." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/related.py:918 +#, python-format +msgid "%(model)s instance with %(field)s %(value)r does not exist." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/related.py:920 +msgid "Foreign Key (type determined by related field)" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/related.py:1227 +msgid "One-to-one relationship" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/related.py:1284 +#, python-format +msgid "%(from)s-%(to)s relationship" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/related.py:1286 +#, python-format +msgid "%(from)s-%(to)s relationships" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/db/models/fields/related.py:1334 +msgid "Many-to-many relationship" +msgstr "" + +#. Translators: If found as last label character, these punctuation +#. characters will prevent the default label_suffix to be appended to the label +#: venv/lib/python3.10/site-packages/django/forms/boundfield.py:176 +msgid ":?.!" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/fields.py:91 +msgid "This field is required." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/fields.py:298 +msgid "Enter a whole number." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/fields.py:467 +#: venv/lib/python3.10/site-packages/django/forms/fields.py:1240 +msgid "Enter a valid date." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/fields.py:490 +#: venv/lib/python3.10/site-packages/django/forms/fields.py:1241 +msgid "Enter a valid time." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/fields.py:517 +msgid "Enter a valid date/time." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/fields.py:551 +msgid "Enter a valid duration." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/fields.py:552 +#, python-brace-format +msgid "The number of days must be between {min_days} and {max_days}." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/fields.py:618 +msgid "No file was submitted. Check the encoding type on the form." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/fields.py:619 +msgid "No file was submitted." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/fields.py:620 +msgid "The submitted file is empty." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/fields.py:622 +#, python-format +msgid "Ensure this filename has at most %(max)d character (it has %(length)d)." +msgid_plural "" +"Ensure this filename has at most %(max)d characters (it has %(length)d)." +msgstr[0] "" +msgstr[1] "" + +#: venv/lib/python3.10/site-packages/django/forms/fields.py:627 +msgid "Please either submit a file or check the clear checkbox, not both." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/fields.py:693 +msgid "" +"Upload a valid image. The file you uploaded was either not an image or a " +"corrupted image." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/fields.py:856 +#: venv/lib/python3.10/site-packages/django/forms/fields.py:948 +#: venv/lib/python3.10/site-packages/django/forms/models.py:1572 +#, python-format +msgid "Select a valid choice. %(value)s is not one of the available choices." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/fields.py:950 +#: venv/lib/python3.10/site-packages/django/forms/fields.py:1069 +#: venv/lib/python3.10/site-packages/django/forms/models.py:1570 +msgid "Enter a list of values." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/fields.py:1070 +msgid "Enter a complete value." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/fields.py:1309 +msgid "Enter a valid UUID." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/fields.py:1339 +msgid "Enter a valid JSON." +msgstr "" + +#. Translators: This is the default suffix added to form field labels +#: venv/lib/python3.10/site-packages/django/forms/forms.py:98 +msgid ":" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/forms.py:248 +#: venv/lib/python3.10/site-packages/django/forms/forms.py:332 +#, python-format +msgid "(Hidden field %(name)s) %(error)s" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/formsets.py:63 +#, python-format +msgid "" +"ManagementForm data is missing or has been tampered with. Missing fields: " +"%(field_names)s. You may need to file a bug report if the issue persists." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/formsets.py:67 +#, python-format +msgid "Please submit at most %(num)d form." +msgid_plural "Please submit at most %(num)d forms." +msgstr[0] "" +msgstr[1] "" + +#: venv/lib/python3.10/site-packages/django/forms/formsets.py:72 +#, python-format +msgid "Please submit at least %(num)d form." +msgid_plural "Please submit at least %(num)d forms." +msgstr[0] "" +msgstr[1] "" + +#: venv/lib/python3.10/site-packages/django/forms/formsets.py:483 +#: venv/lib/python3.10/site-packages/django/forms/formsets.py:490 +msgid "Order" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/formsets.py:496 +msgid "Delete" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/models.py:892 +#, python-format +msgid "Please correct the duplicate data for %(field)s." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/models.py:897 +#, python-format +msgid "Please correct the duplicate data for %(field)s, which must be unique." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/models.py:904 +#, python-format +msgid "" +"Please correct the duplicate data for %(field_name)s which must be unique " +"for the %(lookup)s in %(date_field)s." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/models.py:913 +msgid "Please correct the duplicate values below." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/models.py:1344 +msgid "The inline value did not match the parent instance." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/models.py:1435 +msgid "Select a valid choice. That choice is not one of the available choices." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/models.py:1574 +#, python-format +msgid "“%(pk)s” is not a valid value." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/utils.py:226 +#, python-format +msgid "" +"%(datetime)s couldn’t be interpreted in time zone %(current_timezone)s; it " +"may be ambiguous or it may not exist." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/widgets.py:439 +msgid "Clear" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/widgets.py:440 +msgid "Currently" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/widgets.py:441 +msgid "Change" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/widgets.py:769 +msgid "Unknown" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/widgets.py:770 +msgid "Yes" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/forms/widgets.py:771 +msgid "No" +msgstr "" + +#. Translators: Please do not add spaces around commas. +#: venv/lib/python3.10/site-packages/django/template/defaultfilters.py:853 +msgid "yes,no,maybe" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/template/defaultfilters.py:883 +#: venv/lib/python3.10/site-packages/django/template/defaultfilters.py:900 +#, python-format +msgid "%(size)d byte" +msgid_plural "%(size)d bytes" +msgstr[0] "" +msgstr[1] "" + +#: venv/lib/python3.10/site-packages/django/template/defaultfilters.py:902 +#, python-format +msgid "%s KB" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/template/defaultfilters.py:904 +#, python-format +msgid "%s MB" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/template/defaultfilters.py:906 +#, python-format +msgid "%s GB" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/template/defaultfilters.py:908 +#, python-format +msgid "%s TB" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/template/defaultfilters.py:910 +#, python-format +msgid "%s PB" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dateformat.py:77 +msgid "p.m." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dateformat.py:78 +msgid "a.m." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dateformat.py:83 +msgid "PM" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dateformat.py:84 +msgid "AM" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dateformat.py:155 +msgid "midnight" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dateformat.py:157 +msgid "noon" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:7 +msgid "Monday" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:8 +msgid "Tuesday" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:9 +msgid "Wednesday" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:10 +msgid "Thursday" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:11 +msgid "Friday" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:12 +msgid "Saturday" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:13 +msgid "Sunday" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:16 +msgid "Mon" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:17 +msgid "Tue" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:18 +msgid "Wed" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:19 +msgid "Thu" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:20 +msgid "Fri" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:21 +msgid "Sat" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:22 +msgid "Sun" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:25 +msgid "January" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:26 +msgid "February" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:27 +msgid "March" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:28 +msgid "April" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:29 +msgid "May" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:30 +msgid "June" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:31 +msgid "July" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:32 +msgid "August" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:33 +msgid "September" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:34 +msgid "October" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:35 +msgid "November" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:36 +msgid "December" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:39 +msgid "jan" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:40 +msgid "feb" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:41 +msgid "mar" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:42 +msgid "apr" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:43 +msgid "may" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:44 +msgid "jun" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:45 +msgid "jul" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:46 +msgid "aug" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:47 +msgid "sep" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:48 +msgid "oct" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:49 +msgid "nov" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:50 +msgid "dec" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:53 +msgctxt "abbrev. month" +msgid "Jan." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:54 +msgctxt "abbrev. month" +msgid "Feb." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:55 +msgctxt "abbrev. month" +msgid "March" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:56 +msgctxt "abbrev. month" +msgid "April" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:57 +msgctxt "abbrev. month" +msgid "May" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:58 +msgctxt "abbrev. month" +msgid "June" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:59 +msgctxt "abbrev. month" +msgid "July" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:60 +msgctxt "abbrev. month" +msgid "Aug." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:61 +msgctxt "abbrev. month" +msgid "Sept." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:62 +msgctxt "abbrev. month" +msgid "Oct." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:63 +msgctxt "abbrev. month" +msgid "Nov." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:64 +msgctxt "abbrev. month" +msgid "Dec." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:67 +msgctxt "alt. month" +msgid "January" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:68 +msgctxt "alt. month" +msgid "February" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:69 +msgctxt "alt. month" +msgid "March" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:70 +msgctxt "alt. month" +msgid "April" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:71 +msgctxt "alt. month" +msgid "May" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:72 +msgctxt "alt. month" +msgid "June" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:73 +msgctxt "alt. month" +msgid "July" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:74 +msgctxt "alt. month" +msgid "August" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:75 +msgctxt "alt. month" +msgid "September" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:76 +msgctxt "alt. month" +msgid "October" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:77 +msgctxt "alt. month" +msgid "November" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/dates.py:78 +msgctxt "alt. month" +msgid "December" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/ipv6.py:8 +msgid "This is not a valid IPv6 address." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/text.py:76 +#, python-format +msgctxt "String to return when truncating text" +msgid "%(truncated_text)s…" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/text.py:252 +msgid "or" +msgstr "" + +#. Translators: This string is used as a separator between list elements +#: venv/lib/python3.10/site-packages/django/utils/text.py:271 +#: venv/lib/python3.10/site-packages/django/utils/timesince.py:94 +msgid ", " +msgstr "" + +#: venv/lib/python3.10/site-packages/django/utils/timesince.py:9 +#, python-format +msgid "%(num)d year" +msgid_plural "%(num)d years" +msgstr[0] "" +msgstr[1] "" + +#: venv/lib/python3.10/site-packages/django/utils/timesince.py:10 +#, python-format +msgid "%(num)d month" +msgid_plural "%(num)d months" +msgstr[0] "" +msgstr[1] "" + +#: venv/lib/python3.10/site-packages/django/utils/timesince.py:11 +#, python-format +msgid "%(num)d week" +msgid_plural "%(num)d weeks" +msgstr[0] "" +msgstr[1] "" + +#: venv/lib/python3.10/site-packages/django/utils/timesince.py:12 +#, python-format +msgid "%(num)d day" +msgid_plural "%(num)d days" +msgstr[0] "" +msgstr[1] "" + +#: venv/lib/python3.10/site-packages/django/utils/timesince.py:13 +#, python-format +msgid "%(num)d hour" +msgid_plural "%(num)d hours" +msgstr[0] "" +msgstr[1] "" + +#: venv/lib/python3.10/site-packages/django/utils/timesince.py:14 +#, python-format +msgid "%(num)d minute" +msgid_plural "%(num)d minutes" +msgstr[0] "" +msgstr[1] "" + +#: venv/lib/python3.10/site-packages/django/views/csrf.py:111 +msgid "Forbidden" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/csrf.py:112 +msgid "CSRF verification failed. Request aborted." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/csrf.py:116 +msgid "" +"You are seeing this message because this HTTPS site requires a “Referer " +"header” to be sent by your web browser, but none was sent. This header is " +"required for security reasons, to ensure that your browser is not being " +"hijacked by third parties." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/csrf.py:122 +msgid "" +"If you have configured your browser to disable “Referer” headers, please re-" +"enable them, at least for this site, or for HTTPS connections, or for “same-" +"origin” requests." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/csrf.py:127 +msgid "" +"If you are using the tag or " +"including the “Referrer-Policy: no-referrer” header, please remove them. The " +"CSRF protection requires the “Referer” header to do strict referer checking. " +"If you’re concerned about privacy, use alternatives like for links to third-party sites." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/csrf.py:136 +msgid "" +"You are seeing this message because this site requires a CSRF cookie when " +"submitting forms. This cookie is required for security reasons, to ensure " +"that your browser is not being hijacked by third parties." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/csrf.py:142 +msgid "" +"If you have configured your browser to disable cookies, please re-enable " +"them, at least for this site, or for “same-origin” requests." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/csrf.py:148 +msgid "More information is available with DEBUG=True." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/generic/dates.py:44 +msgid "No year specified" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/generic/dates.py:64 +#: venv/lib/python3.10/site-packages/django/views/generic/dates.py:115 +#: venv/lib/python3.10/site-packages/django/views/generic/dates.py:214 +msgid "Date out of range" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/generic/dates.py:94 +msgid "No month specified" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/generic/dates.py:147 +msgid "No day specified" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/generic/dates.py:194 +msgid "No week specified" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/generic/dates.py:349 +#: venv/lib/python3.10/site-packages/django/views/generic/dates.py:380 +#, python-format +msgid "No %(verbose_name_plural)s available" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/generic/dates.py:652 +#, python-format +msgid "" +"Future %(verbose_name_plural)s not available because %(class_name)s." +"allow_future is False." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/generic/dates.py:692 +#, python-format +msgid "Invalid date string “%(datestr)s” given format “%(format)s”" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/generic/detail.py:56 +#, python-format +msgid "No %(verbose_name)s found matching the query" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/generic/list.py:70 +msgid "Page is not “last”, nor can it be converted to an int." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/generic/list.py:77 +#, python-format +msgid "Invalid page (%(page_number)s): %(message)s" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/generic/list.py:169 +#, python-format +msgid "Empty list and “%(class_name)s.allow_empty” is False." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/static.py:38 +msgid "Directory indexes are not allowed here." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/static.py:40 +#, python-format +msgid "“%(path)s” does not exist" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/static.py:79 +#, python-format +msgid "Index of %(directory)s" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/templates/default_urlconf.html:7 +#: venv/lib/python3.10/site-packages/django/views/templates/default_urlconf.html:221 +msgid "The install worked successfully! Congratulations!" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/templates/default_urlconf.html:207 +#, python-format +msgid "" +"View release notes for Django %(version)s" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/templates/default_urlconf.html:222 +#, python-format +msgid "" +"You are seeing this page because DEBUG=True is in your settings file and you have not " +"configured any URLs." +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/templates/default_urlconf.html:230 +msgid "Django Documentation" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/templates/default_urlconf.html:231 +msgid "Topics, references, & how-to’s" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/templates/default_urlconf.html:239 +msgid "Tutorial: A Polling App" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/templates/default_urlconf.html:240 +msgid "Get started with Django" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/templates/default_urlconf.html:248 +msgid "Django Community" +msgstr "" + +#: venv/lib/python3.10/site-packages/django/views/templates/default_urlconf.html:249 +msgid "Connect, get help, or contribute" +msgstr "" diff --git a/backend/manage.py b/backend/manage.py index 8e7ac79b..d28672ea 100755 --- a/backend/manage.py +++ b/backend/manage.py @@ -6,7 +6,7 @@ def main(): """Run administrative tasks.""" - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") try: from django.core.management import execute_from_command_line except ImportError as exc: @@ -18,5 +18,5 @@ def main(): execute_from_command_line(sys.argv) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/backend/manual/apps.py b/backend/manual/apps.py deleted file mode 100644 index 327f081b..00000000 --- a/backend/manual/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class ManualConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'manual' diff --git a/backend/manual/lorem-ipsum.pdf b/backend/manual/lorem-ipsum.pdf new file mode 100644 index 00000000..22ace57c Binary files /dev/null and b/backend/manual/lorem-ipsum.pdf differ diff --git a/backend/manual/tests.py b/backend/manual/tests.py index 7ce503c2..41441406 100644 --- a/backend/manual/tests.py +++ b/backend/manual/tests.py @@ -1,3 +1,93 @@ -from django.test import TestCase +from base.models import Manual +from base.serializers import ManualSerializer +from util.data_generators import insert_dummy_building, createMemoryFile, insert_dummy_manual +from util.test_tools import BaseTest, BaseAuthTest -# Create your tests here. +f = createMemoryFile("./manual/lorem-ipsum.pdf") + + +class ManualTests(BaseTest): + def __init__(self, methodName="runTest"): + super().__init__(methodName) + + def test_empty_manual_list(self): + self.empty_list("manual/") + + def test_insert_manual(self): + b_id = insert_dummy_building() + self.data1 = {"building": b_id, "file": f} + self.insert("manual/") + + def test_insert_empty(self): + self.insert_empty("manual/") + + def test_insert_dupe_manual(self): + b_id = insert_dummy_building() + self.data1 = {"building": b_id, "file": f, "version_number": 0} + self.insert_dupe("manual/", special=201) + + def test_get_manual(self): + m_id = insert_dummy_manual() + data = ManualSerializer(Manual.objects.get(id=m_id)).data + self.get(f"manual/{m_id}", data) + + def test_get_non_existing(self): + self.get_non_existent("manual/") + + def test_patch_manual(self): + m_id = insert_dummy_manual() + b_id = insert_dummy_building() + self.data1 = {"building": b_id, "file": f, "version_number": 1} + self.patch(f"manual/{m_id}", special=[("version_number", 0)]) + + def test_patch_invalid_manual(self): + b_id = insert_dummy_building() + self.data1 = {"building": b_id, "file": f, "version_number": 0} + self.patch_invalid("manual/") + + def test_patch_error_manual(self): + b_id = insert_dummy_building() + self.data1 = {"building": b_id, "file": f, "version_number": 0} + self.data2 = {"building": b_id, "file": f, "version_number": 1} + self.patch_error("manual/", special=200) + + def test_remove_manual(self): + m_id = insert_dummy_manual() + self.remove(f"manual/{m_id}") + + def test_remove_nonexistent_manual(self): + self.remove_invalid("manual/") + + +class ManualAuthorizationTests(BaseAuthTest): + def __init__(self, methodName="runTest"): + super().__init__(methodName) + + def test_manual_list(self): + codes = {"Default": 403, "Admin": 200, "Superstudent": 200, "Student": 403, "Syndic": 403} + self.list_view("manual/", codes) + + def test_insert_manual(self): + codes = {"Default": 403, "Admin": 201, "Superstudent": 201, "Student": 403, "Syndic": 201} + b_id = insert_dummy_building() + self.data1 = {"building": b_id, "file": f, "version_number": 0} + self.insert_view("manual/", codes) + + def test_get_manual(self): + codes = {"Default": 403, "Admin": 200, "Superstudent": 200, "Student": 200, "Syndic": 403} + m_id = insert_dummy_manual() + self.get_view(f"manual/{m_id}", codes) + + def test_patch_manual(self): + codes = {"Default": 403, "Admin": 200, "Superstudent": 200, "Student": 403, "Syndic": 403} + m_id = insert_dummy_manual() + b_id = insert_dummy_building() + self.data1 = {"building": b_id, "file": f, "version_number": 1} + self.patch_view(f"manual/{m_id}", codes) + + def test_remove_manual(self): + def create(): + return insert_dummy_manual() + + codes = {"Default": 403, "Admin": 204, "Superstudent": 204, "Student": 403, "Syndic": 403} + self.remove_view("manual/", codes, create=create) diff --git a/backend/manual/urls.py b/backend/manual/urls.py index 400911ef..bd072321 100644 --- a/backend/manual/urls.py +++ b/backend/manual/urls.py @@ -1,15 +1,10 @@ from django.urls import path -from .views import ( - ManualView, - ManualBuildingView, - ManualsView, - Default -) +from .views import ManualView, ManualBuildingView, ManualsView, Default urlpatterns = [ - path('/', ManualView.as_view()), - path('building//', ManualBuildingView.as_view()), - path('all/', ManualsView.as_view()), - path('', Default.as_view()) + path("/", ManualView.as_view()), + path("building//", ManualBuildingView.as_view()), + path("all/", ManualsView.as_view()), + path("", Default.as_view()), ] diff --git a/backend/manual/views.py b/backend/manual/views.py index 54fec412..861aa1c6 100644 --- a/backend/manual/views.py +++ b/backend/manual/views.py @@ -1,21 +1,29 @@ +from drf_spectacular.utils import extend_schema +from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request from rest_framework.views import APIView from base.models import Manual, Building +from base.permissions import ( + IsAdmin, + IsSuperStudent, + IsSyndic, + OwnerOfBuilding, + ReadOnlyStudent, + ReadOnlyManualFromSyndic, +) from base.serializers import ManualSerializer from util.request_response_util import * -from drf_spectacular.utils import extend_schema TRANSLATE = {"building": "building_id"} class Default(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent | IsSyndic] serializer_class = ManualSerializer - @extend_schema( - responses={201: ManualSerializer, - 400: None} - ) - def post(self, request): + @extend_schema(responses=post_docs(ManualSerializer)) + def post(self, request: Request): """ Create a new manual with data from post """ @@ -31,47 +39,43 @@ def post(self, request): class ManualView(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent | ReadOnlyStudent | ReadOnlyManualFromSyndic] serializer_class = ManualSerializer - @extend_schema( - responses={200: ManualSerializer, - 400: None} - ) + @extend_schema(responses=get_docs(ManualSerializer)) def get(self, request, manual_id): """ Get info about a manual with given id """ manual_instances = Manual.objects.filter(id=manual_id) if len(manual_instances) != 1: - return bad_request("Manual") - serializer = ManualSerializer(manual_instances[0]) + return not_found("Manual") + manual_instance = manual_instances[0] + + self.check_object_permissions(request, manual_instance) + + serializer = ManualSerializer(manual_instance) return get_success(serializer) - @extend_schema( - responses={204: None, - 400: None} - ) + @extend_schema(responses=delete_docs()) def delete(self, request, manual_id): """ Delete manual with given id """ manual_instances = Manual.objects.filter(id=manual_id) if len(manual_instances) != 1: - return bad_request("Manual") + return not_found("Manual") manual_instances[0].delete() return delete_success() - @extend_schema( - responses={200: ManualSerializer, - 400: None} - ) + @extend_schema(responses=patch_docs(ManualSerializer)) def patch(self, request, manual_id): """ Edit info about a manual with given id """ manual_instances = Manual.objects.filter(id=manual_id) if len(manual_instances) != 1: - return bad_request("Manual") + return not_found("Manual") manual_instance = manual_instances[0] data = request_to_dict(request.data) @@ -84,25 +88,37 @@ def patch(self, request, manual_id): class ManualBuildingView(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent | ReadOnlyStudent | OwnerOfBuilding] serializer_class = ManualSerializer @extend_schema( - responses={200: ManualSerializer, - 400: None} + responses=get_docs(ManualSerializer), + parameters=param_docs(get_most_recent_param_docs("manual")), ) def get(self, request, building_id): """ Get all manuals of a building with given id """ + if not Building.objects.filter(id=building_id): - return bad_request("Building") + return not_found("Building") + + try: + most_recent_only = get_boolean_param(request, "most-recent") + except BadRequest as e: + return Response({"message": str(e)}, status=status.HTTP_400_BAD_REQUEST) manual_instances = Manual.objects.filter(building_id=building_id) - serializer = ManualSerializer(manual_instances, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) + + if most_recent_only: + manual_instances = manual_instances.order_by("-version_number").first() + + serializer = ManualSerializer(manual_instances, many=not most_recent_only) + return get_success(serializer) class ManualsView(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent] serializer_class = ManualSerializer def get(self, request): @@ -112,4 +128,4 @@ def get(self, request): instances = Manual.objects.all() serializer = ManualSerializer(instances, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) + return get_success(serializer) diff --git a/backend/picture_building/apps.py b/backend/picture_building/apps.py deleted file mode 100644 index 43ea1414..00000000 --- a/backend/picture_building/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class PictureBuildingConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'picture_building' diff --git a/backend/picture_building/tests.py b/backend/picture_building/tests.py deleted file mode 100644 index 7ce503c2..00000000 --- a/backend/picture_building/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/backend/picture_building/urls.py b/backend/picture_building/urls.py deleted file mode 100644 index c89ad4e3..00000000 --- a/backend/picture_building/urls.py +++ /dev/null @@ -1,15 +0,0 @@ -from django.urls import path - -from .views import ( - PictureBuildingIndividualView, - PicturesOfBuildingView, - AllPictureBuildingsView, - Default -) - -urlpatterns = [ - path('/', PictureBuildingIndividualView.as_view()), - path('building//', PicturesOfBuildingView.as_view()), - path('all/', AllPictureBuildingsView.as_view()), - path('', Default.as_view()) -] diff --git a/backend/picture_building/views.py b/backend/picture_building/views.py deleted file mode 100644 index 76740c94..00000000 --- a/backend/picture_building/views.py +++ /dev/null @@ -1,111 +0,0 @@ -from rest_framework.views import APIView - -from base.models import PictureBuilding -from base.serializers import PictureBuildingSerializer -from util.request_response_util import * -from drf_spectacular.utils import extend_schema - -TRANSLATE = {"building": "building_id"} - - -class Default(APIView): - serializer_class = PictureBuildingSerializer - - @extend_schema( - responses={201: PictureBuildingSerializer, - 400: None} - ) - def post(self, request): - """ - Create a new PictureBuilding - """ - data = request_to_dict(request.data) - picture_building_instance = PictureBuilding() - - set_keys_of_instance(picture_building_instance, data, TRANSLATE) - - if r := try_full_clean_and_save(picture_building_instance): - return r - - return post_success(PictureBuildingSerializer(picture_building_instance)) - - -class PictureBuildingIndividualView(APIView): - serializer_class = PictureBuildingSerializer - - @extend_schema( - responses={200: PictureBuildingSerializer, - 400: None} - ) - def get(self, request, picture_building_id): - """ - Get PictureBuilding with given id - """ - picture_building_instance = PictureBuilding.objects.filter(id=picture_building_id) - - if len(picture_building_instance) != 1: - return bad_request("PictureBuilding") - serializer = PictureBuildingSerializer(picture_building_instance[0]) - return get_success(serializer) - - @extend_schema( - responses={200: PictureBuildingSerializer, - 400: None} - ) - def patch(self, request, picture_building_id): - """ - Edit info about PictureBuilding with given id - """ - picture_building_instance = PictureBuilding.objects.filter(id=picture_building_id) - if not picture_building_instance: - return bad_request("PictureBuilding") - - picture_building_instance = picture_building_instance[0] - - data = request_to_dict(request.data) - - set_keys_of_instance(picture_building_instance, data, TRANSLATE) - - if r := try_full_clean_and_save(picture_building_instance): - return r - - return patch_success(PictureBuildingSerializer(picture_building_instance)) - - @extend_schema( - responses={204: None, - 400: None} - ) - def delete(self, request, picture_building_id): - """ - delete a pictureBuilding from the database - """ - picture_building_instance = PictureBuilding.objects.filter(id=picture_building_id) - if len(picture_building_instance) != 1: - return bad_request("PictureBuilding") - picture_building_instance[0].delete() - return delete_success() - - -class PicturesOfBuildingView(APIView): - serializer_class = PictureBuildingSerializer - - def get(self, request, building_id): - """ - Get all pictures of a building with given id - """ - picture_building_instances = PictureBuilding.objects.filter(building_id=building_id) - serializer = PictureBuildingSerializer(picture_building_instances, many=True) - return get_success(serializer) - - -class AllPictureBuildingsView(APIView): - serializer_class = PictureBuildingSerializer - - def get(self, request): - """ - Get all pictureBuilding - """ - picture_building_instances = PictureBuilding.objects.all() - - serializer = PictureBuildingSerializer(picture_building_instances, many=True) - return get_success(serializer) diff --git a/backend/picture_building/__init__.py b/backend/picture_of_remark/__init__.py similarity index 100% rename from backend/picture_building/__init__.py rename to backend/picture_of_remark/__init__.py diff --git a/backend/picture_of_remark/scrambled1.png b/backend/picture_of_remark/scrambled1.png new file mode 100644 index 00000000..d6bc01ce Binary files /dev/null and b/backend/picture_of_remark/scrambled1.png differ diff --git a/backend/picture_of_remark/scrambled2.png b/backend/picture_of_remark/scrambled2.png new file mode 100644 index 00000000..5e339372 Binary files /dev/null and b/backend/picture_of_remark/scrambled2.png differ diff --git a/backend/picture_of_remark/tests.py b/backend/picture_of_remark/tests.py new file mode 100644 index 00000000..06bf6977 --- /dev/null +++ b/backend/picture_of_remark/tests.py @@ -0,0 +1,98 @@ +from base.models import PictureOfRemark, RemarkAtBuilding +from base.serializers import PictureOfRemarkSerializer +from util.data_generators import insert_dummy_remark_at_building, createMemoryFile, insert_dummy_picture_of_remark +from util.test_tools import BaseTest, BaseAuthTest + +f = createMemoryFile("./picture_of_remark/scrambled1.png") +f2 = createMemoryFile("./picture_of_remark/scrambled2.png") + + +class PictureOfRemarkTests(BaseTest): + def __init__(self, methodName="runTest"): + super().__init__(methodName) + + def test_empty_picture_of_remark(self): + self.empty_list("picture-of-remark/") + + def test_insert_picture_of_remark(self): + self.data1 = {"picture": f, "remark_at_building": insert_dummy_remark_at_building()} + self.insert("picture-of-remark/") + + def test_insert_empty(self): + self.insert_empty("picture-of-remark/") + + def test_insert_dupe_picture_of_remark(self): + RaB = insert_dummy_remark_at_building() + self.data1 = {"picture": f, "remark_at_building": RaB} + self.insert_dupe("picture-of-remark/") + + def test_get_picture_of_remark(self): + p_id = insert_dummy_picture_of_remark(picture=f) + data = PictureOfRemarkSerializer(PictureOfRemark.objects.get(id=p_id)).data + self.get(f"picture-of-remark/{p_id}", data) + + def test_get_non_existing(self): + self.get_non_existent("picture-of-remark/") + + def test_patch_picture_of_remark(self): + p_id = insert_dummy_picture_of_remark(f) + RaB = insert_dummy_remark_at_building() + self.data1 = {"picture": f2, "remark_at_building": RaB} + self.patch(f"picture-of-remark/{p_id}") + + def test_patch_invalid_picture_of_remark(self): + RaB = insert_dummy_remark_at_building() + self.data1 = {"picture": f, "remark_at_building": RaB} + self.patch_invalid(f"picture-of-remark/") + + def test_patch_error_picture_of_remark(self): + RaB = insert_dummy_remark_at_building() + self.data1 = {"picture": f, "remark_at_building": RaB} + self.data2 = {"picture": f2, "remark_at_building": RaB} + self.patch_error(f"picture-of-remark/") + + def test_remove_picture_of_remark(self): + p_id = insert_dummy_picture_of_remark(f) + self.remove(f"picture-of-remark/{p_id}") + + def test_remove_non_existing_picture_of_remark(self): + self.remove_invalid("picture_of_remark/") + + +class PictureOfRemarkAuthorizationTests(BaseAuthTest): + def __init__(self, methodName="runTest"): + super().__init__(methodName) + + def test_picture_of_remark_list(self): + codes = {"Default": 403, "Admin": 200, "Superstudent": 200, "Student": 403, "Syndic": 403} + self.list_view("picture-of-remark/", codes) + + def test_insert_picture_of_remark(self): + codes = {"Default": 403, "Admin": 201, "Superstudent": 201, "Student": 403, "Syndic": 403} + RaB = insert_dummy_remark_at_building() + specialStudent = RemarkAtBuilding.objects.get(id=RaB).student_on_tour.student.id + self.data1 = {"picture": f, "remark_at_building": RaB} + self.insert_view("picture-of-remark/", codes, special=[(specialStudent, 201)]) + + def test_get_picture_of_remark(self): + codes = {"Default": 403, "Admin": 200, "Superstudent": 200, "Student": 403, "Syndic": 403} + PoR_id = insert_dummy_picture_of_remark(f) + specialStudent = PictureOfRemark.objects.get(id=PoR_id).remark_at_building.student_on_tour.student.id + self.get_view(f"picture-of-remark/{PoR_id}", codes, special=[(specialStudent, 200)]) + + def test_patch_picture_of_remark(self): + codes = {"Default": 403, "Admin": 200, "Superstudent": 200, "Student": 403, "Syndic": 403} + PoR = insert_dummy_picture_of_remark(f) + RaB = insert_dummy_remark_at_building() + specialStudent = RemarkAtBuilding.objects.get(id=RaB).student_on_tour.student.id + self.data1 = {"picture": f, "remark_at_building": RaB} + self.patch_view(f"picture-of-remark/{PoR}", codes, special=[(specialStudent, 200)]) + + def test_remove_picture_of_remark(self): + # testing the special case where the student of the tour removes the instance is very difficult, + # since there are always new instances created and there is no way to access them from here + def create(): + return insert_dummy_picture_of_remark(f) + + codes = {"Default": 403, "Admin": 204, "Superstudent": 204, "Student": 403, "Syndic": 403} + self.remove_view("picture-of-remark/", codes, create=create) diff --git a/backend/picture_of_remark/urls.py b/backend/picture_of_remark/urls.py new file mode 100644 index 00000000..df04b2e8 --- /dev/null +++ b/backend/picture_of_remark/urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from picture_of_remark.views import * + +urlpatterns = [ + path("/", PictureOfRemarkIndividualView.as_view()), + path("all/", AllPictureOfRemark.as_view()), + path("remark//", PicturesOfRemarkView.as_view()), + path("", Default.as_view()), +] diff --git a/backend/picture_of_remark/views.py b/backend/picture_of_remark/views.py new file mode 100644 index 00000000..92dd7687 --- /dev/null +++ b/backend/picture_of_remark/views.py @@ -0,0 +1,147 @@ +from drf_spectacular.utils import extend_schema +from rest_framework.permissions import IsAuthenticated +from rest_framework.views import APIView +import hashlib + +from base.models import PictureOfRemark +from base.permissions import IsAdmin, IsSuperStudent, IsStudent, OwnerAccount +from base.serializers import PictureOfRemarkSerializer +from util.request_response_util import ( + post_docs, + request_to_dict, + set_keys_of_instance, + try_full_clean_and_save, + get_success, + not_found, + delete_docs, + delete_success, + patch_docs, + patch_success, + get_docs, + post_success, + bad_request, +) + +TRANSLATE = {"remark_at_building": "remark_at_building_id"} + + +class Default(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent | (IsStudent & OwnerAccount)] + serializer_class = PictureOfRemarkSerializer + + @extend_schema(responses=post_docs(serializer_class)) + def post(self, request): + """ + Create a new PictureOfRemark with data from post + """ + data = request_to_dict(request.data) + picture_of_remark_instance = PictureOfRemark() + + if "picture" in data: + f = data["picture"] + hashed_image = hashlib.sha1() + hashed_image.update(f.open().read()) + data["hash"] = hashed_image.hexdigest() + + set_keys_of_instance(picture_of_remark_instance, data, TRANSLATE) + + if picture_of_remark_instance.remark_at_building is None: + return bad_request("pictureOfRemark") + + self.check_object_permissions(request, picture_of_remark_instance.remark_at_building.student_on_tour.student) + + if r := try_full_clean_and_save(picture_of_remark_instance): + return r + + return post_success(self.serializer_class(picture_of_remark_instance)) + + +class PictureOfRemarkIndividualView(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent | (IsStudent & OwnerAccount)] + serializer_class = PictureOfRemarkSerializer + + @extend_schema(responses=get_success(serializer_class)) + def get(self, request, picture_of_remark): + """ + Get info about a remark at building with a given id + """ + picture_of_remark = PictureOfRemark.objects.filter(id=picture_of_remark).first() + + if not picture_of_remark: + return not_found(object_name="PictureOfRemark") + + self.check_object_permissions(request, picture_of_remark.remark_at_building.student_on_tour.student) + + return get_success(self.serializer_class(picture_of_remark)) + + @extend_schema(responses=delete_docs()) + def delete(self, request, picture_of_remark): + """ + Delete remark at building with given id + """ + picture_of_remark_instance = PictureOfRemark.objects.filter(id=picture_of_remark).first() + if not picture_of_remark_instance: + return not_found(object_name="RemarkAtBuilding") + + self.check_object_permissions(request, picture_of_remark_instance.remark_at_building.student_on_tour.student) + + picture_of_remark_instance.delete() + return delete_success() + + @extend_schema(responses=patch_docs(serializer_class)) + def patch(self, request, picture_of_remark): + """ + Edit building with given ID + """ + picture_of_remark_instance = PictureOfRemark.objects.filter(id=picture_of_remark).first() + if not picture_of_remark_instance: + return not_found(object_name="RemarkAtBuilding") + + self.check_object_permissions(request, picture_of_remark_instance.remark_at_building.student_on_tour.student) + + data = request_to_dict(request.data) + if "picture" in request.data: + f = request.data["picture"] + hashed_image = hashlib.sha1() + hashed_image.update(f.open().read()) + data["hash"] = hashed_image.hexdigest() + + set_keys_of_instance(picture_of_remark_instance, data, TRANSLATE) + + if r := try_full_clean_and_save(picture_of_remark_instance): + return r + + return patch_success(self.serializer_class(picture_of_remark_instance)) + + +class AllPictureOfRemark(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent] + serializer_class = PictureOfRemarkSerializer + + def get(self, request): + """ + Get all pictures from each remark + """ + picture_of_remark_instances = PictureOfRemark.objects.all() + return get_success(self.serializer_class(picture_of_remark_instances, many=True)) + + +class PicturesOfRemarkView(APIView): + permission_classes = ( + [] + ) # IsAuthenticated, IsAdmin | IsSuperStudent | OwnerAccount | (IsStudent & OwnerAccount)] Residents should also be able to see pictures + serializer_class = PictureOfRemarkSerializer + + @extend_schema(responses=get_docs(serializer_class)) + def get(self, request, remark_id): + """ + Get all pictures on a specific remark + """ + pic_of_remark_instances = PictureOfRemark.objects.filter(remark_at_building_id=remark_id) + if not pic_of_remark_instances: + return not_found("PictureOfRemark") + + for r in pic_of_remark_instances: + self.check_object_permissions(request, r.remark_at_building.student_on_tour.student) + + return get_success(self.serializer_class(pic_of_remark_instances, many=True)) diff --git a/backend/region/apps.py b/backend/region/apps.py deleted file mode 100644 index 4dc60ec4..00000000 --- a/backend/region/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class RegionConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'region' diff --git a/backend/region/tests.py b/backend/region/tests.py index 7ce503c2..fed070fb 100644 --- a/backend/region/tests.py +++ b/backend/region/tests.py @@ -1,3 +1,81 @@ -from django.test import TestCase +from base.models import Region +from base.serializers import RegionSerializer +from util.data_generators import insert_dummy_region +from util.test_tools import BaseTest, BaseAuthTest -# Create your tests here. + +class RegionTests(BaseTest): + def test_empty_region_list(self): + self.empty_list("region/") + + def test_insert_region(self): + self.data1 = {"region": "Gent"} + self.insert("region/") + + def test_insert_empty(self): + self.insert_empty("region/") + + def test_insert_dupe_region(self): + self.data1 = {"region": "Gent"} + self.insert_dupe("region/") + + def test_get_region(self): + r_id = insert_dummy_region() + data = RegionSerializer(Region.objects.get(id=r_id)).data + self.get(f"region/{r_id}", data) + + def test_get_non_existing(self): + self.get_non_existent("region/") + + def test_patch_region(self): + r_id = insert_dummy_region("Brugge") + self.data1 = {"region": "Gent"} + self.patch(f"region/{r_id}") + + def test_patch_invalid_region(self): + self.data1 = {"region": "Gent"} + self.patch_invalid("region/") + + def test_patch_error_region(self): + self.data1 = {"region": "Brugge"} + self.data2 = {"region": "Gent"} + self.patch_error("region/") + + def test_remove_region(self): + r_id = insert_dummy_region() + self.remove(f"region/{r_id}") + + def test_remove_non_existent_region(self): + self.remove_invalid("region/") + + +class RegionAuthorizationTests(BaseAuthTest): + def __init__(self, methodName="runTest"): + super().__init__(methodName) + + def test_region_list(self): + codes = {"Default": 403, "Admin": 200, "Superstudent": 200, "Student": 200, "Syndic": 403} + self.list_view("region/", codes) + + def test_insert_region(self): + codes = {"Default": 403, "Admin": 201, "Superstudent": 403, "Student": 403, "Syndic": 403} + self.data1 = {"region": "Gent"} + self.insert_view("region/", codes) + + def test_get_region(self): + codes = {"Default": 200, "Admin": 200, "Superstudent": 200, "Student": 200, "Syndic": 200} + r_id = insert_dummy_region() + self.get_view(f"region/{r_id}", codes) + + def test_patch_region(self): + codes = {"Default": 403, "Admin": 200, "Superstudent": 403, "Student": 403, "Syndic": 403} + r_id = insert_dummy_region("Bruhhe") + self.data1 = {"region": "Gent"} + self.patch_view(f"region/{r_id}", codes) + + def test_remove_region(self): + def create(): + return insert_dummy_region() + + codes = {"Default": 403, "Admin": 204, "Superstudent": 403, "Student": 403, "Syndic": 403} + self.remove_view("region/", codes, create=create) diff --git a/backend/region/urls.py b/backend/region/urls.py index 8888c868..ab0aca4c 100644 --- a/backend/region/urls.py +++ b/backend/region/urls.py @@ -1,13 +1,9 @@ from django.urls import path -from .views import ( - RegionIndividualView, - AllRegionsView, - Default -) +from .views import RegionIndividualView, AllRegionsView, Default urlpatterns = [ - path('/', RegionIndividualView.as_view()), - path('all/', AllRegionsView.as_view()), - path('', Default.as_view()) + path("/", RegionIndividualView.as_view()), + path("all/", AllRegionsView.as_view()), + path("", Default.as_view()), ] diff --git a/backend/region/views.py b/backend/region/views.py index 7b3f9774..09cd1d15 100644 --- a/backend/region/views.py +++ b/backend/region/views.py @@ -1,21 +1,18 @@ -from rest_framework import permissions +from drf_spectacular.utils import extend_schema +from rest_framework.permissions import IsAuthenticated from rest_framework.views import APIView from base.models import Region +from base.permissions import IsAdmin, ReadOnly, IsSuperStudent, IsStudent from base.serializers import RegionSerializer from util.request_response_util import * -from drf_spectacular.utils import extend_schema class Default(APIView): + permission_classes = [IsAuthenticated, IsAdmin] serializer_class = RegionSerializer - permission_classes = [permissions.IsAuthenticated] - - @extend_schema( - responses={201: RegionSerializer, - 400: None} - ) + @extend_schema(responses=post_docs(RegionSerializer)) def post(self, request): """ Create a new region @@ -34,14 +31,10 @@ def post(self, request): class RegionIndividualView(APIView): + permission_classes = [IsAuthenticated, IsAdmin | ReadOnly] serializer_class = RegionSerializer - permission_classes = [permissions.IsAuthenticated] - - @extend_schema( - responses={200: RegionSerializer, - 400: None} - ) + @extend_schema(responses=get_docs(RegionSerializer)) def get(self, request, region_id): """ Get info about a Region with given id @@ -49,15 +42,12 @@ def get(self, request, region_id): region_instance = Region.objects.filter(id=region_id) if len(region_instance) != 1: - return bad_request(object_name="Region") + return not_found(object_name="Region") serializer = RegionSerializer(region_instance[0]) return get_success(serializer) - @extend_schema( - responses={200: RegionSerializer, - 400: None} - ) + @extend_schema(responses=patch_docs(RegionSerializer)) def patch(self, request, region_id): """ Edit Region with given id @@ -65,7 +55,7 @@ def patch(self, request, region_id): region_instances = Region.objects.filter(id=region_id) if len(region_instances) != 1: - return bad_request(object_name="Region") + return not_found(object_name="Region") region_instance = region_instances[0] @@ -78,11 +68,7 @@ def patch(self, request, region_id): return patch_success(RegionSerializer(region_instance)) - - @extend_schema( - responses={204: None, - 400: None} - ) + @extend_schema(responses=delete_docs()) def delete(self, request, region_id): """ delete a region with given id @@ -90,17 +76,16 @@ def delete(self, request, region_id): region_instances = Region.objects.filter(id=region_id) if len(region_instances) != 1: - return bad_request(object_name="Region") + return not_found(object_name="Region") region_instances[0].delete() return delete_success() class AllRegionsView(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent | IsStudent] serializer_class = RegionSerializer - permission_classes = [permissions.IsAuthenticated] - def get(self, request): """ Get all regions diff --git a/backend/student_at_building_on_tour/__init__.py b/backend/remark_at_building/__init__.py similarity index 100% rename from backend/student_at_building_on_tour/__init__.py rename to backend/remark_at_building/__init__.py diff --git a/backend/remark_at_building/tests.py b/backend/remark_at_building/tests.py new file mode 100644 index 00000000..31ddf5f3 --- /dev/null +++ b/backend/remark_at_building/tests.py @@ -0,0 +1,142 @@ +from datetime import datetime + +import pytz + +from base.models import RemarkAtBuilding, StudentOnTour +from base.serializers import RemarkAtBuildingSerializer +from util.data_generators import insert_dummy_student_on_tour, insert_dummy_building, insert_dummy_remark_at_building +from util.test_tools import BaseTest, BaseAuthTest + + +class RemarkAtBuildingTests(BaseTest): + def __init__(self, methodName="runTest"): + super().__init__(methodName) + + def test_empty_remark_at_building_list(self): + self.empty_list("remark-at-building/") + + def test_insert_remark_at_building(self): + self.data1 = { + "student_on_tour": insert_dummy_student_on_tour(), + "building": insert_dummy_building(), + "timestamp": str(datetime.now(pytz.timezone("CET"))).replace(" ", "T"), + "remark": "illegal dumping", + "type": "AA", + } + self.insert("remark-at-building/") + + def test_insert_empty(self): + self.insert_empty("remark-at-building/") + + def test_insert_dupe_remark_at_building(self): + self.data1 = { + "student_on_tour": insert_dummy_student_on_tour(), + "building": insert_dummy_building(), + "timestamp": str(datetime.now(pytz.timezone("CET"))).replace(" ", "T"), + "remark": "illegal dumping", + "type": "AA", + } + self.insert_dupe("remark-at-building/") + + def test_get_remark_at_building(self): + r_id = insert_dummy_remark_at_building() + data = RemarkAtBuildingSerializer(RemarkAtBuilding.objects.get(id=r_id)).data + self.get(f"remark-at-building/{r_id}", data) + + def test_get_non_existing(self): + self.get_non_existent("remark-at-building/") + + def test_patch_remark_at_building(self): + r_id = insert_dummy_remark_at_building() + self.data1 = { + "student_on_tour": insert_dummy_student_on_tour(), + "building": insert_dummy_building(), + "timestamp": str(datetime.now(pytz.timezone("CET"))).replace(" ", "T"), + "remark": "couldn't enter the building", + "type": "AA", + } + self.patch(f"remark-at-building/{r_id}") + + def test_patch_invalid_remark_at_building(self): + self.data1 = { + "student_on_tour": insert_dummy_student_on_tour(), + "building": insert_dummy_building(), + "timestamp": str(datetime.now(pytz.timezone("CET"))).replace(" ", "T"), + "remark": "couldn't enter the building", + "type": "AA", + } + self.patch_invalid(f"remark-at-building/") + + def test_patch_error_remark_at_building(self): + self.data1 = { + "student_on_tour": insert_dummy_student_on_tour(), + "building": insert_dummy_building(), + "timestamp": str(datetime.now(pytz.timezone("CET"))).replace(" ", "T"), + "remark": "couldn't enter the building", + "type": "AA", + } + self.data2 = { + "student_on_tour": insert_dummy_student_on_tour(), + "building": insert_dummy_building(), + "timestamp": str(datetime.now(pytz.timezone("CET"))).replace(" ", "T"), + "remark": "code was wrong", + "type": "AA", + } + self.patch_error(f"remark-at-building/") + + def test_remove_remark_at_building(self): + r_id = insert_dummy_remark_at_building() + self.remove(f"remark-at-building/{r_id}") + + def test_remove_non_existing_remark_at_building(self): + self.remove_invalid("remark-at-building/") + + +class RemarkAtBuildingAuthorizationTests(BaseAuthTest): + def __init__(self, methodName="runTest"): + super().__init__(methodName) + + def test_remark_at_building_list(self): + codes = {"Default": 403, "Admin": 200, "Superstudent": 200, "Student": 403, "Syndic": 403} + self.list_view("remark-at-building/", codes) + + def test_insert_remark_at_building(self): + codes = {"Default": 403, "Admin": 201, "Superstudent": 201, "Student": 403, "Syndic": 403} + SoT = insert_dummy_student_on_tour() + specialStudent = StudentOnTour.objects.get(id=SoT).student.id + self.data1 = { + "student_on_tour": SoT, + "building": insert_dummy_building(), + "timestamp": str(datetime.now(pytz.timezone("CET"))).replace(" ", "T"), + "remark": "no bins present", + "type": "AA", + } + self.insert_view("remark-at-building/", codes, special=[(specialStudent, 201)]) + + def test_get_remark_at_building(self): + codes = {"Default": 403, "Admin": 200, "Superstudent": 200, "Student": 403, "Syndic": 403} + RaB_id = insert_dummy_remark_at_building() + specialStudent = RemarkAtBuilding.objects.get(id=RaB_id).student_on_tour.student.id + self.get_view(f"remark-at-building/{RaB_id}", codes, special=[(specialStudent, 200)]) + + def test_patch_remark_at_building(self): + codes = {"Default": 403, "Admin": 200, "Superstudent": 200, "Student": 403, "Syndic": 403} + RaB_id = insert_dummy_remark_at_building() + specialStudent = RemarkAtBuilding.objects.get(id=RaB_id).student_on_tour.student.id + self.data1 = { + "student_on_tour": insert_dummy_student_on_tour(), + "building": insert_dummy_building(), + "timestamp": str(datetime.now(pytz.timezone("CET"))).replace(" ", "T"), + "remark": "no bins present", + "type": "AA", + } + self.patch_view(f"remark-at-building/{RaB_id}", codes, special=[(specialStudent, 200)]) + + def test_remove_remark_at_building(self): + # testing the special case where the student of the tour removes the instance is very difficult, + # since there are always new instances created and there is no way to access them from here + def create(): + return insert_dummy_remark_at_building() + + codes = {"Default": 403, "Admin": 204, "Superstudent": 204, "Student": 403, "Syndic": 403} + self.remove_view("remark-at-building/", codes, create=create) diff --git a/backend/remark_at_building/urls.py b/backend/remark_at_building/urls.py new file mode 100644 index 00000000..273770bb --- /dev/null +++ b/backend/remark_at_building/urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from remark_at_building.views import * + +urlpatterns = [ + path("/", RemarkAtBuildingIndividualView.as_view()), + path("all/", AllRemarkAtBuilding.as_view()), + path("building//", RemarksAtBuildingView.as_view()), + path("", Default.as_view()), +] diff --git a/backend/remark_at_building/views.py b/backend/remark_at_building/views.py new file mode 100644 index 00000000..d2d102d3 --- /dev/null +++ b/backend/remark_at_building/views.py @@ -0,0 +1,158 @@ +from django.core.exceptions import BadRequest +from drf_spectacular.utils import extend_schema +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from base.models import RemarkAtBuilding +from base.permissions import ( + IsSuperStudent, + IsAdmin, + IsStudent, + OwnerAccount, + ReadOnlyOwnerOfBuilding, +) +from base.serializers import RemarkAtBuildingSerializer +from util.request_response_util import ( + post_docs, + request_to_dict, + set_keys_of_instance, + try_full_clean_and_save, + not_found, + get_success, + delete_success, + delete_docs, + patch_success, + patch_docs, + get_docs, + param_docs, + get_most_recent_param_docs, + get_boolean_param, + post_success, + bad_request, +) + +TRANSLATE = { + "student_on_tour": "student_on_tour_id", + "building": "building_id", +} + + +class Default(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent | (IsStudent & OwnerAccount)] + serializer_class = RemarkAtBuildingSerializer + + @extend_schema(responses=post_docs(serializer_class)) + def post(self, request): + """ + Create a new RemarkAtBuilding with data from post + """ + data = request_to_dict(request.data) + + remark_at_building = RemarkAtBuilding() + + set_keys_of_instance(remark_at_building, data, TRANSLATE) + + if remark_at_building.student_on_tour is None: + return bad_request("RemarkAtBuilding") + + self.check_object_permissions(request, remark_at_building.student_on_tour.student) + + if r := try_full_clean_and_save(remark_at_building): + return r + return post_success(self.serializer_class(remark_at_building)) + + +class RemarkAtBuildingIndividualView(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent | (IsStudent & OwnerAccount)] + serializer_class = RemarkAtBuildingSerializer + + @extend_schema(responses=get_success(serializer_class)) + def get(self, request, remark_at_building_id): + """ + Get info about a remark at building with given id + """ + remark_at_building_instance = RemarkAtBuilding.objects.filter(id=remark_at_building_id).first() + + if not remark_at_building_instance: + return not_found(object_name="RemarkAtBuilding") + + self.check_object_permissions(request, remark_at_building_instance.student_on_tour.student) + + return get_success(self.serializer_class(remark_at_building_instance)) + + @extend_schema(responses=delete_docs()) + def delete(self, request, remark_at_building_id): + """ + Delete remark at building with given id + """ + remark_at_building_instance = RemarkAtBuilding.objects.filter(id=remark_at_building_id).first() + if not remark_at_building_instance: + return not_found(object_name="RemarkAtBuilding") + + self.check_object_permissions(request, remark_at_building_instance.student_on_tour.student) + + remark_at_building_instance.delete() + return delete_success() + + @extend_schema(responses=patch_docs(serializer_class)) + def patch(self, request, remark_at_building_id): + """ + Edit building with given ID + """ + remark_at_building_instance = RemarkAtBuilding.objects.filter(id=remark_at_building_id).first() + if not remark_at_building_instance: + return not_found(object_name="RemarkAtBuilding") + + self.check_object_permissions(request, remark_at_building_instance.student_on_tour.student) + + data = request_to_dict(request.data) + + set_keys_of_instance(remark_at_building_instance, data, TRANSLATE) + + if r := try_full_clean_and_save(remark_at_building_instance): + return r + + return patch_success(self.serializer_class(remark_at_building_instance)) + + +class AllRemarkAtBuilding(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent] + serializer_class = RemarkAtBuildingSerializer + + def get(self, request): + """ + Get all remarks for each building + """ + remark_at_building_instances = RemarkAtBuilding.objects.all() + return get_success(self.serializer_class(remark_at_building_instances, many=True)) + + +class RemarksAtBuildingView(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent | ReadOnlyOwnerOfBuilding] + serializer_class = RemarkAtBuildingSerializer + + @extend_schema( + responses=get_docs(serializer_class), parameters=param_docs(get_most_recent_param_docs("RemarksAtBuilding")) + ) + def get(self, request, building_id): + """ + Get all remarks on a specific building + """ + remark_at_building_instances = RemarkAtBuilding.objects.filter(building_id=building_id) + + try: + most_recent_only = get_boolean_param(request, "most-recent") + except BadRequest as e: + return Response({"message": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + if most_recent_only: + instances = remark_at_building_instances.order_by("-timestamp").first() + + # Now we have the most recent one, but there are more remarks on that same day + most_recent_day = str(instances.timestamp.date()) + + remark_at_building_instances = remark_at_building_instances.filter(timestamp__gte=most_recent_day) + + return get_success(self.serializer_class(remark_at_building_instances, many=True)) diff --git a/backend/requirements.txt b/backend/requirements.txt index e9594f09..80ab5cf7 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,31 +1,33 @@ asgiref==3.6.0 certifi==2022.12.7 cffi==1.15.1 -charset-normalizer==3.0.1 -cryptography==39.0.2 +charset-normalizer==3.1.0 +cryptography==40.0.2 defusedxml==0.7.1 dj-rest-auth==3.0.0 -Django==4.1.7 -django-allauth==0.52.0 -django-cors-headers==3.13.0 +Django==4.2 +django-allauth==0.54.0 +django-cors-headers==3.14.0 django-phonenumber-field==7.0.2 django-random-id-model==0.1.1 -django-rename-app==0.1.5 +django-rename-app==0.1.6 django-rest-framework==0.1.0 djangorestframework==3.14.0 djangorestframework-simplejwt==5.2.2 idna==3.4 oauthlib==3.2.2 -phonenumbers==8.13.6 -psycopg2-binary==2.9.5 +phonenumbers==8.13.10 +psycopg2-binary==2.9.6 pycparser==2.21 PyJWT==2.6.0 python3-openid==3.2.0 -pytz==2022.7.1 +pytz==2023.3 requests==2.28.2 requests-oauthlib==1.3.1 six==1.16.0 -sqlparse==0.4.3 -urllib3==1.26.14 -Pillow==9.4.0 -drf-spectacular==0.26.0 \ No newline at end of file +sqlparse==0.4.4 +urllib3==1.26.15 +Pillow==9.5.0 +drf-spectacular==0.26.2 +django-nose==1.4.7 +coverage==7.2.3 \ No newline at end of file diff --git a/backend/role/apps.py b/backend/role/apps.py deleted file mode 100644 index 2857409d..00000000 --- a/backend/role/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class RoleConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'role' diff --git a/backend/role/models.py b/backend/role/models.py index 71a83623..6b202199 100644 --- a/backend/role/models.py +++ b/backend/role/models.py @@ -1,3 +1 @@ -from django.db import models - # Create your models here. diff --git a/backend/role/tests.py b/backend/role/tests.py index 7ce503c2..5a6589aa 100644 --- a/backend/role/tests.py +++ b/backend/role/tests.py @@ -1,3 +1,84 @@ -from django.test import TestCase +from base.models import Role +from base.serializers import RoleSerializer +from util.data_generators import insert_dummy_role +from util.test_tools import BaseTest, BaseAuthTest -# Create your tests here. + +class RoleTests(BaseTest): + def __init__(self, methodName="runTest"): + super().__init__(methodName) + + # no empty_list test because there will be regions since there is an admin client + # the admin used in that client has a region so the empty_list test will never succeed + + def test_insert_role(self): + self.data1 = {"name": "Test", "rank": 2, "description": "testRole"} + self.insert("role/") + + def test_insert_dupe_role(self): + self.data1 = {"name": "Test", "rank": 2, "description": "testRole"} + self.insert_dupe("role/") + + def test_insert_empty(self): + self.insert_empty("role/") + + def test_get_role(self): + r_id = insert_dummy_role("testRole") + data = RoleSerializer(Role.objects.get(id=r_id)).data + self.get(f"role/{r_id}", data) + + def test_get_non_existing(self): + self.get_non_existent("role/") + + def test_patch_role(self): + r_id = insert_dummy_role("TestRole") + self.data1 = {"name": "Test", "rank": 2, "description": "testRole"} + self.patch(f"role/{r_id}") + + def test_patch_invalid_role(self): + self.data1 = {"name": "Test", "rank": 2, "description": "testRole"} + self.patch_invalid("role/") + + def test_patch_error_role(self): + self.data1 = {"name": "Test", "rank": 2, "description": "testRole"} + self.data2 = {"name": "Test2", "rank": 2, "description": "testRole"} + self.patch_error("role/") + + def test_remove_role(self): + r_id = insert_dummy_role("testRole") + self.remove(f"role/{r_id}") + + def test_remove_non_existent_role(self): + self.remove_invalid("role/") + + +class RoleAuthorizationTests(BaseAuthTest): + def __init__(self, methodName="runTest"): + super().__init__(methodName) + + def test_role_list(self): + codes = {"Default": 403, "Admin": 200, "Superstudent": 200, "Student": 403, "Syndic": 403} + self.list_view("role/", codes) + + def test_insert_role(self): + codes = {"Default": 403, "Admin": 201, "Superstudent": 403, "Student": 403, "Syndic": 403} + self.data1 = {"name": "Test", "rank": 2, "description": "testRole"} + self.insert_view("role/", codes) + + def test_get_role(self): + codes = {"Default": 403, "Admin": 200, "Superstudent": 200, "Student": 403, "Syndic": 403} + r_id = insert_dummy_role("testRole") + self.get_view(f"role/{r_id}", codes) + + def test_patch_role(self): + codes = {"Default": 403, "Admin": 200, "Superstudent": 200, "Student": 403, "Syndic": 403} + r_id = insert_dummy_role("testRole") + self.data1 = {"name": "Test", "rank": 2, "description": "testRole"} + self.patch_view(f"role/{r_id}", codes) + + def test_remove_role(self): + def create(): + return insert_dummy_role("testRole") + + codes = {"Default": 403, "Admin": 204, "Superstudent": 204, "Student": 403, "Syndic": 403} + self.remove_view("role/", codes, create=create) diff --git a/backend/role/urls.py b/backend/role/urls.py index 5d308001..0bde88de 100644 --- a/backend/role/urls.py +++ b/backend/role/urls.py @@ -1,14 +1,9 @@ from django.urls import path -from .views import ( - RoleIndividualView, - AllRolesView, - DefaultRoleView -) - +from .views import RoleIndividualView, AllRolesView, DefaultRoleView urlpatterns = [ - path('/', RoleIndividualView.as_view()), - path('all/', AllRolesView.as_view()), - path('', DefaultRoleView.as_view()) + path("/", RoleIndividualView.as_view()), + path("all/", AllRolesView.as_view()), + path("", DefaultRoleView.as_view()), ] diff --git a/backend/role/views.py b/backend/role/views.py index 8a8ce545..b3a1d070 100644 --- a/backend/role/views.py +++ b/backend/role/views.py @@ -1,18 +1,18 @@ +from drf_spectacular.utils import extend_schema +from rest_framework.permissions import IsAuthenticated from rest_framework.views import APIView from base.models import Role +from base.permissions import IsAdmin, IsSuperStudent from base.serializers import RoleSerializer from util.request_response_util import * -from drf_spectacular.utils import extend_schema class DefaultRoleView(APIView): + permission_classes = [IsAuthenticated, IsAdmin] serializer_class = RoleSerializer - @extend_schema( - responses={201: RoleSerializer, - 400: None} - ) + @extend_schema(responses=post_docs(RoleSerializer)) def post(self, request): """ Create a new role @@ -30,12 +30,10 @@ def post(self, request): class RoleIndividualView(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent] serializer_class = RoleSerializer - @extend_schema( - responses={200: RoleSerializer, - 400: None} - ) + @extend_schema(responses=get_docs(RoleSerializer)) def get(self, request, role_id): """ Get info about a Role with given id @@ -43,15 +41,12 @@ def get(self, request, role_id): role_instance = Role.objects.filter(id=role_id) if not role_instance: - return bad_request("Role") + return not_found("Role") serializer = RoleSerializer(role_instance[0]) return get_success(serializer) - @extend_schema( - responses={204: None, - 400: None} - ) + @extend_schema(responses=delete_docs()) def delete(self, request, role_id): """ Delete a Role with given id @@ -59,15 +54,12 @@ def delete(self, request, role_id): role_instance = Role.objects.filter(id=role_id) if not role_instance: - return bad_request("Role") + return not_found("Role") role_instance[0].delete() return delete_success() - @extend_schema( - responses={200: RoleSerializer, - 400: None} - ) + @extend_schema(responses=patch_docs(RoleSerializer)) def patch(self, request, role_id): """ Edit info about a Role with given id @@ -75,7 +67,7 @@ def patch(self, request, role_id): role_instance = Role.objects.filter(id=role_id) if not role_instance: - return bad_request("Role") + return not_found("Role") role_instance = role_instance[0] @@ -90,6 +82,7 @@ def patch(self, request, role_id): class AllRolesView(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent] serializer_class = RoleSerializer def get(self, request): diff --git a/backend/schema.yml b/backend/schema.yml deleted file mode 100644 index cd0dab37..00000000 --- a/backend/schema.yml +++ /dev/null @@ -1,2416 +0,0 @@ -openapi: 3.0.3 -info: - title: Dr-Trottoir API - version: 1.0.0 - description: This is the documentation for the Dr-Trottoir API -paths: - /authentication/account-confirm-email/: - post: - operationId: authentication_account_confirm_email_create - tags: - - authentication - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/VerifyEmail' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/VerifyEmail' - multipart/form-data: - schema: - $ref: '#/components/schemas/VerifyEmail' - required: true - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - - {} - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/RestAuthDetail' - description: '' - /authentication/account-confirm-email/{key}/: - post: - operationId: authentication_account_confirm_email_create_2 - parameters: - - in: path - name: key - schema: - type: string - required: true - tags: - - authentication - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/VerifyEmail' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/VerifyEmail' - multipart/form-data: - schema: - $ref: '#/components/schemas/VerifyEmail' - required: true - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - - {} - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/RestAuthDetail' - description: '' - /authentication/login/: - post: - operationId: authentication_login_create - description: |- - Check the credentials and return the REST Token - if the credentials are valid and authenticated. - Calls Django Auth login method to register User ID - in Django session framework - - Accept the following POST parameters: username, password - Return the REST Framework Token Object's key. - tags: - - authentication - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/TokenRefresh' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/TokenRefresh' - multipart/form-data: - schema: - $ref: '#/components/schemas/TokenRefresh' - required: true - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - - {} - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/TokenRefresh' - description: '' - /authentication/logout/: - get: - operationId: authentication_logout_retrieve - description: |- - Calls Django logout method and delete the Token object - assigned to the current User object. - - Accepts/Returns nothing. - tags: - - authentication - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - - {} - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/TokenRefresh' - description: '' - post: - operationId: authentication_logout_create - description: |- - Calls Django logout method and delete the Token object - assigned to the current User object. - - Accepts/Returns nothing. - tags: - - authentication - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/TokenRefresh' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/TokenRefresh' - multipart/form-data: - schema: - $ref: '#/components/schemas/TokenRefresh' - required: true - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - - {} - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/TokenRefresh' - description: '' - /authentication/password/change/: - post: - operationId: authentication_password_change_create - description: |- - Calls Django Auth SetPasswordForm save method. - - Accepts the following POST parameters: new_password1, new_password2 - Returns the success/fail message. - tags: - - authentication - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/PasswordChange' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/PasswordChange' - multipart/form-data: - schema: - $ref: '#/components/schemas/PasswordChange' - required: true - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/RestAuthDetail' - description: '' - /authentication/password/reset/: - post: - operationId: authentication_password_reset_create - description: |- - Calls Django Auth PasswordResetForm save method. - - Accepts the following POST parameters: email - Returns the success/fail message. - tags: - - authentication - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/PasswordReset' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/PasswordReset' - multipart/form-data: - schema: - $ref: '#/components/schemas/PasswordReset' - required: true - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - - {} - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/RestAuthDetail' - description: '' - /authentication/password/reset/confirm/{uidb64}/{token}/: - post: - operationId: authentication_password_reset_confirm_create - description: |- - Password reset e-mail link is confirmed, therefore - this resets the user's password. - - Accepts the following POST parameters: token, uid, - new_password1, new_password2 - Returns the success/fail message. - parameters: - - in: path - name: token - schema: - type: string - required: true - - in: path - name: uidb64 - schema: - type: string - required: true - tags: - - authentication - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/PasswordResetConfirm' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/PasswordResetConfirm' - multipart/form-data: - schema: - $ref: '#/components/schemas/PasswordResetConfirm' - required: true - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - - {} - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/RestAuthDetail' - description: '' - /authentication/signup/: - post: - operationId: authentication_signup_create - tags: - - authentication - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/Register' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/Register' - multipart/form-data: - schema: - $ref: '#/components/schemas/Register' - required: true - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - - {} - responses: - '201': - content: - application/json: - schema: - $ref: '#/components/schemas/JWT' - description: '' - /authentication/signup/resend-email/: - post: - operationId: authentication_signup_resend_email_create - tags: - - authentication - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/ResendEmailVerification' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/ResendEmailVerification' - multipart/form-data: - schema: - $ref: '#/components/schemas/ResendEmailVerification' - required: true - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - - {} - responses: - '201': - content: - application/json: - schema: - $ref: '#/components/schemas/RestAuthDetail' - description: '' - /authentication/signup/verify-email/: - post: - operationId: authentication_signup_verify_email_create - tags: - - authentication - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/VerifyEmail' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/VerifyEmail' - multipart/form-data: - schema: - $ref: '#/components/schemas/VerifyEmail' - required: true - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - - {} - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/RestAuthDetail' - description: '' - /authentication/token/refresh/: - post: - operationId: authentication_token_refresh_create - description: |- - Takes a refresh type JSON web token and returns an access type JSON web - token if the refresh token is valid. - tags: - - authentication - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/TokenRefresh' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/TokenRefresh' - multipart/form-data: - schema: - $ref: '#/components/schemas/TokenRefresh' - required: true - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/TokenRefresh' - description: '' - /authentication/token/verify/: - post: - operationId: authentication_token_verify_create - description: |- - Takes a token and indicates if it is valid. This view provides no - information about a token's fitness for a particular use. - tags: - - authentication - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/TokenVerify' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/TokenVerify' - multipart/form-data: - schema: - $ref: '#/components/schemas/TokenVerify' - required: true - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/TokenVerify' - description: '' - /authentication/verify-email/: - post: - operationId: authentication_verify_email_create - tags: - - authentication - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/VerifyEmail' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/VerifyEmail' - multipart/form-data: - schema: - $ref: '#/components/schemas/VerifyEmail' - required: true - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - - {} - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/RestAuthDetail' - description: '' - /building/: - post: - operationId: building_create - description: Create a new building - tags: - - building - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/Building' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/Building' - multipart/form-data: - schema: - $ref: '#/components/schemas/Building' - required: true - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '201': - content: - application/json: - schema: - $ref: '#/components/schemas/Building' - description: '' - '400': - description: No response body - /building/{building_id}/: - get: - operationId: building_retrieve - description: Get info about building with given id - parameters: - - in: path - name: building_id - schema: - type: integer - required: true - tags: - - building - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/Building' - description: '' - '400': - description: No response body - patch: - operationId: building_partial_update - description: Edit building with given ID - parameters: - - in: path - name: building_id - schema: - type: integer - required: true - tags: - - building - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/PatchedBuilding' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/PatchedBuilding' - multipart/form-data: - schema: - $ref: '#/components/schemas/PatchedBuilding' - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/Building' - description: '' - '400': - description: No response body - delete: - operationId: building_destroy - description: Delete building with given id - parameters: - - in: path - name: building_id - schema: - type: integer - required: true - tags: - - building - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '204': - description: No response body - '400': - description: No response body - /building/all/: - get: - operationId: building_all_retrieve - description: Get all buildings - tags: - - building - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/Building' - description: '' - /building/owner/{owner_id}/: - get: - operationId: building_owner_retrieve - description: Get all buildings owned by syndic with given id - parameters: - - in: path - name: owner_id - schema: - type: integer - required: true - tags: - - building - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/Building' - description: '' - '400': - description: No response body - /building_on_tour/: - post: - operationId: building_on_tour_create - description: Create a new BuildingOnTour with data from post - tags: - - building_on_tour - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/BuildingTour' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/BuildingTour' - multipart/form-data: - schema: - $ref: '#/components/schemas/BuildingTour' - required: true - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '201': - content: - application/json: - schema: - $ref: '#/components/schemas/BuildingTour' - description: '' - '400': - description: No response body - /building_on_tour/{building_tour_id}/: - get: - operationId: building_on_tour_retrieve - description: Get info about a BuildingOnTour with given id - parameters: - - in: path - name: building_tour_id - schema: - type: integer - required: true - tags: - - building_on_tour - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/BuildingTour' - description: '' - '400': - description: No response body - patch: - operationId: building_on_tour_partial_update - description: edit info about a BuildingOnTour with given id - parameters: - - in: path - name: building_tour_id - schema: - type: integer - required: true - tags: - - building_on_tour - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/PatchedBuildingTour' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/PatchedBuildingTour' - multipart/form-data: - schema: - $ref: '#/components/schemas/PatchedBuildingTour' - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/BuildingTour' - description: '' - '400': - description: No response body - delete: - operationId: building_on_tour_destroy - description: delete a BuildingOnTour from the database - parameters: - - in: path - name: building_tour_id - schema: - type: integer - required: true - tags: - - building_on_tour - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '204': - description: No response body - '400': - description: No response body - /building_on_tour/all/: - get: - operationId: building_on_tour_all_retrieve - description: Get all buildings on tours - tags: - - building_on_tour - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/BuildingTour' - description: '' - /buildingurl/: - post: - operationId: buildingurl_create - description: Create a new building url - tags: - - buildingurl - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/BuildingUrl' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/BuildingUrl' - multipart/form-data: - schema: - $ref: '#/components/schemas/BuildingUrl' - required: true - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '201': - content: - application/json: - schema: - $ref: '#/components/schemas/BuildingUrl' - description: '' - '400': - description: No response body - /buildingurl/{building_url_id}/: - get: - operationId: buildingurl_retrieve - description: Get info about a buildingurl with given id - parameters: - - in: path - name: building_url_id - schema: - type: integer - required: true - tags: - - buildingurl - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/BuildingUrl' - description: '' - '400': - description: No response body - patch: - operationId: buildingurl_partial_update - description: Edit info about buildingurl with given id - parameters: - - in: path - name: building_url_id - schema: - type: integer - required: true - tags: - - buildingurl - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/PatchedBuildingUrl' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/PatchedBuildingUrl' - multipart/form-data: - schema: - $ref: '#/components/schemas/PatchedBuildingUrl' - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/BuildingUrl' - description: '' - '400': - description: No response body - delete: - operationId: buildingurl_destroy - description: Delete buildingurl with given id - parameters: - - in: path - name: building_url_id - schema: - type: integer - required: true - tags: - - buildingurl - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '204': - description: No response body - '400': - description: No response body - /buildingurl/all/: - get: - operationId: buildingurl_all_retrieve - description: Get all building urls - tags: - - buildingurl - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/BuildingUrl' - description: '' - /buildingurl/building/{building_id}/: - get: - operationId: buildingurl_building_retrieve - description: Get all building urls of a given building - parameters: - - in: path - name: building_id - schema: - type: integer - required: true - tags: - - buildingurl - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/BuildingUrl' - description: '' - /buildingurl/syndic/{syndic_id}/: - get: - operationId: buildingurl_syndic_retrieve - description: Get all building urls of buildings where the user with given user - id is syndic - parameters: - - in: path - name: syndic_id - schema: - type: integer - required: true - tags: - - buildingurl - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/BuildingUrl' - description: '' - /garbage_collection/: - post: - operationId: garbage_collection_create - description: Create new garbage collection - tags: - - garbage_collection - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/GarbageCollection' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/GarbageCollection' - multipart/form-data: - schema: - $ref: '#/components/schemas/GarbageCollection' - required: true - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '201': - content: - application/json: - schema: - $ref: '#/components/schemas/GarbageCollection' - description: '' - '400': - description: No response body - /garbage_collection/{garbage_collection_id}/: - get: - operationId: garbage_collection_retrieve - description: Get info about a garbage collection with given id - parameters: - - in: path - name: garbage_collection_id - schema: - type: string - required: true - tags: - - garbage_collection - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/GarbageCollection' - description: '' - '400': - description: No response body - patch: - operationId: garbage_collection_partial_update - description: Edit garbage collection with given id - parameters: - - in: path - name: garbage_collection_id - schema: - type: string - required: true - tags: - - garbage_collection - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/PatchedGarbageCollection' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/PatchedGarbageCollection' - multipart/form-data: - schema: - $ref: '#/components/schemas/PatchedGarbageCollection' - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/GarbageCollection' - description: '' - '400': - description: No response body - delete: - operationId: garbage_collection_destroy - description: Delete garbage collection with given id - parameters: - - in: path - name: garbage_collection_id - schema: - type: string - required: true - tags: - - garbage_collection - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '204': - description: No response body - '400': - description: No response body - /garbage_collection/all/: - get: - operationId: garbage_collection_all_retrieve - description: Get all garbage collections - tags: - - garbage_collection - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/GarbageCollection' - description: '' - /garbage_collection/building/{building_id}/: - get: - operationId: garbage_collection_building_retrieve - description: Get info about all garbage collections of a building with given - id - parameters: - - in: path - name: building_id - schema: - type: string - required: true - tags: - - garbage_collection - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/GarbageCollection' - description: '' - /manual/: - post: - operationId: manual_create - description: Create a new manual with data from post - tags: - - manual - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/Manual' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/Manual' - multipart/form-data: - schema: - $ref: '#/components/schemas/Manual' - required: true - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '201': - content: - application/json: - schema: - $ref: '#/components/schemas/Manual' - description: '' - '400': - description: No response body - /manual/{manual_id}/: - get: - operationId: manual_retrieve - description: Get info about a manual with given id - parameters: - - in: path - name: manual_id - schema: - type: integer - required: true - tags: - - manual - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/Manual' - description: '' - '400': - description: No response body - patch: - operationId: manual_partial_update - description: Edit info about a manual with given id - parameters: - - in: path - name: manual_id - schema: - type: integer - required: true - tags: - - manual - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/PatchedManual' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/PatchedManual' - multipart/form-data: - schema: - $ref: '#/components/schemas/PatchedManual' - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/Manual' - description: '' - '400': - description: No response body - delete: - operationId: manual_destroy - description: Delete manual with given id - parameters: - - in: path - name: manual_id - schema: - type: integer - required: true - tags: - - manual - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '204': - description: No response body - '400': - description: No response body - /manual/all/: - get: - operationId: manual_all_retrieve - description: Get all manuals - tags: - - manual - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/Manual' - description: '' - /manual/building/{building_id}/: - get: - operationId: manual_building_retrieve - description: Get all manuals of a building with given id - parameters: - - in: path - name: building_id - schema: - type: integer - required: true - tags: - - manual - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/Manual' - description: '' - '400': - description: No response body - /picture_building/: - post: - operationId: picture_building_create - description: Create a new PictureBuilding - tags: - - picture_building - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/PictureBuilding' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/PictureBuilding' - multipart/form-data: - schema: - $ref: '#/components/schemas/PictureBuilding' - required: true - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '201': - content: - application/json: - schema: - $ref: '#/components/schemas/PictureBuilding' - description: '' - '400': - description: No response body - /picture_building/{picture_building_id}/: - get: - operationId: picture_building_retrieve - description: Get PictureBuilding with given id - parameters: - - in: path - name: picture_building_id - schema: - type: integer - required: true - tags: - - picture_building - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/PictureBuilding' - description: '' - '400': - description: No response body - patch: - operationId: picture_building_partial_update - description: Edit info about PictureBuilding with given id - parameters: - - in: path - name: picture_building_id - schema: - type: integer - required: true - tags: - - picture_building - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/PatchedPictureBuilding' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/PatchedPictureBuilding' - multipart/form-data: - schema: - $ref: '#/components/schemas/PatchedPictureBuilding' - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/PictureBuilding' - description: '' - '400': - description: No response body - delete: - operationId: picture_building_destroy - description: delete a pictureBuilding from the database - parameters: - - in: path - name: picture_building_id - schema: - type: integer - required: true - tags: - - picture_building - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '204': - description: No response body - '400': - description: No response body - /picture_building/all/: - get: - operationId: picture_building_all_retrieve - description: Get all pictureBuilding - tags: - - picture_building - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/PictureBuilding' - description: '' - /picture_building/building/{building_id}/: - get: - operationId: picture_building_building_retrieve - description: Get all pictures of a building with given id - parameters: - - in: path - name: building_id - schema: - type: integer - required: true - tags: - - picture_building - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/PictureBuilding' - description: '' - /region/: - post: - operationId: region_create - description: Create a new region - tags: - - region - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/Region' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/Region' - multipart/form-data: - schema: - $ref: '#/components/schemas/Region' - required: true - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '201': - content: - application/json: - schema: - $ref: '#/components/schemas/Region' - description: '' - '400': - description: No response body - /region/{region_id}/: - get: - operationId: region_retrieve - description: Get info about a Region with given id - parameters: - - in: path - name: region_id - schema: - type: integer - required: true - tags: - - region - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/Region' - description: '' - '400': - description: No response body - patch: - operationId: region_partial_update - description: Edit Region with given id - parameters: - - in: path - name: region_id - schema: - type: integer - required: true - tags: - - region - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/PatchedRegion' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/PatchedRegion' - multipart/form-data: - schema: - $ref: '#/components/schemas/PatchedRegion' - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/Region' - description: '' - '400': - description: No response body - delete: - operationId: region_destroy - description: delete a region with given id - parameters: - - in: path - name: region_id - schema: - type: integer - required: true - tags: - - region - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '204': - description: No response body - '400': - description: No response body - /region/all/: - get: - operationId: region_all_retrieve - description: Get all regions - tags: - - region - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/Region' - description: '' - /student_at_building_on_tour/: - post: - operationId: student_at_building_on_tour_create - description: Create a new StudentAtBuildingOnTour - tags: - - student_at_building_on_tour - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/StudBuildTour' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/StudBuildTour' - multipart/form-data: - schema: - $ref: '#/components/schemas/StudBuildTour' - required: true - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '201': - content: - application/json: - schema: - $ref: '#/components/schemas/StudBuildTour' - description: '' - '400': - description: No response body - /student_at_building_on_tour/{student_at_building_on_tour_id}/: - get: - operationId: student_at_building_on_tour_retrieve - description: Get an individual StudentAtBuildingOnTour with given id - parameters: - - in: path - name: student_at_building_on_tour_id - schema: - type: integer - required: true - tags: - - student_at_building_on_tour - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/StudBuildTour' - description: '' - '400': - description: No response body - patch: - operationId: student_at_building_on_tour_partial_update - description: Edit info about an individual StudentAtBuildingOnTour with given - id - parameters: - - in: path - name: student_at_building_on_tour_id - schema: - type: integer - required: true - tags: - - student_at_building_on_tour - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/PatchedStudBuildTour' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/PatchedStudBuildTour' - multipart/form-data: - schema: - $ref: '#/components/schemas/PatchedStudBuildTour' - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/StudBuildTour' - description: '' - '400': - description: No response body - delete: - operationId: student_at_building_on_tour_destroy - description: Delete StudentAtBuildingOnTour with given id - parameters: - - in: path - name: student_at_building_on_tour_id - schema: - type: integer - required: true - tags: - - student_at_building_on_tour - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '204': - description: No response body - '400': - description: No response body - /student_at_building_on_tour/all/: - get: - operationId: student_at_building_on_tour_all_retrieve - description: Get all StudentAtBuildingOnTours - tags: - - student_at_building_on_tour - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/StudBuildTour' - description: '' - /student_at_building_on_tour/student/{student_id}/: - get: - operationId: student_at_building_on_tour_student_retrieve - description: Get all StudentAtBuildingOnTour for a student with given id - parameters: - - in: path - name: student_id - schema: - type: integer - required: true - tags: - - student_at_building_on_tour - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/StudBuildTour' - description: '' - /tour/: - post: - operationId: tour_create - description: Create a new tour - tags: - - tour - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/Tour' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/Tour' - multipart/form-data: - schema: - $ref: '#/components/schemas/Tour' - required: true - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '201': - content: - application/json: - schema: - $ref: '#/components/schemas/Tour' - description: '' - '400': - description: No response body - /tour/{tour_id}/: - get: - operationId: tour_retrieve - description: Get info about a Tour with given id - parameters: - - in: path - name: tour_id - schema: - type: integer - required: true - tags: - - tour - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/Tour' - description: '' - '400': - description: No response body - patch: - operationId: tour_partial_update - description: Edit a tour with given id - parameters: - - in: path - name: tour_id - schema: - type: integer - required: true - tags: - - tour - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/PatchedTour' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/PatchedTour' - multipart/form-data: - schema: - $ref: '#/components/schemas/PatchedTour' - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/Tour' - description: '' - '400': - description: No response body - delete: - operationId: tour_destroy - description: Delete a tour with given id - parameters: - - in: path - name: tour_id - schema: - type: integer - required: true - tags: - - tour - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '204': - description: No response body - '400': - description: No response body - /tour/all/: - get: - operationId: tour_all_retrieve - description: Get all tours - tags: - - tour - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/Tour' - description: '' - /user/: - post: - operationId: user_create - description: Create a new user - tags: - - user - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/User' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/User' - multipart/form-data: - schema: - $ref: '#/components/schemas/User' - required: true - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '201': - content: - application/json: - schema: - $ref: '#/components/schemas/User' - description: '' - '400': - description: No response body - /user/{user_id}/: - get: - operationId: user_retrieve - description: Get info about user with given id - parameters: - - in: path - name: user_id - schema: - type: integer - required: true - tags: - - user - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/User' - description: '' - '400': - description: No response body - patch: - operationId: user_partial_update - description: Edit user with given id - parameters: - - in: path - name: user_id - schema: - type: integer - required: true - tags: - - user - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/PatchedUser' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/PatchedUser' - multipart/form-data: - schema: - $ref: '#/components/schemas/PatchedUser' - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/User' - description: '' - '400': - description: No response body - delete: - operationId: user_destroy - description: |- - Delete user with given id - We don't acutally delete a user, we put the user on inactive mode - parameters: - - in: path - name: user_id - schema: - type: integer - required: true - tags: - - user - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '204': - description: No response body - '400': - description: No response body - /user/all/: - get: - operationId: user_all_retrieve - description: Get all users - tags: - - user - security: - - jwtHeaderAuth: [] - - jwtCookieAuth: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/User' - description: '' -components: - schemas: - Building: - type: object - properties: - id: - type: integer - readOnly: true - city: - type: string - maxLength: 40 - postal_code: - type: string - maxLength: 10 - street: - type: string - maxLength: 60 - house_number: - type: string - maxLength: 10 - client_number: - type: string - nullable: true - maxLength: 40 - duration: - type: string - format: time - syndic: - type: integer - nullable: true - region: - type: integer - nullable: true - name: - type: string - nullable: true - maxLength: 100 - required: - - city - - house_number - - id - - postal_code - - street - BuildingTour: - type: object - properties: - id: - type: integer - readOnly: true - building: - type: integer - tour: - type: integer - index: - type: integer - maximum: 2147483647 - minimum: 0 - required: - - building - - id - - index - - tour - BuildingUrl: - type: object - properties: - id: - type: integer - readOnly: true - first_name_resident: - type: string - maxLength: 40 - last_name_resident: - type: string - maxLength: 40 - building: - type: integer - required: - - building - - first_name_resident - - id - - last_name_resident - GarbageCollection: - type: object - properties: - id: - type: integer - readOnly: true - building: - type: integer - date: - type: string - format: date - garbage_type: - $ref: '#/components/schemas/GarbageTypeEnum' - required: - - building - - date - - garbage_type - - id - GarbageTypeEnum: - enum: - - GFT - - GLS - - GRF - - KER - - PAP - - PMD - - RES - type: string - description: |- - * `GFT` - GFT - * `GLS` - Glas - * `GRF` - Grof vuil - * `KER` - Kerstbomen - * `PAP` - Papier - * `PMD` - PMD - * `RES` - Restafval - JWT: - type: object - description: Serializer for JWT authentication. - properties: - access_token: - type: string - refresh_token: - type: string - user: - $ref: '#/components/schemas/UserDetails' - required: - - access_token - - refresh_token - - user - Manual: - type: object - properties: - id: - type: integer - readOnly: true - building: - type: integer - version_number: - type: integer - maximum: 2147483647 - minimum: 0 - file: - type: string - format: uri - nullable: true - required: - - building - - id - PasswordChange: - type: object - properties: - new_password1: - type: string - maxLength: 128 - new_password2: - type: string - maxLength: 128 - required: - - new_password1 - - new_password2 - PasswordReset: - type: object - description: Serializer for requesting a password reset e-mail. - properties: - email: - type: string - format: email - required: - - email - PasswordResetConfirm: - type: object - description: Serializer for confirming a password reset attempt. - properties: - new_password1: - type: string - maxLength: 128 - new_password2: - type: string - maxLength: 128 - uid: - type: string - token: - type: string - required: - - new_password1 - - new_password2 - - token - - uid - PatchedBuilding: - type: object - properties: - id: - type: integer - readOnly: true - city: - type: string - maxLength: 40 - postal_code: - type: string - maxLength: 10 - street: - type: string - maxLength: 60 - house_number: - type: string - maxLength: 10 - client_number: - type: string - nullable: true - maxLength: 40 - duration: - type: string - format: time - syndic: - type: integer - nullable: true - region: - type: integer - nullable: true - name: - type: string - nullable: true - maxLength: 100 - PatchedBuildingTour: - type: object - properties: - id: - type: integer - readOnly: true - building: - type: integer - tour: - type: integer - index: - type: integer - maximum: 2147483647 - minimum: 0 - PatchedBuildingUrl: - type: object - properties: - id: - type: integer - readOnly: true - first_name_resident: - type: string - maxLength: 40 - last_name_resident: - type: string - maxLength: 40 - building: - type: integer - PatchedGarbageCollection: - type: object - properties: - id: - type: integer - readOnly: true - building: - type: integer - date: - type: string - format: date - garbage_type: - $ref: '#/components/schemas/GarbageTypeEnum' - PatchedManual: - type: object - properties: - id: - type: integer - readOnly: true - building: - type: integer - version_number: - type: integer - maximum: 2147483647 - minimum: 0 - file: - type: string - format: uri - nullable: true - PatchedPictureBuilding: - type: object - properties: - id: - type: integer - readOnly: true - building: - type: integer - picture: - type: string - format: uri - nullable: true - description: - type: string - nullable: true - timestamp: - type: string - format: date-time - type: - $ref: '#/components/schemas/TypeEnum' - PatchedRegion: - type: object - properties: - id: - type: integer - readOnly: true - region: - type: string - maxLength: 40 - PatchedStudBuildTour: - type: object - properties: - id: - type: integer - readOnly: true - building_on_tour: - type: integer - nullable: true - date: - type: string - format: date - student: - type: integer - nullable: true - PatchedTour: - type: object - properties: - id: - type: integer - readOnly: true - name: - type: string - maxLength: 40 - region: - type: integer - nullable: true - modified_at: - type: string - format: date-time - nullable: true - PatchedUser: - type: object - properties: - id: - type: integer - readOnly: true - is_active: - type: boolean - email: - type: string - format: email - readOnly: true - title: Email address - first_name: - type: string - maxLength: 40 - last_name: - type: string - maxLength: 40 - phone_number: - type: string - maxLength: 128 - region: - type: array - items: - type: integer - role: - type: integer - nullable: true - PictureBuilding: - type: object - properties: - id: - type: integer - readOnly: true - building: - type: integer - picture: - type: string - format: uri - nullable: true - description: - type: string - nullable: true - timestamp: - type: string - format: date-time - type: - $ref: '#/components/schemas/TypeEnum' - required: - - building - - id - - timestamp - - type - Region: - type: object - properties: - id: - type: integer - readOnly: true - region: - type: string - maxLength: 40 - required: - - id - - region - Register: - type: object - properties: - username: - type: string - maxLength: 0 - minLength: 1 - email: - type: string - format: email - password1: - type: string - writeOnly: true - password2: - type: string - writeOnly: true - required: - - email - - password1 - - password2 - ResendEmailVerification: - type: object - properties: - email: - type: string - format: email - required: - - email - RestAuthDetail: - type: object - properties: - detail: - type: string - readOnly: true - required: - - detail - StudBuildTour: - type: object - properties: - id: - type: integer - readOnly: true - building_on_tour: - type: integer - nullable: true - date: - type: string - format: date - student: - type: integer - nullable: true - required: - - date - - id - TokenRefresh: - type: object - properties: - access: - type: string - readOnly: true - refresh: - type: string - required: - - access - - refresh - TokenVerify: - type: object - properties: - token: - type: string - writeOnly: true - required: - - token - Tour: - type: object - properties: - id: - type: integer - readOnly: true - name: - type: string - maxLength: 40 - region: - type: integer - nullable: true - modified_at: - type: string - format: date-time - nullable: true - required: - - id - - name - TypeEnum: - enum: - - AA - - BI - - VE - - OP - type: string - description: |- - * `AA` - Aankomst - * `BI` - Binnen - * `VE` - Vertrek - * `OP` - Opmerking - User: - type: object - properties: - id: - type: integer - readOnly: true - is_active: - type: boolean - email: - type: string - format: email - readOnly: true - title: Email address - first_name: - type: string - maxLength: 40 - last_name: - type: string - maxLength: 40 - phone_number: - type: string - maxLength: 128 - region: - type: array - items: - type: integer - role: - type: integer - nullable: true - required: - - email - - first_name - - id - - last_name - - phone_number - - region - UserDetails: - type: object - description: User model w/o password - properties: - pk: - type: integer - readOnly: true - title: ID - email: - type: string - format: email - readOnly: true - title: Email address - first_name: - type: string - maxLength: 40 - last_name: - type: string - maxLength: 40 - required: - - email - - first_name - - last_name - - pk - VerifyEmail: - type: object - properties: - key: - type: string - writeOnly: true - required: - - key - securitySchemes: - jwtCookieAuth: - type: apiKey - in: cookie - name: jwt-auth - jwtHeaderAuth: - type: http - scheme: bearer - bearerFormat: JWT diff --git a/backend/student_at_building_on_tour/apps.py b/backend/student_at_building_on_tour/apps.py deleted file mode 100644 index 71bda9aa..00000000 --- a/backend/student_at_building_on_tour/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class StudentAtBuildingOnTourConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'student_at_building_on_tour' diff --git a/backend/student_at_building_on_tour/tests.py b/backend/student_at_building_on_tour/tests.py deleted file mode 100644 index 7ce503c2..00000000 --- a/backend/student_at_building_on_tour/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/backend/student_at_building_on_tour/urls.py b/backend/student_at_building_on_tour/urls.py deleted file mode 100644 index ef70f54f..00000000 --- a/backend/student_at_building_on_tour/urls.py +++ /dev/null @@ -1,15 +0,0 @@ -from django.urls import path - -from .views import ( - StudentAtBuildingOnTourIndividualView, - BuildingTourPerStudentView, - AllView, - Default -) - -urlpatterns = [ - path('/', StudentAtBuildingOnTourIndividualView.as_view()), - path('student//', BuildingTourPerStudentView.as_view()), - path('all/', AllView.as_view()), - path('', Default.as_view()) -] diff --git a/backend/student_at_building_on_tour/views.py b/backend/student_at_building_on_tour/views.py deleted file mode 100644 index 58f4207b..00000000 --- a/backend/student_at_building_on_tour/views.py +++ /dev/null @@ -1,114 +0,0 @@ -from rest_framework.views import APIView -from drf_spectacular.utils import extend_schema - -from base.models import StudentAtBuildingOnTour -from base.serializers import StudBuildTourSerializer -from util.request_response_util import * - -TRANSLATE = {"building_on_tour": "building_on_tour_id", "student": "student_id"} - - -class Default(APIView): - serializer_class = StudBuildTourSerializer - - @extend_schema( - responses={201: StudBuildTourSerializer, - 400: None} - ) - def post(self, request): - """ - Create a new StudentAtBuildingOnTour - """ - data = request_to_dict(request.data) - student_at_building_on_tour_instance = StudentAtBuildingOnTour() - - set_keys_of_instance(student_at_building_on_tour_instance, data, TRANSLATE) - - if r := try_full_clean_and_save(student_at_building_on_tour_instance): - return r - - return post_success(StudBuildTourSerializer(student_at_building_on_tour_instance)) - - -class BuildingTourPerStudentView(APIView): - serializer_class = StudBuildTourSerializer - - def get(self, request, student_id): - """ - Get all StudentAtBuildingOnTour for a student with given id - """ - student_at_building_on_tour_instances = StudentAtBuildingOnTour.objects.filter(student_id=student_id) - serializer = StudBuildTourSerializer(student_at_building_on_tour_instances, many=True) - return get_success(serializer) - - -class StudentAtBuildingOnTourIndividualView(APIView): - serializer_class = StudBuildTourSerializer - - @extend_schema( - responses={200: StudBuildTourSerializer, - 400: None} - ) - def get(self, request, student_at_building_on_tour_id): - """ - Get an individual StudentAtBuildingOnTour with given id - """ - stud_tour_building_instance = StudentAtBuildingOnTour.objects.filter(id=student_at_building_on_tour_id) - - if len(stud_tour_building_instance) != 1: - return bad_request("StudentAtBuildingOnTour") - - serializer = StudBuildTourSerializer(stud_tour_building_instance[0]) - return get_success(serializer) - - @extend_schema( - responses={200: StudBuildTourSerializer, - 400: None} - ) - def patch(self, request, student_at_building_on_tour_id): - """ - Edit info about an individual StudentAtBuildingOnTour with given id - """ - stud_tour_building_instances = StudentAtBuildingOnTour.objects.filter(id=student_at_building_on_tour_id) - - if len(stud_tour_building_instances) != 1: - return bad_request("StudentAtBuildingOnTour") - - stud_tour_building_instance = stud_tour_building_instances[0] - - data = request_to_dict(request.data) - - set_keys_of_instance(stud_tour_building_instance, data, TRANSLATE) - - if r := try_full_clean_and_save(stud_tour_building_instance): - return r - - serializer = StudBuildTourSerializer(stud_tour_building_instance) - return patch_success(serializer) - - @extend_schema( - responses={204: None, - 400: None} - ) - def delete(self, request, student_at_building_on_tour_id): - """ - Delete StudentAtBuildingOnTour with given id - """ - stud_tour_building_instances = StudentAtBuildingOnTour.objects.filter(id=student_at_building_on_tour_id) - if len(stud_tour_building_instances) != 1: - return bad_request("StudentAtBuildingOnTour") - stud_tour_building_instances[0].delete() - return delete_success() - - -class AllView(APIView): - serializer_class = StudBuildTourSerializer - - def get(self, request): - """ - Get all StudentAtBuildingOnTours - """ - stud_tour_building_instance = StudentAtBuildingOnTour.objects.all() - - serializer = StudBuildTourSerializer(stud_tour_building_instance, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/backend/student_on_tour/__init__.py b/backend/student_on_tour/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/student_on_tour/scrambled1.png b/backend/student_on_tour/scrambled1.png new file mode 100644 index 00000000..d6bc01ce Binary files /dev/null and b/backend/student_on_tour/scrambled1.png differ diff --git a/backend/student_on_tour/tests.py b/backend/student_on_tour/tests.py new file mode 100644 index 00000000..588e4434 --- /dev/null +++ b/backend/student_on_tour/tests.py @@ -0,0 +1,239 @@ +from django.test import TestCase +from rest_framework.test import APIClient + +from base.test_settings import backend_url, roles +from util.data_generators import createUser, insert_dummy_building_on_tour + + +class StudBuildTourTests(TestCase): + def test_empty_comment_list(self): + user = createUser() + client = APIClient() + client.force_authenticate(user=user) + resp = client.get(f"{backend_url}/student-at-building-on-tour/all", follow=True) + assert resp.status_code == 200 + data = [resp.data[e] for e in resp.data] + assert len(data) == 0 + + def test_insert_comment(self): + user = createUser(withRegion=True) + client = APIClient() + client.force_authenticate(user=user) + b_id = insert_dummy_building_on_tour() + data = {"building_on_tour": b_id, "date": "2023-03-08", "student": user.id} + resp = client.post(f"{backend_url}/student-at-building-on-tour/", data, follow=True) + assert resp.status_code == 201 + for key in data: + assert key in resp.data + assert "id" in resp.data + + def test_insert_dupe_comment(self): + user = createUser(withRegion=True) + client = APIClient() + client.force_authenticate(user=user) + b_id = insert_dummy_building_on_tour() + data = {"building_on_tour": b_id, "date": "2023-03-08", "student": user.id} + + _ = client.post(f"{backend_url}/student-at-building-on-tour/", data, follow=True) + response = client.post(f"{backend_url}/student-at-building-on-tour/", data, follow=True) + assert response.status_code == 400 + + def test_get_comment(self): + user = createUser(withRegion=True) + client = APIClient() + client.force_authenticate(user=user) + b_id = insert_dummy_building_on_tour() + data = {"building_on_tour": b_id, "date": "2023-03-08", "student": user.id} + + response1 = client.post(f"{backend_url}/student-at-building-on-tour/", data, follow=True) + assert response1.status_code == 201 + for key in data: + assert key in response1.data + assert "id" in response1.data + id = response1.data["id"] + response2 = client.get(f"{backend_url}/student-at-building-on-tour/{id}/", follow=True) + assert response2.status_code == 200 + for key in data: + assert key in response2.data + assert "id" in response2.data + + def test_get_non_existing(self): + user = createUser() + client = APIClient() + client.force_authenticate(user) + resp = client.get(f"{backend_url}/student-at-building-on-tour/123456789", follow=True) + assert resp.status_code == 404 + + def test_patch_comment(self): + user = createUser(withRegion=True) + client = APIClient() + client.force_authenticate(user=user) + b_id = insert_dummy_building_on_tour() + data1 = {"building_on_tour": b_id, "date": "2023-03-08", "student": user.id} + data2 = {"building_on_tour": b_id, "date": "2023-03-10", "student": user.id} + response1 = client.post(f"{backend_url}/student-at-building-on-tour/", data1, follow=True) + assert response1.status_code == 201 + id = response1.data["id"] + response2 = client.patch(f"{backend_url}/student-at-building-on-tour/{id}/", data2, follow=True) + assert response2.status_code == 200 + response3 = client.get(f"{backend_url}/student-at-building-on-tour/{id}/", follow=True) + for key in data2: + assert key in response3.data + assert response3.status_code == 200 + assert "id" in response3.data + + def test_patch_invalid_comment(self): + user = createUser(withRegion=True) + client = APIClient() + client.force_authenticate(user=user) + b_id = insert_dummy_building_on_tour() + data = {"building_on_tour": b_id, "date": "2023-03-08", "student": user.id} + response2 = client.patch(f"{backend_url}/student-at-building-on-tour/123434687658/", data, follow=True) + assert response2.status_code == 404 + + def test_patch_error_comment(self): + user = createUser(withRegion=True) + client = APIClient() + client.force_authenticate(user=user) + b_id = insert_dummy_building_on_tour() + data1 = {"building_on_tour": b_id, "date": "2023-03-08", "student": user.id} + data2 = {"building_on_tour": b_id, "date": "2023-03-10", "student": user.id} + response1 = client.post(f"{backend_url}/student-at-building-on-tour/", data1, follow=True) + _ = client.post(f"{backend_url}/student-at-building-on-tour/", data2, follow=True) + assert response1.status_code == 201 + id = response1.data["id"] + response2 = client.patch(f"{backend_url}/student-at-building-on-tour/{id}/", data2, follow=True) + assert response2.status_code == 400 + + def test_remove_comment(self): + user = createUser(withRegion=True) + client = APIClient() + client.force_authenticate(user=user) + b_id = insert_dummy_building_on_tour() + data1 = {"building_on_tour": b_id, "date": "2023-03-08", "student": user.id} + response1 = client.post(f"{backend_url}/student-at-building-on-tour/", data1, follow=True) + assert response1.status_code == 201 + id = response1.data["id"] + response2 = client.delete(f"{backend_url}/student-at-building-on-tour/{id}/", follow=True) + assert response2.status_code == 204 + response3 = client.get(f"{backend_url}/student-at-building-on-tour/{id}/", follow=True) + assert response3.status_code == 404 + + def test_remove_nonexistent_comment(self): + user = createUser() + client = APIClient() + client.force_authenticate(user=user) + response2 = client.delete(f"{backend_url}/student-at-building-on-tour/123456789/", follow=True) + assert response2.status_code == 404 + + def test_add_existing_comment(self): + user = createUser(withRegion=True) + client = APIClient() + client.force_authenticate(user=user) + b_id = insert_dummy_building_on_tour() + data = {"building_on_tour": b_id, "date": "2023-03-08", "student": user.id} + _ = client.post(f"{backend_url}/student-at-building-on-tour/", data, follow=True) + response1 = client.post(f"{backend_url}/student-at-building-on-tour/", data, follow=True) + assert response1.status_code == 400 + + +class StudBuildTourAuthorizationTests(TestCase): + def test_student_at_building_on_tour_list(self): + codes = {"Default": 403, "Admin": 200, "Superstudent": 200, "Student": 403, "Syndic": 403} + for role in roles: + user = createUser(role) + client = APIClient() + client.force_authenticate(user=user) + resp = client.get(f"{backend_url}/student-at-building-on-tour/all") + assert resp.status_code == codes[role] + + def test_insert_student_at_building_on_tour(self): + codes = {"Default": 403, "Admin": 201, "Superstudent": 201, "Student": 403, "Syndic": 403} + adminUser = createUser() + adminClient = APIClient() + adminClient.force_authenticate(user=adminUser) + + b_id = insert_dummy_building_on_tour() + user = createUser("Student", withRegion=True) + data = {"building_on_tour": b_id, "date": "2023-03-08", "student": user.id} + + for role in roles: + user = createUser(role) + client = APIClient() + client.force_authenticate(user=user) + + resp = client.post(f"{backend_url}/student-at-building-on-tour/", data, follow=True) + if resp.status_code != codes[role]: + print(f"role: {role}\tcode: {resp.status_code} (expected {codes[role]})") + print(resp.data) + assert resp.status_code == codes[role] + if resp.status_code == 201: + id = resp.data["id"] + adminClient.delete(f"{backend_url}/student-at-building-on-tour/{id}/", follow=True) + + def test_get_student_at_building_on_tour(self): + codes = {"Default": 403, "Admin": 200, "Superstudent": 200, "Student": 403, "Syndic": 403} + adminUser = createUser() + adminClient = APIClient() + adminClient.force_authenticate(user=adminUser) + + b_id = insert_dummy_building_on_tour() + user = createUser("Student", withRegion=True) + data = {"building_on_tour": b_id, "date": "2023-03-08", "student": user.id} + + response1 = adminClient.post(f"{backend_url}/student-at-building-on-tour/", data, follow=True) + + id = response1.data["id"] + assert response1.status_code == 201 + for role in roles: + user = createUser(role) + client = APIClient() + client.force_authenticate(user=user) + response2 = client.get(f"{backend_url}/student-at-building-on-tour/{id}/", follow=True) + if response2.status_code != codes[role]: + print(f"role: {role}\tcode: {response2.status_code} (expected {codes[role]})") + assert response2.status_code == codes[role] + + def test_patch_student_at_building_on_tour(self): + codes = {"Default": 403, "Admin": 200, "Superstudent": 200, "Student": 403, "Syndic": 403} + adminUser = createUser() + adminClient = APIClient() + adminClient.force_authenticate(user=adminUser) + + b_id = insert_dummy_building_on_tour() + user = createUser("Student", withRegion=True) + data1 = {"building_on_tour": b_id, "date": "2023-03-08", "student": user.id} + data2 = {"building_on_tour": b_id, "date": "2023-03-10", "student": user.id} + + response1 = adminClient.post(f"{backend_url}/student-at-building-on-tour/", data1, follow=True) + id = response1.data["id"] + for role in roles: + user = createUser(role) + client = APIClient() + client.force_authenticate(user) + response2 = client.patch(f"{backend_url}/student-at-building-on-tour/{id}/", data2, follow=True) + assert response2.status_code == codes[role] + + def test_remove_student_at_building_on_tour(self): + codes = {"Default": 403, "Admin": 204, "Superstudent": 204, "Student": 403, "Syndic": 403} + adminUser = createUser() + adminClient = APIClient() + adminClient.force_authenticate(user=adminUser) + + b_id = insert_dummy_building_on_tour() + user = createUser("Student", withRegion=True) + data1 = {"building_on_tour": b_id, "date": "2023-03-08", "student": user.id} + + exists = False + for role in roles: + if not exists: + # building toevoegen als admin + response1 = adminClient.post(f"{backend_url}/student-at-building-on-tour/", data1, follow=True) + id = response1.data["id"] + # proberen verwijderen als `role` + user = createUser(role) + client = APIClient() + client.force_authenticate(user) + response2 = client.delete(f"{backend_url}/student-at-building-on-tour/{id}/", follow=True) + assert response2.status_code == codes[role] + exists = codes[role] != 204 diff --git a/backend/student_on_tour/urls.py b/backend/student_on_tour/urls.py new file mode 100644 index 00000000..71d67f96 --- /dev/null +++ b/backend/student_on_tour/urls.py @@ -0,0 +1,18 @@ +from django.urls import path + +from .views import ( + StudentOnTourIndividualView, + TourPerStudentView, + AllView, + Default, +) + +urlpatterns = [ + path( + "/", + StudentOnTourIndividualView.as_view(), + ), + path("student//", TourPerStudentView.as_view()), + path("all/", AllView.as_view()), + path("", Default.as_view()), +] diff --git a/backend/student_on_tour/views.py b/backend/student_on_tour/views.py new file mode 100644 index 00000000..04bc0b12 --- /dev/null +++ b/backend/student_on_tour/views.py @@ -0,0 +1,161 @@ +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema +from rest_framework.permissions import IsAuthenticated +from rest_framework.views import APIView + +from base.models import StudentOnTour +from base.permissions import IsAdmin, IsSuperStudent, OwnerAccount, ReadOnlyOwnerAccount, IsStudent +from base.serializers import StudOnTourSerializer +from util.request_response_util import * + +TRANSLATE = {"tour": "tour_id", "student": "student_id"} + + +class Default(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent] + serializer_class = StudOnTourSerializer + + @extend_schema(responses=post_docs(StudOnTourSerializer)) + def post(self, request): + """ + Create a new StudentOnTour + """ + data = request_to_dict(request.data) + student_on_tour_instance = StudentOnTour() + + set_keys_of_instance(student_on_tour_instance, data, TRANSLATE) + + if r := try_full_clean_and_save(student_on_tour_instance): + return r + + return post_success(StudOnTourSerializer(student_on_tour_instance)) + + +class TourPerStudentView(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent | (IsStudent & OwnerAccount)] + serializer_class = StudOnTourSerializer + + @extend_schema( + parameters=param_docs( + { + "start-date": ("Filter by start-date", False, OpenApiTypes.DATE), + "end-date": ("Filter by end-date", False, OpenApiTypes.DATE), + } + ) + ) + def get(self, request, student_id): + """ + Get all StudentOnTour for a student with given id + """ + id_holder = type("", (), {})() + id_holder.id = student_id + self.check_object_permissions(request, id_holder) + + filters = { + "start-date": get_filter_object("date__gte"), + "end-date": get_filter_object("date__lte"), + } + student_on_tour_instances = StudentOnTour.objects.filter(student_id=student_id) + try: + student_on_tour_instances = filter_instances(request, student_on_tour_instances, filters) + except BadRequest as e: + return Response({"message": str(e)}, status=status.HTTP_400_BAD_REQUEST) + serializer = StudOnTourSerializer(student_on_tour_instances, many=True) + return get_success(serializer) + + +class StudentOnTourIndividualView(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent | (IsStudent & ReadOnlyOwnerAccount)] + serializer_class = StudOnTourSerializer + + @extend_schema(responses=get_docs(StudOnTourSerializer)) + def get(self, request, student_on_tour_id): + """ + Get an individual StudentOnTour with given id + """ + stud_tour_instances = StudentOnTour.objects.filter(id=student_on_tour_id) + + if len(stud_tour_instances) != 1: + return not_found("StudentOnTour") + stud_tour_instance = stud_tour_instances[0] + + self.check_object_permissions(request, stud_tour_instance.student) + + serializer = StudOnTourSerializer(stud_tour_instance) + return get_success(serializer) + + @extend_schema(responses=patch_docs(StudOnTourSerializer)) + def patch(self, request, student_on_tour_id): + """ + Edit info about an individual StudentOnTour with given id + """ + stud_tour_instances = StudentOnTour.objects.filter(id=student_on_tour_id) + + if len(stud_tour_instances) != 1: + return not_found("StudentOnTour") + + stud_tour_instance = stud_tour_instances[0] + + self.check_object_permissions(request, stud_tour_instance.student) + + data = request_to_dict(request.data) + + set_keys_of_instance(stud_tour_instance, data, TRANSLATE) + + if r := try_full_clean_and_save(stud_tour_instance): + return r + + serializer = StudOnTourSerializer(stud_tour_instance) + return patch_success(serializer) + + @extend_schema(responses=delete_docs()) + def delete(self, request, student_on_tour_id): + """ + Delete StudentOnTour with given id + """ + stud_tour_instances = StudentOnTour.objects.filter(id=student_on_tour_id) + if len(stud_tour_instances) != 1: + return not_found("StudentOnTour") + stud_tour_instance = stud_tour_instances[0] + + self.check_object_permissions(request, stud_tour_instance.student) + + stud_tour_instance.delete() + return delete_success() + + +class AllView(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent] + serializer_class = StudOnTourSerializer + + @extend_schema( + parameters=param_docs( + { + "start-date": ("Filter by start date", False, OpenApiTypes.DATE), + "end-date": ("Filter by end-date", False, OpenApiTypes.DATE), + "student": ("Filter by student (ID)", False, OpenApiTypes.INT), + "tour": ("Filter by tour (ID)", False, OpenApiTypes.INT), + "region": ("Filter by region (ID)", False, OpenApiTypes.INT), + } + ) + ) + def get(self, request): + """ + Get all StudentOnTours + """ + filters = { + "tour-id": get_filter_object("tour_id"), + "region-id": get_filter_object("tour__region_id"), + "start-date": get_filter_object("date__gte"), + "end-date": get_filter_object("date__lte"), + "student-id": get_filter_object("student_id"), + } + stud_on_tour_instances = StudentOnTour.objects.all() + + try: + stud_on_tour_instances = filter_instances(request, stud_on_tour_instances, filters) + except BadRequest as e: + return Response({"message": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + serializer = StudOnTourSerializer(stud_on_tour_instances, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/backend/templates/account/email/password_reset_key_message.html b/backend/templates/account/email/password_reset_key_message.html new file mode 100644 index 00000000..e067ccbe --- /dev/null +++ b/backend/templates/account/email/password_reset_key_message.html @@ -0,0 +1,116 @@ + + + + + + Wachtwoord opnieuw instellen + + + +

+ +
+ +
+

Wachtwoord vergeten?

+ +

Beste {{ first_name }}

+ +

Je hebt een aanvraag ingediend op {{ current_site }} om een nieuw paswoord in te + stellen. Klik op + onderstaande + knop om + deze aanvraag verder te zetten: +

+ + + + +

Als je deze aanvraag niet hebt gedaan, hoef je niets te doen. Je kunt gewoon inloggen + met je bestaande wachtwoord.

+ +

Met vriendelijke groeten
Het DrTrottoir-team

+
+ + + + diff --git a/backend/templates/account/email/password_reset_key_message.txt b/backend/templates/account/email/password_reset_key_message.txt new file mode 100644 index 00000000..887520df --- /dev/null +++ b/backend/templates/account/email/password_reset_key_message.txt @@ -0,0 +1,9 @@ +Beste {{ first_name }} + +Je hebt een aanvraag ingediend op {{ current_site }} om een nieuw paswoord in te stellen. Klik op onderstaande link om deze aanvraag verder te zetten: + +{{ password_reset_url }} + +Als je deze aanvraag niet hebt gedaan, hoef je niets te doen. Je kunt gewoon inloggen met je bestaande wachtwoord. + +Met vriendelijke groeten
Het DrTrottoir-team \ No newline at end of file diff --git a/backend/templates/account/email/password_reset_key_subject.txt b/backend/templates/account/email/password_reset_key_subject.txt new file mode 100644 index 00000000..c433e0f3 --- /dev/null +++ b/backend/templates/account/email/password_reset_key_subject.txt @@ -0,0 +1 @@ +DrTrottoir wachtwoord herstellen \ No newline at end of file diff --git a/backend/tour/apps.py b/backend/tour/apps.py deleted file mode 100644 index 1b9c0b3a..00000000 --- a/backend/tour/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class TourConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'tour' diff --git a/backend/tour/tests.py b/backend/tour/tests.py index 7ce503c2..9e1a03e2 100644 --- a/backend/tour/tests.py +++ b/backend/tour/tests.py @@ -1,3 +1,91 @@ -from django.test import TestCase +from base.models import Tour +from base.serializers import TourSerializer +from util.data_generators import insert_dummy_region, insert_dummy_tour +from util.test_tools import BaseTest, BaseAuthTest -# Create your tests here. + +class TourTests(BaseTest): + def __init__(self, methodName="runTest"): + super().__init__(methodName) + + def test_empty_tour_list(self): + self.empty_list("tour/") + + def test_insert_tour(self): + r_id = insert_dummy_region() + self.data1 = {"name": "Sterre", "region": r_id, "modified_at": "2023-03-08T12:08:29+01:00"} + self.insert("tour/") + + def test_insert_empty(self): + self.insert_empty("tour/") + + def test_insert_dupe_tour(self): + r_id = insert_dummy_region() + self.data1 = {"name": "Sterre", "region": r_id, "modified_at": "2023-03-08T12:08:29+01:00"} + self.insert_dupe("tour/") + + def test_get_tour(self): + t_id = insert_dummy_tour() + data = TourSerializer(Tour.objects.get(id=t_id)).data + self.get(f"tour/{t_id}", data) + + def test_get_non_existing(self): + self.get_non_existent("tour/") + + def test_patch_tour(self): + t_id = insert_dummy_tour() + r_id = insert_dummy_region() + self.data1 = {"name": "Overpoort", "region": r_id, "modified_at": "2023-03-08T12:08:29+01:00"} + self.patch(f"tour/{t_id}") + + def test_patch_invalid_tour(self): + r_id = insert_dummy_region() + self.data1 = {"name": "Sterre", "region": r_id, "modified_at": "2023-03-08T12:08:29+01:00"} + self.patch_invalid("tour/") + + def test_patch_error_tour(self): + r_id = insert_dummy_region() + self.data1 = {"name": "Sterre", "region": r_id, "modified_at": "2023-03-08T12:08:29+01:00"} + self.data2 = {"name": "Overpoort", "region": r_id, "modified_at": "2023-03-08T12:08:29+01:00"} + self.patch_error("tour/") + + def test_remove_tour(self): + t_id = insert_dummy_tour() + self.remove(f"tour/{t_id}") + + def test_remove_nonexistent_tour(self): + self.remove_invalid("tour/") + + +class TourAuthorizationTests(BaseAuthTest): + def __init__(self, methodName="runTest"): + super().__init__(methodName) + + def test_lobby_tour(self): + codes = {"Default": 403, "Admin": 200, "Superstudent": 200, "Student": 403, "Syndic": 403} + self.list_view("tour/", codes) + + def test_insert_tour(self): + codes = {"Default": 403, "Admin": 201, "Superstudent": 201, "Student": 403, "Syndic": 403} + r_id = insert_dummy_region() + self.data1 = {"name": "Sterre", "region": r_id, "modified_at": "2023-03-08T12:08:29+01:00"} + self.insert_view("tour/", codes) + + def test_get_tour(self): + codes = {"Default": 403, "Admin": 200, "Superstudent": 200, "Student": 200, "Syndic": 403} + t_id = insert_dummy_tour() + self.get_view(f"tour/{t_id}", codes) + + def test_patch_tour(self): + codes = {"Default": 403, "Admin": 200, "Superstudent": 200, "Student": 403, "Syndic": 403} + t_id = insert_dummy_tour() + r_id = insert_dummy_region() + self.data1 = {"name": "OverPoort", "region": r_id, "modified_at": "2023-03-08T12:08:29+01:00"} + self.patch_view(f"tour/{t_id}", codes) + + def test_remove_tour(self): + def create(): + return insert_dummy_tour() + + codes = {"Default": 403, "Admin": 204, "Superstudent": 204, "Student": 403, "Syndic": 403} + self.remove_view("tour/", codes, create=create) diff --git a/backend/tour/urls.py b/backend/tour/urls.py index 53af4c67..93f501b2 100644 --- a/backend/tour/urls.py +++ b/backend/tour/urls.py @@ -1,13 +1,11 @@ from django.urls import path -from .views import ( - TourIndividualView, - AllToursView, - Default -) +from .views import TourIndividualView, AllToursView, Default, AllBuildingsOnTourView, BuildingSwapView urlpatterns = [ - path('/', TourIndividualView.as_view()), - path('all/', AllToursView.as_view()), - path('', Default.as_view()) + path("/", TourIndividualView.as_view()), + path("/sequence/", BuildingSwapView.as_view()), + path("/buildings/", AllBuildingsOnTourView.as_view()), + path("all/", AllToursView.as_view()), + path("", Default.as_view()), ] diff --git a/backend/tour/views.py b/backend/tour/views.py index cc346889..e3fd3f87 100644 --- a/backend/tour/views.py +++ b/backend/tour/views.py @@ -1,22 +1,22 @@ -from rest_framework import permissions +from queue import PriorityQueue + +from drf_spectacular.utils import extend_schema, OpenApiExample +from rest_framework.permissions import IsAuthenticated from rest_framework.views import APIView -from base.models import Tour -from base.serializers import TourSerializer +from base.models import Tour, BuildingOnTour, Building +from base.permissions import IsAdmin, IsSuperStudent, ReadOnlyStudent +from base.serializers import TourSerializer, BuildingSerializer, SuccessSerializer, BuildingSwapRequestSerializer from util.request_response_util import * -from drf_spectacular.utils import extend_schema TRANSLATE = {"region": "region_id"} class Default(APIView): - permission_classes = [permissions.IsAuthenticated] + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent] serializer_class = TourSerializer - @extend_schema( - responses={201: TourSerializer, - 400: None} - ) + @extend_schema(responses=post_docs(TourSerializer)) def post(self, request): """ Create a new tour @@ -32,13 +32,69 @@ def post(self, request): return post_success(TourSerializer(tour_instance)) -class TourIndividualView(APIView): +class BuildingSwapView(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent] serializer_class = TourSerializer + description = "Note that buildingID should also be an integer." + @extend_schema( - responses={200: TourSerializer, - 400: None} + description="POST body consists of a list of building_id - index pairs that will be assigned to this tour. " + "This enables the frontend to restructure a tour in 1 request instead of multiple. If a building is " + "added to the tour (no BuildingOnTour entry existed before), a new entry will be created. If buildings that " + "were originally on the tour are left out, they will be removed from the tour." + "The indices that should be used in the request start at 0 and should be incremented 1 at a time.", + request=BuildingSwapRequestSerializer, + responses={200: SuccessSerializer, 400: None}, + examples=[ + OpenApiExample( + "Set 2 buildings on the tour", + value={"buildingID1": 0, "buildingID2": 1}, + description=description, + request_only=True, + ), + OpenApiExample( + "Reorder more than 2 buildings", + value={"buildingID1": 1, "buildingID2": 3, "buildingID3": 2, "buildingID4": 0}, + description=description + " The new order of buildings will be [4,1,3,2]", + request_only=True, + ), + ], ) + def post(self, request, tour_id): + data = request_to_dict(request.data) + tour = Tour.objects.filter(id=tour_id).first() + if not tour: + return not_found("Tour") + items = BuildingOnTour.objects.filter(tour=tour) + items.delete() + q = PriorityQueue() + max_index = -1 + for b_id, i in data.items(): + index = int(i) + 1 + if index < 0: + return bad_request("Index") + if index > max_index: + max_index = index + q.put((index, b_id)) + if len(data.items()) > max_index: + return bad_request("Index") + while not q.empty(): + index, b_id = q.get() + instance = BuildingOnTour(index=index, building_id=b_id, tour=tour) + if r := try_full_clean_and_save(instance): + return r + + dummy = type("", (), {})() + dummy.data = {"data": "success"} + return post_success(serializer=dummy) + + +class TourIndividualView(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent | ReadOnlyStudent] + serializer_class = TourSerializer + + @extend_schema(responses=get_docs(TourSerializer)) def get(self, request, tour_id): """ Get info about a Tour with given id @@ -46,17 +102,13 @@ def get(self, request, tour_id): tour_instances = Tour.objects.filter(id=tour_id) if not tour_instances: - return bad_request(object_name="Tour") + return not_found(object_name="Tour") tour_instance = tour_instances[0] serializer = TourSerializer(tour_instance) return get_success(serializer) - - @extend_schema( - responses={200: TourSerializer, - 400: None} - ) + @extend_schema(responses=patch_docs(TourSerializer)) def patch(self, request, tour_id): """ Edit a tour with given id @@ -65,7 +117,7 @@ def patch(self, request, tour_id): data = request_to_dict(request.data) if not tour_instance: - return bad_request(object_name="Tour") + return not_found(object_name="Tour") tour_instance = tour_instance[0] @@ -76,10 +128,7 @@ def patch(self, request, tour_id): return patch_success(TourSerializer(tour_instance)) - @extend_schema( - responses={204: None, - 400: None} - ) + @extend_schema(responses=delete_docs()) def delete(self, request, tour_id): """ Delete a tour with given id @@ -87,15 +136,32 @@ def delete(self, request, tour_id): tour_instances = Tour.objects.filter(id=tour_id) if not tour_instances: - return bad_request(object_name="Tour") + return not_found(object_name="Tour") tour_instances[0].delete() return delete_success() +class AllBuildingsOnTourView(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent | ReadOnlyStudent] + serializer_class = BuildingSerializer + + @extend_schema(responses=get_docs(BuildingSerializer)) + def get(self, request, tour_id): + """ + Get all buildings on a tour with given tour id. The buildings in the response body are ordered by their index. + """ + building_instances = Building.objects.filter(buildingontour__tour_id=tour_id).order_by("buildingontour__index") + + serializer = BuildingSerializer(building_instances, many=True) + return get_success(serializer) + + class AllToursView(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent] serializer_class = TourSerializer + @extend_schema(responses=get_docs(TourSerializer)) def get(self, request): """ Get all tours diff --git a/backend/users/managers.py b/backend/users/managers.py index 4c644326..413d416e 100644 --- a/backend/users/managers.py +++ b/backend/users/managers.py @@ -15,9 +15,7 @@ def create_user(self, email, password, **extra_fields): if not email: raise ValueError(_("Email is required")) email = self.normalize_email(email) - user = self.model( - email=email, - **extra_fields) + user = self.model(email=email, **extra_fields) user.set_password(password) user.save() return user diff --git a/backend/users/tests.py b/backend/users/tests.py index d5b2fcec..b4546abb 100644 --- a/backend/users/tests.py +++ b/backend/users/tests.py @@ -1,9 +1,13 @@ from django.contrib.auth import get_user_model from django.test import TestCase +from base.models import User +from base.serializers import UserSerializer +from util.data_generators import insert_dummy_region, insert_dummy_role, insert_test_user +from util.test_tools import BaseTest -class UsersManagersTests(TestCase): +class UsersManagersTests(TestCase): def test_create_user(self): User = get_user_model() user = User.objects.create_user(email="normal@user.com", password="foo") @@ -38,5 +42,142 @@ def test_create_superuser(self): except AttributeError: pass with self.assertRaises(ValueError): - User.objects.create_superuser( - email="super@user.com", password="foo", is_superuser=False) + User.objects.create_superuser(email="super@user.com", password="foo", is_superuser=False) + + +class UserTests(BaseTest): + def __init__(self, methodName="runTest"): + super().__init__(methodName) + + def test_insert_user(self): + G_id = insert_dummy_region("Gent") + B_id = insert_dummy_region("Brugge") + R_id = insert_dummy_role("admin") + self.data1 = { + "username": "testUser", + "email": "testuser@example.com", + "is_staff": True, + "is_active": False, + "first_name": "Test", + "last_name": "User", + "phone_number": "+32487172529", + "role": R_id, + "region": [ + G_id, + B_id, + ], + "password": "testPassword", + } + self.insert("user/", excluded=["username", "is_staff", "password"]) + + def test_insert_empty(self): + self.insert_empty("user/") + + def test_insert_dupe(self): + G_id = insert_dummy_region("Gent") + B_id = insert_dummy_region("Brugge") + R_id = insert_dummy_role("admin") + self.data1 = { + "username": "testUser", + "email": "testuser@example.com", + "is_staff": True, + "is_active": False, + "first_name": "Test", + "last_name": "User", + "phone_number": "+32487172529", + "role": R_id, + "region": [ + G_id, + B_id, + ], + "password": "testPassword", + } + self.insert_dupe("user/") + + def test_get_user(self): + u = insert_test_user() + data = UserSerializer(User.objects.get(id=u)).data + self.get(f"user/{u}", data) + + def test_get_non_existing(self): + self.get_non_existent("user/") + + def test_patch_user(self): + u = insert_test_user(role="superstudent") + B_id = insert_dummy_region("Brugge") + R_id = insert_dummy_role("student") + self.data1 = { + "username": "testUserfdsq", + "email": "testuserfdsq@example.com", + "is_staff": False, + "is_active": True, + "first_name": "Test1", + "last_name": "User1", + "phone_number": "+32487172525", + "role": R_id, + "region": [ + B_id, + ], + "password": "testPassword1", + } + self.patch(f"user/{u}", excluded=["username", "is_staff", "password"]) + + def test_patch_invalid_user(self): + B_id = insert_dummy_region("Brugge") + R_id = insert_dummy_role("student") + self.data1 = { + "username": "testUserfdsq", + "email": "testuserfdsq@example.com", + "is_staff": False, + "is_active": True, + "first_name": "Test1", + "last_name": "User1", + "phone_number": "+32487172525", + "role": R_id, + "region": [ + B_id, + ], + "password": "testPassword1", + } + self.patch_invalid("user/") + + def test_patch_error_user(self): + B_id = insert_dummy_region("Brugge") + R_id = insert_dummy_role("student") + self.data1 = { + "username": "testUserfdsq", + "email": "testuserfdsq@example.com", + "is_staff": False, + "is_active": True, + "first_name": "Test1", + "last_name": "User1", + "phone_number": "+32487172525", + "role": R_id, + "region": [ + B_id, + ], + "password": "testPassword1", + } + self.data1 = { + "username": "fd", + "email": "fd@example.com", + "is_staff": True, + "is_active": False, + "first_name": "te", + "last_name": "fdsq", + "phone_number": "+32485172525", + "role": R_id, + "region": [ + B_id, + ], + "password": "456", + } + self.patch_invalid("user/") + + def test_remove_user(self): + u = insert_test_user(role="student") + # special case: user should still exist but will be inactive + self.remove(f"user/{u}", 200) + + def test_remove_nonexistent_user(self): + self.remove_invalid("user/") diff --git a/backend/users/urls.py b/backend/users/urls.py index 41ba50d3..cbe4a62c 100644 --- a/backend/users/urls.py +++ b/backend/users/urls.py @@ -1,13 +1,9 @@ from django.urls import path -from .views import ( - UserIndividualView, - AllUsersView, - DefaultUser -) +from .views import UserIndividualView, AllUsersView, DefaultUser urlpatterns = [ - path('/', UserIndividualView.as_view()), - path('all/', AllUsersView.as_view()), - path('', DefaultUser.as_view()) + path("/", UserIndividualView.as_view()), + path("all/", AllUsersView.as_view(), name="user-list"), + path("", DefaultUser.as_view()), ] diff --git a/backend/users/user_utils.py b/backend/users/user_utils.py new file mode 100644 index 00000000..ad7d45cc --- /dev/null +++ b/backend/users/user_utils.py @@ -0,0 +1,31 @@ +from django.db import IntegrityError +from rest_framework import status +from rest_framework.response import Response + + +def try_adding_region_to_user_instance(user_instance, region_value): + try: + user_instance.region.add(region_value) + except IntegrityError as e: + return Response(str(e.__cause__), status=status.HTTP_400_BAD_REQUEST) + + +def add_regions_to_user(user_instance, regions): + # if not regions_raw: + # return + + try: + # regions = list(map(lambda x: int(x), regions_raw.strip("][").split(","))) + if not type(regions) == list: + raise SyntaxError() + + except (SyntaxError, ValueError): + return Response( + {"message": "Invalid syntax. Regions must be a list of id's"}, status=status.HTTP_400_BAD_REQUEST + ) + + user_instance.region.clear() + + for region in regions: + if r := try_adding_region_to_user_instance(user_instance, region): + return r diff --git a/backend/users/views.py b/backend/users/views.py index dd40c5e6..fea427ba 100644 --- a/backend/users/views.py +++ b/backend/users/views.py @@ -1,45 +1,40 @@ -import json - -from rest_framework import permissions +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema +from rest_framework.permissions import IsAuthenticated from rest_framework.views import APIView from base.models import User +from base.permissions import ( + IsAdmin, + IsSuperStudent, + OwnerAccount, + CanEditUser, + CanEditRole, + CanDeleteUser, + CanCreateUser, +) from base.serializers import UserSerializer +from users.user_utils import add_regions_to_user from util.request_response_util import * -from drf_spectacular.utils import extend_schema - TRANSLATE = {"role": "role_id"} -# In GET, you only get active users -# Except when you explicitly pass a parameter 'include_inactive' to the body of the request and set it as true -# If you -def _include_inactive(request) -> bool: - data = request_to_dict(request.data) - if "include_inactive" in data: - return data["include_inactive"] - return False - - -def _try_adding_region_to_user_instance(user_instance, region_value): - try: - user_instance.region.add(region_value) - except IntegrityError as e: - user_instance.delete() - return Response(str(e.__cause__), status=status.HTTP_400_BAD_REQUEST) +DESCRIPTION = "For region, pass a list of id's. For example: [1, 2, 3]" class DefaultUser(APIView): + permission_classes = [IsAuthenticated, CanCreateUser] serializer_class = UserSerializer - # TODO: authorization - # permission_classes = [permissions.IsAuthenticated] - # TODO: in order for this to work, you have to pass a password - # In the future, we probably won't use POST this way anymore (if we work with the whitelist method) - @extend_schema( - responses={201: UserSerializer, - 400: None} - ) + @extend_schema(responses=get_docs(UserSerializer)) + def get(self, request): + """ + Get the current user info + """ + serializer = UserSerializer(request.user) + return get_success(serializer) + + @extend_schema(responses=post_docs(UserSerializer)) def post(self, request): """ Create a new user @@ -50,29 +45,32 @@ def post(self, request): set_keys_of_instance(user_instance, data, TRANSLATE) + self.check_object_permissions(request, user_instance) + if r := try_full_clean_and_save(user_instance): return r # Now that we have an ID, we can look at the many-to-many relationship region - if "region" in data.keys(): - region_dict = json.loads(data["region"]) - for value in region_dict.values(): - if r := _try_adding_region_to_user_instance(user_instance, value): - return r + if r := add_regions_to_user(user_instance, data["region"]): + user_instance.delete() + return r serializer = UserSerializer(user_instance) return post_success(serializer) class UserIndividualView(APIView): + permission_classes = [ + IsAuthenticated, + IsAdmin | IsSuperStudent | OwnerAccount, + CanEditUser, + CanEditRole, + CanDeleteUser, + ] serializer_class = UserSerializer - permission_classes = [permissions.IsAuthenticated] - @extend_schema( - responses={200: UserSerializer, - 400: None} - ) + @extend_schema(responses=get_docs(UserSerializer)) def get(self, request, user_id): """ Get info about user with given id @@ -80,35 +78,33 @@ def get(self, request, user_id): user_instance = User.objects.filter(id=user_id) if not user_instance: - return bad_request(object_name="User") + return not_found(object_name="User") + user_instance = user_instance[0] - serializer = UserSerializer(user_instance[0]) + self.check_object_permissions(request, user_instance) + + serializer = UserSerializer(user_instance) return get_success(serializer) - @extend_schema( - responses={204: None, - 400: None} - ) + @extend_schema(responses=delete_docs()) def delete(self, request, user_id): """ Delete user with given id - We don't acutally delete a user, we put the user on inactive mode + We don't actually delete a user, we put the user on inactive mode """ user_instance = User.objects.filter(id=user_id) if not user_instance: - return bad_request(object_name="User") - + return not_found(object_name="User") user_instance = user_instance[0] + self.check_object_permissions(request, user_instance) + user_instance.is_active = False user_instance.save() return delete_success() - @extend_schema( - responses={200: UserSerializer, - 400: None} - ) + @extend_schema(responses=patch_docs(UserSerializer)) def patch(self, request, user_id): """ Edit user with given id @@ -116,10 +112,12 @@ def patch(self, request, user_id): user_instance = User.objects.filter(id=user_id) if not user_instance: - return bad_request(object_name="User") + return not_found(object_name="User") user_instance = user_instance[0] + self.check_object_permissions(request, user_instance) + data = request_to_dict(request.data) set_keys_of_instance(user_instance, data, TRANSLATE) @@ -128,27 +126,55 @@ def patch(self, request, user_id): return r # Now that we have an ID, we can look at the many-to-many relationship region - if "region" in data.keys(): - region_dict = json.loads(data["region"]) - user_instance.region.clear() - for value in region_dict.values(): - if r := _try_adding_region_to_user_instance(user_instance, value): - return r + if data.get("region") and (r := add_regions_to_user(user_instance, data["region"])): + return r serializer = UserSerializer(user_instance) return patch_success(serializer) class AllUsersView(APIView): + permission_classes = [IsAuthenticated, IsAdmin | IsSuperStudent] serializer_class = UserSerializer + @extend_schema( + description="GET all users in the database. There is the possibility to filter as well. You can filter on " + "various parameters. If the parameter name includes 'list' then you can add multiple entries of " + "those in the url.", + parameters=param_docs( + { + "region-id-list": ("Filter by region ids", False, OpenApiTypes.INT), + "include-inactive-bool": ("Include the inactive users", False, OpenApiTypes.BOOL), + "include-role-name-list": ("Include all the users with specific role names", False, OpenApiTypes.STR), + "exclude-role-name-list": ("Exclude all the users with specific role names", False, OpenApiTypes.STR), + } + ), + ) def get(self, request): """ Get all users """ - if _include_inactive(request): - user_instances = User.objects.all() - else: - user_instances = User.objects.filter(is_active=True) + + user_instances = User.objects.all() + filters = { + "region-id-list": get_filter_object("region__in"), + "include-inactive-bool": get_filter_object("is_active"), + "include-role-name-list": get_filter_object("role__name__in"), + "exclude-role-name-list": get_filter_object("role__name__in", exclude=True), + } + + def transformations(key, param_value): + if key == "include-inactive-bool": + return None if param_value else True + elif key in ["include-role-name-list", "exclude-role-name-list"]: + return list(map(lambda role: role.lower().capitalize(), param_value)) if param_value else param_value + else: + return param_value + + try: + user_instances = filter_instances(request, user_instances, filters, transformations) + except BadRequest as e: + return Response({"message": str(e)}, status=status.HTTP_400_BAD_REQUEST) + serializer = UserSerializer(user_instances, many=True) return get_success(serializer) diff --git a/backend/util/data_generators.py b/backend/util/data_generators.py new file mode 100644 index 00000000..7e8f68cf --- /dev/null +++ b/backend/util/data_generators.py @@ -0,0 +1,251 @@ +import hashlib +import io +import mimetypes +from datetime import date, datetime +from datetime import timedelta + +import pytz +from django.core.files.uploadedfile import InMemoryUploadedFile + +from base.models import ( + User, + Region, + Building, + Tour, + Role, + BuildingOnTour, + StudentOnTour, + RemarkAtBuilding, + PictureOfRemark, + BuildingComment, + EmailTemplate, + GarbageCollection, + Lobby, + Manual, +) + + +def insert_dummy_region(name="Gent"): + o = Region.objects.filter(region=name) + if len(o) == 1: + return o[0].id + r = Region(region=name) + r.save() + return r.id + + +ranks = { + "admin": 1, + "superstudent": 2, + "student": 3, + "syndic": 3, +} + + +def insert_dummy_role(role): + o = Role.objects.filter(name=role.lower()) + if len(o) == 1: + return o[0].id + r = Role(name=role.lower(), rank=ranks.get(role.lower(), 2147483647), description="testrole") + r.save() + return r.id + + +def insert_test_user( + first_name: str = "test_student", + last_name: str = "test", + phone_number="+32467240957", + role: str = "admin", +) -> int: + global email_counter + o = User.objects.filter(first_name=first_name).first() + if o: + return o.id + s = User( + first_name=first_name, + last_name=last_name, + phone_number=phone_number, + role=Role.objects.get(id=insert_dummy_role(role)), + email=f"test_{email_counter}@test.com", + ) + email_counter += 1 + s.save() + return s.id + + +def insert_dummy_syndic(): + return insert_test_user(first_name="test_syn", role="syndic") + + +def insert_dummy_student(): + return insert_test_user(first_name="test_std", role="student") + + +email_counter = 0 + + +def createUser(name: str = "admin", is_staff: bool = True, withRegion: bool = False) -> User: + global email_counter + r = Role.objects.get(id=insert_dummy_role(name)) + user = User( + first_name="test", + last_name="test", + email=f"test_{email_counter}@test.com", + is_staff=is_staff, + is_active=True, + phone_number="+32485710347", + role=r, + ) + email_counter += 1 + user.save() + if withRegion: + r_id = insert_dummy_region() + user.region.add(Region.objects.get(id=r_id)) + return user + + +index = 0 + + +def insert_dummy_tour(): + global index + r_id = insert_dummy_region() + t = Tour(name=f"Sterre S{index}", region=Region.objects.get(id=r_id), modified_at="2023-03-08T12:08:29+01:00") + index += 1 + t.save() + return t.id + + +number = 1 + + +def insert_dummy_building(street="Overpoort"): + global number + r_id = insert_dummy_region() + s_id = insert_dummy_syndic() + b = Building( + city="Gent", + postal_code=9000, + street=street, + house_number=number, + client_number="1234567890abcdef", + duration="1:00:00", + region=Region.objects.get(id=r_id), + syndic=User.objects.get(id=s_id), + name="CB", + ) + number += 1 + b.save() + return b.id + + +def insert_dummy_building_on_tour(): + t_id = insert_dummy_tour() + b_id = insert_dummy_building() + BoT = BuildingOnTour(tour=Tour.objects.get(id=t_id), building=Building.objects.get(id=b_id), index=0) + BoT.save() + return BoT.id + + +def insert_dummy_student_on_tour(): + SoT = StudentOnTour(tour_id=insert_dummy_tour(), date=date.today(), student_id=insert_dummy_student()) + SoT.save() + return SoT.id + + +def insert_dummy_remark_at_building(): + RaB = RemarkAtBuilding( + student_on_tour_id=insert_dummy_student_on_tour(), + building_id=insert_dummy_building(), + remark="illegal dumping", + timestamp=datetime.now(pytz.utc), + type="AA", + ) + RaB.save() + return RaB.id + + +def insert_dummy_picture_of_remark(picture): + f = picture + hashed_image = hashlib.sha1() + hashed_image.update(f.open().read()) + PoR = PictureOfRemark( + picture=picture, + remark_at_building=RemarkAtBuilding.objects.get(id=insert_dummy_remark_at_building()), + hash=hashed_image, + ) + PoR.save() + return PoR.id + + +def insert_dummy_building_comment(): + b_id = insert_dummy_building() + BC = BuildingComment(comment="<3 python", date="2023-03-08T12:08:29+01:00", building=Building.objects.get(id=b_id)) + BC.save() + return BC.id + + +def createMemoryFile(filename: str): + filename = filename.strip() + with open(filename, "rb") as file: + file_object = io.BytesIO(file.read()) + file_object.seek(0) + file_object.read() + size = file_object.tell() + file_object.seek(0) + + content_type, charset = mimetypes.guess_type(filename) + + return InMemoryUploadedFile( + file=file_object, name=filename, field_name=None, content_type=content_type, charset=charset, size=size + ) + + +title = "a" + + +def insert_dummy_email_template(): + global title + ET = EmailTemplate(name="testTemplate " + title, template="

{{name}

") + title += "a" + ET.save() + return ET.id + + +d = date.today() + + +def insert_dummy_garbage(): + global d + b_id = insert_dummy_building() + garbage = GarbageCollection(building=Building.objects.get(id=b_id), date=d, garbage_type="RES") + d += timedelta(days=1) + garbage.save() + return garbage.id + + +def insert_dummy_lobby(): + global email_counter + r_id = insert_dummy_role("Student") + lobby = Lobby( + email=f"test_lobby_{email_counter}@example.com", + role=Role.objects.get(id=r_id), + verification_code="azerty>qwerty", + ) + email_counter += 1 + lobby.save() + return lobby.id + + +f = createMemoryFile("./manual/lorem-ipsum.pdf") + +version = 0 + + +def insert_dummy_manual(): + global version + b_id = insert_dummy_building() + m = Manual(building=Building.objects.get(id=b_id), file=f, version_number=version) + version += 1 + m.save() + return m.id diff --git a/backend/util/request_response_util.py b/backend/util/request_response_util.py index ae2b6d85..b04cc827 100644 --- a/backend/util/request_response_util.py +++ b/backend/util/request_response_util.py @@ -1,9 +1,143 @@ -from django.core.exceptions import ValidationError, ObjectDoesNotExist -from django.db import IntegrityError +import uuid +from datetime import datetime +from typing import Callable + +from django.core.exceptions import ValidationError, BadRequest +from django.utils.translation import gettext_lazy as _ +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter from rest_framework import status from rest_framework.response import Response +def get_id_param(request, name, required=False): + param = request.GET.get(name, None) + if param: + if not param.isdigit(): + raise BadRequest(_(f"The query parameter {name} should be an integer")) + else: + if required: + raise BadRequest(_(f"The query parameter {name} is required")) + return param + + +def get_date_param(request, name, required=False): + param = request.GET.get(name, None) + if param: + try: + param = datetime.strptime(param, "%Y-%m-%d") + except ValueError: + raise BadRequest( + _("The date parameter '{name}': '{param}' hasn't the appropriate form (=YYYY-MM-DD).").format( + name=name, param=param + ) + ) + else: + if required: + raise BadRequest(_("The query parameter {name} is required").format(name=name)) + return param + + +def get_boolean_param(request, name, required=False): + param = request.GET.get(name, None) + if param is None: + if required: + raise BadRequest(_("The query parameter {name} is required").format(name=name)) + else: + return None + elif param.lower() == "true": + return True + elif param.lower() == "false": + return False + else: + raise BadRequest( + _("Invalid value for boolean parameter '{name}': '{param}' (true or false expected)").format( + name=name, param=param + ) + ) + + +def get_list_param(request, name, required=False): + param = request.GET.getlist(name) + if not param: + if required: + raise BadRequest(_("The query parameter {name} is required").format(name=name)) + else: + return None + return param + + +def get_param(request, key, required): + if "date" in key: + return get_date_param(request, key, required) + elif "list" in key: + param_list = get_list_param(request, key, required) + if param_list and "id" in key: + return list(map(int, param_list)) + return param_list + elif "id" in key: + return get_id_param(request, key, required) + elif "bool" in key: + return get_boolean_param(request, key, required) + # add more conditions here as needed + else: + return None + + +def get_most_recent_param_docs(obj="object"): + return { + "most-recent": ( + f"When set to 'true', only the most recent {obj} will be returned", + False, + OpenApiTypes.BOOL, + ) + } + + +def get_maybe_most_recent_param(request, instances, serializer, order_by) -> Response: + most_recent_only = False + param = request.GET.get("most-recent", None) + if param: + if param.capitalize() not in ["True", "False"]: + return Response( + {"message": f"Invalid value for boolean parameter 'most-recent': {param} (true or false expected)"}, + status=status.HTTP_400_BAD_REQUEST, + ) + else: + most_recent_only = param.lower() == "true" + if most_recent_only: + instances = instances.order_by(order_by).first() + + return get_success(serializer(instances, many=not most_recent_only)) + + +def get_filter_object(filter_key: str, required=False, exclude=False) -> dict: + return {"filter_key": filter_key, "required": required, "exclude": exclude} + + +def filter_instances(request, instances, filters, query_param_value_transformation=lambda k, v: v): + for key, filter_object in filters.items(): + param_value = get_param(request, key, filter_object["required"]) + param_value = query_param_value_transformation(key, param_value) + if param_value is not None: + if filter_object["exclude"]: + instances = instances.exclude(**{filter_object["filter_key"]: param_value}) + else: + instances = instances.filter(**{filter_object["filter_key"]: param_value}) + return instances + + +def get_unique_uuid(lookup_func: Callable[[str], bool] = None): + # https://docs.python.org/3/library/uuid.html + out_id = uuid.uuid4().hex + + # Normally it should never happen that the generated `id` is not unique, + # but just to be theoretically sure, you can pass a function that checks if the uuid is already in the database + while lookup_func and lookup_func(out_id): + out_id = uuid.uuid4().hex + return out_id + + def set_keys_of_instance(instance, data: dict, translation: dict = {}): for key in translation.keys(): if key in data: @@ -16,17 +150,22 @@ def set_keys_of_instance(instance, data: dict, translation: dict = {}): return instance +def not_found(object_name="Object"): + return Response({"message": _("{} was not found").format(object_name)}, status=status.HTTP_404_NOT_FOUND) + + def bad_request(object_name="Object"): - return Response( - {"res", f"{object_name} with given ID does not exist."}, - status=status.HTTP_400_BAD_REQUEST - ) + return Response({"message": _("bad input for {}").format(object_name)}, status=status.HTTP_400_BAD_REQUEST) def bad_request_relation(object1: str, object2: str): return Response( - {"res", f"There is no {object1} that is linked to {object2} with given id."}, - status=status.HTTP_400_BAD_REQUEST + { + "message": _("There is no {object1} that is linked to {object2} with given id.").format( + object1=object1, object2=object2 + ) + }, + status=status.HTTP_400_BAD_REQUEST, ) @@ -37,12 +176,7 @@ def try_full_clean_and_save(model_instance, rm=False): model_instance.save() except ValidationError as e: error_message = e.message_dict - except AttributeError as e: - # If body is empty, an attribute error is thrown in the clean function - # if there is not checked whether the fields in self are intialized - error_message = str(e) + \ - ". This error could be thrown after you passed an empty body with e.g. a POST request." - except (IntegrityError, ObjectDoesNotExist, ValueError) as e: + except Exception as e: error_message = str(e) finally: if rm: @@ -75,3 +209,30 @@ def get_success(serializer): def patch_success(serializer): return get_success(serializer) + + +def post_docs(serializer): + return {201: serializer, 400: None} + + +def delete_docs(): + return {204: None, 400: None} + + +def get_docs(serializer): + return {200: serializer, 400: None} + + +def patch_docs(serializer): + return get_docs(serializer) + + +def param_docs(values): + """ + values (dict) : this a dictionary with the name of a parameter as its key and the triplet + (description, required, type) as value + """ + docs = [] + for name, value in values.items(): + docs.append(OpenApiParameter(name=name, description=value[0], required=value[1], type=value[2])) + return docs diff --git a/backend/util/test_tools.py b/backend/util/test_tools.py new file mode 100644 index 00000000..fb48f675 --- /dev/null +++ b/backend/util/test_tools.py @@ -0,0 +1,305 @@ +import json +from copy import deepcopy + +from django.core.files.uploadedfile import InMemoryUploadedFile +from django.test import TestCase +from rest_framework.test import APIClient + +from base.models import User +from base.test_settings import backend_url, roles +from util.data_generators import createUser + + +def get_authenticated_client(role="admin"): + user = createUser(role) + client = APIClient() + client.force_authenticate(user=user) + return client + + +def errorMessage(name, expected, got): + return f"[TEST FAILED]\tname: {name}\tgot: {got} (type: {type(got)})\texpected: {expected} (type: {type(expected)})" + + +class BaseTest(TestCase): + data1 = None + data2 = None + + def setUp(self): + self.client = get_authenticated_client() + + def empty_list(self, url): + resp = self.client.get(backend_url + "/" + url + "all/", follow=True) + assert resp.status_code == 200, errorMessage("empty_list", 200, resp.status_code) + data = [resp.data[e] for e in resp.data] + assert len(data) == 0, errorMessage("empty_list", 0, len(data)) + + def insert(self, url, excluded=None): + if excluded is None: + excluded = [] + assert self.data1 is not None, "no data found" + data = self.data1 + if "region" in self.data1: + data = json.dumps(self.data1) + resp = self.client.post(backend_url + "/" + url, data, content_type="application/json", follow=True) + else: + resp = self.client.post(backend_url + "/" + url, data, follow=True) + assert resp.status_code == 201, errorMessage("insert", 201, resp.status_code) + for key in self.data1: + if key in excluded: + continue + assert key in resp.data, errorMessage("insert", key, None) + if type(self.data1[key]) == InMemoryUploadedFile: + continue + assert self.data1[key] == resp.data[key], errorMessage( + f"insert (key:{key})", self.data1[key], resp.data[key] + ) + assert "id" in resp.data, errorMessage("insert", "id", None) + + def insert_empty(self, url): + resp = self.client.post(backend_url + "/" + url, {}, follow=True) + print(resp) + assert resp.status_code == 400, errorMessage("insert_empty", 400, resp.status_code) + + def insert_dupe(self, url, special=None): + assert self.data1 is not None, "no data found" + if "region" in self.data1: + data = json.dumps(self.data1) + _ = self.client.post(backend_url + "/" + url, data, content_type="application/json", follow=True) + else: + _ = self.client.post(backend_url + "/" + url, self.data1, follow=True) + # _ = self.client.post(backend_url + "/" + url, self.data1, follow=True) + if "region" in self.data1: + data = json.dumps(self.data1) + response = self.client.post(backend_url + "/" + url, data, content_type="application/json", follow=True) + else: + response = self.client.post(backend_url + "/" + url, self.data1, follow=True) + + # response = self.client.post(backend_url + "/" + url, self.data1, follow=True) + if special is None: + assert response.status_code == 400, errorMessage("insert_dupe", 400, response.status_code) + else: + assert response.status_code == special, errorMessage("insert_dupe", special, response.status_code) + + def get(self, url, data): + response2 = self.client.get(backend_url + "/" + url, follow=True) + assert response2.status_code == 200, errorMessage("get", 200, response2.status_code) + for key in data: + # all data should be present + assert key in response2.data, errorMessage("get", key, None) + if type(data[key]) == InMemoryUploadedFile: + continue + assert data[key] == response2.data[key], errorMessage("get (key:{key})", data[key], response2.data[key]) + # an ID should be present + assert "id" in response2.data, errorMessage("insert", "id", None) + + def get_non_existent(self, url): + resp = self.client.get(backend_url + "/" + url + "1234567", follow=True) + assert resp.status_code == 404, errorMessage("get_non_existent", 404, resp.status_code) + + def patch(self, url, special=None, excluded=None): + if excluded is None: + excluded = [] + if special is None: + special = [] + assert self.data1 is not None, "no data found" + if "region" in self.data1: + data = json.dumps(self.data1) + response2 = self.client.patch(backend_url + "/" + url, data, content_type="application/json", follow=True) + else: + response2 = self.client.patch(backend_url + "/" + url, self.data1, follow=True) + # response2 = self.client.patch(backend_url + "/" + url, self.data1, follow=True) + assert response2.status_code == 200, errorMessage("patch", 200, response2.status_code) + response3 = self.client.get(backend_url + "/" + url, follow=True) + checked = [] + for key, value in special: + if key in excluded: + continue + assert key in response3.data, errorMessage("patch", key, None) + assert value == response3.data[key], errorMessage("patch", value, response3.data[key]) + checked.append(key) + for key in self.data1: + if key in checked or key in excluded: + continue + # all data should be present + assert key in response3.data, errorMessage("patch", key, None) + if type(self.data1[key]) == InMemoryUploadedFile: + continue + assert self.data1[key] == response3.data[key], errorMessage("patch", self.data1[key], response3.data[key]) + assert response3.status_code == 200, errorMessage("patch", 200, response3.status_code) + assert "id" in response3.data, errorMessage("patch", "id", None) + + def patch_invalid(self, url): + if "region" in self.data1: + data = json.dumps(self.data1) + response2 = self.client.patch( + backend_url + "/" + url + "123434687658/", data, content_type="application/json", follow=True + ) + else: + response2 = self.client.patch(backend_url + "/" + url + "123434687658/", self.data1, follow=True) + # response2 = self.client.patch(backend_url + "/" + url + "123434687658/", self.data1, follow=True) + assert response2.status_code == 404, errorMessage("patch_invalid", 404, response2.status_code) + + def patch_error(self, url, special=None): + assert self.data1 is not None, "no data found" + assert self.data2 is not None, "no data found" + # taking a deepcopy to fix file issues when uploading pictures + backup = deepcopy(self.data2) + if "region" in self.data1: + data = json.dumps(self.data1) + response1 = self.client.post(backend_url + "/" + url, data, content_type="application/json", follow=True) + else: + response1 = self.client.post(backend_url + "/" + url, self.data1, follow=True) + + # response1 = self.client.post(backend_url + "/" + url, self.data1, follow=True) + if "region" in self.data2: + data = json.dumps(self.data2) + _ = self.client.post(backend_url + "/" + url, data, content_type="application/json", follow=True) + else: + _ = self.client.post(backend_url + "/" + url, self.data2, follow=True) + + # _ = self.client.post(backend_url + "/" + url, self.data2, follow=True) + assert response1.status_code == 201, errorMessage("patch_error", 201, response1.status_code) + result_id = response1.data["id"] + if "region" in backup: + data = json.dumps(backup) + response2 = self.client.patch( + backend_url + "/" + url + f"{result_id}", data, content_type="application/json", follow=True + ) + else: + response2 = self.client.patch(backend_url + "/" + url + f"{result_id}", backup, follow=True) + + # response2 = self.client.patch(backend_url + "/" + url + f"{result_id}/", backup, follow=True) + if special is None: + assert response2.status_code == 400, errorMessage("patch_error", 400, response2.status_code) + else: + assert response2.status_code == special, errorMessage("patch_error", special, response2.status_code) + + def remove(self, url, special=None): + response2 = self.client.delete(backend_url + "/" + url, follow=True) + assert response2.status_code == 204, errorMessage("remove", 204, response2.status_code) + response3 = self.client.get(backend_url + "/" + url, follow=True) + if special is not None: + assert response3.status_code == special, errorMessage("remove", special, response3.status_code) + else: + assert response3.status_code == 404, errorMessage("remove", 404, response3.status_code) + + def remove_invalid(self, url): + response2 = self.client.delete(backend_url + "/" + url + "123434687658", follow=True) + assert response2.status_code == 404, errorMessage("remove_invalid", 404, response2.status_code) + + +def auth_error_message(name, role, got, expected): + return f"[TEST FAILED]\tname: {name}\trole: {role}\tcode: {got} (expected {expected})" + + +class BaseAuthTest(TestCase): + data1 = None + + def list_view(self, url, codes): + for role in roles: + client = get_authenticated_client(role) + resp = client.get(backend_url + "/" + url + "all/") + assert resp.status_code == codes[role], auth_error_message("list_view", role, resp.status_code, codes[role]) + + def insert_view(self, url, codes, special=None): + if special is None: + special = [] + adminClient = get_authenticated_client() + for role in roles: + client = get_authenticated_client(role) + if "region" in self.data1: + data = json.dumps(self.data1) + resp = client.post(backend_url + "/" + url, data, content_type="application/json", follow=True) + else: + resp = client.post(backend_url + "/" + url, self.data1, follow=True) + + # resp = client.post(backend_url + "/" + url, self.data1, follow=True) + assert resp.status_code == codes[role], auth_error_message( + "insert_view", role, resp.status_code, codes[role] + ) + if resp.status_code == 201: + result_id = resp.data["id"] + _ = adminClient.delete(backend_url + "/" + url + str(result_id)) + for user_id, result in special: + user = User.objects.filter(id=user_id).first() # there should only be 1 + if not User: + raise ValueError("user not valid") + client = APIClient() + client.force_authenticate(user=user) + # response2 = client.post(backend_url + "/" + url, self.data1, follow=True) + if "region" in self.data1: + data = json.dumps(self.data1) + response2 = client.post(backend_url + "/" + url, data, content_type="application/json", follow=True) + else: + response2 = client.post(backend_url + "/" + url, self.data1, follow=True) + assert response2.status_code == result, auth_error_message( + "insert_view (special case)", user.role.name, response2.status_code, result + ) + if response2.status_code == 201: + result_id = response2.data["id"] + _ = adminClient.delete(backend_url + "/" + url + str(result_id)) + + def get_view(self, url, codes, special=None): + if special is None: + special = [] + for role in roles: + client = get_authenticated_client(role) + response2 = client.get(backend_url + "/" + url, follow=True) + assert response2.status_code == codes[role], auth_error_message( + "get_view", role, response2.status_code, codes[role] + ) + for user_id, result in special: + user = User.objects.filter(id=user_id).first() # there should only be 1 + if not User: + raise ValueError("user not valid") + client = APIClient() + client.force_authenticate(user=user) + response2 = client.get(backend_url + "/" + url, follow=True) + assert response2.status_code == result, auth_error_message( + "get_view (special case)", user.role.name, response2.status_code, result + ) + + def patch_view(self, url, codes, special=None): + if special is None: + special = [] + for role in roles: + client = get_authenticated_client(role) + if "region" in self.data1: + data = json.dumps(self.data1) + response2 = client.patch(backend_url + "/" + url, data, content_type="application/json", follow=True) + else: + response2 = client.patch(backend_url + "/" + url, self.data1, follow=True) + # response2 = client.patch(backend_url + "/" + url, self.data1, follow=True) + assert response2.status_code == codes[role], auth_error_message( + "patch_view", role, response2.status_code, codes[role] + ) + for user_id, result in special: + user = User.objects.filter(id=user_id).first() # there should only be 1 + if not User: + raise ValueError("user not valid") + client = APIClient() + client.force_authenticate(user=user) + if "region" in self.data1: + data = json.dumps(self.data1) + response2 = client.patch(backend_url + "/" + url, data, content_type="application/json", follow=True) + else: + response2 = client.patch(backend_url + "/" + url, self.data1, follow=True) + # response2 = client.patch(backend_url + "/" + url, self.data1, follow=True) + assert response2.status_code == result, auth_error_message( + "patch_view (special case)", user.role.name, response2.status_code, result + ) + + def remove_view(self, url, codes, create): + exists = False + instance_id = -1 + for role in roles: + if not exists: + instance_id = create() + # try to remove as `role` + client = get_authenticated_client(role) + response2 = client.delete(backend_url + "/" + url + f"{instance_id}/", follow=True) + assert response2.status_code == codes[role], auth_error_message( + "remove_view", role, response2.status_code, codes[role] + ) + exists = codes[role] != 204 diff --git a/backend/util/util.py b/backend/util/util.py new file mode 100644 index 00000000..11229e76 --- /dev/null +++ b/backend/util/util.py @@ -0,0 +1,17 @@ +from datetime import datetime, timedelta + + +def get_monday_of_week(date_str) -> datetime: + """ + Gives you the monday of the week wherein the date resides + """ + date = datetime.strptime(date_str, "%Y-%m-%d") + return date - timedelta(days=date.weekday()) + + +def get_sunday_of_week(date_str: str) -> datetime: + """ + Gives you the sunday of the week wherein the date resides + """ + date = datetime.strptime(date_str, "%Y-%m-%d") + return date - timedelta(days=date.weekday() + 1) + timedelta(days=6) diff --git a/docker-compose.yml b/docker-compose.yml index a62145c5..fdaffa49 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,5 @@ +version: "2.4" + services: reverseproxy: image: reverseproxy @@ -26,13 +28,24 @@ services: volumes: - ./data/drtrottoir:/var/lib/postgresql/data/ + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U django -d drtrottoir" ] + interval: 5s + timeout: 5s + retries: 5 + frontend: build: context: ./frontend dockerfile: Dockerfile volumes: - - ./frontend:/app/frontend + - ./frontend/pages:/app/frontend/pages + - ./frontend/components:/app/frontend/components + - ./frontend/lib:/app/frontend/lib + - ./frontend/public:/app/frontend/public + - ./frontend/styles:/app/frontend/styles + - ./frontend/locales:/app/frontend/locales depends_on: - backend @@ -51,5 +64,8 @@ services: volumes: - ./backend:/app/backend + mem_limit: 4g + depends_on: - - web \ No newline at end of file + web: + condition: service_healthy \ No newline at end of file diff --git a/docs/db/ER_diagram.jpg b/docs/db/ER_diagram.jpg new file mode 100644 index 00000000..218095c5 Binary files /dev/null and b/docs/db/ER_diagram.jpg differ diff --git a/docs/img/create_superuser.png b/docs/img/create_superuser.png new file mode 100644 index 00000000..58aee761 Binary files /dev/null and b/docs/img/create_superuser.png differ diff --git a/docs/img/user_after_extend.jpg b/docs/img/user_after_extend.jpg new file mode 100644 index 00000000..eb714eaa Binary files /dev/null and b/docs/img/user_after_extend.jpg differ diff --git a/docs/img/user_before_extend.jpg b/docs/img/user_before_extend.jpg new file mode 100644 index 00000000..e39a42b9 Binary files /dev/null and b/docs/img/user_before_extend.jpg differ diff --git a/frontend/.env.local b/frontend/.env.local new file mode 100644 index 00000000..cc40b602 --- /dev/null +++ b/frontend/.env.local @@ -0,0 +1,68 @@ +NEXT_PUBLIC_HOST=localhost +NEXT_PUBLIC_HTTP_PORT=80 +NEXT_PUBLIC_HTTPS_PORT=443 + + +NEXT_PUBLIC_BASE_API_URL=http://$NEXT_PUBLIC_HOST/api/ +NEXT_PUBLIC_API_LOGIN=authentication/login/ +NEXT_PUBLIC_API_SIGNUP=authentication/signup/ +NEXT_PUBLIC_API_LOGOUT=authentication/logout/ +NEXT_PUBLIC_API_RESET_PASSWORD=authentication/password/reset/ +NEXT_PUBLIC_API_CHANGE_PASSWORD=authentication/password/change/ +NEXT_PUBLIC_API_REFRESH_TOKEN=authentication/token/refresh/ +NEXT_PUBLIC_API_VERIFY_TOKEN=authentication/token/verify/ + +NEXT_PUBLIC_API_USER=user/ +NEXT_PUBLIC_API_ALL_USERS=user/all/ + +NEXT_PUBLIC_API_MANUAL=manual/ +NEXT_PUBLIC_API_MANUAL_BUILDING=manual/building/ +NEXT_PUBLIC_API_ALL_MANUALS=manual/all/ + +NEXT_PUBLIC_API_BUILDING=building/ +NEXT_PUBLIC_API_ALL_BUILDINGS=building/all/ +NEXT_PUBLIC_API_OWNER_BUILDING=building/owner/ +NEXT_PUBLIC_API_PUBLIC_ID_BUILDING=building/public/ +NEXT_PUBLIC_API_NEW_PUBLIC_ID_BUILDING=building/new-public-id/ +NEXT_PUBLIC_API_GET_NEW_PUBLIC_ID_BUILDING=building/random-new-public-id/ + +NEXT_PUBLIC_API_BUILDING_COMMENT=building-comment/ +NEXT_PUBLIC_API_BUILDING_COMMENT_BUILDING=building-comment/building/ +NEXT_PUBLIC_API_ALL_BUILDING_COMMENTS=building-comment/all/ + +NEXT_PUBLIC_API_EMAIL_TEMPLATE=email-template/ +NEXT_PUBLIC_API_ALL_EMAIL_TEMPLATES=email-template/all/ + +NEXT_PUBLIC_API_LOBBY=lobby/ +NEXT_PUBLIC_API_LOBBY_NEW_CODE=lobby/new-verification-code/ +NEXT_PUBLIC_API_ALL_LOBBY=lobby/all/ + +NEXT_PUBLIC_API_REGION=region/ +NEXT_PUBLIC_API_ALL_REGIONS=region/all/ + +NEXT_PUBLIC_API_GARBAGE_COLLECTION=garbage-collection/ +NEXT_PUBLIC_API_GARBAGE_COLLECTION_BUILDING=garbage-collection/building/ +NEXT_PUBLIC_API_ALL_GARBAGE_COLLECTIONS=garbage-collection/all/ +NEXT_PUBLIC_API_DUPLICATE_GARBAGE_COLLECTION=garbage-collection/duplicate/ + +NEXT_PUBLIC_API_BUILDING_ON_TOUR=building-on-tour/ +NEXT_PUBLIC_API_ALL_BUILDINGS_ON_TOUR=building-on-tour/all/ +NEXT_PUBLIC_API_ALL_BUILDINGS_ON_TOUR_ID=building-on-tour/tour/ + +NEXT_PUBLIC_API_ROLE=role/ +NEXT_PUBLIC_API_ALL_ROLES=role/all/ + +NEXT_PUBLIC_API_STUDENT_ON_TOUR=student-on-tour/ +NEXT_PUBLIC_API_ALL_STUDENT_ON_TOURS=student-on-tour/all/ +NEXT_PUBLIC_API_TOURS_OF_STUDENT=student-on-tour/student/ + +NEXT_PUBLIC_API_REMARK_AT_BUILDING=remark-at-building/ +NEXT_PUBLIC_API_ALL_REMARKS=remark-at-building/all/ +NEXT_PUBLIC_API_REMARKS_OF_A_BUILDING=remark-at-building/building/ + +NEXT_PUBLIC_API_PICTURE_OF_REMARK=picture-of-remark/ +NEXT_PUBLIC_API_ALL_PICTURES=picture-of-remark/all/ +NEXT_PUBLIC_API_PICTURES_OF_A_REMARK=picture-of-remark/remark/ + +NEXT_PUBLIC_API_TOUR=tour/ +NEXT_PUBLIC_API_ALL_TOURS=tour/all/ diff --git a/frontend/.gitignore b/frontend/.gitignore index c87c9b39..f9be2184 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -25,9 +25,6 @@ yarn-debug.log* yarn-error.log* .pnpm-debug.log* -# local env files -.env*.local - # vercel .vercel diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 875b4c58..dc3326ae 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -4,12 +4,10 @@ WORKDIR /app/frontend/ COPY package*.json /app/frontend/ RUN npm install -RUN npm install axios -RUN npm install dotenv COPY . /app/frontend/ RUN npm run build -CMD ["npm", "run", "dev"] +ENTRYPOINT npm run dev diff --git a/frontend/components/admin/deleteEmailModal.tsx b/frontend/components/admin/deleteEmailModal.tsx new file mode 100644 index 00000000..1d055ff5 --- /dev/null +++ b/frontend/components/admin/deleteEmailModal.tsx @@ -0,0 +1,82 @@ +import { Button, Modal } from "react-bootstrap"; +import React, { useState } from "react"; +import { UserView } from "@/types"; +import { handleError } from "@/lib/error"; +import { useTranslation } from "react-i18next"; +import { deleteMailTemplate, Emailtemplate } from "@/lib/emailtemplate"; + +export function DeleteEmailModal({ + show, + closeModal, + selectedMail, + setMail, +}: { + show: boolean; + closeModal: () => void; + selectedMail: Emailtemplate | null; + setMail: (x: any) => void; +}) { + const { t } = useTranslation(); + const [errorMessages, setErrorMessages] = useState([]); + + /** + * Remove a user, show errors if necessary + */ + function removeMailTemplate() { + if (!selectedMail) { + return; + } + deleteMailTemplate(selectedMail.id).then( + () => { + setErrorMessages([]); + setMail(null); + closeModal(); + }, + (err) => { + const e = handleError(err); + setErrorMessages(e); + } + ); + } + + return ( + + + Verwijder template: + + {errorMessages.length !== 0 && ( +

+
    + {errorMessages.map((err, i) => ( +
  • {t(err)}
  • + ))} +
+ +
+ )} + Bent u zeker dat u template {selectedMail?.name} wil verwijderen? + + + + + + ); +} diff --git a/frontend/components/admin/editEmailModal.tsx b/frontend/components/admin/editEmailModal.tsx new file mode 100644 index 00000000..69a5f339 --- /dev/null +++ b/frontend/components/admin/editEmailModal.tsx @@ -0,0 +1,162 @@ +import { Emailtemplate, patchMailTemplate, postMailTemplate } from "@/lib/emailtemplate"; +import React, { useState } from "react"; +import { Button, Form, Modal } from "react-bootstrap"; +import { useTranslation } from "react-i18next"; +import { handleError } from "@/lib/error"; + +export default function ({ + show, + hideModal, + selectedEmail, + setEmail, + edit, +}: { + show: boolean; + hideModal: () => void; + selectedEmail: Emailtemplate | null; + setEmail: (x: any) => void; + edit: boolean; +}) { + const { t } = useTranslation(); + const [errorMessages, setErrorMessages] = useState([]); + + /** + * Edit a mailtemplate + */ + function editMail() { + if (!selectedEmail || !selectedEmail.name) { + setErrorMessages(["De naam van een template mag niet leeg zijn."]); + return; + } + if (!selectedEmail.template) { + setErrorMessages(["Een template mag niet leeg zijn."]); + return; + } + patchMailTemplate(selectedEmail.id, { name: selectedEmail.name, template: selectedEmail.template }).then( + (_) => { + setEmail(null); + hideModal(); + }, + (error) => { + const e = handleError(error); + setErrorMessages(e); + } + ); + } + + /** + * Create a new mailtemplate + */ + function createMail() { + if (!selectedEmail || !selectedEmail.name) { + setErrorMessages(["De naam van een template mag niet leeg zijn."]); + return; + } + if (!selectedEmail.template) { + setErrorMessages(["Een template mag niet leeg zijn."]); + return; + } + postMailTemplate(selectedEmail.name, selectedEmail.template).then( + (_) => { + setEmail(null); + hideModal(); + }, + (err) => { + const e = handleError(err); + setErrorMessages(e); + } + ); + } + + return ( + { + hideModal(); + setErrorMessages([]); + }} + > + {edit ? "Bewerk template" : "Nieuwe template"} + {errorMessages.length !== 0 && ( +
+
    + {errorMessages.map((err, i) => ( +
  • {t(err)}
  • + ))} +
+ +
+ )} + +
+
+ + ) => { + setEmail((prevState: Emailtemplate | null) => + prevState + ? { + ...prevState, + name: e.target.value, + } + : { id: 0, name: e.target.value, template: "" } + ); + }} + required + /> +
+
+ + + +
+
+ + { + handleFileChange(e); + e.target.value = ""; // reset the value of the input field + }} + accept="image/*" + /> +
+ + + + + + + + ); +} diff --git a/frontend/pages/student/dashboard.tsx b/frontend/pages/student/dashboard.tsx new file mode 100644 index 00000000..3806d391 --- /dev/null +++ b/frontend/pages/student/dashboard.tsx @@ -0,0 +1,137 @@ +import StudentHeader from "@/components/header/studentHeader"; +import { withAuthorisation } from "@/components/withAuthorisation"; +import { useEffect, useState } from "react"; +import { getToursOfStudent, StudentOnTour, StudentOnTourStringDate } from "@/lib/student-on-tour"; +import { getCurrentUser, User } from "@/lib/user"; +import { getTour, Tour } from "@/lib/tour"; +import { getRegion, RegionInterface } from "@/lib/region"; +import ToursList from "@/components/student/toursList"; +import { useRouter } from "next/router"; +import Loading from "@/components/loading"; +import { datesEqual } from "@/lib/date"; + +// https://www.figma.com/proto/9yLULhNn8b8SlsWlOnRSpm/SeLab2-mockup?node-id=32-29&scaling=contain&page-id=0%3A1&starting-point-node-id=118%3A1486 +function StudentDashboard() { + const router = useRouter(); + const [user, setUser] = useState(null); + const [toursToday, setToursToday] = useState([]); + const [prevTours, setPrevTours] = useState([]); + const [upcomingTours, setUpcomingTours] = useState([]); + const [tours, setTours] = useState>({}); + const [regions, setRegions] = useState>({}); + const [loading, setLoading] = useState(true); + + useEffect(() => { + getCurrentUser().then((res) => { + const u: User = res.data; + setUser(u); + }, console.error); + }, []); + + useEffect(() => { + if (!user) { + return; + } + // Get all the tours the student is/was assigned to from one month back to next month + const monthAgo: Date = new Date(); + monthAgo.setMonth(monthAgo.getMonth() - 1); // This also works for january to december + + const nextMonth: Date = new Date(); + nextMonth.setMonth(nextMonth.getMonth() + 1); + getToursOfStudent(user.id, { startDate: monthAgo, endDate: nextMonth }).then(async (res) => { + // Some cache to recognize duplicate tours (to not do unnecessary requests) + const t: Record = {}; + const r: Record = {}; + + const data: StudentOnTourStringDate[] = res.data; + + for (const rec of data) { + // Get the tours & regions of tours where the student was assigned to + if (!(rec.tour in t)) { + try { + const res = await getTour(rec.tour); + const tour: Tour = res.data; + t[tour.id] = tour; + setTours(t); + + if (!(tour.region in r)) { + // get the region + const resRegion = await getRegion(tour.region); + const region: RegionInterface = resRegion.data; + r[region.id] = region; + setRegions(r); + } + } catch (e) { + console.error(e); + } + } + } + // Get the tours today + const sot: StudentOnTour[] = data.map((s: StudentOnTourStringDate) => { + return { id: s.id, student: s.student, tour: s.tour, date: new Date(s.date) }; + }); + const today: StudentOnTour[] = sot.filter((s: StudentOnTour) => { + const d: Date = s.date; + const currentDate: Date = new Date(); + return datesEqual(d, currentDate); + }); + setToursToday(today); + setLoading(false); + + // Get the tours the student has done prev month + const finishedTours: StudentOnTour[] = sot.filter((s: StudentOnTour) => { + const d: Date = s.date; + const currentDate: Date = new Date(); + return d < currentDate && !datesEqual(d, currentDate); + }); + setPrevTours(finishedTours); + + // Get the tours the student is assigned to in the future + const futureTours: StudentOnTour[] = sot.filter((s: StudentOnTour) => { + const d: Date = s.date; + const currentDate: Date = new Date(); + return d > currentDate && !datesEqual(d, currentDate); + }); + setUpcomingTours(futureTours); + }, console.error); + }, [user]); + + function redirectToSchedule(studentOnTourId: number): void { + router + .push({ + pathname: "/student/schedule", + query: { studentOnTourId }, + }) + .then(); + } + + return ( + <> + + {loading && } + + + + + ); +} + +export default withAuthorisation(StudentDashboard, ["Student"]); diff --git a/frontend/pages/student/schedule.tsx b/frontend/pages/student/schedule.tsx new file mode 100644 index 00000000..10bb3139 --- /dev/null +++ b/frontend/pages/student/schedule.tsx @@ -0,0 +1,120 @@ +import StudentHeader from "@/components/header/studentHeader"; +import { useRouter } from "next/router"; +import React, { useEffect, useState } from "react"; +import { getBuildingsOfTour, getTour, Tour } from "@/lib/tour"; +import { getStudentOnTour, StudentOnTour, StudentOnTourStringDate } from "@/lib/student-on-tour"; +import { getRegion, RegionInterface } from "@/lib/region"; +import { BuildingInterface, getAddress } from "@/lib/building"; +import { Button } from "react-bootstrap"; +import { withAuthorisation } from "@/components/withAuthorisation"; +import { datesEqual } from "@/lib/date"; + +interface ParsedUrlQuery {} + +interface DataScheduleQuery extends ParsedUrlQuery { + studentOnTourId?: number; +} + +function StudentSchedule() { + const router = useRouter(); + const [tour, setTour] = useState(null); + const [studentOnTour, setStudentOnTour] = useState(null); + const [region, setRegion] = useState(""); + const [buildings, setBuildings] = useState([]); + + useEffect(() => { + const query: DataScheduleQuery = router.query as DataScheduleQuery; + if (query.studentOnTourId) { + getStudentOnTour(query.studentOnTourId).then((res) => { + const sots: StudentOnTourStringDate = res.data; + + // Get the tour info + getTour(sots.tour).then((res) => { + const t: Tour = res.data; + setTour(t); + + getRegion(t.region).then((res) => { + const r: RegionInterface = res.data; + setRegion(r.region); + }, console.error); + }, console.error); + getBuildingsOfTour(sots.tour).then( + (res) => { + const buildings: BuildingInterface[] = res.data; + setBuildings(buildings); + }, + (err) => { + console.error(err); + } + ); + // Set the studentOnTour state + setStudentOnTour({ + id: sots.id, + student: sots.student, + tour: sots.tour, + date: new Date(sots.date), + }); + }, console.error); + } + }, [router.isReady]); + + async function routeToFirstBuilding() { + if (buildings.length === 0) { + return; + } + await router.push({ + pathname: `/student/building`, + query: { studentOnTourId: studentOnTour?.id }, + }); + } + + return ( + <> + +
+ {tour ? `Ronde ${tour?.name}` : ""} +

{region ? `Regio ${region}` : ""}

+

{studentOnTour ? studentOnTour.date.toLocaleDateString("en-GB") : ""}

+ {buildings.length > 0 && ( + <> +

Gebouwen op deze ronde:

+
+ {buildings.map((el: BuildingInterface, index: number) => { + return ( + +
+
{getAddress(el)}
+ {index + 1} +
+

{el.name}

+
+ ); + })} +
+ + )} +
+ {(studentOnTour ? datesEqual(new Date(), studentOnTour?.date) : false) && ( + + )} + {studentOnTour && + !datesEqual(new Date(), studentOnTour.date) && + new Date() < studentOnTour.date && ( +

{`U kan deze ronde nog niet starten, kom terug op ${studentOnTour.date.toLocaleDateString( + "en-GB" + )}`}

+ )} + {studentOnTour && + !datesEqual(new Date(), studentOnTour.date) && + new Date() > studentOnTour.date && ( +

{`U hebt deze ronde afgewerkt op ${studentOnTour.date.toLocaleDateString("en-GB")}.`}

+ )} +
+
+ + ); +} + +export default withAuthorisation(StudentSchedule, ["Student"]); diff --git a/frontend/pages/syndic/building.tsx b/frontend/pages/syndic/building.tsx new file mode 100644 index 00000000..8c4797dc --- /dev/null +++ b/frontend/pages/syndic/building.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import { withAuthorisation } from "@/components/withAuthorisation"; +import SyndicHeader from "@/components/header/syndicHeader"; +import SyndicFooter from "@/components/footer/syndicFooter"; +import BuildingPage from "@/components/building/BuildingPage"; + +function SyndicBuilding() { + // https://www.figma.com/proto/9yLULhNn8b8SlsWlOnRSpm/SeLab2-mockup?node-id=16-1310&scaling=contain&page-id=0%3A1&starting-point-node-id=118%3A1486 + + return ( + <> + + + + + + + ); +} + +export default withAuthorisation(SyndicBuilding, ["Syndic"]); diff --git a/frontend/pages/syndic/dashboard.tsx b/frontend/pages/syndic/dashboard.tsx new file mode 100644 index 00000000..5976ae21 --- /dev/null +++ b/frontend/pages/syndic/dashboard.tsx @@ -0,0 +1,95 @@ +import { withAuthorisation } from "@/components/withAuthorisation"; +import router from "next/router"; +import { BuildingInterface, getBuildingsFromOwner } from "@/lib/building"; +import React, { useEffect, useState } from "react"; +import { AxiosResponse } from "axios"; +import DefaultHeader from "@/components/header/defaultHeader"; +import SyndicFooter from "@/components/footer/syndicFooter"; +import Loading from "@/components/loading"; +import SyndicHeader from "@/components/header/syndicHeader"; + +function SyndicDashboard() { + const [id, setId] = useState(""); + const [buildings, setBuildings] = useState([]); + + const [loading, setLoading] = useState(true); + + useEffect(() => { + setId(sessionStorage.getItem("id") || ""); + }, []); + + useEffect(() => { + if (!id) { + return; + } + + async function fetchBuildings() { + getBuildingsFromOwner(id) + .then((buildings: AxiosResponse) => { + setBuildings(buildings.data); + setLoading(false); + }) + .catch((error) => { + console.error(error); + setLoading(false); + }); + } + + fetchBuildings(); + }, [id]); + + return ( + <> + + {loading ? ( + + ) : ( +
+

Uw gebouwen

+ +
+ {buildings + .sort((a: BuildingInterface, b: BuildingInterface) => { + if (a.city < b.city) return -1; + else if (a.city > b.city) return 1; + if (a.street < b.street) return -1; + else if (a.street > b.street) return 1; + if (a.house_number < b.house_number) return -1; + else if (a.house_number > b.house_number) return 1; + return 0; + }) + .map((building: BuildingInterface) => { + return ( +
{ + e.preventDefault(); + router.push({ + pathname: "building", + query: { id: building.id }, + }); + }} + > +
+
+
+ {building.name} {building.postal_code} {building.city} +
+

+ {building.street} {building.house_number}{" "} +

+
+
+
+ ); + })} +
+
+ )} + + + ); +} + +export default withAuthorisation(SyndicDashboard, ["Syndic"]); diff --git a/frontend/pages/user/profile.tsx b/frontend/pages/user/profile.tsx new file mode 100644 index 00000000..8923f74a --- /dev/null +++ b/frontend/pages/user/profile.tsx @@ -0,0 +1,238 @@ +import React, { useEffect, useState } from "react"; +import { getCurrentUser, getUserRole, patchUser, User } from "@/lib/user"; +import styles from "@/styles/Login.module.css"; +import { useTranslation } from "react-i18next"; +import { getAllRegions, RegionInterface } from "@/lib/region"; +import AdminHeader from "@/components/header/adminHeader"; +import StudentHeader from "@/components/header/studentHeader"; +import SyndicHeader from "@/components/header/syndicHeader"; +import { handleError } from "@/lib/error"; +import PasswordModal from "@/components/password/passwordModal"; + +export default function UserProfile() { + const { t } = useTranslation(); + const [user, setUser] = useState(null); + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); + const [email, setEmail] = useState(""); + const [phoneNumber, setPhoneNumber] = useState(""); + const [selectedRegions, setSelectedRegions] = useState([]); + const [allRegions, setAllRegions] = useState([]); + const [role, setRole] = useState(""); + const [showPasswordModal, setShowPasswordModal] = useState(false); + + const [errorMessages, setErrorMessages] = useState([]); + const [succesPatch, setSuccessPatch] = useState(false); + + useEffect(() => { + getCurrentUser().then( + (res) => { + const u: User = res.data; + if (getUserRole(u.role.toString()) != "Syndic") { + getAllRegions().then((res) => { + const regions: RegionInterface[] = res.data; + setAllRegions(regions); + }); + } + setUserInfo(u); + }, + (err) => { + console.error(err); + } + ); + }, []); + + const openPasswordModal = () => { + setShowPasswordModal(true); + }; + const closePasswordModal = () => { + setShowPasswordModal(false); + }; + + function setUserInfo(u: User) { + setRole(getUserRole(u.role.toString())); + setUser(u); + setFirstName(u.first_name); + setLastName(u.last_name); + setEmail(u.email); + setPhoneNumber(u.phone_number); + setSelectedRegions(u.region); + } + + function submit() { + if (!user) { + return; + } + const patchBody: { [name: string]: string | number | number[] } = {}; + if (firstName !== user?.first_name) { + patchBody["first_name"] = firstName; + } + if (lastName !== user?.last_name) { + patchBody["last_name"] = lastName; + } + if (email !== user?.email) { + patchBody["email"] = email; + } + if (phoneNumber !== user?.phone_number) { + patchBody["phone_number"] = phoneNumber; + } + if (selectedRegions !== user?.region) { + patchBody["region"] = selectedRegions; // convert list to string + } + patchUser(user.id, patchBody).then( + (res) => { + const u: User = res.data; + setUserInfo(u); + setSuccessPatch(true); + }, + (err) => { + const e = handleError(err); + setErrorMessages(e); + } + ); + } + + return ( + <> + {["Admin", "Superstudent"].includes(role) && } + {"Student" === role && } + {"Syndic" === role && } + {errorMessages.length !== 0 && ( +
+
    + {errorMessages.map((err, i) => ( +
  • {t(err)}
  • + ))} +
+ +
+ )} + {succesPatch && ( +
+ Succes! Uw profiel werd met succes gewijzigd! + +
+ )} +
+
+ + Profiel +
+ +
+ + ) => { + setFirstName(e.target.value); + e.target.setCustomValidity(""); + }} + onInvalid={(e: React.ChangeEvent) => { + e.target.setCustomValidity("Voornaam is verplicht."); + }} + required + /> +
+ +
+ + ) => { + setLastName(e.target.value); + e.target.setCustomValidity(""); + }} + onInvalid={(e: React.ChangeEvent) => { + e.target.setCustomValidity("Achternaam is verplicht."); + }} + required + /> +
+ +
+ + ) => { + setEmail(e.target.value); + }} + required + /> +
+ +
+ + ) => setPhoneNumber(e.target.value)} + /> +
+ +
+ +
+ + {allRegions.length > 0 && ( +
+ + {allRegions?.map((r: RegionInterface) => { + return ( +
+ n === r.id)} + onChange={(e: React.ChangeEvent) => { + const regionId = Number(e.target.value); + const regions = [...selectedRegions]; + if ( + e.target.checked && + !selectedRegions.find((el: number) => el === regionId) + ) { + regions.push(regionId); + setSelectedRegions(regions); + } else if ( + !e.target.checked && + selectedRegions.find((el: number) => el === regionId) + ) { + const i = regions.indexOf(regionId); + if (i > -1) { + regions.splice(i, 1); + } + setSelectedRegions(regions); + } + }} + /> + +
+ ); + })} +
+ )} + +
+ + + + + +
+ + ); +} diff --git a/frontend/pages/welcome.tsx b/frontend/pages/welcome.tsx deleted file mode 100644 index 0d2704a4..00000000 --- a/frontend/pages/welcome.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import BaseHeader from "@/components/header/BaseHeader"; -import styles from "styles/Welcome.module.css"; -import soon from "public/coming_soon.png"; -import Image from "next/image"; -import api from "../pages/api/axios" -import {useContext, useEffect, useState} from "react"; -import {useRouter} from "next/router"; -import AuthContext from "@/context/AuthProvider"; - -function Welcome() { - let {auth, logoutUser} = useContext(AuthContext); - const router = useRouter(); - const [data, setData] = useState([]); - const [loading, setLoading] = useState(true); // prevents preview welcome page before auth check - - useEffect(() => { - let present = "auth" in sessionStorage; - let value = sessionStorage.getItem('auth') - if (present && value == "true") { - setLoading(false); - fetchData(); - } else { - router.push('/login'); - } - }, []); - - async function fetchData() { - try { - api.get(`${process.env.NEXT_PUBLIC_BASE_API_URL}${process.env.NEXT_PUBLIC_API_ALL_USERS}`).then(info => { - if (!info.data || info.data.length === 0) { - router.push('/login'); - } else { - setData(info.data); - } - }); - } catch (error) { - console.error(error); - } - } - - const handleLogout = async () => { - try { - const response = await api.post(`${process.env.NEXT_PUBLIC_API_LOGOUT}`); - if (response.status === 200) { - logoutUser(); - await router.push('/login'); - } - } catch (error) { - console.error(error); - } - }; - - return ( - <> - {loading ? ( -
Loading...
- ) : ( - <> - -

Welcome!

- Site coming soon - -

Users:

-
    - {data.map((item, index) => ( -
  • {JSON.stringify(item)}
  • - ))} -
- - )} - - ) - - -} - -export default Welcome \ No newline at end of file diff --git a/frontend/public/filler_image.png b/frontend/public/filler_image.png new file mode 100644 index 00000000..8747ead0 Binary files /dev/null and b/frontend/public/filler_image.png differ diff --git a/frontend/public/fire_image.png b/frontend/public/fire_image.png new file mode 100644 index 00000000..a32f656b Binary files /dev/null and b/frontend/public/fire_image.png differ diff --git a/frontend/public/icons/menu.svg b/frontend/public/icons/menu.svg new file mode 100644 index 00000000..8ee6cb9f --- /dev/null +++ b/frontend/public/icons/menu.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/public/icons/person.svg b/frontend/public/icons/person.svg new file mode 100644 index 00000000..fef4d05f --- /dev/null +++ b/frontend/public/icons/person.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/styles/AdminDataBuildingsEdit.module.css b/frontend/styles/AdminDataBuildingsEdit.module.css new file mode 100644 index 00000000..92780c90 --- /dev/null +++ b/frontend/styles/AdminDataBuildingsEdit.module.css @@ -0,0 +1,9 @@ +.container { + max-width: 600px; + margin: 0 auto; + padding: 2rem; +} + +.form { + margin-bottom: 2rem; +} diff --git a/frontend/styles/Combobox.module.css b/frontend/styles/Combobox.module.css new file mode 100644 index 00000000..58b8898a --- /dev/null +++ b/frontend/styles/Combobox.module.css @@ -0,0 +1,75 @@ +/* Combobox styles */ +.comboboxContainer { + position: relative; + } + +.comboboxInput { +width: 100%; +border: none; +padding: 0.5rem 1rem; +font-size: 1rem; +line-height: 1.5rem; +color: #1F2937; +background-color: #FFFFFF; +border-radius: 0.375rem; +box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05), 0 1px 3px 0 rgba(0, 0, 0, 0.15); +} + +.comboboxInput:focus { +outline: none; +} + +.comboboxButton { +position: absolute; +top: 50%; +right: 0.75rem; +transform: translateY(-50%); +} + +.comboboxButtonIcon { +width: 1.25rem; +height: 1.25rem; +color: #9CA3AF; +} + +/* Combobox options */ +.comboboxOptions { +position: absolute; +z-index: 1; +top: 100%; +left: 0; +display: none; +max-height: 10rem; +overflow-y: auto; +background-color: #FFFFFF; +border: 1px solid #D1D5DB; +border-radius: 0.375rem; +box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05), 0 1px 3px 0 rgba(0, 0, 0, 0.15); +} + +.comboboxOptions.show { +display: block; +} + +.comboboxOption { +display: block; +padding: 0.5rem 1rem; +font-size: 1rem; +line-height: 1.5rem; +color: #1F2937; +cursor: default; +} + +.comboboxOption:hover, .comboboxOption.active { +background-color: #6B7280; +color: #FFFFFF; +} + +.comboboxOption.active .comboboxOption-check { +display: inline-block; +margin-right: 0.5rem; +width: 1rem; +height: 1rem; +vertical-align: middle; +fill: #FFFFFF; +} \ No newline at end of file diff --git a/frontend/styles/Login.module.css b/frontend/styles/Login.module.css index 5074cec7..466a9e8d 100644 --- a/frontend/styles/Login.module.css +++ b/frontend/styles/Login.module.css @@ -1,88 +1,35 @@ -.main_container { - width: 850px; - height: 600px; - border-radius: 5px; - margin: auto; - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - box-shadow: 2px 2px 20px gray; -} - -.filler_container { - float: left; - width: 55%; - height: 100%; -} .filler_image { width: 100%; height: 100%; + object-fit: cover; padding: 10px; - display: block; border-radius: 5px; } -.login_container { - float: right; - width: 45%; - height: 100%; -} - -.title { - text-align: left; - margin-left: 60px; - margin-top: 100px; - margin-bottom: 80px; - font: 36px Helvetica, sans-serif; - font-weight: bold; -} - -.signup_title { - text-align: left; - margin-left: 60px; - margin-top: 60px; - margin-bottom: 30px; - font: 36px Helvetica, sans-serif; - font-weight: bold; -} - -.text { - margin-left: 60px; - margin-top: 20px; - margin-bottom: 10px; - font: 16px Helvetica, sans-serif; -} - .input { - width: 250px; + max-width: 350px; height: 40px; - margin-left: 60px; - margin-bottom: 10px; - padding-left: 10px; - padding-right: 10px; overflow: hidden; + border-color: #1D1D1D; + font-size: 14px; +} + +.input:focus { + box-shadow: 0 0 0 0.1rem lightgray; } .button { width: 250px; - height: 40px; - margin-top: 20px; - margin-left: 60px; border-radius: 5px; background-color: #1D1D1D; - color: yellow; + color: white; } .button:hover { width: 250px; - height: 40px; - margin-top: 20px; - margin-left: 60px; border-radius: 5px; background-color: transparent; color: #1D1D1D; transition: ease-in 0.2s; -} \ No newline at end of file +} diff --git a/frontend/styles/PDFUploader.module.css b/frontend/styles/PDFUploader.module.css new file mode 100644 index 00000000..153f7232 --- /dev/null +++ b/frontend/styles/PDFUploader.module.css @@ -0,0 +1,23 @@ +.customFileUpload { + display: inline-block; + cursor: pointer; + background-color: #f8f9fa; + border: 1px solid #ced4da; + border-radius: 0.25rem; + padding: 0.375rem 0.75rem; + font-weight: 400; + color: #495057; + text-align: center; + vertical-align: middle; + user-select: none; + font-size: 1rem; + line-height: 1.5; +} + +.customFileUpload:hover { + background-color: #e9ecef; +} + +.hiddenFileInput { + display: none; +} diff --git a/frontend/styles/globals.css b/frontend/styles/globals.css index 5fa3e1a8..931e608a 100644 --- a/frontend/styles/globals.css +++ b/frontend/styles/globals.css @@ -1,59 +1,112 @@ -:root { - --max-width: 1100px; - --border-radius: 12px; - --font-mono: ui-monospace, Menlo, Monaco, 'Cascadia Mono', 'Segoe UI Mono', - 'Roboto Mono', 'Oxygen Mono', 'Ubuntu Monospace', 'Source Code Pro', - 'Fira Mono', 'Droid Sans Mono', 'Courier New', monospace; +/* General Styles */ - --foreground-rgb: 0, 0, 0; - --background-start-rgb: 214, 219, 220; - --background-end-rgb: 255, 255, 255; +/* Reset default margin, padding, and border for all elements */ +* { + box-sizing: border-box; + padding: 0; + margin: 0; +} - --callout-rgb: 238, 240, 241; - --callout-border-rgb: 172, 175, 176; - --card-rgb: 180, 185, 188; - --card-border-rgb: 131, 134, 135; +/* Define font and font size for the whole document */ +body { + font-family: Helvetica, sans-serif; + font-size: 16px; } -@media (prefers-color-scheme: light) { - :root { - --foreground-rgb: 255, 255, 255; - --background-start-rgb: 0, 0, 0; - --background-end-rgb: 0, 0, 0; - - --tile-start-rgb: 2, 13, 46; - --tile-end-rgb: 2, 5, 19; - --tile-border: conic-gradient( - #ffffff80, - #ffffff40, - #ffffff30, - #ffffff20, - #ffffff10, - #ffffff10, - #ffffff80 - ); - - --callout-rgb: 20, 20, 20; - --callout-border-rgb: 108, 108, 108; - --card-rgb: 100, 100, 100; - --card-border-rgb: 200, 200, 200; - } +/* Button Styles */ + +/* Style for all buttons */ +Button, button { + height: 50px; + width: 150px; + margin: 5px; + outline: 0; + cursor: pointer; + border: 2px solid #000; + border-radius: 5px; + color: #fff; + background: #000; + font-size: 20px; + font-weight: 500; + line-height: 24px; + padding: 5px 10px; + text-align: center; + transition: ease-in 0.2s; } -* { - box-sizing: border-box; - padding: 0; - margin: 0; +/* Style for buttons when hovering */ +Button:hover, button:hover { + color: #000; + background: rgb(255, 218, 87); } +/* Link Styles */ + +/* Style for all links */ a { - color: inherit; - text-decoration: none; + color: inherit; + text-decoration: none; } +/* Style for links when hovering */ +a:hover { + text-decoration: underline; +} + +/* Input Styles */ + +/* Style for all inputs */ +input { + min-width: 250px; + max-width: 350px; + height: 40px; + background-color: #E8F0FC; + overflow: hidden; + padding: 10px 5px; + margin: 5px; + border: solid #1D1D1D 1px; + border-radius: 5px; + font-size: 14px; +} + +input:focus { + border: none; + outline: none; + box-shadow: 0 0 0 0.1rem lightgray; +} @media (prefers-color-scheme: light) { - html { - color-scheme: light; - } + html { + color-scheme: light; + } +} + +.rbc-time-view .rbc-label { + display: none !important; +} + +.rbc-time-view .rbc-allday-cell { + height: calc(90vh) !important; + max-height: unset !important; +} +/* Style for all icons */ +.icon { + display: inline-block; + width: 24px; + height: 24px; + cursor: pointer; + background-repeat: no-repeat; + background-size: 100%; +} + +.rbc-time-view .rbc-time-content { + display: none !important; +} + +.rbc-time-view .rbc-time-slot { + height: 30px !important; +} + +.clickable { + cursor: pointer; } diff --git a/frontend/types.d.ts b/frontend/types.d.ts new file mode 100644 index 00000000..89cb024e --- /dev/null +++ b/frontend/types.d.ts @@ -0,0 +1,63 @@ +export type Login = { + email: string; + password: string; +}; + +export type SignUp = { + first_name: string; + last_name: string; + phone_number: phone_number, + email: string; + password1: string; + password2: string; + verification_code: verification_code, +}; + +export type Reset_Password = { + email: string; +}; + +export type TourView = { + name: string; + region: string; + last_modified: string; + tour_id: number; +}; + +export type BuildingView = { + name: string; + address: string; + building_id: number; + syndic_email: string; +}; + +export type BuildingOnTourView = { + buildingName: string; + city: string; + postalCode: string; + street: string; + houseNumber: string; + bus: string; + buildingId: number; + index: number; +}; + +export type BuildingNotOnTourView = { + buildingName: string; + city: string; + postalCode: string; + street: string; + houseNumber: string; + bus: string; + buildingId: number; +}; + +export type UserView = { + email : string; + first_name : string; + last_name : string; + role : string; + phone_number : string; + userId : number; + isActive : boolean; +} \ No newline at end of file diff --git a/frontend/types.d.tsx b/frontend/types.d.tsx deleted file mode 100644 index d98e2945..00000000 --- a/frontend/types.d.tsx +++ /dev/null @@ -1,16 +0,0 @@ -export type Login = { - email: string - password: string -} - -export type SignUp = { - first_name: string, - last_name: string, - email: string, - password1: string, - password2: string, -} - -export type Reset_Password = { - email: string -} diff --git a/nginx/nginx.conf b/nginx/nginx.conf index cdfbc130..ab7119ac 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -27,19 +27,35 @@ http { server { listen 80 default_server; listen [::]:80; + # for development purposes + location ~ ^/api(/?|/(?.*))$ { + proxy_pass http://docker-backend/$rest?$args; + proxy_set_header X-Script-Name /api; + proxy_cookie_path / /api; + } location / { proxy_pass http://docker-frontend; } + # on server: + # return 301 https://$host$request_uri; } server { listen 443 default_server; # todo add ssl listen [::]:443; # todo add ssl + + location ~ ^/api/(?.*)$ { + proxy_pass http://docker-backend/$rest?$args; + proxy_set_header X-Script-Name /api; + proxy_cookie_path / /api; + } + location / { proxy_pass http://docker-frontend; -# proxy_redirect http:// https://; } + # ssl settings for when deployed + # ssl_certificate '/etc/letsencrypt/live/sel2-4.ugent.be/fullchain.pem'; # ssl_certificate_key '/etc/letsencrypt/live/sel2-4.ugent.be/privkey.pem'; # include /etc/letsencrypt/options-ssl-nginx.conf; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..9f93f522 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "Dr-Trottoir-4", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/readme/API-docs.md b/readme/API-docs.md index e01027fc..b01efb34 100644 --- a/readme/API-docs.md +++ b/readme/API-docs.md @@ -41,7 +41,7 @@ class AllUsersView(APIView): if not user_instances: return Response( - {"res": "No users found"}, + {"message": "No users found"}, status=status.HTTP_400_BAD_REQUEST ) diff --git a/readme/authorisation.md b/readme/authorisation.md new file mode 100644 index 00000000..c51aa515 --- /dev/null +++ b/readme/authorisation.md @@ -0,0 +1,104 @@ +# Authorisation + +## Overview + +### Role based permissions + +- `IsAdmin` (global): checks if the user is an admin +- `IsSuperStudent` (global): checks if the user is a super student +- `IsStudent` (global): checks if the user is a student +- `ReadOnlyStudent` (global): checks if the user is a student and only performs a `GET/HEAD/OPTIONS` request +- `IsSyndic` (global): checks if the user is a syndic + +### Object based permissions + +- `OwnerOfBuilding` (global + object): checks if the user is a syndic and if he is the owner +- `ReadOnlyOwnerOfBuilding` (global + object): checks if the user is a syndic and if he owns the building and only tries + to read from it +- `OwnerAccount` (object): checks if the user tries to access his own user info or info that belongs to him/her +- `ReadOnlyOwnerAccount` (object): checks if the user tries to read his own user info or info that belongs to him/her +- `CanCreateUser` (object): checks if the user only creates users of higher or equal rank +- `CanDeleteUser` (object): checks if the user has a higher rank than the one he tries to delete +- `CanEditUser` (object): checks if the user who tries to edit is in fact the user himself or someone with a higher rank +- `CanEditRole` (object): checks if the user who tries to assign a role, doesn't set a role higher than his own role +- `ReadOnlyManualFromSyndic` (global + object): checks if the user that tries to access the manual is in fact the owner of the + building for which this manual was uploaded and checks if he only tries to read. + +### Action based permissions + +- `ReadOnly` (global): checks if the method is a safe method (`GET`, `HEAD`, `OPTIONS`) + +## Protected endpoints + +For all these views, `IsAuthenticated` is required. Therefor we only mention the interesting permissions here. + +### Building urls + +- `building/ - [..., IsAdmin|IsSuperStudent]` +- `building/id - [..., IsAdmin | IsSuperStudent | ReadOnlyStudent | ReadOnlyOwnerOfBuilding]` +- `building/owner/id - [..., IsAdmin | IsSuperStudent | ReadOnlyOwnerOfBuilding]` +- `building/all - [...,IsAdmin | IsSuperStudent]` + +### BuildingComment urls + +- `building/ - [..., IsAdmin | IsSuperStudent | OwnerOfBuildin]` +- `building/comment_id - [..., IsAdmin | IsSuperStudent | OwnerOfBuilding | ReadOnlyStudent]` +- `building/building_id - [..., IsAdmin | IsSuperStudent | OwnerOfBuilding | ReadOnlyStudent]` + +### BuildingOnTour urls + +- `building_on_tour/ - [...,IsAdmin | IsSuperStudent]` +- `building_on_tour/id - [..., IsAdmin | IsSuperStudent | ReadOnlyStudent]` +- `building_on_tour/all - [..., IsAdmin | IsSuperStudent | ReadOnlyStudent]` + +### Garbage Collection + +- `garbage_collection/ - [..., IsAdmin | IsSuperStudent]` +- `garbage_collection/id - [..., IsAdmin | IsSuperStudent | ReadOnlyStudent]` +- `garbage_collection/building/id - [IsAdmin | IsSuperStudent | ReadOnlyStudent | ReadOnlyOwnerOfBuilding]` +- `garbage_collection/all - [..., IsAdmin | IsSuperStudent]` + +### Manual + +- `manual/ - [..., IsAdmin | IsSuperStudent | IsSyndic]` +- `manual/id - [..., IsAdmin | IsSuperStudent | ReadOnlyStudent | ReadOnlyManualFromSyndic]` +- `manual/building/id - [..., IsAdmin | IsSuperStudent | ReadOnlyStudent | OwnerOfBuilding]` +- `manual/all/ - [..., IsAdmin | IsSuperStudent]` + +### PictureBuilding + +- `picture_building/ - [..., IsAdmin | IsSuperStudent | IsStudent]` +- `picture_building/id - [..., IsAdmin | IsSuperStudent | IsStudent | ReadOnlyOwnerOfBuilding]` +- `picture_building/building/id - [..., IsAdmin | IsSuperStudent | IsStudent | ReadOnlyOwnerOfBuilding]` +- `picture_building/all - [..., IsAdmin | IsSuperStudent]` + +### Region + +- `region/ - [..., IsAdmin]` +- `region/id - [..., IsAdmin | ReadOnly]` +- `region/all - [..., IsAdmin | IsSuperStudent | IsStudent]` + +### Role + +- `role/ - [..., IsAdmin]` +- `role/id - [..., IsAdmin | IsSuperStudent]` +- `role/all - [..., IsAdmin | IsSuperStudent]` + +### Student at building on tour + +- `student_at_building_on_tour/ - [..., IsAdmin | IsSuperStudent]` +- `student_at_building_on_tour/id - [..., IsAdmin | IsSuperStudent | ReadOnlyOwnerAccount]` +- `student_at_building_on_tour/student/id - [..., IsAdmin | IsSuperStudent | OwnerAccount]` +- `student_at_building_on_tour/all - [..., IsAdmin | IsSuperStudent]` + +### Tour urls + +- `tour/ - [..., IsAdmin | IsSuperStudent]` +- `tour/id - [..., IsAdmin | IsSuperStudent | ReadOnlyStudent]` +- `tour/all - [..., IsAdmin | IsSuperStudent]` + +### User urls + +- `user/ - [..., IsAdmin | IsSuperStudent, CanCreateUser]` +- `user/id - [..., IsAuthenticated, IsAdmin | IsSuperStudent | OwnerAccount, CanEditUser, CanEditRole, CanDeleteUser]` +- `user/all - [..., IsAdmin | IsSuperStudent]` \ No newline at end of file diff --git a/readme/img/example_coverage.jpg b/readme/img/example_coverage.jpg new file mode 100644 index 00000000..d98553aa Binary files /dev/null and b/readme/img/example_coverage.jpg differ diff --git a/runtests.sh b/runtests.sh new file mode 100755 index 00000000..e17672f2 --- /dev/null +++ b/runtests.sh @@ -0,0 +1,11 @@ +#!/bin/bash +docker compose exec backend python manage.py test picture_of_remark/tests.py users/tests.py remark_at_building/tests.py lobby/tests.py email_template/tests.py building/tests.py role/tests.py building_comment/tests.py building_on_tour/tests.py garbage_collection/tests.py manual/tests.py region/tests.py tour/tests.py --with-coverage --cover-package=lobby,email_template,building,building_on_tour,garbage_collection,manual,region,tour,building_comment,role,remark_at_building,picture_of_remark,users + +# wait script +echo "Press any key to continue" +while [ true ]; do + read -t 3 -n 1 + if [ $? = 0 ]; then + exit + fi +done