3

I have recently learning about Validators and how they work but I am trying to add a function to my blog project to raise an error when a bad word is used. I have a list of bad words in a txt and added the code to be in the models.py the problem is that nothing is blocked for some reason I am not sure of.

Here is the models.py

class Post(models.Model):
       title = models.CharField(max_length=100, unique=True)
       ---------------other unrelated------------------------

def validate_comment_text(text):
    with open("badwords.txt") as f:
        censored_word = f.readlines()
    words = set(re.sub("[^\w]", " ", text).split())
    if any(censored_word in words for censored_word in CENSORED_WORDS):
        raise ValidationError(f"{censored_word} is censored!")

class Comment(models.Model):
    user = models.ForeignKey(User, on_delete=models.CASCADE)
    post = models.ForeignKey(Post, on_delete=models.CASCADE)
    content = models.TextField(max_length=300, validators=[validate_comment_text])
    updated = models.DateTimeField(auto_now=True)
    created = models.DateTimeField(auto_now=True)

here is the views.py:

class PostDetailView(DetailView):
    model = Post
    template_name = "blog/post_detail.html"  # <app>/<model>_<viewtype>.html

    def get_context_data(self, *args, **kwargs):
        context = super(PostDetailView, self).get_context_data()
        post = get_object_or_404(Post, slug=self.kwargs['slug'])
        comments = Comment.objects.filter(
            post=post).order_by('-id')
        total_likes = post.total_likes()
        liked = False
        if post.likes.filter(id=self.request.user.id).exists():
            liked = True

        if self.request.method == 'POST':
            comment_form = CommentForm(self.request.POST or None)
            if comment_form.is_valid():
                content = self.request.POST.get('content')
                comment_qs = None

                comment = Comment.objects.create(
                    post=post, user=self.request.user, content=content)
                comment.save()
                return HttpResponseRedirect("blog/post_detail.html")
        else:
            comment_form = CommentForm()

        context["comments"] = comments
        context["comment_form"] = comment_form
        context["total_likes"] = total_likes
        context["liked"] = liked
        return context

    def get(self, request, *args, **kwargs):
        res = super().get(request, *args, **kwargs)
        self.object.incrementViewCount()
        if self.request.is_ajax():
            context = self.get_context_data(self, *args, **kwargs)
            html = render_to_string('blog/comments.html', context, request=self.request)
            return JsonResponse({'form': html})
        return res

class PostCommentCreateView(LoginRequiredMixin, CreateView):
    model = Comment
    form_class = CommentForm

    def form_valid(self, form):
        post = get_object_or_404(Post, slug=self.kwargs['slug'])
        form.instance.user = self.request.user
        form.instance.post = post
        return super().form_valid(form)

Here is my trial which didn't work

def validate_comment_text(sender,text, instance, **kwargs):
    instance.full_clean()
    with open("badwords.txt") as f:
        CENSORED_WORDS = f.readlines()

    words = set(re.sub("[^\w]", " ", text).split())
    if any(censored_word in words for censored_word in CENSORED_WORDS):
        raise ValidationError(f"{censored_word} is censored!")

pre_save.connect(validate_comment_text, dispatch_uid='validate_comment_text')

I am new learner so if you could provide some explanation to the answer I would be grateful so that I can avoid repeating the same mistakes.

Shiko
  • 149
  • 9
  • 2
    (1) Your indentation is off. I'm assuming this is just a copy/paste issue to SO. (2) This check will be case sensitive - if you're storing your words as all upper/lower or something, there's a strong chance many won't match. Suggest converting your text to all lower, and storing all lower case in `CENSORED_WORDS` and see if that helps. – michjnich Nov 18 '20 at 09:23
  • @michjnich it is just an copy/paste error causing the indentation error, but it is not the problem for not working – Shiko Nov 23 '20 at 03:11

2 Answers2

4

I'm sure there are many ways to handle this, but I finally decided to adopt a common practice in all my Django projects:

when a Model requires validation, I override clean() to collect all validation logic in a single place and provide appropriate error messages.

In clean(), you can access all model fields, and do not need to return anything; just raise ValidationErrors as required:

