Начнем с добавления LoginView. Хорошо, что в Django это уже есть для нас: https://docs.djangoproject.com/en/2.0/topics/auth/default/

Нам просто нужно изменить hnews/urls.py

from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.urls import include, path

urlpatterns = [
    path('admin/', admin.site.urls),
    path('posts/', include('hnews.posts.urls')),
    path('accounts/login/', auth_views.LoginView.as_view(), name='login'),
]

Теперь нам нужно установить, куда будет перенаправляться представление входа в систему. В settings.py

LOGIN_REDIRECT_URL = '/posts/'

Давайте добавим шаблон для представления входа в систему (скопируйте макароны из документации Django).

$ mkdir -p templates/registration
$ touch templates/registration/login.html
$ cat templates/registration/login.html
<html>
<body>
{% if form.errors %}
<p>Your username and password didn't match. Please try again.</p>
{% endif %}

{% if next %}
    {% if user.is_authenticated %}
    <p>Your account doesn't have access to this page. To proceed,
    please login with an account that has access.</p>
    {% else %}
    <p>Please login to see this page.</p>
    {% endif %}
{% endif %}

<form method="post" action="{% url 'login' %}">
{% csrf_token %}
<table>
<tr>
    <td>{{ form.username.label_tag }}</td>
    <td>{{ form.username }}</td>
</tr>
<tr>
    <td>{{ form.password.label_tag }}</td>
    <td>{{ form.password }}</td>
</tr>
</table>

<input type="submit" value="login" />
<input type="hidden" name="next" value="{{ next }}" />
</form>
</body>
</html>

Для входа перейдите по этому адресу: http: // localhost: 8000 / accounts / login /

Давайте изменим наш шаблон списка.

<html>
<body>
{% if user.is_authenticated %}
{{ user }}
{% else %}
<a href="{% url 'login' %}">Log in</a>
{% endif %}

{% if posts %}
<ul>
{% for post in posts %}
    <li>{{ post.title }} - {{ post.how_long_ago }} - {{ post.get_domain_name }}</li>
{% endfor %}
</ul>
{% endif %}
</body>
</html>

Теперь мы можем проверить, вошли ли мы в систему. Давайте добавим представление выхода.

urls.py

from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.urls import include, path

urlpatterns = [
    path('admin/', admin.site.urls),
    path('posts/', include('hnews.posts.urls')),
    path('accounts/login/', auth_views.LoginView.as_view(), name='login'),
    path('accounts/logout/', auth_views.LogoutView.as_view(), name='logout'),
]

settings.py

LOGOUT_REDIRECT_URL = '/posts/'

list.html

<html>
<body>
{% if user.is_authenticated %}
{{ user }} - <a href="{% url 'logout' %}">Log out</a>
{% else %}
<a href="{% url 'login' %}">Log in</a>
{% endif %}

{% if posts %}
<ul>
{% for post in posts %}
    <li>{{ post.title }} - {{ post.how_long_ago }} - {{ post.get_domain_name }}</li>
{% endfor %}
</ul>
{% endif %}
</body>
</html>

Хорошо, теперь у нас есть эта часть. Давайте добавим возможность голосовать за. Сначала давайте добавим VueJS.

<html>
<body>
{% if user.is_authenticated %}
{{ user }} - <a href="{% url 'logout' %}">Log out</a>
{% else %}
<a href="{% url 'login' %}">Log in</a>
{% endif %}

<div id="app">
    <ul>
        <li v-for="post in posts">
            [[ post.title  ]] - [[ post.how_long_ago ]] - [[ post.domain_name ]]
        </li>
    </ul>
</div>

<!-- development version, includes helpful console warnings -->
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script>
var app = new Vue({
  el: '#app',
  delimiters: ['[[', ']]'],
  data: {
    posts: {{ posts }},  
  }
})
</script>
</body>
</html>

Давайте изменим наше представление, чтобы отправить JSON в шаблон.

