🌏

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. 初期設定
  2. サインアップ・ログイン・ログアウト機能
  3. 記事投稿、一覧表示、削除
  4. ページネーション
  5. 管理画面設定
  6. 今回プロジェクト

次回やること(後半・予定)

  1. チェック、一覧表示、解除
  2. コメント投稿、削除
  3. つながる、一覧表示、またね
  4. いいね(非同期)
  5. 検索

1. 初期設定

プロジェクト、アプリを作成します。

コマンド・プロンプト
django-admin startproject sns
cd sns
python manage.py startapp text_sns

settings.py(初期コメントは削除します)

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 のパスを設定していきます。

sns/urls.py
...
from django.urls import path, include

urlpatterns = [
    ...
    path('text_sns/', include('text_sns.urls')),
]
text_sns/urls.py
from . import views

app_name = 'text_sns'

urlpatterns = [
    path('', views.index, name='index'),
]

ビューを作成します。

text_sns/views.py
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 で直接行ってます。

templates/text_sns/base.html
<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>

インデックス・ファイルを作ります。

templates/text_sns/index.html
{% extends 'text_sns/base.html' %}
{% block main_content %}
<div>
  <h1 class="text-center">みこと(Mikoto) SNS</h1>
</div>
{% endblock %}

CSS ファイルで、Web フォント適用、リンクと入力表示の設定を行います。

static/text_sns/css/style.css
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 の順でファイルを作っていきます。
前回の記事で詳細を書いているので、簡単にすすめていきます。
アカウント削除は、確認ページも作ります。

text_sns/urls.py
...
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'),
...

フォームを作成します。

text_sns/forms.py
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())

ビューを作成します。

text_sns/views.py
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 を作成します。

templates/text_sns/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 %}

メッセージ用のスニペット・ファイルを作成します。

tempaltes/snippets/message.html
{% if messages %}
  {% for message in messages %}
    {{ message }}
  {% endfor %}
{% endif %}

サインアップ用のファイルを作成します。

text_sns/signup.html
{% 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 %}

ログイン用のファイルを作成します。

text_sns/login.html
{% 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 %}

ユーザー削除用のファイルを作成します。
確認用のファイルを作成します。

text_sns/delete_confirm.html
{% 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 %}

削除用のファイルを作成します。

text_sns/user_delete.html
{% 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 %}

インデックス画面にリンクをはります。
ログインしているかどうかで、表示を変えます。

text_sns/index.html
{% 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 パスを指定します。

text_sns/urls.py
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'),
]

投稿用のコンテンツ・モデルを作成します。

text_sns/models.py
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" で本人を入力します。

text_sns/forms.py
...
class PostsForm(forms.ModelForm):
    class Meta:
        model = Content
        exclude = ('good', 'tags',)
        widgets = {
            'author': forms.TextInput(),
            'content': forms.Textarea(attrs={'rows': 6, 'cols': 40}),
        }

投稿用、削除用のビューを作成します。

text_sns/views.py
...
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 ファイルを作成します。

text_sns/posts.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/

text_sns/index.html
...
  {% 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/

text_sns/views.py
...
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 のキーを変更したので修正し、スニペットをはります。

text_sns/index.html
...
    {% for obj in page_obj %}
...
    {% endfor %}
    <span class="d-flex justify-content-center">
      {% include 'snippets/pagination.html' %}
    </span>
...

追加投稿して、動作確認します。
無事に動作している感じです。


5. 管理画面設定

管理画面を使えるようにしておきます。

コマンド・プロンプト
python manage.py createsuperuser

管理画面でモデルを操作できるようにします。

text_sns/admin.py
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