Просмотр исходного кода

#40 first pass on post validation framework

Rafał Pitoń 8 лет назад
Родитель
Сommit
763e2c5b94

+ 15 - 1
docs/PostingProcess.md

@@ -88,4 +88,18 @@ Do not make assumptions or "piggyback" on other middlewares save orders. Introsp
 
 Middlewares can always interrupt (and rollback) posting process during `interrupt_posting` phrase by raising `misago.threads.posting.PostingInterrupt` exception with error message as its only argument.
 
-All `PostingInterrupt`s raised outside that phase will be escalated to `ValueError` that will result in 500 error response from Misago. However as this will happen inside database transaction, there is chance that no data loss has occured in the process.
+All `PostingInterrupt`s raised outside that phase will be escalated to `ValueError` that will result in 500 error response from Misago. However as this will happen inside database transaction, there is chance that no data loss has occured in the process.
+
+
+## Performing custom validation
+
+Misago defines custom validation framework for posts very much alike one used for [validating new registrations](./ValidatingRegistrations.md).
+
+This framework works by passing user post, title, its parsing result as well as posting middleware's context trough list of callables imported from paths specified in the `MISAGO_POST_VALIDATORS` settings.
+
+Each serializer is expected to be callable accepting two arguments:
+
+* `data` dict with cleaned data. This will `post` containing raw input entered by user into editor, `parsing_result`, an dict defining `parsed_text` key containing parsed message, `mentions` with list of mentions, `images` list of urls to images and two lists: `outgoing_links` and `internal_links`. In case of user posting new thread, this dict will also contain `title` key containing cleaned title.
+* `context` dict with context that was passed to posting middleware.
+
+Your validator should raise `from rest_framework.serializers.ValidationError` on error. If validation passes it may return nothing, or updated `data` dict, which allows validators to perform last-minute cleanups on user input.

+ 5 - 0
docs/settings/Core.md

@@ -152,6 +152,11 @@ Max age, in days, of notifications stored in database. Notifications older than
 Limit of attachments that may be uploaded in single post. Lower limits may hamper image-heavy forums, but help keep memory usage by posting process. 
 
 
+## `MISAGO_POST_VALIDATORS`
+
+List of post validators used to validate posts.
+
+
 ## `MISAGO_POSTING_MIDDLEWARES`
 
 List of middleware classes participating in posting process.

+ 5 - 0
misago/conf/defaults.py