class PostListView(ListView):
    template_name = 'posts/list.html'
    model = Post
    context_object_name = 'posts'

    def get_context_data(self, *, object_list=None, **kwargs):
        context = super().get_context_data(object_list=object_list, **kwargs)
        context['posts'] = json.dumps([
            {
                'title': post.title,
                'how_long_ago': post.how_long_ago(),
                'domain_name': post.get_domain_name(),
            }

            for post in context['posts']
        ])
        return context

Если мы перейдем на http: // localhost: 8000 / posts /, это не сработает. Когда вы смотрите на HTML-код страницы, вы видите, что JSON экранирован.

var app = new Vue({
  el: '#app',
  delimiters: ['[[', ']]'],
  data: {
    posts: [{&quot;title&quot;: &quot;Microsoft Is Said to Have Agreed to Acquire GitHub&quot;, &quot;how_long_ago&quot;: &quot;20 hours ago&quot;, &quot;domain_name&quot;: &quot;bloomberg.com&quot;}, {&quot;title&quot;: &quot;GitLab sees huge spike in project imports&quot;, &quot;how_long_ago&quot;: &quot;20 hours ago&quot;, &quot;domain_name&quot;: &quot;monitor.gitlab.net&quot;}, {&quot;title&quot;: &quot;Facebook Gave Device Makers Deep Access to Data on Users and Friends&quot;, &quot;how_long_ago&quot;: &quot;20 hours ago&quot;, &quot;domain_name&quot;: &quot;nytimes.com&quot;}],
  }
})

Давайте исправим это с помощью фильтра safe.

<script>
var app = new Vue({
  el: '#app',
  delimiters: ['[[', ']]'],
  data: {
    posts: {{ posts|safe }},  
  }
})
</script>

Теперь это работает! Давайте отправим данные в шаблон, чтобы проголосовать за него.

def get_context_data(self, *, object_list=None, **kwargs):
    context = super().get_context_data(object_list=object_list, **kwargs)
    context['posts'] = json.dumps([
        {
            'title': post.title,
            'how_long_ago': post.how_long_ago(),
            'domain_name': post.get_domain_name(),
            'upvoted': post.upvotes.filter(id=self.request.user.id).count() > 0,
            'upvote_url': reverse('posts:set_upvoted_post', kwargs={'post_id': post.id}),
        }
        for post in context['posts']
    ])
    return context

Теперь о шаблоне:

<html>
<body>
{% if user.is_authenticated %}
{{ user }} - <a href="{% url 'logout' %}">Log out</a>
{% else %}
<a href="{% url 'login' %}">Log in</a>
{% endif %}

<div id="app">
    <ul>
        <li v-for="post in posts">
            [[ post.title  ]] - [[ post.how_long_ago ]] - [[ post.domain_name ]]
            {% if user.is_authenticated %}
            - <span v-if="post.upvoted">Upvoted</span><span v-else>Not Upvoted</span>
            - <button v-on:click="upvote(post)">Toggle upvoted</button>
            {% endif %}
        </li>
    </ul>
</div>

<!-- development version, includes helpful console warnings -->
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script>
function getCookie(name) {
var cookieValue = null;
if (document.cookie && document.cookie !== '') {
    var cookies = document.cookie.split(';');
    for (var i = 0; i < cookies.length; i++) {
        var cookie = jQuery.trim(cookies[i]);
        // Does this cookie string begin with the name we want?
        if (cookie.substring(0, name.length + 1) === (name + '=')) {
            cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
            break;
        }
    }
}
return cookieValue;
}

function csrfSafeMethod(method) {
    // these HTTP methods do not require CSRF protection
    return (/^(GET|HEAD|OPTIONS|TRACE)$/.test(method));
}

var csrftoken = getCookie('csrftoken');

$.ajaxSetup({
    beforeSend: function(xhr, settings) {
        if (!csrfSafeMethod(settings.type) && !this.crossDomain) {
            xhr.setRequestHeader("X-CSRFToken", csrftoken);
        }
    }
});

