diff --git a/.gitignore b/.gitignore index a900da2a..6d45635e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,21 +1,22 @@ -.env/* -*.pyc -*_design1 -fet2020/.env/* -*.sqlite3 -.theia/* -.flake8 -migrate -run -*.pid -*~ -APIKEY.txt -tmp -.ruff_cache -.venv -etherpad -files -flowbite -gallery -tailwind -whoosh_index +.env/* +*.pyc +*_design1 +fet2020/.env/* +*.sqlite3 +.theia/* +.flake8 +migrate +run +*.pid +*~ +APIKEY.txt +tmp +.ruff_cache +.venv +etherpad +files +flowbite +gallery/* +tailwind +whoosh_index +databases/django diff --git a/Readme.md b/Readme.md index 5d9f6bd2..05a2a545 100644 --- a/Readme.md +++ b/Readme.md @@ -96,6 +96,8 @@ docker build -t django-nginx-image -f nginx/Dockerfile ./nginx ### Start docker container +Add email password for 'Verleih' account to EMAIL_HOST_PASSWORD in the docker compose file! + Build the docker containers: ```bash @@ -134,6 +136,10 @@ ckeditor -> django-prose-editor ## Version History +2.2.1 + +* Fix rental (view, pdf file, sending mail) + 2.2.0 * Add rental diff --git a/assets/rental/Verleihformular.pdf b/assets/rental/Verleihformular.pdf index db8dce11..eba462bd 100644 Binary files a/assets/rental/Verleihformular.pdf and b/assets/rental/Verleihformular.pdf differ diff --git a/docker-compose.yml b/docker-compose.yml index 015a4c45..dbcbe946 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,7 +9,9 @@ services: depends_on: - django-homepage volumes: - - files-volume:/usr/src/app/files + - ./files:/usr/src/app/files + - ./gallery:/usr/src/app/files/uploads/gallery + - ./assets:/usr/src/app/assets:ro networks: - fet-network django-homepage: @@ -23,6 +25,8 @@ services: MYSQL_USER: "user" MYSQL_PASSWORD: "hgu" ETHERPAD_GROUP: "g.snlbqn7S6ksRbom3" + EMAIL_HOST_USER: "verleih@fet.at" + EMAIL_HOST_PASSWORD: "" depends_on: mysql: condition: service_healthy @@ -32,6 +36,7 @@ services: - ./fet2020:/usr/src/app - ./gallery:/usr/src/app/files/uploads/gallery:shared - files-volume:/usr/src/app/files + - ./assets:/usr/src/app/assets:ro networks: - fet-network - django-db-network @@ -79,7 +84,8 @@ services: MYSQL_CHARSET: utf8 MYSQL_ALLOW_EMPTY_PASSWORD: "yes" volumes: - - mysql-volume:/docker-entrypoint-initdb.d/ + - ./inits/django:/docker-entrypoint-initdb.d/ + - ./databases/django:/var/lib/mysql:Z networks: - django-db-network healthcheck: @@ -97,7 +103,8 @@ services: MYSQL_CHARSET: utf8 MYSQL_ALLOW_EMPTY_PASSWORD: "yes" volumes: - - etherpad-mysql-volume:/docker-entrypoint-initdb.d/ + - ./init/etherpad:/docker-entrypoint-initdb.d/ + - ./databases/etherpad:/var/lib/mysql:Z networks: - etherpad-db-network healthcheck: diff --git a/fet2020/fet2020/__init__.py b/fet2020/fet2020/__init__.py index 3b383497..05b94323 100644 --- a/fet2020/fet2020/__init__.py +++ b/fet2020/fet2020/__init__.py @@ -1,6 +1,6 @@ from django.utils.version import get_version -VERSION = (2, 2, 0, "final", 0) +VERSION = (2, 2, 1, "final", 0) BUILD = 0 __version__ = get_version(VERSION) diff --git a/fet2020/fet2020/settings.py b/fet2020/fet2020/settings.py index 68add289..7b564f73 100644 --- a/fet2020/fet2020/settings.py +++ b/fet2020/fet2020/settings.py @@ -18,6 +18,8 @@ env = environ.Env( ETHERPAD_GROUP=(str, ""), GALLERY_PATH=(str, "uploads/gallery"), MC_MASTERPASSWORD=(str, ""), + EMAIL_HOST_USER=(str, ""), + EMAIL_HOST_PASSWORD=(str, ""), ) # Build paths inside the project like this: os.path.join(BASE_DIR, ...) @@ -103,6 +105,8 @@ else: EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" EMAIL_HOST = "buran.htu.tuwien.ac.at" EMAIL_PORT = 587 +EMAIL_HOST_USER = env("EMAIL_HOST_USER") +EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD") EMAIL_USE_TLS = True diff --git a/fet2020/finance/forms.py b/fet2020/finance/forms.py index 4968750b..77003fec 100644 --- a/fet2020/finance/forms.py +++ b/fet2020/finance/forms.py @@ -6,6 +6,7 @@ from django import forms from django.core.validators import ValidationError from django.db.models import Count, Q from django.forms import DateInput +from django.utils import timezone from members.models import Member @@ -412,7 +413,7 @@ class BillAdminForm(forms.ModelForm): self.fields["resolution"].queryset = self.fields["resolution"].queryset.filter( ( Q(option=Resolution.Option.FINANCE) - & Q(date__gt=datetime.datetime.now(tz=datetime.UTC).date() - relativedelta(years=2)) + & Q(date__gt=timezone.now().date() - relativedelta(years=2)) ) | Q(option=Resolution.Option.PERMANENT) ) diff --git a/fet2020/finance/utils.py b/fet2020/finance/utils.py index 270f5765..32121e62 100644 --- a/fet2020/finance/utils.py +++ b/fet2020/finance/utils.py @@ -1,8 +1,8 @@ -import datetime import io from pathlib import Path from django.core.files import File +from django.utils import timezone from pypdf import PdfReader, PdfWriter from pypdf.constants import FieldDictionaryAttributes as FA # noqa: N814 @@ -34,7 +34,7 @@ def generate_pdf(wiref): ) # Get budget year - today = datetime.datetime.now(tz=datetime.UTC).date() + today = timezone.now().date() if today.month < 7: budget_year = f"{today.year - 1}-{today.year}" else: diff --git a/fet2020/gallery/migrations/0003_alter_album_description_alter_album_photographer_and_more.py b/fet2020/gallery/migrations/0003_alter_album_description_alter_album_photographer_and_more.py new file mode 100644 index 00000000..d53b6ecf --- /dev/null +++ b/fet2020/gallery/migrations/0003_alter_album_description_alter_album_photographer_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.7 on 2025-10-27 19:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('gallery', '0002_album_event_place'), + ] + + operations = [ + migrations.AlterField( + model_name='album', + name='description', + field=models.TextField(blank=True, default=''), + ), + migrations.AlterField( + model_name='album', + name='photographer', + field=models.CharField(blank=True, default='', max_length=200, verbose_name='Fotograph(en)'), + ), + migrations.AlterField( + model_name='album', + name='thumbnail', + field=models.CharField(blank=True, default='', max_length=200, verbose_name='Thumbnail'), + ), + ] diff --git a/fet2020/gallery/utils.py b/fet2020/gallery/utils.py index 44073387..9068bc05 100644 --- a/fet2020/gallery/utils.py +++ b/fet2020/gallery/utils.py @@ -1,58 +1,60 @@ -import logging -import os -from pathlib import Path - -from django.conf import settings -from django.core.validators import get_available_image_extensions -from PIL import Image, ImageOps - -gallery_path = Path(settings.MEDIA_ROOT) / settings.GALLERY["path"] -gallery_path_url = Path(settings.MEDIA_URL) / settings.GALLERY["path"] -gallery_thumb_path = Path(settings.MEDIA_ROOT) / settings.GALLERY["thumb_path"] -gallery_thumb_path_url = Path(settings.MEDIA_URL) / settings.GALLERY["thumb_path"] -logger = logging.getLogger(__name__) -size = (320, 320) - -Image.logger.setLevel(level=logging.INFO) - - -def get_image_list(folder_name: str) -> list: - image_path = Path(gallery_path) / folder_name - thumb_path = Path(gallery_thumb_path) / folder_name - img_list = [] - - if not Path(image_path).exists(): - logger.info("Image path '%s' not found.", image_path) - return img_list - - Path(thumb_path).mkdir(exist_ok=True) - - for _file in os.listdir(image_path): - if Path(_file).suffix.lower()[1:] not in get_available_image_extensions(): - continue - - thumb_file_path = Path(thumb_path) / f"thumb_{_file}" - if not Path(thumb_file_path).exists(): - with Image.open(Path(image_path) / _file, "r") as im: - if im._getexif() is not None: - im = ImageOps.exif_transpose(im) - - thumb = ImageOps.fit(im, size, Image.Resampling.LANCZOS) - thumb.save(thumb_file_path) - logger.info("Save thumb 'thumb_%s'.", _file) - - img_dict = { - "title": _file, - "image_url": Path(gallery_path_url) / folder_name / _file, - "thumb_url": Path(gallery_thumb_path_url) / folder_name / f"thumb_{_file}", - } - img_list.append(img_dict) - - return img_list - - -def get_folder_list(): - if Path(gallery_path).exists(): - return next(os.walk(gallery_path))[1] - - return None +import logging +import os +from pathlib import Path + +from django.conf import settings +from django.core.validators import get_available_image_extensions +from PIL import Image, ImageOps + +gallery_path = Path(settings.MEDIA_ROOT) / settings.GALLERY["path"] +gallery_path_url = Path(settings.MEDIA_URL) / settings.GALLERY["path"] +gallery_thumb_path = Path(settings.MEDIA_ROOT) / settings.GALLERY["thumb_path"] +gallery_thumb_path_url = Path(settings.MEDIA_URL) / settings.GALLERY["thumb_path"] +logger = logging.getLogger(__name__) +size = (320, 320) + +Image.logger.setLevel(level=logging.INFO) + + +def get_image_list(folder_name: str) -> list: + image_path = Path(gallery_path) / folder_name + thumb_path = Path(gallery_thumb_path) / folder_name + img_list = [] + + if not Path(image_path).exists(): + logger.info("Image path '%s' not found.", image_path) + return img_list + + Path(thumb_path).mkdir(exist_ok=True) + + for _file in os.listdir(image_path): + if Path(_file).suffix.lower()[1:] not in get_available_image_extensions(): + continue + + thumb_file_path = Path(thumb_path) / f"thumb_{_file}" + if not Path(thumb_file_path).exists(): + with Image.open(Path(image_path) / _file, "r") as im: + if im._getexif() is not None: + im = ImageOps.exif_transpose(im) + + thumb = ImageOps.fit(im, size, Image.Resampling.LANCZOS) + thumb.save(thumb_file_path) + logger.info("Save thumb 'thumb_%s'.", _file) + + img_dict = { + "title": _file, + "image_url": Path(gallery_path_url) / folder_name / _file, + "thumb_url": Path(gallery_thumb_path_url) / folder_name / f"thumb_{_file}", + } + img_list.append(img_dict) + + # Sort images alphabetically by filename (case-insensitive) to ensure consistent ordering. + # Directory listings may return files in different orders. + return sorted(img_list, key=lambda x: x["title"].lower()) + + +def get_folder_list(): + if Path(gallery_path).exists(): + return next(os.walk(gallery_path))[1] + + return None diff --git a/fet2020/posts/managers.py b/fet2020/posts/managers.py index db874880..ff4d40b1 100644 --- a/fet2020/posts/managers.py +++ b/fet2020/posts/managers.py @@ -1,201 +1,208 @@ -import datetime - -from django.db import models -from django.db.models import Case, Q, When - -from .choices import PostType, Status - - -class PublishedManager(models.Manager): - def published(self, public=True): - """ - publish all posts with status 'PUBLIC' - """ - return self.get_queryset().filter(status=Status.PUBLIC) if public else self.get_queryset() - - def published_all(self, public=True): - """ - publish all posts with status 'PUBLIC' and 'ONLY_INTERN' - """ - return ( - self.get_queryset().filter(~Q(status=Status.DRAFT)) if public else self.get_queryset() - ) - - -class PostManager(PublishedManager, models.Manager): - def get_queryset(self): - qs = ( - super() - .get_queryset() - .annotate( - date=Case( - When(post_type=PostType.NEWS, then="public_date"), - When(post_type=PostType.EVENT, then="event_start__date"), - When(post_type=PostType.FETMEETING, then="event_start__date"), - ), - ) - ) - return qs.order_by("-date", "-id") - - def date_sorted(self, public=True): - return self.published(public) - - def date_filter( - self, - public=True, - year=None, - month=None, - fet_meeting_only=None, - ): - qs_filter = Q() - - if fet_meeting_only: - qs_filter &= Q(post_type=PostType.FETMEETING) - - if year: - qs_filter &= Q(date__year=year) - if month: - qs_filter &= Q(date__month=month) - - return self.published(public).filter(qs_filter) - - -class ArticleManager(PublishedManager, models.Manager): - """ - Provide a query set only for "Article" - regular fet meetings should not be contained in the news stream - """ - - def get_queryset(self): - qs = super().get_queryset().filter(Q(post_type=PostType.NEWS) | Q(post_type=PostType.EVENT)) - qs = qs.annotate( - date=Case( - When(post_type=PostType.NEWS, then="public_date"), - When(post_type=PostType.EVENT, then="event_start__date"), - ), - ) - return qs.order_by("-date", "-id") - - def date_sorted(self, public=True): - return self.published(public) - - def pinned(self, public=True): - # Get date for pinned news that is max 1 month old. - post_date = datetime.datetime.now(tz=datetime.UTC).date() - __month = post_date.month - __year = post_date.year - - if __month != 1: - __month -= 1 - else: - # If the current month is January, you get the date from December of previous year. - __month = 12 - __year -= 1 - - post_date = post_date.replace(year=__year, month=__month) - - # Get date for event posts that is max 1 day old. - event_date = datetime.datetime.now(tz=datetime.UTC).date() - datetime.timedelta(1) - - return ( - self.published(public) - .filter( - Q(is_pinned=True) - & ( - (Q(post_type=PostType.NEWS) & Q(public_date__gt=post_date)) - | (Q(post_type=PostType.EVENT) & Q(event_end__date__gt=event_date)) - ) - ) - .first() - ) - - -class NewsManager(PublishedManager, models.Manager): - """ - Provide a query set only for "News" - """ - - def get_queryset(self): - qs = super().get_queryset().filter(post_type=PostType.NEWS) - qs = qs.annotate( - date=Case( - When(post_type=PostType.NEWS, then="public_date"), - ), - ) - return qs.order_by("-date") - - -class AllEventManager(PublishedManager, models.Manager): - """ - Provide a query set for all events ("Event" and "Fet Meeting") - """ - - def get_queryset(self): - qs = ( - super() - .get_queryset() - .filter(Q(post_type=PostType.EVENT) | Q(post_type=PostType.FETMEETING)) - ) - qs = qs.annotate( - date=Case( - When(post_type=PostType.EVENT, then="event_start__date"), - When(post_type=PostType.FETMEETING, then="event_start__date"), - ), - ) - return qs.order_by("-date") - - def future_events(self, public=True): - date_today = datetime.datetime.now(tz=datetime.UTC).date() - qs = self.published(public).filter(event_start__gt=date_today) - return qs.reverse() - - -class EventManager(PublishedManager, models.Manager): - """ - Provide a query set only for "Events" - regular fet meetings should not be contained in the news stream - """ - - def get_queryset(self): - qs = super().get_queryset().filter(post_type=PostType.EVENT) - qs = qs.annotate( - date=Case( - When(post_type=PostType.EVENT, then="event_start__date"), - ), - ) - return qs.order_by("-date") - - def future_events(self, public=True): - date_today = datetime.datetime.now(tz=datetime.UTC).date() - qs = self.published(public).filter(event_start__gt=date_today) - return qs.reverse() - - def past_events(self, public=True): - date_today = datetime.datetime.now(tz=datetime.UTC).date() - qs = self.published(public).filter(event_start__lt=date_today) - return qs - - -class FetMeetingManager(PublishedManager, models.Manager): - """ - Provide a query set only for "Fet Meeting" - """ - - def get_queryset(self): - qs = super().get_queryset().filter(post_type=PostType.FETMEETING) - qs = qs.annotate( - date=Case( - When(post_type=PostType.FETMEETING, then="event_start__date"), - ), - ) - return qs.order_by("-date") - - def future_events(self): - date_today = datetime.datetime.now(tz=datetime.UTC).date() - qs = self.published().filter(event_start__gt=date_today) - return qs.reverse() - - def past_events(self): - date_today = datetime.datetime.now(tz=datetime.UTC).date() - qs = self.published().filter(event_start__lt=date_today) - return qs +import calendar +import datetime + +from django.db import models +from django.db.models import Case, Q, When +from django.utils import timezone + +from .choices import PostType, Status + + +class PublishedManager(models.Manager): + def published(self, public=True): + """ + publish all posts with status 'PUBLIC' + """ + return self.get_queryset().filter(status=Status.PUBLIC) if public else self.get_queryset() + + def published_all(self, public=True): + """ + publish all posts with status 'PUBLIC' and 'ONLY_INTERN' + """ + return ( + self.get_queryset().filter(~Q(status=Status.DRAFT)) if public else self.get_queryset() + ) + + +class PostManager(PublishedManager, models.Manager): + def get_queryset(self): + qs = ( + super() + .get_queryset() + .annotate( + date=Case( + When(post_type=PostType.NEWS, then="public_date"), + When(post_type=PostType.EVENT, then="event_start__date"), + When(post_type=PostType.FETMEETING, then="event_start__date"), + ), + ) + ) + return qs.order_by("-date", "-id") + + def date_sorted(self, public=True): + return self.published(public) + + def date_filter( + self, + public=True, + year=None, + month=None, + fet_meeting_only=None, + ): + qs_filter = Q() + + if fet_meeting_only: + qs_filter &= Q(post_type=PostType.FETMEETING) + + if year: + qs_filter &= Q(date__year=year) + if month: + qs_filter &= Q(date__month=month) + + return self.published(public).filter(qs_filter) + + +class ArticleManager(PublishedManager, models.Manager): + """ + Provide a query set only for "Article" + regular fet meetings should not be contained in the news stream + """ + + def get_queryset(self): + qs = super().get_queryset().filter(Q(post_type=PostType.NEWS) | Q(post_type=PostType.EVENT)) + qs = qs.annotate( + date=Case( + When(post_type=PostType.NEWS, then="public_date"), + When(post_type=PostType.EVENT, then="event_start__date"), + ), + ) + return qs.order_by("-date", "-id") + + def date_sorted(self, public=True): + return self.published(public) + + def pinned(self, public=True): + # Get date for pinned news that is max 1 month old. + post_date = timezone.now().date() + _day = post_date.day + _month = post_date.month + _year = post_date.year + + if _month != 1: + _month -= 1 + else: + # If the current month is January, you get the date from December of previous year. + _month = 12 + _year -= 1 + + # Clamp day to last day of target month (handles 30/31 and Feb) + last_day = calendar.monthrange(_year, _month)[1] + safe_day = min(_day, last_day) + + post_date = post_date.replace(year=_year, month=_month, day=safe_day) + + # Get date for event posts that is max 1 day old. + event_date = timezone.now().date() - datetime.timedelta(1) + + return ( + self.published(public) + .filter( + Q(is_pinned=True) + & ( + (Q(post_type=PostType.NEWS) & Q(public_date__gt=post_date)) + | (Q(post_type=PostType.EVENT) & Q(event_end__date__gt=event_date)) + ) + ) + .first() + ) + + +class NewsManager(PublishedManager, models.Manager): + """ + Provide a query set only for "News" + """ + + def get_queryset(self): + qs = super().get_queryset().filter(post_type=PostType.NEWS) + qs = qs.annotate( + date=Case( + When(post_type=PostType.NEWS, then="public_date"), + ), + ) + return qs.order_by("-date") + + +class AllEventManager(PublishedManager, models.Manager): + """ + Provide a query set for all events ("Event" and "Fet Meeting") + """ + + def get_queryset(self): + qs = ( + super() + .get_queryset() + .filter(Q(post_type=PostType.EVENT) | Q(post_type=PostType.FETMEETING)) + ) + qs = qs.annotate( + date=Case( + When(post_type=PostType.EVENT, then="event_start__date"), + When(post_type=PostType.FETMEETING, then="event_start__date"), + ), + ) + return qs.order_by("-date") + + def future_events(self, public=True): + date_today = timezone.now().date() + qs = self.published(public).filter(event_start__gt=date_today) + return qs.reverse() + + +class EventManager(PublishedManager, models.Manager): + """ + Provide a query set only for "Events" + regular fet meetings should not be contained in the news stream + """ + + def get_queryset(self): + qs = super().get_queryset().filter(post_type=PostType.EVENT) + qs = qs.annotate( + date=Case( + When(post_type=PostType.EVENT, then="event_start__date"), + ), + ) + return qs.order_by("-date") + + def future_events(self, public=True): + date_today = timezone.now().date() + qs = self.published(public).filter(event_start__gt=date_today) + return qs.reverse() + + def past_events(self, public=True): + date_today = timezone.now().date() + qs = self.published(public).filter(event_start__lt=date_today) + return qs + + +class FetMeetingManager(PublishedManager, models.Manager): + """ + Provide a query set only for "Fet Meeting" + """ + + def get_queryset(self): + qs = super().get_queryset().filter(post_type=PostType.FETMEETING) + qs = qs.annotate( + date=Case( + When(post_type=PostType.FETMEETING, then="event_start__date"), + ), + ) + return qs.order_by("-date") + + def future_events(self): + date_today = timezone.now().date() + qs = self.published().filter(event_start__gt=date_today) + return qs.reverse() + + def past_events(self): + date_today = timezone.now().date() + qs = self.published().filter(event_start__lt=date_today) + return qs diff --git a/fet2020/rental/admin.py b/fet2020/rental/admin.py index 42227a1d..5bb7487f 100644 --- a/fet2020/rental/admin.py +++ b/fet2020/rental/admin.py @@ -1,124 +1,121 @@ -from django.contrib import admin, messages -from django.http import HttpResponseRedirect - -from .forms import RentalAdminForm, RentalItemAdminForm -from .models import Rental, RentalItem -from .utils import generate_rental_pdf - - -@admin.register(Rental) -class RentalAdmin(admin.ModelAdmin): - form = RentalAdminForm - model = Rental - - list_display = [ - "id", - "firstname", - "surname", - "status", - "total_disposit", - "date_start", - "date_end", - ] - ordering = ["-id"] - - readonly_fields = ["total_disposit"] - - fieldsets = ( - ( - "Persönliche Daten", - { - "fields": ( - ("firstname", "surname"), - ("organization", "matriculation_number"), - ("email", "phone"), - ), - }, - ), - ( - "Verleih", - { - "fields": ( - ("date_start", "date_end"), - "reason", - "rentalitems", - "total_disposit", - ), - }, - ), - ( - "Sonstiges", - { - "fields": ( - "comment", - "file_field", - "status", - ), - }, - ), - ) - - def add_view(self, request, form_url="", extra_context=None): - extra_context = extra_context or {} - extra_context["help_text"] = "Fette Schriften sind Pflichtfelder." - return super().add_view(request, form_url, extra_context=extra_context) - - def change_view(self, request, object_id, form_url="", extra_context=None): - extra_context = extra_context or {} - extra_context["help_text"] = "Fette Schriften sind Pflichtfelder." - extra_context["generate_rental_pdf"] = True - return super().change_view(request, object_id, form_url, extra_context=extra_context) - - def response_change(self, request, obj): - if "_generate_rental_pdf" in request.POST: - if generate_rental_pdf(obj): - self.message_user( - request, - "Neues Verleihformular wurde generiert.", - messages.SUCCESS, - ) - else: - self.message_user( - request, - ( - "Das PDF-Dokument konnte nicht generiert werden, da der Status nicht auf " - "'Verleih genehmigt' gesetzt ist." - ), - messages.WARNING, - ) - return HttpResponseRedirect(".") - return super().response_change(request, obj) - - def save_model(self, request, obj, form, change): - obj.author = request.user - super().save_model(request, obj, form, change) - - @admin.display(description="Kaution (EUR)") - def total_disposit(self, obj): - total_disposit = 0 - for elem in obj.rentalitems.all(): - total_disposit += elem.deposit - - return f"{total_disposit}" - - -@admin.register(RentalItem) -class RentalItemAdmin(admin.ModelAdmin): - form = RentalItemAdminForm - model = RentalItem - - ordering = ["name"] - - def add_view(self, request, form_url="", extra_context=None): - extra_context = extra_context or {} - extra_context["help_text"] = "Fette Schriften sind Pflichtfelder." - return super().add_view(request, form_url, extra_context=extra_context) - - def change_view(self, request, object_id, form_url="", extra_context=None): - extra_context = extra_context or {} - extra_context["help_text"] = "Fette Schriften sind Pflichtfelder." - return super().change_view(request, object_id, form_url, extra_context=extra_context) - - def save_model(self, request, obj, form, change): - obj.author = request.user - super().save_model(request, obj, form, change) +from django.contrib import admin, messages +from django.http import HttpResponseRedirect + +from .forms import RentalAdminForm, RentalItemAdminForm +from .models import Rental, RentalItem +from .utils import generate_rental_pdf + + +@admin.register(Rental) +class RentalAdmin(admin.ModelAdmin): + form = RentalAdminForm + model = Rental + + list_display = [ + "id", + "firstname", + "surname", + "status", + "total_disposit", + "date_start", + "date_end", + ] + ordering = ["-id"] + + readonly_fields = ["total_disposit"] + + fieldsets = ( + ( + "Persönliche Daten", + { + "fields": ( + ("firstname", "surname"), + ("organization", "matriculation_number"), + ("email", "phone"), + ), + }, + ), + ( + "Verleih", + { + "fields": ( + ("date_start", "date_end"), + "reason", + "rentalitems", + ("total_disposit", "intern"), + ), + }, + ), + ( + "Sonstiges", + { + "fields": ( + "comment", + "file_field", + "status", + ), + }, + ), + ) + + def add_view(self, request, form_url="", extra_context=None): + extra_context = extra_context or {} + extra_context["help_text"] = "Fette Schriften sind Pflichtfelder." + return super().add_view(request, form_url, extra_context=extra_context) + + def change_view(self, request, object_id, form_url="", extra_context=None): + extra_context = extra_context or {} + extra_context["help_text"] = "Fette Schriften sind Pflichtfelder." + extra_context["generate_rental_pdf"] = True + return super().change_view(request, object_id, form_url, extra_context=extra_context) + + def response_change(self, request, obj): + if "_generate_rental_pdf" in request.POST: + if generate_rental_pdf(obj): + self.message_user( + request, + "Neues Verleihformular wurde generiert.", + messages.SUCCESS, + ) + else: + self.message_user( + request, + ( + "Das PDF-Dokument konnte nicht generiert werden, da der Status nicht auf " + "'Verleih genehmigt' gesetzt ist." + ), + messages.WARNING, + ) + return HttpResponseRedirect(".") + return super().response_change(request, obj) + + def save_model(self, request, obj, form, change): + obj.author = request.user + super().save_model(request, obj, form, change) + + @admin.display(description="Kaution insgesamt") + def total_disposit(self, obj): + total_disposit = obj.calc_total_deposit() + return f"{total_disposit} €" + + +@admin.register(RentalItem) +class RentalItemAdmin(admin.ModelAdmin): + form = RentalItemAdminForm + model = RentalItem + + ordering = ["name"] + + def add_view(self, request, form_url="", extra_context=None): + extra_context = extra_context or {} + extra_context["help_text"] = "Fette Schriften sind Pflichtfelder." + return super().add_view(request, form_url, extra_context=extra_context) + + def change_view(self, request, object_id, form_url="", extra_context=None): + extra_context = extra_context or {} + extra_context["help_text"] = "Fette Schriften sind Pflichtfelder." + return super().change_view(request, object_id, form_url, extra_context=extra_context) + + def save_model(self, request, obj, form, change): + obj.author = request.user + super().save_model(request, obj, form, change) diff --git a/fet2020/rental/forms.py b/fet2020/rental/forms.py index 366ce88f..fdf4eb0a 100644 --- a/fet2020/rental/forms.py +++ b/fet2020/rental/forms.py @@ -1,58 +1,65 @@ -from django import forms -from django.forms import DateInput - -from .models import Rental, RentalItem - - -class DateInput(DateInput): - input_type = "date" - - -class RentalCreateForm(forms.ModelForm): - # Conformation - conformation = forms.BooleanField( - required=True, - label=("Ich habe die Verleihregeln gelesen und akzeptiere sie."), - initial=False, - ) - - class Meta: - model = Rental - - fields = [ - "firstname", - "surname", - "matriculation_number", - "email", - "phone", - "organization", - "date_start", - "date_end", - "reason", - "comment", - "rentalitems", - ] - - widgets = { - "date_start": DateInput(format=("%Y-%m-%d")), - "date_end": DateInput(format=("%Y-%m-%d")), - } - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) # to get the self.fields set - - self.fields["firstname"].autofocus = True - - -class RentalAdminForm(forms.ModelForm): - class Meta: - model = Rental - fields = "__all__" - - widgets = {"rentalitems": forms.CheckboxSelectMultiple()} - - -class RentalItemAdminForm(forms.ModelForm): - class Meta: - model = RentalItem - fields = "__all__" +from django import forms +from django.forms import DateInput + +from .models import Rental, RentalItem + + +class DateInput(DateInput): + input_type = "date" + + +class RentalCreateForm(forms.ModelForm): + # Conformation + conformation = forms.BooleanField( + required=True, + label=("Ich habe die Verleihregeln gelesen und akzeptiere sie."), + initial=False, + ) + + class Meta: + model = Rental + + fields = [ + "firstname", + "surname", + "matriculation_number", + "email", + "phone", + "organization", + "date_start", + "date_end", + "reason", + "comment", + "rentalitems", + ] + + widgets = { + "date_start": DateInput(format=("%Y-%m-%d")), + "date_end": DateInput(format=("%Y-%m-%d")), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) # to get the self.fields set + + self.fields["firstname"].autofocus = True + + +class RentalAdminForm(forms.ModelForm): + class Meta: + model = Rental + fields = "__all__" + + widgets = {"rentalitems": forms.CheckboxSelectMultiple()} + + help_texts = { + "status": ( + "Wird der Status auf 'Verleih genehmigt' oder 'Verleih abgelehnt' gesetzt, wird " + "eine E-Mail gesendet." + ), + } + + +class RentalItemAdminForm(forms.ModelForm): + class Meta: + model = RentalItem + fields = "__all__" diff --git a/fet2020/rental/mails.py b/fet2020/rental/mails.py index 967ac989..47c0f3eb 100644 --- a/fet2020/rental/mails.py +++ b/fet2020/rental/mails.py @@ -1,53 +1,53 @@ -import logging - -from django.core.mail import EmailMessage - -RENTAL_EMAIL = "verleih@fet.at" -logger = logging.getLogger(__name__) - - -def send_mail_approved(obj): - subject = f"FET-Verleih #{obj.id}: {obj.get_status_display()}" - - total_deposit = 0 - for rentalitem in obj.rentalitems.all(): - total_deposit += rentalitem.deposit - - message = ( - f"Hallo {obj.firstname},\n" - f"deine Verleihanfrage mit der Nummer #{obj.id} wurde erfolgreich genehmigt. Die " - f"Gegenstände können am {obj.date_start.strftime('%d.%m.%Y')} während der Beratungszeit " - "(Montag - Donnerstag: 09:00 - 14:00, Freitag: 09:00 - 12:00) abgeholt werden. Bitte bring " - f"den Gesamtpfand in Höhe von {total_deposit} € in bar mit.\n" - "Liebe Grüße,\n" - "das Verleih-Team" - ) - - email = EmailMessage( - subject, message, from_email=RENTAL_EMAIL, to=[obj.email], cc=[RENTAL_EMAIL] - ) - - try: - email.send() - except Exception as exc: - logger.error("Failed to send approval email for rental #%s. Error: %s", obj.id, exc) - - -def send_mail_rejected(obj): - subject = f"FET-Verleih #{obj.id}: {obj.get_status_display()}" - - message = ( - f"Hallo {obj.firstname},\n" - f"deine Verleihanfrage mit der Nummer #{obj.id} wurde abgelehnt.\n" - "Liebe Grüße,\n" - "das Verleih-Team" - ) - - email = EmailMessage( - subject, message, from_email=RENTAL_EMAIL, to=[obj.email], cc=[RENTAL_EMAIL] - ) - - try: - email.send() - except Exception as exc: - logger.error("Failed to send rejection email for rental #%s. Error: %s", obj.id, exc) +import logging + +from django.conf import settings +from django.core.mail import EmailMessage + +RENTAL_EMAIL = settings.EMAIL_HOST_USER +logger = logging.getLogger(__name__) + + +def send_mail_approved(obj): + subject = f"FET-Verleih #{obj.id}: {obj.get_status_display()}" + total_deposit = obj.calc_total_deposit() + + message = ( + f"Hallo {obj.firstname},\n\n" + f"deine Verleihanfrage mit der Nummer #{obj.id} wurde erfolgreich genehmigt. Die " + f"Gegenstände können am {obj.date_start.strftime('%d.%m.%Y')} während der Beratungszeit " + "(Montag - Donnerstag: 09:00 - 14:00, Freitag: 09:00 - 12:00) abgeholt werden.\n" + ) + + if total_deposit > 0: + message += f"Bitte bring den Gesamtpfand in Höhe von {total_deposit} € in bar mit.\n" + + message += "\nLiebe Grüße,\ndas Verleih-Team" + + email = EmailMessage( + subject, message, from_email=RENTAL_EMAIL, to=[obj.email], cc=[RENTAL_EMAIL] + ) + + try: + email.send() + except Exception as exc: + logger.info("Failed to send approval email for rental #%s. Error: %s", obj.id, exc) + + +def send_mail_rejected(obj): + subject = f"FET-Verleih #{obj.id}: {obj.get_status_display()}" + + message = ( + f"Hallo {obj.firstname},\n\n" + f"deine Verleihanfrage mit der Nummer #{obj.id} wurde abgelehnt.\n\n" + "Liebe Grüße,\n" + "das Verleih-Team" + ) + + email = EmailMessage( + subject, message, from_email=RENTAL_EMAIL, to=[obj.email], cc=[RENTAL_EMAIL] + ) + + try: + email.send() + except Exception as exc: + logger.info("Failed to send rejection email for rental #%s. Error: %s", obj.id, exc) diff --git a/fet2020/rental/managers.py b/fet2020/rental/managers.py new file mode 100644 index 00000000..9f2cc974 --- /dev/null +++ b/fet2020/rental/managers.py @@ -0,0 +1,6 @@ +from django.db import models + + +class RentalItemsManager(models.Manager): + def get_queryset(self): + return super().get_queryset().order_by("name") diff --git a/fet2020/rental/migrations/0001_initial.py b/fet2020/rental/migrations/0001_initial.py new file mode 100644 index 00000000..fc696be8 --- /dev/null +++ b/fet2020/rental/migrations/0001_initial.py @@ -0,0 +1,55 @@ +# Generated by Django 5.2.7 on 2025-10-30 12:05 + +import django.core.validators +import rental.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='RentalItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=128, verbose_name='Name')), + ('description', models.CharField(blank=True, default='', max_length=128, verbose_name='Beschreibung')), + ('deposit', models.DecimalField(decimal_places=2, default=0, max_digits=6, verbose_name='Kaution (EUR)')), + ('image', models.ImageField(blank=True, null=True, upload_to='rentalitems', verbose_name='Bild')), + ('induction', models.BooleanField(default=False, verbose_name='Einschulung notwendig')), + ('location', models.CharField(blank=True, default='', max_length=128, verbose_name='Standort')), + ], + options={ + 'verbose_name': 'Verleihgegenstand', + 'verbose_name_plural': 'Verleihgegenstände', + }, + ), + migrations.CreateModel( + name='Rental', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('firstname', models.CharField(max_length=128, verbose_name='Vorname')), + ('surname', models.CharField(max_length=128, verbose_name='Nachname')), + ('matriculation_number', models.CharField(max_length=8, verbose_name='Matrikelnummer')), + ('email', models.EmailField(max_length=254, verbose_name='E-Mail')), + ('phone', models.CharField(max_length=32, validators=[rental.validators.PhoneNumberValidator()], verbose_name='Telefonnummer')), + ('organization', models.CharField(max_length=128, verbose_name='Organisation')), + ('date_start', models.DateField(verbose_name='Abholdatum')), + ('date_end', models.DateField(verbose_name='Rückgabedatum')), + ('reason', models.TextField(max_length=500, verbose_name='Grund der Ausleihe')), + ('comment', models.TextField(blank=True, default='', max_length=500, verbose_name='Kommentar')), + ('file_field', models.FileField(blank=True, null=True, upload_to='uploads/rental/rental/', validators=[django.core.validators.FileExtensionValidator(['pdf'])], verbose_name='Verleihformular')), + ('status', models.CharField(choices=[('S', 'Eingereicht'), ('A', 'Verleih genehmigt'), ('J', 'Verleih abgelehnt'), ('I', 'Verleihgegenstände ausgegeben'), ('R', 'Verleihgegenstände zurückgegeben')], default='S', max_length=1, verbose_name='Status')), + ('rentalitems', models.ManyToManyField(to='rental.rentalitem', verbose_name='Verleihgegenstände')), + ], + options={ + 'verbose_name': 'Verleih', + 'verbose_name_plural': 'Verleih', + }, + ), + ] diff --git a/fet2020/rental/migrations/0002_rental_intern.py b/fet2020/rental/migrations/0002_rental_intern.py new file mode 100644 index 00000000..60e4df62 --- /dev/null +++ b/fet2020/rental/migrations/0002_rental_intern.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2025-10-30 14:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('rental', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='rental', + name='intern', + field=models.BooleanField(default=False, verbose_name='Interner Verleih'), + ), + ] diff --git a/fet2020/rental/migrations/__init__.py b/fet2020/rental/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fet2020/rental/models.py b/fet2020/rental/models.py index 60a68c76..f874c00b 100644 --- a/fet2020/rental/models.py +++ b/fet2020/rental/models.py @@ -3,6 +3,7 @@ from django.db import models from django.forms import ValidationError from .mails import send_mail_approved, send_mail_rejected +from .managers import RentalItemsManager from .validators import PhoneNumberValidator @@ -22,6 +23,8 @@ class RentalItem(models.Model): location = models.CharField(verbose_name="Standort", max_length=128, blank=True, default="") + objects = RentalItemsManager() + class Meta: verbose_name = "Verleihgegenstand" verbose_name_plural = "Verleihgegenstände" @@ -41,6 +44,7 @@ class Rental(models.Model): ) organization = models.CharField(verbose_name="Organisation", max_length=128) + intern = models.BooleanField(verbose_name="Interner Verleih", default=False) date_start = models.DateField(verbose_name="Abholdatum") date_end = models.DateField(verbose_name="Rückgabedatum") @@ -107,3 +111,12 @@ class Rental(models.Model): if self.date_start > self.date_end: raise ValidationError("Das Abholdatum muss vor dem Rückgabedatum liegen.") + + def calc_total_deposit(self) -> int: + total_deposit = 0 + + if not self.intern: + for item in self.rentalitems.all(): + total_deposit += item.deposit + + return total_deposit diff --git a/fet2020/rental/static/rental/Verleihformular.pdf b/fet2020/rental/static/rental/Verleihformular.pdf index db8dce11..eba462bd 100644 Binary files a/fet2020/rental/static/rental/Verleihformular.pdf and b/fet2020/rental/static/rental/Verleihformular.pdf differ diff --git a/fet2020/rental/urls.py b/fet2020/rental/urls.py index db656620..4712200f 100644 --- a/fet2020/rental/urls.py +++ b/fet2020/rental/urls.py @@ -10,7 +10,7 @@ urlpatterns = [ path("overview/", RentalListView.as_view(), name="index"), path("request-rental/", RentalCreateView.as_view(), name="rental_create"), path( - "request-rental//done/", + "request-rental/done/", RentalCreateDoneView.as_view(), name="rental_create_done", ), diff --git a/fet2020/rental/utils.py b/fet2020/rental/utils.py index 4c4ff0b2..0f52a2dd 100644 --- a/fet2020/rental/utils.py +++ b/fet2020/rental/utils.py @@ -1,66 +1,61 @@ -import io -from pathlib import Path - -from django.core.files import File -from pypdf import PdfReader, PdfWriter - -from .models import Rental - - -def generate_rental_pdf(rental: Rental) -> bool: - if not rental or rental.status != Rental.Status.APPROVED: - return False - - # Get data for pdf - data = {} - data.update( - { - "Vorname": rental.firstname, - "Nachname": rental.surname, - "Orga": rental.organization, - "Matrikelnummer": rental.matriculation_number, - "E-Mail": rental.email, - "Telefonnummer": rental.phone, - # Change to the correct date format - "Abholdatum": str(rental.date_start.strftime("%d.%m.%Y")), - "Rückgabedatum": str(rental.date_end.strftime("%d.%m.%Y")), - }, - ) - - total_deposit = 0 - - for i, item in enumerate(rental.rentalitems.all(), start=1): - total_deposit += item.deposit - data.update( - { - f"Produkt Row{i}": item.name, - f"Menge Row{i}": "1", - f"Kaution Row{i}": item.deposit, - }, - ) - - data.update( - { - "Gesamtkaution": total_deposit, - }, - ) - - # Write data in pdf - pdf_path = Path(Path(__file__).parent) / "static/rental/Verleihformular.pdf" - reader = PdfReader(pdf_path) - writer = PdfWriter() - writer.append(reader) - - writer.update_page_form_field_values( - writer.pages[0], - data, - ) - - with io.BytesIO() as bytes_stream: - writer.write(bytes_stream) - - # Save pdf in rental - rental_name = f"Verleihformular-{str(rental.pk).zfill(4)}.pdf" - rental.file_field.save(rental_name, File(bytes_stream, rental_name)) - - return True +import io + +from django.contrib.staticfiles import finders +from django.core.files import File +from pypdf import PdfReader, PdfWriter + +from .models import Rental + + +def generate_rental_pdf(rental: Rental) -> bool: + if not rental or rental.status != Rental.Status.APPROVED: + return False + + # Get data for pdf + data = {} + data.update( + { + "Vorname": rental.firstname, + "Nachname": rental.surname, + "Orga": rental.organization, + "Matrikelnummer": rental.matriculation_number, + "E-Mail": rental.email, + "Telefonnummer": rental.phone, + # Change to the correct date format + "Abholdatum": str(rental.date_start.strftime("%d.%m.%Y")), + "Rückgabedatum": str(rental.date_end.strftime("%d.%m.%Y")), + }, + ) + + for i, item in enumerate(rental.rentalitems.all(), start=1): + data.update( + { + f"Produkt Row{i}": item.name, + f"Menge Row{i}": "1", + f"Kaution Row{i}": (str(item.deposit) if not rental.intern else "0"), + }, + ) + + total_deposit = rental.calc_total_deposit() + data.update({"Gesamtkaution": str(total_deposit)}) + + # Write data in pdf + pdf_path_str = finders.find("rental/Verleihformular.pdf") + reader = PdfReader(pdf_path_str) + writer = PdfWriter() + writer.append(reader) + + writer.update_page_form_field_values( + writer.pages[0], + data, + ) + + with io.BytesIO() as bytes_stream: + writer.write(bytes_stream) + bytes_stream.seek(0) + + # Save pdf in rental + rental_name = f"Verleihformular-{str(rental.pk).zfill(4)}.pdf" + rental.file_field.save(rental_name, File(bytes_stream, rental_name)) + + return True diff --git a/fet2020/rental/views.py b/fet2020/rental/views.py index f6116094..4815d588 100644 --- a/fet2020/rental/views.py +++ b/fet2020/rental/views.py @@ -1,9 +1,12 @@ import calendar import datetime +from datetime import date from django.db.models import Q +from django.http import HttpResponseRedirect from django.shortcuts import render from django.urls import reverse +from django.utils import timezone from django.views.generic import ListView, TemplateView from django.views.generic.detail import DetailView from django.views.generic.edit import CreateView @@ -11,91 +14,164 @@ from django.views.generic.edit import CreateView from .forms import RentalCreateForm from .models import Rental, RentalItem +# Maximum number of rental items per rental entry because of table size limitations in PDF file +RENTAL_ITEMS_MAX = 5 + + +def _calc_days_from_current_month(month: date) -> list: + last_day_of_month = month.replace(day=calendar.monthrange(month.year, month.month)[1]) + + return [month + datetime.timedelta(days=i) for i in range((last_day_of_month - month).days + 1)] + + +def _calc_days_from_prev_month(month: date) -> list: + days_of_prev_period = [] + + if month.weekday() != calendar.MONDAY: + for i in range(1, 7): + day = month + datetime.timedelta(days=-i) + days_of_prev_period.append(day) + if day.weekday() == calendar.MONDAY: + break + + return sorted(days_of_prev_period) + + +def _calc_days_from_next_month(month: date) -> list: + last_day_of_month = month.replace(day=calendar.monthrange(month.year, month.month)[1]) + days_of_next_period = [] + + if last_day_of_month.weekday() != calendar.SUNDAY: + for i in range(1, 7): + day = last_day_of_month + datetime.timedelta(days=i) + days_of_next_period.append(day) + if day.weekday() == calendar.SUNDAY: + break + + return days_of_next_period + + +def _get_display_period(view_type: str, period: str) -> date: + display_date: date = None + + # Handle week view + if view_type == "week": + try: + # Parse the requested calendar week + display_date = ( + datetime.datetime.strptime(f"{period}-1", "%G-KW%V-%u") + .replace(tzinfo=datetime.UTC) + .date() + ) + except Exception: + # Get first day of the current week + today = timezone.now().date() + display_date = today - datetime.timedelta(days=today.weekday()) + + # Handle month view + else: + try: + # Parse the requested month + display_date = ( + datetime.datetime.strptime(period, "%Y-%m").replace(tzinfo=datetime.UTC).date() + ) + except Exception: + # Get the first day of the current month + display_date = timezone.now().date().replace(day=1) + + return display_date + class RentalListView(ListView): model = Rental template_name = "rental/calendar.html" - # Month is the month displayed in the calendar (and should be the first day of the month) - month = None - # Rental items to filter - rentalitem_filters = [] + def __init__(self): + super().__init__() + # Current display period and view settings + self.display_period = None + self.view_type = "month" # Default view + self.rentalitem_filters = [] def get(self, request, *args, **kwargs): - # Get the rental items from the filter (max. 4) - self.rentalitem_filters = request.GET.getlist("rentalitems", [])[:4] - if not self.rentalitem_filters: - for rentalitem in RentalItem.objects.all()[:4]: - self.rentalitem_filters.append(rentalitem.name) + # Get view parameters from request + _view_type = request.GET.get("view_type", "week") + _period = request.GET.get("period_value", "") + _prev_period = request.GET.get("prev_period", "") + _next_period = request.GET.get("next_period", "") - # Get the displayed month from the request - _date_str = request.GET.get("month", "") - if _date_str: - self.month = ( - datetime.datetime.strptime(_date_str, "%Y-%m").replace(tzinfo=datetime.UTC).date() - ) - else: - self.month = datetime.datetime.now(tz=datetime.UTC).date().replace(day=1) + if _prev_period: + _period = _prev_period + elif _next_period: + _period = _next_period + + self.view_type = _view_type + self.display_period = _get_display_period(_view_type, _period) + + self.rentalitem_filters = request.GET.getlist("rentalitems", []) + if not self.rentalitem_filters: + items = RentalItem.objects.all() + self.rentalitem_filters = [item.name for item in items] + + # Update request.GET + _request_get_list = request.GET.copy() + _request_get_list.pop("prev_period", None) + _request_get_list.pop("next_period", None) + _request_get_list["period_value"] = _period + request.GET = _request_get_list return super().get(request, *args, **kwargs) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - # Calculate the day of the previous month from Monday - days_of_prev_month = [] + if self.view_type != "week": + # Add the displayed, previous and next month + context["view_period"] = self.display_period + context["prev_period"] = self.display_period + datetime.timedelta(days=-1) + context["next_period"] = self.display_period + datetime.timedelta( + days=calendar.monthrange(self.display_period.year, self.display_period.month)[1] + 1 + ) - if self.month.weekday() != calendar.MONDAY: - for i in range(1, 7): - day = self.month + datetime.timedelta(days=-i) - days_of_prev_month.append(day) - if day.weekday() == calendar.MONDAY: - break + # Add the days of the displayed, previous and next month + days_of_view_period = _calc_days_from_current_month(self.display_period) + context["days_of_view_period"] = days_of_view_period + context["days_of_prev_period"] = _calc_days_from_prev_month(self.display_period) + context["days_of_next_period"] = _calc_days_from_next_month(self.display_period) - # Calculate the days of the next month until Sunday - last_day_of_month = self.month.replace( - day=calendar.monthrange(self.month.year, self.month.month)[1] - ) - days_of_next_month = [] + context["view_type"] = "month" - if last_day_of_month.weekday() != calendar.SUNDAY: - for i in range(1, 7): - day = last_day_of_month + datetime.timedelta(days=i) - days_of_next_month.append(day) - if day.weekday() == calendar.SUNDAY: - break + context["week_num"] = None - # Calculate the days of the displayed month - days_of_month = [ - self.month + datetime.timedelta(days=i) - for i in range((last_day_of_month - self.month).days + 1) - ] + else: + # Current week + year, week_num, _ = self.display_period.isocalendar() + context["view_period"] = f"{year}-KW{week_num:02d}" # formats as "2025-KW02" - # Create a dictionary with the rental items for each day - rental_dict = {} - for rental in self.get_queryset(): - for day in days_of_month: - if rental["date_start"] <= day and rental["date_end"] >= day: - if day not in rental_dict: - rental_dict[day] = [] + # Calculate previous week + prev_week = self.display_period - datetime.timedelta(days=7) + prev_year, prev_week_num, _ = prev_week.isocalendar() + context["prev_period"] = f"{prev_year}-KW{prev_week_num:02d}" - if rental["rentalitems__name"] not in rental_dict[day]: - rental_dict[day].append(rental["rentalitems__name"]) + # Calculate next week + next_week = self.display_period + datetime.timedelta(days=7) + next_year, next_week_num, _ = next_week.isocalendar() + context["next_period"] = f"{next_year}-KW{next_week_num:02d}" - # Add the displayed, previous and next month - context["month"] = self.month - context["prev_month"] = self.month + datetime.timedelta(days=-1) - context["next_month"] = self.month + datetime.timedelta( - days=calendar.monthrange(self.month.year, self.month.month)[1] + 1 - ) + # Add days of week (7 days starting from first_day_of_week) + days_of_view_period = [ + self.display_period + datetime.timedelta(days=i) for i in range(7) + ] + context["days_of_view_period"] = days_of_view_period + context["days_of_prev_period"] = [] + context["days_of_next_period"] = [] - # Add the days of the displayed, previous and next month - context["days_of_month"] = days_of_month - context["days_of_prev_month"] = sorted(days_of_prev_month) - context["days_of_next_month"] = days_of_next_month + context["view_type"] = "week" + + context["week_num"] = week_num # Get the current date for the calendar - context["today"] = datetime.datetime.now(tz=datetime.UTC).date() + context["today"] = timezone.now().date() # Add rental items to the context for the filter context["rentalitems"] = RentalItem.objects.all() @@ -103,7 +179,18 @@ class RentalListView(ListView): # Add the selected rental items to the context for the filter context["rentalitem_filters"] = {"rentalitems": self.rentalitem_filters} - context["rental_dict"] = rental_dict + # Create a dictionary with the rental items for each day + rental_dict = {} + for rental in self.get_queryset(): + for day in days_of_view_period: + if rental["date_start"] <= day and rental["date_end"] >= day: + if day not in rental_dict: + rental_dict[day] = [] + + if rental["rentalitems__name"] not in rental_dict[day]: + rental_dict[day].append(rental["rentalitems__name"]) + + context["rental_dict"] = {k: sorted(v, key=str.casefold) for k, v in rental_dict.items()} return context @@ -118,20 +205,22 @@ class RentalListView(ListView): ) ) - last_day_of_month = self.month.replace( - day=calendar.monthrange(self.month.year, self.month.month)[1] - ) + if self.view_type == "week": + qs_date_end = self.display_period + datetime.timedelta(days=6) + else: + qs_date_end = self.display_period.replace( + day=calendar.monthrange(self.display_period.year, self.display_period.month)[1] + ) # Filter by date - qs_new = qs.filter(date_start__gte=self.month, date_start__lte=last_day_of_month) - qs_new |= qs.filter(date_end__gte=self.month, date_end__lte=last_day_of_month) + if self.display_period and qs_date_end: + qs_new = qs.filter(date_start__gte=self.display_period, date_start__lte=qs_date_end) + qs_new |= qs.filter(date_end__gte=self.display_period, date_end__lte=qs_date_end) # Filter by rental items qs = qs.filter(rentalitems__name__in=self.rentalitem_filters).distinct() - qs = qs.values("id", "date_start", "date_end", "rentalitems__name").distinct() - - return qs + return qs.values("id", "date_start", "date_end", "rentalitems__name").distinct() class RentalCreateView(CreateView): @@ -139,6 +228,35 @@ class RentalCreateView(CreateView): model = Rental template_name = "rental/create.html" + def form_valid(self, form): + # Get unsaved base Rental with all form data + base_rental = form.save(commit=False) + items = list(form.cleaned_data["rentalitems"]) + selected_items = sorted(items, key=lambda x: x.name.lower()) + + for i in range(0, len(selected_items), RENTAL_ITEMS_MAX): + batch = selected_items[i : i + RENTAL_ITEMS_MAX] + + # Clone base_rental — copying all its field values + new_rental = Rental.objects.create( + firstname=base_rental.firstname, + surname=base_rental.surname, + matriculation_number=base_rental.matriculation_number, + email=base_rental.email, + phone=base_rental.phone, + organization=base_rental.organization, + date_start=base_rental.date_start, + date_end=base_rental.date_end, + reason=base_rental.reason, + comment=base_rental.comment, + status=base_rental.status, + ) + + # Important: Add M2M relations after the object has been saved + new_rental.rentalitems.add(*batch) + + return HttpResponseRedirect(self.get_success_url()) + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -147,26 +265,12 @@ class RentalCreateView(CreateView): return context def get_success_url(self): - return reverse("rental:rental_create_done", kwargs={"pk": self.object.pk}) + return reverse("rental:rental_create_done") class RentalCreateDoneView(TemplateView): template_name = "rental/create_done.html" - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - - obj = Rental.objects.get(pk=self.kwargs["pk"]) - - total_deposit = 0 - for elem in obj.rentalitems.all(): - total_deposit += elem.deposit - - context["object"] = obj - context["total_deposit"] = total_deposit - - return context - class RentalItemDetailView(DetailView): model = RentalItem @@ -175,7 +279,7 @@ class RentalItemDetailView(DetailView): def rental_calendar(request): """ - ICS-calendar for outlook, google calender, ... + ICS-calendar for Outlook, Google Calendar, etc. """ rentals = Rental.objects.all() diff --git a/fet2020/templates/finance/index.html b/fet2020/templates/finance/index.html index 99003e69..9208b5e3 100644 --- a/fet2020/templates/finance/index.html +++ b/fet2020/templates/finance/index.html @@ -1,78 +1,78 @@ -{% extends "base.html" %} - -{% block title %}Meine Rechnungen / Honorarnoten{% endblock %} - -{% block content %} - -
-

Meine Rechnungen / Honorarnoten

- - Rechnung einreichen - - -
-
- - - - - - - - - - - - {% for result in object_list %} - - - - - - - - {% endfor %} - -
DatumVerwendungszweck / TätigkeitSummeStatus
{{ result.date|date:"d.m.Y" }}{{ result.purpose }}{{ result.amount }}€ - {% if result.model == "BILL" %} - {% if result.status == bill_status.SUBMITTED %} - {{ bill_status.SUBMITTED.label }} - {% elif result.status == bill_status.INCOMPLETED %} - {{ bill_status.INCOMPLETED.label }} - {% elif result.status == bill_status.CLEARED %} - {{ bill_status.CLEARED.label }} - {% elif result.status == bill_status.FINISHED %} - {{ bill_status.FINISHED.label }} - {% endif %} - {% endif %} - - {% if result.model == "BILL" %} - - {% endif %} -
-
- -
-
- -
-
-
-
-{% endblock content %} +{% extends "base.html" %} + +{% block title %}Meine Rechnungen{% endblock %} + +{% block content %} + +
+

Meine Rechnungen

+ + Rechnung einreichen + + +
+
+ + + + + + + + + + + + {% for result in object_list %} + + + + + + + + {% endfor %} + +
DatumVerwendungszweck / TätigkeitSummeStatus
{{ result.date|date:"d.m.Y" }}{{ result.purpose }}{{ result.amount }}€ + {% if result.model == "BILL" %} + {% if result.status == bill_status.SUBMITTED %} + {{ bill_status.SUBMITTED.label }} + {% elif result.status == bill_status.INCOMPLETED %} + {{ bill_status.INCOMPLETED.label }} + {% elif result.status == bill_status.CLEARED %} + {{ bill_status.CLEARED.label }} + {% elif result.status == bill_status.FINISHED %} + {{ bill_status.FINISHED.label }} + {% endif %} + {% endif %} + + {% if result.model == "BILL" %} + + {% endif %} +
+
+ +
+
+ +
+
+
+
+{% endblock content %} diff --git a/fet2020/templates/home.html b/fet2020/templates/home.html index ee9563f8..882c5075 100644 --- a/fet2020/templates/home.html +++ b/fet2020/templates/home.html @@ -58,7 +58,7 @@
  • - Meine Rechnungen / Honorarnoten + Meine Rechnungen
  • diff --git a/fet2020/templates/rental/calendar.html b/fet2020/templates/rental/calendar.html index 8cca61f5..722da159 100644 --- a/fet2020/templates/rental/calendar.html +++ b/fet2020/templates/rental/calendar.html @@ -1,192 +1,281 @@ -{% extends 'base.html' %} - -{% load static %} - -{% block title %}Verleih{% endblock %} - -{% block content %} -
    -

    Verleih

    - -
    -

    - Willkommen bei unserem Verleih! -

    - - {% if user.is_authenticated %} - Verleih-Kalender abonnieren - {% endif %} - - - Verleih anfragen - -
    - -
    -
    -
    - - - - -
    -
    - -
    -
    -
    - {{ month|date:'F Y' }} - -
    - - -
    -
    - - - - - - - - - - - - - - - - {% for day in days_of_prev_month %} - {% if day.weekday == 0 %} - - {% endif %} - - - {% endfor %} - - {% for day in days_of_month %} - {% if day.weekday == 0 %} - - {% endif %} - - - - {% if day.weekday == 6 %} - - {% endif %} - {% endfor %} - - {% for day in days_of_next_month %} - - - {% if day.weekday == 6 %} - - {% endif %} - {% endfor %} - - -
    - - Mon - - - Tue - - - Wed - - - Thu - - - Fri - - - Sat - - - Sun -
    -
    -
    - {{ day.day }} -
    -
    -
    -
    -
    -
    - {% if day == today %} - {{ day.day }} - {% else %} - {{ day.day }} - {% endif %} -
    - - {% for key, names in rental_dict.items %} - {% if key == day %} - {% for name in names %} -
    -
    - - - - - - {{ name|truncatechars:3 }} -
    -
    - {% empty %} -
    - {% endfor %} - {% endif %} - {% endfor %} -
    -
    -
    -
    - {{ day.day }} -
    -
    -
    -
    -
    -
    -
    -
    -{% endblock content %} +{% extends 'base.html' %} + +{% load static %} + +{% block title %}Verleih{% endblock %} + +{% block content %} +
    +

    Verleih

    + +
    +

    + Willkommen bei unserem Verleih! +

    + + {% if user.is_authenticated %} + + Verleih-Kalender abonnieren + + {% endif %} + + + Verleih anfragen + +
    + +
    +
    +
    + + + +
    + + +
    +
    + + +
    + + + + +
    +
    + +
    +
    +
    + + {% if view_type == 'month' %} + {{ view_period|date:'F Y' }} + {% else %} + {% if days_of_view_period.0.month != days_of_view_period.6.month %} + KW{{ week_num|stringformat:"02d" }} - {{ days_of_view_period.0|date:'F' }} / {{ days_of_view_period.6|date:'F Y' }} + {% else %} + KW{{ week_num|stringformat:"02d" }} - {{ days_of_view_period.0|date:'F Y' }} + {% endif %} + {% endif %} + + +
    + + +
    +
    + + + + + + + + + + + + + + + + {% for day in days_of_prev_period %} + {% if day.weekday == 0 %} + + {% endif %} + + + {% endfor %} + + {% for day in days_of_view_period %} + {% if day.weekday == 0 %} + + {% endif %} + + + + {% if day.weekday == 6 %} + + {% endif %} + {% endfor %} + + {% for day in days_of_next_period %} + + + {% if day.weekday == 6 %} + + {% endif %} + {% endfor %} + + +
    + + Mo + + + Di + + + Mi + + + Do + + + Fr + + + Sa + + + So +
    +
    +
    + {{ day.day }} +
    +
    +
    +
    +
    +
    + {% if day == today %} + {{ day.day }} + {% else %} + {{ day.day }} + {% endif %} +
    + + {% for key, names in rental_dict.items %} + {% if key == day %} + {% if view_type == 'month' %} + {% for name in names|slice:":3" %} +
    +
    + + + + + + {{ name|truncatechars:3 }} +
    +
    + {% endfor %} + {% if names|length > 3 %} + + {% endif %} + {% else %} + {% for name in names %} +
    +
    + + + + + + {{ name|truncatechars:3 }} +
    +
    + {% endfor %} + {% endif %} + {% endif %} + {% endfor %} +
    +
    +
    +
    + {{ day.day }} +
    +
    +
    +
    +
    +
    +
    +
    +{% endblock content %} diff --git a/fet2020/templates/rental/create.html b/fet2020/templates/rental/create.html index 1c95a97a..c4425da0 100644 --- a/fet2020/templates/rental/create.html +++ b/fet2020/templates/rental/create.html @@ -1,121 +1,123 @@ -{% extends 'base.html' %} - -{% load static %} - -{% block title %}Verleih Anfrage{% endblock %} - -{% block content %} - -
    -

    Verleih Anfrage

    - -
    - {% csrf_token %} - {% include "baseform/non_field_errors.html" %} - -
    -

    Persönliche Daten

    - Bitte gib deine persönlichen Daten ein. - -
    -
    - {% include "baseform/text.html" with field=form.firstname %} -
    -
    - {% include "baseform/text.html" with field=form.surname %} -
    -
    - {% include "baseform/text.html" with field=form.organization %} -
    -
    - {% include "baseform/text.html" with field=form.matriculation_number %} -
    -
    - {% include "baseform/email.html" with field=form.email %} -
    -
    - {% include "baseform/text.html" with field=form.phone %} -
    -
    -
    - -
    -

    Verleihgegenstände

    - Wähl deine gewünschten Verleihgegenstände aus. - -
    - {% if form.rentalitems.errors %} -
    -
    {{ form.rentalitems.errors }}
    -
    - {% endif %} - -
    - {% for elem in form.rentalitems %} -
    - - - - {% for item in rentalitems_addinfo %} - {% if item.name == elem.choice_label and item.induction %} -

    Einschulung erforderlich!

    - {% endif %} - {% endfor %} -
    - {% endfor %} -
    -
    -
    -
    - {% include "baseform/date.html" with field=form.date_start %} -
    -
    - {% include "baseform/date.html" with field=form.date_end %} -
    -
    - {% include "baseform/textarea.html" with field=form.reason %} -
    -
    -
    - -
    -

    Zusätzliche Informationen

    - Hier kannst du zusätzliche Informationen, Anliegen und Sonstiges angeben. - -
    -
    - {% include "baseform/textarea.html" with field=form.comment %} -
    -
    -
    - -
    -
    -
    - {% include "baseform/checkbox.html" with field=form.conformation %} -
    - - - Verleihregeln - -
    -
    - -
    - -
    -
    -
    -{% endblock content %} +{% extends 'base.html' %} + +{% load static %} + +{% block title %}Verleih Anfrage{% endblock %} + +{% block content %} + +
    +

    Verleih Anfrage

    + +
    + {% csrf_token %} + {% include "baseform/non_field_errors.html" %} + +
    +

    Persönliche Daten

    + Bitte gib deine persönlichen Daten ein. + +
    +
    + {% include "baseform/text.html" with field=form.firstname %} +
    +
    + {% include "baseform/text.html" with field=form.surname %} +
    +
    + {% include "baseform/text.html" with field=form.organization %} +
    +
    + {% include "baseform/text.html" with field=form.matriculation_number %} +
    +
    + {% include "baseform/email.html" with field=form.email %} +
    +
    + {% include "baseform/text.html" with field=form.phone %} +
    +
    +
    + +
    +

    Verleihgegenstände

    + Wähl deine gewünschten Verleihgegenstände aus. + +
    + {% if form.rentalitems.errors %} +
    +
    {{ form.rentalitems.errors }}
    +
    + {% endif %} + +
    + {% for elem in form.rentalitems %} +
    + + + {% for item in rentalitems_addinfo %} + {% if item.name == elem.choice_label and item.induction %} +

    Einschulung erforderlich!

    + {% endif %} + {% endfor %} +
    + {% endfor %} +
    +
    +
    +
    + {% include "baseform/date.html" with field=form.date_start %} +
    +
    + {% include "baseform/date.html" with field=form.date_end %} +
    +
    + {% include "baseform/textarea.html" with field=form.reason %} +
    +
    +
    + +
    +

    Zusätzliche Informationen

    + Hier kannst du zusätzliche Informationen, Anliegen und Sonstiges angeben. + +
    +
    + {% include "baseform/textarea.html" with field=form.comment %} +
    +
    +
    + +
    +
    +
    + {% include "baseform/checkbox.html" with field=form.conformation %} +
    + + + Verleihregeln + +
    +
    + +
    + +
    +
    +
    +{% endblock content %} diff --git a/fet2020/templates/rental/create_done.html b/fet2020/templates/rental/create_done.html index bb909c04..48be356c 100644 --- a/fet2020/templates/rental/create_done.html +++ b/fet2020/templates/rental/create_done.html @@ -1,17 +1,22 @@ -{% extends 'base.html' %} - -{% block title %}Verleihanfrage erfolgreich eingereicht{% endblock %} - -{% block content %} - -
    -
    -

    - Deine Verleihanfrage mit der Nummer #{{ pk }} wurde erfolgreich eingereicht. Die Gegenstände können am {{ object.date_start }} während der Beratungszeit (Montag - Donnerstag: 09:00 - 14:00, Freitag: 09:00 - 12:00) abgeholt werden. Bitte bring den Gesamtpfand in Höhe von {{ total_deposit }} € in bar mit. -

    - -
    -
    -{% endblock content %} +{% extends 'base.html' %} + +{% block title %}Verleihanfrage erfolgreich eingereicht{% endblock %} + +{% block content %} + +
    +
    +

    + Deine Verleihanfrage ist eingegangen - danke dir! 🎉 + Wir kümmern uns jetzt darum und melden uns per E-Mail mit den nächsten Schritten. +

    +

    + Kleiner Hinweis: Bevor du die Sachen abholen kannst, wird deine Anfrage kurz geprüft und freigegeben. + Sobald alles genehmigt ist, bekommst du von uns eine Mail. +

    + +
    +
    +{% endblock content %} diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 605647bc..f19584b4 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -26,6 +26,12 @@ server { location /files { alias /usr/src/app/files/; + } + + location /assets/ { + alias /usr/src/app/assets/; + try_files $uri $uri/ =404; + add_header X-Content-Type-Options nosniff; } # location /files/uploads/finance {