Compare commits

..

21 Commits

Author SHA1 Message Date
f8050cc32d feat: studybug as new name 2025-06-30 19:40:13 +02:00
e1859f8f29 feat: studybug as new name 2025-06-30 18:48:26 +02:00
f92d34b0a0 fix: open ticket counter 2025-06-29 16:36:57 +02:00
65360a95e5 fix: changed text 2025-06-29 16:26:48 +02:00
71b834393d feat: new welcome message in home.html 2025-06-29 16:22:19 +02:00
05f4102b81 feat: new logo on login page 2025-06-29 15:36:47 +02:00
2fb8435600 feat: added some responsiveness 2025-06-22 14:34:21 +02:00
db9fab89e5 feat: AssignedTicketListView removed, not needed anymore 2025-06-13 13:30:26 +02:00
d282f39ac1 fix: wrong link 2025-06-11 22:45:51 +02:00
349ccabd74 fix: reformatting 2025-06-11 22:14:24 +02:00
272415d219 fix: black formatting 2025-06-11 21:57:28 +02:00
8af0bbdb37 feat: toggle faq accordion 2025-06-11 21:56:44 +02:00
61a7ca6403 fix: expanded permission logic for admins 2025-06-11 21:37:52 +02:00
35d8727348 feat: answer field now invisible if not editable 2025-06-11 21:21:13 +02:00
1388166e9d fix: removed double line 2025-06-11 20:16:10 +02:00
af8176ac48 feat: added requirements.txt 2025-06-11 20:13:24 +02:00
45b7f91f13 feat: updated README 2025-06-11 20:01:33 +02:00
7ad635dc12 fix: remove unicode character 2025-06-11 10:30:17 +02:00
190dab5427 fix: css adjustment index.html 2025-06-04 23:47:36 +02:00
3e17050c40 fead: added kurs column and filter 2025-06-04 23:08:34 +02:00
63a66f6d3e fix: stored js in static folder 2025-06-04 22:35:01 +02:00
19 changed files with 734 additions and 457 deletions

View File

