[djnago] django 를 이용한 간단한 블로그앱 만들기 – 2

이전 포스트에서 django 설치, 관리자 페이지 및 hello world 페이지 까지 작성하였다. 본 포스트에서는 리스트 페이지, 뷰페이지, 글쓰기 및 수정 삭제 기능, 페이징, 검색기능을 구현하려고 한다.

목록

  1. 리스트 페이지 작성
  2. 뷰 페이지 작성
  3. 글쓰기 페이지 작성
  4. 수정페이지 작성
  5. 삭제기능 추가
  6. 템플렛 다듬기

1 . 리스트페이지 작성

view.py에서 사용할 모델을 import한다. 그리고 현재보다 미래의 글은 리스트에 출력되면 안되기 때문에 시간을 처리해주는 장고 내장함수(timezone)를 import 한다.

#view.py

from django.shortcuts import render
from django.http import HttpResponse
from django.utils import timezone
from .models import Post

# Create your views here.
def index(requests):
    posts = Post.objects.filter(published_date__lte = timezone.now())
    return render(requests, 'myblog/list.html', {'posts' : posts})

render함수에 requests, ‘템플릿경로’, ‘데이터 객체’를 전달하고 리턴해주면 된다.
localhost:8000/myblog 로 접속해보면 템플렛을 찾을수 없다고 에러가 발생할 것이다.
장고가 render 함수로 전달된 ‘myblog/list.html’ 부분을 찾게 해주기 위해서 해당 경로에 디렉토리 및 파일을 생성해준다.

myblog 디렉토리 구조
templates 폴더 안에 앱이름으로 폴더를 생성하는 이유는 django는 프로젝트내 에서 모든 templates 폴더를 한곳으로 모아 파일을 찾는데 같은 파일이름이 있을 수 있으면 문제가 발생하기 때문에 네임스페이를 처리해주기 위해서 관행적으로 생성해준다고 한다.

이제 list.html 파일을 열고 마크업을 작성한다.

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <title>myblog</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css">
</head>
<body>
    <div>
        <table class="table">
            <thead>
                <tr>
                    <th>title</th>
                    <th>published_date</th>
                </tr>
            </thead>
            <tbody>
                {% for post in posts %}
                <tr>
                    <td>{{ post.title }}</td>
                    <td>{{ post.published_date | date:"Y-m-d" }}</td>
                </tr>
                {% endfor %}
            </tbody>
        </table>
    </div>
</body>
</html>

view.py 에서 전달받은 posts객체를 이용해서 리스트를 출력해준다.

2. 뷰페이지 작성

게시글의 제목을 클릭하면 뷰페이지로 이동하는 부분을 작성할 차례이다. 작업순서는 상관없지만 나름대로 규칙을 가지고 작성하는 것이 좋기 때문에 아래와 같은 순서로 진행했다.

1) urls.py 에 뷰페이지 url 패턴 등록
2) view.py 에 게시글을 얻는 함수 추가
3) list.html에 게시글의 제목에 링크를 걸어준다.
4) post.html (뷰페이지) 작성

#urls.py
from . import views
from django.urls import path, include

app_name='myblog'

urlpatterns = [
    path('', views.index, name="index"),
    path('post/<int:pk>/', views.post, name="post"),
]

새로등록한 패턴은 아래의 주소처럼 접근할 수 있게 된다. http://localhost:8000/myblog/post/게시글번호

