Commit 1f197932 authored by David Haynes's avatar David Haynes 🙆
Browse files

Merge branch '25-edit-links' into '2.2-dev'

Resolve "Allow users to edit their own links"

See merge request !82
parents ef18dac9 68b66572
Pipeline #1188 passed with stage
in 2 minutes and 7 seconds
......@@ -11,8 +11,10 @@ from datetime import datetime, timedelta
from six.moves import urllib
# Django Imports
from django import forms
from django.core.exceptions import ValidationError
from django.forms import (BooleanField, CharField, ChoiceField, DateTimeField,
ModelForm, RadioSelect, SlugField, Textarea,
TextInput, URLField, URLInput)
from django.utils import timezone
from django.utils.safestring import mark_safe
......@@ -27,7 +29,7 @@ from crispy_forms.helper import FormHelper
from crispy_forms.layout import HTML, Div, Field, Fieldset, Layout, Submit
class URLForm(forms.ModelForm):
class URLForm(ModelForm):
"""
The form that is used in URL creation.
"""
......@@ -66,11 +68,11 @@ class URLForm(forms.ModelForm):
return target
# Custom target URL field
target = forms.URLField(
target = URLField(
required=True,
label='Long URL (Required)',
max_length=1000,
widget=forms.URLInput(attrs={
widget=URLInput(attrs={
'placeholder': 'https://yoursite.com/'
})
)
......@@ -92,10 +94,10 @@ class URLForm(forms.ModelForm):
raise ValidationError('Short url already exists.')
# Custom short-url field with validators.
short = forms.SlugField(
short = SlugField(
required=False,
label='Short URL (Optional)',
widget=forms.TextInput(),
widget=TextInput(),
validators=[unique_short],
max_length=20,
min_length=3,
......@@ -120,12 +122,12 @@ class URLForm(forms.ModelForm):
)
# Add preset expiration choices.
expires = forms.ChoiceField(
expires = ChoiceField(
required=True,
label='Expiration (Required)',
choices=EXPIRATION_CHOICES,
initial=NEVER,
widget=forms.RadioSelect(),
widget=RadioSelect(),
)
def valid_date(value):
......@@ -142,7 +144,7 @@ class URLForm(forms.ModelForm):
# Add a custom expiration choice.
expires_custom = forms.DateTimeField(
expires_custom = DateTimeField(
required=False,
label='Custom Date',
input_formats=['%m-%d-%Y'],
......@@ -233,37 +235,107 @@ class URLForm(forms.ModelForm):
# what attributes are included
fields = ['target']
class SignupForm(forms.ModelForm):
class EditForm(URLForm):
def __init__(self, *args, **kwargs):
"""
On initialization of the form, crispy forms renders this layout
"""
# Grab that host info
self.host = kwargs.pop('host', None)
super(URLForm, self).__init__(*args, **kwargs)
# Define the basics for crispy-forms
self.helper = FormHelper()
self.helper.form_method = 'POST'
# Some xtra vars for form css purposes
self.helper.form_class = 'form-horizontal'
self.helper.label_class = 'col-md-1'
self.helper.field_class = 'col-md-6'
# The main "layout" defined
self.helper.layout = Layout(
Fieldset('',
#######################
Accordion(
# Step 1: Long URL
AccordionGroup('Step 1: Long URL',
Div(
HTML("""
<h4>Modify the URL you would like to shorten:</h4>
<br />"""),
'target',
style="background: rgb(#F6F6F6);"),
active=True,
template='crispy/accordian-group.html'),
# Step 2: Short URL
AccordionGroup('Step 2: Short URL',
Div(
HTML("""
<h4>Modify the Go address:</h4>
<br />"""),
PrependedText(
'short', 'https://go.gmu.edu/', template='crispy/customPrepended.html'),
style="background: rgb(#F6F6F6);"),
active=True,
template='crispy/accordian-group.html',),
# Step 3: Expiration
AccordionGroup('Step 3: URL Expiration',
Div(
HTML("""
<h4>Modify the expiration date:</h4>
<br />"""),
'expires',
Field('expires_custom', template="crispy/customDateField.html"),
style="background: rgb(#F6F6F6);"),
active=True,
template='crispy/accordian-group.html'),
# FIN
template='crispy/accordian.html'),
#######################
HTML("""
<br />"""),
StrictButton('Submit Changes', css_class="btn btn-primary btn-md col-md-4", type='submit')))
class Meta(URLForm.Meta):
# what attributes are included
fields = URLForm.Meta.fields
class SignupForm(ModelForm):
"""
The form that is used when a user is signing up to be a RegisteredUser
"""
# The full name of the RegisteredUser
full_name = forms.CharField(
full_name = CharField(
required=True,
label='Full Name (Required)',
max_length=100,
widget=forms.TextInput(),
widget=TextInput(),
)
# The RegisteredUser's chosen organization
organization = forms.CharField(
organization = CharField(
required=True,
label='Organization (Required)',
max_length=100,
widget=forms.TextInput(),
widget=TextInput(),
)
# The RegisteredUser's reason for signing up to us Go
description = forms.CharField(
description = CharField(
required=False,
label='Description (Optional)',
max_length=200,
widget=forms.Textarea(),
widget=Textarea(),
)
# A user becomes registered when they agree to the TOS
registered = forms.BooleanField(
registered = BooleanField(
required=True,
# ***Need to replace lower url with production URL***
# ie. go.gmu.edu/about#terms
......
<!-- include the base html template -->
{% extends 'layouts/base.html' %}
<!-- Tell Django to load static files -->
{% load staticfiles %}
<!-- load django crispy forms' tags -->
{% load crispy_forms_tags %}
<!-- define the page title block -->
{% block title %}
SRCT Go &bull; Edit Link
{% endblock %}
<!-- define the content block for the page -->
{% block content %}
<!-- define the page header div -->
<div class="page-header" id="banner">
<div class="row">
<div class="col-md-12">
<h1><strong>
<span class="fa-stack fa-lg">
<i class="fa fa-circle fa-stack-2x"></i>
<i class="fa fa-wrench fa-stack-1x fa-inverse"></i>
</span>
<i class="fa">Edit Link</i>
</strong></h1>
</div>
</div>
</div>
<!-- call django crispy forms to render the go link creation form here -->
{% crispy form %}
<!-- load some JS to hide/show the custom date field -->
<script src="{% static "js/new_link.js" %}"></script>
{% endblock %}
<!-- include the base html template -->
{% extends 'layouts/base.html' %}
<!-- Tell Django to load static files -->
{% load staticfiles %}
<!-- load django crispy forms' tags -->
{% load crispy_forms_tags %}
<!-- define the page title block -->
{% block title %}
SRCT Go &bull; A University Branded URL Shortener
SRCT Go &bull; New Link
{% endblock %}
<!-- define the content block for the page -->
......@@ -31,32 +34,6 @@ SRCT Go &bull; A University Branded URL Shortener
{% crispy form %}
<!-- load some JS to hide/show the custom date field -->
<script type="text/javascript">
$(function() {
// hide by default
$("div_id_expires_custom").hide();
// if the expires_custom checkbox is checked..
if ($("#id_expires_5").is(":checked")) {
// display the field
$("#div_id_expires_custom").slideDown();
} else {
// keep it up
$("#div_id_expires_custom").slideUp();
}
// if the expires_custom checkbox is clicked..
$("#div_id_expires").click(function() {
// if the expires_custom checkbox is checked..
if ($("#id_expires_5").is(":checked")) {
// display the field
$("#div_id_expires_custom").slideDown();
} else {
// keep it hidden
$("#div_id_expires_custom").slideUp();
}
})
})
</script>
<script src="{% static "js/new_link.js" %}"></script>
{% endblock %}
......@@ -35,8 +35,6 @@
rel="stylesheet">
<link rel="stylesheet" href="{% static "css/bootswatch.min.css" %}" />
<link rel="stylesheet" href="{% static "css/styles.css" %}" />
<link rel="stylesheet" href="{% static "css/style-link-box.css" %}" />
<link rel="stylesheet" href="{% static "css/style-link-box.css" %}" />
<!-- Load in global js -->
<script src="{% static "js/jquery.min.js" %}"></script>
......@@ -58,8 +56,5 @@
<!-- Load in the footer template -->
{% include 'layouts/footer.html' %}
<!-- Load in js that relies on page elements -->
<script src="{% static "js/copy.js" %}"></script>
</body>
</html>
......@@ -61,7 +61,7 @@
<div class="col-md-8">
<!-- what is the address? -->
<h3>Go Address:</h3>
<div id="link-container" class="input-group">
<div id="link-container-{{url.short}}" class="input-group">
<a id="link-{{url.short}}" class="form-control" href="{{domain}}{{url.short}}" target="_blank">
{{domain}}{{url.short}}
</a>
......@@ -95,17 +95,17 @@
<strong>Expires:</strong> {{url.expires|default_if_none:"Never"}}
<br></br>
<!-- Delete Button -->
<a class="button btn btn-danger btn-sm fa" data-target="#deletionModal-{{url.short}}" data-toggle="modal">
<i class="fa fa-trash-o fa-lg"></i> Delete
</a>
<!-- edit button -->
<a tabindex="0" class="button btn btn-info btn-sm fa" role="button" data-toggle="popover" title="Not Implemented Quite Yet :(" data-content="We are working on adding this feature in a future
release of Go. If you'd like to check that progress or potentially
contribute code, head to git.gmu.edu/srct/go">
<i class="fa fa-cog"></i> Edit
<!-- Edit Button -->
<a href="{{domain}}edit/{{url.short}}" class="button btn btn-info btn-sm fa">
<i class="fa fa-cog fa-lg"></i> Edit
</a>
<!--Define Delete Modal-->
<div id="deletionModal-{{url.short}}" class="modal fade" role="dialog" tabindex="-1">
<div class="modal-dialog">
......
......@@ -17,7 +17,7 @@ from django.contrib.auth.models import User
from django.test import TestCase
# App Imports
from go.forms import SignupForm, URLForm
from go.forms import SignupForm, URLForm, EditForm
from go.models import URL, RegisteredUser
class URLFormTest(TestCase):
......@@ -134,6 +134,21 @@ class URLFormTest(TestCase):
print(form.errors)
self.assertFalse(form.is_valid())
class EditForm(TestCase):
"""
Test cases for the edit URL form.
As currently this form inherits from the URLForm and does not add any fields,
we cannot test any values. It exists purely for aesthetics.
"""
def test_django_test(self):
"""
Default test case, does not actually test anything
"""
self.assertEqual("Hello World!", "Hello World!")
class SignupFormTest(TestCase):
"""
Test cases for the Signup form
......
......@@ -104,6 +104,33 @@ class ViewTest(TestCase):
response = self.client.get('/view/test')
self.assertEqual(response.status_code, 200)
class EditTest(TestCase):
"""
Test cases for the edit view
"""
def setUp(self):
"""
Set up any variables such as dummy objects that will be utilised in
testing methods
"""
# Setup a blank URL object with an owner
User.objects.create(username='dhaynes', password='password')
get_user = User.objects.get(username='dhaynes')
get_registered_user = RegisteredUser.objects.get(user=get_user)
URL.objects.create(owner=get_registered_user, short='test')
def test_edit_get_anon(self):
"""
Test that the delete view redirects anons to login with cas on an EXTERNAL
CAS link, so 302 REDIRECT.
"""
response = self.client.get('/edit/test')
self.assertEqual(response.status_code, 302)
class DeleteTest(TestCase):
"""
Test cases for the delete view
......
......@@ -24,7 +24,7 @@ from django.utils import timezone
from ratelimit.decorators import ratelimit
# App Imports
from go.forms import SignupForm, URLForm
from go.forms import SignupForm, URLForm, EditForm
from go.models import URL, RegisteredUser
......@@ -193,6 +193,127 @@ def view(request, short):
'domain': domain,
})
@login_required
def edit(request, short):
"""
This view allows a logged in user to edit the details of a Go link that they
own. They can modify any value that they wish. If `short` is modified then
we will need to create a new link and copy over stats from the previous.
"""
# Do not allow unapproved users to edit links
if not request.user.registereduser.approved:
if request.user.registereduser.blocked:
return render(request, 'banned.html')
else:
return render(request, 'not_registered.html')
# Get the URL that is going to be edited
url = get_object_or_404(URL, short__iexact=short)
# If the RegisteredUser is the owner of the URL
if url.owner == request.user.registereduser:
# If a POST request is received, then the user has submitted a form and it's
# time to parse the form and edit that URL object
if request.method == 'POST':
# Now we initialize the form again but this time we have the POST
# request
url_form = EditForm(request.POST, host=request.META.get('HTTP_HOST'))
# Make a copy of the old URL
copy = url
# Remove the old one
url.delete()
# Django will check the form to make sure it's valid
if url_form.is_valid():
# If the short changed then we need to create a new object and
# migrate some data over
if url_form.cleaned_data.get('short').strip() != copy.short:
# Parse the form and create a new URL object
res = post(request, url_form)
# If there is a 500 error returned, handle it
if res == 500:
return HttpResponseServerError(render(request, '500.html'))
# We can procede with the editing process
else:
# Migrate clicks data
res.clicks = copy.clicks
res.qrclicks = copy.qrclicks
res.socialclicks = copy.socialclicks
# Save the new URL
res.save()
# Redirect to the shiny new *edited URL
return redirect('view', res.short)
# The short was not edited and thus, we can directly edit the url
else:
if url_form.cleaned_data.get('target').strip() != copy.target:
copy.target = url_form.cleaned_data.get('target').strip()
copy.save()
# Grab the expiration field value. It's currently an unsable
# string value, so we need to parse it into a datetime object
# relative to right now.
expires = url_form.cleaned_data.get('expires')
# Determine what the expiration date is
if expires == URLForm.DAY:
edited_expires = timezone.now() + timedelta(days=1)
elif expires == URLForm.WEEK:
edited_expires = timezone.now() + timedelta(weeks=1)
elif expires == URLForm.MONTH:
edited_expires = timezone.now() + timedelta(weeks=3)
elif expires == URLForm.CUSTOM:
edited_expires = url_form.cleaned_data.get('expires_custom')
else:
pass # leave the field NULL
if edited_expires != copy.expires:
copy.expires = edited_expires
copy.save()
# Redirect to the shiny new *edited URL
return redirect('view', copy.short)
# Else, there is an error, redisplay the form with the validation errors
else:
# Render index.html passing the form to the template
return render(request, 'core/edit_link.html', {
'form': url_form
})
else:
# Initial data set here
if url.expires != None:
# Initialize a URL form with an expire date
url_form = EditForm(host=request.META.get('HTTP_HOST'), initial={
'target': url.target,
'short': url.short,
'expires': 'Custom Date',
'expires_custom': url.expires
}) # unbound form
else:
# Initialize a URL form without an expire date
url_form = EditForm(host=request.META.get('HTTP_HOST'), initial={
'target': url.target,
'short': url.short,
'expires': 'Never',
}) # unbound form
# Render index.html passing the form to the template
return render(request, 'core/edit_link.html', {
'form': url_form
})
else:
# do not allow them to edit
raise PermissionDenied()
@login_required
def delete(request, short):
"""
......@@ -211,7 +332,7 @@ def delete(request, short):
if url.owner == request.user.registereduser:
# remove the URL
url.delete()
# rediret to my_links
# redirect to my_links
return redirect('my_links')
else:
# do not allow them to delete
......
......@@ -82,6 +82,39 @@ class UrlsTest(TestCase):
url = reverse('my_links')
self.assertEqual(url, '/myLinks')
def test_edit_reverse_chars(self):
"""
/edit/<short> - Delete a link, no content display.
"""
url = reverse('edit', args=['dhaynes'])
self.assertEqual(url, '/edit/dhaynes')
def test_edit_reverse_ints(self):
"""
/edit/<short> - Delete a link, no content display.
"""
url = reverse('edit', args=['123456789'])
self.assertEqual(url, '/edit/123456789')
def test_edit_reverse_chars_ints(self):
"""
/edit/<short> - Delete a link, no content display.
"""
url = reverse('edit', args=['dhaynes123'])
self.assertEqual(url, '/edit/dhaynes123')
def test_edit_reverse_full_slug(self):
"""
/edit/<short> - Delete a link, no content display.
"""
url = reverse('edit', args=['dhaynes123_-'])
self.assertEqual(url, '/edit/dhaynes123_-')
def test_delete_reverse_chars(self):
"""
/delete/<short> - Delete a link, no content display.
......
......@@ -42,6 +42,9 @@ urlpatterns = [
# /myLinks - My-Links page, view and review links.
url(r'^myLinks/?$', go.views.my_links, name='my_links'),
# /edit/<short> - Edit link form
url(r'^edit/(?P<short>[-\w]+)$', go.views.edit, name='edit'),
# /delete/<short> - Delete a link, no content display.
url(r'^delete/(?P<short>[-\w]+)$', go.views.delete, name='delete'),
......
a.share:link {color:#000000;}
a.share:visited {color:#000000;}
a.share:hover {color:#000000;}
a.share {text-decoration:none;}
a.button:link {color:#FFF;}
a.button:visited {color:#FFF;}
a.button:hover {color:#FFF;}
legend {
font-size: inherit;
}
#button-container {
padding-bottom: 4px;
padding-left: 6px;
padding-right: 6px;
padding-top: 4px;
}
#copy-button {
border: none;
background-color: #eeeeee;
padding-bottom: 3px;
}
#copy-button:focus {
outline: none;
}
#clipboard-icon {
font-size: 14px;
}