from django.db import models
from django.core.exceptions import ValidationError


class MyModel(models.Model):

    def clean(self):
         
        if (...something is wrong in "self.field1" ...) {
            raise ValidationError({'field1': "Please check field1"})
        }
        if (...something is wrong in "self.field2" ...) {
            raise ValidationError({'field2': "Please check field2"})
        }

        if (... something is globally wrong in the model ...) {
            raise ValidationError('Error message here')
        }

The admin already takes advantages from this, calling clean() from ModelAdmin.save_model(), and showing any error in the change view; when a field is addressed by the ValidationError, the corresponding widget will be emphasized in the form.

To run the very same validation when saving a model programmatically, just override save() as follows:

class MyModel(models.Model):

    def save(self, *args, **kwargs):
        self.full_clean()
        ...
        return super().save(*args, **kwargs)

Proof:

file models.py

from django.db import models


class Model1(models.Model):

    def clean(self):
        print("Inside Model1.clean()")

    def save(self, *args, **kwargs):
        print('Enter Model1.save() ...')
        super().save(*args, **kwargs)
        print('Leave Model1.save() ...')
        return

class Model2(models.Model):

    def clean(self):
        print("Inside Model2.clean()")

    def save(self, *args, **kwargs):
        print('Enter Model2.save() ...')
        self.full_clean()
        super().save(*args, **kwargs)
        print('Leave Model2.save() ...')
        return

file test.py

from django.test import TestCase
from project.models import Model1
from project.models import Model2

class SillyTestCase(TestCase):

    def test_save_model1(self):
        model1 = Model1()
        model1.save()

    def test_save_model2(self):
        model2 = Model2()
        model2.save()

Result:

❯ python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
Enter Model1.save() ...
Leave Model1.save() ...
.Enter Model2.save() ...
Inside Model2.clean()
Leave Model2.save() ...
.
----------------------------------------------------------------------
Ran 2 tests in 0.002s

OK
Destroying test database for alias 'default'...
Mario Orlandi
  • 5,629
  • 26
  • 29
  • You can add a `self.instance.full_clean()` into `def clean(self)` to run the model validations. If there's an issue it will raise a `ValidationError` just like any other issue. – Tim Tisdall Nov 23 '20 at 13:51
  • As the other answer says... It would also be good to switch to a ModelForm and the the model validations would be automatically as well as setting the fields. – Tim Tisdall Nov 23 '20 at 13:52
  • @TimTisdall... since you said "and added the code to be in the models.py", that's what I did. And I do believe there is a very good reason for this: should you save a model programmatically, that would enjoy the very same validation. As long as the Model's code protects itself, you're data will always be in good shape, despite how good or bad behaves the user interface (i.e. the ModelForm) – Mario Orlandi Nov 23 '20 at 17:07
  • @TimTisdall "You can add a self.instance.full_clean() into def clean(self)" ... I do believe Django works in the opposite direction: first you call full_clean() in Model.save(), then (as a consequence) the framework will invoke clean(). I do agree the method names are rather confusing here ;) – Mario Orlandi Nov 23 '20 at 17:10
  • You seem to have confused me with the OP. Also, `Model.full_clean()` won't call `Form.clean()` and I'm confused why you would think so. Also, `full_clean()` raises `ValidationError` which is only caught within a form, so your solution will just give a 500 and stacktrace on invalid data unless something catches the exception. – Tim Tisdall Nov 23 '20 at 18:33
  • ... ah.. I see there's also a `Model.clean()`. Still, if you just call `Model.save()` within the view, the `ValidationError` won't be caught (as the OP is doing). It should be called within the `CommentForm.clean()` instead of `Comment.save()`. – Tim Tisdall Nov 23 '20 at 18:36
  • @TimTisdall just added to my post a proof of what I said. Model.full_clean() does not call Form.clean(), but it does call Model.clean(). And ... yes ! ... when clean() raises ValidationError, or any other Exception, the caller has better to catch and manage it. ModelAdmin already does; you're code should as well – Mario Orlandi Nov 24 '20 at 05:51
  • `ValidationError` should be used within the `Form` where it can then be used to show the end user the validation errors. Putting it within `save()` means it's going to be called in contexts not involving a `Form`. – Tim Tisdall Nov 24 '20 at 13:42
  • Why? When saving objects programmatically in the db you should always be prepared to handle errors (that is: react to exception accordingly). For example, if you forget to assign a not-nullable FK, an exception will be raised, and you've better handle it. ValidationErrors are just a specific kind of exception, to make it crystal clear that the error is due to the "logic" behind the Model – Mario Orlandi Nov 24 '20 at 15:12
  • https://stackoverflow.com/questions/4441539/why-doesnt-djangos-model-save-call-full-clean – Tim Tisdall Nov 25 '20 at 13:45
  • There, everybody seems to agree that calling full_clean() from save() IS SAFE, and the reason why Django doesn't already do that is just for backward compatibility with previous versions – Mario Orlandi Nov 25 '20 at 15:06
  • @TimTisdall "ValidationError should be used within the Form where it can then be used to show the end user the validation errors." Bull crap. ValidationErrors should be used whereever a validation fails. The model validates data integrity, the form validates input. The distinction is subtle but real: a form cannot validate foreign keys, unique constraints etc, without going to the database, so why not do it in the place that interfaces with the database: the model. The form should worry about transforming strings to numbers, valid date formats, combine multi-widget values etc. –  Nov 26 '20 at 13:51
  • 1
    @TimTisdall I honestly don't get where this notion comes from that ValidationErrors are for forms. DRF also uses them in serializers and for models, here is the [official documentation](https://docs.djangoproject.com/en/3.1/ref/models/instances/#validating-objects) listing specifically that ValidationError should be used, in what order things are called and why clean() is needed aside from individual field cleaning methods. –  Nov 26 '20 at 13:55
  • @Melvyn +1 for mentioning DRF as a real live context where validation occurs despite not having a user interface – Mario Orlandi Nov 26 '20 at 16:28