이제 path(‘post//’, views.post, name=”post”)에서 전달한 2번째 인자인 view.post 부분을 view.py에 post라는 함수를 작성하겠다.
post 함수는 url에서 전달받은 pk를 테이블에서 조회해서 템플렛으로 렌더링하는 함수이다.

#view.py
from django.shortcuts import render, get_object_or_404
from django.http import HttpResponse
from django.utils import timezone
from .models import Post

# Create your views here.
def index(requests):
    posts = Post.objects.filter(published_date__lte = timezone.now())
    return render(requests, 'myblog/list.html', {'posts' : posts})

def post(requests, pk):
    post = get_object_or_404(Post, pk=pk)
    return render(requests, 'myblog/post.html', {'post' : post})

*get_object_or_404 : 테이블에서 pk를 찾을 수 없으면 404 에러를 일으킨다.

<!-- list.html -->
{% load static %}
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <title>myblog</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css">
    <link rel="stylesheet" href="{% static 'myblog/css/style.css' %}">
</head>
<body>
    <div>
        <table class="table">
            <thead>
                <tr>
                    <th>title</th>
                    <th>published_date</th>
                </tr>
            </thead>
            <tbody>
                {% for post in posts %}
                <tr>
                    <td>
                        <a href="{% url 'myblog:post' post.pk %}">{{ post.title }}</a>
                    </td>
                    <td>{{ post.published_date | date:"Y-m-d" }}</td>
                </tr>
                {% endfor %}
            </tbody>
        </table>
    </div>    
</body>
</html>

{% url ‘myblog:post’ post.pk %}
*’myblog:post’ : myblog앱의 urls.py의 패턴으로 등록된 ‘name=post’라는 url 패턴을 사용하겠다는 의미이다.

django는 각각의 앱이 모듈로 사용되어지기 때문에 네임스페이스 처리를 해주는 것이 좋다. 네임스페이스는 urls.py에서 app_name을 이용해서 선언해준다.

#urls.py
app_name='myblog' #myblog 네임스페이스 선언

urlpatterns = [
    path('', views.index, name="index"),
    path('post/<int:pk>/', views.post, name="post"),
]

post.html 작성
view.post 를 통해 context로 전달한 post를 이용해 간단하게 내용을 출력 할 수 있다.

{% load static %}
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <title>myblog</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css">
    <link rel="stylesheet" href="{% static 'myblog/css/style.css' %}">
</head>
<body>
    <table class="table">
        <tbody>
            <tr>
                <th>제목</th>
                <td>{{ post.title }}</td>
            </tr>
            <tr>    
                <th>발행일</th>
                <td>{{ post.published_date | date:"Y-m-d" }}</td>
            </tr>
            <tr>
                <th>내용</th>
                <td>{{ post.content }}</td>
            </tr>
        </tbody>
    </table>        
</body>
</html>
뷰페이지 완성!

3. 글쓰기 페이지 작성

1) urls.py 에 뷰페이지 url 패턴 등록

urlpatterns = [
    path('', views.index, name="index"),
    path('post/<int:pk>/', views.post, name="post"),
    path('write/', views.write, name="write"),

2) form.py에 PostForm 클래스 생성

from django import forms
from .models import Post

class PostForm(forms.ModelForm):
    class Meta:
        model = Post
        fields = ('autor', 'title', 'content', 'created_date', 'published_date')

3) view.py 에 write함수 추가

def write(request):
    if request.method == 'POST':
        form = PostForm(request.POST)
        if form.is_valid():
            post = form.save(commit=False)
            post.author = request.user
            post.save()
            return HttpResponseRedirect(reverse('myblog:post', args=(post.pk,)))
    else :
        form = PostForm()
    return render(request, 'myblog/write.html', {'form' : form})

4) wirte.html에 form 작성

{% load static %}
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <title>myblog</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css">
    <link rel="stylesheet" href="{% static 'myblog/css/style.css' %}">
</head>
<body>
    <form action="" method="post">
        {% csrf_token %}
        {{ form.as_p }}
        <input type="submit" value="save">
    </form>
    <a href="{% url 'myblog:index' %}">목록</a>
</body>
</html>

4. 게시글 수정 페이지 작성

1) urls.py 에 수정페이지 url 패턴 등록

urlpatterns = [
    path('', views.index, name="index"),
    path('post/<int:pk>/', views.post, name="post"),
    path('write/', views.write, name="write"),
    path('write/<int:pk>/', views.modi, name="modi"),
]

2) view.py에 modi 함수추가

