1 . はじめに
はじめまして。現在pythonを中心にプログラミングを学習中のやまpythonと申します。
この度、社内の様々な業務連絡や申請作業をペーパーレス化・効率化することを目的とした「社内ポータルサイト」をPython (Flask) で開発しました。本記事では、アプリの機能や技術スタック、開発において工夫した点や苦労した点についてまとめます。
※ セキュリティおよび情報漏洩防止の観点から、記事内のコードや仕様については一部ダミーデータや抽象化した表現に変更しています。
2 . 主な機能
本アプリケーションは、社員の日常的な業務をサポートする以下の機能を備えています。
-
ユーザー認証機能
- アカウントの新規登録、ログイン・ログアウト機能
- セキュリティを考慮したパスワードのハッシュ化保存
-
残業管理機能
- 日々の早出・残業時間の登録
- 締め日を考慮した「月ごとの累計残業時間」の自動集計・過去データの閲覧機能
-
お知らせ(掲示板)機能
- 管理者から全社員へのお知らせ配信
- 新着情報がある場合の未読通知(バッジ)機能
-
社内申請・確認機能
- お弁当の注文申請(
lunch_order.html) - 休暇申請(
vacation.html) - 給与明細の確認(
salary.html) - シフト・スケジュールの確認(
schedule.html)
- お弁当の注文申請(
-
管理者専用パネル
- 事前登録された社員番号のみアカウント作成を許可するホワイトリスト機能
- お知らせのCRUD操作(作成・更新・削除)
3 . 完成イメージ
※ 下記イメージ以外の機能はレイアウトのみ作成しています
- ログイン画面
- ダッシュボード(メインメニュー)
- 残業申請画面
- 管理者向け画面(ユーザー管理・お知らせ配信)
4 . 使用した技術・ライブラリ
| ライブラリ・技術 | 使用目的 |
|---|---|
| Python 3.14, Flask | アプリの基本構成 |
| PostgreSQL, SQLAlchemy | データベース |
| Flask-Migrate | マイグレーション |
| Flask-Login, Werkzeug | 認証システム, Passのハッシュ化 |
| HTML, CSS | フロントエンド(Jinja2を使用) |
| Heroku | デプロイ |
5 . ファイル構成
開発当初は1つのファイルに全ての処理を記述していましたが、規模が大きくなるにつれて保守性が低下したため、Application FactoryパターンとBlueprintを用いて機能ごとにディレクトリを分割するリファクタリングを行いました。
.
├── myproject
│ ├── app.py # アプリ起動のエントリポイント
│ └── myapp
│ ├── __init__.py # アプリケーションファクトリ(create_app)
│ ├── extensions.py # db, login_managerなどの初期化
│ ├── models.py # DBスキーマ定義(User, Info, Overtimeなど)
│ ├── utils.py # 共通処理・ヘルパー関数群
│ ├── static # 静的ファイル(CSS, 画像)
│ │ ├── css
│ │ │ └── styles.css
│ │ └── img
│ ├── templates # Jinja2 HTMLテンプレート群
│ │ ├── _macros.html
│ │ ├── admin.html
│ │ ├── base.html
│ │ ├── info.html
│ │ ├── login.html
│ │ ├── lunch_order.html
│ │ ├── menu.html
│ │ ├── overtime_register.html
│ │ ├── past_overtime_data.html
│ │ ├── readmore.html
│ │ ├── salary.html
│ │ ├── schedule.html
│ │ ├── settings.html
│ │ ├── signup.html
│ │ └── vacation.html
│ └── views # Blueprintによるルーティング分割
│ ├── admin.py # 管理者向け機能
│ ├── auth.py # 認証・設定関連
│ ├── main.py # メニュー・お知らせ閲覧
│ └── overtime.py # 残業申請・集計関連
├── Procfile # デプロイ用設定ファイル
├── README.md
├── requirements.txt # パッケージ依存関係
└── runtime.txt # Pythonバージョン指定
6 . コード概要
6-1 . extentions.py
データベースやログインなどの拡張機能を別のファイルに分離し、ここからインポートするようにします。
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_login import LoginManager
db = SQLAlchemy()
migrate = Migrate()
login_manager = LoginManager()
6-2 . models.py
データベースのモデルクラスのみ集めます。
from datetime import datetime
from flask_login import UserMixin
from sqlalchemy import UniqueConstraint
from .extensions import db
class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
number = db.Column(db.Integer, nullable=False, unique=True)
username = db.Column(db.String(50), nullable=False)
nickname = db.Column(db.String(50), nullable=True)
# パスワードはハッシュ化して保存
password = db.Column(db.String(200), nullable=False)
post = db.Column(db.String(20), nullable=True)
last_checked_info = db.Column(db.DateTime, nullable=True)
class SignupAllowedUser(db.Model):
id = db.Column(db.Integer, primary_key=True)
number = db.Column(db.Integer, nullable=False)
username = db.Column(db.String(50), nullable=False)
__table_args__ = (
UniqueConstraint("number", "username", name="uq_signup_allowed_number_username"),
)
class MonthlyOvertime(db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
billing_month = db.Column(db.String(7), nullable=False)
total_minutes = db.Column(db.Integer, default=0)
user = db.relationship("User", backref=db.backref("monthly_overtimes", lazy=True))
class DateOvertime(db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
monthly_overtime_id = db.Column(db.Integer, db.ForeignKey("monthly_overtime.id"), nullable=False)
work_date = db.Column(db.Date, nullable=False)
minutes = db.Column(db.Integer, nullable=False)
user = db.relationship("User", backref=db.backref("overtimes", lazy=True))
monthly_overtime = db.relationship("MonthlyOvertime", backref=db.backref("daily_overtime", lazy=True))
class Info(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(50), nullable=False)
body = db.Column(db.String(1000), nullable=False)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.now)
6-3 . utils.py
複数ファイルで使用する関数をまとめています。
import os
from datetime import timedelta
def parse_admin_accounts(app_debug=False):
raw = os.environ.get("ADMIN_ACCOUNTS", "")
if app_debug and not raw:
raw = "社員番号:ユーザー名"
accounts = set()
for item in raw.split(","):
item = item.strip()
if not item or ":" not in item:
continue
number_str, username = item.split(":", 1)
try:
accounts.add((int(number_str.strip()), username.strip()))
except ValueError:
continue
return accounts
def can_access_admin(user, admin_accounts):
if not user or not getattr(user, "is_authenticated", False):
return False
return (user.number, user.username) in admin_accounts
# 給与15日締めを想定したロジック
def get_billing_month(work_date):
if work_date.day >= 16:
target_date = work_date.replace(day=28) + timedelta(days=5)
return target_date.strftime("%Y-%m")
return work_date.strftime("%Y-%m")
6-4 . myapp/views
Blueprintを使用してルーティングを定義します。これにより、別ファイルにルーティングを書き出すことが可能です。
認証・設定処理
from flask import Blueprint, render_template, request, flash, redirect, url_for
from flask_login import login_user, logout_user, login_required, current_user
from werkzeug.security import generate_password_hash, check_password_hash
from sqlalchemy.exc import IntegrityError
from ..extensions import db
from ..models import User, SignupAllowedUser
auth_bp = Blueprint("auth", __name__)
# 認証・設定処理
@auth_bp.route("/login", methods=["GET", "POST"])
def login():
***
@auth_bp.route("/signup", methods=["GET", "POST"])
def signup():
***
@auth_bp.route("/logout")
@login_required
def logout():
logout_user()
return redirect(url_for("auth.login"))
@auth_bp.route("/settings", methods=["GET", "POST"])
@login_required
def settings():
***
メインメニュー・お知らせ処理
from flask import Blueprint, render_template
from flask_login import login_required, current_user
from datetime import datetime
from ..extensions import db
from ..models import MonthlyOvertime, Info
from ..utils import get_billing_month
main_bp = Blueprint("main", __name__)
@main_bp.route("/menu")
@login_required
def menu():
***
@main_bp.route('/info')
@login_required
def info():
***
@main_bp.route("/info/<int:info_id>")
@login_required
def readmore(info_id):
info = Info.query.get_or_404(info_id)
return render_template("readmore.html", info=info)
残業管理処理
from datetime import datetime, timedelta
from flask import Blueprint, flash, redirect, render_template, request, url_for
from flask_login import current_user, login_required
from ..utils import get_billing_month
from ..extensions import db
from ..models import DateOvertime, MonthlyOvertime
overtime_bp = Blueprint("overtime", __name__)
# 公共交通機関の勤務を想定。早出と残業勤務両方を登録
EARLY_SHIFTS = [
{"code": "勤務番号", "minutes": "残業時間"}
]
OVERTIME_SHIFTS = [
{"code": "勤務番号", "minutes": "残業時間"}
]
_ALL_SHIFTS = EARLY_SHIFTS + OVERTIME_SHIFTS
def format_minutes_jp(total_minutes):
hours = total_minutes // 60
mins = total_minutes % 60
if mins > 0:
return f"{hours}時間{mins}分"
return f"{hours}時間"
def save_daily_overtime(user_id, work_date, minutes):
billing_month = get_billing_month(work_date)
monthly_record = MonthlyOvertime.query.filter_by(
user_id=user_id, billing_month=billing_month
).first()
if not monthly_record:
monthly_record = MonthlyOvertime(
user_id=user_id, billing_month=billing_month, total_minutes=0
)
db.session.add(monthly_record)
db.session.flush()
daily_record = DateOvertime.query.filter_by(
user_id=user_id, work_date=work_date
).first()
if daily_record:
daily_record.minutes = minutes
else:
daily_record = DateOvertime(
user_id=user_id,
monthly_overtime_id=monthly_record.id,
work_date=work_date,
minutes=minutes,
)
db.session.add(daily_record)
db.session.commit()
total = (
db.session.query(db.func.sum(DateOvertime.minutes))
.filter_by(monthly_overtime_id=monthly_record.id)
.scalar()
)
monthly_record.total_minutes = int(total or 0)
db.session.commit()
def get_monthly_data():
monthly_records = (
MonthlyOvertime.query.filter_by(user_id=current_user.id)
.order_by(MonthlyOvertime.billing_month.desc())
.all()
)
rows = []
for record in monthly_records:
_, month = record.billing_month.split("-")
label = f"{int(month)}月"
rows.append(
{
"label": label,
"past_months_overtime": format_minutes_jp(record.total_minutes),
}
)
return rows
def _parse_uint_field(raw, name_for_error):
if raw is None or str(raw).strip() == "":
return 0, None
try:
return max(0, int(str(raw).strip())), None
except ValueError:
return None, f"{name_for_error}は0以上の整数で入力してください"
def _find_shift_minutes(code):
for shift in _ALL_SHIFTS:
if shift["code"] == code:
return shift["minutes"], shift["code"]
return None, None
@overtime_bp.route("/overtime_register", methods=["GET", "POST"])
@login_required
def overtime_register():
***
@overtime_bp.route("/past_overtime_data")
@login_required
def past_overtime_data():
past_months_data = get_monthly_data()
date_records = (
DateOvertime.query.filter_by(user_id=current_user.id)
.order_by(DateOvertime.work_date.desc())
.all()
)
past_date_data = []
for record in date_records[:20]:
label = f"{record.work_date.month}月{record.work_date.day}日"
past_date_data.append(
{
"id": record.id,
"label": label,
"past_overtime": format_minutes_jp(record.minutes),
}
)
return render_template(
"past_overtime_data.html",
past_months_data=past_months_data,
past_date_data=past_date_data,
)
@overtime_bp.route("/delete/<int:date_overtime_id>")
@login_required
def delete_daily_overtime(date_overtime_id):
date_overtime = db.session.get(DateOvertime, date_overtime_id)
if not date_overtime:
return redirect(url_for("overtime.past_overtime_data"))
if date_overtime.user_id != current_user.id:
flash("このデータを削除する権限がありません")
return redirect(url_for("overtime.past_overtime_data"))
monthly_record = date_overtime.monthly_overtime
db.session.delete(date_overtime)
db.session.flush()
total = (
db.session.query(db.func.sum(DateOvertime.minutes))
.filter_by(monthly_overtime_id=monthly_record.id)
.scalar()
)
monthly_record.total_minutes = int(total or 0)
if monthly_record.total_minutes == 0:
db.session.delete(monthly_record)
db.session.commit()
return redirect(url_for("overtime.past_overtime_data"))
管理者機能
from flask import Blueprint, render_template, request, flash, redirect, url_for, current_app
from flask_login import login_required, current_user
from sqlalchemy.exc import IntegrityError
from ..extensions import db
from ..models import User, SignupAllowedUser, Info, MonthlyOvertime, DateOvertime
from ..utils import can_access_admin, parse_admin_accounts
admin_bp = Blueprint("admin", __name__)
@admin_bp.route("/admin", methods=["GET", "POST"])
@login_required
def admin():
***
6-5 . init.py
それぞれに分割したパーツを create_app() で処理します。
import os
from flask import Flask
from .extensions import db, migrate, login_manager
from .models import User, SignupAllowedUser
from .utils import parse_admin_accounts, can_access_admin
from sqlalchemy.exc import IntegrityError
def create_app():
app = Flask(__name__)
if app.debug:
app.config["SECRET_KEY"] = ***
db_info = {"user": "postgres", "password": "", "host": "localhost", "name": "postgres"}
app.config["SQLALCHEMY_DATABASE_URI"] = "postgresql+psycopg://{user}:{password}@{host}/{name}".format(**db_info)
else:
app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY")
database_url = os.environ.get("DATABASE_URL")
if not database_url:
raise RuntimeError("DATABASE_URL must be set when FLASK_DEBUG is off")
app.config["SQLALCHEMY_DATABASE_URI"] = database_url.replace("postgres://", "postgresql+psycopg://")
# 拡張機能の初期化
db.init_app(app)
migrate.init_app(app, db)
login_manager.init_app(app)
login_manager.login_view = "auth.login"
@login_manager.user_loader
def load_user(user_id):
return db.session.get(User, int(user_id))
admin_accounts = parse_admin_accounts(app.debug)
@app.context_processor
def inject_admin_access():
from flask_login import current_user
return {"can_access_admin": can_access_admin(current_user, admin_accounts)}
# Blueprintの登録
from .views.auth import auth_bp
from .views.main import main_bp
from .views.admin import admin_bp
from .views.overtime import overtime_bp
app.register_blueprint(auth_bp)
app.register_blueprint(main_bp)
app.register_blueprint(admin_bp)
app.register_blueprint(overtime_bp)
# 管理者アカウントの初期化
with app.app_context():
try:
ensure_admin_signup_allowed_users(db, admin_accounts)
except Exception:
app.logger.exception("管理者用事前登録の自動作成に失敗しました")
return app
def ensure_admin_signup_allowed_users(db_instance, admin_accounts):
if not admin_accounts:
return
for number, username in admin_accounts:
exists = SignupAllowedUser.query.filter_by(number=number, username=username).first()
if exists:
continue
db_instance.session.add(SignupAllowedUser(number=number, username=username))
try:
db_instance.session.commit()
except IntegrityError:
db_instance.session.rollback()
7 . 工夫した点
7-1 . Blueprintを活用した設計
当初はルーティングやデータベースを1つのファイルに設計していましたが、機能を追加していくにつれて管理が複雑になりました。そこで、FlaskのBlueprintを活用して、「管理者機能」「認証」「残業管理機能」など、役割ごとにファイルを分割しました。これにより、どこになにが書かれているかが明確になり、後からの追加機能やデバッグが容易になる構造を実現できました。
7-2 . 事前登録によるセキュアなユーザー登録
社内ポータルという性質上、誰でもアカウントを作成できる状態は防ぐ必要がありました。そこで、管理者が事前に「社員番号と氏名」をデータベースに登録しておき、その情報と完全に一致したユーザーのみがパスワードを設定して本登録できる仕組み(SignupAllowedUser モデルを用いた照合)を実装しました。
8 . 苦労した点・今後の課題
8-1 . ファイル分割時のインポートエラー
リファクタリングでファイルを分割した際、インポートエラーに悩まされました。そこで、拡張機能を extensions.py という独立したファイルにまとめて、各ファイルがそこからインポートできるように設計することで、この問題を解決することができました。
8-2 . 締め日を考慮した日付計算
残業時間の集計において、「毎月15日締め」などの社内独自のルールをシステムに落とし込む必要がありました。標準の月単位ではなく、日付を判定して「締め月(billing month)」という文字列を生成する共通関数を utils.py に作成し、それをキーとしてデータベースから月次データを引っぱってくるロジックの構築に苦労しました。
8-3 . UI/UXのさらなる改善
JavaScriptを導入し、画面遷移なしでの非同期処理を実装することで、ユーザーの操作性を更に改善したいです。
9 . おわりに
この開発を通して、Flaskの基礎から、データベースの設計、CRUD処理、そして保守性を意識したディレクトリ構成まで、Webアプリケーション開発の全体像を深く学ぶことができました。
今後はさらに技術を磨き、ユーザーにとってより使いやすいアプリケーションを開発していきたいと考えています。最後までお読みいただきありがとうございました!