var app = new Vue({
  el: '#app',
  delimiters: ['[[', ']]'],
  data: {
    posts: {{ posts|safe }},
  },
  methods: {
    upvote: function (post) {
      $.post({
        url: post.upvote_url,
        data: JSON.stringify({
            upvoted: !post.upvoted
        }),
        success: function (data, text_status, jq_XHR) {
          post.upvoted = !post.upvoted
        },
      })
    }
  }
})
</script>
</body>
</html>

Мы используем некоторый код отсюда для отправки токена CSRF при выполнении вызова AJAX.

При тестировании голоса за, у меня была ошибка, мой пользователь продолжал выходить из системы. Затем я понял, что удаляю пользователя каждый раз, когда отменяю голосование.

Менеджер "многие ко многим" возвращает экземпляры User, а не PostUpvote. Я удалю менеджера, чтобы не запутаться. Теперь мой models.py выглядит так:

from datetime import timedelta
from urllib.parse import urlparse

from django.contrib.auth.models import User
from django.db import models
from django.template.defaultfilters import pluralize
from django.utils import timezone


class Post(models.Model):
    creator = models.ForeignKey(
        User,
        related_name='posts',
        on_delete=models.SET_NULL,
        null=True,
    )
    creation_date = models.DateTimeField(auto_now_add=True)
    url = models.URLField()
    title = models.CharField(max_length=256)

    def how_long_ago(self):
        how_long = timezone.now() - self.creation_date
        if how_long < timedelta(minutes=1):
            return f'{how_long.seconds} second{pluralize(how_long.seconds)} ago'
        elif how_long < timedelta(hours=1):
            # total_seconds returns a float
            minutes = int(how_long.total_seconds()) // 60
            return f'{minutes} minute{pluralize(minutes)} ago'
        elif how_long < timedelta(days=1):
            hours = int(how_long.total_seconds()) // 3600
            return f'{hours} hour{pluralize(hours)} ago'
        else:
            return f'{how_long.days} day{pluralize(how_long.days)} ago'

    def get_domain_name(self):
        name = urlparse(self.url).hostname
        if name.startswith('www.'):
            return name[len('www.'):]
        else:
            return name

    def set_upvoted(self, user, *, upvoted):
        if upvoted:
            PostUpvote.objects.get_or_create(post=self, user=user)
        else:
            self.upvotes.filter(user=user).delete()


class PostUpvote(models.Model):
    post = models.ForeignKey(Post, related_name='upvotes', on_delete=models.CASCADE)
    user = models.ForeignKey(User, related_name='post_upvotes', on_delete=models.CASCADE)

    class Meta:
        unique_together = ('post', 'user')


class Comment(models.Model):
    creation_date = models.DateTimeField(auto_now_add=True)
    creator = models.ForeignKey(
        User,
        related_name='comments',
        on_delete=models.SET_NULL,
        null=True,
    )
    post = models.ForeignKey(Post, related_name='comments', on_delete=models.CASCADE)
    parent = models.ForeignKey('Comment', related_name='replies', on_delete=models.CASCADE, null=True, default=None)
    content = models.TextField(null=True)

    def set_upvoted(self, user, *, upvoted):
        if upvoted:
            CommentUpvote.objects.get_or_create(comment=self, user=user)
        else:
            self.upvotes.filter(user=user).delete()


class CommentUpvote(models.Model):
    comment = models.ForeignKey(Comment, related_name='upvotes', on_delete=models.CASCADE)
    user = models.ForeignKey(User, related_name='comment_upvotes', on_delete=models.CASCADE)

    class Meta:
        unique_together = ('comment', 'user')

Мне также пришлось изменить свой get_context_data:

def get_context_data(self, *, object_list=None, **kwargs):
    context = super().get_context_data(object_list=object_list, **kwargs)
    context['posts'] = json.dumps([
        {
            'title': post.title,
            'how_long_ago': post.how_long_ago(),
            'domain_name': post.get_domain_name(),
            'upvoted': post.upvotes.filter(user=self.request.user).count() > 0,
            'upvote_url': reverse('posts:set_upvoted_post', kwargs={'post_id': post.id}),
        }
        for post in context['posts']
    ])
    return context

Хорошо, на сегодня хорошо!

Увидимся!