def modi(request, pk):
    post = get_object_or_404(Post, pk=pk)
    if request.method == 'POST':
        form = PostForm(request.POST, instance=post)
        if form.is_valid():
            post = form.save(commit=False)
            post.author = request.user
            post.save()
            return HttpResponseRedirect(reverse('myblog:post', args=(post.pk,)))
    else :
        form = PostForm(instance=post)
    return render(request, 'myblog/write.html', {'form' : form})

5. 삭제 기능 추가

1) 삭제 url 팬턴 추가

urlpatterns = [
    path('', views.index, name="index"),
    path('post/<int:pk>/', views.post, name="post"),
    path('write/', views.write, name="write"),
    path('write/<int:pk>/', views.modi, name="modi"),
    path('del/<int:pk>/', views.delete, name="delete"),
]

2. view.py delete 함수 추가

def delete(request, pk):
    post = get_object_or_404(Post, pk=pk)
    post.delete()
    return HttpResponseRedirect( reverse('myblog:index') )

6.템플렛 다듬기

템플릿 파일의 중복된 부분을 수정하기 위해서 공통으로 쓰여질 부분(base.html)을 만들어준다.

<!-- base.html -->
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <title>myblog</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css">
</head>
<body>
    {% block content %}
    {% endblock %}
</body>
</html>

list.html post.html, write.html을 수정해 준다.

<!--list.html -->
{% extends 'myblog/base.html' %}

{% block content %}
<div>
    <table class="table">
        <thead>
            <tr>
                <th>title</th>
                <th>published_date</th>
            </tr>
        </thead>
        <tbody>
            {% for post in posts %}
            <tr>
                <td>
                    <a href="{% url 'myblog:post' post.pk %}">{{ post.title }}</a>
                </td>
                <td>{{ post.published_date | date:"Y-m-d" }}</td>
            </tr>
            {% endfor %}
        </tbody>
    </table>
</div>
<a href="{% url 'myblog:write' %}">write</a>
{% endblock %}
<!-- write.html -->
{% extends 'myblog/base.html' %}
{% block content %}
<form action="" method="post">
    {% csrf_token %}
    {{ form.as_p }}
    <input type="submit" value="save">
</form>
<a href="{% url 'myblog:index' %}">목록</a>
{% endblock %}
<!-- post.html -->
{% extends 'myblog/base.html' %}

{% block content %}
<table class="table">
    <tbody>
        <tr>
            <th>제목</th>
            <td>{{ post.title }}</td>
        </tr>
        <tr>    
            <th>발행일</th>
            <td>{{ post.published_date | date:"Y-m-d" }}</td>
        </tr>
        <tr>
            <th>내용</th>
            <td>{{ post.content }}</td>
        </tr>
    </tbody>
</table>
<a href="{% url 'myblog:index' %}">목록</a>
<a href="{% url 'myblog:modi' post.pk%}">수정</a>
<a href="{% url 'myblog:delete' post.pk%}">삭제</a>
{% endblock %}

[djnago] django 를 이용한 간단한 블로그앱 만들기 – 1

며칠간 공부한 내용을 정리하면서 장고를 이용한 블로그앱을 개발하고자 한다.

장고를 이용한 블로그앱 개발 목차

  1. 가상환경 세팅
  2. 프로젝트 생성 및 관리자 계정생성
  3. 블로그앱 생성
  4. setting.py 에 블로그앱 등록 및 기본설정 수정
  5. 모델만들기
  6. url.py 생성

1 . 가상환경 세팅

virtualenv 를 글로벌 설치 한다.

$ pip install virtualenv

설치가 끝났으면 프로젝트 폴더를 만들고 이동한다.

$ mkdir myblog
$ cd myblog

vitrualenv를 이용해 가상환경을 생성한다

$ virtualenv venv

디렉토리 목록을 조회해서 venv 폴더가 생성되었으면 가상환경 설치가 끝난것이다. 이제 가상환경을 활성화시켜보자

