diff --git a/src/c3nav/editor/forms.py b/src/c3nav/editor/forms.py
index bd84bef1..1141d0bd 100644
--- a/src/c3nav/editor/forms.py
+++ b/src/c3nav/editor/forms.py
@@ -283,7 +283,7 @@ def create_editor_form(editor_model):
'outside', 'can_search', 'can_describe', 'geometry', 'single', 'altitude', 'short_label',
'origin_space', 'target_space', 'data', 'comment', 'slow_down_factor',
'extra_seconds', 'speed', 'description', 'speed_up', 'description_up', 'enter_description',
- 'level_change_description', 'base_mapdata_accessible',
+ 'level_change_description', 'base_mapdata_accessible', 'can_report_missing',
'label_settings', 'label_override', 'min_zoom', 'max_zoom', 'font_size',
'allow_levels', 'allow_spaces', 'allow_areas', 'allow_pois', 'left', 'top', 'right', 'bottom']
field_names = [field.name for field in editor_model._meta.get_fields() if not field.one_to_many]
diff --git a/src/c3nav/locale/de/LC_MESSAGES/django.po b/src/c3nav/locale/de/LC_MESSAGES/django.po
index 55dadc24..a18681a1 100644
--- a/src/c3nav/locale/de/LC_MESSAGES/django.po
+++ b/src/c3nav/locale/de/LC_MESSAGES/django.po
@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2019-12-24 03:50+0100\n"
+"POT-Creation-Date: 2019-12-24 17:22+0100\n"
"PO-Revision-Date: 2019-12-22 00:44+0100\n"
"Last-Translator: Jenny Danzmayr \n"
"Language-Team: \n"
@@ -42,7 +42,7 @@ msgstr "Nich angemeldet."
msgid "Logout successful."
msgstr "Login erfolgreich."
-#: c3nav/api/models.py:14
+#: c3nav/api/models.py:14 c3nav/mapdata/models/report.py:79
msgid "secret"
msgstr "secret"
@@ -514,6 +514,7 @@ msgstr "zurück"
#: c3nav/control/templates/control/user.html:73
#: c3nav/mapdata/models/access.py:77 c3nav/mapdata/models/geometry/space.py:385
+#: c3nav/mapdata/models/report.py:58 c3nav/mapdata/models/report.py:109
#: c3nav/site/models.py:17
msgid "author"
msgstr "Autor"
@@ -788,8 +789,8 @@ msgid "Change Set"
msgstr "Änderungsset"
#: c3nav/editor/models/changedobject.py:35 c3nav/editor/models/changeset.py:39
-#: c3nav/editor/views/changes.py:266 c3nav/site/models.py:14
-#: c3nav/site/models.py:54
+#: c3nav/editor/views/changes.py:266 c3nav/mapdata/models/report.py:56
+#: c3nav/site/models.py:14 c3nav/site/models.py:54
msgid "created"
msgstr "erstellt"
@@ -863,7 +864,7 @@ msgstr "letzte Statusänderung"
#: c3nav/editor/models/changeset.py:48 c3nav/mapdata/models/base.py:64
#: c3nav/mapdata/models/graph.py:38 c3nav/mapdata/models/locations.py:256
-#: c3nav/mapdata/models/locations.py:421 c3nav/mapdata/utils/locations.py:344
+#: c3nav/mapdata/models/locations.py:423 c3nav/mapdata/utils/locations.py:344
msgid "Title"
msgstr "Titel"
@@ -871,7 +872,8 @@ msgstr "Titel"
msgid "Description"
msgstr "Beschreibung"
-#: c3nav/editor/models/changeset.py:51
+#: c3nav/editor/models/changeset.py:51 c3nav/mapdata/models/report.py:66
+#: c3nav/mapdata/models/report.py:113
msgid "assigned to"
msgstr "zugewiesen"
@@ -898,7 +900,7 @@ msgid_plural "%(num)d objects changed"
msgstr[0] "%(num)d Objekt geändert"
msgstr[1] "%(num)d Objekte geändert"
-#: c3nav/editor/models/changesetupdate.py:12
+#: c3nav/editor/models/changesetupdate.py:12 c3nav/mapdata/models/report.py:108
msgid "datetime"
msgstr "Zeitpunkt"
@@ -1150,7 +1152,7 @@ msgid "Log out"
msgstr "Abmelden"
#: c3nav/editor/templates/editor/fragment_nav.html:23
-#: c3nav/editor/views/account.py:27 c3nav/site/views.py:227
+#: c3nav/editor/views/account.py:27 c3nav/site/views.py:232
msgid "Log in"
msgstr "Anmelden"
@@ -1277,7 +1279,7 @@ msgid "Activate direct editing"
msgstr "Direktes Bearbeiten aktivieren"
#: c3nav/editor/templates/editor/user.html:54 c3nav/editor/views/account.py:85
-#: c3nav/site/templates/site/account.html:29 c3nav/site/views.py:292
+#: c3nav/site/templates/site/account.html:29 c3nav/site/views.py:297
msgid "Change password"
msgstr "Passwort ändern"
@@ -1298,11 +1300,11 @@ msgid "All recent change sets"
msgstr "Alle kürzlichen Änderungssets"
#: c3nav/editor/views/account.py:30 c3nav/editor/views/account.py:61
-#: c3nav/site/views.py:234 c3nav/site/views.py:269
+#: c3nav/site/views.py:239 c3nav/site/views.py:274
msgid "Create new account"
msgstr "Neues Konto erstellen"
-#: c3nav/editor/views/account.py:75 c3nav/site/views.py:283
+#: c3nav/editor/views/account.py:75 c3nav/site/views.py:288
msgid "Password successfully changed."
msgstr "Passwort erfolgreich geändert."
@@ -1509,7 +1511,7 @@ msgstr "Invalides GeoJSON."
msgid "Could not clean geometry."
msgstr "Konnte Geometrie nicht bereinigen."
-#: c3nav/mapdata/forms.py:54
+#: c3nav/mapdata/forms.py:55
#, python-brace-format
msgid "You have to choose a value for {field} in at least one language."
msgstr "Du must das Feld {field} in mindestens einer Sprache ausfüllen."
@@ -1679,9 +1681,11 @@ msgstr "Werte zurücksetzen"
msgid "save result to the stats directory"
msgstr "Ergebnis im stats-Ordner speichern"
-#: c3nav/mapdata/models/access.py:23
+#: c3nav/mapdata/models/access.py:23 c3nav/mapdata/models/report.py:59
+#: c3nav/mapdata/models/report.py:110
+#: c3nav/site/templates/site/report_detail.html:9
msgid "open"
-msgstr "öffnen"
+msgstr "offen"
#: c3nav/mapdata/models/access.py:24
msgid "Groups"
@@ -1723,8 +1727,8 @@ msgstr "Zugangserlaubnis-Token"
msgid "Access Permission Tokens"
msgstr "Zugangserlaubnis-Token"
-#: c3nav/mapdata/models/access.py:133 c3nav/site/views.py:79
-#: c3nav/site/views.py:326
+#: c3nav/mapdata/models/access.py:133 c3nav/site/views.py:84
+#: c3nav/site/views.py:331
msgid "Area successfully unlocked."
msgid_plural "Areas successfully unlocked."
msgstr[0] "Bereich erfolgreich freigeschaltet."
@@ -1976,6 +1980,7 @@ msgstr "Zielraum"
#: c3nav/mapdata/models/geometry/space.py:308
#: c3nav/mapdata/models/geometry/space.py:347 c3nav/mapdata/models/graph.py:48
+#: c3nav/mapdata/models/report.py:63
msgid "description"
msgstr "Beschreibung"
@@ -2000,6 +2005,7 @@ msgid "Cross descriptions"
msgstr "Durchschreitungsbeschreibungen"
#: c3nav/mapdata/models/geometry/space.py:386
+#: c3nav/mapdata/models/report.py:111
msgid "comment"
msgstr "Kommentar"
@@ -2159,7 +2165,7 @@ msgstr "name eines material icons"
msgid "searchable"
msgstr "suchbar"
-#: c3nav/mapdata/models/locations.py:162 c3nav/mapdata/models/locations.py:321
+#: c3nav/mapdata/models/locations.py:162 c3nav/mapdata/models/locations.py:323
msgid "Location Groups"
msgstr "Ortgruppen"
@@ -2215,7 +2221,7 @@ msgstr "Ortgruppenkategorie"
msgid "Location Group Categories"
msgstr "Ortgruppenkategorien"
-#: c3nav/mapdata/models/locations.py:309 c3nav/mapdata/models/locations.py:342
+#: c3nav/mapdata/models/locations.py:309 c3nav/mapdata/models/locations.py:344
msgid "Category"
msgstr "Kategorie"
@@ -2228,65 +2234,162 @@ msgid "unless location specifies otherwise"
msgstr "kann von Orten überschrieben werden"
#: c3nav/mapdata/models/locations.py:315
+msgid "for missing locations"
+msgstr "for fehlende Orte"
+
+#: c3nav/mapdata/models/locations.py:316
+msgid "can be used when reporting a missing location"
+msgstr "kann beim melden eines fehlenden Ortges genutzt werden"
+
+#: c3nav/mapdata/models/locations.py:317
msgid "background color"
msgstr "Hintergrundfarbe"
-#: c3nav/mapdata/models/locations.py:320
+#: c3nav/mapdata/models/locations.py:322
msgid "Location Group"
msgstr "Ortgruppe"
-#: c3nav/mapdata/models/locations.py:344 c3nav/mapdata/models/locations.py:359
+#: c3nav/mapdata/models/locations.py:346 c3nav/mapdata/models/locations.py:361
msgid "color"
msgstr "Farbe"
-#: c3nav/mapdata/models/locations.py:345
+#: c3nav/mapdata/models/locations.py:347
msgid "priority"
msgstr "Priorität"
-#: c3nav/mapdata/models/locations.py:355
+#: c3nav/mapdata/models/locations.py:357
msgid "search"
msgstr "suchen"
-#: c3nav/mapdata/models/locations.py:357
+#: c3nav/mapdata/models/locations.py:359
msgid "describe"
msgstr "beschreiben"
-#: c3nav/mapdata/models/locations.py:361
+#: c3nav/mapdata/models/locations.py:363
msgid "internal"
msgstr "intern"
-#: c3nav/mapdata/models/locations.py:378
+#: c3nav/mapdata/models/locations.py:380
#, python-brace-format
msgid "{category_title}, {num_locations}"
msgstr "{category_title}, {num_locations}"
-#: c3nav/mapdata/models/locations.py:380
+#: c3nav/mapdata/models/locations.py:382
#, python-format
msgid "%(num)d location"
msgid_plural "%(num)d locations"
msgstr[0] "%(num)d Ort"
msgstr[1] "%(num)d Orte"
-#: c3nav/mapdata/models/locations.py:403
+#: c3nav/mapdata/models/locations.py:405
msgid "target"
msgstr "Ziel"
-#: c3nav/mapdata/models/locations.py:422
+#: c3nav/mapdata/models/locations.py:424
msgid "min zoom"
msgstr "Mindestzoom"
-#: c3nav/mapdata/models/locations.py:425
+#: c3nav/mapdata/models/locations.py:427
msgid "max zoom"
msgstr "Maximalzoom"
-#: c3nav/mapdata/models/locations.py:428
+#: c3nav/mapdata/models/locations.py:430
msgid "font size"
msgstr "Schriftgröße"
-#: c3nav/mapdata/models/locations.py:444 c3nav/mapdata/models/locations.py:445
+#: c3nav/mapdata/models/locations.py:446 c3nav/mapdata/models/locations.py:447
msgid "Label Settings"
msgstr "Labeleinstellungen"
+#: c3nav/mapdata/models/report.py:52
+msgid "location issue"
+msgstr "Ortfehler"
+
+#: c3nav/mapdata/models/report.py:53
+msgid "missing location"
+msgstr "fehlender Ort"
+
+#: c3nav/mapdata/models/report.py:54
+msgid "route issue"
+msgstr "Routenfehler"
+
+#: c3nav/mapdata/models/report.py:57
+msgid "category"
+msgstr "Kategorie"
+
+#: c3nav/mapdata/models/report.py:60
+msgid "last_update"
+msgstr "letzte Anderung"
+
+#: c3nav/mapdata/models/report.py:61
+msgid "title"
+msgstr "Titel"
+
+#: c3nav/mapdata/models/report.py:62
+msgid "a short title for your report"
+msgstr "ein kurzer Titel für deine Meldung"
+
+#: c3nav/mapdata/models/report.py:64
+msgid "tell us precisely what's wrong"
+msgstr "Sag uns im Detail was falsch ist"
+
+#: c3nav/mapdata/models/report.py:68
+msgid "location"
+msgstr "Ort"
+
+#: c3nav/mapdata/models/report.py:69
+msgid "coordinates"
+msgstr "Koordinaten"
+
+#: c3nav/mapdata/models/report.py:70
+msgid "origin"
+msgstr "Start"
+
+#: c3nav/mapdata/models/report.py:71
+msgid "destination"
+msgstr "Ziel"
+
+#: c3nav/mapdata/models/report.py:72
+msgid "route options"
+msgstr "Routenoptionen"
+
+#: c3nav/mapdata/models/report.py:74
+msgid "new location title"
+msgstr "Neuer Ortstitel"
+
+#: c3nav/mapdata/models/report.py:75
+msgid "you have to supply a title in at least one language"
+msgstr "Du must das Feld {field} in mindestens einer Sprache ausfüllen."
+
+#: c3nav/mapdata/models/report.py:76
+msgid "location groups"
+msgstr "Ortgruppen"
+
+#: c3nav/mapdata/models/report.py:78
+msgid "select all groups that apply, if any"
+msgstr ""
+
+#: c3nav/mapdata/models/report.py:86
+#: c3nav/site/templates/site/report_detail.html:6
+msgid "Report"
+msgstr "Meldung"
+
+#: c3nav/mapdata/models/report.py:87
+msgid "Reports"
+msgstr "Meldungen"
+
+#: c3nav/mapdata/models/report.py:114
+msgid "public"
+msgstr "öffentlich"
+
+#: c3nav/mapdata/models/report.py:117
+msgid "Report update"
+msgstr "Meldungsupdate"
+
+#: c3nav/mapdata/models/report.py:118
+msgid "Report updates"
+msgstr "Meldungsupdates"
+
#: c3nav/mapdata/models/source.py:18
msgid "Source"
msgstr "Vorlage"
@@ -2408,7 +2511,7 @@ msgstr "Unerreichbarer Ort."
msgid "No route found."
msgstr "Keine Route gefunden."
-#: c3nav/routing/api.py:110
+#: c3nav/routing/api.py:109
msgid "Invalid scan data."
msgstr "Invalide Scandaten."
@@ -2456,65 +2559,66 @@ msgstr "Invalider Scan. Unerlaubte Frequenz."
msgid "Invalid Scan. Invalid last timestamp."
msgstr "Invalider Scan. Letzter Zeitstempel ungültig."
-#: c3nav/routing/models.py:21 c3nav/routing/models.py:22
+#: c3nav/routing/models.py:20 c3nav/routing/models.py:21
+#: c3nav/site/templates/site/fragment_report_meta.html:17
#: c3nav/site/templates/site/map.html:177
msgid "Route options"
msgstr "Routenoptionen"
-#: c3nav/routing/models.py:33
+#: c3nav/routing/models.py:32
msgid "Routing mode"
msgstr "Routemodus"
-#: c3nav/routing/models.py:34
+#: c3nav/routing/models.py:33
msgid "fastest"
msgstr "schnellste"
-#: c3nav/routing/models.py:34
+#: c3nav/routing/models.py:33
msgid "shortest"
msgstr "kürzeste"
-#: c3nav/routing/models.py:38
+#: c3nav/routing/models.py:37
msgid "Walk speed"
msgstr "Gehgeschwindigkeit"
-#: c3nav/routing/models.py:39
+#: c3nav/routing/models.py:38
msgid "slow"
msgstr "langsam"
-#: c3nav/routing/models.py:39
+#: c3nav/routing/models.py:38
msgid "default"
msgstr "standard"
-#: c3nav/routing/models.py:39
+#: c3nav/routing/models.py:38
msgid "fast"
msgstr "schnell"
-#: c3nav/routing/models.py:45
+#: c3nav/routing/models.py:44
msgid "allow"
msgstr "erlaubt"
-#: c3nav/routing/models.py:47
+#: c3nav/routing/models.py:46
msgid "avoid upwards"
msgstr "aufwärts vermeiden"
-#: c3nav/routing/models.py:48
+#: c3nav/routing/models.py:47
msgid "avoid downwards"
msgstr "abwärts vermeiden"
-#: c3nav/routing/models.py:49
+#: c3nav/routing/models.py:48
msgid "avoid completely"
msgstr "komplett vermeiden"
-#: c3nav/routing/models.py:51
+#: c3nav/routing/models.py:50
msgid "avoid"
msgstr "vermeiden"
-#: c3nav/routing/models.py:144
+#: c3nav/routing/models.py:143
#, python-format
msgid "Unknown route option: %s"
msgstr "Unbekannte Routenoption: %s"
-#: c3nav/routing/models.py:148
+#: c3nav/routing/models.py:147
#, python-format
msgid "Invalid value for route option %s."
msgstr "Invalider Wert für Routenoption %s."
@@ -2658,6 +2762,26 @@ msgstr "Du bist angemeldet als %(username)s."
msgid "You can access the control panel."
msgstr "Du kannst das Control Panel betreten."
+#: c3nav/site/templates/site/fragment_report_meta.html:3
+msgid "You are reporting an issue with the following location:"
+msgstr "Du meldest einen Fehler beim folgenden Ort:"
+
+#: c3nav/site/templates/site/fragment_report_meta.html:6
+msgid "You are reporting an missing location at the following position:"
+msgstr "Du meldest einen fehlenden Ort an der folgenden Stelle."
+
+#: c3nav/site/templates/site/fragment_report_meta.html:9
+msgid "You are reporting an issue with the following route:"
+msgstr "Du meldest einen Fehler bei der folgenden Route:"
+
+#: c3nav/site/templates/site/fragment_report_meta.html:11
+msgid "Origin"
+msgstr "Start"
+
+#: c3nav/site/templates/site/fragment_report_meta.html:14
+msgid "Destination"
+msgstr "Ziel"
+
#: c3nav/site/templates/site/language.html:7
msgid "Pick your language"
msgstr "Sprache wählen"
@@ -2697,11 +2821,11 @@ msgstr "Route von hier aus"
#: c3nav/site/templates/site/map.html:52 c3nav/site/templates/site/map.html:149
#: c3nav/site/templates/site/map.html:170
+#: c3nav/site/templates/site/report_create.html:6
msgid "Report issue"
msgstr "Fehler melden"
#: c3nav/site/templates/site/map.html:56 c3nav/site/templates/site/map.html:153
-#| msgid "Select this location"
msgid "Report missing location"
msgstr "Fehlenden Ort melden"
@@ -2770,39 +2894,70 @@ msgstr ""
msgid "open in c3nav"
msgstr "in c3nav öffnen"
-#: c3nav/site/templates/site/report.html:6
-msgid "Coming soon"
-msgstr "Coming soon"
+#: c3nav/site/templates/site/report_create.html:13
+msgid "Submit"
+msgstr "Absenden"
-#: c3nav/site/views.py:71 c3nav/site/views.py:318
+#: c3nav/site/templates/site/report_detail.html:11
+msgid "closed"
+msgstr "geschlossen"
+
+#: c3nav/site/templates/site/report_detail.html:15
+msgid "anonymous submission"
+msgstr "anonyme Meldung"
+
+#: c3nav/site/templates/site/report_detail.html:17
+msgid "by"
+msgstr "von"
+
+#: c3nav/site/templates/site/report_detail.html:35
+msgid "(none)"
+msgstr "(keine)"
+
+#: c3nav/site/views.py:76 c3nav/site/views.py:323
msgid "You need to log in to unlock areas."
msgstr "Du musst dich anmelden um Bereiche freizuschalten."
-#: c3nav/site/views.py:206
+#: c3nav/site/views.py:211
msgid "Areas could not be unlocked because the token has expired."
msgstr ""
"Zugangserlaubnis konnte nicht gewährt werden weil der Code abgelaufen ist."
-#: c3nav/site/views.py:249
+#: c3nav/site/views.py:254
msgid "account creation is currently disabled."
msgstr "Benutzerregistrierung ist momentan deaktiviert."
-#: c3nav/site/views.py:311
+#: c3nav/site/views.py:316
msgid "This token does not exist or was already redeemed."
msgstr "Dieser Code existiert nicht oder wurde bereits eingelöst."
-#: c3nav/site/views.py:331
+#: c3nav/site/views.py:336
msgid "Unlock area"
msgid_plural "Unlock areas"
msgstr[0] "Bereich freischalten"
msgstr[1] "Bereiche freischalten"
-#: c3nav/site/views.py:332
+#: c3nav/site/views.py:337
msgid "You have been invited to unlock the following area:"
msgid_plural "You have been invited to unlock the following areas:"
msgstr[0] "Du wurdest eingeladen, den folgenden Bereich freizuschalten:"
msgstr[1] "Du wurdest eingeladen, die folgenden Bereiche freizuschalten:"
+#: c3nav/site/views.py:408
+msgid "Your report was submitted."
+msgstr "Deine Meldiung wurde abgesendet."
+
+#: c3nav/site/views.py:411
+msgid "You can keep track of it from your user dashboard."
+msgstr "Du kannst sie in deinerm Benutzerdashboard verfolgen."
+
+#: c3nav/site/views.py:413
+msgid "You can keep track of it by revisiting the public URL mentioned below."
+msgstr "Du kannst sie mit dem unten angegebenen öffentlichen link verfolgen."
+
+#~ msgid "Coming soon"
+#~ msgstr "Coming soon"
+
#~ msgid "You can not edit this object."
#~ msgstr "Du kannst dieses Objekt nicht bearbeiten."
diff --git a/src/c3nav/mapdata/forms.py b/src/c3nav/mapdata/forms.py
index c58d20c5..67713873 100644
--- a/src/c3nav/mapdata/forms.py
+++ b/src/c3nav/mapdata/forms.py
@@ -41,7 +41,8 @@ class I18nModelFormMixin(ModelForm):
new_fields[sub_field_name] = CharField(label=field_title,
required=False,
initial=values[language].strip(),
- max_length=model_field.i18n_max_length)
+ max_length=model_field.i18n_max_length,
+ help_text=form_field.help_text)
if has_values:
self.i18n_fields.append((model_field, values))
diff --git a/src/c3nav/mapdata/migrations/0078_reports.py b/src/c3nav/mapdata/migrations/0078_reports.py
new file mode 100644
index 00000000..facab48b
--- /dev/null
+++ b/src/c3nav/mapdata/migrations/0078_reports.py
@@ -0,0 +1,84 @@
+# Generated by Django 2.2.8 on 2019-12-24 15:52
+
+import c3nav.mapdata.fields
+import c3nav.mapdata.models.report
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('mapdata', '0077_obstacle_altitude'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Report',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
+ ('category', models.CharField(choices=[('location-issue', 'location issue'), ('missing-location', 'missing location'), ('route-issue', 'route issue')], db_index=True, max_length=20, verbose_name='category')),
+ ('open', models.BooleanField(default=True, verbose_name='open')),
+ ('last_update', models.DateTimeField(auto_now=True, verbose_name='last_update')),
+ ('title', models.CharField(default='', help_text='a short title for your report', max_length=100, verbose_name='title')),
+ ('description', models.TextField(default='', help_text="tell us precisely what's wrong", max_length=1000, verbose_name='description')),
+ ('coordinates_id', models.CharField(max_length=48, null=True, verbose_name='coordinates')),
+ ('origin_id', models.CharField(max_length=48, null=True, verbose_name='origin')),
+ ('destination_id', models.CharField(max_length=48, null=True, verbose_name='destination')),
+ ('route_options', models.CharField(max_length=128, null=True, verbose_name='route options')),
+ ('created_title', c3nav.mapdata.fields.I18nField(fallback_any=True, help_text='you have to supply a title in at least one language', plural_name='titles', verbose_name='new location title')),
+ ('secret', models.CharField(default=c3nav.mapdata.models.report.get_report_secret, max_length=32, verbose_name='secret')),
+ ('assigned_to', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='assigned_reports', to=settings.AUTH_USER_MODEL, verbose_name='assigned to')),
+ ('author', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='report', to=settings.AUTH_USER_MODEL, verbose_name='author')),
+ ],
+ options={
+ 'verbose_name': 'Report',
+ 'verbose_name_plural': 'Reports',
+ 'default_related_name': 'report',
+ },
+ ),
+ migrations.AlterModelOptions(
+ name='lineobstacle',
+ options={'default_related_name': 'lineobstacles', 'ordering': ('altitude', 'height'), 'verbose_name': 'Line Obstacle', 'verbose_name_plural': 'Line Obstacles'},
+ ),
+ migrations.AlterModelOptions(
+ name='obstacle',
+ options={'default_related_name': 'obstacles', 'ordering': ('altitude', 'height'), 'verbose_name': 'Obstacle', 'verbose_name_plural': 'Obstacles'},
+ ),
+ migrations.AddField(
+ model_name='locationgroup',
+ name='can_report_missing',
+ field=models.BooleanField(default=False, help_text='can be used when reporting a missing location', verbose_name='for missing locations'),
+ ),
+ migrations.CreateModel(
+ name='ReportUpdate',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('datetime', models.DateTimeField(auto_now_add=True, verbose_name='datetime')),
+ ('open', models.NullBooleanField(verbose_name='open')),
+ ('comment', models.TextField(verbose_name='comment')),
+ ('public', models.BooleanField(verbose_name='public')),
+ ('assigned_to', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='report_update_assigns', to=settings.AUTH_USER_MODEL, verbose_name='assigned to')),
+ ('author', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='reportupdate', to=settings.AUTH_USER_MODEL, verbose_name='author')),
+ ('report', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reportupdate', to='mapdata.Report')),
+ ],
+ options={
+ 'verbose_name': 'Report update',
+ 'verbose_name_plural': 'Report updates',
+ 'default_related_name': 'reportupdate',
+ },
+ ),
+ migrations.AddField(
+ model_name='report',
+ name='created_groups',
+ field=models.ManyToManyField(blank=True, help_text='select all groups that apply, if any', limit_choices_to={'can_report_missing': True}, related_name='report', to='mapdata.LocationGroup', verbose_name='location groups'),
+ ),
+ migrations.AddField(
+ model_name='report',
+ name='location',
+ field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reports', to='mapdata.LocationSlug', verbose_name='location'),
+ ),
+ ]
diff --git a/src/c3nav/mapdata/models/locations.py b/src/c3nav/mapdata/models/locations.py
index 1dbc74c4..2480e099 100644
--- a/src/c3nav/mapdata/models/locations.py
+++ b/src/c3nav/mapdata/models/locations.py
@@ -312,6 +312,8 @@ class LocationGroup(Location, models.Model):
label_settings = models.ForeignKey('mapdata.LabelSettings', null=True, blank=True, on_delete=models.PROTECT,
verbose_name=_('label settings'),
help_text=_('unless location specifies otherwise'))
+ can_report_missing = models.BooleanField(default=False, verbose_name=_('for missing locations'),
+ help_text=_('can be used when reporting a missing location'))
color = models.CharField(null=True, blank=True, max_length=32, verbose_name=_('background color'))
objects = LocationGroupManager()
diff --git a/src/c3nav/mapdata/models/report.py b/src/c3nav/mapdata/models/report.py
new file mode 100644
index 00000000..0ceeed71
--- /dev/null
+++ b/src/c3nav/mapdata/models/report.py
@@ -0,0 +1,119 @@
+import string
+
+from django.conf import settings
+from django.core.exceptions import ObjectDoesNotExist
+from django.db import models
+from django.utils.crypto import get_random_string
+from django.utils.translation import ugettext_lazy as _
+
+from c3nav.mapdata.fields import I18nField
+from c3nav.mapdata.utils.locations import get_location_by_id_for_request
+
+
+def get_report_secret():
+ return get_random_string(32, string.ascii_letters)
+
+
+class LocationById():
+ def __init__(self):
+ super().__init__()
+ self.name = None
+ self.cached_id = None
+ self.cached_value = None
+
+ def __set_name__(self, owner, name):
+ self.name = name
+
+ def __get__(self, instance, owner=None):
+ value_id = getattr(instance, self.name+'_id')
+ if value_id is None:
+ self.cached_pk = None
+ self.cached_value = None
+ return None
+
+ if value_id == self.cached_id:
+ return self.cached_value
+
+ value = get_location_by_id_for_request(value_id, getattr(instance, 'request', None))
+ if value is None:
+ raise ObjectDoesNotExist
+ self.cached_id = value_id
+ self.cached_value = value
+ return value
+
+ def __set__(self, instance, value):
+ self.cached_id = value.pk
+ self.cached_value = value
+ setattr(instance, self.name+'_id', value.pk)
+
+
+class Report(models.Model):
+ CATEGORIES = (
+ ('location-issue', _('location issue')),
+ ('missing-location', _('missing location')),
+ ('route-issue', _('route issue')),
+ )
+ created = models.DateTimeField(auto_now_add=True, verbose_name=_('created'))
+ category = models.CharField(max_length=20, db_index=True, choices=CATEGORIES, verbose_name=_('category'))
+ author = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.PROTECT, verbose_name=_('author'))
+ open = models.BooleanField(default=True, verbose_name=_('open'))
+ last_update = models.DateTimeField(auto_now=True, verbose_name=_('last_update'))
+ title = models.CharField(max_length=100, default='', verbose_name=_('title'),
+ help_text=_('a short title for your report'))
+ description = models.TextField(max_length=1000, default='', verbose_name=_('description'),
+ help_text=_('tell us precisely what\'s wrong'))
+ assigned_to = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.PROTECT,
+ related_name='assigned_reports', verbose_name=_('assigned to'))
+ location = models.ForeignKey('mapdata.LocationSlug', null=True, on_delete=models.SET_NULL,
+ related_name='reports', verbose_name=_('location'))
+ coordinates_id = models.CharField(_('coordinates'), null=True, max_length=48)
+ origin_id = models.CharField(_('origin'), null=True, max_length=48)
+ destination_id = models.CharField(_('destination'), null=True, max_length=48)
+ route_options = models.CharField(_('route options'), null=True, max_length=128)
+
+ created_title = I18nField(_('new location title'), plural_name='titles', blank=False, fallback_any=True,
+ help_text=_('you have to supply a title in at least one language'))
+ created_groups = models.ManyToManyField('mapdata.LocationGroup', verbose_name=_('location groups'), blank=True,
+ limit_choices_to={'can_report_missing': True},
+ help_text=_('select all groups that apply, if any'))
+ secret = models.CharField(_('secret'), max_length=32, default=get_report_secret)
+
+ coordinates = LocationById()
+ origin = LocationById()
+ destination = LocationById()
+
+ class Meta:
+ verbose_name = _('Report')
+ verbose_name_plural = _('Reports')
+ default_related_name = 'report'
+
+ @property
+ def form_cls(self):
+ from c3nav.site.forms import ReportMissingLocationForm, ReportIssueForm
+ return ReportMissingLocationForm if self.category == 'missing-location' else ReportIssueForm
+
+ @classmethod
+ def qs_for_request(cls, request):
+ if request.user.is_superuser:
+ # todo: permissions!
+ return cls.objects.all()
+ elif request.user.is_authenticated:
+ return cls.objects.filter(author=request.user)
+ else:
+ return cls.objects.none()
+
+
+class ReportUpdate(models.Model):
+ report = models.ForeignKey(Report, on_delete=models.CASCADE)
+ datetime = models.DateTimeField(auto_now_add=True, verbose_name=_('datetime'))
+ author = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.PROTECT, verbose_name=_('author'))
+ open = models.NullBooleanField(verbose_name=_('open'))
+ comment = models.TextField(verbose_name=_('comment'))
+ assigned_to = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.PROTECT,
+ related_name='report_update_assigns', verbose_name=_('assigned to'))
+ public = models.BooleanField(verbose_name=_('public'))
+
+ class Meta:
+ verbose_name = _('Report update')
+ verbose_name_plural = _('Report updates')
+ default_related_name = 'reportupdate'
diff --git a/src/c3nav/mapdata/utils/locations.py b/src/c3nav/mapdata/utils/locations.py
index 658e56be..7eb3ad24 100644
--- a/src/c3nav/mapdata/utils/locations.py
+++ b/src/c3nav/mapdata/utils/locations.py
@@ -418,3 +418,6 @@ class CustomLocation:
@cached_property
def subtitle(self):
return self.title_subtitle[1]
+
+ def get_icon(self):
+ return self.icon
diff --git a/src/c3nav/routing/models.py b/src/c3nav/routing/models.py
index 65bea5da..652a4f4f 100644
--- a/src/c3nav/routing/models.py
+++ b/src/c3nav/routing/models.py
@@ -174,6 +174,7 @@ class RouteOptions(models.Model):
for choice_name, choice_title in field.choices
],
'value': self[name],
+ 'value_display': dict(field.choices)[self[name]],
}
for name, field in self.get_fields().items()
]
@@ -181,6 +182,12 @@ class RouteOptions(models.Model):
def serialize_string(self):
return ','.join('%s=%s' % (key, val) for key, val in self.data.items())
+ @classmethod
+ def unserialize_string(cls, data):
+ return RouteOptions(
+ data=dict(item.split('=') for item in data.split(','))
+ )
+
def save(self, *args, **kwargs):
if self.request is None or self.request.user.is_authenticated:
self.user = self.request.user
diff --git a/src/c3nav/site/forms.py b/src/c3nav/site/forms.py
new file mode 100644
index 00000000..b130827b
--- /dev/null
+++ b/src/c3nav/site/forms.py
@@ -0,0 +1,16 @@
+from django.forms import ModelForm
+
+from c3nav.mapdata.forms import I18nModelFormMixin
+from c3nav.mapdata.models.report import Report
+
+
+class ReportIssueForm(I18nModelFormMixin, ModelForm):
+ class Meta:
+ model = Report
+ fields = ['title', 'description']
+
+
+class ReportMissingLocationForm(I18nModelFormMixin, ModelForm):
+ class Meta:
+ model = Report
+ fields = ['title', 'description', 'created_title', 'created_groups']
diff --git a/src/c3nav/site/static/site/css/c3nav.scss b/src/c3nav/site/static/site/css/c3nav.scss
index b481f79e..9d669e97 100644
--- a/src/c3nav/site/static/site/css/c3nav.scss
+++ b/src/c3nav/site/static/site/css/c3nav.scss
@@ -547,6 +547,9 @@ main.show-options #resultswrapper #route-options {
padding: 5px 10px 5px 53px;
height: 55px;
}
+.location.location-form-value {
+ margin: -10px -10px 5px -10px;
+}
.location .icon {
font-size: 36px;
position: absolute;
@@ -1256,6 +1259,25 @@ main .narrow p, main .narrow form, main .narrow button {
main .narrow form button {
width: 100%;
}
+main form > p, #modal form > p {
+ margin-bottom: 15px;
+ > :last-child {
+ margin-bottom: 0;
+ }
+ .helptext {
+ display: block;
+ margin-top: -15px;
+ font-style: italic;
+ color: #999999;
+ }
+ textarea {
+ resize: none;
+ height: 100px;
+ }
+ select {
+ height: 100px;
+ }
+}
.user-permissions-form label {
font-weight: 400;
diff --git a/src/c3nav/site/templates/site/account_form.html b/src/c3nav/site/templates/site/account_form.html
index 23264994..4f69c273 100644
--- a/src/c3nav/site/templates/site/account_form.html
+++ b/src/c3nav/site/templates/site/account_form.html
@@ -14,7 +14,7 @@
+{% endif %}
diff --git a/src/c3nav/site/templates/site/report.html b/src/c3nav/site/templates/site/report.html
deleted file mode 100644
index 53e02dd0..00000000
--- a/src/c3nav/site/templates/site/report.html
+++ /dev/null
@@ -1,10 +0,0 @@
-{% extends 'site/base.html' %}
-{% load i18n %}
-
-{% block content %}
-
- {% trans 'Coming soon' %}
-
- {% include 'site/fragment_messages.html' %}
-
-{% endblock %}
diff --git a/src/c3nav/site/templates/site/report_create.html b/src/c3nav/site/templates/site/report_create.html
new file mode 100644
index 00000000..1a9dd7af
--- /dev/null
+++ b/src/c3nav/site/templates/site/report_create.html
@@ -0,0 +1,16 @@
+{% extends 'site/base.html' %}
+{% load i18n %}
+
+{% block content %}
+
+ {% trans 'Report issue' %}
+ {% include 'site/fragment_messages.html' %}
+
+
+
+{% endblock %}
diff --git a/src/c3nav/site/templates/site/report_detail.html b/src/c3nav/site/templates/site/report_detail.html
new file mode 100644
index 00000000..3e726a0f
--- /dev/null
+++ b/src/c3nav/site/templates/site/report_detail.html
@@ -0,0 +1,44 @@
+{% extends 'site/base.html' %}
+{% load i18n %}
+
+{% block content %}
+
+ {% trans 'Report' %}: {{ report.title }}
+
+ {% if report.open %}
+ {% trans 'open' %}
+ {% else %}
+ {% trans 'closed' %}
+ {% endif %}
+ –
+ {% if report.author %}
+ {% trans 'anonymous submission' %}
+ {% else %}
+ {% trans 'by' %} {{ request.author.username }}
+ {% endif %}
+ –
+ {{ report.created }}
+
+ {% include 'site/fragment_messages.html' %}
+ {% include 'site/fragment_report_meta.html' %}
+
+ {% for field in form %}
+ {% if field.name != 'title' %}
+
+ {{ field.label }}:
+ {% if field.name == 'description' %}
+ {{ report.description | linebreaksbr }}
+ {% elif field.name == 'created_groups' %}
+ {% for group in report.created_groups.all %}
+ {{ group.title }}
+ {% empty %}
+ {% trans '(none)' %}
+ {% endfor %}
+ {% else %}
+ {{ field.value }}
+ {% endif %}
+
+ {% endif %}
+ {% endfor %}
+
+{% endblock %}
diff --git a/src/c3nav/site/urls.py b/src/c3nav/site/urls.py
index 67ea455d..529bb36e 100644
--- a/src/c3nav/site/urls.py
+++ b/src/c3nav/site/urls.py
@@ -1,7 +1,7 @@
from django.conf.urls import url
from c3nav.site.views import (about_view, access_redeem_view, account_view, change_password_view, choose_language,
- login_view, logout_view, map_index, qr_code, register_view, report_view)
+ login_view, logout_view, map_index, qr_code, register_view, report_create, report_detail)
slug = r'(?P[a-z0-9-_.:]+)'
coordinates = r'(?P[a-z0-9-_:]+:-?\d+(\.\d+)?:-?\d+(\.\d+)?)'
@@ -27,7 +27,10 @@ urlpatterns = [
url(r'^lang/$', choose_language, name='site.language'),
url(r'^about/$', about_view, name='site.about'),
url(r'^report/$', about_view, name='site.about'),
- url(r'^report/l/%s/$' % coordinates, report_view, name='site.report'),
- url(r'^report/l/(?P\d+)/$', report_view, name='site.report'),
- url(r'^report/r/(?P[^/]+)/(?P[^/]+)/(?P[^/]+)/$', report_view, name='site.report'),
+ url(r'^report/(?P\d+)/$', report_detail, name='site.report_detail'),
+ url(r'^report/(?P\d+)/(?P[^/]+)/$', report_detail, name='site.report_detail'),
+ url(r'^report/l/%s/$' % coordinates, report_create, name='site.report_create'),
+ url(r'^report/l/(?P\d+)/$', report_create, name='site.report_create'),
+ url(r'^report/r/(?P[^/]+)/(?P[^/]+)/(?P[^/]+)/$',
+ report_create, name='site.report_create'),
]
diff --git a/src/c3nav/site/views.py b/src/c3nav/site/views.py
index 51b04e76..4fbfacda 100644
--- a/src/c3nav/site/views.py
+++ b/src/c3nav/site/views.py
@@ -9,11 +9,12 @@ from django.contrib.auth import login, logout
from django.contrib.auth.decorators import login_required
from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm, UserCreationForm
from django.contrib.auth.views import redirect_to_login
+from django.core.exceptions import ObjectDoesNotExist, SuspiciousOperation
from django.core.serializers.json import DjangoJSONEncoder
from django.db import transaction
-from django.http import HttpResponse, HttpResponseBadRequest
+from django.http import Http404, HttpResponse, HttpResponseBadRequest
from django.middleware import csrf
-from django.shortcuts import redirect, render
+from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _
@@ -27,9 +28,12 @@ from c3nav.mapdata.grid import grid
from c3nav.mapdata.models import Location, Source
from c3nav.mapdata.models.access import AccessPermissionToken
from c3nav.mapdata.models.locations import LocationRedirect, SpecificLocation
-from c3nav.mapdata.utils.locations import get_location_by_slug_for_request, levels_by_short_label_for_request
+from c3nav.mapdata.models.report import Report
+from c3nav.mapdata.utils.locations import (get_location_by_id_for_request, get_location_by_slug_for_request,
+ levels_by_short_label_for_request)
from c3nav.mapdata.utils.user import can_access_editor, get_user_data
from c3nav.mapdata.views import set_tile_access_cookie
+from c3nav.routing.models import RouteOptions
from c3nav.site.models import Announcement, SiteUpdate
@@ -350,6 +354,86 @@ def about_view(request):
})
+def get_report_location_for_request(pk, request):
+ location = get_location_by_id_for_request(pk, request)
+ if location is None:
+ raise Http404
+ return location
+
+
@never_cache
-def report_view(request, coordinates=None, location=None, origin=None, destination=None, options=None):
- return render(request, 'site/report.html', {})
+def report_create(request, coordinates=None, location=None, origin=None, destination=None, options=None):
+ report = Report()
+ report.request = request
+
+ if coordinates:
+ report.category = 'missing-location'
+ report.coordinates_id = coordinates
+ try:
+ report.coordinates
+ except ObjectDoesNotExist:
+ raise Http404
+ elif location:
+ report.category = 'location-issue'
+ report.location = get_report_location_for_request(location, request)
+ if report.location is None:
+ raise Http404
+ report.location = location
+ elif origin:
+ report.category = 'route-issue'
+ report.origin_id = origin
+ report.destination_id = destination
+ try:
+ # noinspection PyStatementEffect
+ report.origin
+ # noinspection PyStatementEffect
+ report.destination
+ except ObjectDoesNotExist:
+ raise Http404
+ try:
+ options = RouteOptions.unserialize_string(options)
+ except Exception:
+ raise SuspiciousOperation
+ report.options = options.serialize_string()
+
+ if request.method == 'POST':
+ form = report.form_cls(instance=report, data=request.POST)
+ if form.is_valid():
+ report = form.instance
+ if request.user.is_authenticated:
+ report.author = request.user
+ report.save()
+
+ success_messages = [_('Your report was submitted.')]
+ success_kwargs = {'pk': report.pk}
+ if request.user.is_authenticated:
+ success_messages.append(_('You can keep track of it from your user dashboard.'))
+ else:
+ success_messages.append(_('You can keep track of it by revisiting the public URL mentioned below.'))
+ success_kwargs = {'secret': report.secret}
+ messages.success(request, ' '.join(str(s) for s in success_messages))
+ return redirect(reverse('site.report_detail', kwargs=success_kwargs))
+ else:
+ form = report.form_cls(instance=report)
+
+ return render(request, 'site/report_create.html', {
+ 'report': report,
+ 'options': options,
+ 'form': form,
+ })
+
+
+def report_detail(request, pk, secret=None):
+ if secret:
+ qs = Report.objects.filter(secret=secret)
+ else:
+ qs = Report.qs_for_request(request)
+ report = get_object_or_404(qs, pk=pk)
+ report.request = request
+
+ form = report.form_cls(instance=report)
+
+ return render(request, 'site/report_detail.html', {
+ 'report': report,
+ 'form': form,
+ })