Python、Django でプロトタイプ・テキスト SNS を実装(前半)
いよいよ、「みことプロジェクト」用アプリの実装をしていこうと思います。
軽量重視で、プロトタイプのテキスト SNS アプリを作ることにしました。
それでは、いきまっしょい!
環境
Wnidows10、VSCode
Python v3.13.2、Django v5.1.7
フォルダ構成(前半)
※今回、作成・変更したもののみ
sns
├ static(以下、省略)
├ templates(以下、省略)
├ sns
| ├ settings.py
| └ urls.py
└ text_sns
├ admin.py
├ forms.py
├ models.py
├ urls.py
└ views.py
今回やること(前半)
- 初期設定
- サインアップ・ログイン・ログアウト機能
- 記事投稿、一覧表示、削除
- ページネーション
- 管理画面設定
- 今回プロジェクト
次回やること(後半・予定)
- チェック、一覧表示、解除
- コメント投稿、削除
- つながる、一覧表示、またね
- いいね(非同期)
- 検索
1. 初期設定
プロジェクト、アプリを作成します。
django-admin startproject sns
cd sns
python manage.py startapp text_sns
settings.py(初期コメントは削除します)
import os
...
BASE_DIR = Path(__file__).resolve().parent.parent
TEMPLATE_DIR = os.path.join(BASE_DIR, 'templates')
STATIC_DIR = os.path.join(BASE_DIR, 'static')
...
INSTALLED_APPS = [
...
'text_sns',
]
...
TEMPLATES = [
{
...
'DIRS': [TEMPLATE_DIR],
...
# パスワード強化
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.BCryptSHA256PasswordHasher",
"django.contrib.auth.hashers.PBKDF2PasswordHasher",
"django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher",
"django.contrib.auth.hashers.Argon2PasswordHasher",
"django.contrib.auth.hashers.ScryptPasswordHasher",
]
AUTH_PASSWORD_VALIDATORS = [
...
LANGUAGE_CODE = 'ja'
TIME_ZONE = 'Asia/Tokyo'
...
STATICFILES_DIRS = [STATIC_DIR,]
STATIC_URL = '/static/'
...
bcrypt をインストールします(パスワード強化用)
pip install bcrypt
sns プロジェクト・フォルダ内に、static フォルダと templates フォルダを作成します。
URL のパスを設定していきます。
...
from django.urls import path, include
urlpatterns = [
...
path('text_sns/', include('text_sns.urls')),
]
from . import views
app_name = 'text_sns'
urlpatterns = [
path('', views.index, name='index'),
]
ビューを作成します。
from django.shortcuts import render
def index(request):
return render(request, 'text_sns/index.html', context={})
Google フォント(Web フォント)、ブートストラップ、CSS ファイルの設定を行います。
Google フォント: https://fonts.google.com/ ( HTML と CSS の設定が必要になります)
ブートストラップ: https://getbootstrap.jp/docs/5.3/getting-started/rtl
まずは、ベースのファイルを作ります。
ブートストラップのスターター・テンプレート・ベース、ブロックの横幅調整を style で直接行ってます。
<html lang="ja">
<head>
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.gstatic.com">
<link href="https://fonts.googleapis.com/css2?family=M+PLUS+1p:wght@300&display=swap" rel="stylesheet">
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
{% load static %}
<link rel="stylesheet" href="{% static 'text_sns/css/style.css' %}">
<title>Mikoto SNS</title>
</head>
<body>
<div class="container" style="max-width: 50rem; width: 90%;">
{% block main_content %}
<h1>Mikoto SNS</h1>
{% endblock %}
</div>
<!-- Optional JavaScript; choose one of the two! -->
<!-- Option 1: Bootstrap Bundle with Popper -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM" crossorigin="anonymous"></script>
<!-- Option 2: Separate Popper and Bootstrap JS -->
<!--
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.9.2/dist/umd/popper.min.js" integrity="sha384-IQsoLXl5PILFhosVNubq5LC7Qb9DXgDA9i+tQ8Zj3iwWAwPtgFTxbJ8NT4GN1R8p" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.min.js" integrity="sha384-cVKIPhGWiC2Al4u+LWgxfKTRIcfu0JTxR+EQDz/bgldoEyl4H0zUF0QKbrJ0EcQF" crossorigin="anonymous"></script>
-->
</body>
</html>
インデックス・ファイルを作ります。
{% extends 'text_sns/base.html' %}
{% block main_content %}
<div>
<h1 class="text-center">みこと(Mikoto) SNS</h1>
</div>
{% endblock %}
CSS ファイルで、Web フォント適用、リンクと入力表示の設定を行います。
body {
/* Web フォント */
font-family: "M PLUS 1p", sans-serif;
background-color: #fff;
color: #000;
}
a {
text-decoration: none;
}
input {
width: 100%;
}
動作確認します。
python manage.py runserver 8080
http://localhost:8080/text_sns/
にアクセス(以降ダークモードです)
2. サインアップ・ログイン・ログアウト機能
urls.py、forms.py、views.py、html の順でファイルを作っていきます。
前回の記事で詳細を書いているので、簡単にすすめていきます。
アカウント削除は、確認ページも作ります。
...
urlpatterns = [
...
path('signup/', views.signup_func, name='signup'),
path('login/', views.login_func, name='login'),
path('logout/', views.logout_func, name='logout'),
path('delete_confirm/', views.delete_confirm_func, name='delete_confirm'),
path('user_delete/', views.user_delete_func, name='user_delete'),
...
フォームを作成します。
from django import forms
from django.contrib.auth.models import User
from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError
class SignupForm(forms.ModelForm):
class Meta:
model = User
fields = ('username', 'email', 'password')
labels = {
'username': 'ユーザー名',
'email': 'メールアドレス',
'password': 'パスワード',
}
widgets = {'password': forms.PasswordInput()}
reconfirmation_password = forms.CharField(
label='パスワード再確認',
widget=forms.PasswordInput()
)
def clean(self):
cleaned_data = super().clean()
password = cleaned_data['password']
reconfirmation_password = cleaned_data['reconfirmation_password']
if password != reconfirmation_password:
self.add_error('password', 'パスワードが一致しません')
try:
validate_password(password, self.instance) # instance: 入力ユーザー情報
except ValidationError as e:
self.add_error('password', e)
return cleaned_data
class LoginForm(forms.Form):
username = forms.CharField(label="ユーザー名")
password = forms.CharField(label="パスワード", widget=forms.PasswordInput())
ビューを作成します。
from django.contrib import messages
from django.contrib.auth import authenticate, login, logout
from django.contrib.auth.models import User
from django.shortcuts import render, redirect
from .forms import SignupForm, LoginForm
...
def signup_func(request):
if request.method == 'POST':
signup_form = SignupForm(request.POST)
if signup_form.is_valid(): # True or False
user = signup_form.save(commit=False) # commit=False のみインスタンスを返す
password = signup_form.cleaned_data['password']
user.set_password(password) # パスワードのハッシュ化
user.save()
messages.success(request, 'サインアップに成功しました')
login(request, user)
return redirect('text_sns:index')
else:
messages.error(request, 'サインアップに失敗しました')
return redirect('text_sns:signup')
else:
signup_form = SignupForm()
return render(request, 'text_sns/signup.html', context={'form': signup_form},)
def login_func(request):
if request.method == 'POST':
login_form = LoginForm(request.POST)
if login_form.is_valid():
username = login_form.cleaned_data['username']
password = login_form.cleaned_data['password']
user = authenticate(username=username, password=password)
if user:
login(request, user)
messages.success(request, 'ログインしました')
return redirect('text_sns:index')
else:
messages.error(request, 'ログインに失敗しました')
return redirect('text_sns:login')
else:
messages.error(request, 'ログインに失敗しました')
return redirect('text_sns:login')
else:
login_form = LoginForm()
return render(request, 'text_sns/login.html', context={'form': login_form})
def logout_func(request):
logout(request)
messages.success(request, 'ログアウトしました')
return redirect('text_sns:login')
def delete_confirm_func(request):
return render(request, 'text_sns/delete_confirm.html', context={})
def user_delete_func(request):
user = str(request.user)
if user != 'AnonymousUser':
user = User.objects.filter(pk=request.user.id)
user.delete()
return render(request, 'text_sns/user_delete.html', context={})
else:
return redirect('text_sns:index')
入力用ファイルは、共通レイアウトにするため、input.html を作成します。
{% extends 'text_sns/base.html' %}
{% block main_content %}
<div class="d-flex justify-content-center">
<form method="POST">
<p class="mb-3 p-3 text-center">みこと(Mikoto) SNS</p>
<p class="text-center">{% include 'snippets/message.html' %}</p>
{% block sub_content %}
{% endblock %}
</form>
</div>
{% endblock %}
メッセージ用のスニペット・ファイルを作成します。
{% if messages %}
{% for message in messages %}
{{ message }}
{% endfor %}
{% endif %}
サインアップ用のファイルを作成します。
{% extends 'text_sns/input.html' %}
{% block sub_content %}
{% csrf_token %}
{% for item in form %}
<div>
{{ item.label }}:<br>
{{ item }}
</div>
{% endfor %}
<br>
<button class="w-100 btn btn-light" type="submit">サインアップ</button>
<hr>
<small class="d-flex justify-content-center">
<a href="{% url 'text_sns:login' %}">ログイン</a>
</small>
{% endblock %}
ログイン用のファイルを作成します。
{% extends 'text_sns/input.html' %}
{% block sub_content %}
{% csrf_token %}
{% for item in form %}
<div>
{{ item.label }}:<br>
{{ item }}
</div>
{% endfor %}
<br>
<button class="w-100 btn btn-light" type="submit">ログイン</button>
<hr>
<small class="d-flex justify-content-center">
<a href="{% url 'text_sns:signup' %}">サインアップ</a>
</small>
{% endblock %}
ユーザー削除用のファイルを作成します。
確認用のファイルを作成します。
{% extends 'text_sns/base.html' %}
{% block main_content %}
<div class="justify-content-center">
<p class="h3 p-4 text-center">みこと(Mikoto) SNS</p>
<hr>
<p class="text-center">本当にアカウントを削除してもよろしいでしょうか?</p>
<p class="text-center">
<a href="{% url 'text_sns:user_delete' %}">アカウント削除</a>
</p>
</div>
{% endblock %}
削除用のファイルを作成します。
{% extends 'text_sns/base.html' %}
{% block main_content %}
<div class="justify-content-center">
<p class="h3 p-4 text-center">みこと(Mikoto) SNS</p>
<hr>
<p class="text-center">アカウントを削除しました</p>
<p class="text-center">ご利用いただき、どうもありがとうございました</p>
</div>
{% endblock %}
インデックス画面にリンクをはります。
ログインしているかどうかで、表示を変えます。
{% extends 'text_sns/base.html' %}
{% block main_content %}
<div class="flex justify-content-center align-items-center">
<p class="text-center">みこと(Mikoto) SNS</p>
<p class="text-center">{% include 'snippets/message.html' %}</p>
{% if user.is_authenticated %}
<p>{{ user.username }}</p>
<p>
<a class="navbar-brand fs-6" href="{% url 'text_sns:index' %}">ホーム</a>
<a class="navbar-brand fs-6" href="{% url 'text_sns:logout' %}">ログアウト</a>
<a class="navbar-brand fs-6" href="{% url 'text_sns:delete_confirm' %}">アカウント削除</a>
</p>
{% else %}
<p class="text-center"><a href="{% url 'text_sns:signup' %}">サインアップ</a></p>
<p class="text-center"><a href="{% url 'text_sns:login' %}">ログイン</a></p>
{% endif %}
</div>
{% endblock %}
マイグレートします。
python manage.py migrate
サインアップ、ログアウト、ログイン、アカウント削除の動作確認します。
サインアップ
成功すると、ホーム(インデックス)画面に遷移します。
ログアウトすると、ログイン画面に遷移します。
再びログインして、アカウントを削除します。
確認ページに遷移します。
アカウント削除すると、削除完了ページに遷移します。
3. 記事投稿、一覧表示、削除
urls.py、models.py、forms.py、views.py、html の順でファイルを作っていきます。
一覧表示は、個人用( index )と全体用( all_contents )に分け、後に個人用にはつながった(フォローした)人の投稿も表示されるようにします。
また、記事は本人のみ削除できるようにします。
URL パスを指定します。
urlpatterns = [
path('', views.index, name='index'),
...
path('posts/', views.posts_func, name='posts'),
path('posts_delete/', views.posts_delete_func, name='posts_delete'),
path('all_contents', views.all_contents_func, name='all_contents'),
]
投稿用のコンテンツ・モデルを作成します。
from django.contrib.auth import get_user_model
from django.db import models
class Content(models.Model):
author = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
title = models.CharField('タイトル', max_length=64)
content = models.TextField('コンテンツ', max_length=256)
created_at = models.DateTimeField('作成日時', auto_now_add=True)
def __str__(self):
return self.title
投稿用のフォームを作り、表示を調整します。
入力者はユーザー本人に固定するため、TextInput にして、後に HTML で type="hidden" で本人を入力します。
...
class PostsForm(forms.ModelForm):
class Meta:
model = Content
exclude = ('good', 'tags',)
widgets = {
'author': forms.TextInput(),
'content': forms.Textarea(attrs={'rows': 6, 'cols': 40}),
}
投稿用、削除用のビューを作成します。
...
from .forms import SignupForm, LoginForm, PostsForm
from .models import Content
...
def index(request):
contents = Content.objects.filter(author=request.user.id).order_by('-created_at')
context = {
'contents': contents
}
return render(request, 'text_sns/index.html', context)
...
def posts_func(request):
if request.method == 'POST':
posts_form = PostsForm(request.POST)
if posts_form.is_valid():
posts_form.save()
return redirect('text_sns:index')
else:
posts_form = PostsForm()
return render(request, 'text_sns/posts.html', context={'form': posts_form})
def posts_delete_func(request, content_id):
content = Content.objects.get(pk=content_id)
content.delete()
messages.success(request, 'コンテンツを削除しました')
return redirect('text_sns:index')
def all_contents_func(request):
contents = Content.objects.all().order_by('-created_at')
context = {
'contents': contents
}
return render(request, 'text_sns/index.html', context)
投稿用の HTML ファイルを作成します。
{% extends 'text_sns/input.html' %}
{% block sub_content %}
{% csrf_token %}
<div>
<input type="hidden" name="author" value="{{ user.id }}">
{% for item in form %}
{% if item.label != 'Author' %}
{{ item.label }}:<br>
{{ item }}<br>
{% endif %}
{% endfor %}
</div>
<br>
<button class="w-100 btn btn-light" type="submit">投稿</button>
{% endblock %}
インデックス・ページに、一覧表示を追加します。
表示の調整は、ブートストラップと CSS、DTL( Django Template Language )のフィルタで行ないます。
DTL 組込みテンプレート・タグとフィルタ:https://docs.djangoproject.com/ja/5.2/ref/templates/builtins/
...
{% if user.is_authenticated %}
<p>{{ user.username }}</p>
<p>
<a class="navbar-brand fs-6" href="{% url 'text_sns:index' %}">ホーム</a>
<a class="navbar-brand fs-6" href="{% url 'text_sns:posts' %}">投稿</a>
...
</p>
{% for obj in page_obj %}
<p class="text-muted small m-1">
「{{ obj.title }}」: {{ obj.author }}
{% if user.id == obj.author.id %}
<span class="text-muted small m-1 " style="float: right;">
<a href="{% url 'text_sns:posts_delete' obj.id %}">削除</a>
</span>
{% endif %}
</p>
<p class="border border-2 p-1 rounded-3">
{{ obj.content | linebreaksbr | urlize }}
</p>
{% endfor %}
{% else %}
...
マイグレートします。
python manage.py makemigrations
python manage.py migrate
動作確認します。
記事投稿
一覧に表示されます。
数件投稿して、最初の記事を削除できるか試します。
無事に削除できました。
4. ページネーション
1ページの表示件数を設定する、ページネーションを実装します。
ページネーション: https://docs.djangoproject.com/ja/5.2/topics/pagination/
...
from django.core.paginator import Paginator
...
PAGENATOR_NUM = 3
...
def index(request):
objs = Content.objects.filter(author=request.user.id).order_by('-created_at')
paginator = Paginator(user_objects, PAGENATOR_NUM)
page_number = request.GET.get('page')
context = {
'page_obj': paginator.get_page(page_number),
'page_number': page_number,
'title': 'ホーム',
}
return render(request, 'text_sns/index.html', context)
インデックスに渡す context のキーを変更したので修正し、スニペットをはります。
...
{% for obj in page_obj %}
...
{% endfor %}
<span class="d-flex justify-content-center">
{% include 'snippets/pagination.html' %}
</span>
...
追加投稿して、動作確認します。
無事に動作している感じです。
5. 管理画面設定
管理画面を使えるようにしておきます。
python manage.py createsuperuser
管理画面でモデルを操作できるようにします。
from django.contrib import admin
from .models import Content
admin.site.register(Content)
管理画面にログインします。(http://localhost:8080/admin
)
6. 今回のプロジェクト
GitHub: https://github.com/Animalyzm/mikoto_project
今回のプロジェクトは、django/sns です。
(Git のコミット・メッセージは django_sns_first です)
データベースなどは削除してるので、使用するにはマイグレートが必要になります。
python manage.py makemigrations
python manage.py migrate
ぷはーっ、ながかったー。
前半は、ここまでにしよーと思います!
ありがとうございましたー♪
Discussion