$ source ./venv/bin/activate
(venv) : $ 

명령줄 맨 앞에 ‘(venv)’이 붙으면 가상환경 활성이 된것이다. 가상화경을 끄고 싶으면 ‘deactivate’ 명령어 실행
이제 가상환경에 django를 설치한다.

(venv) : pip install django

2 . 프로젝트 생성 및 관리자 계성 생성

기본적인 준비가 끝났으니 장고프로젝트를 생성한다

(venv) $ django-admin startproject mysite
(venv) $ cd myside
(venv) $ ./manage.py runserver

서버가 실행되면 아래와 같은 메세지가 나타난다. 중간에 미리정의된 앱이 마이그레이션되어있지 않다고 메세지가 나오는데 일단은 무시한다.

Performing system checks...
System check identified no issues (0 silenced).

You have 15 unapplied migration(s). Your project may not work properly until you apply the migrations for app(s): admin, auth, contenttypes, sessions.
Run 'python manage.py migrate' to apply them.

March 03, 2019 - 08:34:34
Django version 2.1.7, using settings 'mysite.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.

이제 localhost:8000 으로 접속해보자

장고 개발서버 인덱스 페이지

관리자에 접속할수 있는 계정을 만들기에 앞서 데이터베이스를 마이그레이션 한다.

(venv) $ ./manage.py migrate

이제 다시 서버를 실행시켜보면 좀전에 나왔던 에러메세지가 나오지 않는다.
테이블을 생성했으니 관리자 계정을 추가한다.

(venv) $ ./manage.py createsuperuser

아이디, 비밀번호, 이메일등을 입력하면 완료됨 localhost:8000/admin 으로 접속해서 로그인이 성공하면 됨

장고 관리자 페이지

3 . 블로그앱 생성

기본준비를 마쳤으니 실제로 개발할 블로그앱을 생성한다.

(venv) $ ./manay.py startapp myblog
myblog의 디렉토리 구조

4 . setting.py 에 블로그앱 등록 및 기본설정 수정

본격적으로 개발하기전에 /mysite/settings.py에서 생성한 블로그 앱을 등록해야한다.

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    #생성한 블로그앱을 추가.
    'myblog.apps.MyblogConfig'
]

settings.py 하단의 서버시간을 한국으로 변경해준다.

TIME_ZONE = 'Asia/Seoul'

5 . model 생성

django는 ORM을 이용하기 때문에 models.py에서 테이블을 정의한 뒤 makemigrations 로 쿼리문을 생성하고 migrate로 쿼리를 실행한다.

#myblog/models.py

from django.db import models
from django.utils import timezone

# Create your models here.
class Post(models.Model):
    autor = models.ForeignKey('auth.User', on_delete=models.CASCADE)
    title = models.CharField(max_length=255, null=False, blank=False)
    content = models.TextField()
    created_date = models.DateTimeField(timezone.now)
    published_date = models.DateTimeField(timezone.now)

테이블 구조를 정의 했으니 테이블을 생성해준다.

(venv) $ : ./manage.py makemigrations
(venv) $ : ./manage.py migrate

생성한 모델을 관리자에 등록해줘야 게시글 관리가 가능하다. admin.py를 수정해주자.

from django.contrib import admin
from .models import Post
# Register your models here.
admin.site.register(Post)
모델이 추가된 장고 관리자페이지
모델이 추가된 장고 관리자페이지

add 버튼을 눌러 게시글을 작성해보면 게시한 제목대신 post object 라고 표시된다. 이부분을 수정해주자.

# Create your models here.
class Post(models.Model):
    autor = models.ForeignKey('auth.User', on_delete=models.CASCADE)
    title = models.CharField(max_length=255, null=False, blank=False)
    content = models.TextField()
    created_date = models.DateTimeField(default=timezone.now)
    published_date = models.DateTimeField(default=timezone.now)

#관리자사이트에서 제목을 출력
    def __str__(self):
        return self.title

