Compare commits

...

3 Commits

Author SHA1 Message Date
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
8 changed files with 253 additions and 132 deletions

View File

@@ -1,24 +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 .models import Ticket, Course, FAQ
from .models import FAQ, Course, Ticket
admin.site.register(Ticket)
admin.site.register(Course)

View File

@@ -1,7 +1,8 @@
from django import forms
from .models import Comment, Ticket
from django.core.exceptions import ValidationError
from .models import Comment, Ticket
class CommentForm(forms.ModelForm):
class Meta:
@@ -17,45 +18,55 @@ class CommentForm(forms.ModelForm):
class TicketForm(forms.ModelForm):
# Zentrale Definition der Status-Übergänge
STATUS_TRANSITIONS = {
'tutor': {
'new': ['in_progress'],
'in_progress': ['resolved', 'new'],
'resolved': ['closed'],
'closed': [],
"tutor": {
"new": ["in_progress"],
"in_progress": ["resolved", "new"],
"resolved": ["closed"],
"closed": [],
},
'creator': {
'new': [],
'in_progress': [],
'resolved': ['closed', 'new'],
'closed': [],
"creator": {
"new": [],
"in_progress": [],
"resolved": ["closed", "new"],
"closed": [],
},
'superuser': {
"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'],
}
"new": ["in_progress", "resolved", "closed"],
"in_progress": ["new", "resolved", "closed"],
"resolved": ["new", "in_progress", "closed"],
"closed": ["new", "in_progress", "resolved"],
},
}
# Zentrale Definition welche Felder wann required sind
REQUIRED_FIELDS_BY_STATUS = {
'resolved': ['answer'], # Answer required when resolving
"resolved": ["answer"], # Answer required when resolving
}
class Meta:
model = Ticket
fields = ["title", "description", "status", "mistake", "course", "answer", "material"]
fields = [
"title",
"description",
"status",
"mistake",
"course",
"answer",
"material",
]
widgets = {
'answer': forms.Textarea(attrs={
'rows': 4,
'placeholder': 'Beschreibe die Lösung des Problems...'
})
"answer": forms.Textarea(
attrs={
"rows": 4,
"placeholder": "Beschreibe die Lösung des Problems...",
}
)
}
def __init__(self, *args, **kwargs):
self.user = kwargs.pop('user', None)
self.ticket = kwargs.pop('ticket', None)
self.user = kwargs.pop("user", None)
self.ticket = kwargs.pop("ticket", None)
super().__init__(*args, **kwargs)
if self.ticket and self.user:
@@ -79,17 +90,17 @@ class TicketForm(forms.ModelForm):
if is_superuser:
return
if self.ticket.status == 'resolved' and is_creator:
if self.ticket.status == "resolved" and is_creator:
for field_name in self.fields:
if field_name == "answer":
self.fields[field_name].disabled = True
elif is_tutor:
# Tutor darf ändern:
readonly_fields = ['title', 'description', 'material']
readonly_fields = ["title", "description", "material"]
for field_name in readonly_fields:
if field_name in self.fields:
self.fields[field_name].disabled = True
elif is_creator and self.ticket.status != 'resolved':
elif is_creator and self.ticket.status != "resolved":
for field_name in self.fields:
self.fields[field_name].disabled = True
@@ -99,11 +110,11 @@ class TicketForm(forms.ModelForm):
# Superuser bekommen alle Status-Optionen
if is_superuser:
role = 'superuser'
role = "superuser"
elif is_tutor:
role = 'tutor'
role = "tutor"
elif is_creator:
role = 'creator'
role = "creator"
else:
role = None
@@ -114,10 +125,9 @@ class TicketForm(forms.ModelForm):
allowed_statuses = [current_status]
# Status-Choices filtern
all_choices = list(self.fields['status'].choices)
self.fields['status'].choices = [
choice for choice in all_choices
if choice[0] in allowed_statuses
all_choices = list(self.fields["status"].choices)
self.fields["status"].choices = [
choice for choice in all_choices if choice[0] in allowed_statuses
]
def _get_allowed_transitions(self, from_status, role):
@@ -137,7 +147,7 @@ class TicketForm(forms.ModelForm):
def clean(self):
cleaned_data = super().clean()
status = cleaned_data.get('status')
status = cleaned_data.get("status")
if not self.ticket or not status:
return cleaned_data
@@ -150,27 +160,31 @@ class TicketForm(forms.ModelForm):
# Superuser dürfen alle Übergänge
if is_superuser:
role = 'superuser'
role = "superuser"
elif is_tutor:
role = 'tutor'
role = "tutor"
elif is_creator:
role = 'creator'
role = "creator"
else:
role = None
if role and not self._is_transition_allowed(old_status, status, role):
raise ValidationError({
'status': f'Übergang von "{self.ticket.get_status_display()}" zu "{dict(self.fields["status"].choices)[status]}" ist nicht erlaubt.'
})
raise ValidationError(
{
"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
required_fields = self._get_required_fields_for_status(status)
for field_name in required_fields:
if not cleaned_data.get(field_name):
field_label = self.fields[field_name].label
raise ValidationError({
field_name: f'{field_label} ist erforderlich, wenn der Status auf "{dict(self.fields["status"].choices)[status]}" gesetzt wird.'
})
raise ValidationError(
{
field_name: f'{field_label} ist erforderlich, wenn der Status auf "{dict(self.fields["status"].choices)[status]}" gesetzt wird.'
}
)
return cleaned_data
@@ -184,6 +198,7 @@ class TicketForm(forms.ModelForm):
# Setze answered_at wenn eine Antwort gegeben wird
if ticket.answer and not ticket.answered_at:
from django.utils import timezone
ticket.answered_at = timezone.now()
if commit:

View File

@@ -1,11 +1,14 @@
from django.db import models
from django.contrib.auth.models import User
from django.db import models
class Course(models.Model):
"""Kurs-Model für Backend-Verwaltung"""
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")
tutor = models.ForeignKey(
@@ -14,14 +17,14 @@ class Course(models.Model):
null=True,
blank=True,
verbose_name="Tutor",
related_name="courses_as_tutor"
related_name="courses_as_tutor",
)
is_active = models.BooleanField(default=True, verbose_name="Aktiv")
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['name']
ordering = ["name"]
verbose_name = "Kurs"
verbose_name_plural = "Kurse"
@@ -64,37 +67,40 @@ class Ticket(models.Model):
description = models.TextField()
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="new")
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(
blank=True,
null=True,
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(
blank=True,
null=True,
verbose_name="Beantwortet am"
blank=True, null=True, verbose_name="Beantwortet am"
)
course = models.ForeignKey(
Course,
on_delete=models.CASCADE,
verbose_name="Kurs",
related_name="tickets"
Course, on_delete=models.CASCADE, verbose_name="Kurs", related_name="tickets"
)
created_by = models.ForeignKey(
User, related_name="tickets_created", on_delete=models.CASCADE
)
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)
updated_at = models.DateTimeField(auto_now=True)
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):
@@ -126,13 +132,14 @@ class TicketHistory(models.Model):
class FAQ(models.Model):
"""Einfaches FAQ Model"""
question = models.CharField(max_length=300, verbose_name="Frage")
answer = models.TextField(verbose_name="Antwort")
order = models.IntegerField(default=0, verbose_name="Reihenfolge")
is_active = models.BooleanField(default=True, verbose_name="Aktiv")
class Meta:
ordering = ['order', 'question']
ordering = ["order", "question"]
verbose_name = "FAQ"
verbose_name_plural = "FAQs"

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

@@ -1,19 +1,46 @@
{% extends "ticketsystem/base.html" %}
{% load static %}
{% block content %}
<div class="max-w-6xl mx-auto px-4 py-6">
<!-- Header -->
<div class="bg-white rounded-lg shadow p-6 mb-6">
<div class="flex justify-between items-center">
<h1 class="text-3xl font-bold">❓ Häufig gestellte Fragen</h1>
<a href="{% url 'faq-pdf-download' %}"
class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded">📄 PDF Download</a>
<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' %}"
class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded">📄 PDF Download</a>
</div>
</div>
</div>
<!-- FAQ Liste -->
{% for faq in faqs %}
<div class="bg-white rounded-lg shadow p-6 mb-4">
<h3 class="font-bold text-lg mb-2">{{ forloop.counter }}. {{ faq.question }}</h3>
<div class="text-gray-700">{{ faq.answer|linebreaks }}</div>
<div class="bg-white rounded-lg shadow mb-4 overflow-hidden">
<!-- FAQ Header - klickbar -->
<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>
{% empty %}
<div class="bg-white rounded-lg shadow p-8 text-center">
@@ -21,4 +48,7 @@
</div>
{% endfor %}
</div>
<!-- FAQ JavaScript -->
<script src="{% static 'js/faq.js' %}"></script>
{% endblock %}

View File

@@ -1,15 +1,8 @@
from django.urls import path
from .views import (
TicketListView,
TicketCreateView,
TicketUpdateView,
HomeView,
AssignedTicketListView,
TicketDetailUpdateView,
faq_list,
faq_pdf_download
)
from .views import (AssignedTicketListView, HomeView, TicketCreateView,
TicketDetailUpdateView, TicketListView, TicketUpdateView,
faq_list, faq_pdf_download)
urlpatterns = [
# /ticketsystem/
@@ -22,6 +15,6 @@ urlpatterns = [
path("new/", TicketCreateView.as_view(), name="create"),
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/download/', faq_pdf_download, name='faq-pdf-download'),
path("faq/", faq_list, name="faq-list"),
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.shortcuts import redirect, render
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Q
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.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import cm
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer
from reportlab.lib.enums import TA_LEFT, TA_CENTER
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer
from .models import Ticket, TicketHistory, FAQ, Course
from .forms import CommentForm, TicketForm
from .models import FAQ, Course, Ticket, TicketHistory
class HomeView(TemplateView):
@@ -77,6 +75,12 @@ class TicketDetailUpdateView(UpdateView):
template_name = "ticketsystem/detail.html"
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):
return reverse("detail", kwargs={"pk": self.object.pk})
@@ -92,9 +96,9 @@ class TicketDetailUpdateView(UpdateView):
# Bearbeitungsrechte abhängig vom Status
if is_superuser:
self.can_edit = True
elif self.ticket.status == 'resolved' and is_creator:
elif self.ticket.status == "resolved" and is_creator:
self.can_edit = True
elif self.ticket.status == 'closed':
elif self.ticket.status == "closed":
self.can_edit = False
else:
self.can_edit = is_assigned_tutor
@@ -108,16 +112,16 @@ class TicketDetailUpdateView(UpdateView):
def get_form_kwargs(self):
"""Übergibt zusätzliche kwargs ans Form"""
kwargs = super().get_form_kwargs()
kwargs['user'] = self.request.user
kwargs['ticket'] = self.object
kwargs["user"] = self.request.user
kwargs["ticket"] = self.object
return kwargs
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Füge Berechtigungs-Infos zum Context hinzu
context['is_creator'] = self.is_creator
context['is_tutor'] = self.is_tutor
context['can_edit'] = self.can_edit
context["is_creator"] = self.is_creator
context["is_tutor"] = self.is_tutor
context["can_edit"] = self.can_edit
# Kommentarformular hinzufügen
if "comment_form" not in context:
@@ -134,7 +138,15 @@ class TicketDetailUpdateView(UpdateView):
response = super().form_valid(form) # Speichert das Ticket
# 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:
if field in form.changed_data:
old_value = getattr(original, field)
@@ -152,11 +164,15 @@ class TicketDetailUpdateView(UpdateView):
new_value = str(new_value)
elif field == "answer":
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:
old_value = "Keine Antwort"
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:
new_value = "Keine Antwort"
@@ -286,41 +302,54 @@ class TicketUpdateView(LoginRequiredMixin, UpdateView):
def faq_list(request):
"""Zeigt alle aktiven FAQs an"""
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):
"""Generiert PDF mit allen FAQs"""
# Response Setup
response = HttpResponse(content_type='application/pdf')
response['Content-Disposition'] = 'attachment; filename="FAQ_Ticketsystem.pdf"'
current_date = timezone.now()
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
doc = SimpleDocTemplate(response, pagesize=A4, topMargin=2 * cm, bottomMargin=2 * cm)
doc = SimpleDocTemplate(
response, pagesize=A4, topMargin=2 * cm, bottomMargin=2 * cm
)
# Styles
styles = getSampleStyleSheet()
title_style = ParagraphStyle(
'CustomTitle',
parent=styles['Heading1'],
"CustomTitle",
parent=styles["Heading1"],
fontSize=24,
textColor='#1a1a1a',
textColor="#1a1a1a",
spaceAfter=20,
alignment=TA_CENTER,
)
subtitle_style = ParagraphStyle(
"Subtitle",
parent=styles["Normal"],
fontSize=12,
textColor="#666666",
spaceAfter=30,
alignment=TA_CENTER
alignment=TA_CENTER,
)
question_style = ParagraphStyle(
'Question',
parent=styles['Heading2'],
"Question",
parent=styles["Heading2"],
fontSize=14,
textColor='#2563eb',
spaceAfter=10
textColor="#2563eb",
spaceAfter=10,
)
answer_style = ParagraphStyle(
'Answer',
parent=styles['Normal'],
fontSize=11,
spaceAfter=20,
alignment=TA_LEFT
"Answer", parent=styles["Normal"], fontSize=11, spaceAfter=20, alignment=TA_LEFT
)
# Content
@@ -328,21 +357,27 @@ def faq_pdf_download(request):
# Titel
elements.append(Paragraph("Häufig gestellte Fragen (FAQ)", title_style))
elements.append(Paragraph(f"Stand: {date_display}", subtitle_style))
elements.append(Spacer(1, 20))
# FAQs holen
faqs = FAQ.objects.filter(is_active=True)
# FAQs hinzufügen
for i, faq in enumerate(faqs, 1):
# Frage
elements.append(Paragraph(f"{i}. {faq.question}", question_style))
if faqs.exists():
# FAQs hinzufügen
for i, faq in enumerate(faqs, 1):
# Frage
elements.append(Paragraph(f"{i}. {faq.question}", question_style))
# Antwort
elements.append(Paragraph(faq.answer.replace('\n', '<br/>'), answer_style))
# Antwort
elements.append(Paragraph(faq.answer.replace("\n", "<br/>"), answer_style))
# Abstand zwischen FAQs
elements.append(Spacer(1, 10))
# Abstand zwischen FAQs
elements.append(Spacer(1, 10))
else:
elements.append(Paragraph("Derzeit sind keine FAQs verfügbar.", answer_style))
# PDF generieren
doc.build(elements)