Compare commits

...

27 Commits

Author SHA1 Message Date
96af47c539 Fixed syntax error. 2020-09-09 20:26:49 +02:00
e17f55d7d3 Merge remote-tracking branch 'origin/master' into feature/unauth_content 2020-09-09 20:22:44 +02:00
f8cae76f05 testing base views 2020-09-04 21:48:05 +02:00
17637ff0b9 more test 2020-09-03 22:32:28 +02:00
b055c5f891 Tests for place_image 2020-09-03 22:22:04 +02:00
1dee72cd15 Commenting tests 2020-09-03 20:24:20 +02:00
2837999a5b Bufix, i guess 2020-09-03 20:11:02 +02:00
bf07795f4d Wrong file 2020-09-03 20:07:11 +02:00
015cd768e1 Tests for the place model 2020-09-03 20:06:06 +02:00
8ab0e1b177 only submitted can delete tags 2020-09-02 00:25:31 +02:00
47718ce17b Merge branch 'feature/tags' of mowoe.com:reverend/lostplaces-backend into feature/tags 2020-09-02 00:08:04 +02:00
6d89bca033 Deletion of tags 2020-09-02 00:08:00 +02:00
69f0f4ccfd Added documentation, links, missing information. 2020-09-01 22:35:39 +02:00
75f8ac4a95 Mentioned new taggit dependency in Readme.md 2020-09-01 22:24:05 +02:00
c3eede548c Added comment ot POST-only url. 2020-09-01 22:14:19 +02:00
0eea31a0af Removed obsolete jquery files. 2020-09-01 22:14:06 +02:00
2509c669f9 Merge branch 'feature/tags' of mowoe.com:reverend/lostplaces-backend into feature/tags 2020-09-01 21:33:07 +02:00
e00c9318fa Tagify dropdown styling 2020-09-01 18:31:46 +02:00
6ed6c2c990 Better Tagggin integration 2020-09-01 18:20:02 +02:00
328e6899a6 Merge branch 'master' into feature/tags 2020-08-31 18:28:21 +02:00
e4cd8bb301 Tagging using JS using Partials 2020-08-31 18:18:24 +02:00
66581a9d2d Adding tags is now possible 2020-08-30 18:39:45 +02:00
490a0e9f3e Merge branch 'master' into feature/tags 2020-08-30 17:53:34 +02:00
fc39b46c52 Merge branch 'master' into feature/tags 2020-08-27 17:17:39 +02:00
36744c65fb Merge branch 'master' into feature/tags 2020-08-27 17:16:20 +02:00
8198c43451 Adding taggit to the dependencies 2020-08-27 17:00:41 +02:00
78f0e80136 First tries on using tags 2020-08-27 15:46:45 +02:00
24 changed files with 1231 additions and 135 deletions

View File

@ -5,13 +5,14 @@ verify_ssl = true
[dev-packages]
pylint = "*"
coverage = "*"
[packages]
django = "*"
easy-thumbnails = "*"
image = "*"
django-widget-tweaks = "*"
django-svg-icons = "*"
django-taggit = "*"
# Commented out to not explicitly specify Python 3 subversion.
# [requires]

View File

