diff --git a/THIRD_PARTY_LICENSES_PYTHON.md b/django/THIRD_PARTY_LICENSES_PYTHON.md
similarity index 95%
rename from THIRD_PARTY_LICENSES_PYTHON.md
rename to django/THIRD_PARTY_LICENSES_PYTHON.md
index 060bc13..e8696aa 100644
--- a/THIRD_PARTY_LICENSES_PYTHON.md
+++ b/django/THIRD_PARTY_LICENSES_PYTHON.md
@@ -17,6 +17,8 @@
| cryptography | 46.0.3 | UNKNOWN |
| daphne | 4.1.2 | BSD License |
| django-cleanup | 9.0.0 | MIT License |
+| geographiclib | 2.1 | MIT |
+| geopy | 2.4.1 | MIT License |
| hyperlink | 21.0.0 | MIT License |
| idna | 3.11 | UNKNOWN |
| incremental | 24.7.2 | MIT License |
diff --git a/django/library/views.py b/django/library/views.py
index 5da7064..bc62d6f 100644
--- a/django/library/views.py
+++ b/django/library/views.py
@@ -861,12 +861,12 @@ def new_question(request):
messages.error(request, 'Quiz nicht gefunden oder keine Berechtigung.')
return redirect('library:overview_quiz')
- if question_type not in ['multiple_choice', 'true_false','input','order','guess']:
+ if question_type not in ['multiple_choice', 'true_false','input','order','guess','map']:
base_url = reverse('library:new_question')
return redirect(f'{base_url}?type=multiple_choice&quiz_id={quiz_id}')
if request.method == 'POST':
- question_text = request.POST.get('question', '')
+ question_text = request.POST.get('question', 'Kein Fragentext angegeben')
# Handle different question types
if question_type == 'true_false':
@@ -946,20 +946,27 @@ def new_question(request):
max_value = request.POST.get('max')
tolerance = request.POST.get('tolerance')
json_data = {
- 'type': question_type,
- 'question': question_text,
- 'correct_answer': correct_answer,
- 'min': min_value,
- 'max': max_value,
- 'tolerance': tolerance,
- 'options': [
- {'value': correct_answer, 'is_correct': True}
- ]
- }
-
-
-
+ 'type': question_type,
+ 'question': question_text,
+ 'correct_answer': correct_answer,
+ 'min': min_value,
+ 'max': max_value,
+ 'tolerance': tolerance,
+ 'options': [
+ {'value': correct_answer, 'is_correct': True}
+ ]
+ }
+ elif question_type == 'map':
+ target_location = request.POST.get('location')
+ tolerance_radius = request.POST.get('tolerance_radius')
+
+ json_data = {
+ 'type': question_type,
+ 'question': question_text,
+ 'target_location': target_location,
+ 'tolerance_radius': tolerance_radius
+ }
question = QivipQuestion()
question.data = json.dumps(json_data)
question.quiz_id = quiz
@@ -1030,18 +1037,26 @@ def new_question(request):
max_value = request.POST.get('max')
tolerance = request.POST.get('tolerance')
json_data = {
- 'type': question_type,
- 'question': '',
- 'correct_answer': correct_answer,
- 'min': min_value,
- 'max': max_value,
- 'tolerance': tolerance,
- 'options': [
- {'value': correct_answer, 'is_correct': True}
- ]
- }
+ 'type': question_type,
+ 'question': '',
+ 'correct_answer': correct_answer,
+ 'min': min_value,
+ 'max': max_value,
+ 'tolerance': tolerance,
+ 'options': [
+ {'value': correct_answer, 'is_correct': True}
+ ]
+ }
standard_time_per_question = 30
+ elif question_type == 'map':
+ question_data = {
+ 'type': question_type,
+ 'question': '',
+ 'target_location': "",
+ 'tolerance_radius': 90000
+ }
+ standard_time_per_question = 30
template_name = f'library/question/question_{question_type}.html'
context = {
@@ -1083,6 +1098,13 @@ def edit_question(request, pk):
{'value': 'Falsch', 'is_correct': not correct_answer}
]
}
+ elif question_type == 'map':
+ question_data = {
+ 'type': question_type,
+ 'question': question_data.get('question', ''),
+ 'target_location': question_data.get('target_location', ''),
+ 'tolerance_radius': question_data.get('tolerance_radius', 90000)
+ }
else:
# For multiple choice questions
question_data.setdefault('type', question_type)
@@ -1200,17 +1222,27 @@ def edit_question(request, pk):
max_value = request.POST.get('max')
tolerance = request.POST.get('tolerance')
json_data = {
- 'type': question_type,
- 'question': question_text,
- 'correct_answer': correct_answer,
- 'min': min_value,
- 'max': max_value,
- 'tolerance': tolerance,
- 'options': [
- {'value': correct_answer, 'is_correct': True}
- ]
- }
+ 'type': question_type,
+ 'question': question_text,
+ 'correct_answer': correct_answer,
+ 'min': min_value,
+ 'max': max_value,
+ 'tolerance': tolerance,
+ 'options': [
+ {'value': correct_answer, 'is_correct': True}
+ ]
+ }
+ elif question_type == 'map':
+ target_location = request.POST.get('location')
+ tolerance_radius = request.POST.get('tolerance_radius')
+
+ json_data = {
+ 'type': question_type,
+ 'question': question_text,
+ 'target_location': target_location,
+ 'tolerance_radius': tolerance_radius
+ }
question.data = json.dumps(json_data)
question.time_per_question = request.POST.get('time_per_question', '30')
try:
diff --git a/django/play/consumers/game.py b/django/play/consumers/game.py
index a227b4e..21ac078 100644
--- a/django/play/consumers/game.py
+++ b/django/play/consumers/game.py
@@ -6,6 +6,7 @@ from django.utils import timezone
from django.urls import reverse
from play.models import QuizGame, QuizGameParticipant, QuizAnswer
from django.conf import settings
+from geopy.distance import geodesic
class GameConsumer(AsyncWebsocketConsumer):
@@ -31,6 +32,13 @@ class GameConsumer(AsyncWebsocketConsumer):
# Check if game should be deleted
await self.cleanup_game()
+
+ @database_sync_to_async
+ def get_question_data(self):
+ quiz_game = QuizGame.objects.get(join_code=self.join_code)
+ current_question = quiz_game.quiz_id.questions.all()[quiz_game.current_question_index]
+ return json.loads(current_question.data)
+
async def receive(self, text_data):
text_data_json = json.loads(text_data)
message_type = text_data_json['type']
@@ -84,14 +92,16 @@ class GameConsumer(AsyncWebsocketConsumer):
elif message_type == 'submit_answer':
participant_id = text_data_json['participant_id']
- try:
- answer = text_data_json['answer']
- except:
- answer=-1
+ answer = text_data_json['answer']
time_remaining = text_data_json.get('time_remaining', 0)
- # Save answer and update participant score
- await self.save_answer(participant_id, answer, time_remaining)
+
+ question_data = await self.get_question_data()
+ if question_data['type'] == "map":
+ await self.save_answer_map(participant_id, answer, time_remaining)
+ else:
+ await self.save_answer(participant_id, answer, time_remaining)
+
# Notify host about the new answer
await self.channel_layer.group_send(
@@ -182,6 +192,138 @@ class GameConsumer(AsyncWebsocketConsumer):
except QuizGame.DoesNotExist:
return False
+ @database_sync_to_async
+ def save_answer_map(self, participant_id, answer_location, time_remaining):
+ answer_index = 0
+ try:
+ # Get all necessary data first
+ participant = QuizGameParticipant.objects.get(
+ quiz_game__join_code=self.join_code,
+ participant_id=participant_id
+ )
+ quiz_game = participant.quiz_game
+ current_question = quiz_game.quiz_id.questions.all()[quiz_game.current_question_index]
+ question_data = json.loads(current_question.data)
+ target_location = question_data['target_location']
+ tolerance_radius = question_data.get('tolerance_radius', 1000) # Default 1km if not set
+
+
+ # Parse coordinates if they are strings
+ def parse_coords(coord):
+ if isinstance(coord, str):
+ try:
+ # Handle both comma and space separated coordinates
+ if ',' in coord:
+ lat, lng = map(float, [x.strip() for x in coord.split(',')])
+ else:
+ # Try splitting by space if no comma
+ parts = coord.strip().split()
+ if len(parts) >= 2:
+ lat, lng = map(float, parts[:2])
+ else:
+ raise ValueError("Invalid coordinate format")
+ return (lat, lng)
+ except (ValueError, AttributeError) as e:
+ print(f"Error parsing coordinates {coord}: {e}")
+ return None
+ elif isinstance(coord, (list, tuple)) and len(coord) >= 2:
+ return (float(coord[0]), float(coord[1]))
+ return None
+
+ # Parse the coordinates
+ target_location = parse_coords(target_location)
+ answer_location_coords = answer_location
+ answer_location = parse_coords(answer_location)
+
+
+ if not target_location or not answer_location:
+ print("Invalid coordinates, cannot calculate score")
+ is_correct = False
+ score = 0
+ else:
+
+ # Configuration - Make these easily adjustable
+ MAX_DISTANCE_KM = 200 # Maximum distance for scoring (in km)
+ DIRECT_HIT_BONUS = 200 # Extra points for direct hit within tolerance
+
+ try:
+ # Calculate distance
+ distance_km = geodesic(answer_location, target_location).kilometers
+ tolerance_km = float(tolerance_radius) / 1000 # Convert meters to kilometers
+
+ print(f"Distance: {distance_km} km, Tolerance: {tolerance_km} km")
+
+ # Calculate base score with non-linear decrease
+ if distance_km <= tolerance_km:
+ # Direct hit - full points + bonus
+ base_score = 1000 + DIRECT_HIT_BONUS
+ is_correct = True
+ else:
+ if distance_km > MAX_DISTANCE_KM:
+ # Beyond max distance - 0 points
+ base_score = 0
+ is_correct = False
+ else:
+ # Non-linear decrease using exponential falloff
+ progress = (distance_km - tolerance_km) / (MAX_DISTANCE_KM - tolerance_km)
+ falloff = progress ** 1.5 # 1.5 makes the falloff steeper at the beginning
+ base_score = 1000 * (1 - falloff)
+ is_correct = True
+
+ # Apply time penalty (reduces score based on time taken)
+ if is_correct and base_score > 0:
+ # Calculate time taken as a fraction of total time (0-1)
+ time_taken_sec = (int(current_question.time_per_question) * 1000 - time_remaining) / 1000
+ max_time_sec = int(current_question.time_per_question)
+ time_fraction = min(1.0, time_taken_sec / max_time_sec) if max_time_sec > 0 else 1.0
+
+ # Reduce points based on time taken (up to 50% reduction for answering at the last second)
+ time_penalty = base_score * 0.5 * time_fraction # Up to 50% penalty
+ final_score = max(1, base_score - time_penalty) # At least 1 point if correct
+ else:
+ final_score = base_score # 0 points for incorrect answers
+
+ # Apply max score limit after all calculations (including bonus)
+ final_score = min(final_score, 1200) # Cap at 1200 (1000 + 200 bonus)
+
+ score = int(round(final_score))
+
+ except Exception as e:
+ print(f"Error calculating score: {e}")
+ is_correct = False
+ score = 0
+
+ # Save the answer
+ answer_obj, created = QuizAnswer.objects.get_or_create(
+ participant=participant,
+ question_index=quiz_game.current_question_index,
+ defaults={
+ 'answer_index': answer_index,
+ 'is_correct': is_correct,
+ 'score': score,
+ 'time_remaining': time_remaining
+ }
+ )
+
+ if not created:
+ answer_obj.answer_index = answer_index
+ answer_obj.is_correct = is_correct
+ answer_obj.score = score
+ answer_obj.time_remaining = time_remaining
+ answer_obj.save()
+
+ participant.last_score = score
+ participant.score += score
+ participant.coordinate_answer= answer_location_coords
+ participant.last_answer_correct = is_correct
+ participant.save()
+
+ return True
+
+ except (QuizGameParticipant.DoesNotExist, IndexError):
+ return False
+
+
@database_sync_to_async
def save_answer(self, participant_id, answer, time_remaining):
try:
diff --git a/django/play/migrations/0016_quizgameparticipant_coordinate_answer.py b/django/play/migrations/0016_quizgameparticipant_coordinate_answer.py
new file mode 100644
index 0000000..a0d3b7f
--- /dev/null
+++ b/django/play/migrations/0016_quizgameparticipant_coordinate_answer.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.1.7 on 2025-11-01 15:20
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('play', '0015_alter_quizgame_current_state'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='quizgameparticipant',
+ name='coordinate_answer',
+ field=models.CharField(blank=True, max_length=256, null=True),
+ ),
+ ]
diff --git a/django/play/models.py b/django/play/models.py
index 81bca6a..6d47299 100644
--- a/django/play/models.py
+++ b/django/play/models.py
@@ -50,6 +50,7 @@ class QuizGameParticipant(models.Model):
avatar = models.CharField(max_length=200, blank=True, default="")
last_heartbeat = models.DateTimeField(auto_now_add=True)
last_answer = models.IntegerField(null=True, blank=True)
+ coordinate_answer = models.CharField(null=True, blank=True,max_length=256)
last_answer_correct = models.BooleanField(null=True, blank=True)
def save(self, *args, **kwargs):
diff --git a/django/static/css/t-input.css b/django/static/css/t-input.css
index bc9d632..bf58810 100644
--- a/django/static/css/t-input.css
+++ b/django/static/css/t-input.css
@@ -124,4 +124,13 @@ div.qp-question-container {
div.qp-question-container img {
@apply max-w-[50vw] max-h-[40vh] mx-auto;
+}
+
+div#mapquestion-container {
+ display: flex;
+ justify-content: center;
+}
+
+div#map {
+ @apply md:w-[600px] lg:w-[900px] h-[400px]
}
\ No newline at end of file
diff --git a/django/templates/base.html b/django/templates/base.html
index abef9e8..70db57b 100644
--- a/django/templates/base.html
+++ b/django/templates/base.html
@@ -9,6 +9,9 @@
qivip
+
diff --git a/django/templates/library/detail_quiz.html b/django/templates/library/detail_quiz.html
index 111b946..83d30dd 100644
--- a/django/templates/library/detail_quiz.html
+++ b/django/templates/library/detail_quiz.html
@@ -98,9 +98,6 @@
Eingabe
-
-
-
Multiple Choice
@@ -116,10 +113,15 @@
Reihenfolge
-
Schätzen
+
+
+ Karte
+
{% endif %}
@@ -140,8 +142,14 @@
{{ question.data.question }}
-
- {% if question.data.type == 'multiple_choice' %}Multiple Choice{% elif question.data.type == 'input' %}Eingabe{% elif question.data.type == 'true_false' %}Wahr/Falsch{% elif question.data.type == 'order' %}Reihenfolge{% else %}Schätzen{% endif %}
+
+ {% if question.data.type == 'multiple_choice' %}Multiple Choice
+ {% elif question.data.type == 'input' %}Eingabe
+ {% elif question.data.type == 'map' %}Karte
+ {% elif question.data.type == 'true_false' %}Wahr/Falsch
+ {% elif question.data.type == 'order' %}Reihenfolge
+ {% else %}Schätzen
+ {% endif %}
@@ -173,8 +181,46 @@