@@ -29,6 +29,11 @@ MISAGO_ACL_EXTENSIONS = [
 MISAGO_MARKUP_EXTENSIONS = []
 
 
+# Custom post validators
+
+MISAGO_POST_VALIDATORS = []
+
+
 # Posting middlewares
 # https://misago.readthedocs.io/en/latest/developers/posting_process.html
 

+ 2 - 2
misago/markup/api.py

@@ -5,7 +5,7 @@ from rest_framework.response import Response
 from django.core.exceptions import ValidationError
 from django.utils import six
 
-from misago.threads.validators import validate_post
+from misago.threads.validators import validate_post_length
 
 from . import common_flavour, finalise_markup
 
@@ -15,7 +15,7 @@ def parse_markup(request):
     post = six.text_type(request.data.get('post', '')).strip()
 
     try:
-        validate_post(post)
+        validate_post_length(post)
     except ValidationError as e:
         return Response({'detail': e.args[0]}, status=status.HTTP_400_BAD_REQUEST)
 

+ 2 - 2
misago/markup/parser.py

@@ -56,7 +56,7 @@ def parse(
         'mentions': [],
         'images': [],
         'outgoing_links': [],
-        'inside_links': [],
+        'internal_links': [],
     }
 
     # Parse text
@@ -161,7 +161,7 @@ def clean_links(request, result, force_shva=False):
     for link in soup.find_all('a'):
         if is_internal_link(link['href'], host):
             link['href'] = clean_internal_link(link['href'], host)
-            result['inside_links'].append(link['href'])
+            result['internal_links'].append(link['href'])
             link['href'] = clean_attachment_link(link['href'], force_shva)
         else:
             result['outgoing_links'].append(link['href'])

+ 18 - 11
misago/threads/api/postingendpoint/reply.py

@@ -4,7 +4,7 @@ from django.utils.translation import ugettext_lazy
 
 from misago.markup import common_flavour
 from misago.threads.checksums import update_post_checksum
-from misago.threads.validators import validate_post, validate_title
+from misago.threads.validators import validate_post, validate_post_length, validate_title
 
 from . import PostingEndpoint, PostingMiddleware
 
@@ -12,15 +12,15 @@ from . import PostingEndpoint, PostingMiddleware
 class ReplyMiddleware(PostingMiddleware):
     def get_serializer(self):
         if self.mode == PostingEndpoint.START:
-            return ThreadSerializer(data=self.request.data)
+            return ThreadSerializer(data=self.request.data, context=self.kwargs)
         else:
-            return ReplySerializer(data=self.request.data)
+            return ReplySerializer(data=self.request.data, context=self.kwargs)
 
     def save(self, serializer):
         if self.mode == PostingEndpoint.START:
             self.new_thread(serializer.validated_data)
 
-        parsing_result = self.parse_post(serializer.validated_data['post'])
+        parsing_result = serializer.validated_data['parsing_result']
 
         if self.mode == PostingEndpoint.EDIT:
             self.edit_post(serializer.validated_data, parsing_result)
@@ -74,21 +74,28 @@ class ReplyMiddleware(PostingMiddleware):
         self.post.original = parsing_result['original_text']
         self.post.parsed = parsing_result['parsed_text']
 
-    def parse_post(self, post):
-        if self.mode == PostingEndpoint.START:
-            return common_flavour(self.request, self.user, post)
-        else:
-            return common_flavour(self.request, self.post.poster, post)
-
 
 class ReplySerializer(serializers.Serializer):
     post = serializers.CharField(
-        validators=[validate_post],
+        validators=[validate_post_length],
         error_messages={
             'required': ugettext_lazy("You have to enter a message."),
         }
     )
 
+    def validate(self, data):
+        if data.get('post'):
+            data['parsing_result'] = self.parse_post(data['post'])
+            data = validate_post(self.context, data)
+
+        return data
+
+    def parse_post(self, post):
+        if self.context['mode'] == PostingEndpoint.START:
+            return common_flavour(self.context['request'], self.context['user'], post)
+        else:
+            return common_flavour(self.context['request'], self.context['post'].poster, post)
+
 
 class ThreadSerializer(ReplySerializer):
     title = serializers.CharField(

+ 7 - 7
misago/threads/tests/test_validators.py

@@ -2,30 +2,30 @@ from django.core.exceptions import ValidationError
 from django.test import TestCase
 
 from misago.conf import settings
-from misago.threads.validators import validate_post, validate_title
+from misago.threads.validators import validate_post_length, validate_title
 
 
-class ValidatePostTests(TestCase):
-    def test_valid_posts(self):
+class ValidatePostLengthTests(TestCase):
+    def test_valid_post(self):
         """valid post passes validation"""
-        validate_post("Lorem ipsum dolor met sit amet elit.")
+        validate_post_length("Lorem ipsum dolor met sit amet elit.")
 
     def test_empty_post(self):
         """empty post is rejected"""
         with self.assertRaises(ValidationError):
-            validate_post("")
+            validate_post_length("")
 
     def test_too_short_post(self):
         """too short post is rejected"""
         with self.assertRaises(ValidationError):
             post = 'a' * settings.post_length_min
-            validate_post(post[1:])
+            validate_post_length(post[1:])
 
     def test_too_long_post(self):
         """too long post is rejected"""
         with self.assertRaises(ValidationError):
             post = 'a' * settings.post_length_max
-            validate_post(post * 2)
+            validate_post_length(post * 2)
 
 
 class ValidateTitleTests(TestCase):

+ 55 - 33
misago/threads/validators.py

@@ -33,39 +33,6 @@ def validate_category(user, category_id, allow_root=False):
     return category
 
 
-def validate_post(post):
-    post_len = len(post)
-
-    if not post_len:
-        raise ValidationError(_("You have to enter a message."))
-
-    if post_len < settings.post_length_min:
-        message = ungettext(
-            "Posted message should be at least %(limit_value)s character long (it has %(show_value)s).",
-            "Posted message should be at least %(limit_value)s characters long (it has %(show_value)s).",
-            settings.post_length_min,
-        )
-        raise ValidationError(
-            message % {
-                'limit_value': settings.post_length_min,
-                'show_value': post_len,
-            }
-        )
-
-    if settings.post_length_max and post_len > settings.post_length_max:
-        message = ungettext(
-            "Posted message cannot be longer than %(limit_value)s character (it has %(show_value)s).",
-            "Posted message cannot be longer than %(limit_value)s characters (it has %(show_value)s).",
-            settings.post_length_max,
-        )
-        raise ValidationError(
-            message % {
-                'limit_value': settings.post_length_max,
-                'show_value': post_len,
-            }
-        )
-
-
 def validate_title(title):
     title_len = len(title)
 
@@ -103,3 +70,58 @@ def validate_title(title):
     validate_sluggable(error_not_sluggable, error_slug_too_long)(title)
 
     return title
+
+
+def validate_post_length(post):
+    post_len = len(post)
+
+    if not post_len:
+        raise ValidationError(_("You have to enter a message."))
+
+    if post_len < settings.post_length_min:
+        message = ungettext(
+            "Posted message should be at least %(limit_value)s character long (it has %(show_value)s).",
+            "Posted message should be at least %(limit_value)s characters long (it has %(show_value)s).",
+            settings.post_length_min,
+        )
+        raise ValidationError(
+            message % {
+                'limit_value': settings.post_length_min,
+                'show_value': post_len,
+            }
+        )
+
+    if settings.post_length_max and post_len > settings.post_length_max:
+        message = ungettext(
+            "Posted message cannot be longer than %(limit_value)s character (it has %(show_value)s).",
+            "Posted message cannot be longer than %(limit_value)s characters (it has %(show_value)s).",
+            settings.post_length_max,
+        )
+        raise ValidationError(
+            message % {
+                'limit_value': settings.post_length_max,
+                'show_value': post_len,
+            }
+        )
+
+
+# Post validation framework
+def load_post_validators(validators):
+    loaded_validators = []
+    for path in validators:
+        module = import_module('.'.join(path.split('.')[:-1]))
+        loaded_validators.append(getattr(module, path.split('.')[-1]))
+    return loaded_validators
+
+
+validators_list = settings.MISAGO_POST_VALIDATORS
+POST_VALIDATORS = load_post_validators(validators_list)
+
+
+def validate_post(context, data, validators=None):
+    validators = validators or POST_VALIDATORS
+
+    for validator in validators:
+        data = validator(context, data) or data
+
+    return data