Лабораторная работа №9. Eleven Note
Как обычно создайте новую ветку разработки, папку для вашего проекта elevennote
и виртуальное окружение с именем elevennote-env
, в котором установите следующие пакеты:
(elevennote-env) $ pip install Django==1.11.5
(elevennote-env) $ pip install psycopg2==2.7.3.1
(elevennote-env) $ pip install python-decouple==3.1
(elevennote-env) $ pip install uWSGI==2.0.15
Можете запустить команду pip freeze
для просмотра установленных пакетов:
(elevennote-env) $ pip freeze
Django==1.11.5
psycopg2==2.7.3.1
uWSGI==2.0.15
python-decouple==3.1
pytz==2017.2
Все зависимости будем хранить в отдельном манифесте:
(elevennote-env) $ mkdir requirements
(elevennote-env) $ pip freeze > requirements/base.txt
(elevennote-env) $ echo "-r base.txt" >> requirements/dev.txt
(elevennote-env) $ echo "-r base.txt" >> requirements/prod.txt
Теперь создадим новый проект с помощью команды django-admin
(исходники проекта будем хранить в папке src
). Обратите внимание, что имя проекта config
, а все файлы проекта будут созданы в текущей рабочей директории (на что указывает .
):
(elevennote-env) $ mkdir src && cd $_
(elevennote-env) $ django-admin startproject config .
(elevennote-env) $ ls
config manage.py
В результате вы должны получить следующую структуру проекта:
elevennote/
├── requirements
│ ├── base.txt
│ ├── dev.txt
│ └── prod.txt
└── src
├── config
│ ├── __init__.py
│ ├── settings.py
│ ├── urls.py
│ └── wsgi.py
└── manage.py
Можете проверить, что проект запускается командой python manage.py runserver
(если после запуска была создана БД db.sqlite3
, а какие-то файлы закешированы __pycache__
, то можете смело удалить их).
Конфигурацию нашего проекта разделим на:
base.py
- общая конфигурация для всех развертываний проекта;local.py
- конфигурация, которая используется при разработке (включенный режим откладки, дополнительные пакеты для разработки, например,django-debug-toolbar
);production.py
- настройки, которые используются для production-сервера.
(elevennote-env) $ mkdir src/config/settings
(elevennote-env) $ mv src/config/settings.py src/config/settings/base.py
(elevennote-env) $ touch src/config/settings/__init__.py
(elevennote-env) $ touch src/config/settings/local.py
(elevennote-env) $ touch src/config/settings/production.py
(elevennote-env) $ touch src/config/settings/settings.ini
(elevennote-env) $ tree src/config
config/
├── __init__.py
├── settings
│ ├── __init__.py
│ ├── base.py
│ ├── local.py
│ ├── production.py
│ └── settings.ini
├── urls.py
└── wsgi.py
config/settings/base.py
import os
from decouple import config
def root(*dirs):
base_dir = os.path.join(os.path.dirname(__file__), '..', '..')
return os.path.abspath(os.path.join(base_dir, *dirs))
BASE_DIR = root()
SECRET_KEY = config('SECRET_KEY')
INSTALLED_APPS = [
# ...
]
MIDDLEWARE = [
# ...
]
ROOT_URLCONF = 'config.urls'
TEMPLATES = [
# ...
]
WSGI_APPLICATION = 'config.wsgi.application'
AUTH_PASSWORD_VALIDATORS = [
# ...
]
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_L10N = True
USE_TZ = True
configs/settings/local.py
from .base import *
DEBUG = True
INSTALLED_APPS += [
'django.contrib.postgres',
]
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql_psycopg2',
'NAME': config('DB_NAME'),
'USER': config('DB_USER'),
'PASSWORD': config('DB_PASSWORD'),
'HOST': config('DB_HOST'),
'PORT': config('DB_PORT', cast=int),
}
}
В config/settings/settings.ini
укажите секретный ключ для Django, имя БД, пользователя БД, его пароль, адрес хоста и порт, на котором будет запущена БД:
[settings]
SECRET_KEY=
DB_NAME=
DB_USER=
DB_PASSWORD=
DB_HOST=
DB_PORT=
manage.py
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local")
Создадим docker-окружение для разработки и запуска нашего проекта.
Установите докер для вашей системы (инструкции можно получить тут) и создайте Dokerfile
в корне вашего проекта:
FROM python:3.6
ENV PYTHONUNBUFFERED 1
RUN mkdir /config
COPY requirements /config/requirements
RUN pip install -r /config/requirements/dev.txt
RUN mkdir /src;
WORKDIR /src
TODO: Пояснения к Dockerfile
Теперь в корне проекта создадим файл docker-compose.yml
:
version: '2'
services:
nginx:
image: nginx:latest
container_name: ng01
ports:
- "8000:8000"
volumes:
- ./deploy/nginx:/etc/nginx/conf.d
depends_on:
- web
web:
build: .
container_name: dg01
command: bash -c "python manage.py makemigrations && python manage.py migrate && uwsgi --ini /usr/local/etc/elevennote.ini"
depends_on:
- db
volumes:
- ./src:/src
- ./deploy/uwsgi:/usr/local/etc/
expose:
- "8000"
db:
image: postgres:latest
container_name: ps01
TODO: Пояснения к docker-compose.yml
deploy/uwsgi/elevennote.ini
[uwsgi]
chdir = /src
module = config.wsgi:application
master = true
processes = 5
socket = 0.0.0.0:8000
vacuum = true
die-on-term = true
env = DJANGO_SETTINGS_MODULE=config.settings.local
TODO: Пояснения к конфигурации uwsgi
deploy/nginx/elevennote.conf
upstream web {
ip_hash;
server web:8000;
}
server {
location / {
include uwsgi_params;
uwsgi_pass web;
}
listen 8000;
server_name localhost;
}
TODO: Пояснения к nginx
На текущий момент структура вашего проекта должна быть следующей:
.
├── Dockerfile
├── deploy
│ ├── nginx
│ │ └── elevennote.conf
│ └── uwsgi
│ └── elevennote.ini
├── docker-compose.yml
├── requirements
│ ├── base.txt
│ ├── dev.txt
│ └── prod.txt
└── src
├── config
│ ├── __init__.py
│ ├── settings
│ │ ├── __init__.py
│ │ ├── base.py
│ │ ├── local.py
│ │ ├── production.py
│ │ └── settings.ini
│ ├── urls.py
│ └── wsgi.py
└── manage.py
$ docker-compose build
$ docker-compose up
Теперь приложение запущено и вы можете обратиться к нему по адресу http://localhost:8000, где должны увидеть стандартное приветствие Django:
На текущий момент осталась одна нерешенная проблема - обработка статических файлов:
Задание: Решите обозначенную проблему путем внесения соответствующих изменений в docker-compose.yml
, deploy/nginx/elevennote.conf
и src/config/settings/base.py
.
Создадим нового суперпользователя. Для этого подключимся к контейнеру:
$ docker exec -it dg01 bash
$ root@67a3bcc5c881$ python manage.py createsuperuser
Username (leave blank to use 'root'):
Email address:
Password:
Password (again):
Superuser created successfully.
root@67a3bcc5c881:/src# exit
Создадим новое приложение notes
(заметки) и добавим его в INSTALLED_APPS
:
$ docker-compose run web python manage.py startapp notes
src/config/settings/base.py
INSTALLED_APPS = [
# ...
'notes',
]
Создадим первую модель Note
со следующими полями:
- заголовок (
title
) с ограничением 200 символов; - текст заметки (
body
); - и датой создания (
pub_date
).
notes/models.py
class Note(models.Model):
title = models.CharField(max_length=200)
body = models.TextField()
pub_date = models.DateTimeField('date published')
Зарегистрируем созданную модель в панели администратора:
notes/admin.py
from django.contrib import admin
from .models import Note
admin.site.register(Note)
И применим внесенные изменения к БД:
$ docker-compose run web python manage.py makemigrations notes
$ docker-compose run web python manage.py migrate
Теперь откройте панель администратора http://localhost:8000/admin. Вы должны увидеть, что появился новый раздел Notes
, в котором вы можете создавать, редактировать и удалять заметки.
$ pip install django-admin-honeypot==1.0.0
config/settings/base.py
INSTALLED_APPS = [
# ...
'admin_honeypot',
# ...
]
config/urls.py
urlpatterns = [
url(r'^admin/', include('admin_honeypot.urls', namespace='admin_honeypot')),
url(r'^secret/', admin.site.urls),
]
notes/admin.py
class NoteAdmin(admin.ModelAdmin):
list_display = ('title', 'pub_date', 'was_published_recently')
list_filter = ['pub_date']
# Replace your other register call with this line:
admin.site.register(Note, NoteAdmin)
notes/models.py
def was_published_recently(self):
return self.pub_date >= timezone.now() - timedelta(days=1)
$ mkdir notes/tests
$ mv notes/tests.py notes/tests/test_models.py
notes/tests/test_models.py
import datetime
from django.utils import timezone
from django.test import TestCase
from .models import Note
class NoteMethodTests(TestCase):
def test_was_published_recently(self):
"""
was_published_recently() should return False for notes whose pub_date is in the future.
"""
time = timezone.now() + datetime.timedelta(days=30)
future_note = Note(pub_date=time)
self.assertEqual(future_note.was_published_recently(), False)
$ python manage.py test
Creating test database for alias 'default'...
F
======================================================================
FAIL: test_was_published_recently (note.tests.NoteMethodTests)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/home/vagrant/Projects/elevennote/elevennote/note/tests.py", line 18, in test_was_published_recently
self.assertEqual(future_note.was_published_recently(), False)
AssertionError: True != False
----------------------------------------------------------------------
Ran 1 test in 0.001s
FAILED (failures=1)
Destroying test database for alias 'default'...
notes/models.py
def was_published_recently(self):
now = timezone.now()
return now - timedelta(days=1) <= self.pub_date <= now
config/urls.py
url(r'^notes/', include('notes.urls', namespace="notes")),
$ touch notes/urls.py
notes/urls.py
from django.conf.urls import url
from . import views
urlpatterns = [
url(r'^$', views.index, name='index'),
url(r'^(?P<note_id>[0-9]+)/$', views.detail, name='detail'),
]
notes/views.py
from django.shortcuts import get_object_or_404, render
from .models import Note
def index(request):
latest_note_list = Note.objects.order_by('-pub_date')[:5]
context = {
'latest_note_list': latest_note_list,
}
return render(request, 'notes/index.html', context)
def detail(request, note_id):
note = get_object_or_404(Note, pk=note_id)
return render(request, 'notes/detail.html', {'note': note})
$ mkdir -p templates/notes
config/settings/base.py
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [root('templates')],
# ...
},
]
templates/note/index.html
{% if latest_note_list %}
<ul>
{% for note in latest_note_list %}
<li><a href="{% url 'notes:detail' note.id %}">{{ note.title }}</a></li>
{% endfor %}
</ul>
{% else %}
<p>No notes are available.</p>
{% endif %}
templates/note/detail.html
<h1>{{ note.title }}</h1>
<p>{{ note.body }}</p>
notes/tests/tests_views.py
$ python manage.py startapp accounts
config/urls.py
#...
from django.http import HttpResponseRedirect
urlpatterns = [
# Handle the root url.
url(r'^$', lambda r: HttpResponseRedirect('notes/')),
# Admin
url(r'^admin/', include('admin_honeypot.urls', namespace='admin_honeypot')),
url(r'^secret/', admin.site.urls),
# Accounts app
url(r'^accounts/', include('accounts.urls', namespace="accounts")),
# Notes app
url(r'^notes/', include('note.urls', namespace="notes")),
]
$ touch accounts/urls.py
accounts/urls.py
from django.conf.urls import url
from django.contrib.auth import views
urlpatterns = [
url(r'^login/$', views.login, name='login'),
url(r'^logout/$', views.logout, name='logout'),
]
templates/registration/login.html
<form action="{% url 'accounts:login' %}" method="post" accept-charset="utf-8">
{% csrf_token %}
{% for field in form %}
<label>{{ field.label }}</label>
{% if field.errors %}
{{ field.errors }}
{% endif %}
{{ field }}
{% endfor %}
<input type="hidden" name="next" value="{{ next }}" />
<input class="button small" type="submit" value="Submit"/>
</form>
notes/views.py
# ...
from django.contrib.auth.decorators import login_required
@login_required
def index(request):
# ...
@login_required
def detail(request):
# ...
notes/tests/test_views.py
$ mkdir static
$ cd static
$ wget https://github.com/twbs/bootstrap/releases/download/v3.0.3/bootstrap-3.0.3-dist.zip
$ unzip bootstrap-3.0.3-dist.zip
$ mv dist bootstrap
$ cd ..
config/settings/base.py
# ...
STATIC_URL = '/static/'
STATIC_ROOT = '/static'
STATICFILES_DIRS = [
root('static'),
]
# Fix me
wget https://github.com/sixfeetup/ElevenNote/raw/master/templates-ch06.zip
unzip templates-ch6.zip # If prompted to replace, say (Y)es
cd ..
accounts/views.py
from django.contrib.auth import authenticate, login
from django.contrib.auth.forms import UserCreationForm
from django.views.generic import FormView
class RegisterView(FormView):
template_name = 'registration/register.html'
form_class = UserCreationForm
success_url='/'
def form_valid(self, form):
#save the new user first
form.save()
#get the username and password
username = self.request.POST['username']
password = self.request.POST['password1']
#authenticate user then login
user = authenticate(username=username, password=password)
login(self.request, user)
return super(RegisterView, self).form_valid(form)
accounts/urls.py
from django.conf.urls import url
from django.contrib.auth import views as auth_views
from django.core.urlresolvers import reverse_lazy
from .views import RegisterView
urlpatterns = [
url(r'^login/$', auth_views.login, name='login'),
url(r'^logout/$', auth_views.logout, {"next_page" : reverse_lazy('accounts:login')}, name='logout'),
url('^register/', RegisterView.as_view(), name='register'),
]
config/settings/base.py
# ...
LOGIN_REDIRECT_URL = '/'
accounts/tests/test_views.py
notes/models.py
# ...
from django.contrib.auth.models import User
class Note(models.Model):
# ...
owner = models.ForeignKey(User, related_name='notes', on_delete=models.CASCADE)
$ python manage.py makemigrations
$ python manage.py migrate
notes/views.py
latest_note_list = Note.objects.filter(owner=request.user).order_by('-pub_date')[:5]
notes/admin.py
list_display = ('title', 'owner', 'pub_date', 'was_published_recently')
notes/views.py
from django.utils.decorators import method_decorator
from django.contrib.auth.decorators import login_required
from django.views.generic import ListView, DetailView
from .models import Note
class NoteList(ListView):
paginate_by = 5
template_name = 'notes/index.html'
context_object_name = 'latest_note_list'
@method_decorator(login_required)
def dispatch(self, *args, **kwargs):
return super(NoteList, self).dispatch(*args, **kwargs)
def get_queryset(self):
return Note.objects.filter(owner=self.request.user)
class NoteDetail(DetailView):
model = Note
template_name = 'notes/detail.html'
context_object_name = 'note'
@method_decorator(login_required)
def dispatch(self, *args, **kwargs):
return super(NoteDetail, self).dispatch(*args, **kwargs)
notes/urls.py
from django.conf.urls import url
from .views import NoteList, NoteDetail
urlpatterns = [
url(r'^$', NoteList.as_view(), name='index'),
url(r'^(?P<pk>[0-9]+)/$', NoteDetail.as_view(), name='detail'),
]
notes/views.py
from django.contrib.auth.mixins import LoginRequiredMixin
class NoteList(LoginRequiredMixin, ListView):
# ...
class NoteDetail(LoginRequiredMixin, DetailView):
# ...
notes/forms.py
from django import forms
from .models import Note
class NoteForm(forms.ModelForm):
class Meta:
model = Note
exclude = ['owner', 'pub_date']
notes/views.py
from django.views.generic import ListView, DetailView, CreateView
from django.utils import timezone
from django.core.urlresolvers import reverse_lazy
from .forms import NoteForm
...
class NoteCreate(LoginRequiredMixin, CreateView):
form_class = NoteForm
template_name = 'notes/form.html'
success_url = reverse_lazy('notes:index')
def form_valid(self, form):
form.instance.owner = self.request.user
form.instance.pub_date = timezone.now()
return super(NoteCreate, self).form_valid(form)
notes/urls.py
url(r'^new/$', NoteCreate.as_view(), name='create'),
templates/notes/form.html
{% extends "base.html" %}
{% block content %}
{% if form.errors %}
{% for error in form.non_field_errors %}
<div class="alert alert-danger" role="alert">
<strong>{{ error|escape }}</strong>
</div>
{% endfor %}
{% endif %}
<form action="{% url 'notes:create' %}" method="post" accept-charset="utf-8">
{% csrf_token %}
{% for field in form %}
<p>
<label>{{ field.label }}</label>
{% if field.errors %}
<div class="alert alert-danger" role="alert">
{{ field.errors }}
</div>
{% endif %}
{{ field }}
</p>
{% endfor %}
<input type="hidden" name="next" value="{{ next }}" />
<input class="button small" type="submit" value="Submit"/>
</form>
{% endblock %}
templates/notes/index.html
<a href="{% url 'notes:create' %}">Create a new note</a>
template/notes/index.html
{% if is_paginated %}
<div class="pagination">
<span class="step-links">
{% if page_obj.has_previous %}
<a href="?page={{ page_obj.previous_page_number }}">previous</a>
{% endif %}
<span class="current">
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}.
</span>
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}">next</a>
{% endif %}
</span>
</div>
{% endif %}
notes/views.py
return Note.objects.filter(owner=self.request.user)
return Note.objects.filter(owner=self.request.user).order_by('-pub_date')
$ python -m pip install django-wysiwyg
INSTALLED_APPS = [
# ...
'django_wysiwyg',
# ...
]
#...
DJANGO_WYSIWYG_FLAVOR = 'ckeditor'
$ cd static
$ wget https://download.cksource.com/CKEditor/CKEditor/CKEditor%204.7.2/ckeditor_4.7.2_full.zip
$ unzip ckeditor_4.7.2_full.zip
$ cd ..
notes/views.py
from django.core.exceptions import PermissionDenied
...
# In NoteDetail class, override the get() method to raise an
# error if the user tries to view another user's note.
def get(self, request, *args, **kwargs):
self.object = self.get_object()
if self.object.owner != self.request.user:
raise PermissionDenied
context = self.get_context_data(object=self.object)
return self.render_to_response(context)
notes/views.py
from django.views.generic import ListView, DetailView, CreateView, UpdateView
...
class NoteUpdate(LoginRequiredMixin, UpdateView):
model = Note
form_class = NoteForm
template_name = 'notes/form.html'
success_url = reverse_lazy('notes:index')
def form_valid(self, form):
form.instance.pub_date = timezone.now()
return super(NoteUpdate, self).form_valid(form)
notes/urls.py
url(r'^(?P<pk>[0-9]+)/edit/$', NoteUpdate.as_view(), name='update'),
templates/notes/form.html
{% if object %}
<form action="{% url 'notes:update' object.pk %}" method="post" accept-charset="utf-8">
{% else %}
<form action="{% url 'notes:create' %}" method="post" accept-charset="utf-8">
{% endif %}
templates/notes/index.html
<a href="{% url 'notes:update' note.id %}">Edit</a>
templates/notes/index.html
<p>
<a href="{% url 'notes:update' note.id %}">{{ note.title }}</a><br />
{{ note.body | safe }}
</p>
<hr />
notes/mixins.py
from .models import Note
class NoteMixin(object):
def get_context_data(self, **kwargs):
context = super(NoteMixin, self).get_context_data(**kwargs)
context.update({
'notes': Note.objects.filter(owner=self.request.user).order_by('-pub_date'),
})
return context
notes/views.py
class NoteCreate(LoginRequiredMixin, NoteMixin, CreateView):
class NoteUpdate(LoginRequiredMixin, NoteMixin, UpdateView):
...
def get(self, request, *args, **kwargs):
self.object = self.get_object()
if self.object.owner != self.request.user:
raise PermissionDenied
return super(NoteUpdate, self).get(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
self.object = self.get_object()
if self.object.owner != self.request.user:
raise PermissionDenied
return super(NoteUpdate, self).post(request, *args, **kwargs)
$ wget https://github.com/sixfeetup/ElevenNote/raw/master/templates-ch16.zip
$ unzip templates-ch16.zip
cd static
wget https://github.com/sixfeetup/ElevenNote/raw/master/static-elevennote-ch16.zip
unzip static-elevennote-ch16.zip
cd ..
notes/mixins.py
def check_user_or_403(self, user):
""" Issue a 403 if the current user is no the same as the `user` param. """
if self.request.user != user:
raise PermissionDenied
notes/views.py
class NoteDelete(LoginRequiredMixin, NoteMixin, DeleteView):
model = Note
success_url = reverse_lazy('notes:index')
def post(self, request, *args, **kwargs):
self.object = self.get_object()
self.check_user_or_403(self.object.owner)
return super(NoteDelete, self).post(request, *args, **kwargs)
notes/urls.py
url(r'^(?P<pk>[0-9]+)/delete/$', NoteDelete.as_view(), name='delete'),
templates/notes/form.html
{% if object %}
<form action="{% url 'notes:delete' object.pk %}" method="post" id="delete-note-form">
{% csrf_token %}
<a class="btn btn-default" id="delete-note">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span>
</a>
</form>
{% endif %}
notes/views.py
def get_success_url(self):
return reverse('notes:update', kwargs={
'pk': self.object.pk
})
Задание: Добавьте возможность указывать теги у каждой заметки (можете использовать Bootstrap Tags Input для отображения и управления тегами на стороне клиента, см. скриншот ниже). Добавление и удаление тегов должно быть асинхронным (с использованием технологии AJAX). При нажатии на тег должен выводиться список только тех заметок, у которых указан этот тег.
Continuous Integration с CircleCI
Добавляем API с помощью DRF
$ pip install djangorestframework
$ python manage.py api
config/settings/base.py
INSTALLED_APPS = [
# ...
'rest_framework',
# ...
'api',
]
api/base/serializers.py
from rest_framework import serializers
from notes.models import Note
class NoteSerializer(serializers.ModelSerializer):
class Meta:
model = Note
fields = ('id', 'title', 'body', 'pub_date')