はじめに
前回は、Flaskのログイン機能(パスワードハッシュ化・セッション管理・認証デコレータ)を実装しました。
前回の記事:Python × Dify × RAGで学ぶ業務システム開発入門【第4回 Flaskログイン機能編】
第5回では、業務システムの中核となる社員管理のCRUDを実装します。
本連載で使用するソースコードは、以下のGitHubリポジトリで公開しています。
CRUDとは、データ操作の基本4操作のことです。
C:Create → 社員登録
R:Read → 社員一覧・検索・編集対象の取得
U:Update → 社員情報更新
D:Delete → 社員削除(論理削除)
CRUDが実装できれば、研修管理・評価管理など他のあらゆる機能も同じパターンで作れます。
本回でそのパターンをしっかり習得してください。
本連載は「作って終わり」ではなく、なぜそう設計するのかを重視しています。
設計の意図を理解することで、他のシステムにも応用できる力が身につきます。
本記事で学ぶこと
| 項目 | 内容 |
|---|---|
| サービス層の実装 | DBアクセスをサービスモジュールに分離する |
| 一覧・検索 | SQLの WHERE・LIKE を使った絞り込み |
| 登録・更新 | フォームデータの受け取りとDB保存 |
| 論理削除 | レコードを消さずに無効化する設計 |
| 入力制御 | HTMLのrequired属性とDB制約で必須項目・重複を制御する |
CRUDの実装パターン
本システムでは、すべてのCRUD操作を以下の3層に分けて実装します。
app.py(ルーティング層)
↕ フォームデータの受け渡し
services/employee_service.py(サービス層)
↕ SQL実行・データ変換
database.db(データ層)
| 層 | ファイル | 役割 |
|---|---|---|
| ルーティング層 | app.py |
URLとHTTPメソッドの定義、テンプレートへのデータ渡し |
| サービス層 | services/employee_service.py |
DB操作と取得結果のデータ変換 |
| データ層 | database.db |
データの永続化 |
この分離により、「画面の変更」と「業務ロジックの変更」を独立して行えます。
実装:employee_service.py
サービス層に社員CRUDのDB操作をまとめます。
# services/employee_service.py
from database import get_db
def _row_to_dict(row):
return dict(row) if row else None
def get_all_employees(keyword: str = "") -> list:
conn = get_db()
if keyword:
like = f"%{keyword}%"
rows = conn.execute(
"""
SELECT * FROM employee
WHERE is_active = 1
AND (name LIKE ? OR department LIKE ? OR employee_no LIKE ?)
ORDER BY employee_no
""",
(like, like, like),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM employee WHERE is_active = 1 ORDER BY employee_no"
).fetchall()
conn.close()
return [dict(row) for row in rows]
def get_employee_by_id(employee_id: int) -> dict | None:
conn = get_db()
row = conn.execute(
"SELECT * FROM employee WHERE id = ?", (employee_id,)
).fetchone()
conn.close()
return _row_to_dict(row)
def create_employee(data: dict) -> int:
conn = get_db()
cur = conn.execute(
"""
INSERT INTO employee
(employee_no, name, department, position, hire_date)
VALUES (?, ?, ?, ?, ?)
""",
(
data.get("employee_no"),
data.get("name"),
data.get("department"),
data.get("position"),
data.get("hire_date"),
),
)
conn.commit()
employee_id = cur.lastrowid
conn.close()
return employee_id
def update_employee(employee_id: int, data: dict) -> bool:
conn = get_db()
cur = conn.execute(
"""
UPDATE employee
SET employee_no = ?, name = ?, department = ?, position = ?, hire_date = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""",
(
data.get("employee_no"),
data.get("name"),
data.get("department"),
data.get("position"),
data.get("hire_date"),
employee_id,
),
)
conn.commit()
ok = cur.rowcount > 0
conn.close()
return ok
def delete_employee(employee_id: int) -> bool:
conn = get_db()
cur = conn.execute(
"UPDATE employee SET is_active = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(employee_id,),
)
conn.commit()
ok = cur.rowcount > 0
conn.close()
return ok
検索対象は、社員番号・氏名・部署です。役職は一覧へ表示しますが、現在の検索条件には含まれていません。
論理削除とは
削除には「物理削除」と「論理削除」の2種類があります。
物理削除(DELETE文)
→ レコードをDBから完全に消す
→ 問題:研修履歴・評価データの employee_id が
参照先を失い、データの整合性が壊れる
論理削除(is_active フラグ)
→ is_active = 0 に更新するだけでレコードは残す
→ 利点①:退職後も研修履歴・評価は参照できる
→ 利点②:誤って削除した場合に復元できる
一覧表示では WHERE is_active = 1 を付けるだけで退職者を除外できます。
現在のテーブルには退職日専用のカラムがないため、「いつ退職したか」を厳密に管理する場合はretired_atなどのカラム追加が必要です。
また、社員番号EMP001の社員を論理削除すると、認証時の条件e.is_active = 1に一致しなくなるため、EMP001アカウントは社員専用マイページへ遷移しなくなります。ただし、userテーブルのログインアカウント自体は削除されません。実務では、社員の無効化とユーザーアカウントの停止を連動させる設計も検討します。
実装:app.py(社員管理ルーティング)
# app.py(社員管理部分)
from flask import render_template, request, redirect, url_for, flash
from services.auth_service import login_required, roles_required
from services.employee_service import (
get_all_employees, get_employee_by_id,
create_employee, update_employee, delete_employee
)
@app.route("/employees")
@login_required
@roles_required("staff", "admin")
def employee_list():
keyword = request.args.get("keyword", "")
return render_template("employee_list.html", employees=get_all_employees(keyword))
@app.route("/employees/new", methods=["GET", "POST"])
@login_required
@roles_required("staff", "admin")
def employee_new():
if request.method == "POST":
create_employee(request.form.to_dict())
flash("社員を登録しました", "success")
return redirect(url_for("employee_list"))
return render_template("employee_form.html", employee=None)
@app.route("/employees/<int:employee_id>/edit", methods=["GET", "POST"])
@login_required
@roles_required("staff", "admin")
def employee_edit(employee_id):
employee = get_employee_by_id(employee_id)
if not employee:
flash("社員が見つかりません", "warning")
return redirect(url_for("employee_list"))
if request.method == "POST":
update_employee(employee_id, request.form.to_dict())
flash("社員情報を更新しました", "success")
return redirect(url_for("employee_list"))
return render_template("employee_form.html", employee=employee)
@app.route("/employees/<int:employee_id>/delete", methods=["POST"])
@login_required
@roles_required("admin")
def employee_delete(employee_id):
delete_employee(employee_id)
flash("社員を削除しました", "success")
return redirect(url_for("employee_list"))
削除は GET ではなく POST メソッドで受け付けます。
GET で削除できる設計にすると、URLを誰かに送るだけで意図せず削除されるリスクがあります。
HTMLフォームの method="POST" と onsubmit="return confirm(...)" を使用し、誤操作を減らします。なお、本番運用ではCSRF対策も追加する必要があります。
実装:テンプレート(employee_list.html)
{% extends "base.html" %}
{% block title %}社員一覧 - AI人事研修システム{% endblock %}
{% block content %}
<div class="content-header"><div><h1>社員管理</h1><p>社員情報の検索・登録・編集を行います。</p></div></div>
<section class="page-card">
<div class="page-title-row"><div><h2>👤 社員一覧</h2><p>表示件数:<strong>{{ employees|length }}</strong>名</p></div><a href="{{ url_for('employee_new') }}" class="btn btn-primary">+ 新規登録</a></div>
<!-- 検索フォーム -->
<form method="GET" action="{{ url_for('employee_list') }}" class="search-panel">
<input type="search" name="keyword"
placeholder="社員番号・氏名・部署で検索"
value="{{ request.args.get('keyword', '') }}">
<button type="submit" class="btn btn-dark">検索</button>
{% if request.args.get('keyword') %}
<a href="{{ url_for('employee_list') }}" class="btn btn-secondary">クリア</a>
{% endif %}
</form>
<!-- 社員一覧テーブル -->
<div class="table-wrap"><table class="table data-table">
<thead>
<tr>
<th>社員番号</th>
<th>氏名</th>
<th>部署</th>
<th>役職</th>
<th>入社日</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% if employees %}
{% for emp in employees %}
<tr>
<td>{{ emp.employee_no }}</td>
<td>{{ emp.name }}</td>
<td><span class="badge">{{ emp.department or '未設定' }}</span></td>
<td>{{ emp.position }}</td>
<td>{{ emp.hire_date }}</td>
<td>
<a href="{{ url_for('employee_edit', employee_id=emp.id) }}"
class="btn btn-small btn-secondary">編集</a>
{% if session.get('role') == 'admin' %}
<form method="POST"
action="{{ url_for('employee_delete', employee_id=emp.id) }}"
style="display:inline;"
onsubmit="return confirm('削除しますか?');">
<button type="submit" class="btn btn-small btn-danger">削除</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="6" class="text-center">条件に一致する社員データがありません。</td>
</tr>
{% endif %}
</tbody>
</table></div>
</section>
{% endblock %}
実装:テンプレート(employee_form.html)
登録と編集で同じフォームを使い回します。
employeeが存在するかどうかで「登録」か「編集」かを判定します。
{% extends "base.html" %}
{% block title %}
{% if employee %}社員編集{% else %}社員登録{% endif %} - AI人事・研修システム
{% endblock %}
{% block content %}
<div class="content-header"><div><h1>{% if employee %}社員情報編集{% else %}社員登録{% endif %}</h1><p>社員の基本情報を入力してください。</p></div></div>
<form method="POST" class="form-card">
<div class="form-group">
<label for="employee_no">社員番号 <span class="required">*</span></label>
<input type="text" id="employee_no" name="employee_no"
value="{{ employee.employee_no if employee else '' }}"
placeholder="例: EMP001" required>
<p class="help-text">社員を一意に識別する番号です。</p>
</div>
<div class="form-group">
<label for="name">氏名 <span class="required">*</span></label>
<input type="text" id="name" name="name"
value="{{ employee.name if employee else '' }}"
placeholder="例: 山田 太郎" required>
</div>
<div class="form-group">
<label for="department">部署</label>
<input type="text" id="department" name="department"
value="{{ employee.department if employee else '' }}"
placeholder="例: 人事部">
</div>
<div class="form-group">
<label for="position">役職</label>
<input type="text" id="position" name="position"
value="{{ employee.position if employee else '' }}"
placeholder="例: 主任">
</div>
<div class="form-group">
<label for="hire_date">入社日</label>
<input type="date" id="hire_date" name="hire_date"
value="{{ employee.hire_date if employee else '' }}">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
{% if employee %}更新する{% else %}登録する{% endif %}
</button>
<a href="{{ url_for('employee_list') }}" class="btn btn-secondary">
キャンセル
</a>
</div>
</form>
{% endblock %}
現在の入力チェック
最新ソースでは、次の入力制御を行っています。
| フィールド | 現在の制御 |
|---|---|
| 社員番号 | HTMLのrequired属性、DBのNOT NULL・UNIQUE制約 |
| 氏名 | HTMLのrequired属性、DBのNOT NULL制約 |
| 部署 | 任意入力 |
| 役職 | 任意入力 |
| 入社日 |
input type="date"による日付入力 |
社員番号と氏名はブラウザ側で必須入力にしています。また、社員番号の重複はSQLiteのUNIQUE制約で防止します。
現在のサンプルには、サーバー側の入力検証やsqlite3.IntegrityErrorの個別処理は実装されていません。そのため、重複した社員番号を送信した場合は、利用者向けのエラーメッセージを表示できない可能性があります。
実務向けに発展させる場合は、サービス層で必須項目・文字数・日付形式・社員番号の重複を検証し、フォームへエラーを返す処理を追加します。
編集画面では社員番号も変更できます。社員番号は社員ログイン時のユーザー名との照合にも使用するため、EMP001など社員番号アカウントと連携している社員の番号を変更する場合は、user.usernameとの整合性にも注意が必要です。
動作確認
手順
# Flaskアプリを起動
python app.py
# ブラウザで http://localhost:5000/employees にアクセス
確認ポイント
| 操作 | 期待する動作 |
|---|---|
| 社員一覧を開く | 登録済み社員が一覧表示される |
| 検索ボックスに「人事」と入力 | 人事部の社員のみ表示される |
| 検索ボックスに「EMP001」と入力 | 社員番号が一致する山田 太郎が表示される |
| 新規登録:社員番号または氏名を空にする | ブラウザの必須入力チェックで送信されない |
| 正しい値で新規登録 | 一覧に追加され、成功メッセージが表示される |
| 社員編集で社員番号を変更 | 変更後の社員番号が一覧に表示される |
staffで社員一覧を開く |
編集ボタンは表示され、削除ボタンは表示されない |
adminで社員一覧を開く |
編集ボタンと削除ボタンが表示される |
| 社員削除:確認ダイアログで「OK」 | 一覧から消え、成功メッセージが表示される |
| 削除した社員が一覧に表示されない |
is_active=0 として論理削除されている |
SQLインジェクション対策
本実装では ? プレースホルダを使っているため、SQLインジェクションを防いでいます。
# NG:文字列結合(SQLインジェクションに脆弱)
keyword = request.args.get("keyword")
rows = conn.execute(
f"SELECT * FROM employee WHERE name LIKE '%{keyword}%'"
).fetchall()
# keyword に "' OR '1'='1" を入れると全件取得される
# OK:プレースホルダを使用
rows = conn.execute(
"SELECT * FROM employee WHERE name LIKE ?",
(f"%{keyword}%",) # ← タプルで渡す
).fetchall()
# keyword がどんな値でも、文字列として安全に扱われる
SQLを組み立てるときは絶対に文字列結合を使わないでください。
プレースホルダ(?)を使えば、ユーザーの入力はSQL命令として解釈されません。
おわりに
第5回では、社員管理のCRUD全操作を実装しました。
ポイントを振り返ります。
-
サービス層の分離:
employee_service.pyにDBアクセスを集約し、app.pyはルーティングを担当した -
一覧・検索:
LIKE句と%keyword%で部分一致検索を実装した -
論理削除:
is_active = 0フラグで退職者データを残しつつ一覧から除外した -
入力制御:HTMLの
required属性とDB制約で必須入力・社員番号の重複を制御した -
権限制御:
staffは登録・編集、adminは登録・編集・削除を利用できるようにした -
SQLインジェクション対策:プレースホルダ(
?)を徹底してSQLインジェクションを防いだ
このようにルーティングとDB操作を分ける考え方は、研修管理や評価管理にも応用できます。
次回の「研修管理編」では、研修の登録と、社員・研修を関連付けた研修履歴の入力を実装します。
次回 : Python × Dify × RAGで学ぶ業務システム開発入門【第6回 研修管理編】
本連載について
本連載の各記事で実装していく内容は以下のとおりです。
| 回 | タイトル | 対応画面 | 主な内容 |
|---|---|---|---|
| 第1回 | 業務システム全体設計編 | ― | システム概要・技術選定・アーキテクチャ |
| 第2回 | 要求定義・要件定義編 | ― | 業務分析・機能要件・非機能要件 |
| 第3回 | ER図・画面設計編 | ― | テーブル設計・画面遷移図・ワイヤーフレーム |
| 第4回 | Flaskログイン機能編 | 🔐 ログイン | セッション・ハッシュ・ロール認可・社員番号ログイン |
| 第5回 | 社員管理CRUD編 | 👤 社員管理 | 一覧・検索・登録・更新・論理削除・入力制御 |
| 第6回 | 研修管理編 | 📚 研修管理 | 研修登録・LEFT JOIN・履歴一括入力・UPSERT |
| 第7回 | Excel業務自動化編 | 📥 取込 / 📤 出力 | pandas取込・openpyxl出力 |
| 第8回 | Dify API連携編 | 📊 評価管理 | API・フォールバック・生成中UI・二重送信防止 |
| 第9回 | RAG構築編 | 🔍 文書検索 | Dify Chat API・質問送信・回答表示・エラー処理 |
| 第10回 | FAQチャットボット編 | 💬 FAQチャット | 会話ID・Fetch API・生成中表示・二重送信防止 |
| 公開編 | Ubuntu VPS・Docker公開編 | 🌐 サービス公開 | Ubuntu VPS・Docker・Nginxを使った本番環境構築とWebアプリケーション公開 |