6 . urls.py 설정

관리자에서는 등록한 모델이 수정 삭제가 가능하지만 일반페이지는 아무것도 개발되지 않은 빈 상태다. 우선 localhost:8000/myblog 라는 주소로 접속할 수 있게 mysite/urls.py를 수정해준다.

#mysite/urls.py

from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('myblog/', include('myblog.urls'))
]

위 코드에서 path(‘blog/’, include(‘myblog.urls’)) 부분은 url을 처리하는 패턴에 myblog/urls.py에 정의된 패턴을 사용하겠다는 뜻으로 이해했다.

mysite/urls.py에서 myblog/urls.py를 불러오지만 실제로는 해당경로에 파일이 존재 하지 않기 때문에 파일을 생성하고 url 패턴 처리를 해준다.

$ mysite/urls.py

from django.shortcuts import render
from django.http import HttpResponse

# Create your views here.
def index(requests):
    return HttpResponse('hello world!')

이제 localhost:8000/myblog로 접속하면 ‘hello world’를 확인 할 수 있다.

지금까지 설치부터 간단한 hello world 페이지 까지 작성하였다.
다음 포스팅에서는 리스팅페이지, 글쓰기, 수정 기능에 대해서 내용을 이어나가겠다.

[django] model.objects.filter를 이용한 검색기능 추가

django에서 model.objects.filter를 이용해 제목, 본문을 검색할 수 있는 기능을 추가하려고 한다.

#index.html

<form class="form-inline" action="{% url 'blog:index' %}" method="get">
    <div class="input-group">
        <select name="fd_name" id="" class="form-control">
            <option value="title">title</option>
            <option value="content">content</option>
        </select>
        <input type="text" class="form-control" name='q'>
        <button class="btn btn-secondary" type="submit">Search</button>
    </div>
</form>
#vew.py

def index(request):
    if request.GET.get('q'):
            variable_column = request.GET.get('fd_name')
            search_type = 'contains'
            filter = variable_column + '__' + search_type
            posts = Post.objects.filter(**{ filter: request.GET.get('q') }).order_by('-published_date')
    return render(request, 'blog/index.html', {
        'posts': posts,
    })

맨처음에는 Post.objdects.filter(requests.GET.get(‘q’)+’__contains’ = requests.GET.get(‘q’))으로 시도를 했지만 에러를 냈다. 문자열로 받아드리기 때문에 장고에서 필드명을 찾지 못하는것 같았다.

modesl.objects.fillter(**kwargs)로 인자를 받기때문에 문자열이 아닌 딕셔너리 **{key:value}로 인자를 전달해서 문제를 해결했다.

참고 사이트
https://docs.djangoproject.com/en/2.1/topics/db/queries/
https://stackoverflow.com/questions/4720079/django-query-filter-with-variable-column
https://wayhome25.github.io/django/2017/05/04/django-queryset-search/

[heroku]로컬db 헤로쿠로db에 덮어쓰기

이전 포스팅에서 헤로쿠 디비를 로컬로 복원하는 방법을 기록했다. 이번에는 반대의 경우를 포스팅 하고자 한다.

환경
1. django, postgreSQL 사용
2 .우분투

로컬에서 dump 파일 생성

PGPASSWORD=mypassword pg_dump -Fc --no-acl --no-owner -h localhost -U myuser mydb > mydb.dump

헤로쿠에 업데이트하기

heroku pg:backups:restore '<SIGNED URL>' DATABASE_URL

‘<SIGNED URL>’ 을 통해서 헤로쿠에 파일을 전달해야 하므로 파일을 웹(깃, 기타 호스팅) 어디가에 저장해놓아야 한다.

참고사이트 : https://devcenter.heroku.com/articles/heroku-postgres-import-export
이전포스팅: 헤로쿠 DB백업 후 로컬로 복원

[heroku] 헤로쿠 DB백업 후 로컬로 복원

헤로쿠 메인페이지
헤로쿠 메인페이지

