Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
Menu
Open sidebar
SRCT
go
Commits
c0551abb
Commit
c0551abb
authored
Aug 12, 2019
by
Zac Wood
Browse files
Merge branch 'zac-updates' into 'master'
Python 3.7, Django 2, auto approve users See merge request
!133
parents
32b99e08
28a9349e
Pipeline
#4558
passed with stages
in 2 minutes and 1 second
Changes
24
Pipelines
2
Hide whitespace changes
Inline
Side-by-side
.gitlab-ci.yml
View file @
c0551abb
...
...
@@ -11,7 +11,7 @@ variables:
before_script
:
-
apt-get update -qy
-
apt-get install -y
mysql-client
libmysqlclient-dev python-mysqldb redis-server
-
apt-get install -y
default-
libmysqlclient-dev python-mysqldb redis-server
-
pip install -r requirements/ci.txt
-
nohup redis-server &
-
cd go/
...
...
@@ -35,20 +35,20 @@ before_script:
-
python manage.py migrate
-
echo "from django.contrib.auth import get_user_model; User = get_user_model(); User.objects.create_superuser('root', 'root@srct.gmu.edu', 'root') " | python manage.py shell
Go-py2.7
:
image
:
library/python:2.7
stage
:
test
script
:
-
python manage.py test
#
Go-py2.7:
#
image: library/python:2.7
#
stage: test
#
script:
#
- python manage.py test
Go-py3.4
:
image
:
library/python:3.4
stage
:
test
script
:
-
python manage.py test
#
Go-py3.4:
#
image: library/python:3.4
#
stage: test
#
script:
#
- python manage.py test
Go-py3.
5
:
image
:
library/python:3.
5
Go-py3.
7
:
image
:
library/python:3.
7
stage
:
test
script
:
-
python manage.py test
...
...
@@ -61,9 +61,10 @@ Go-py3.5:
# - coverage run --source=go --omit=*migrations/*,*admin.py,*manage.py,*wsgi.py,*settings.py,*secret.py,*__init__.py,*.pyc,*templates/*,*static/* manage.py test
# - coverage html -i && grep pc_cov htmlcov/index.html | egrep -o "[0-9]+\%" | awk '{ print "covered " $1;}'
Go-flake8
:
image
:
library/python:3.
5
image
:
library/python:3.
7
stage
:
lint
script
:
-
pip install flake8
-
flake8 go/ --statistics --exit-zero
\ No newline at end of file
-
flake8 go/ --statistics --exit-zero
docker-compose.yml
View file @
c0551abb
version
:
'
2
'
version
:
'
3.7
'
services
:
web
:
build
:
.
...
...
@@ -13,7 +13,7 @@ services:
environment
:
-
GO_ALLOWED_HOSTS=*
-
GO_EMAIL_DOMAIN=@masonlive.gmu.edu
-
GO_CAS_URL=https://
cas.srct
.gmu.edu/
-
GO_CAS_URL=https://
login
.gmu.edu/
-
GO_DB_NAME=go
-
GO_DB_USER=go
-
GO_DB_PASSWORD=go
...
...
@@ -25,10 +25,11 @@ services:
-
GO_EMAIL_HOST_PASSWORD=
-
GO_EMAIL_FROM=
-
GO_EMAIL_TO=
-
superuser=dhaynes3
-
GO_SECRET_KEY=spookyspecret
-
superuser=zwood2
db
:
image
:
mysql
image
:
mysql
:5.7
environment
:
MYSQL_ALLOW_EMPTY_PASSWORD
:
'
yes'
MYSQL_DATABASE
:
go
...
...
go/go/admin.py
View file @
c0551abb
...
...
@@ -2,10 +2,6 @@
go/admin.py
"""
# Future Imports
from
__future__
import
(
absolute_import
,
division
,
print_function
,
unicode_literals
)
# Django Imports
from
django.contrib
import
admin
from
django.contrib.auth.admin
import
UserAdmin
...
...
go/go/cas_callbacks.py
View file @
c0551abb
...
...
@@ -2,85 +2,13 @@
go/cas_callbacks.py
"""
# Future Imports
from
__future__
import
(
absolute_import
,
division
,
print_function
,
unicode_literals
)
# Django Imports
from
django.conf
import
settings
from
django.contrib.auth.models
import
User
# Other Imports
import
requests
def
pfparse
(
pf_name_result
):
"""
Parse what peoplefinder sends back to us and make a list out of it
"""
# name comes in format of Anderson, Nicholas J
name_list
=
pf_name_result
.
split
(
','
)
# there's random whitespace with the first name
first_name_section
=
name_list
[
1
].
strip
()
# check if there's a middle initial
mi_q
=
first_name_section
.
split
(
' '
)
# make sure that the additional elements aren't multiple names
if
len
(
mi_q
[
-
1
])
==
1
:
first_name
=
' '
.
join
(
mi_q
[:
-
1
])
else
:
first_name
=
first_name_section
# our list containing the name of the person in a usable list
new_name_list
=
[
first_name
,
name_list
[
0
]]
return
new_name_list
def
pfinfo
(
uname
):
"""
Get information from peoplefinder
"""
base_url
=
settings
.
PF_URL
url
=
base_url
+
"basic/all/"
+
str
(
uname
)
try
:
metadata
=
requests
.
get
(
url
,
timeout
=
5
)
print
(
"Retrieving information from the peoplefinder api."
)
metadata
.
raise_for_status
()
except
requests
.
exceptions
.
RequestException
as
ex
:
print
(
"Cannot resolve to peoplefinder api:"
,
ex
)
print
(
"Returning empty user info tuple."
)
return
[
''
,
''
]
else
:
pfjson
=
metadata
.
json
()
try
:
if
len
(
pfjson
[
'results'
])
==
1
:
if
pfjson
[
'method'
]
==
'peoplefinder'
:
name_str
=
pfjson
[
'results'
][
0
][
'name'
]
name
=
pfparse
(
name_str
)
elif
pfjson
[
'method'
]
==
'ldap'
:
name
=
[
pfjson
[
'results'
][
0
][
'givenname'
],
pfjson
[
'results'
][
0
][
'surname'
]]
else
:
name
=
pfjson
[
'results'
][
0
][
'name'
]
return
name
else
:
if
pfjson
[
'method'
]
==
'peoplefinder'
:
name_str
=
pfjson
[
'results'
][
1
][
'name'
]
name
=
pfparse
(
name_str
)
elif
pfjson
[
'method'
]
==
'ldap'
:
name
=
[
pfjson
[
'results'
][
1
][
'givenname'
],
pfjson
[
'results'
][
1
][
'surname'
]]
else
:
name
=
pfjson
[
'results'
][
0
][
'name'
]
return
name
# if the name is not in peoplefinder, return empty first and last name
except
IndexError
as
ex
:
print
(
"Name not found in peoplefinder."
)
return
[
''
,
''
]
except
Exception
as
ex
:
print
(
"Unknown peoplefinder error:"
,
ex
)
print
(
"Returning empty user info tuple."
)
return
[
''
,
''
]
def
create_user
(
tree
):
"""
Create a django user based off of the
peoplefinder info we parsed earlier
Create a django user based off of the
CAS info
"""
print
(
"Parsing CAS information."
)
...
...
@@ -90,9 +18,6 @@ def create_user(tree):
except
Exception
as
ex
:
print
(
"CAS callback unsuccessful:"
,
ex
)
# error handling in pfinfo function
info_name
=
pfinfo
(
username
)
try
:
if
user_created
:
print
(
"Created user object %s."
%
username
)
...
...
@@ -106,11 +31,7 @@ def create_user(tree):
user
.
save
()
print
(
"Added user's email, %s."
%
email_str
)
user
.
first_name
=
info_name
[
0
]
user
.
last_name
=
info_name
[
1
]
user
.
save
()
print
(
"Added user's name, %s %s."
%
(
info_name
[
0
],
info_name
[
1
]))
print
(
"User object creation process completed."
)
else
:
...
...
go/go/forms.py
View file @
c0551abb
"""
go/forms.py
"""
# Future Imports
from
__future__
import
(
absolute_import
,
division
,
print_function
,
unicode_literals
)
# Python stdlib Imports
from
datetime
import
datetime
,
timedelta
...
...
@@ -20,7 +17,7 @@ from django.utils.safestring import mark_safe
from
.models
import
URL
,
RegisteredUser
# Other Imports
from
bootstrap3_datetime.widgets
import
DateTimePicker
#
from bootstrap3_datetime.widgets import DateTimePicker
from
crispy_forms.bootstrap
import
(
Accordion
,
AccordionGroup
,
PrependedText
,
StrictButton
)
from
crispy_forms.helper
import
FormHelper
...
...
@@ -94,7 +91,7 @@ class URLForm(ModelForm):
DAY
=
'1 Day'
WEEK
=
'1 Week'
MONTH
=
'1 Month'
CUSTOM
=
'Custom Date'
#
CUSTOM = 'Custom Date'
NEVER
=
'Never'
# Define a tuple of string date standards to be used as our date choices
...
...
@@ -103,7 +100,7 @@ class URLForm(ModelForm):
(
WEEK
,
WEEK
),
(
MONTH
,
MONTH
),
(
NEVER
,
NEVER
),
(
CUSTOM
,
CUSTOM
),
#
(CUSTOM, CUSTOM),
)
# Add preset expiration choices.
...
...
@@ -129,22 +126,22 @@ class URLForm(ModelForm):
# Add a custom expiration choice.
expires_custom
=
DateTimeField
(
required
=
False
,
label
=
'Custom Date'
,
input_formats
=
[
'%m-%d-%Y'
],
validators
=
[
valid_date
],
initial
=
lambda
:
datetime
.
now
()
+
timedelta
(
days
=
1
),
widget
=
DateTimePicker
(
options
=
{
"format"
:
"MM-DD-YYYY"
,
"pickTime"
:
False
,
},
icon_attrs
=
{
"class"
:
"fa fa-calendar"
,
},
)
)
#
expires_custom = DateTimeField(
#
required=False,
#
label='Custom Date',
#
input_formats=['%m-%d-%Y'],
#
validators=[valid_date],
#
initial=lambda: datetime.now() + timedelta(days=1),
#
widget=DateTimePicker(
#
options={
#
"format": "MM-DD-YYYY",
#
"pickTime": False,
#
},
#
icon_attrs={
#
"class": "fa fa-calendar",
#
},
#
)
#
)
def
__init__
(
self
,
*
args
,
**
kwargs
):
"""
...
...
go/go/management/commands/expirelinks.py
View file @
c0551abb
...
...
@@ -2,10 +2,6 @@
go/commands/expirelinks.py
"""
# Future Imports
from
__future__
import
(
absolute_import
,
division
,
print_function
,
unicode_literals
)
# Django Imports
from
django.core.management.base
import
BaseCommand
from
django.utils
import
timezone
...
...
go/go/management/commands/test_expirelinks.py
View file @
c0551abb
...
...
@@ -2,10 +2,6 @@
go/commands/test_expirelinks.py
"""
# Future Imports
from
__future__
import
(
absolute_import
,
division
,
print_function
,
unicode_literals
)
# Python stdlib Imports
from
datetime
import
timedelta
...
...
go/go/models.py
View file @
c0551abb
...
...
@@ -2,10 +2,6 @@
go/models.py
"""
# Future Imports
from
__future__
import
(
absolute_import
,
division
,
print_function
,
unicode_literals
)
# Python stdlib Imports
import
string
...
...
@@ -16,7 +12,8 @@ from django.db import models
from
django.db.models.signals
import
post_save
from
django.dispatch
import
receiver
from
django.utils
import
timezone
from
django.utils.encoding
import
python_2_unicode_compatible
from
django.conf
import
settings
from
django.core.mail
import
EmailMessage
,
send_mail
# Other Imports
from
hashids
import
Hashids
# http://hashids.org/python/
...
...
@@ -26,7 +23,6 @@ HASHIDS = Hashids(
salt
=
"srct.gmu.edu"
,
alphabet
=
(
string
.
ascii_lowercase
+
string
.
digits
)
)
@
python_2_unicode_compatible
class
RegisteredUser
(
models
.
Model
):
"""
This is simply a wrapper model for the user object which, if an object
...
...
@@ -34,7 +30,7 @@ class RegisteredUser(models.Model):
"""
# Let's associate a User to this RegisteredUser
user
=
models
.
OneToOneField
(
User
)
user
=
models
.
OneToOneField
(
User
,
on_delete
=
models
.
CASCADE
)
# What is your name?
full_name
=
models
.
CharField
(
...
...
@@ -52,10 +48,10 @@ class RegisteredUser(models.Model):
description
=
models
.
TextField
(
blank
=
True
)
# Have you filled out the registration form?
registered
=
models
.
BooleanField
(
default
=
Fals
e
)
registered
=
models
.
BooleanField
(
default
=
Tru
e
)
# Are you approved to use Go?
approved
=
models
.
BooleanField
(
default
=
Fals
e
)
approved
=
models
.
BooleanField
(
default
=
Tru
e
)
# Is this User Blocked?
blocked
=
models
.
BooleanField
(
default
=
False
)
...
...
@@ -79,9 +75,25 @@ def handle_regUser_creation(sender, instance, created, **kwargs):
if
created
:
RegisteredUser
.
objects
.
create
(
user
=
instance
)
# Don't send mail for now
#
# user_mail = instance.username + settings.EMAIL_DOMAIN
# send_mail(
# 'We have received your Go application!',
# ######################
# 'Hey there %s,\n\n'
# 'The Go admins have received your application and are '
# 'currently in the process of reviewing it.\n\n'
# 'You will receive another email when you have been '
# 'approved.\n\n'
# '- Go Admins'
# % (str(instance.username)),
# ######################
# settings.EMAIL_FROM,
# [user_mail]
# )
@
python_2_unicode_compatible
class
URL
(
models
.
Model
):
"""
This model represents a stored URL redirection rule. Each URL has an
...
...
@@ -90,7 +102,7 @@ class URL(models.Model):
"""
# Who is the owner of this Go link
owner
=
models
.
ForeignKey
(
RegisteredUser
)
owner
=
models
.
ForeignKey
(
RegisteredUser
,
on_delete
=
models
.
CASCADE
)
# When was this link created?
date_created
=
models
.
DateTimeField
(
default
=
timezone
.
now
)
...
...
@@ -133,11 +145,17 @@ class URL(models.Model):
should be updated to be simpler
"""
if
cache
.
get
(
"hashids_counter"
)
is
None
:
print
(
URL
.
objects
.
count
())
cache
.
set
(
"hashids_counter"
,
URL
.
objects
.
count
())
print
(
cache
.
get
(
"hashids_counter"
))
tries
=
1
while
tries
<
100
:
try
:
short
=
HASHIDS
.
encrypt
(
cache
.
get
(
"hashids_counter"
))
counter
=
cache
.
get
(
"hashids_counter"
)
if
counter
is
None
:
short
=
HASHIDS
.
encrypt
(
0
)
else
:
short
=
HASHIDS
.
encrypt
(
counter
)
tries
+=
1
cache
.
incr
(
"hashids_counter"
)
URL
.
objects
.
get
(
short__iexact
=
short
)
...
...
go/go/templates/admin/useradmin.html
View file @
c0551abb
...
...
@@ -25,126 +25,7 @@
</div>
</div>
<!-- Table 1 -->
<!-- define the div where we can select users from a table to judge them -->
<div
class=
"row"
>
<div
class=
"col-md-12"
>
<input
class=
"inputfilter"
type=
"text"
id=
"appliedInput"
placeholder=
"Search Usernames or Full Names"
>
<h3>
Users awaiting moderation
</h3>
<form
method=
"post"
action=
"useradmin"
>
<!-- csrf protection -->
{% csrf_token %}
<!-- define out table of users that need approval -->
<table
class=
"table table-striped table-hover"
id=
"appliedTable"
>
<!-- define the header row -->
<thead>
<tr>
<th>
Selected
</th>
<th>
Username
</th>
<th>
Full Name
</th>
<th>
Description
</th>
</tr>
</thead>
<!-- define the body rows -->
<tbody>
<!-- loop through all users in the need_approval list -->
{% for unapproved in need_approval %}
<!-- ..and make a new row for each user -->
<tr>
<td><input
type=
"checkbox"
name=
"username"
value=
{{
unapproved.user
}}
></td>
<td>
{{ unapproved.user }}
</td>
<td>
{{ unapproved.full_name }}
</td>
<td>
{{ unapproved.description|default:"No description provided" }}
</td>
</tr>
<!-- unless it's empty in which case we show nothing -->
{% empty %}
<tr>
<td>
none
</td>
<td>
none
</td>
<td>
none
</td>
<td>
none
</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- a div containing our form submission buttons -->
<div
class=
"form-group"
>
<!-- the modal for approve doesn't work-->
<a
class=
"btn btn-primary btn-sm"
data-target=
"#approveModal"
data-toggle=
"modal"
>
Approve
</a>
<!-- input type="submit" name="_approve" value="Approve" class="btn btn-primary btn-sm"-->
<a
class=
"btn btn-danger btn-sm"
data-target=
"#denyModal"
data-toggle=
"modal"
>
Deny
</a>
<a
class=
"btn btn-default btn-sm btn-blockUsr"
data-target=
"#blockModal"
data-toggle=
"modal"
style=
"background-color: black; color: white;"
>
Block
</a>
<!--Define Approve Modal-->
<div
id=
"approveModal"
class=
"modal fade"
role=
"dialog"
tabindex=
"-1"
>
<div
class=
"modal-dialog"
>
<!-- Modal content-->
<div
class=
"modal-content"
style=
"background-color:#f5f5f5; border-radius: 7px"
>
<div
class=
"modal-header"
style=
"text-align:center;"
>
<h4
class=
"modal-title text-center"
style=
"font-weight:bold; font-size: 21px !important;"
>
Are you sure you would like to approve a user?
</h4>
</div>
<div
class=
"modal-body"
style=
"padding-bottom: 80px"
>
<a
type=
"button"
class=
"btn btn-success btn-lg"
style=
"border-width: 0px;float:left; width:49%; background-color: #A9B0AD; color: #ffffff; border-radius: 4px;"
data-dismiss=
"modal"
>
Cancel
</a>
<input
type=
"submit"
name=
"_approve"
value=
"Approve"
class=
"btn btn-primary btn-lg"
style=
"border-width: 0px;float:right; width:49%; background-color: #00331a; color: #ffffff; border-radius: 4px;"
>
</div>
</div>
</div>
</div>
<!--Define Deny Modal-->
<div
id=
"denyModal"
class=
"modal fade"
role=
"dialog"
tabindex=
"-1"
>
<div
class=
"modal-dialog"
>
<!-- Modal content-->
<div
class=
"modal-content"
style=
"background-color:#f5f5f5; border-radius: 7px"
>
<div
class=
"modal-header"
style=
"text-align:center;"
>
<h4
class=
"modal-title text-center"
style=
"font-weight:bold; font-size: 21px !important;"
>
Are you sure you would like to deny a user?
</h4>
<h6
style=
"font-weight:500; margin-top: 0px; margin-bottom: 0px;"
>
Please remember the user's feelings
</h6>
</div>
<div
class=
"modal-body"
style=
"padding-bottom: 80px"
>
<a
type=
"button"
class=
"btn btn-success btn-lg"
style=
"border-width: 0px;float:left; width:49%; background-color: #A9B0AD; color: #ffffff; border-radius: 4px;"
data-dismiss=
"modal"
>
Cancel
</a>
<input
type=
"submit"
name=
"_deny"
value=
"Deny"
class=
"btn btn-danger btn-lg"
style=
"border-width: 0px;float:right; width:49%; background-color: #ac1d37; color: #ffffff; border-radius: 4px;"
>
</div>
</div>
</div>
</div>
<!--Define Block Modal-->
<!-- Note there is another block modal for the other block button below -->
<div
id=
"blockModal"
class=
"modal fade"
role=
"dialog"
tabindex=
"-1"
>
<div
class=
"modal-dialog"
>
<!-- Modal content-->
<div
class=
"modal-content"
style=
"background-color:#f5f5f5; border-radius: 7px"
>
<div
class=
"modal-header"
style=
"text-align:center;"
>
<h4
class=
"modal-title text-center"
style=
"font-weight:bold; font-size: 21px !important;"
>
Are you sure you would like to block a user?
</h4>
<h6
style=
"font-weight:500; margin-top: 0px; margin-bottom: 0px;"
>
Please remember the user's feelings
</h6>
</div>
<div
class=
"modal-body"
style=
"padding-bottom: 80px"
>
<a
type=
"button"
class=
"btn btn-success btn-lg"
style=
"border-width: 0px;float:left; width:49%; background-color: #A9B0AD; color: #ffffff; border-radius: 4px;"
data-dismiss=
"modal"
>
Cancel
</a>
<input
type=
"submit"
name=
"_block"
value=
"Block"
class=
"btn btn-danger btn-lg"
style=
"border-width: 0px; float:right; width:49%;background-color: #000000; color: #ffffff; border-radius: 4px;"
>
</div>
</div>
</div>
</div>
</div>
</form>
</div>
</div>
<!-- Table 2 -->
<!-- define the table that displays blocked users and allows us to unblock them -->
<div
class=
"row"
>
<div
class=
"col-md-12"
>
...
...
@@ -160,9 +41,7 @@
<tr>
<th>
Selected
</th>
<th>
Username
</th>
<th>
Full Name
</th>
<th>
Description
</th>
<th>
Approved
</th>
<th>
Email
</th>
</tr>
</thead>
...
...
@@ -173,9 +52,7 @@
<tr>
<td><input
type=
"checkbox"
name=
"username"
value=
{{
blockedUsers.user
}}
></td>
<td>
{{ blockedUsers.user }}
</td>
<td>
{{ blockedUsers.full_name }}
</td>
<td>
{{ blockedUsers.description|default:"No description provided" }}
</td>
<td>
{{ blockedUsers.approved }}
</td>
<td>
{{ blockedUsers.user.email }}
</td>
</tr>
<!-- unless there are no blocked users -->
{% empty %}
...
...
@@ -183,8 +60,6 @@
<td>
none
</td>
<td>
none
</td>
<td>
none
</td>
<td>
none
</td>
<td>
none
</td>
</tr>
{% endfor %}
</tbody>
...
...
@@ -219,7 +94,7 @@
</div>
</div>
<!-- Table
3
-->
<!-- Table
2
-->
<!-- define the table of current users -->
<div
class=
"row"
>
<div
class=
"col-md-12"
>
...
...
@@ -234,9 +109,8 @@
<thead>
<tr>
<th>
Selected
</th>
<th>
Username
</th>
<th>
Full Name
</th>
<th>
Description
</th>
<th>
Username
</th>
<th>
Email
</th>
</tr>
</thead>
...
...
@@ -246,15 +120,13 @@
{% for currentUsers in current_users %}
<tr>
<td><input
type=
"checkbox"
name=
"username"
value=
{{
currentUsers.user
}}
></td>
<td>
{{ currentUsers.user }}
</td>
<td>
{{ currentUsers.full_name }}
</td>
<td>
{{ currentUsers.description|default:"No description provided" }}
</td>
<td>
{{ currentUsers.user }}
</td>
<td>
{{ currentUsers.user.email }}
</td>
</tr>
<!-- unless there are none -->
{% empty %}
<tr>
<td>
none
</td>
<td>
none
</td>
<td>
none
</td>
<td>
none
</td>
<td>
none
</td>
</tr>
...
...
@@ -315,4 +187,4 @@
<script
src=
"{% static "
js
/
useradmin.js
"
%}"
></script>
{% endblock %}
\ No newline at end of file
{% endblock %}
go/go/templates/layouts/navigation.html
View file @
c0551abb
...
...
@@ -26,13 +26,7 @@
<!-- only show if the current user is authenticated -->
{% if user.is_authenticated %}
<!-- only if they are an approved user -->
{% if user.registereduser.approved %}
<li><a
href=
"{% url 'new_link' %}"
>
New Link
</a></li>
{% endif %}
<!-- only if the current user has not registered to use go -->
{% if not user.registereduser.registered %}
<li><a
href=
"{% url 'signup' %}"
>
Register
</a></li>
{% endif %}
<li><a
href=
"{% url 'new_link' %}"
>
New Link
</a></li>
{% endif %}
</ul>
...
...
go/go/templates/link_box.html
View file @
c0551abb
...
...
@@ -90,7 +90,7 @@
<strong>
Target URL:
</strong>
<a
href=
"{{url.target}}"
target=
"_blank"
>
{{url.target}}
</a>
<br
/>
{% if url.owner == request.user.registereduser
and request.user.registereduser.approved == True
%}
{% if url.owner == request.user.registereduser %}
<strong>
Clicks:
</strong>
{{url.clicks}}
<br
/>
...
...
@@ -208,4 +208,4 @@