diff options
-rw-r--r-- | mediagoblin/auth/views.py | 10 | ||||
-rw-r--r-- | mediagoblin/config_spec.ini | 4 | ||||
-rw-r--r-- | mediagoblin/db/indexes.py | 15 | ||||
-rw-r--r-- | mediagoblin/db/models.py | 2 | ||||
-rw-r--r-- | mediagoblin/edit/forms.py | 4 | ||||
-rw-r--r-- | mediagoblin/edit/views.py | 12 | ||||
-rw-r--r-- | mediagoblin/submit/forms.py | 4 | ||||
-rw-r--r-- | mediagoblin/submit/views.py | 8 | ||||
-rw-r--r-- | mediagoblin/templates/mediagoblin/submit/start.html | 1 | ||||
-rw-r--r-- | mediagoblin/templates/mediagoblin/user_pages/media.html | 3 | ||||
-rw-r--r-- | mediagoblin/templates/mediagoblin/user_pages/user.html | 1 | ||||
-rw-r--r-- | mediagoblin/templates/mediagoblin/utils/object_gallery.html | 6 | ||||
-rw-r--r-- | mediagoblin/templates/mediagoblin/utils/tags.html | 25 | ||||
-rw-r--r-- | mediagoblin/tests/test_mgoblin_app.ini | 4 | ||||
-rw-r--r-- | mediagoblin/tests/test_submission.py | 39 | ||||
-rw-r--r-- | mediagoblin/tests/test_tags.py | 50 | ||||
-rw-r--r-- | mediagoblin/util.py | 57 |
17 files changed, 231 insertions, 14 deletions
diff --git a/mediagoblin/auth/views.py b/mediagoblin/auth/views.py index fb5db870..df7e2a88 100644 --- a/mediagoblin/auth/views.py +++ b/mediagoblin/auth/views.py @@ -145,23 +145,19 @@ def verify_email(request): user['status'] = u'active' user['email_verified'] = True user.save() - verification_successful = True messages.add_message( request, messages.SUCCESS, ('Your email address has been verified. ' 'You may now login, edit your profile, and submit images!')) else: - verification_successful = False messages.add_message(request, messages.ERROR, 'The verification key or user id is incorrect') - return render_to_response( - request, - 'mediagoblin/user_pages/user.html', - {'user': user, - 'verification_successful' : verification_successful}) + return redirect( + request, 'mediagoblin.user_pages.user_home', + user=request.user['username']) def resend_activation(request): diff --git a/mediagoblin/config_spec.ini b/mediagoblin/config_spec.ini index 28be5f34..bbc1f7d6 100644 --- a/mediagoblin/config_spec.ini +++ b/mediagoblin/config_spec.ini @@ -24,6 +24,10 @@ email_sender_address = string(default="notice@mediagoblin.example.org") # Set to false to disable registrations allow_registration = boolean(default=True) +# tag parsing +tags_delimiter = string(default=",") +tags_max_length = integer(default=50) + # By default not set, but you might want something like: # "%(here)s/user_dev/templates/" local_templates = string() diff --git a/mediagoblin/db/indexes.py b/mediagoblin/db/indexes.py index a832e013..30d43c98 100644 --- a/mediagoblin/db/indexes.py +++ b/mediagoblin/db/indexes.py @@ -90,6 +90,21 @@ MEDIAENTRY_INDEXES = { # Indexing on uploaders and when media entries are created. # Used for showing a user gallery, etc. 'index': [('uploader', ASCENDING), + ('created', DESCENDING)]}, + + 'state_uploader_tags_created': { + # Indexing on processed?, media uploader, associated tags, and timestamp + # Used for showing media items matching a tag search, most recent first. + 'index': [('state', ASCENDING), + ('uploader', ASCENDING), + ('tags.slug', DESCENDING), + ('created', DESCENDING)]}, + + 'state_tags_created': { + # Indexing on processed?, media tags, and timestamp (across all users) + # This is used for a front page tag search. + 'index': [('state', ASCENDING), + ('tags.slug', DESCENDING), ('created', DESCENDING)]}} diff --git a/mediagoblin/db/models.py b/mediagoblin/db/models.py index bad15aca..4ef2d928 100644 --- a/mediagoblin/db/models.py +++ b/mediagoblin/db/models.py @@ -184,7 +184,7 @@ class MediaEntry(Document): 'media_type': unicode, 'media_data': dict, # extra data relevant to this media_type 'plugin_data': dict, # plugins can dump stuff here. - 'tags': [unicode], + 'tags': [dict], 'state': unicode, # For now let's assume there can only be one main file queued diff --git a/mediagoblin/edit/forms.py b/mediagoblin/edit/forms.py index 0ed52af1..a1783a72 100644 --- a/mediagoblin/edit/forms.py +++ b/mediagoblin/edit/forms.py @@ -16,6 +16,7 @@ import wtforms +from mediagoblin.util import tag_length_validator, TOO_LONG_TAG_WARNING class EditForm(wtforms.Form): @@ -26,6 +27,9 @@ class EditForm(wtforms.Form): 'Slug', [wtforms.validators.Required(message="The slug can't be empty")]) description = wtforms.TextAreaField('Description of this work') + tags = wtforms.TextField( + 'Tags', + [tag_length_validator]) class EditProfileForm(wtforms.Form): bio = wtforms.TextAreaField('Bio', diff --git a/mediagoblin/edit/views.py b/mediagoblin/edit/views.py index f372fbb9..5cbaadb5 100644 --- a/mediagoblin/edit/views.py +++ b/mediagoblin/edit/views.py @@ -16,10 +16,13 @@ from webob import exc +from string import split from mediagoblin import messages +from mediagoblin import mg_globals from mediagoblin.util import ( - render_to_response, redirect, cleaned_markdown_conversion) + render_to_response, redirect, clean_html, convert_to_tag_list_of_dicts, + media_tags_as_string, cleaned_markdown_conversion) from mediagoblin.edit import forms from mediagoblin.edit.lib import may_edit_media from mediagoblin.decorators import require_active_login, get_user_media_entry @@ -34,7 +37,8 @@ def edit_media(request, media): form = forms.EditForm(request.POST, title = media['title'], slug = media['slug'], - description = media['description']) + description = media['description'], + tags = media_tags_as_string(media['tags'])) if request.method == 'POST' and form.validate(): # Make sure there isn't already a MediaEntry with such a slug @@ -50,7 +54,9 @@ def edit_media(request, media): else: media['title'] = request.POST['title'] media['description'] = request.POST.get('description') - + media['tags'] = convert_to_tag_list_of_dicts( + request.POST.get('tags')) + media['description_html'] = cleaned_markdown_conversion( media['description']) diff --git a/mediagoblin/submit/forms.py b/mediagoblin/submit/forms.py index 3fd9ea49..f02c95a6 100644 --- a/mediagoblin/submit/forms.py +++ b/mediagoblin/submit/forms.py @@ -16,6 +16,7 @@ import wtforms +from mediagoblin.util import tag_length_validator class SubmitStartForm(wtforms.Form): @@ -24,3 +25,6 @@ class SubmitStartForm(wtforms.Form): [wtforms.validators.Length(min=0, max=500)]) description = wtforms.TextAreaField('Description of this work') file = wtforms.FileField('File') + tags = wtforms.TextField( + 'Tags', + [tag_length_validator]) diff --git a/mediagoblin/submit/views.py b/mediagoblin/submit/views.py index 1848f5e5..87e57dda 100644 --- a/mediagoblin/submit/views.py +++ b/mediagoblin/submit/views.py @@ -16,11 +16,13 @@ from os.path import splitext from cgi import FieldStorage +from string import split from werkzeug.utils import secure_filename from mediagoblin.util import ( - render_to_response, redirect, cleaned_markdown_conversion) + render_to_response, redirect, cleaned_markdown_conversion, \ + convert_to_tag_list_of_dicts) from mediagoblin.decorators import require_active_login from mediagoblin.submit import forms as submit_forms, security from mediagoblin.process_media import process_media_initial @@ -59,6 +61,10 @@ def submit_start(request): entry['media_type'] = u'image' # heh entry['uploader'] = request.user['_id'] + # Process the user's folksonomy "tags" + entry['tags'] = convert_to_tag_list_of_dicts( + request.POST.get('tags')) + # Save, just so we can get the entry id for the sake of using # it to generate the file path entry.save(validate=False) diff --git a/mediagoblin/templates/mediagoblin/submit/start.html b/mediagoblin/templates/mediagoblin/submit/start.html index 50c86afe..6d00510c 100644 --- a/mediagoblin/templates/mediagoblin/submit/start.html +++ b/mediagoblin/templates/mediagoblin/submit/start.html @@ -27,6 +27,7 @@ {{ wtforms_util.render_field_div(submit_form.file) }} {{ wtforms_util.render_field_div(submit_form.title) }} {{ wtforms_util.render_textarea_div(submit_form.description) }} + {{ wtforms_util.render_field_div(submit_form.tags) }} <div class="form_submit_buttons"> <input type="submit" value="Submit" class="button" /> </div> diff --git a/mediagoblin/templates/mediagoblin/user_pages/media.html b/mediagoblin/templates/mediagoblin/user_pages/media.html index dc0b6210..7622d6e6 100644 --- a/mediagoblin/templates/mediagoblin/user_pages/media.html +++ b/mediagoblin/templates/mediagoblin/user_pages/media.html @@ -116,6 +116,9 @@ </p> {% endif %} </p> + {% if media.tags %} + {% include "mediagoblin/utils/tags.html" %} + {% endif %} </div> {% else %} <p>Sorry, no such media found.<p/> diff --git a/mediagoblin/templates/mediagoblin/user_pages/user.html b/mediagoblin/templates/mediagoblin/user_pages/user.html index 7769b8b3..76cf36be 100644 --- a/mediagoblin/templates/mediagoblin/user_pages/user.html +++ b/mediagoblin/templates/mediagoblin/user_pages/user.html @@ -80,6 +80,7 @@ <div class="grid_10 omega"> {% set pagination_base_url = user_gallery_url %} {% include "mediagoblin/utils/object_gallery.html" %} + <div class="clear"></div> <p><a href="{{ user_gallery_url }}">View all of {{ user.username }}'s media</a></p> <a href={{ request.urlgen( 'mediagoblin.user_pages.atom_feed', diff --git a/mediagoblin/templates/mediagoblin/utils/object_gallery.html b/mediagoblin/templates/mediagoblin/utils/object_gallery.html index 2c7a7129..1b1c69f6 100644 --- a/mediagoblin/templates/mediagoblin/utils/object_gallery.html +++ b/mediagoblin/templates/mediagoblin/utils/object_gallery.html @@ -19,7 +19,7 @@ {% from "mediagoblin/utils/pagination.html" import render_pagination %} {% block object_gallery_content -%} - {% if media_entries %} + {% if media_entries and media_entries.count() %} {% for entry in media_entries %} <div class="media_thumbnail"> <a href="{{ entry.url_for_self(request.urlgen) }}"> @@ -33,5 +33,9 @@ {% else %} {{ render_pagination(request, pagination) }} {% endif %} + {% else %} + <p> + <i>There doesn't seem to be any media here yet...</i> + </p> {% endif %} {% endblock %} diff --git a/mediagoblin/templates/mediagoblin/utils/tags.html b/mediagoblin/templates/mediagoblin/utils/tags.html new file mode 100644 index 00000000..94c4cf69 --- /dev/null +++ b/mediagoblin/templates/mediagoblin/utils/tags.html @@ -0,0 +1,25 @@ +{# +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2011 Free Software Foundation, Inc +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +#} + +{% block tags_content -%} + <ul class="mediaentry_tags"> + {% for tag in media.tags %} + <li class="tag">{{ tag['name'] }}</li> + {% endfor %} + </ul> +{% endblock %} diff --git a/mediagoblin/tests/test_mgoblin_app.ini b/mediagoblin/tests/test_mgoblin_app.ini index fd0f87a4..7716e9ca 100644 --- a/mediagoblin/tests/test_mgoblin_app.ini +++ b/mediagoblin/tests/test_mgoblin_app.ini @@ -7,6 +7,10 @@ email_sender_address = "notice@mediagoblin.example.org" email_debug_mode = true db_name = __mediagoblin_tests__ +# tag parsing +tags_delimiter = "," +tags_max_length = 50 + # Celery shouldn't be set up by the application as it's setup via # mediagoblin.init.celery.from_celery celery_setup_elsewhere = true diff --git a/mediagoblin/tests/test_submission.py b/mediagoblin/tests/test_submission.py index 22b6117c..a7248255 100644 --- a/mediagoblin/tests/test_submission.py +++ b/mediagoblin/tests/test_submission.py @@ -35,6 +35,9 @@ EVIL_JPG = pkg_resources.resource_filename( EVIL_PNG = pkg_resources.resource_filename( 'mediagoblin.tests', 'test_submission/evil.png') +GOOD_TAG_STRING = 'yin,yang' +BAD_TAG_STRING = 'rage,' + 'f' * 26 + 'u' * 26 + class TestSubmission: def setUp(self): @@ -110,6 +113,42 @@ class TestSubmission: assert util.TEMPLATE_TEST_CONTEXT.has_key( 'mediagoblin/user_pages/user.html') + def test_tags(self): + # Good tag string + # -------- + util.clear_test_template_context() + response = self.test_app.post( + '/submit/', { + 'title': 'Balanced Goblin', + 'tags': GOOD_TAG_STRING + }, upload_files=[( + 'file', GOOD_JPG)]) + + # New media entry with correct tags should be created + response.follow() + context = util.TEMPLATE_TEST_CONTEXT['mediagoblin/user_pages/user.html'] + request = context['request'] + media = request.db.MediaEntry.find({'title': 'Balanced Goblin'})[0] + assert_equal(media['tags'], + [{'name': u'yin', 'slug': u'yin'}, + {'name': u'yang', 'slug': u'yang'}]) + + # Test tags that are too long + # --------------- + util.clear_test_template_context() + response = self.test_app.post( + '/submit/', { + 'title': 'Balanced Goblin', + 'tags': BAD_TAG_STRING + }, upload_files=[( + 'file', GOOD_JPG)]) + + # Too long error should be raised + context = util.TEMPLATE_TEST_CONTEXT['mediagoblin/submit/start.html'] + form = context['submit_form'] + assert form.tags.errors == [ + u'Tags must be shorter than 50 characters. Tags that are too long'\ + ': ffffffffffffffffffffffffffuuuuuuuuuuuuuuuuuuuuuuuuuu'] def test_malicious_uploads(self): # Test non-suppoerted file with non-supported extension diff --git a/mediagoblin/tests/test_tags.py b/mediagoblin/tests/test_tags.py new file mode 100644 index 00000000..c2e9fa2b --- /dev/null +++ b/mediagoblin/tests/test_tags.py @@ -0,0 +1,50 @@ +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2011 Free Software Foundation, Inc +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from mediagoblin.tests.tools import setup_fresh_app +from mediagoblin import util +from mediagoblin import mg_globals + + +@setup_fresh_app +def test_list_of_dicts_conversion(test_app): + """ + When the user adds tags to a media entry, the string from the form is + converted into a list of tags, where each tag is stored in the database + as a dict. Each tag dict should contain the tag's name and slug. Another + function performs the reverse operation when populating a form to edit tags. + """ + # Leading, trailing, and internal whitespace should be removed and slugified + assert util.convert_to_tag_list_of_dicts('sleep , 6 AM, chainsaw! ') == [ + {'name': u'sleep', 'slug': u'sleep'}, + {'name': u'6 AM', 'slug': u'6-am'}, + {'name': u'chainsaw!', 'slug': u'chainsaw'}] + + # If the user enters two identical tags, record only one of them + assert util.convert_to_tag_list_of_dicts('echo,echo') == [{'name': u'echo', + 'slug': u'echo'}] + + # Make sure converting the list of dicts to a string works + assert util.media_tags_as_string([{'name': u'yin', 'slug': u'yin'}, + {'name': u'yang', 'slug': u'yang'}]) == \ + u'yin,yang' + + # If the tag delimiter is a space then we expect different results + mg_globals.app_config['tags_delimiter'] = u' ' + assert util.convert_to_tag_list_of_dicts('unicorn ceramic nazi') == [ + {'name': u'unicorn', 'slug': u'unicorn'}, + {'name': u'ceramic', 'slug': u'ceramic'}, + {'name': u'nazi', 'slug': u'nazi'}] diff --git a/mediagoblin/util.py b/mediagoblin/util.py index 1892378c..5880f856 100644 --- a/mediagoblin/util.py +++ b/mediagoblin/util.py @@ -25,6 +25,7 @@ import re import urllib from math import ceil, floor import copy +import wtforms from babel.localedata import exists import jinja2 @@ -384,9 +385,63 @@ def clean_html(html): return HTML_CLEANER.clean_html(html) -MARKDOWN_INSTANCE = markdown.Markdown(safe_mode='escape') +def convert_to_tag_list_of_dicts(tag_string): + """ + Filter input from incoming string containing user tags, + + Strips trailing, leading, and internal whitespace, and also converts + the "tags" text into an array of tags + """ + taglist = [] + if tag_string: + + # Strip out internal, trailing, and leading whitespace + stripped_tag_string = u' '.join(tag_string.strip().split()) + + # Split the tag string into a list of tags + for tag in stripped_tag_string.split( + mg_globals.app_config['tags_delimiter']): + + # Ignore empty or duplicate tags + if tag.strip() and tag.strip() not in [t['name'] for t in taglist]: + + taglist.append({'name': tag.strip(), + 'slug': slugify(tag.strip())}) + return taglist + + +def media_tags_as_string(media_entry_tags): + """ + Generate a string from a media item's tags, stored as a list of dicts + + This is the opposite of convert_to_tag_list_of_dicts + """ + media_tag_string = '' + if media_entry_tags: + media_tag_string = mg_globals.app_config['tags_delimiter'].join( + [tag['name'] for tag in media_entry_tags]) + return media_tag_string + +TOO_LONG_TAG_WARNING = \ + u'Tags must be shorter than %s characters. Tags that are too long: %s' + +def tag_length_validator(form, field): + """ + Make sure tags do not exceed the maximum tag length. + """ + tags = convert_to_tag_list_of_dicts(field.data) + too_long_tags = [ + tag['name'] for tag in tags + if len(tag['name']) > mg_globals.app_config['tags_max_length']] + + if too_long_tags: + raise wtforms.ValidationError( + TOO_LONG_TAG_WARNING % (mg_globals.app_config['tags_max_length'], \ + ', '.join(too_long_tags))) +MARKDOWN_INSTANCE = markdown.Markdown(safe_mode='escape') + def cleaned_markdown_conversion(text): """ Take a block of text, run it through MarkDown, and clean its HTML. |