{% endif %}
+
+ {% elif question.data.type == 'map' and detail %}
+
+
+ Zielort:
+
+ {% if question.data.target_location %}
+ {{ question.data.target_location }}
+ {% else %}
+ Kein Zielort festgelegt
+ {% endif %}
+
+
+ {% if question.data.target_location %}
+
+ {% endif %}
+ {% if question.data.tolerance_radius %}
+
+ Toleranzradius: {{ question.data.tolerance_radius }} Meter
+
+ {% endif %}
+ {% if question.image.image.url %}
+

+ {% endif %}
+
{% elif question.data.type == 'order' %}
-
+
{% for option in question.data.options|dictsort:"order" %}
-
@@ -188,7 +234,6 @@
{% endif %}
-
{% elif question.data.type == 'input' %}
diff --git a/django/templates/library/question/question_map.html b/django/templates/library/question/question_map.html
new file mode 100644
index 0000000..cd3cb7f
--- /dev/null
+++ b/django/templates/library/question/question_map.html
@@ -0,0 +1,166 @@
+{% extends 'base.html' %}
+
+{% block content %}
+
+
+
+
+
+
+
{% if question %}Frage bearbeiten{% else %}Neue Karten Frage{% endif %}
+
+ Zurück zum Quiz
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/django/templates/play/game/question_host.html b/django/templates/play/game/question_host.html
index f3a168c..761c523 100644
--- a/django/templates/play/game/question_host.html
+++ b/django/templates/play/game/question_host.html
@@ -50,8 +50,7 @@
- {% if question_data.type == "multiple_choice" or question_data.type == "true_false" %}
-
+ {% if question_data.type == "multiple_choice" or question_data.type == "true_false" %}
{% for option in question_data.options %}
{% endblock %}
@@ -232,7 +312,7 @@ class="px-6 py-2 bg-blue-500 hover:bg-blue-600 text-white font-semibold rounded-
setTimeout(() => {
- submitAnswer( -1 );
+ submitAnswer(-1);
window.location.href = data.redirect_url;
}, 1000);
}
diff --git a/django/templates/play/game/question_participant.html b/django/templates/play/game/question_participant.html
index 74c3632..7d1766d 100644
--- a/django/templates/play/game/question_participant.html
+++ b/django/templates/play/game/question_participant.html
@@ -25,8 +25,7 @@
-
- {% if question_data.type == "multiple_choice" or question_data.type == "true_false" %}
+ {% if question_data.type == "multiple_choice" or question_data.type == "true_false" %}
{% for option in question_data.options %}
+ {% elif question_data.type == "map" %}
+
+ Ziel auswählen
+
+
+
+
+ Abgeben
+
+
+
+ {% else %}
+ aaaaah gibts nicht
{% endif %}
@@ -193,7 +244,7 @@
window.location.href = data.redirect_url;
{% if question_data.type == "order" %}
submitOrderAnswer();
- {% else %}
+ {% elif question_data.type != "map" %}
submitAnswer(-1);
{% endif %}
}
@@ -203,7 +254,22 @@
wsUtil.handleClose(e);
};
- function submitAnswer(optionIndex) {
+ function submitAnswer(optionIndex=0, has_options=true) {
+ if (!has_options){
+ answer = document.getElementById("singleanswer").value;
+
+ if (answer == ""){
+ alert("Bitte eine Antwort eingeben!");
+ return;
+ }
+
+ gameSocket.send(JSON.stringify({
+ type: 'submit_answer',
+ participant_id: participantId,
+ answer:answer,
+ time_remaining: questionDuration
+ }));
+ }
if (hasAnswered) return;
let input = ""; // Declare input here
@@ -249,7 +315,42 @@
}
}
-
+ function submitAnswerMap() {
+ answer = document.getElementById("location").value;
+
+ if (answer == ""){
+ alert("Bitte eine Antwort eingeben!");
+ return;
+ }
+
+ // Add grey overlay
+ const overlay = document.createElement('div');
+ overlay.style.position = 'fixed';
+ overlay.style.top = '0';
+ overlay.style.left = '0';
+ overlay.style.width = '100%';
+ overlay.style.height = '100%';
+ overlay.style.backgroundColor = 'rgba(128, 128, 128, 0.5)';
+ overlay.style.zIndex = '1000';
+ overlay.style.display = 'flex';
+ overlay.style.justifyContent = 'center';
+ overlay.style.alignItems = 'center';
+ overlay.style.color = 'white';
+ overlay.style.fontSize = '24px';
+ overlay.style.fontWeight = 'bold';
+ overlay.textContent = 'Antwort übermittelt';
+ document.body.appendChild(overlay);
+
+ const now = Date.now();
+ const timeRemaining = Math.max(0, questionDuration - (now - questionStartTime));
+
+ gameSocket.send(JSON.stringify({
+ type: 'submit_answer',
+ participant_id: participantId,
+ answer: answer,
+ time_remaining: timeRemaining
+ }));
+ }
// Timer
function updateTimer() {
diff --git a/django/templates/play/game/score_overview.html b/django/templates/play/game/score_overview.html
index 7230ee2..e9ea22b 100644
--- a/django/templates/play/game/score_overview.html
+++ b/django/templates/play/game/score_overview.html
@@ -9,9 +9,129 @@
Aktuelle Frage
{{ question_data.question }}
- {% if question_data.type != "order" and question_data.type != "guess" %}
+
+ {% if question_data.type == "map" %}
+
+
+
+
+
+
+ {% elif question_data.type != "order" and question_data.type != "guess" %}
+
-
{% for option in question_data.options %}
@@ -25,7 +145,7 @@
{% endfor %}
- {% elif question_data.type == "guess" %}
+ {% elif question_data.type == "guess" %}
diff --git a/requirements.txt b/requirements.txt
index 376bf71..be6814f 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -11,4 +11,6 @@ python-dotenv # Production server safety
qrcode~=7.4.2
reportlab~=4.2.0
pip-licenses ~= 5.0.0
-requests
\ No newline at end of file
+requests
+requests
+geopy