@@ -2,10 +2,43 @@
Die Webanwendung unterstützt das Korrekturmanagement von Materialien aus dem Fernstudium Die Webanwendung unterstützt das Korrekturmanagement von Materialien aus dem Fernstudium
## Live-Demo
Eine Testumgebung der aktuellen Version wird unter folgender URL bereitgestellt:
### [studybug.de](https://studybug.de)
## Installation ## Installation
Use the package manager [pip](https://pip.pypa.io/en/stable/) to install django. ```bash
git clone https://git.paul.nrw/Paul/korrekturmanagementsystem.git
cd ticketsystem
```
Virtual environment erstellen und aktivieren
```bash
python -m venv venv
# Windows
venv\Scripts\activate
# macOS / Linux
source venv/bin/activate
```
Abhängigkeiten installieren
```bash
pip install -r requirements.txt
```
Datenbank einrichten
```bash ```bash
pip install django python manage.py makemigrations
python manage.py migrate
```
Entwicklungsserver starten
```bash
python manage.py runserver
```
Tailwindcss CLI installieren und CSS generieren
```bash
npm install tailwindcss @tailwindcss/cli
npx @tailwindcss/cli -i ticketsystem/static/css/style.css -o ticketsystem/static/css/tailwind.css
``` ```

View File

@@ -126,5 +126,5 @@ STATICFILES_DIRS = [BASE_DIR / "static"]
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
LOGIN_REDIRECT_URL = "/ticketsystem" LOGIN_REDIRECT_URL = "/"
LOGOUT_REDIRECT_URL = "/accounts/login/" LOGOUT_REDIRECT_URL = "/accounts/login/"

View File

@@ -19,7 +19,7 @@ from django.contrib import admin
from django.urls import include, path from django.urls import include, path
urlpatterns = [ urlpatterns = [
path("ticketsystem/", include("ticketsystem.urls")), path("", include("ticketsystem.urls")),
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("accounts/", include("django.contrib.auth.urls")), path("accounts/", include("django.contrib.auth.urls")),
] ]

32
requirements.txt Normal file
View File

@@ -0,0 +1,32 @@
asgiref==3.8.1
astroid==3.3.10
black==25.1.0
chardet==5.2.0
click==8.1.8
colorama==0.4.6
cssbeautifier==1.15.4
dill==0.4.0
Django==5.2
djlint==1.36.4
EditorConfig==0.17.0
gunicorn==23.0.0
isort==6.0.1
jsbeautifier==1.15.4
json5==0.12.0
mccabe==0.7.0
mypy_extensions==1.1.0
packaging==25.0
pathspec==0.12.1
pillow==11.2.1
platformdirs==4.3.7
pylint==3.3.7
pylint-django==2.6.1
pylint-plugin-utils==0.8.2
PyYAML==6.0.2
regex==2024.11.6
reportlab==4.4.1
six==1.17.0
sqlparse==0.5.3
tomlkit==0.13.3
tqdm==4.67.1
tzdata==2025.2

View File

@@ -1,6 +1,6 @@
from django.contrib import admin from django.contrib import admin
from .models import Ticket, Course, FAQ from .models import FAQ, Course, Ticket
admin.site.register(Ticket) admin.site.register(Ticket)
admin.site.register(Course) admin.site.register(Course)

View File

@@ -1,7 +1,8 @@
from django import forms from django import forms
from .models import Comment, Ticket
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from .models import Comment, Ticket
class CommentForm(forms.ModelForm): class CommentForm(forms.ModelForm):
class Meta: class Meta:
@@ -17,38 +18,55 @@ class CommentForm(forms.ModelForm):
class TicketForm(forms.ModelForm): class TicketForm(forms.ModelForm):
# Zentrale Definition der Status-Übergänge # Zentrale Definition der Status-Übergänge
STATUS_TRANSITIONS = { STATUS_TRANSITIONS = {
'tutor': { "tutor": {
'new': ['in_progress'], "new": ["in_progress"],
'in_progress': ['resolved', 'new'], "in_progress": ["resolved", "new"],
'resolved': ['closed'], "resolved": ["closed"],
'closed': [], "closed": [],
},
"creator": {
"new": [],
"in_progress": [],
"resolved": ["closed", "new"],
"closed": [],
},
"superuser": {
# Superuser können alle Übergänge machen
"new": ["in_progress", "resolved", "closed"],
"in_progress": ["new", "resolved", "closed"],
"resolved": ["new", "in_progress", "closed"],
"closed": ["new", "in_progress", "resolved"],
}, },
'creator': {
'new': [],
'in_progress': [],
'resolved': ['closed', 'new'],
'closed': [],
}
} }
# Zentrale Definition welche Felder wann required sind # Zentrale Definition welche Felder wann required sind
REQUIRED_FIELDS_BY_STATUS = { REQUIRED_FIELDS_BY_STATUS = {
'resolved': ['answer'], # Answer required when resolving "resolved": ["answer"], # Answer required when resolving
} }
class Meta: class Meta:
model = Ticket model = Ticket
fields = ["title", "description", "status", "mistake", "course", "answer", "material"] fields = [
"title",
"description",
"status",
"mistake",
"course",
"answer",
"material",
]
widgets = { widgets = {
'answer': forms.Textarea(attrs={ "answer": forms.Textarea(
'rows': 4, attrs={
'placeholder': 'Beschreibe die Lösung des Problems...' "rows": 4,
}) "placeholder": "Beschreibe die Lösung des Problems...",
}
)
} }
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.user = kwargs.pop('user', None) self.user = kwargs.pop("user", None)
self.ticket = kwargs.pop('ticket', None) self.ticket = kwargs.pop("ticket", None)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if self.ticket and self.user: if self.ticket and self.user:
@@ -61,35 +79,42 @@ class TicketForm(forms.ModelForm):
is_superuser = self.user.is_superuser is_superuser = self.user.is_superuser
# Status-Choices einschränken # Status-Choices einschränken
self._limit_status_choices(is_tutor, is_creator) self._limit_status_choices(is_tutor, is_creator, is_superuser)
# Feld-Berechtigungen setzen # Feld-Berechtigungen setzen
self._set_field_permissions(is_tutor, is_creator, is_superuser) self._set_field_permissions(is_tutor, is_creator, is_superuser)
def _set_field_permissions(self, is_tutor, is_creator, is_superuser): def _set_field_permissions(self, is_tutor, is_creator, is_superuser):
"""Setzt welche Felder bearbeitet werden dürfen""" """Setzt welche Felder bearbeitet werden dürfen"""
if self.ticket.status == 'resolved' and is_creator and not is_superuser: # Superuser können alles bearbeiten
if is_superuser:
return
if self.ticket.status == "resolved" and is_creator:
for field_name in self.fields: for field_name in self.fields:
if field_name == "answer": if field_name == "answer":
self.fields[field_name].disabled = True self.fields[field_name].disabled = True
elif is_tutor and not is_superuser: elif is_tutor:
# Tutor darf ändern: # Tutor darf ändern:
readonly_fields = ['title', 'description', 'material'] readonly_fields = ["title", "description", "material"]
for field_name in readonly_fields: for field_name in readonly_fields:
if field_name in self.fields: if field_name in self.fields:
self.fields[field_name].disabled = True self.fields[field_name].disabled = True
elif is_creator and not is_superuser and self.ticket.status != 'resolved': elif is_creator and self.ticket.status != "resolved":
for field_name in self.fields: for field_name in self.fields:
self.fields[field_name].disabled = True self.fields[field_name].disabled = True
def _limit_status_choices(self, is_tutor, is_creator): def _limit_status_choices(self, is_tutor, is_creator, is_superuser):
"""Beschränkt verfügbare Status-Optionen basierend auf der zentralen Logik""" """Beschränkt verfügbare Status-Optionen basierend auf der zentralen Logik"""
current_status = self.ticket.status current_status = self.ticket.status
if is_tutor: # Superuser bekommen alle Status-Optionen
role = 'tutor' if is_superuser:
role = "superuser"
elif is_tutor:
role = "tutor"
elif is_creator: elif is_creator:
role = 'creator' role = "creator"
else: else:
role = None role = None
@@ -100,10 +125,9 @@ class TicketForm(forms.ModelForm):
allowed_statuses = [current_status] allowed_statuses = [current_status]
# Status-Choices filtern # Status-Choices filtern
all_choices = list(self.fields['status'].choices) all_choices = list(self.fields["status"].choices)
self.fields['status'].choices = [ self.fields["status"].choices = [
choice for choice in all_choices choice for choice in all_choices if choice[0] in allowed_statuses
if choice[0] in allowed_statuses
] ]
def _get_allowed_transitions(self, from_status, role): def _get_allowed_transitions(self, from_status, role):
@@ -123,7 +147,7 @@ class TicketForm(forms.ModelForm):
def clean(self): def clean(self):
cleaned_data = super().clean() cleaned_data = super().clean()
status = cleaned_data.get('status') status = cleaned_data.get("status")
if not self.ticket or not status: if not self.ticket or not status:
return cleaned_data return cleaned_data
@@ -132,27 +156,35 @@ class TicketForm(forms.ModelForm):
old_status = self.ticket.status old_status = self.ticket.status
is_tutor = self.user == self.ticket.assigned_to is_tutor = self.user == self.ticket.assigned_to
is_creator = self.user == self.ticket.created_by is_creator = self.user == self.ticket.created_by
is_superuser = self.user.is_superuser
if is_tutor: # Superuser dürfen alle Übergänge
role = 'tutor' if is_superuser:
role = "superuser"
elif is_tutor:
role = "tutor"
elif is_creator: elif is_creator:
role = 'creator' role = "creator"
else: else:
role = None role = None
if role and not self._is_transition_allowed(old_status, status, role): if role and not self._is_transition_allowed(old_status, status, role):
raise ValidationError({ raise ValidationError(
'status': f'Übergang von "{self.ticket.get_status_display()}" zu "{dict(self.fields["status"].choices)[status]}" ist nicht erlaubt.' {
}) "status": f'Übergang von "{self.ticket.get_status_display()}" zu "{dict(self.fields["status"].choices)[status]}" ist nicht erlaubt.'
}
)
# Prüfe required fields für neuen Status # Prüfe required fields für neuen Status
required_fields = self._get_required_fields_for_status(status) required_fields = self._get_required_fields_for_status(status)
for field_name in required_fields: for field_name in required_fields:
if not cleaned_data.get(field_name): if not cleaned_data.get(field_name):
field_label = self.fields[field_name].label field_label = self.fields[field_name].label
raise ValidationError({ raise ValidationError(
{
field_name: f'{field_label} ist erforderlich, wenn der Status auf "{dict(self.fields["status"].choices)[status]}" gesetzt wird.' field_name: f'{field_label} ist erforderlich, wenn der Status auf "{dict(self.fields["status"].choices)[status]}" gesetzt wird.'
}) }
)
return cleaned_data return cleaned_data
@@ -166,6 +198,7 @@ class TicketForm(forms.ModelForm):
# Setze answered_at wenn eine Antwort gegeben wird # Setze answered_at wenn eine Antwort gegeben wird
if ticket.answer and not ticket.answered_at: if ticket.answer and not ticket.answered_at:
from django.utils import timezone from django.utils import timezone
ticket.answered_at = timezone.now() ticket.answered_at = timezone.now()
if commit: if commit:

View File

@@ -1,11 +1,14 @@
from django.db import models
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db import models
class Course(models.Model): class Course(models.Model):
"""Kurs-Model für Backend-Verwaltung""" """Kurs-Model für Backend-Verwaltung"""
name = models.CharField(max_length=200, verbose_name="Kurs-Name") name = models.CharField(max_length=200, verbose_name="Kurs-Name")
code = models.CharField(max_length=50, unique=True, verbose_name="Kurs-Code") # z.B. "PROG-101" code = models.CharField(
max_length=50, unique=True, verbose_name="Kurs-Code"
) # z.B. "PROG-101"
description = models.TextField(blank=True, verbose_name="Beschreibung") description = models.TextField(blank=True, verbose_name="Beschreibung")
tutor = models.ForeignKey( tutor = models.ForeignKey(
@@ -14,14 +17,14 @@ class Course(models.Model):
null=True, null=True,
blank=True, blank=True,
verbose_name="Tutor", verbose_name="Tutor",
related_name="courses_as_tutor" related_name="courses_as_tutor",
) )
is_active = models.BooleanField(default=True, verbose_name="Aktiv") is_active = models.BooleanField(default=True, verbose_name="Aktiv")
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
class Meta: class Meta:
ordering = ['name'] ordering = ["name"]
verbose_name = "Kurs" verbose_name = "Kurs"
verbose_name_plural = "Kurse" verbose_name_plural = "Kurse"
@@ -50,6 +53,7 @@ class Ticket(models.Model):
] ]
MATERIAL_CHOICES = [ MATERIAL_CHOICES = [
("script", "Skript"),
("learning_sprint", "Learning Sprint"), ("learning_sprint", "Learning Sprint"),
("ilse", "Intensive Live Session"), ("ilse", "Intensive Live Session"),
("video", "Video"), ("video", "Video"),
@@ -63,37 +67,40 @@ class Ticket(models.Model):
description = models.TextField() description = models.TextField()
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="new") status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="new")
mistake = models.CharField(max_length=20, choices=MISTAKE_CHOICES, default="medium") mistake = models.CharField(max_length=20, choices=MISTAKE_CHOICES, default="medium")
material = models.CharField(max_length=20, choices=MATERIAL_CHOICES, default="script") material = models.CharField(
max_length=20, choices=MATERIAL_CHOICES, default="script"
)
answer = models.TextField( answer = models.TextField(
blank=True, blank=True,
null=True, null=True,
verbose_name="Antwort/Lösung", verbose_name="Antwort/Lösung",
help_text="Beschreibung der Lösung (erforderlich bei Status 'Gelöst')" help_text="Beschreibung der Lösung (erforderlich bei Status 'Gelöst')",
) )
answered_at = models.DateTimeField( answered_at = models.DateTimeField(
blank=True, blank=True, null=True, verbose_name="Beantwortet am"
null=True,
verbose_name="Beantwortet am"
) )
course = models.ForeignKey( course = models.ForeignKey(
Course, Course, on_delete=models.CASCADE, verbose_name="Kurs", related_name="tickets"
on_delete=models.CASCADE,
verbose_name="Kurs",
related_name="tickets"
) )
created_by = models.ForeignKey( created_by = models.ForeignKey(
User, related_name="tickets_created", on_delete=models.CASCADE User, related_name="tickets_created", on_delete=models.CASCADE
) )
assigned_to = models.ForeignKey( assigned_to = models.ForeignKey(
User, related_name="tickets_assigned", null=True, blank=True, on_delete=models.CASCADE, User,
related_name="tickets_assigned",
null=True,
blank=True,
on_delete=models.CASCADE,
) )
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
def __str__(self): def __str__(self):
return f"[{self.get_mistake_display()}] {self.title} ({self.get_status_display()})" return (
f"[{self.get_mistake_display()}] {self.title} ({self.get_status_display()})"
)
class Comment(models.Model): class Comment(models.Model):
@@ -115,7 +122,7 @@ class TicketHistory(models.Model):
changed_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True) changed_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
changed_at = models.DateTimeField(auto_now_add=True) changed_at = models.DateTimeField(auto_now_add=True)
field = models.CharField(max_length=100) # z.B. "status" oder "description" field = models.CharField(max_length=100) # z.B. "status" oder "description"
old_value = models.TextField(null=True, blank=True) old_value = models.TextField(null=True, blank=True)
new_value = models.TextField(null=True, blank=True) new_value = models.TextField(null=True, blank=True)
@@ -125,13 +132,14 @@ class TicketHistory(models.Model):
class FAQ(models.Model): class FAQ(models.Model):
"""Einfaches FAQ Model""" """Einfaches FAQ Model"""
question = models.CharField(max_length=300, verbose_name="Frage") question = models.CharField(max_length=300, verbose_name="Frage")
answer = models.TextField(verbose_name="Antwort") answer = models.TextField(verbose_name="Antwort")
order = models.IntegerField(default=0, verbose_name="Reihenfolge") order = models.IntegerField(default=0, verbose_name="Reihenfolge")
is_active = models.BooleanField(default=True, verbose_name="Aktiv") is_active = models.BooleanField(default=True, verbose_name="Aktiv")
class Meta: class Meta:
ordering = ['order', 'question'] ordering = ["order", "question"]
verbose_name = "FAQ" verbose_name = "FAQ"
verbose_name_plural = "FAQs" verbose_name_plural = "FAQs"

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -0,0 +1,33 @@
function toggleFAQ(index) {
const content = document.getElementById(`content-${index}`);
const icon = document.getElementById(`icon-${index}`);
if (content.classList.contains('hidden')) {
content.classList.remove('hidden');
icon.classList.add('rotate-180');
} else {
content.classList.add('hidden');
icon.classList.remove('rotate-180');
}
}
function toggleAllFAQs() {
const button = document.getElementById('toggle-all-btn');
const allContents = document.querySelectorAll('[id^="content-"]');
const allIcons = document.querySelectorAll('[id^="icon-"]');
// Prüfen ob alle eingeklappt sind
const allCollapsed = Array.from(allContents).every(content => content.classList.contains('hidden'));
if (allCollapsed) {
// Alle ausklappen
allContents.forEach(content => content.classList.remove('hidden'));
allIcons.forEach(icon => icon.classList.add('rotate-180'));
button.innerHTML = '📁 Alle einklappen';
} else {
// Alle einklappen
allContents.forEach(content => content.classList.add('hidden'));
allIcons.forEach(icon => icon.classList.remove('rotate-180'));
button.innerHTML = '📂 Alle ausklappen';
}
}

View File

@@ -0,0 +1,83 @@
function initializeTicketDetail(config) {
// Course-Tutor Mapping
if (config.canEdit) {
const courseSelect = document.getElementById('id_course');
const tutorText = document.getElementById('tutor_text');
const tutorDisplay = document.getElementById('tutor_display');
if (courseSelect && config.courseTutorMap) {
courseSelect.addEventListener('change', function() {
const selectedCourseId = this.value;
if (selectedCourseId && config.courseTutorMap[selectedCourseId]) {
tutorText.textContent = config.courseTutorMap[selectedCourseId];
tutorDisplay.classList.remove('bg-gray-100');
tutorDisplay.classList.add('bg-blue-50');
} else if (selectedCourseId) {
tutorText.textContent = 'Kein Tutor zugewiesen';
tutorDisplay.classList.remove('bg-blue-50');
tutorDisplay.classList.add('bg-gray-100');
}
});
}
}
// Answer Field Toggle (nur für Tutoren)
if (config.canEdit && !config.isCreator) {
const statusSelect = document.querySelector('select[name="status"]');
const answerField = document.querySelector('textarea[name="answer"]');
const answerLabel = answerField?.previousElementSibling;
function toggleAnswerField() {
if (!answerField) return;
if (statusSelect.value === 'resolved') {
// Antwort-Feld einblenden und aktivieren
answerField.closest('div').style.display = 'block';
answerField.disabled = false;
answerField.readOnly = false;
answerField.style.pointerEvents = 'auto'; // Erlaube Klicks
answerField.classList.remove('bg-gray-100', 'cursor-not-allowed');
answerField.classList.add('bg-white');
answerField.required = true;
if (answerLabel && !answerLabel.querySelector('.text-red-500')) {
answerLabel.innerHTML = answerLabel.textContent + ' <span class="text-red-500">*</span>';
}
} else if (statusSelect.value === 'closed') {
// Antwort-Feld einblenden aber ausgegraut (readonly statt disabled)
answerField.closest('div').style.display = 'block';
answerField.disabled = false;
answerField.readOnly = true;
answerField.style.pointerEvents = 'none'; // Verhindert Klicks
answerField.classList.add('bg-gray-100', 'cursor-not-allowed');
answerField.classList.remove('bg-white');
answerField.required = false;
if (answerLabel) {
answerLabel.innerHTML = answerLabel.textContent.replace(' *', '');
}
} else {
// Antwort-Feld ausblenden
answerField.closest('div').style.display = 'none';
answerField.required = false;
if (answerLabel) {
answerLabel.innerHTML = answerLabel.textContent.replace(' *', '');
}
}
}
if (statusSelect && answerField) {
toggleAnswerField();
statusSelect.addEventListener('change', toggleAnswerField);
}
}
}
// Initialisierung wenn DOM geladen
document.addEventListener('DOMContentLoaded', function() {
// Config wird vom Template bereitgestellt
if (window.ticketDetailConfig) {
initializeTicketDetail(window.ticketDetailConfig);
}
});

View File

@@ -3,13 +3,18 @@
<!DOCTYPE html> <!DOCTYPE html>
<head> <head>
<link href="{% static 'css/tailwind.css' %}" rel="stylesheet"> <link href="{% static 'css/tailwind.css' %}" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head> </head>
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4"> <div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4">
<div class="max-w-md w-full space-y-8"> <div class="max-w-md w-full space-y-8">
<div class="text-center"> <div class="text-center">
<div class="text-6xl mb-4">🔐</div> <div class="mb-4 flex justify-center">
<h2 class="text-3xl font-bold text-gray-900 mb-2">Anmelden</h2> <img src="{% static 'IU-logo.png' %}"
<p class="text-gray-600 mb-8">Melde dich in deinem Ticketsystem an</p> alt="Logo"
class="w-[60%] h-auto" />
</div>
<h2 class="text-3xl font-bold text-gray-900 mb-2">Studybug</h2>
<p class="text-gray-600 mb-8">Melde dich im Korrekturmanagementsystem der IU an</p>
</div> </div>
<div class="bg-white rounded-lg shadow-md p-8"> <div class="bg-white rounded-lg shadow-md p-8">
<form method="post" class="space-y-6"> <form method="post" class="space-y-6">
@@ -44,7 +49,7 @@
<div> <div>
<button type="submit" <button type="submit"
class="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-4 rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"> class="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-4 rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
🚀 Anmelden 🪲 Anmelden
</button> </button>
</div> </div>
</form> </form>

View File

@@ -1,69 +0,0 @@
{% extends "ticketsystem/base.html" %}
{% block content %}
<style>
.ticket-list-container {
max-width: 700px;
margin: 2rem auto;
padding: 2rem;
border: 1px solid #ddd;
border-radius: 8px;
background-color: #fafafa;
font-family: sans-serif;
}
.ticket-list-container h1 {
margin-bottom: 1.5rem;
font-size: 1.8rem;
color: #333;
}
.ticket-item {
padding: 1rem;
border-bottom: 1px solid #ccc;
}
.ticket-item:last-child {
border-bottom: none;
}
.ticket-item a {
text-decoration: none;
color: #007bff;
font-weight: bold;
}
.ticket-item a:hover {
text-decoration: underline;
}
.ticket-meta {
font-size: 0.9rem;
color: #666;
margin-top: 0.3rem;
}
</style>
<div class="ticket-list-container">
<h1>🧾 Meine zugewiesenen Tickets</h1>
<p style="color: #777; font-size: 0.9rem; margin-top: -0.5rem; margin-bottom: 1.5rem;">
Hinweis: Bereits geschlossene Tickets werden hier nicht aufgelistet.
</p>
{% for ticket in tickets %}
<div class="ticket-item">
<a href="{% url 'detail' ticket.pk %}">
#{{ ticket.id }} {{ ticket.title }}
</a>
<div class="ticket-meta">
Status: {{ ticket.get_status_display }} |
Priorität: {{ ticket.get_priority_display }} |
Angelegt am {{ ticket.created_at|date:"d.m.Y H:i" }}
</div>
</div>
{% empty %}
<p>Es sind derzeit keine Tickets vorhanden.</p>
{% endfor %}
</div>
{% endblock %}

View File

@@ -3,35 +3,69 @@
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title> <title>
{% block title %}TicketSystem{% endblock %} {% block title %}Studybug🪲{% endblock %}
</title> </title>
<link href="{% static 'css/tailwind.css' %}" rel="stylesheet"> <link href="{% static 'css/tailwind.css' %}" rel="stylesheet">
</head> </head>
<body class="bg-gray-100"> <body class="bg-gray-100">
<nav class="bg-gray-700 text-white py-4"> <nav class="bg-gray-700 text-white py-4">
<div class="max-w-4xl mx-auto flex justify-between items-center px-4"> <div class="max-w-6xl mx-auto px-4">
<div class="flex space-x-4"> <!-- Mobile Header -->
<div class="flex justify-between items-center md:hidden">
<h1 class="text-lg font-semibold">Studybug🪲</h1>
<button id="mobile-menu-btn" class="text-2xl"></button>
</div>
<!-- Desktop Menu -->
<div class="hidden md:flex md:justify-between md:items-center">
<div class="flex space-x-6">
<a href="{% url 'home' %}" class="text-white hover:text-gray-300">🏠 Start</a> <a href="{% url 'home' %}" class="text-white hover:text-gray-300">🏠 Start</a>
<a href="{% url 'ticket-list' %}" class="text-white hover:text-gray-300">📋 Tickets</a> <a href="{% url 'ticket-list' %}" class="text-white hover:text-gray-300">📋 Tickets</a>
<a href="{% url 'assigned-tickets' %}"
class="text-white hover:text-gray-300">🧾 Meine Tickets</a>
<a href="{% url 'faq-list' %}" class="text-white hover:text-gray-300">❓ FAQ</a> <a href="{% url 'faq-list' %}" class="text-white hover:text-gray-300">❓ FAQ</a>
</div> </div>
<div class="flex items-center space-x-4">
{% if user.is_authenticated %} {% if user.is_authenticated %}
<span class="text-white">👤 {{ user.username }}</span> <div class="flex items-center space-x-4">
<span>👤 {{ user.username }}</span>
<form method="post" action="{% url 'logout' %}" class="inline"> <form method="post" action="{% url 'logout' %}" class="inline">
{% csrf_token %} {% csrf_token %}
<button type="submit" class="text-white hover:text-gray-300">🚪 Logout</button> <button type="submit" class="text-white hover:text-gray-300">🚪 Logout</button>
</form> </form>
</div>
{% endif %}
</div>
<!-- Mobile Menu -->
<div id="mobile-menu" class="hidden mt-4">
<a href="{% url 'home' %}" class="block text-white py-2">🏠 Start</a>
<a href="{% url 'ticket-list' %}" class="block text-white py-2">📋 Tickets</a>
<a href="{% url 'faq-list' %}" class="block text-white py-2">❓ FAQ</a>
{% if user.is_authenticated %}
<div class="border-t border-gray-600 pt-3 mt-3">
<span class="block text-white py-1">👤 {{ user.username }}</span>
<form method="post" action="{% url 'logout' %}">
{% csrf_token %}
<button type="submit" class="text-white py-2">🚪 Logout</button>
</form>
</div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
</nav> </nav>
<!-- Global Container --> <!-- Global Container -->
<div class="max-w-5xl mx-auto mt-8 px-4"> <div class="max-w-6xl mx-auto mt-8 px-4">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </div>
<!-- Mobile Menu Script -->
<script>
const menuBtn = document.getElementById('mobile-menu-btn');
const menu = document.getElementById('mobile-menu');
if (menuBtn && menu) {
menuBtn.onclick = () => menu.classList.toggle('hidden');
}
</script>
</body> </body>
</html> </html>

View File

@@ -1,4 +1,5 @@
{% extends "ticketsystem/base.html" %} {% extends "ticketsystem/base.html" %}
{% load static %}
{% block content %} {% block content %}
<!-- Messages --> <!-- Messages -->
{% if messages %} {% if messages %}
@@ -11,6 +12,12 @@
</div> </div>
{% endif %} {% endif %}
<div class="max-w-6xl mx-auto px-4 py-6"> <div class="max-w-6xl mx-auto px-4 py-6">
<div class="mb-6">
<a href="{% url 'ticket-list' %}"
class="inline-flex items-center text-blue-500 hover:text-blue-600 font-medium">
← Zurück zur Übersicht
</a>
</div>
<!-- Ticket Bearbeitung --> <!-- Ticket Bearbeitung -->
<div class="bg-white rounded-lg shadow p-6 mb-6"> <div class="bg-white rounded-lg shadow p-6 mb-6">
<h1 class="text-3xl font-bold mb-4">🎫 Ticket #{{ ticket.id }} {{ ticket.title }}</h1> <h1 class="text-3xl font-bold mb-4">🎫 Ticket #{{ ticket.id }} {{ ticket.title }}</h1>
@@ -75,7 +82,7 @@
{% if form.status.errors %}<div class="text-red-600 text-sm mt-1">{{ form.status.errors }}</div>{% endif %} {% if form.status.errors %}<div class="text-red-600 text-sm mt-1">{{ form.status.errors }}</div>{% endif %}
</div> </div>
<div> <div>
<label class="block text-sm font-medium mb-1">Priorität:</label> <label class="block text-sm font-medium mb-1">Fehlerart:</label>
<select name="mistake" <select name="mistake"
{% if not view.can_edit or form.mistake.field.disabled %}disabled{% endif %} {% if not view.can_edit or form.mistake.field.disabled %}disabled{% endif %}
class="w-full p-2 border border-gray-300 rounded shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 {% if not view.can_edit or form.mistake.field.disabled %}bg-gray-100{% endif %}"> class="w-full p-2 border border-gray-300 rounded shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 {% if not view.can_edit or form.mistake.field.disabled %}bg-gray-100{% endif %}">
@@ -119,12 +126,12 @@
<!-- Bearbeitbares Feld --> <!-- Bearbeitbares Feld -->
<textarea name="answer" <textarea name="answer"
rows="4" rows="4"
{% if not view.can_edit or ticket.status != 'resolved' %}disabled{% endif %} {% if not view.can_edit or form.answer.field.disabled %}disabled{% endif %}
placeholder="Beschreibe die Lösung des Problems..." placeholder="Beschreibe die Lösung des Problems..."
class="w-full p-2 border border-gray-300 rounded shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 placeholder:text-black {% if not view.can_edit or ticket.status != 'resolved' %}bg-gray-100 cursor-not-allowed{% endif %}">{{ ticket.answer|default:'' }}</textarea> class="w-full p-2 border border-gray-300 rounded shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 placeholder:text-gray-900 {% if not view.can_edit or ticket.status != 'resolved' or form.answer.field.disabled %}bg-gray-100 cursor-not-allowed{% endif %}">{{ ticket.answer|default:'' }}</textarea>
{% if form.answer.errors %}<div class="text-red-600 text-sm mt-1">{{ form.answer.errors }}</div>{% endif %} {% if form.answer.errors %}<div class="text-red-600 text-sm mt-1">{{ form.answer.errors }}</div>{% endif %}
<p class="text-xs text-gray-500 mt-1"> <p class="text-xs text-gray-500 mt-1">
{% if ticket.status != 'resolved' or ticket.status != 'closed' %} {% if ticket.status != 'resolved' %}
Eine Antwort ist erforderlich beim Setzen des Status auf "Gelöst" Eine Antwort ist erforderlich beim Setzen des Status auf "Gelöst"
{% endif %} {% endif %}
</p> </p>
@@ -197,70 +204,16 @@
</div> </div>
</div> </div>
<!-- JavaScript für dynamische Tutor-Anzeige --> <!-- JavaScript für dynamische Tutor-Anzeige -->
<script src="{% static 'js/ticket_detail.js' %}"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { window.ticketDetailConfig = {
const courseSelect = document.getElementById('id_course'); canEdit: {% if view.can_edit %}true{% else %}false{% endif %},
const tutorText = document.getElementById('tutor_text'); isCreator: {% if is_creator %}true{% else %}false{% endif %},
const tutorDisplay = document.getElementById('tutor_display'); courseTutorMap: {
// Course-Tutor Mapping
const courseTutorMap = {
{% for course in form.course.field.queryset %} {% for course in form.course.field.queryset %}
{% if course.tutor %}'{{ course.pk }}': '{{ course.tutor.username }}',{% endif %} {% if course.tutor %}'{{ course.pk }}': '{{ course.tutor.username }}',{% endif %}
{% endfor %} {% endfor %}
}
}; };
// Nur aktivieren wenn User bearbeiten kann
{% if view.can_edit %}
courseSelect.addEventListener('change', function() {
const selectedCourseId = this.value;
if (selectedCourseId && courseTutorMap[selectedCourseId]) {
tutorText.textContent = courseTutorMap[selectedCourseId];
tutorDisplay.classList.remove('bg-gray-100');
tutorDisplay.classList.add('bg-blue-50');
} else if (selectedCourseId) {
tutorText.textContent = 'Kein Tutor zugewiesen';
tutorDisplay.classList.remove('bg-blue-50');
tutorDisplay.classList.add('bg-gray-100');
}
});
{% endif %}
});
// Answer Feld nur bei Status "resolved" aktivieren
{% if view.can_edit %}
document.addEventListener('DOMContentLoaded', function() {
const statusSelect = document.querySelector('select[name="status"]');
const answerField = document.querySelector('textarea[name="answer"]');
const answerLabel = answerField.previousElementSibling;
function toggleAnswerField() {
if (statusSelect.value === 'resolved' || statusSelect.value === 'closed') {
answerField.disabled = false;
answerField.classList.remove('bg-gray-100', 'cursor-not-allowed');
answerField.classList.add('bg-white');
answerField.required = true;
// Pflichtfeld-Stern anzeigen
if (!answerLabel.querySelector('.text-red-500')) {
answerLabel.innerHTML = answerLabel.textContent + ' <span class="text-red-500">*</span>';
}
} else {
answerField.disabled = true;
answerField.classList.add('bg-gray-100', 'cursor-not-allowed');
answerField.classList.remove('bg-white');
answerField.required = false;
// Pflichtfeld-Stern entfernen
answerLabel.innerHTML = answerLabel.textContent.replace(' *', '');
}
}
// Initial prüfen
toggleAnswerField();
// Bei Status-Änderung prüfen
statusSelect.addEventListener('change', toggleAnswerField);
});
{% endif %}
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -1,24 +1,52 @@
{% extends "ticketsystem/base.html" %} {% extends "ticketsystem/base.html" %}
{% load static %}
{% block content %} {% block content %}
<div class="max-w-6xl mx-auto px-4 py-6">
<!-- Header --> <!-- Header -->
<div class="bg-white rounded-lg shadow p-6 mb-6"> <div class="bg-white rounded-lg shadow p-6 mb-6">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<h1 class="text-3xl font-bold">❓ Häufig gestellte Fragen</h1> <h1 class="text-3xl font-bold">❓ Häufig gestellte Fragen</h1>
<div class="flex gap-2">
<button id="toggle-all-btn"
onclick="toggleAllFAQs()"
class="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded">
📂 Alle ausklappen
</button>
<a href="{% url 'faq-pdf-download' %}" <a href="{% url 'faq-pdf-download' %}"
class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded">📄 PDF Download</a> class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded">📄 PDF Download</a>
</div> </div>
</div> </div>
</div>
<!-- FAQ Liste --> <!-- FAQ Liste -->
{% for faq in faqs %} {% for faq in faqs %}
<div class="bg-white rounded-lg shadow p-6 mb-4"> <div class="bg-white rounded-lg shadow mb-4 overflow-hidden">
<h3 class="font-bold text-lg mb-2">{{ forloop.counter }}. {{ faq.question }}</h3> <!-- FAQ Header - klickbar -->
<div class="text-gray-700">{{ faq.answer|linebreaks }}</div> <button class="w-full p-6 text-left hover:bg-gray-50 focus:outline-none focus:bg-gray-50 transition-colors duration-200"
onclick="toggleFAQ({{ forloop.counter0 }})">
<div class="flex justify-between items-center">
<h3 class="font-bold text-lg pr-4">{{ forloop.counter }}. {{ faq.question }}</h3>
<svg id="icon-{{ forloop.counter0 }}"
class="w-5 h-5 text-gray-500 transform transition-transform duration-200"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</div>
</button>
<!-- FAQ Content - versteckt standardmäßig -->
<div id="content-{{ forloop.counter0 }}"
class="hidden px-6 pb-6">
<div class="text-gray-700 border-t pt-4">{{ faq.answer|linebreaks }}</div>
</div>
</div> </div>
{% empty %} {% empty %}
<div class="bg-white rounded-lg shadow p-8 text-center"> <div class="bg-white rounded-lg shadow p-8 text-center">
<p class="text-gray-500">Noch keine FAQs vorhanden.</p> <p class="text-gray-500">Noch keine FAQs vorhanden.</p>
</div> </div>
{% endfor %} {% endfor %}
</div>
<!-- FAQ JavaScript -->
<script src="{% static 'js/faq.js' %}"></script>
{% endblock %} {% endblock %}

View File

@@ -3,10 +3,10 @@
<div class="max-w-6xl mx-auto px-4 py-12"> <div class="max-w-6xl mx-auto px-4 py-12">
<!-- Header --> <!-- Header -->
<div class="text-center mb-12"> <div class="text-center mb-12">
<div class="text-6xl mb-4">🎫</div> <div class="text-6xl mb-4">🪲</div>
<h1 class="text-4xl font-bold text-gray-900 mb-4">Willkommen im Ticketsystem</h1> <h1 class="text-4xl font-bold text-gray-900 mb-4">Willkommen bei Studybug!</h1>
<p class="text-lg text-gray-600 mb-2">Verwalte deine Aufgaben und Tickets effizient</p> <p class="text-lg text-gray-600 mb-2">- Das Korrekturmanagementsystem der IU Internationale Hochschule - </p>
<p class="text-gray-500">Was möchten Sie tun?</p> <p class="text-gray-500">Erstelle ein Ticket, wenn du Probleme oder Fehler erkannt hast</p>
</div> </div>
<!-- Quick Actions --> <!-- Quick Actions -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12"> <div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12">
@@ -25,7 +25,7 @@
<p class="text-sm text-green-600">Ein neues Ticket erstellen</p> <p class="text-sm text-green-600">Ein neues Ticket erstellen</p>
</a> </a>
<!-- Offene Tickets --> <!-- Offene Tickets -->
<a href="{% url 'ticket-list' %}?status=open" <a href="{% url 'ticket-list' %}?status=new"
class="bg-blue-50 rounded-lg shadow-md p-6 text-center hover:shadow-lg transition-shadow duration-200 border border-blue-200"> class="bg-blue-50 rounded-lg shadow-md p-6 text-center hover:shadow-lg transition-shadow duration-200 border border-blue-200">
<div class="text-3xl mb-3">📂</div> <div class="text-3xl mb-3">📂</div>
<h3 class="text-lg font-bold text-blue-800 mb-2">Offene Tickets</h3> <h3 class="text-lg font-bold text-blue-800 mb-2">Offene Tickets</h3>
@@ -40,7 +40,7 @@
<div class="text-sm text-gray-600">Tickets insgesamt</div> <div class="text-sm text-gray-600">Tickets insgesamt</div>
</div> </div>
<div class="bg-white rounded-lg p-4 shadow-sm"> <div class="bg-white rounded-lg p-4 shadow-sm">
<div class="text-2xl font-bold text-red-600 mb-1">{{ open_tickets|default:"0" }}</div> <div class="text-2xl font-bold text-red-600 mb-1">{{ new_tickets|default:"0" }}</div>
<div class="text-sm text-gray-600">Offene Tickets</div> <div class="text-sm text-gray-600">Offene Tickets</div>
</div> </div>
<div class="bg-white rounded-lg p-4 shadow-sm"> <div class="bg-white rounded-lg p-4 shadow-sm">

View File

@@ -2,31 +2,30 @@
{% block content %} {% block content %}
<!-- Messages --> <!-- Messages -->
{% if messages %} {% if messages %}
<div class="max-w-6xl mx-auto px-4 pt-4"> <div class="mb-4">
{% for message in messages %} {% for message in messages %}
<div class="mb-4 p-3 rounded {% if message.tags == 'error' %}bg-red-100 text-red-700{% elif message.tags == 'success' %}bg-green-100 text-green-700{% else %}bg-yellow-100 text-yellow-700{% endif %}"> <div class="mb-2 p-3 rounded text-sm {% if message.tags == 'error' %}bg-red-100 text-red-700{% elif message.tags == 'success' %}bg-green-100 text-green-700{% else %}bg-yellow-100 text-yellow-700{% endif %}">
{{ message }} {{ message }}
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
<div class="max-w-6xl mx-auto px-4 py-6">
<!-- Header --> <!-- Header -->
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center mb-6"> <div class="flex flex-col sm:flex-row sm:justify-between sm:items-center mb-6">
<div> <div>
<h1 class="text-3xl font-bold text-gray-900 mb-2">🎫 Ticket-Übersicht</h1> <h1 class="text-2xl sm:text-3xl font-bold text-gray-900 mb-2">🎫 Ticket-Übersicht</h1>
<p class="text-gray-600">Verwalte und verfolge alle deine Tickets</p> <p class="text-gray-600">Verwalte und verfolge Tickets</p>
</div> </div>
<div class="mt-4 sm:mt-0"> <div class="mt-4 sm:mt-0">
<a href="{% url 'create' %}" <a href="{% url 'create' %}"
class="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded font-medium"> class="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded font-medium block text-center sm:inline-block">
Neues Ticket erstellen Neues Ticket erstellen
</a> </a>
</div> </div>
</div> </div>
<!-- Filter und Suche --> <!-- Filter und Suche -->
<div class="bg-white rounded-lg shadow p-4 mb-6"> <div class="bg-white rounded-lg shadow p-4 mb-6">
<div class="grid grid-cols-1 lg:grid-cols-4 gap-4"> <div class="grid grid-cols-1 lg:grid-cols-5 gap-4">
<!-- Status Filter --> <!-- Status Filter -->
<div> <div>
<form method="get"> <form method="get">
@@ -46,6 +45,7 @@
name="assigned_to" name="assigned_to"
value="{{ request.GET.assigned_to }}"> value="{{ request.GET.assigned_to }}">
{% endif %} {% endif %}
{% if request.GET.course %}<input type="hidden" name="course" value="{{ request.GET.course }}">{% endif %}
{% if search_query %}<input type="hidden" name="q" value="{{ search_query }}">{% endif %} {% if search_query %}<input type="hidden" name="q" value="{{ search_query }}">{% endif %}
</form> </form>
</div> </div>
@@ -54,18 +54,43 @@
<label class="block text-sm font-medium mb-1">Zuweisung:</label> <label class="block text-sm font-medium mb-1">Zuweisung:</label>
{% if request.GET.assigned_to == user.id|stringformat:'s' %} {% if request.GET.assigned_to == user.id|stringformat:'s' %}
<!-- Wenn aktiv: Button zum Deaktivieren --> <!-- Wenn aktiv: Button zum Deaktivieren -->
<a href="?{% if selected_status %}status={{ selected_status }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}" <a href="?{% if selected_status %}status={{ selected_status }}{% endif %}{% if selected_course %}&course={{ selected_course }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}"
class="block w-full h-10 px-3 border border-purple-300 rounded shadow-sm text-center bg-purple-200 hover:bg-purple-300 transition-colors flex items-center justify-center"> class="block w-full h-10 px-3 border border-purple-300 rounded shadow-sm text-center bg-purple-200 hover:bg-purple-300 transition-colors flex items-center justify-center">
👤 Meine Tickets ✓ 👤 Meine Tickets ✓
</a> </a>
{% else %} {% else %}
<!-- Wenn nicht aktiv: Button zum Aktivieren --> <!-- Wenn nicht aktiv: Button zum Aktivieren -->
<a href="?assigned_to={{ user.id }}{% if selected_status %}&status={{ selected_status }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}" <a href="?assigned_to={{ user.id }}{% if selected_status %}&status={{ selected_status }}{% endif %}{% if selected_course %}&course={{ selected_course }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}"
class="block w-full h-10 px-3 border border-purple-300 rounded shadow-sm text-center bg-purple-50 hover:bg-purple-100 transition-colors flex items-center justify-center"> class="block w-full h-10 px-3 border border-purple-300 rounded shadow-sm text-center bg-purple-50 hover:bg-purple-100 transition-colors flex items-center justify-center">
👤 Meine Tickets 👤 Meine Tickets
</a> </a>
{% endif %} {% endif %}
</div> </div>
<!-- Kurs Filter -->
<div>
<form method="get">
<label class="block text-sm font-medium mb-1">Kurs:</label>
<select name="course"
onchange="this.form.submit()"
class="w-full h-10 px-3 border border-gray-300 rounded shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="">Alle Kurse</option>
{% for course in courses %}
<option value="{{ course.id }}"
{% if selected_course == course.id|stringformat:'s' %}selected{% endif %}>
{{ course.code }} - {{ course.name }}
</option>
{% endfor %}
</select>
<!-- Andere Filter beibehalten -->
{% if selected_status %}<input type="hidden" name="status" value="{{ selected_status }}">{% endif %}
{% if request.GET.assigned_to %}
<input type="hidden"
name="assigned_to"
value="{{ request.GET.assigned_to }}">
{% endif %}
{% if search_query %}<input type="hidden" name="q" value="{{ search_query }}">{% endif %}
</form>
</div>
<!-- Suche --> <!-- Suche -->
<div class="lg:col-span-2"> <div class="lg:col-span-2">
<form method="get"> <form method="get">
@@ -77,6 +102,7 @@
placeholder="Titel oder Beschreibung durchsuchen..." placeholder="Titel oder Beschreibung durchsuchen..."
class="flex-1 h-10 px-3 border border-gray-300 rounded-l shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"> class="flex-1 h-10 px-3 border border-gray-300 rounded-l shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
{% if selected_status %}<input type="hidden" name="status" value="{{ selected_status }}">{% endif %} {% if selected_status %}<input type="hidden" name="status" value="{{ selected_status }}">{% endif %}
{% if selected_course %}<input type="hidden" name="course" value="{{ selected_course }}">{% endif %}
{% if request.GET.assigned_to %} {% if request.GET.assigned_to %}
<input type="hidden" <input type="hidden"
name="assigned_to" name="assigned_to"
@@ -117,6 +143,16 @@
class="ml-1 text-green-600 hover:text-green-800">×</a> class="ml-1 text-green-600 hover:text-green-800">×</a>
</span> </span>
{% endif %} {% endif %}
{% if selected_course %}
<span class="px-2 py-1 bg-green-100 text-green-800 text-sm rounded">
Kurs:
{% for course in courses %}
{% if course.id|stringformat:'s' == selected_course %}{{ course.code }}{% endif %}
{% endfor %}
<a href="?{% if selected_status %}status={{ selected_status }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}{% if request.GET.assigned_to %}&assigned_to={{ request.GET.assigned_to }}{% endif %}"
class="ml-1 text-green-600 hover:text-green-800">×</a>
</span>
{% endif %}
<a href="{% url 'ticket-list' %}" <a href="{% url 'ticket-list' %}"
class="text-sm text-gray-600 hover:text-gray-800 font-medium">Alle Filter entfernen</a> class="text-sm text-gray-600 hover:text-gray-800 font-medium">Alle Filter entfernen</a>
</div> </div>
@@ -125,12 +161,15 @@
</div> </div>
<!-- Ticket Tabelle --> <!-- Ticket Tabelle -->
{% if tickets %} {% if tickets %}
<div class="bg-white rounded-lg shadow overflow-hidden"> <!-- Desktop -->
<div class="hidden lg:block bg-white rounded-lg shadow overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full"> <table class="w-full">
<thead class="bg-blue-600 text-white"> <thead class="bg-blue-600 text-white">
<tr> <tr>
<th class="px-4 py-3 text-center text-sm font-bold">#</th> <th class="px-4 py-3 text-center text-sm font-bold">#</th>
<th class="px-4 py-3 text-left text-sm font-bold">Titel</th> <th class="px-4 py-3 text-left text-sm font-bold">Titel</th>
<th class="px-4 py-3 text-center text-sm font-bold">Kurs</th>
<th class="px-4 py-3 text-center text-sm font-bold">Status</th> <th class="px-4 py-3 text-center text-sm font-bold">Status</th>
<th class="px-4 py-3 text-center text-sm font-bold">Fehlerart</th> <th class="px-4 py-3 text-center text-sm font-bold">Fehlerart</th>
<th class="px-4 py-3 text-left text-sm font-bold">Zugewiesen an</th> <th class="px-4 py-3 text-left text-sm font-bold">Zugewiesen an</th>
@@ -143,15 +182,20 @@
<td class="px-4 py-3 text-center text-sm font-bold text-gray-500">#{{ ticket.id }}</td> <td class="px-4 py-3 text-center text-sm font-bold text-gray-500">#{{ ticket.id }}</td>
<td class="px-4 py-3"> <td class="px-4 py-3">
<a href="{% url 'detail' ticket.pk %}" <a href="{% url 'detail' ticket.pk %}"
class="font-bold text-blue-600 hover:text-blue-800 hover:underline"> class="font-bold text-blue-600 hover:text-blue-800 hover:underline block break-words">
{{ ticket.title }} {{ ticket.title|truncatechars:30 }}
</a> </a>
</td> </td>
<td class="px-4 py-3 text-center text-sm">
<span class="px-2 py-1 bg-white border border-gray-900 rounded-full text-xs font-bold text-gray-900">
{{ ticket.course.code }}
</span>
</td>
<td class="px-4 py-3 text-center"> <td class="px-4 py-3 text-center">
{% if ticket.status == 'new' %} {% if ticket.status == 'new' %}
<span class="px-2 py-1 rounded-full text-xs font-bold bg-blue-500 text-white">{{ ticket.get_status_display }}</span> <span class="px-2 py-1 rounded-full text-xs font-bold bg-blue-500 text-white">{{ ticket.get_status_display }}</span>
{% elif ticket.status == 'in_progress' %} {% elif ticket.status == 'in_progress' %}
<span class="px-2 py-1 rounded-full text-xs font-bold bg-yellow-400 text-gray-900"> <span class="px-2 py-1 rounded-full text-xs font-bold bg-yellow-400 text-gray-900 whitespace-nowrap">
{{ ticket.get_status_display }} {{ ticket.get_status_display }}
</span> </span>
{% elif ticket.status == 'resolved' %} {% elif ticket.status == 'resolved' %}
@@ -163,7 +207,9 @@
{% endif %} {% endif %}
</td> </td>
<td class="px-4 py-3 text-center"> <td class="px-4 py-3 text-center">
<span class="px-2 py-1 rounded-full text-xs font-bold bg-blue-500 text-white">{{ ticket.get_mistake_display }}</span> <span class="px-2 py-1 rounded-full text-xs font-bold whitespace-nowrap {% if ticket.mistake == 'typo' %}bg-blue-500 {% elif ticket.mistake == 'formatting_issue' %}bg-purple-500 {% elif ticket.mistake == 'missing_content' %}bg-red-500 {% elif ticket.mistake == 'outdated_content' %}bg-orange-500 {% elif ticket.mistake == 'audio_problem' %}bg-green-500 {% elif ticket.mistake == 'video_problem' %}bg-yellow-500 {% elif ticket.mistake == 'other' %}bg-gray-500 {% else %}bg-gray-400 {% endif %} text-white">
{{ ticket.get_mistake_display }}
</span>
</td> </td>
<td class="px-4 py-3 text-sm text-gray-600"> <td class="px-4 py-3 text-sm text-gray-600">
{% if ticket.assigned_to %} {% if ticket.assigned_to %}
@@ -181,17 +227,55 @@
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
<!-- Mobile -->
<div class="lg:hidden space-y-4">
{% for ticket in tickets %}
<div class="bg-white rounded-lg shadow p-4">
<div class="flex justify-between items-start mb-2">
<span class="text-sm font-bold text-gray-500">#{{ ticket.id }}</span>
<span class="text-xs text-gray-500">{{ ticket.created_at|date:"d.m.Y" }}</span>
</div>
<a href="{% url 'detail' ticket.pk %}"
class="font-bold text-blue-600 hover:text-blue-800 block mb-2">{{ ticket.title|truncatechars:50 }}</a>
<div class="space-y-2">
<div class="flex justify-between items-center">
<span class="text-sm text-gray-600">Status:</span>
{% if ticket.status == 'new' %}
<span class="px-2 py-1 rounded-full text-xs font-bold bg-blue-500 text-white">{{ ticket.get_status_display }}</span>
{% elif ticket.status == 'in_progress' %}
<span class="px-2 py-1 rounded-full text-xs font-bold bg-yellow-400 text-gray-900">{{ ticket.get_status_display }}</span>
{% elif ticket.status == 'resolved' %}
<span class="px-2 py-1 rounded-full text-xs font-bold bg-green-400 text-white">{{ ticket.get_status_display }}</span>
{% elif ticket.status == 'closed' %}
<span class="px-2 py-1 rounded-full text-xs font-bold bg-gray-600 text-white">{{ ticket.get_status_display }}</span>
{% endif %}
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-gray-600">Kurs:</span>
<span class="text-sm font-medium">{{ ticket.course.code }}</span>
</div>
{% if ticket.assigned_to %}
<div class="flex justify-between items-center">
<span class="text-sm text-gray-600">Zugewiesen:</span>
<span class="text-sm">{{ ticket.assigned_to.username }}</span>
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
<!-- Pagination --> <!-- Pagination -->
{% if is_paginated %} {% if is_paginated %}
<div class="mt-6 flex justify-between items-center"> <div class="mt-6 flex justify-between items-center">
<div class="text-sm text-gray-600">Seite {{ page_obj.number }} von {{ page_obj.paginator.num_pages }}</div> <div class="text-sm text-gray-600">Seite {{ page_obj.number }} von {{ page_obj.paginator.num_pages }}</div>
<div class="flex gap-2"> <div class="flex gap-2">
{% if page_obj.has_previous %} {% if page_obj.has_previous %}
<a href="?page={{ page_obj.previous_page_number }}{% if selected_status %}&status={{ selected_status }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}" <a href="?page={{ page_obj.previous_page_number }}{% if selected_status %}&status={{ selected_status }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}{% if selected_course %}&course={{ selected_course }}{% endif %}"
class="px-3 py-1 border border-gray-300 rounded hover:bg-gray-50">Zurück</a> class="px-3 py-1 border border-gray-300 rounded hover:bg-gray-50">Zurück</a>
{% endif %} {% endif %}
{% if page_obj.has_next %} {% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}{% if selected_status %}&status={{ selected_status }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}" <a href="?page={{ page_obj.next_page_number }}{% if selected_status %}&status={{ selected_status }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}{% if selected_course %}&course={{ selected_course }}{% endif %}"
class="px-3 py-1 border border-gray-300 rounded hover:bg-gray-50">Weiter</a> class="px-3 py-1 border border-gray-300 rounded hover:bg-gray-50">Weiter</a>
{% endif %} {% endif %}
</div> </div>
@@ -217,5 +301,4 @@
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
</div>
{% endblock %} {% endblock %}

View File

@@ -1,15 +1,8 @@
from django.urls import path from django.urls import path
from .views import ( from .views import (HomeView, TicketCreateView,
TicketListView, TicketDetailUpdateView, TicketListView, TicketUpdateView,
TicketCreateView, faq_list, faq_pdf_download)
TicketUpdateView,
HomeView,
AssignedTicketListView,
TicketDetailUpdateView,
faq_list,
faq_pdf_download
)
urlpatterns = [ urlpatterns = [
# /ticketsystem/ # /ticketsystem/
@@ -21,7 +14,6 @@ urlpatterns = [
# /ticketsystem/new/ # /ticketsystem/new/
path("new/", TicketCreateView.as_view(), name="create"), path("new/", TicketCreateView.as_view(), name="create"),
path("<int:pk>/modify/", TicketUpdateView.as_view(), name="modify"), path("<int:pk>/modify/", TicketUpdateView.as_view(), name="modify"),
path("meine-tickets/", AssignedTicketListView.as_view(), name="assigned-tickets"), path("faq/", faq_list, name="faq-list"),
path('faq/', faq_list, name='faq-list'), path("faq/download/", faq_pdf_download, name="faq-pdf-download"),
path('faq/download/', faq_pdf_download, name='faq-pdf-download'),
] ]

View File

@@ -1,22 +1,20 @@
from django.views.generic import ListView, TemplateView
from django.views.generic.edit import CreateView, UpdateView
from django.urls import reverse_lazy
from django.views.generic.detail import DetailView
from django.views.generic.edit import FormMixin
from .forms import CommentForm, TicketForm
from django.urls import reverse
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib import messages from django.contrib import messages
from django.shortcuts import redirect, render from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Q from django.db.models import Q
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import redirect, render
from django.urls import reverse, reverse_lazy
from django.utils import timezone
from django.views.generic import ListView, TemplateView
from django.views.generic.edit import CreateView, UpdateView
from reportlab.lib.enums import TA_CENTER, TA_LEFT
from reportlab.lib.pagesizes import A4 from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import cm from reportlab.lib.units import cm
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer
from reportlab.lib.enums import TA_LEFT, TA_CENTER
from .models import Ticket, TicketHistory, FAQ from .forms import CommentForm, TicketForm
from .models import FAQ, Course, Ticket, TicketHistory
class HomeView(TemplateView): class HomeView(TemplateView):
@@ -27,7 +25,7 @@ class HomeView(TemplateView):
context.update( context.update(
{ {
"total_tickets": Ticket.objects.count(), "total_tickets": Ticket.objects.count(),
"open_tickets": Ticket.objects.filter(status="open").count(), "new_tickets": Ticket.objects.filter(status="new").count(),
"closed_tickets": Ticket.objects.filter(status="closed").count(), "closed_tickets": Ticket.objects.filter(status="closed").count(),
"recent_tickets": Ticket.objects.order_by("-updated_at")[:5], "recent_tickets": Ticket.objects.order_by("-updated_at")[:5],
} }
@@ -47,23 +45,27 @@ class TicketListView(ListView):
status = self.request.GET.get("status") status = self.request.GET.get("status")
assigned_to = self.request.GET.get("assigned_to") assigned_to = self.request.GET.get("assigned_to")
query = self.request.GET.get("q") query = self.request.GET.get("q")
course = self.request.GET.get("course")
if status: if status:
queryset = queryset.filter(status=status) queryset = queryset.filter(status=status)
if assigned_to: if assigned_to:
queryset = queryset.filter(assigned_to_id=assigned_to) queryset = queryset.filter(assigned_to_id=assigned_to)
if course: # NEU
queryset = queryset.filter(course_id=course)
if query: if query:
queryset = queryset.filter( queryset = queryset.filter(
Q(title__icontains=query) | Q(description__icontains=query) Q(title__icontains=query) | Q(description__icontains=query)
) )
return queryset return queryset
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context["selected_status"] = self.request.GET.get("status", "") context["selected_status"] = self.request.GET.get("status", "")
context["selected_course"] = self.request.GET.get("course", "")
context["search_query"] = self.request.GET.get("q", "") context["search_query"] = self.request.GET.get("q", "")
context["status_choices"] = Ticket.STATUS_CHOICES context["status_choices"] = Ticket.STATUS_CHOICES
context["courses"] = Course.objects.filter(is_active=True)
return context return context
@@ -73,6 +75,12 @@ class TicketDetailUpdateView(UpdateView):
template_name = "ticketsystem/detail.html" template_name = "ticketsystem/detail.html"
comment_form_class = CommentForm comment_form_class = CommentForm
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.can_edit = False
self.is_creator = False
self.is_tutor = False
def get_success_url(self): def get_success_url(self):
return reverse("detail", kwargs={"pk": self.object.pk}) return reverse("detail", kwargs={"pk": self.object.pk})
@@ -86,12 +94,14 @@ class TicketDetailUpdateView(UpdateView):
is_superuser = user.is_superuser is_superuser = user.is_superuser
# Bearbeitungsrechte abhängig vom Status # Bearbeitungsrechte abhängig vom Status
if self.ticket.status == 'resolved' and is_creator: if is_superuser:
self.can_edit = True self.can_edit = True
elif self.ticket.status == 'closed' and not is_superuser: elif self.ticket.status == "resolved" and is_creator:
self.can_edit = True
elif self.ticket.status == "closed":
self.can_edit = False self.can_edit = False
else: else:
self.can_edit = is_assigned_tutor or is_superuser self.can_edit = is_assigned_tutor
# Zusätzliche Flags für Template # Zusätzliche Flags für Template
self.is_creator = is_creator self.is_creator = is_creator
@@ -102,16 +112,16 @@ class TicketDetailUpdateView(UpdateView):
def get_form_kwargs(self): def get_form_kwargs(self):
"""Übergibt zusätzliche kwargs ans Form""" """Übergibt zusätzliche kwargs ans Form"""
kwargs = super().get_form_kwargs() kwargs = super().get_form_kwargs()
kwargs['user'] = self.request.user kwargs["user"] = self.request.user
kwargs['ticket'] = self.object kwargs["ticket"] = self.object
return kwargs return kwargs
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
# Füge Berechtigungs-Infos zum Context hinzu # Füge Berechtigungs-Infos zum Context hinzu
context['is_creator'] = self.is_creator context["is_creator"] = self.is_creator
context['is_tutor'] = self.is_tutor context["is_tutor"] = self.is_tutor
context['can_edit'] = self.can_edit context["can_edit"] = self.can_edit
# Kommentarformular hinzufügen # Kommentarformular hinzufügen
if "comment_form" not in context: if "comment_form" not in context:
@@ -128,7 +138,15 @@ class TicketDetailUpdateView(UpdateView):
response = super().form_valid(form) # Speichert das Ticket response = super().form_valid(form) # Speichert das Ticket
# History tracking für geänderte Felder # History tracking für geänderte Felder
tracked_fields = ["title", "description", "status", "mistake", "course", "answer", "material"] tracked_fields = [
"title",
"description",
"status",
"mistake",
"course",
"answer",
"material",
]
for field in tracked_fields: for field in tracked_fields:
if field in form.changed_data: if field in form.changed_data:
old_value = getattr(original, field) old_value = getattr(original, field)
@@ -146,11 +164,15 @@ class TicketDetailUpdateView(UpdateView):
new_value = str(new_value) new_value = str(new_value)
elif field == "answer": elif field == "answer":
if old_value: if old_value:
old_value = old_value[:50] + "..." if len(old_value) > 50 else old_value old_value = (
old_value[:50] + "..." if len(old_value) > 50 else old_value
)
else: else:
old_value = "Keine Antwort" old_value = "Keine Antwort"
if new_value: if new_value:
new_value = new_value[:50] + "..." if len(new_value) > 50 else new_value new_value = (
new_value[:50] + "..." if len(new_value) > 50 else new_value
)
else: else:
new_value = "Keine Antwort" new_value = "Keine Antwort"
@@ -211,18 +233,6 @@ class TicketDetailUpdateView(UpdateView):
return super().post(request, *args, **kwargs) return super().post(request, *args, **kwargs)
class AssignedTicketListView(LoginRequiredMixin, ListView):
model = Ticket
template_name = "ticketsystem/assigned_tickets.html"
context_object_name = "tickets"
ordering = ["-created_at"]
def get_queryset(self):
return Ticket.objects.filter(assigned_to=self.request.user).exclude(
status="closed"
) # oder "geschlossen", je nach Wahl
class TicketCreateView(CreateView): class TicketCreateView(CreateView):
model = Ticket model = Ticket
form_class = TicketForm form_class = TicketForm
@@ -280,41 +290,54 @@ class TicketUpdateView(LoginRequiredMixin, UpdateView):
def faq_list(request): def faq_list(request):
"""Zeigt alle aktiven FAQs an""" """Zeigt alle aktiven FAQs an"""
faqs = FAQ.objects.filter(is_active=True) faqs = FAQ.objects.filter(is_active=True)
return render(request, 'ticketsystem/faq.html', {'faqs': faqs}) return render(request, "ticketsystem/faq.html", {"faqs": faqs})
def faq_pdf_download(request): def faq_pdf_download(request):
"""Generiert PDF mit allen FAQs""" """Generiert PDF mit allen FAQs"""
# Response Setup
response = HttpResponse(content_type='application/pdf') current_date = timezone.now()
response['Content-Disposition'] = 'attachment; filename="FAQ_Ticketsystem.pdf"' date_string = current_date.strftime("%Y-%m-%d")
date_display = current_date.strftime("%d.%m.%Y")
# Response Setup mit Datum im Dateinamen
response = HttpResponse(content_type="application/pdf")
response["Content-Disposition"] = (
f'attachment; filename="FAQ_Ticketsystem_{date_string}.pdf"'
)
# PDF erstellen # PDF erstellen
doc = SimpleDocTemplate(response, pagesize=A4, topMargin=2 * cm, bottomMargin=2 * cm) doc = SimpleDocTemplate(
response, pagesize=A4, topMargin=2 * cm, bottomMargin=2 * cm
)
# Styles # Styles
styles = getSampleStyleSheet() styles = getSampleStyleSheet()
title_style = ParagraphStyle( title_style = ParagraphStyle(
'CustomTitle', "CustomTitle",
parent=styles['Heading1'], parent=styles["Heading1"],
fontSize=24, fontSize=24,
textColor='#1a1a1a', textColor="#1a1a1a",
spaceAfter=20,
alignment=TA_CENTER,
)
subtitle_style = ParagraphStyle(
"Subtitle",
parent=styles["Normal"],
fontSize=12,
textColor="#666666",
spaceAfter=30, spaceAfter=30,
alignment=TA_CENTER alignment=TA_CENTER,
) )
question_style = ParagraphStyle( question_style = ParagraphStyle(
'Question', "Question",
parent=styles['Heading2'], parent=styles["Heading2"],
fontSize=14, fontSize=14,
textColor='#2563eb', textColor="#2563eb",
spaceAfter=10 spaceAfter=10,
) )
answer_style = ParagraphStyle( answer_style = ParagraphStyle(
'Answer', "Answer", parent=styles["Normal"], fontSize=11, spaceAfter=20, alignment=TA_LEFT
parent=styles['Normal'],
fontSize=11,
spaceAfter=20,
alignment=TA_LEFT
) )
# Content # Content
@@ -322,21 +345,27 @@ def faq_pdf_download(request):
# Titel # Titel
elements.append(Paragraph("Häufig gestellte Fragen (FAQ)", title_style)) elements.append(Paragraph("Häufig gestellte Fragen (FAQ)", title_style))
elements.append(Paragraph(f"Stand: {date_display}", subtitle_style))
elements.append(Spacer(1, 20)) elements.append(Spacer(1, 20))
# FAQs holen # FAQs holen
faqs = FAQ.objects.filter(is_active=True) faqs = FAQ.objects.filter(is_active=True)
if faqs.exists():
# FAQs hinzufügen # FAQs hinzufügen
for i, faq in enumerate(faqs, 1): for i, faq in enumerate(faqs, 1):
# Frage # Frage
elements.append(Paragraph(f"{i}. {faq.question}", question_style)) elements.append(Paragraph(f"{i}. {faq.question}", question_style))
# Antwort # Antwort
elements.append(Paragraph(faq.answer.replace('\n', '<br/>'), answer_style)) elements.append(Paragraph(faq.answer.replace("\n", "<br/>"), answer_style))
# Abstand zwischen FAQs # Abstand zwischen FAQs
elements.append(Spacer(1, 10)) elements.append(Spacer(1, 10))
else:
elements.append(Paragraph("Derzeit sind keine FAQs verfügbar.", answer_style))
# PDF generieren # PDF generieren
doc.build(elements) doc.build(elements)