0

Validators run only when you use ModelForm. If you directly call comment.save(), validator won't run. link to docs

So either you need to validate the field using ModelForm or you can add a pre_save signal and run the validation there (you'll need to manually call the method, or use full_clean to run the validations). Something like:

from django.db.models.signals import pre_save

def validate_model(sender, instance, **kwargs):
    instance.full_clean()

pre_save.connect(validate_model, dispatch_uid='validate_models')
Aman Garg
  • 2,507
  • 1
  • 11
  • 21
  • Not clear why this got voted down? Not enough info from the question to tell if it's the actual solution, but seems like a reasonable suggestion! – michjnich Nov 18 '20 at 09:25
  • I agree: this answer is usefull, so I'll upvote it ;) Just as a matter of personal taste, I would rather override save() instead, since this seems more explicit to me. Signals are more appopriate when you need an hook for a Model declared in another ap – Mario Orlandi Nov 18 '20 at 11:31
  • @Aman Garg I have been trying with the pre_save but I have not been successful in implementing it, I have updated the post with my trial. – Shiko Nov 23 '20 at 03:19
  • @michjnich I've included Post Model and views to add more details to the question. Thanks – Shiko Nov 23 '20 at 03:36
  • @Shiko `validate_comment_text(sender,text, instance, **kwargs)` doesn't have the correct signature. The second argument is the `instance`, so just update the signature as mentioned in the answer and it will work. [link](https://docs.djangoproject.com/en/3.1/ref/signals/#pre-save) – Aman Garg Nov 23 '20 at 12:50
  • It's better to validate within the `Form.clean()` then on `pre_save` as the validation exception here will not be caught and cause a 500 error. – Tim Tisdall Nov 23 '20 at 13:47
  • Downvoted, cause it takes shortcuts: "Validators only run on modelform" is a simplification. Anything that calls full_clean() runs validator, in vanilla that's the model form, but from a normal form, from a signal, heck from model save() you can call full_clean() if you want to. Second, connecting your own model to a signal to call a custom validate model method, instead of just overriding save....I don't see the upside in that. –  Nov 24 '20 at 00:32