@ -11,12 +11,13 @@ Right now it depends on the following non-core Python 3 libraries. These can be
* [easy-thumbnails](https://github.com/SmileyChris/easy-thumbnails) A powerful, yet easy to implement thumbnailing application for Django 1.11+
* [image](https://github.com/francescortiz/image) Image cropping for django
* [django-widget-tweaks](https://github.com/jazzband/django-widget-tweaks) Tweak the form field rendering in templates, not in python-level form definitions.
* [django-taggit](https://github.com/jazzband/django-taggit) A simpler approach to tagging with Django.
## Development
### Setting up a (pipenv) virtual environment for development
After having obtained the repository contents (either via .zip download or git clone), you can easily setup a pipenv virtual environment. The repo provides a Pipfile for easy dependency management that does not mess with your system.
After having obtained the repository contents (either via .zip download or git clone), you can easily setup a [pipenv](https://docs.pipenv.org/) virtual environment. The repo provides a Pipfile for easy dependency management that does not mess with your system.
```
$ cd lostplaces-backend
@ -25,14 +26,17 @@ $ pipenv shell
(lostplaces-backend) $ lostplaces/manage.py makemigrations
(lostplaces-backend) $ lostplaces/manage.py migrate
(lostplaces-backend) $ lostplaces/manage.py createsuperuser
(lostplaces-backend) $ lostplaces/manage.py runserver
(lostplaces-backend) $ lostplaces/manage.py runserver --ipv6
```
### Returning to the venv
```
$ cd lostplaces-backend
$ pipenv shell
(lostplaces-backend) $ lostplaces/manage.py runserver
(lostplaces-backend) $ pipenv update # If dependencies changed, or updates available
(lostplaces-backend) $ lostplaces/manage.py makemigrations # If datamodels changed
(lostplaces-backend) $ lostplaces/manage.py migrate # If datamodels changed
(lostplaces-backend) $ lostplaces/manage.py runserver --ipv6
```
Visit: [admin](http://localhost:8000/admin) for administrative backend or
@ -41,16 +45,15 @@ Visit: [admin](http://localhost:8000/admin) for administrative backend or
## Installing lostplaces
### Install dependencies
Python3, Django3, easy-thumbnails, image, django-widget-tweaks
Python3, Django3, easy-thumbnails, image, django-widget-tweaks, django-taggit
```
pip install --user django easy-thumbnails image django-widget-tweaks
pip install --user django easy-thumbnails image django-widget-tweaks django-taggit
```
Or, if you use pipenv
```
pipenv install
pipenv install / update
```
### Add 'lostplaces_app' to your INSTALLED_APPS setting like this
```
@ -59,6 +62,7 @@ INSTALLED_APPS = [
'lostplaces_app',
'easy_thumbnails',
'widget_tweaks',
'django_taggit'
]
```
@ -92,11 +96,11 @@ urlpatterns = [
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
```
Run ``python manage.py migrate`` to create the lost places models.
Run ``./manage.py migrate`` to create the lost places models.
Start the development server and visit http://127.0.0.1:8000/admin/
Start the development server and visit http://localhost:8000/admin/
Visit http://127.0.0.1:8000/lostplaces/ to CRUD lost places.
Visit http://localhost:8000/lostplaces/ to CRUD lost places.
Happy developing ;-)

View File

@ -29,7 +29,7 @@ SECRET_KEY = 'n$(bx8(^)*wz1ygn@-ekt7rl^1km*!_c+fwwjiua8m@-x_rpl0'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = []
ALLOWED_HOSTS = ['localhost', '192.168.178.49']
# Application definition
@ -43,7 +43,8 @@ INSTALLED_APPS = [
'django.contrib.messages',
'django.contrib.staticfiles',
'easy_thumbnails',
'widget_tweaks'
'widget_tweaks',
'taggit'
]
MIDDLEWARE = [

View File

@ -48,3 +48,11 @@ class PlaceImageCreateForm(forms.ModelForm):
super().__init__(*args, **kwargs)
self.fields['filename'].required = False
class TagSubmitForm(forms.Form):
tag_list = forms.CharField(
max_length=500,
required=False,
widget=forms.TextInput(attrs={'autocomplete':'off'})
)

View File

@ -9,7 +9,9 @@ import uuid
from django.db import models
from django.dispatch import receiver
from django.contrib.auth.models import AbstractUser
from django.core.validators import MaxValueValidator, MinValueValidator
from easy_thumbnails.fields import ThumbnailerImageField
from taggit.managers import TaggableManager
# Create your models here.
@ -51,10 +53,21 @@ class Place (models.Model):
related_name='places'
)
location = models.CharField(max_length=50)
latitude = models.FloatField()
longitude = models.FloatField()
latitude = models.FloatField(
validators=[
MinValueValidator(-90),
MaxValueValidator(90)
]
)
longitude = models.FloatField(
validators=[
MinValueValidator(-180),
MaxValueValidator(180)
]
)
description = models.TextField()
tags = TaggableManager(blank=True)
# Get center position of LP-geocoordinates.
def average_latlon(place_list):
@ -95,6 +108,7 @@ class PlaceImage (models.Model):
on_delete=models.CASCADE,
related_name='images'
)
submitted_when = models.DateTimeField(auto_now_add=True, null=True)
submitted_by = models.ForeignKey(
Explorer,
on_delete=models.SET_NULL,
@ -145,21 +159,21 @@ def auto_delete_file_on_change(sender, instance, **kwargs):
class ExternalLink(models.Model):
url = models.URLField(max_length=200)
label = models.CharField(max_length=100)
submitted_by = models.ForeignKey(
url = models.URLField(max_length=200)
label = models.CharField(max_length=100)
submitted_by = models.ForeignKey(
Explorer,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='external_links'
)
submitted_when = models.DateTimeField(auto_now_add=True, null=True)
submitted_when = models.DateTimeField(auto_now_add=True, null=True)
class PhotoAlbum(ExternalLink):
place = models.ForeignKey(
place = models.ForeignKey(
Place,
on_delete=models.CASCADE,
related_name='photo_albums',
null=True
null=True
)

View File

@ -0,0 +1,548 @@
:root {
--tagify-dd-color-primary: rgb(53, 149, 246);
--tagify-dd-bg-color: white
}
.tagify {
--tags-border-color: #DDD;
--tags-hover-border-color: #CCC;
--tags-focus-border-color: #3595f6;
--tag-bg: #E5E5E5;
--tag-hover: #D3E2E2;
--tag-text-color: black;
--tag-text-color--edit: black;
--tag-pad: 0.3em 0.5em;
--tag-inset-shadow-size: 1.1em;
--tag-invalid-color: #D39494;
--tag-invalid-bg: rgba(211, 148, 148, 0.5);
--tag-remove-bg: rgba(211, 148, 148, 0.3);
--tag-remove-btn-color: black;
--tag-remove-btn-bg: none;
--tag-remove-btn-bg--hover: #c77777;
--input-color: inherit;
--tag--min-width: 1ch;
--tag--max-width: auto;
--tag-hide-transition: .3s;
--placeholder-color: rgba(0, 0, 0, 0.4);
--placeholder-color-focus: rgba(0, 0, 0, 0.25);
--loader-size: .8em;
display: flex;
align-items: flex-start;
flex-wrap: wrap;
border: 1px solid #ddd;
border: 1px solid var(--tags-border-color);
padding: 0;
line-height: 1.1;
cursor: text;
outline: 0;
position: relative;
transition: .1s
}
@keyframes tags--bump {
30% {
transform: scale(1.2)
}
}
@keyframes rotateLoader {
to {
transform: rotate(1turn)
}
}
.tagify:hover {
border-color: #ccc;
border-color: var(--tags-hover-border-color)
}
.tagify.tagify--focus {
transition: 0s;
border-color: #3595f6;
border-color: var(--tags-focus-border-color)
}
.tagify[readonly]:not(.tagify--mix) {
cursor: default
}
.tagify[readonly]:not(.tagify--mix)>.tagify__input {
visibility: hidden;
width: 0;
margin: 5px 0
}
.tagify[readonly]:not(.tagify--mix) .tagify__tag__removeBtn {
display: none
}
.tagify[readonly]:not(.tagify--mix) .tagify__tag>div {
padding: .3em .5em;
padding: var(--tag-pad)
}
.tagify[readonly]:not(.tagify--mix) .tagify__tag>div::before {
background: linear-gradient(45deg, var(--tag-bg) 25%, transparent 25%, transparent 50%, var(--tag-bg) 50%, var(--tag-bg) 75%, transparent 75%, transparent) 0/5px 5px;
box-shadow: none;
filter: brightness(.95)
}
.tagify--loading .tagify__input::before {
content: none
}
.tagify--loading .tagify__input::after {
content: '';
vertical-align: middle;
opacity: 1;
width: .7em;
height: .7em;
width: var(--loader-size);
height: var(--loader-size);
border: 3px solid;
border-color: #eee #bbb #888 transparent;
border-radius: 50%;
animation: rotateLoader .4s infinite linear;
margin: -2px 0 -2px .5em
}
.tagify--loading .tagify__input:empty::after {
margin-left: 0
}
.tagify+input,
.tagify+textarea {
display: none !important
}
.tagify__tag {
display: inline-flex;
align-items: center;
margin: 5px 0 5px 5px;
position: relative;
z-index: 1;
outline: 0;
cursor: default;
transition: .13s ease-out
}
.tagify__tag>div {
vertical-align: top;
box-sizing: border-box;
max-width: 100%;
padding: .3em .5em;
padding: var(--tag-pad);
color: #000;
color: var(--tag-text-color);
line-height: inherit;
border-radius: 3px;
-webkit-user-select: none;
user-select: none;
white-space: nowrap;
transition: .13s ease-out
}
.tagify__tag>div>* {
white-space: pre-wrap;
overflow: hidden;
text-overflow: ellipsis;
display: inline-block;
vertical-align: top;
min-width: var(--tag--min-width);
max-width: var(--tag--max-width);
transition: .8s ease, .1s color
}
.tagify__tag>div>[contenteditable] {
outline: 0;
-webkit-user-select: text;
user-select: text;
cursor: text;
margin: -2px;
padding: 2px;
max-width: 350px
}
.tagify__tag>div::before {
content: '';
position: absolute;
border-radius: inherit;
left: 0;
top: 0;
right: 0;
bottom: 0;
z-index: -1;
pointer-events: none;
transition: 120ms ease;
animation: tags--bump .3s ease-out 1;
box-shadow: 0 0 0 1.1em #e5e5e5 inset;
box-shadow: 0 0 0 var(--tag-inset-shadow-size) var(--tag-bg) inset
}
.tagify__tag:hover:not([readonly]) div::before {
top: -2px;
right: -2px;
bottom: -2px;
left: -2px;
box-shadow: 0 0 0 1.1em #d3e2e2 inset;
box-shadow: 0 0 0 var(--tag-inset-shadow-size) var(--tag-hover) inset
}
.tagify__tag--loading {
pointer-events: none
}
.tagify__tag--loading .tagify__tag__removeBtn {
display: none
}
.tagify__tag--loading::after {
--loader-size: .4em;
content: '';
vertical-align: middle;
opacity: 1;
width: .7em;
height: .7em;
width: var(--loader-size);
height: var(--loader-size);
border: 3px solid;
border-color: #eee #bbb #888 transparent;
border-radius: 50%;
animation: rotateLoader .4s infinite linear;
margin: 0 .5em 0 -.1em
}
.tagify__tag--flash div::before {
animation: none
}
.tagify__tag--hide {
width: 0 !important;
padding-left: 0;
padding-right: 0;
margin-left: 0;
margin-right: 0;
opacity: 0;
transform: scale(0);
transition: .3s;
transition: var(--tag-hide-transition);
pointer-events: none
}
.tagify__tag.tagify--noAnim>div::before {
animation: none
}
.tagify__tag.tagify--notAllowed:not(.tagify__tag--editable) div>span {
opacity: .5
}
.tagify__tag.tagify--notAllowed:not(.tagify__tag--editable) div::before {
box-shadow: 0 0 0 1.1em rgba(211, 148, 148, .5) inset !important;
box-shadow: 0 0 0 var(--tag-inset-shadow-size) var(--tag-invalid-bg) inset !important;
transition: .2s
}
.tagify__tag[readonly] .tagify__tag__removeBtn {
display: none
}
.tagify__tag[readonly]>div::before {
background: linear-gradient(45deg, var(--tag-bg) 25%, transparent 25%, transparent 50%, var(--tag-bg) 50%, var(--tag-bg) 75%, transparent 75%, transparent) 0/5px 5px;
box-shadow: none;
filter: brightness(.95)
}
.tagify__tag--editable>div {
color: #000;
color: var(--tag-text-color--edit)
}
.tagify__tag--editable>div::before {
box-shadow: 0 0 0 2px #d3e2e2 inset !important;
box-shadow: 0 0 0 2px var(--tag-hover) inset !important
}
.tagify__tag--editable.tagify--invalid>div::before {
box-shadow: 0 0 0 2px #d39494 inset !important;
box-shadow: 0 0 0 2px var(--tag-invalid-color) inset !important
}
.tagify__tag__removeBtn {
order: 5;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50px;
cursor: pointer;
font: 14px/1 Arial;
background: 0 0;
background: var(--tag-remove-btn-bg);
color: #000;
color: var(--tag-remove-btn-color);
width: 14px;
height: 14px;
margin-right: 4.66667px;
margin-left: -4.66667px;
transition: .2s ease-out
}
.tagify__tag__removeBtn::after {
content: "\00D7"
}
.tagify__tag__removeBtn:hover {
color: #fff;
background: #c77777;
background: var(--tag-remove-btn-bg--hover)
}
.tagify__tag__removeBtn:hover+div>span {
opacity: .5
}
.tagify__tag__removeBtn:hover+div::before {
box-shadow: 0 0 0 1.1em rgba(211, 148, 148, .3) inset !important;
box-shadow: 0 0 0 var(--tag-inset-shadow-size) var(--tag-remove-bg) inset !important;
transition: .2s
}
.tagify:not(.tagify--mix) .tagify__input br {
display: none
}
.tagify:not(.tagify--mix) .tagify__input * {
display: inline;
white-space: nowrap
}
.tagify__input {
flex-grow: 1;
display: inline-block;
min-width: 110px;
margin: 5px;
padding: .3em .5em;
padding: var(--tag-pad, .3em .5em);
line-height: inherit;
position: relative;
white-space: pre-wrap;
color: inherit;
color: var(--input-color)
}
.tagify__input:empty::before {
transition: .2s ease-out;
opacity: 1;
transform: none;
display: inline-block;
width: auto
}
.tagify--mix .tagify__input:empty::before {
display: inline-block
}
.tagify__input:focus {
outline: 0
}
.tagify__input:focus::before {
transition: .2s ease-out;
opacity: 0;
transform: translatex(6px)
}
@media all and (-ms-high-contrast:none),
(-ms-high-contrast:active) {
.tagify__input:focus::before {
display: none
}
}
@supports (-ms-ime-align:auto) {
.tagify__input:focus::before {
display: none
}
}
.tagify__input:focus:empty::before {
transition: .2s ease-out;
opacity: 1;
transform: none;
color: rgba(0, 0, 0, .25);
color: var(--placeholder-color-focus)
}
@-moz-document url-prefix() {
.tagify__input:focus:empty::after {
display: none
}
}
.tagify__input::before {
content: attr(data-placeholder);
height: 1em;
line-height: 1em;
margin: auto 0;
z-index: 1;
color: rgba(0, 0, 0, .4);
color: var(--placeholder-color);
white-space: nowrap;
pointer-events: none;
opacity: 0;
position: absolute
}
.tagify--mix .tagify__input::before {
display: none;
position: static;
line-height: inherit
}
.tagify__input::after {
content: attr(data-suggest);
display: inline-block;
white-space: pre;
color: #000;
opacity: .3;
pointer-events: none;
max-width: 100px
}
.tagify__input .tagify__tag {
margin: 0
}
.tagify__input .tagify__tag>div {
padding-top: 0;
padding-bottom: 0
}
.tagify--mix {
display: block
}
.tagify--mix .tagify__input {
padding: 5px;
margin: 0;
width: 100%;
height: 100%;
line-height: 1.5
}
.tagify--mix .tagify__input::before {
height: auto
}
.tagify--mix .tagify__input::after {
content: none
}
.tagify--select::after {
content: '>';
opacity: .5;
position: absolute;
top: 50%;
right: 0;
bottom: 0;
font: 16px monospace;
line-height: 8px;
height: 8px;
pointer-events: none;
transform: translate(-150%, -50%) scaleX(1.2) rotate(90deg);
transition: .2s ease-in-out
}
.tagify--select[aria-expanded=true]::after {
transform: translate(-150%, -50%) rotate(270deg) scaleY(1.2)
}
.tagify--select .tagify__tag {
position: absolute;
top: 0;
right: 1.8em;
bottom: 0
}
.tagify--select .tagify__tag div {
display: none
}
.tagify--select .tagify__input {
width: 100%
}
.tagify--invalid {
--tags-border-color: #D39494
}
.tagify__dropdown {
position: absolute;
z-index: 9999;
transform: translateY(1px);
overflow: hidden
}
.tagify__dropdown[placement=top] {
margin-top: 0;
transform: translateY(-100%)
}
.tagify__dropdown[placement=top] .tagify__dropdown__wrapper {
border-top-width: 1px;
border-bottom-width: 0
}
.tagify__dropdown[position=text] {
box-shadow: 0 0 0 3px rgba(var(--tagify-dd-color-primary), .1);
font-size: .9em
}
.tagify__dropdown[position=text] .tagify__dropdown__wrapper {
border-width: 1px
}
.tagify__dropdown__wrapper {
max-height: 300px;
overflow: hidden;
background: #fff;
background: var(--tagify-dd-bg-color);
border: 1px solid #3595f6;
border-color: var(--tagify-dd-color-primary);
border-top-width: 0;
box-shadow: 0 2px 4px -2px rgba(0, 0, 0, .2);
transition: .25s cubic-bezier(0, 1, .5, 1)
}
.tagify__dropdown__wrapper:hover {
overflow: auto
}
.tagify__dropdown--initial .tagify__dropdown__wrapper {
max-height: 20px;
transform: translateY(-1em)
}
.tagify__dropdown--initial[placement=top] .tagify__dropdown__wrapper {
transform: translateY(2em)
}
.tagify__dropdown__item {
box-sizing: inherit;
padding: .3em .5em;
margin: 1px;
cursor: pointer;
border-radius: 2px;
position: relative;
outline: 0
}
.tagify__dropdown__item--active {
background: #3595f6;
background: var(--tagify-dd-color-primary);
color: #fff
}
.tagify__dropdown__item:active {
filter: brightness(105%)
}

File diff suppressed because one or more lines are too long

View File

@ -20,7 +20,7 @@
<b>We do not change anything in the location.</b>
</li>
<li>
We don't smoke if possible. Not only, because it smells bad and causes litter, there is always the chance, to set anything on fire with flying sparks. Let it be dry leaves on a hot summer day or (poentially) flammable materials in industrial plants.
<b>We don't smoke if possible.</b> Not only, because it smells bad and causes litter, there is always the chance, to set anything on fire with flying sparks. Let it be dry leaves on a hot summer day or (poentially) flammable materials in industrial plants.
</li>
<li>
<b>Spraying is an absolute "no-go"!</b>

View File

@ -3,19 +3,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{% static 'main.css' %}">
<link rel="icon" type="image/png" href="{% static 'favicon.ico' %}">
<title>
{% block title %}Urban Exploration{% endblock %}
</title>
<head>
{% block additional_head %}
{% endblock additional_head %}
{% block additional_head %}
{% endblock additional_head %}
</head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{% static 'main.css' %}">
<link rel="icon" type="image/png" href="{% static 'favicon.ico' %}">
<title>
{% block title %}Urban Exploration{% endblock %}
</title>
</head>
<body>
<div class="LP-Wrapper__Site">

View File

@ -1,16 +1,18 @@
{% load widget_tweaks %}
<div class="LP-Input {% if field.errors %} LP-Input--error {% endif %}">
<div class="LP-Input {% if classes%}{{classes}}{% endif %} {% if field.errors %} LP-Input--error {% endif %}">
<label for="{{field.id_for_label}}" class="LP-Input__Label">{{field.label}}</label>
{% render_field field class="LP-Input__Field"%}
{% with class="LP-Input__Field "%}
{% render_field field class=class%}
{% endwith %}
<span class="LP-Input__Message">
{% if field.errors %}
{% for error in field.errors%}
{{error}}
{% endfor %}
{% for error in field.errors%}
{{error}}
{% endfor %}
{% elif field.help_text%}
{{ field.help_text }}
{{ field.help_text }}
{% endif %}
</span>
</div>

View File

@ -0,0 +1,66 @@
<div class="LP-TagList">
<ul class="LP-TagList__List">
{% for tag in tag_list %}
<li class="LP-TagList__Item">
<div class="LP-Tag">
<a href="#" class="LP-Link">
<span class="LP-Link__Text">{{tag}}</span>
</a>
{% if request.user and request.user == config.tagged_item.submitted_by %}
<a href="{% url config.delete_url_name tagged_id=config.tagged_item.id tag_id=tag.id %}" class="LP-Link">
<span class="LP-Tag__Remove RV-Iconized__Container RV-Iconized__Container--extraSmall">
<svg class="RV-Iconized__Icon" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
<path d="M0 0h24v24H0z" fill="none"></path>
<path d="M19 6.4L17.6 5 12 10.6 6.4 5 5 6.4 10.6 12 5 17.6 6.4 19 12 13.4 17.6 19 19 17.6 13.4 12z">
</path>
</svg>
</span>
</a>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
</div>
<form id="id_tag_submit_form" class="LP-Form LP-Form--inline LP-Form--tagging" method="POST" action="{{config.submit_url}}">
<fieldset class="LP-Form__Fieldset">
<legend class="LP-Form__Legend">Tags hinzufügen</legend>
{% csrf_token %}
<div class="LP-Form__Composition LP-Form__Composition--breakable">
<div class="LP-Form__Field LP-Form__Button LP-Input LP-Input--tagging">
<button id="id_tag_submit_button" class="LP-Button"> Tags hinzufügen</button>
</div>
<div class="LP-Form__Field">
{% include 'partials/form/inputField.html' with field=config.submit_form.tag_list classes="LP-Input--tagging" %}
</div>
</div>
</fieldset>
</form>
<script>
const input = document.getElementById('{{config.submit_form.tag_list.auto_id}}')
const submit_form = document.getElementById('id_tag_submit_form')
const submit_button = document.getElementById('id_tag_submit_button')
submit_form.onsubmit = () => false
const tagify = new Tagify(input, {
'whitelist': [
{% for tag in all_tags %}
'{{tag}}',
{% endfor %}
]
})
const on_form_submit = function() {
concat_value = ''
console.log(tagify)
concat_value = tagify.value.map(value => value.value).join(',')
console.log(concat_value)
input.value = concat_value
submit_form.submit()
}
submit_button.onclick = on_form_submit
</script>

View File

@ -6,102 +6,103 @@
{% block additional_head %}
<link rel="stylesheet" href="{% static 'maps/ol.css' %}" type="text/css">
<script src="{% static 'maps/ol.js' %}"></script>
<script src="{% static 'tagify.min.js' %}"></script>
<link rel="stylesheet" href="{% static 'minimal.css' %}" type="text/css">
{% endblock additional_head %}
{% block title %}{{place.name}}{% endblock %}
{% block additional_menu_items %}
<li class="LP-Menu__Item LP-Menu__Item--additional"><a href="{% url 'place_edit' pk=place.pk %}" class="LP-Link"><span
class="LP-Link__Text">Edit place</span></a></li>
<li class="LP-Menu__Item LP-Menu__Item--additional"><a href="{% url 'place_delete' pk=place.pk %}" class="LP-Link"><span
class="LP-Link__Text">Delete place</span></a></li>
<li class="LP-Menu__Item LP-Menu__Item--additional"><a href="{% url 'place_edit' pk=place.pk %}" class="LP-Link"><span class="LP-Link__Text">Edit place</span></a></li>
<li class="LP-Menu__Item LP-Menu__Item--additional"><a href="{% url 'place_delete' pk=place.pk %}" class="LP-Link"><span class="LP-Link__Text">Delete place</span></a></li>
{% endblock additional_menu_items %}
{% block maincontent %}
<article class="LP-PlaceDetail">
<header class="LP-PlaceDetail__Header">
<h1 class="LP-Headline">{{ place.name }}</h1>
{% if place.images.first.filename.hero.url %}
<figure class="LP-PlaceDetail__Image">
<img src="{{ place.images.first.filename.hero.url }}" class="LP-Image" />
</figure>
{% endif %}
</header>
<header class="LP-PlaceDetail__Header">
<h1 class="LP-Headline">{{ place.name }}</h1>
{% if place.images.first.filename.hero.url %}
<figure class="LP-PlaceDetail__Image">
<img src="{{ place.images.first.filename.hero.url }}" class="LP-Image" />
</figure>
{% endif %}
</header>
<div class="LP-PlaceDetail__Description">
<p class="LP-Paragraph">{{ place.description }}</p>
</div>
<div class="LP-PlaceDetail__Description">
<p class="LP-Paragraph">{{ place.description }}</p>
</div>
<section class="LP-Section">
<h1 class="LP-Headline">Map-Links</h1>
{% include 'partials/osm_map.html' %}
<div class="LP-LinkList">
<ul class="LP-LinkList__Container">
<li class="LP-LinkList__Item"><a target="_blank"
href="https://www.google.com/maps?q={{place.latitude}},{{place.longitude}}"
class="LP-Link"><span class="LP-Text">Google Maps</span></a></li>
<li class="LP-LinkList__Item"><a target="_blank"
href="https://www.tim-online.nrw.de/tim-online2/?center={{place.latitude}},{{place.longitude}}&icon=true&bg=dop"
class="LP-Link"><span class="LP-Text">TIM Online</span></a></li>
<li class="LP-LinkList__Item"><a target="_blank"
href="http://www.openstreetmap.org/?mlat={{place.latitude}}&mlon={{place.longitude}}&zoom=16"
class="LP-Link"><span class="LP-Text">OSM</span></a></li>
</ul>
</div>
</section>
<section class="LP-Section">
<section class="LP-Section">
<h1 class="LP-Headline">Photoalben</h1>
<div class="LP-LinkList">
<ul class="LP-LinkList__Container">
{% for photo_album in place.photo_albums.all %}
<li class="LP-LinkList__Item">
<a target="_blank" href="{{photo_album.url}}" class="LP-Link">
<span class="LP-Text">{{photo_album.label}}</span>
</a>
{% if user == photo_album.submitted_by or user == place.submitted_by %}
<a href="{% url 'photo_album_delete' pk=photo_album.pk%}" class="LP-Link LP-LinkList__ItemHover" title="Delete Photo Album">
<div class="RV-Iconized__Container RV-Iconized__Container--small">
{% icon 'trash' className="RV-Iconized__Icon" %}
</div>
</a>
{% endif %}
</li>
{% endfor %}
<li class="LP-LinkList__Item">
<a href="{% url 'photo_album_create' place_id=place.id %}" class="LP-Link">
<div class="RV-Iconized__Container RV-Iconized__Container--small">
<svg class="RV-Iconized__Icon" version="1.1" id="Capa_1"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
x="0px" y="0px" viewBox="0 0 512 512" xml:space="preserve">
<g>
<path d="M492,236H276V20c0-11.046-8.954-20-20-20c-11.046,0-20,8.954-20,20v216H20c-11.046,0-20,8.954-20,20s8.954,20,20,20h216
{% url 'place_tag_submit' place_id=place.id as tag_submit_url%}
{% include 'partials/tagging.html' with tag_list=place.tags.all config=tagging_config all_tags=all_tags %}
</section>
<section class="LP-Section">
<h1 class="LP-Headline">Map-Links</h1>
{% include 'partials/osm_map.html' %}
<div class="LP-LinkList">
<ul class="LP-LinkList__Container">
<li class="LP-LinkList__Item"><a target="_blank" href="https://www.google.com/maps?q={{place.latitude}},{{place.longitude}}" class="LP-Link"><span class="LP-Text">Google Maps</span></a></li>
<li class="LP-LinkList__Item"><a target="_blank" href="https://www.tim-online.nrw.de/tim-online2/?center={{place.latitude}},{{place.longitude}}&icon=true&bg=dop" class="LP-Link"><span class="LP-Text">TIM Online</span></a></li>
<li class="LP-LinkList__Item"><a target="_blank" href="http://www.openstreetmap.org/?mlat={{place.latitude}}&mlon={{place.longitude}}&zoom=16" class="LP-Link"><span class="LP-Text">OSM</span></a></li>
</ul>
</div>
</section>
<section class=" LP-Section">
<h1 class="LP-Headline">Photoalben</h1>
<div class="LP-LinkList">
<ul class="LP-LinkList__Container">
{% for photo_album in place.photo_albums.all %}
<li class="LP-LinkList__Item">
<a target="_blank" href="{{photo_album.url}}" class="LP-Link">
<span class="LP-Text">{{photo_album.label}}</span>
</a>
{% if user == photo_album.submitted_by or user == place.submitted_by %}
<a href="{% url 'photo_album_delete' pk=photo_album.pk%}" class="LP-Link LP-LinkList__ItemHover" title="Delete Photo Album">
<div class="RV-Iconized__Container RV-Iconized__Container--small">
{% icon 'trash' className="RV-Iconized__Icon" %}
</div>
</a>
{% endif %}
</li>
{% endfor %}
<li class="LP-LinkList__Item">
<a href="{% url 'photo_album_create' place_id=place.id %}" class="LP-Link">
<div class="RV-Iconized__Container RV-Iconized__Container--small">
<svg class="RV-Iconized__Icon" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 512 512" xml:space="preserve">
<g>
<path d="M492,236H276V20c0-11.046-8.954-20-20-20c-11.046,0-20,8.954-20,20v216H20c-11.046,0-20,8.954-20,20s8.954,20,20,20h216
v216c0,11.046,8.954,20,20,20s20-8.954,20-20V276h216c11.046,0,20-8.954,20-20C512,244.954,503.046,236,492,236z" />
</g>
</svg>
<span class="RV-Iconized__Text">Fotoalbum hinzufügen</span>
</div>
</a>
</li>
</ul>
</div>
</section>
</g>
</svg>
<span class="RV-Iconized__Text">Fotoalbum hinzufügen</span>
</div>
</a>
</li>
</ul>
</div>
</section>
<section class="LP-Section">
<h1 class="LP-Headline">Bilder</h1>
<div class="LP-ImageGrid">
<ul class="LP-ImageGrid__Container">
{% for place_image in place.images.all %}
<li class="LP-ImageGrid__Item">
<a href="{{ place_image.filename.large.url }}" class="LP-Link"><img class="LP-Image"
src="{{ place_image.filename.thumbnail.url }}"></a>
</li>
{% endfor %}
</ul>
</div>
</section>
<section class="LP-Section">
<h1 class="LP-Headline">Bilder</h1>
<div class="LP-ImageGrid">
<ul class="LP-ImageGrid__Container">
{% for place_image in place.images.all %}
<li class="LP-ImageGrid__Item">
<a href="{{ place_image.filename.large.url }}" class="LP-Link"><img class="LP-Image" src="{{ place_image.filename.thumbnail.url }}"></a>
</li>
{% endfor %}
</ul>
</div>
</section>
</article>
{% endblock maincontent %}

View File

@ -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.

View File

@ -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]

View File

@ -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='<Class>'
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)

View File

@ -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)]))

View File

@ -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
)
)

View File

@ -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)

View File

@ -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
)

View File

@ -7,6 +7,8 @@ from .views import (
PlaceCreateView,
PlaceUpdateView,
PlaceDeleteView,
PlaceTagDeleteView,
PlaceTagSubmitView,
PhotoAlbumCreateView,
PhotoAlbumDeleteView,
FlatView
@ -22,5 +24,9 @@ urlpatterns = [
path('place/update/<int:pk>/', PlaceUpdateView.as_view(), name='place_edit'),
path('place/delete/<int:pk>/', PlaceDeleteView.as_view(), name='place_delete'),
path('place/', PlaceListView.as_view(), name='place_list'),
path('flat/<slug:slug>/', FlatView, name='flatpage')
path('flat/<slug:slug>/', FlatView, name='flatpage'),
# POST-only URL for tag submission
path('place/tag/<int:place_id>', PlaceTagSubmitView.as_view(), name='place_tag_submit'),
path('place/tag/delete/<int:tagged_id>/<int:tag_id>', PlaceTagDeleteView.as_view(), name='place_tag_delete')
]

View File

@ -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)

View File

@ -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')