ユーザー管理システム
SQLiteでユーザー情報を管理するCRUDアプリ。ログイン認証・権限管理・パスワードハッシュ化を実装します。
1. アプリ概要
SQLiteでユーザー情報を管理するCRUDアプリ。ログイン認証・権限管理・パスワードハッシュ化を実装します。
パスワードは _hash_pw() で hashlib.pbkdf2_hmac()(PBKDF2-HMAC-SHA256)にかけ、os.urandom(16) で作ったランダムソルトと反復回数を添えて pbkdf2_sha256$反復回数$ソルト$ハッシュ という 1 カラムの文字列で保存します。照合は _verify_pw() が同じソルト・同じ反復回数で計算し直し、hmac.compare_digest() で比較します。ソルトがユーザーごとに違うので、同じパスワードでも保存される値は毎回変わり、事前計算した表(レインボーテーブル)での逆引きが効きません。
パラメータの根拠: ソルトは Python 公式ドキュメントの「os.urandom() のような適切なソースからの、およそ 16 バイトかそれ以上のバイト列」に合わせました。反復回数 600,000 回は OWASP Password Storage Cheat Sheet の「PBKDF2-HMAC-SHA256: 600,000 iterations (recommended)」を採用したものです(Python 公式は「2022 年時点で SHA-256 なら数十万回」と記載)。この設定では 1 回のハッシュ計算に約 0.25 秒かかりました(Python 3.12.10・Windows 11 実機での実測値・5 回平均 246 ミリ秒)。ログイン時に一瞬待つのは、総当たりを遅くするための意図した代償です。なお OWASP が第一に勧めるのは Argon2id や bcrypt で、PBKDF2 は「FIPS-140 準拠が必要なときに選ぶ」位置づけです。ここでは追加インストール無しで標準ライブラリだけで書けることを優先して PBKDF2 を選びました。出典: Python 公式ドキュメント hashlib.pbkdf2_hmac / OWASP Password Storage Cheat Sheet(いずれも 2026-09-09 確認)
初期管理者のパスワードはコードに書いていません。初回起動時に出るセットアップ画面で決める(8 文字以上)か、環境変数 APP18_ADMIN_PASSWORD を設定して起動してください。環境変数で渡す方法は、コマンド履歴に残る・同じ PC の他のプロセスから読める、という弱点があります。共有 PC では初回画面で設定し、環境変数を使った場合は管理者作成後に削除してください。以前のソルト無し版で作った users.db が残っている場合、そのアカウントはログインできず「このアカウントは旧形式(ソルト無し)で保存されています。別の管理者アカウントでログインしてパスワードを再設定するか、users.dbを削除して初回セットアップをやり直してください(削除すると全ユーザーが消えます)」と表示されます。学習用のため移行処理は入れていないので、users.db を削除してから初回セットアップをやり直してください(削除すると登録済みのユーザー情報はすべて消えます。残したい内容は先に控えてください。admin アカウント自身が旧形式のときはログインできず、この削除が唯一の復旧手段です)。
ハッシュの作り方は標準的な手法に沿っていますが、このコードをそのまま本番のユーザー認証に使わないでください。ログイン試行回数の制限・パスワードの強度チェック・通信路の暗号化・DB ファイルの権限管理・監査ログは実装しておらず、認証システム全体として実運用の基準を満たすことを確認したコードではありません。CRUD と画面連携の学習に焦点を当てたコードです。
このアプリは中級カテゴリに分類される実践的なGUIアプリです。使用ライブラリは tkinter(標準ライブラリ) で、難易度は ★★★ です。
このアプリは「データベース連携」カテゴリです。データベース連携 GUI は永続化と画面更新を結びつける典型例で、トランザクション・スキーマ設計・UI 反映の三点を一度に学べます。tkinter(標準ライブラリ) を活かして実装するこの構造は、他のアプリにも応用が効きます。
完成形を見てから細部の解説に進む流れが効果的です。実行→気になる箇所を解説で確認→自分の手で改造、というサイクルで定着が早まります。
カスタマイズ章では具体的な拡張アイデアを示しています。一つ実装するごとに動作確認することで、変更が予期せぬ副作用を起こさないかも体感できます。
2. 機能一覧
- 「管理者を作成」ボタン(
_do_setup())・「ログイン」ボタン(_do_login())・「➕ 新規作成」ボタン(_add_user())・「✏️ 更新」ボタン(_update_user())・「🗑️ 削除」ボタン(_delete_user())・「🔒 ログアウト」ボタン(_logout())で操作 - キー
<Return>から_do_login()を実行 - イベント
<<ComboboxSelected>>から_load_users()を実行 - イベント
<<TreeviewSelect>>から_on_select()を実行 ttk.Treeviewによる表形式の一覧表示sqlite3.connect()による SQLite データベース接続と SQL 実行messagebox.askyesno()・messagebox.showerror()・messagebox.showwarning()によるダイアログ通知- ウィンドウはタイトル「ユーザー管理システム」・サイズ 860x560 で起動
3. 事前準備・環境
Python 3.10 以上 / Windows 11(実機)で起動・基本操作を確認 / Linux Mint 22.3(仮想マシン)で起動・画面表示を確認(macOS は未検証)
Windows 11(実機)で起動と基本操作を確認しています(全機能の網羅テストではありません)。Linux Mint 22.3(仮想マシン)では起動と画面表示を確認しました。macOS は標準ライブラリの範囲で動作する想定ですが、未検証です。
- Python 3.10 以上
- OS: Windows 11(実機で起動・基本操作を確認)・Linux Mint 22.3(仮想マシンで起動・画面表示を確認)
4. 完全なソースコード
右上の「コピー」ボタンをクリックするとコードをクリップボードにコピーできます。
import tkinter as tk
from tkinter import ttk, messagebox
import sqlite3
import hashlib
import hmac
import base64
import os
from datetime import datetime
class App18:
"""ユーザー管理システム"""
DB_PATH = os.path.join(os.path.dirname(__file__), "users.db")
ROLES = ["admin", "manager", "user", "guest"]
# パスワードは PBKDF2-HMAC-SHA256 で保存する。
# 保存形式は "pbkdf2_sha256$反復回数$ソルト$ハッシュ" の 1 カラム。
HASH_PREFIX = "pbkdf2_sha256"
HASH_NAME = "sha256"
ITERATIONS = 600000
SALT_BYTES = 16
MIN_PW_LEN = 8
ADMIN_PW_ENV = "APP18_ADMIN_PASSWORD"
def __init__(self, root):
self.root = root
self.root.title("ユーザー管理システム")
self.root.geometry("860x560")
self.root.configure(bg="#f8f9fc")
self.current_user = None
self._init_db()
self._build_ui()
if self._admin_exists():
self._show_login()
else:
self._show_setup()
def _init_db(self):
self.conn = sqlite3.connect(self.DB_PATH)
self.conn.execute("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
email TEXT,
role TEXT DEFAULT 'user',
active INTEGER DEFAULT 1,
created_at TEXT,
last_login TEXT,
notes TEXT
)
""")
self.conn.commit()
# 環境変数でパスワードを渡されていれば管理者を自動作成する
env_pw = os.environ.get(self.ADMIN_PW_ENV, "")
if env_pw and not self._admin_exists():
self._create_admin(env_pw)
def _admin_exists(self):
return self.conn.execute(
"SELECT 1 FROM users WHERE role='admin'").fetchone() is not None
def _create_admin(self, pw):
self.conn.execute(
"INSERT INTO users (username, password_hash, email, role, created_at) "
"VALUES (?, ?, ?, 'admin', ?)",
("admin", self._hash_pw(pw),
"admin@example.com",
datetime.now().isoformat()))
self.conn.commit()
@classmethod
def _hash_pw(cls, pw):
"""パスワードをランダムソルト付きの保存用文字列に変換する"""
salt = os.urandom(cls.SALT_BYTES)
dk = hashlib.pbkdf2_hmac(cls.HASH_NAME, pw.encode("utf-8"),
salt, cls.ITERATIONS)
return "{}${}${}${}".format(
cls.HASH_PREFIX, cls.ITERATIONS,
base64.b64encode(salt).decode("ascii"),
base64.b64encode(dk).decode("ascii"))
@classmethod
def _verify_pw(cls, pw, stored):
"""入力パスワードと保存済み文字列を照合する(一致で True)"""
parts = str(stored or "").split("$")
if len(parts) != 4 or parts[0] != cls.HASH_PREFIX:
return False
if not parts[1].isdigit():
return False
try:
salt = base64.b64decode(parts[2])
expected = base64.b64decode(parts[3])
except ValueError:
return False
dk = hashlib.pbkdf2_hmac(cls.HASH_NAME, pw.encode("utf-8"),
salt, int(parts[1]))
return hmac.compare_digest(dk, expected)
@classmethod
def _is_legacy_hash(cls, stored):
"""旧形式(ソルト無し SHA-256)で保存された値かどうか"""
return bool(stored) and not str(stored).startswith(
cls.HASH_PREFIX + "$")
def _build_ui(self):
title_frame = tk.Frame(self.root, bg="#1565c0", pady=10)
title_frame.pack(fill=tk.X)
tk.Label(title_frame, text="👥 ユーザー管理システム",
font=("Noto Sans JP", 15, "bold"),
bg="#1565c0", fg="white").pack(side=tk.LEFT, padx=12)
self.login_info_label = tk.Label(title_frame, text="",
bg="#1565c0", fg="#ccc",
font=("Arial", 10))
self.login_info_label.pack(side=tk.RIGHT, padx=12)
self.setup_frame = tk.Frame(self.root, bg="#f8f9fc")
self.login_frame = tk.Frame(self.root, bg="#f8f9fc")
self.main_frame = tk.Frame(self.root, bg="#f8f9fc")
self.status_var = tk.StringVar(value="")
tk.Label(self.root, textvariable=self.status_var,
bg="#dde", font=("Arial", 9), anchor="w", padx=8
).pack(fill=tk.X, side=tk.BOTTOM)
def _hide_frames(self):
for frame in (self.setup_frame, self.login_frame, self.main_frame):
frame.pack_forget()
def _show_setup(self):
self._hide_frames()
self.setup_frame.pack(fill=tk.BOTH, expand=True)
for w in self.setup_frame.winfo_children():
w.destroy()
c = tk.Frame(self.setup_frame, bg="#f8f9fc")
c.place(relx=0.5, rely=0.4, anchor="center")
tk.Label(c, text="🛠️ 初回セットアップ",
font=("Noto Sans JP", 14, "bold"),
bg="#f8f9fc").pack(pady=10)
tk.Label(c, text="管理者アカウント admin のパスワードを決めてください",
bg="#f8f9fc", fg="#555").pack()
form = tk.Frame(c, bg="#f8f9fc")
form.pack(pady=8)
tk.Label(form, text="パスワード:",
bg="#f8f9fc").grid(row=0, column=0, sticky="w", pady=4)
self.setup_pw_var = tk.StringVar()
ttk.Entry(form, textvariable=self.setup_pw_var,
show="●", width=20).grid(row=0, column=1, padx=8)
tk.Label(form, text="確認のため再入力:",
bg="#f8f9fc").grid(row=1, column=0, sticky="w", pady=4)
self.setup_pw2_var = tk.StringVar()
ttk.Entry(form, textvariable=self.setup_pw2_var,
show="●", width=20).grid(row=1, column=1, padx=8)
ttk.Button(c, text="管理者を作成",
command=self._do_setup).pack(pady=12)
tk.Label(c, text=f"{self.MIN_PW_LEN}文字以上。環境変数 "
f"{self.ADMIN_PW_ENV} でも設定できます",
bg="#f8f9fc", fg="#888", font=("Arial", 9)).pack()
def _do_setup(self):
pw = self.setup_pw_var.get()
if len(pw) < self.MIN_PW_LEN:
messagebox.showwarning(
"警告", f"パスワードは{self.MIN_PW_LEN}文字以上にしてください")
return
if pw != self.setup_pw2_var.get():
messagebox.showwarning("警告", "確認用のパスワードが一致しません")
return
self._create_admin(pw)
self.status_var.set("管理者 admin を作成しました")
self._show_login()
def _show_login(self):
self._hide_frames()
self.login_frame.pack(fill=tk.BOTH, expand=True)
for w in self.login_frame.winfo_children():
w.destroy()
c = tk.Frame(self.login_frame, bg="#f8f9fc")
c.place(relx=0.5, rely=0.4, anchor="center")
tk.Label(c, text="🔐 ログイン",
font=("Noto Sans JP", 14, "bold"),
bg="#f8f9fc").pack(pady=10)
form = tk.Frame(c, bg="#f8f9fc")
form.pack()
tk.Label(form, text="ユーザー名:",
bg="#f8f9fc").grid(row=0, column=0, sticky="w", pady=4)
self.login_user_var = tk.StringVar(value="admin")
ttk.Entry(form, textvariable=self.login_user_var,
width=20).grid(row=0, column=1, padx=8)
tk.Label(form, text="パスワード:",
bg="#f8f9fc").grid(row=1, column=0, sticky="w", pady=4)
self.login_pw_var = tk.StringVar()
self.login_pw_entry = ttk.Entry(form, textvariable=self.login_pw_var,
show="●", width=20)
self.login_pw_entry.grid(row=1, column=1, padx=8)
ttk.Button(c, text="ログイン",
command=self._do_login).pack(pady=12)
tk.Label(c, text="初回セットアップで決めた admin のパスワードを入力",
bg="#f8f9fc", fg="#888", font=("Arial", 9)).pack()
self.login_pw_entry.bind("<Return>", lambda e: self._do_login())
def _do_login(self):
username = self.login_user_var.get().strip()
pw = self.login_pw_var.get()
row = self.conn.execute(
"SELECT id, role, active, password_hash FROM users "
"WHERE username=?",
(username,)).fetchone()
if row and self._is_legacy_hash(row[3]):
messagebox.showerror(
"エラー",
"このアカウントは旧形式(ソルト無し)で保存されています。"
"別の管理者アカウントでログインしてパスワードを"
"再設定するか、users.dbを削除して初回セットアップを"
"やり直してください(削除すると全ユーザーが消えます)")
return
if not row or not self._verify_pw(pw, row[3]):
messagebox.showerror("エラー", "ユーザー名またはパスワードが正しくありません")
return
if not row[2]:
messagebox.showerror("エラー", "このアカウントは無効です")
return
uid, role = row[0], row[1]
self.current_user = {"id": uid, "username": username, "role": role}
self.conn.execute(
"UPDATE users SET last_login=? WHERE id=?",
(datetime.now().isoformat(), uid))
self.conn.commit()
self.login_info_label.config(
text=f"ログイン中: {username} ({role})")
self._show_main()
def _show_main(self):
self._hide_frames()
self.main_frame.pack(fill=tk.BOTH, expand=True)
for w in self.main_frame.winfo_children():
w.destroy()
paned = ttk.PanedWindow(self.main_frame, orient=tk.HORIZONTAL)
paned.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)
# 左: ユーザーリスト
left = tk.Frame(paned, bg="#f8f9fc")
search_f = tk.Frame(left, bg="#f8f9fc")
search_f.pack(fill=tk.X, pady=4)
tk.Label(search_f, text="🔍", bg="#f8f9fc").pack(side=tk.LEFT, padx=4)
self.search_var = tk.StringVar()
search_entry = ttk.Entry(search_f, textvariable=self.search_var, width=20)
search_entry.pack(side=tk.LEFT, padx=4, fill=tk.X, expand=True)
self.search_var.trace_add("write", lambda *a: self._load_users())
self.role_filter_var = tk.StringVar(value="すべて")
role_cb = ttk.Combobox(search_f, textvariable=self.role_filter_var,
values=["すべて"] + self.ROLES,
state="readonly", width=9)
role_cb.pack(side=tk.LEFT, padx=4)
role_cb.bind("<<ComboboxSelected>>", lambda e: self._load_users())
cols = ("id", "username", "email", "role", "active", "last_login")
self.tree = ttk.Treeview(left, columns=cols, show="headings",
selectmode="browse")
for c, h, w in [("id", "ID", 35), ("username", "ユーザー名", 120),
("email", "メール", 160), ("role", "権限", 70),
("active", "有効", 45), ("last_login", "最終ログイン", 130)]:
self.tree.heading(c, text=h)
self.tree.column(c, width=w, minwidth=30)
sb = ttk.Scrollbar(left, command=self.tree.yview)
self.tree.configure(yscrollcommand=sb.set)
sb.pack(side=tk.RIGHT, fill=tk.Y)
self.tree.pack(fill=tk.BOTH, expand=True)
self.tree.bind("<<TreeviewSelect>>", self._on_select)
self.tree.tag_configure("admin", foreground="#e74c3c")
self.tree.tag_configure("inactive", foreground="#aaa")
paned.add(left, weight=2)
# 右: 詳細・編集
right = ttk.LabelFrame(paned, text="ユーザー編集", padding=12)
fields = [("ユーザー名", "uname"), ("メール", "email_edit"),
("パスワード(変更時のみ)", "newpw"), ("メモ", "notes_edit")]
for lbl, attr in fields:
tk.Label(right, text=f"{lbl}:").pack(anchor="w")
if lbl == "メモ":
var = tk.Text(right, height=3, width=26, font=("Arial", 10))
var.pack(fill=tk.X, pady=2)
setattr(self, f"{attr}_text", var)
else:
var = tk.StringVar()
entry = ttk.Entry(right, textvariable=var, width=26,
show="●" if "パスワード" in lbl else "")
entry.pack(fill=tk.X, pady=2)
setattr(self, f"{attr}_var", var)
tk.Label(right, text="権限:").pack(anchor="w")
self.role_edit_var = tk.StringVar(value="user")
role_edit_cb = ttk.Combobox(right, textvariable=self.role_edit_var,
values=self.ROLES, state="readonly", width=14)
role_edit_cb.pack(anchor="w", pady=2)
self.active_edit_var = tk.BooleanVar(value=True)
ttk.Checkbutton(right, text="アカウント有効",
variable=self.active_edit_var).pack(anchor="w", pady=4)
is_admin = self.current_user["role"] == "admin"
btn_f = tk.Frame(right, bg=ttk.Style().lookup("TLabelframe", "background"))
btn_f.pack(fill=tk.X, pady=6)
if is_admin:
ttk.Button(btn_f, text="➕ 新規作成",
command=self._add_user).pack(side=tk.LEFT, padx=2)
ttk.Button(btn_f, text="✏️ 更新",
command=self._update_user).pack(side=tk.LEFT, padx=2)
ttk.Button(btn_f, text="🗑️ 削除",
command=self._delete_user).pack(side=tk.LEFT, padx=2)
ttk.Button(btn_f, text="🔒 ログアウト",
command=self._logout).pack(side=tk.LEFT, padx=2)
paned.add(right, weight=1)
self._load_users()
self._selected_id = None
def _load_users(self):
q = self.search_var.get().lower()
role_f = self.role_filter_var.get()
sql = ("SELECT id, username, email, role, active, last_login "
"FROM users WHERE 1=1")
params = []
if q:
sql += " AND (LOWER(username) LIKE ? OR LOWER(email) LIKE ?)"
params += [f"%{q}%", f"%{q}%"]
if role_f != "すべて":
sql += " AND role=?"
params.append(role_f)
sql += " ORDER BY id"
rows = self.conn.execute(sql, params).fetchall()
self.tree.delete(*self.tree.get_children())
for row in rows:
uid, uname, email, role, active, last_login = row
tag = "admin" if role == "admin" else ("inactive" if not active else "")
ll = last_login[:16] if last_login else ""
self.tree.insert("", "end", iid=str(uid),
values=(uid, uname, email or "", role,
"✅" if active else "❌", ll),
tags=(tag,))
self.status_var.set(f"{len(rows)} 件")
def _on_select(self, event):
sel = self.tree.selection()
if not sel:
return
uid = int(sel[0])
self._selected_id = uid
row = self.conn.execute(
"SELECT username, email, role, active, notes FROM users WHERE id=?",
(uid,)).fetchone()
if row:
uname, email, role, active, notes = row
self.uname_var.set(uname)
self.email_edit_var.set(email or "")
self.newpw_var.set("")
self.role_edit_var.set(role)
self.active_edit_var.set(bool(active))
self.notes_edit_text.delete("1.0", tk.END)
self.notes_edit_text.insert("1.0", notes or "")
def _add_user(self):
uname = self.uname_var.get().strip()
if not uname:
messagebox.showwarning("警告", "ユーザー名を入力してください")
return
pw = self.newpw_var.get()
if len(pw) < self.MIN_PW_LEN:
messagebox.showwarning(
"警告", f"パスワードは{self.MIN_PW_LEN}文字以上にしてください")
return
try:
self.conn.execute(
"INSERT INTO users (username, password_hash, email, role, "
"active, created_at, notes) VALUES (?,?,?,?,?,?,?)",
(uname, self._hash_pw(pw), self.email_edit_var.get(),
self.role_edit_var.get(),
1 if self.active_edit_var.get() else 0,
datetime.now().isoformat(),
self.notes_edit_text.get("1.0", tk.END).strip()))
self.conn.commit()
self._load_users()
self.status_var.set(f"ユーザー作成: {uname}")
except sqlite3.IntegrityError:
messagebox.showerror("エラー", "このユーザー名は既に使用されています")
def _update_user(self):
if not self._selected_id:
return
uname = self.uname_var.get().strip()
pw = self.newpw_var.get()
if pw and len(pw) < self.MIN_PW_LEN:
messagebox.showwarning(
"警告", f"パスワードは{self.MIN_PW_LEN}文字以上にしてください")
return
params = [uname, self.email_edit_var.get(),
self.role_edit_var.get(),
1 if self.active_edit_var.get() else 0,
self.notes_edit_text.get("1.0", tk.END).strip()]
sql = ("UPDATE users SET username=?,email=?,role=?,active=?,notes=?"
+ (",password_hash=?" if pw else "") + " WHERE id=?")
if pw:
params.append(self._hash_pw(pw))
params.append(self._selected_id)
self.conn.execute(sql, params)
self.conn.commit()
self._load_users()
self.status_var.set("更新完了")
def _delete_user(self):
if not self._selected_id:
return
if self._selected_id == self.current_user["id"]:
messagebox.showwarning("警告", "自分自身は削除できません")
return
if messagebox.askyesno("確認", "このユーザーを削除しますか?"):
self.conn.execute("DELETE FROM users WHERE id=?",
(self._selected_id,))
self.conn.commit()
self._selected_id = None
self._load_users()
def _logout(self):
self.current_user = None
self.login_info_label.config(text="")
self._show_login()
if __name__ == "__main__":
root = tk.Tk()
app = App18(root)
root.mainloop()
5. コード解説
ユーザー管理システムのコードを、実際に書かれている実装に沿って解説します。
クラス設計とコンストラクタ
App18 クラスにアプリの全機能をまとめています(メソッド20個・全443行)。__init__ ではタイトル「ユーザー管理システム」とウィンドウサイズ 860x560 を設定します。あわせて状態を保持する変数(self.current_user)を初期化し、そのあと _init_db() → _build_ui() の順に呼び出して初期状態を作ります。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
ウィジェット構成
画面は tk.Frame×11・tk.Label×14(ほかにループでも生成)・ttk.Entry×5(ほかにループでも生成)・ttk.Button×6・ttk.PanedWindow・ttk.Combobox×2・ttk.Treeview・ttk.Scrollbar・ttk.LabelFrame・tk.Text(ループで生成)・ttk.Checkbutton・ttk.Style で構成しています。見出し付きの枠(LabelFrame)は「ユーザー編集」のラベルでエリアを区切っています。PanedWindow を使っているので、ペインの境界をマウスでドラッグして幅を変えられます。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
イベント処理とボタンの接続
「管理者を作成」ボタン(_do_setup())・「ログイン」ボタン(_do_login())・「➕ 新規作成」ボタン(_add_user())・「✏️ 更新」ボタン(_update_user())・「🗑️ 削除」ボタン(_delete_user())・「🔒 ログアウト」ボタン(_logout())を command= で接続しています。また bind("<Return>", ...) から _do_login()、bind("<<ComboboxSelected>>", ...) から _load_users()、bind("<<TreeviewSelect>>", ...) から _on_select() を呼べるようにイベントも登録しています。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
SQLite データベースの接続と実行
sqlite3.connect() でデータベースファイルに接続し、SQL の実行は execute()、確定は commit() で行います。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
クラス定数によるデータ定義
クラスの先頭でデータを定数として定義しています:DB_PATH = os.path.join(os.path.dirname(__file__), 'users.db')、ROLES = ['admin', 'manager', 'user', 'guest']。ロジックの中に直接書かず定数にまとめておくと、内容を変えたいときに1か所を書き換えるだけで済みます。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
例外処理
try-except で ValueError・sqlite3.IntegrityError を捕捉しています。あわせて messagebox.askyesno()・messagebox.showerror()・messagebox.showwarning() のダイアログで、ユーザーへの通知・確認を行います。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
6. ステップバイステップガイド
このアプリをゼロから自分で作る手順を解説します。コードをコピーするだけでなく、実際に手順を追って自分で書いてみましょう。
-
1ファイルを作成する
新しいファイルを作成して app18.py と保存します。使うのは
base64・datetime・hashlib・hmac・os・sqlite3・tkinterだけなので、追加インストールは不要です。 -
2クラスの骨格を作る
App18クラスを定義し、__init__と、末尾のroot = tk.Tk()~mainloop()の最小構成を書いて、まず空のウィンドウが出ることを確認します。 -
3ウィンドウを設定する
title("ユーザー管理システム")・geometry("860x560")・configure(bg="#f8f9fc")をコンストラクタで設定します。 -
4画面部品を並べる
_build_ui()の中で、tk.Frame×11・tk.Label×14(ほかにループでも生成)・ttk.Entry×5(ほかにループでも生成)・ttk.Button×6・ttk.PanedWindow・ttk.Combobox×2・ttk.Treeview・ttk.Scrollbar・ttk.LabelFrame・tk.Text(ループで生成)・ttk.Checkbutton・ttk.Styleを作って配置します(掲載コードと同じ並び順で書くとレイアウトが一致します)。まず表示だけ確認しましょう。 -
5イベントを接続する
「2. 機能一覧」で挙げた各ボタンを
command=で対応するメソッド(_do_setup()・_do_login()・_add_user()・_update_user()・_delete_user()・_logout())につなぎます。bind("<Return>", ...)・bind("<<ComboboxSelected>>", ...)・bind("<<TreeviewSelect>>", ...)の登録も忘れずに。 -
6中心になるメソッドを実装する
アプリの本体である
_do_login()(30行)・_load_users()(24行)・_add_user()(24行)など を実装します。SQL の実行結果はexecute()の戻り値から取り出します。 -
7動作確認する
python app18.pyで起動し、各ボタンが反応すること・データベースファイルを開いて内容が表示されること・キー操作(<Return>)が効くことを確認します。
7. カスタマイズアイデア
基本機能を習得したら、以下のカスタマイズに挑戦してみましょう。少しずつ機能を追加することで、Pythonのスキルが飛躍的に向上します。
💡 ダークモードを追加する
bg色・fg色を辞書で管理し、ボタン1つでダークモード・ライトモードを切り替えられるようにしましょう。
💡 クラス定数を書き換えて動きを変える
データはクラス定数(DB_PATH・ROLES)にまとまっています。中身を書き換えて動きが変わることを確かめましょう。複数の定数が対応関係にある場合は、セットで整合させる必要がある点に注意してください。
💡 配色をまとめて管理する
背景色 #f8f9fc をはじめ、色コードが各所に直書きされています。色を1つの辞書にまとめると、テーマの切り替えが1か所の変更で済むようになります。
8. よくある問題と解決法
❌ 文字のフォントが崩れる・見た目が違う
原因:このコードは font=("Noto Sans JP", ...) のようにフォント名を直接指定しています。環境にこのフォントが入っていないと、OS が代わりのフォントで表示するため、見本と見た目が変わることがあります。
解決法:動作には支障ありません。気になる場合は font 引数の名前を自分の OS に入っているフォントへ書き換えるか、font 引数を省略してください。
❌ 「database is locked」というエラーが出る
原因:同じデータベースファイルを別のツール(DB Browser など)で開いたまま書き込むと、SQLite がファイルをロックしてこのエラーになります。
解決法:他のツールでの接続を閉じてから実行してください。書き込みは commit() で確定するまで反映されない点にも注意しましょう。
❌ <Return> キーを押しても反応しない
原因:bind("<Return>", ...) はウィジェット単位で登録されるため、ウィンドウ(またはバインド先)にキーボードフォーカスが無いとイベントが届きません。
解決法:一度ウィンドウ内をクリックしてフォーカスを与えてから、キーを押してください。
❌ ウィンドウの大きさが画面に合わない
原因:geometry("860x560") の固定サイズで起動するためです。
解決法:この数値を書き換えて起動サイズを調整してください。なお、このコードにはウィンドウサイズを固定する設定(resizable の指定)が無いため、ウィンドウの端をドラッグしたサイズ変更は既定どおり可能です。
9. 練習問題
アプリの理解を深めるための練習問題です。気になるものから挑戦してみてください(課題3は定型の発展課題です)。このアプリは仕組みを学ぶための学習用です。実際のユーザー認証には使わないでください(1章の注意も参照)。ユーザー情報は同じフォルダの users.db に保存されます(14行)。初回セットアップで決める管理者のパスワードに、ふだん使っている本物のパスワードを入れないでください。練習は testuser1 のような仮のアカウントと、その場で決めた8文字以上の文字列で行ってください。「🗑️ 削除」で消したユーザーをアプリから戻す方法はありません。パスワードの保存は PBKDF2-HMAC-SHA256 で組んであり(16-23行・_hash_pw()・_verify_pw())、下の2問はこの部分を変えずに解けます。
-
課題1:「✏️ 更新」ではユーザー名を空にでき、重複したときは黙って失敗する
_add_user()には、ユーザー名の空チェック(374-377行)と重複の受け止め(395-396行)があります。ところが_update_user()には、そのどちらもありません(398-419行)。usernameにはUNIQUEが付いているので(43行)、重複した瞬間に例外が飛びます。期待結果:管理者でログインし、「➕ 新規作成」で
testuser1とtestuser2を作る。一覧でtestuser2を選び、ユーザー名の欄をtestuser1に書き換えて「✏️ 更新」を押す。画面にはメッセージが出ず、下のステータスの更新完了も出ない。コマンドプロンプトから起動していれば、そこにsqlite3.IntegrityError: UNIQUE constraint failed: users.usernameが出る。ユーザー名を空にして更新した場合は、そのまま成功して一覧の名前が空欄になる。直したあとは、どちらも警告が出て一覧は変わらない。合格条件
- ユーザー名が空のまま「✏️ 更新」を押すと警告が出て、一覧の名前は変わらない
- ほかのユーザーと同じ名前にして更新すると、画面にメッセージが出る
- 重複していないときの更新は、いままでどおりステータスに
更新完了と出る
ヒント①(どこを触るか): 触るのは2か所です。
_update_user()の先頭(399-402行)と、更新を実行する部分(416-419行)。
ヒント②(使うもの): 空チェックの書き方は_add_user()の 374-377行、sqlite3.IntegrityErrorの受け止め方は 383-396行がそのまま手本になります。try:で囲む範囲は 416-419行の4行です。
つまずきやすい点: パスワード欄が空のときはpassword_hashを更新しない作りになっています(411-414行)。ここは保存の仕組みそのものなので、今回は触らないでください。Tkinter はボタンの処理で起きた例外を画面に出さないため、コマンドプロンプトから起動していないと失敗に気づけません。 -
課題2:検索欄に
_や%を入れると、関係ないユーザーまで一覧に出る検索の条件は、入力された文字を
LIKEの中へそのまま挟んで作っています(335-337行)。LIKEでは_が「任意の1文字」、%が「任意の文字列」という意味を持ちます。入力をそのまま渡すと、この2つが検索の記号として働いてしまいます。期待結果:「➕ 新規作成」で
test_aとtestXaの2人を作る。検索欄にtest_aと入れると、testXaまで一覧に出て下のステータスが2 件になる。検索欄に%だけを入れると、登録されている全員が並ぶ。直したあとは、test_aで1件だけになり、%では0件になる。合格条件
test_aで検索したとき、testXaは一覧に出ない%や_を入れたときは、その文字を実際に含むユーザーだけが出る- ふつうの文字だけで検索したときの結果は、いままでどおり
ヒント①(どこを触るか): 触るのは1か所です。
_load_users()の検索条件を組み立てる部分(335-337行)。
ヒント②(使うもの): SQLite のLIKEにはESCAPEを付けられます。LIKE ? ESCAPE '!'のように印を決めると、!%は記号そのものの%という意味になります。検索語の側も、印にした文字・%・_の順にstr.replace()で置き換えてから 337行へ渡してください。
つまずきやすい点: 337行は同じ文字列を2回渡していて、これはユーザー名とメールの2条件があるためです(336行)。片方だけ直すと、メール側で記号が効いたままになります。置き換えの順番も大切で、印にした文字を先に処理しないと二重に置き換わります。 -
課題3:新しいボタンを追加する
既存のボタンと同じ書き方で新しいボタンを1つ足し、押されたときに動くメソッドを自作してみましょう。ボタンの作成と
command=の接続がセットで身に付きます。
写経中に赤いエラー文が出たら、Pythonエラー一覧&解決法(英語メッセージ逆引き)で原因と直し方をすぐ確認できます。
このレベルのアプリが作れたら、入門書の次の「作るための本」へ進む時期です。実践におすすめのPython本(当サイトの参考書ランキング総合3〜4位)で自動化とコードの書き方の2冊を、その直後の用途別専門書の節でデータ分析・Web・AIの本を比較しています。