0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[Flask]社内業務を一元管理・効率化するポータルサイトを開発しました[Python]

0
Posted at

1 . はじめに

はじめまして。現在pythonを中心にプログラミングを学習中のやまpythonと申します。

この度、社内の様々な業務連絡や申請作業をペーパーレス化・効率化することを目的とした「社内ポータルサイト」をPython (Flask) で開発しました。本記事では、アプリの機能や技術スタック、開発において工夫した点や苦労した点についてまとめます。

※ セキュリティおよび情報漏洩防止の観点から、記事内のコードや仕様については一部ダミーデータや抽象化した表現に変更しています。


2 . 主な機能

本アプリケーションは、社員の日常的な業務をサポートする以下の機能を備えています。

  1. ユーザー認証機能
    • アカウントの新規登録、ログイン・ログアウト機能
    • セキュリティを考慮したパスワードのハッシュ化保存
  2. 残業管理機能
    • 日々の早出・残業時間の登録
    • 締め日を考慮した「月ごとの累計残業時間」の自動集計・過去データの閲覧機能
  3. お知らせ(掲示板)機能
    • 管理者から全社員へのお知らせ配信
    • 新着情報がある場合の未読通知(バッジ)機能
  4. 社内申請・確認機能
    • お弁当の注文申請(lunch_order.html
    • 休暇申請(vacation.html
    • 給与明細の確認(salary.html
    • シフト・スケジュールの確認(schedule.html
  5. 管理者専用パネル
    • 事前登録された社員番号のみアカウント作成を許可するホワイトリスト機能
    • お知らせの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

データベースやログインなどの拡張機能を別のファイルに分離し、ここからインポートするようにします。

myapp/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

データベースのモデルクラスのみ集めます。

myapp/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

複数ファイルで使用する関数をまとめています。

myapp/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を使用してルーティングを定義します。これにより、別ファイルにルーティングを書き出すことが可能です。

認証・設定処理

auth.py
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():
    ***

メインメニュー・お知らせ処理

main.py
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)

残業管理処理

overtime.py
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"))

管理者機能

admin.py
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() で処理します。

myapp/__init__.py
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アプリケーション開発の全体像を深く学ぶことができました。

今後はさらに技術を磨き、ユーザーにとってより使いやすいアプリケーションを開発していきたいと考えています。最後までお読みいただきありがとうございました!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?