diff --git a/lostplaces/lostplaces_app/templates/partials/form/inputField.html b/lostplaces/lostplaces_app/templates/partials/form/inputField.html
index 1d141c2..8447c67 100644
--- a/lostplaces/lostplaces_app/templates/partials/form/inputField.html
+++ b/lostplaces/lostplaces_app/templates/partials/form/inputField.html
@@ -1,16 +1,18 @@
{% load widget_tweaks %}
-
+
{{field.label}}
- {% render_field field class="LP-Input__Field"%}
+ {% with class="LP-Input__Field "%}
+ {% render_field field class=class%}
+ {% endwith %}
- {% if field.errors %}
- {% for error in field.errors%}
- {{error}}
- {% endfor %}
+ {% if field.errors %}
+ {% for error in field.errors%}
+ {{error}}
+ {% endfor %}
{% elif field.help_text%}
- {{ field.help_text }}
+ {{ field.help_text }}
{% endif %}
\ No newline at end of file
diff --git a/lostplaces/lostplaces_app/templates/partials/tagging.html b/lostplaces/lostplaces_app/templates/partials/tagging.html
new file mode 100644
index 0000000..581055b
--- /dev/null
+++ b/lostplaces/lostplaces_app/templates/partials/tagging.html
@@ -0,0 +1,66 @@
+
+
+ {% for tag in tag_list %}
+
+
+
+ {{tag}}
+
+ {% if request.user and request.user == config.tagged_item.submitted_by %}
+
+
+
+ {% endif %}
+
+
+ {% endfor %}
+
+
+
+
+
+
\ No newline at end of file
diff --git a/lostplaces/lostplaces_app/templates/place/place_detail.html b/lostplaces/lostplaces_app/templates/place/place_detail.html
index 160b2fd..452b38e 100644
--- a/lostplaces/lostplaces_app/templates/place/place_detail.html
+++ b/lostplaces/lostplaces_app/templates/place/place_detail.html
@@ -6,102 +6,103 @@
{% block additional_head %}
+
+
+
+
+
{% endblock additional_head %}
{% block title %}{{place.name}}{% endblock %}
{% block additional_menu_items %}
-
-
+
+
{% endblock additional_menu_items %}
{% block maincontent %}
-
+
-
-
{{ place.description }}
-
+
+
{{ place.description }}
+
-
- Map-Links
- {% include 'partials/osm_map.html' %}
-
-
+
-
-
- Bilder
-
-
- {% for place_image in place.images.all %}
-
-
-
- {% endfor %}
-
-
-
+
+ Bilder
+
+
+ {% for place_image in place.images.all %}
+
+
+
+ {% endfor %}
+
+
+
{% endblock maincontent %}
\ No newline at end of file
diff --git a/lostplaces/lostplaces_app/tests.py b/lostplaces/lostplaces_app/tests.py
deleted file mode 100644
index 1a11e63..0000000
--- a/lostplaces/lostplaces_app/tests.py
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-
-''' Tests for the lostplaces_app. '''
-
-rom django.test import TestCase
-
-# Create your tests here.
diff --git a/lostplaces/lostplaces_app/tests/__init__.py b/lostplaces/lostplaces_app/tests/__init__.py
new file mode 100644
index 0000000..d23299f
--- /dev/null
+++ b/lostplaces/lostplaces_app/tests/__init__.py
@@ -0,0 +1,13 @@
+from django.db import models as django_models
+from lostplaces_app.models import Explorer
+
+
+def mock_user():
+ explorer_list = Explorer.objects.all()
+ if len(explorer_list) <= 0:
+ return Explorer.objects.create_user(
+ username='testpeter',
+ password='Develop123'
+ )
+ else:
+ return explorer_list[0]
\ No newline at end of file
diff --git a/lostplaces/lostplaces_app/tests/models/__init__.py b/lostplaces/lostplaces_app/tests/models/__init__.py
new file mode 100644
index 0000000..7538d94
--- /dev/null
+++ b/lostplaces/lostplaces_app/tests/models/__init__.py
@@ -0,0 +1,121 @@
+from django.db import models
+
+class TestModel:
+ '''
+ Base class for Lostplaces models
+ '''
+ model_name = None
+
+ def _test_field(self, field_name, field_class):
+ '''
+ Tests if a field exists under the given name and
+ if the field is of the right type
+ '''
+ field = self.object._meta.get_field(field_name)
+ self.assertTrue(
+ field,
+ msg="%s has no field named '%s'" % (
+ self.model_name,
+ field_name
+ )
+ )
+ self.assertEqual(
+ type(field), field_class,
+ msg='%s.%s name field is no CharField' % (
+ self.model_name,
+ field_name
+ )
+ )
+ return field
+
+
+ def _test_char_field(self, field_name, min_length, max_length):
+ '''
+ Tests if the given field is a char field and if its max_length
+ is in min_length and max_legth
+ '''
+ field = self._test_field(field_name, models.CharField)
+ self.assertEqual(
+ type(field), models.CharField,
+ msg='%s.%s name field is no CharField' % (
+ self.model_name,
+ field_name
+ )
+ )
+ self.assertTrue(
+ field.max_length in range(min_length, max_length),
+ msg='%s.%s field max_length not in range of %d and %d' % (
+ self.model_name,
+ field_name,
+ min_length,
+ max_length
+ )
+ )
+
+ def _test_float_field(self, field_name, min_value=None, max_value=None):
+ '''
+ Tests if the field is a floatfield. If min_value and/or max_value are passed,
+ the validators of the field are also checked. The validator list of the field should
+ look like
+ [MinValueValidator, MayValueValidator], if both values are passed,
+ [MinValueValidator] if only min_value is passed,
+ [MaxValueValidator] if only max_value is passed
+ '''
+ field = self._test_field(field_name, models.FloatField)
+ if min_value:
+ self.assertTrue(
+ len(field.validators) >= 1,
+ msg='%s.%s first validator should check minimum' % (
+ self.model_name,
+ field_name
+ )
+ )
+ self.assertEqual(
+ field.validators[0].limit_value,
+ min_value,
+ msg='%s.%s min value missmatch' % (
+ self.model_name,
+ field_name
+ )
+ )
+ if max_value:
+ index = 0
+ if min_value:
+ index += 1
+ self.assertTrue(
+ len(field.validators) >= index+1,
+ msg='%s.%s second validator should check maximum' % (
+ self.model_name,
+ field_name
+ )
+ )
+ self.assertEqual(
+ field.validators[1].limit_value,
+ max_value,
+ msg='%s.%s max value missmatch' % (
+ self.model_name,
+ field_name
+ )
+ )
+
+class TestSubmittable(TestModel):
+ model_name='
'
+ related_name = None
+ nullable = False
+
+ def test_submitted_when(self):
+ submitted_when = self._test_field('submitted_when', models.DateTimeField)
+ self.assertTrue(submitted_when.auto_now_add,
+ msg='%s.submitted_when should be auto_now_add' % (
+ self.model_name
+ )
+ )
+
+ def test_submitted_by(self):
+ submitted_by = self._test_field('submitted_by',models.ForeignKey)
+ if self.related_name:
+ self.assertEqual(submitted_by.remote_field.related_name, self.related_name)
+ if self.nullable:
+ self.assertTrue(submitted_by.null,)
+ self.assertTrue(submitted_by.blank)
+ self.assertEqual(submitted_by.remote_field.on_delete, models.SET_NULL)
diff --git a/lostplaces/lostplaces_app/tests/models/test_place_image_model.py b/lostplaces/lostplaces_app/tests/models/test_place_image_model.py
new file mode 100644
index 0000000..bd1bf41
--- /dev/null
+++ b/lostplaces/lostplaces_app/tests/models/test_place_image_model.py
@@ -0,0 +1,55 @@
+import datetime
+from unittest import mock
+
+from django.test import TestCase
+from django.db import models
+from django.core.files import File
+
+from lostplaces_app.models import PlaceImage
+from lostplaces_app.tests.models import TestSubmittable
+from lostplaces_app.tests import mock_user
+from lostplaces_app.tests.models import TestModel
+from lostplaces_app.tests.models.test_place_model import mock_place
+
+from easy_thumbnails.fields import ThumbnailerImageField
+
+def mock_place_image():
+ return PlaceImage(
+ description='Im a description',
+ filename=mock.MagicMock(spec=File, name='FileMock'),
+ place=mock_place(),
+ submitted_when=datetime.datetime.now(),
+ submitted_by=mock_user()
+ )
+
+class TestPlaceImage(TestSubmittable, TestCase):
+ model_name = 'PlaceImage'
+
+ def setUp(self):
+ self.object = mock_place_image()
+
+ def test_description(self):
+ self._test_field('description', models.TextField)
+
+ def test_filename(self):
+ self._test_field('filename',ThumbnailerImageField)
+
+ def test_place(self):
+ field = self._test_field('place', models.ForeignKey)
+ self.assertEqual(field.remote_field.on_delete, models.CASCADE,
+ msg='%s.%s deleting of %s should be cascadinf' % (
+ self.model_name,
+ 'place',
+ self.model_name
+ )
+ )
+ self.assertEqual(field.remote_field.related_name, 'images',
+ msg='%s.%s related name should be images' % (
+ self.model_name,
+ 'place'
+ )
+ )
+
+ def test_str(self):
+ place_image = mock_place_image()
+ self.assertEqual(str(place_image), ' '.join([place_image.place.name, str(place_image.pk)]))
diff --git a/lostplaces/lostplaces_app/tests/models/test_place_model.py b/lostplaces/lostplaces_app/tests/models/test_place_model.py
new file mode 100644
index 0000000..7213266
--- /dev/null
+++ b/lostplaces/lostplaces_app/tests/models/test_place_model.py
@@ -0,0 +1,136 @@
+import datetime
+
+from django.test import TestCase
+from django.db import models
+
+from lostplaces_app.models import Place
+from lostplaces_app.tests.models import TestSubmittable
+from lostplaces_app.tests import mock_user
+from lostplaces_app.tests.models import TestModel
+
+from taggit.managers import TaggableManager
+
+def mock_place():
+ place = Place.objects.create(
+ name='Im a place',
+ submitted_when=datetime.datetime.now(),
+ submitted_by=mock_user(),
+ location='Testtown',
+ latitude=50.5,
+ longitude=7.0,
+ description='This is just a test, do not worry'
+ )
+ place.tags.add('I a tag', 'testlocation')
+
+ return place
+
+class PlaceTestCase(TestSubmittable, TestCase):
+ model_name = 'Place'
+ related_name = 'places'
+ nullable = True
+
+ def setUp(self):
+ self.place = mock_place()
+ self.object = self.place
+
+ def test_name_field(self):
+ self._test_char_field(
+ field_name='name',
+ min_length=10,
+ max_length=100
+ )
+
+ def test_location(self):
+ self._test_char_field(
+ field_name='location',
+ min_length=10,
+ max_length=100
+ )
+
+ def test_latitude(self):
+ self._test_float_field(
+ field_name='latitude',
+ min_value=-90,
+ max_value=90
+ )
+
+ def test_longitude(self):
+ self._test_float_field(
+ field_name='longitude',
+ min_value=-180,
+ max_value=180
+ )
+
+ def test_decsription(self):
+ self._test_field('description', models.TextField)
+
+ def test_tags(self):
+ self._test_field('tags', TaggableManager)
+
+ def test_average_latlon(self):
+ '''
+ Tests the average latitude/longitude calculation of a list
+ of 10 places
+ '''
+ place_list = []
+ for i in range(10):
+ place = mock_place()
+ place.latitude = i+1
+ place.longitude = i+10
+ place_list.append(place)
+
+ avg_latlon = Place.average_latlon(place_list)
+ self.assertEqual(avg_latlon[0], 5.5,
+ msg='%s: average latitude missmatch' % (
+ self.model_name
+ )
+ )
+ self.assertEqual(avg_latlon[1], 14.5,
+ msg='%s: average longitude missmatch' % (
+ self.model_name
+ )
+ )
+
+ def test_average_latlon_one_place(self):
+ '''
+ Tests the average latitude/longitude calculation of a list
+ of one place
+ '''
+ place = mock_place()
+ avg_latlon = Place.average_latlon([place])
+ self.assertEqual(avg_latlon[0], place.latitude,
+ msg='%s:(one place) average latitude missmatch' % (
+ self.model_name
+ )
+ )
+ self.assertEqual(avg_latlon[1], place.longitude,
+ msg='%s: (one place) average longitude missmatch' % (
+ self.model_name
+ )
+ )
+
+ def test_average_latlon_no_places(self):
+ '''
+ Tests the average latitude/longitude calculation of
+ an empty list
+ '''
+ avg_latlon = Place.average_latlon([])
+ self.assertEqual(avg_latlon[0], 0,
+ msg='%s: (no places) average latitude missmatch' % (
+ self.model_name
+ )
+ )
+ self.assertEqual(avg_latlon[1], 0,
+ msg='%s: a(no places) verage longitude missmatch' % (
+ self.model_name
+ )
+ )
+
+ def test_str(self):
+ place = mock_place()
+ self.assertEqual(str(place), place.name,
+ msg='%s __str__ should return the name' % (
+ self.model_name
+ )
+
+ )
diff --git a/lostplaces/lostplaces_app/tests/test_models.py b/lostplaces/lostplaces_app/tests/test_models.py
new file mode 100644
index 0000000..e69de29
diff --git a/lostplaces/lostplaces_app/tests/views/__init__.py b/lostplaces/lostplaces_app/tests/views/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/lostplaces/lostplaces_app/tests/views/test_base_views.py b/lostplaces/lostplaces_app/tests/views/test_base_views.py
new file mode 100644
index 0000000..6525595
--- /dev/null
+++ b/lostplaces/lostplaces_app/tests/views/test_base_views.py
@@ -0,0 +1,58 @@
+from django.test import TestCase, Client
+from django.urls import reverse_lazy
+
+from lostplaces_app.models import Place
+
+from lostplaces_app.models import Explorer
+from lostplaces_app.tests.models.test_place_model import mock_place
+from lostplaces_app.tests import mock_user
+
+class TestIsAuthenticated(TestCase):
+ def setUp(self):
+ self. client = Client()
+ mock_place()
+ mock_user()
+
+ def test_logged_in(self):
+ self.client.login(username='testpeter', password='Develop123')
+ response = self.client.get(reverse_lazy('place_detail', kwargs={'pk': 1}))
+ self.assertEqual(response.status_code, 200)
+
+ def test_not_logged_in(self):
+ url = reverse_lazy('place_detail', kwargs={'pk': 1})
+ response = self.client.get(url, follow=True)
+ self.assertRedirects(
+ response=response,
+ expected_url='?'.join([str(reverse_lazy('login')), 'redirect_to=/place/1/']),
+ status_code=302,
+ target_status_code=200,
+ msg_prefix='''Accesing an IsAuthenticated view while not logged should
+ redirect to login page with redirect params
+ ''',
+ fetch_redirect_response=True
+ )
+ self.assertTrue(response.context['messages'])
+ self.assertTrue(len(response.context['messages']) > 0)
+
+class TestIsPlaceSubmitter(TestCase):
+
+ def setUp(self):
+ self. client = Client()
+ mock_place()
+ mock_user()
+
+ def test_is_submitter(self):
+ self.client.login(username='testpeter', password='Develop123')
+ response = self.client.get(reverse_lazy('place_edit', kwargs={'pk': 1}))
+ self.assertEqual(response.status_code, 200)
+
+ def test_is_no_submitter(self):
+ Explorer.objects.create_user(
+ username='manfred',
+ password='Develop123'
+ )
+ self.client.login(username='manfred', password='Develop123')
+ response = self.client.get(reverse_lazy('place_edit', kwargs={'pk': 1}))
+ self.assertEqual(response.status_code, 403)
+ self.assertTrue(response.context['messages'])
+ self.assertTrue(len(response.context['messages']) > 0)
\ No newline at end of file
diff --git a/lostplaces/lostplaces_app/tests/views/test_place_views.py b/lostplaces/lostplaces_app/tests/views/test_place_views.py
new file mode 100644
index 0000000..ff4b46f
--- /dev/null
+++ b/lostplaces/lostplaces_app/tests/views/test_place_views.py
@@ -0,0 +1,35 @@
+from django.test import TestCase, Client
+from django.urls import reverse_lazy
+
+from lostplaces_app.models import Place
+
+from lostplaces_app.tests.models.test_place_model import mock_place
+from lostplaces_app.tests import mock_user
+
+class TestPlaceCreateView(TestCase):
+
+ def setUp(self):
+ self. client = Client()
+ mock_place()
+ mock_user()
+
+ def test_url_logged_in(self):
+ self.client.login(username='testpeter', password='Develop123')
+ response = self.client.get(reverse_lazy('place_detail', kwargs={'pk': 1}))
+ self.assertEqual(response.status_code, 200)
+
+ def test_url_not_logged_in(self):
+ url = reverse_lazy('place_detail', kwargs={'pk': 1})
+ response = self.client.get(url)
+ self.assertRedirects(
+ response=response,
+ expected_url='?'.join([str(reverse_lazy('login')), 'redirect_to=/place/1/']),
+ status_code=302,
+ target_status_code=200,
+ msg_prefix='''Accesing PlaceDetailView while not logged should
+ redirect to login page with redirect params
+ ''',
+ fetch_redirect_response=True
+ )
+
+
\ No newline at end of file
diff --git a/lostplaces/lostplaces_app/urls.py b/lostplaces/lostplaces_app/urls.py
index 6733958..b6d8246 100644
--- a/lostplaces/lostplaces_app/urls.py
+++ b/lostplaces/lostplaces_app/urls.py
@@ -7,6 +7,8 @@ from .views import (
PlaceCreateView,
PlaceUpdateView,
PlaceDeleteView,
+ PlaceTagDeleteView,
+ PlaceTagSubmitView,
PhotoAlbumCreateView,
PhotoAlbumDeleteView,
FlatView
@@ -23,4 +25,8 @@ urlpatterns = [
path('place/delete//', PlaceDeleteView.as_view(), name='place_delete'),
path('place/', PlaceListView.as_view(), name='place_list'),
path('flat//', FlatView, name='flatpage')
+
+ # POST-only URL for tag submission
+ path('place/tag/', PlaceTagSubmitView.as_view(), name='place_tag_submit'),
+ path('place/tag/delete//', PlaceTagDeleteView.as_view(), name='place_tag_delete')
]
diff --git a/lostplaces/lostplaces_app/views/place_views.py b/lostplaces/lostplaces_app/views/place_views.py
index f407d71..f538187 100644
--- a/lostplaces/lostplaces_app/views/place_views.py
+++ b/lostplaces/lostplaces_app/views/place_views.py
@@ -11,7 +11,9 @@ from django.urls import reverse_lazy
from lostplaces_app.models import Place, PlaceImage
from lostplaces_app.views import IsAuthenticated, IsPlaceSubmitter
-from lostplaces_app.forms import PlaceForm, PlaceImageCreateForm
+from lostplaces_app.forms import PlaceForm, PlaceImageCreateForm, TagSubmitForm
+
+from taggit.models import Tag
class PlaceListView(IsAuthenticated, ListView):
paginate_by = 5
@@ -30,7 +32,14 @@ class PlaceDetailView(IsAuthenticated, View):
context = {
'place': place,
'place_list': [ place ],
- 'place_map_center': [ place.latitude, place.longitude ]
+ 'place_map_center': [ place.latitude, place.longitude ],
+ 'all_tags': Tag.objects.all(),
+ 'tagging_config': {
+ 'submit_url': reverse_lazy('place_tag_submit', kwargs={'place_id': place.id}),
+ 'submit_form': TagSubmitForm(),
+ 'tagged_item': place,
+ 'delete_url_name': 'place_tag_delete'
+ }
}
return render(request, 'place/place_detail.html', context)
diff --git a/lostplaces/lostplaces_app/views/views.py b/lostplaces/lostplaces_app/views/views.py
index c638205..8b2c1ef 100644
--- a/lostplaces/lostplaces_app/views/views.py
+++ b/lostplaces/lostplaces_app/views/views.py
@@ -5,15 +5,19 @@ from django.contrib.messages.views import SuccessMessageMixin
from django.contrib import messages
from django.urls import reverse_lazy
from django.shortcuts import render, redirect, get_object_or_404
+from django.http import HttpResponseForbidden
-from lostplaces_app.forms import ExplorerCreationForm
+from lostplaces_app.forms import ExplorerCreationForm, TagSubmitForm
from lostplaces_app.models import Place, PhotoAlbum
-from lostplaces_app.views import IsAuthenticated
+from lostplaces_app.views.base_views import IsAuthenticated
from lostplaces_app.views.base_views import (
PlaceAssetCreateView,
- PlaceAssetDeleteView
+ PlaceAssetDeleteView,
)
+
+from taggit.models import Tag
+
class SignUpView(SuccessMessageMixin, CreateView):
form_class = ExplorerCreationForm
success_url = reverse_lazy('login')
@@ -49,5 +53,27 @@ class PhotoAlbumDeleteView(PlaceAssetDeleteView):
success_message = 'Photo Album deleted'
permission_denied_messsage = 'You do not have permissions to alter this photo album'
+class PlaceTagSubmitView(IsAuthenticated, View):
+ def post(self, request, place_id, *args, **kwargs):
+ place = Place.objects.get(pk=place_id)
+ form = TagSubmitForm(request.POST)
+ if form.is_valid():
+ tag_list_raw = form.cleaned_data['tag_list']
+ tag_list_raw = tag_list_raw.strip().split(',')
+ tag_list = []
+ for tag in tag_list_raw:
+ tag_list.append(tag.strip())
+ place.tags.add(*tag_list)
+ place.save()
+
+ return redirect(reverse_lazy('place_detail', kwargs={'pk': place.id}))
+
+class PlaceTagDeleteView(IsAuthenticated, View):
+ def get(self, request, tagged_id, tag_id, *args, **kwargs):
+ place = Place.objects.get(pk=tagged_id)
+ tag = Tag.objects.get(pk=tag_id)
+ place.tags.remove(tag)
+ return redirect(reverse_lazy('place_detail', kwargs={'pk': tagged_id}))
+
def FlatView(request, slug):
return render(request, 'flat/' + slug + '.html')