헤로쿠앱에서 원격으로 작업하지 않고 로컬에서 작업후 업데이트하고자 했음.
로컬에은 sqlite3을 사용하였고 헤로쿠앱은 postgresSQL을 사용하고 있었기 때문에 로컬의 데이터베이스시스템을 변경할 필요가 있었다.

1. 헤로쿠에서 디비파일을 덤프뜨고 로컬로 내려받는다.

heroku pg:backups:capture
heroku pg:backups:download

2. 윈도우용 postgresSQL 설치 후 환경변수 등록
postgresSQl 다운로드
내pc>속성>고급시스템설정>고급>환경변수>시스템변수에서 path 편집>C:\Program Files\PostgreSQL\11\bin경로 추가

3. pgAdmin4에서 사용자 및 디비를 생성한다.
참고주소 : https://www.pgadmin.org/docs/pgadmin4/dev/pgadmin_user.html

4. 디비를 복원한다

pg_restore --verbose --clean --no-acl --no-owner -h localhost -U myuser -d mydb latest.dump

5. 헤로쿠 깃에서 앱 다운로드

heroku git:clone -a myapp

6. setting.py 파일 수정

django와 postgreSQL을 연결하기 위해서  psycopg2 라는 모듈이 필요함 pip list 명령어를 통해서 패키지 확인후 없으면 pip install  psycopg2
설치를 마치면 setting.py 파일의 데이터베이스부분을 수정

setting.py

DATABASES = {
    "default": {
	'ENGINE': 'django.db.backends.postgresql_psycopg2',
        'NAME': 'databsename',
        'USER': 'username',
        'PASSWORD': 'password',
        'HOST': 'localhost',
        'PORT': '',
    }
}

7. django 실행

# 정적파일들을 해당앱으로 불러온다
python manage.py collectstatic 

# 로컬서버실행
heroku local web -f Procfile.windows

# 리눅스일경우
heroku local web

# 데이터베이스 마이그레이션
python .managr.py makemigrations
python .managr.py migrate

localhost:5000/admin으로 접속해 데이터 복원을 확인

[django] 페이지 네이션 구현

모듈 로드

from django.core.paginator import Paginator

view.py

import math

from django.shortcuts import render, get_object_or_404
from django.core.paginator import Paginator
from .models import Post

# Create your views here.
def index(request):
    posts = Post.objects.all().order_by('-published_date')
    paginator = Paginator(posts, 10)
    page = request.GET.get('page')
    contacts = paginator.get_page(page)
    page_range = 5 #페이지 범위 지정 예 1, 2, 3, 4, 5 / 6, 7, 8, 9, 10
    current_block = math.ceil(int(page)/page_range) #해당 페이지가 몇번째 블럭인가
    start_block = (current_block-1) * page_range
    end_block = start_block + page_range
    p_range = paginator.page_range[start_block:end_block]
    return render(request, 'blog/index.html', {
        'contacts': contacts,
        'p_range' : p_range,
    })

start_block = ((current_block-1) * page_range) + 1
end_block = start_block + page_range – 1
위와 같이 계산해 줘야 [1, 2, 3, 4, 5]를 구할 수 있으나 paginator.page_range 배열에서 리스트 슬라이싱을 하기 위해 위 코드처럼 작성 하였다. (리스트는 0부터 시작하기 때문)

list.html

<div class="pagination">
    <span class="step-links">
        {% if contacts.has_previous %}
            <a href="?page=1">&laquo; first</a>
            <a href="?page={{ contacts.previous_page_number }}">previous</a>
        {% endif %}

        {% for i in p_range %}
            <a href="?page={{i}}" {% if contacts.number == i %}class="active" {% endif %}>{{i}}</a>
        {% endfor %}

        {% if contacts.has_next %}
            <a href="?page={{ contacts.next_page_number }}">next</a>
            <a href="?page={{ contacts.paginator.num_pages }}">last &raquo;</a>
        {% endif %}
    </span>
</div>