ending-note / app.py
yukiputi's picture
Upload 2 files
b549f89 verified
# -*- coding: utf-8 -*-
"""
エンディングノート - デモ版 (Hugging Face Spaces用)
・パスワード固定: demo
・データはメモリのみ保存(再起動でリセット)
・ファイルへの書き込みなし
"""
import os
import json
import io
import zipfile
import urllib.request
import tempfile
from datetime import datetime, date
from functools import wraps
from copy import deepcopy
from flask import (
Flask, render_template, request, redirect, url_for,
session, jsonify, send_file, flash
)
from dotenv import load_dotenv
load_dotenv()
# ============================================================
# アプリ初期化
# ============================================================
app = Flask(__name__)
app.secret_key = os.environ.get("SECRET_KEY", "ending-note-demo-secret-2024")
# デモ用固定パスワード
DEMO_PASSWORD = "demo"
# ============================================================
# パス定数(PDF出力用のみ)
# ============================================================
BASE_DIR = os.path.dirname(__file__)
FONTS_DIR = os.path.join(BASE_DIR, "static", "fonts")
# HF Spaces(Linux)ではシステムフォントを優先使用
LINUX_FONT_CANDIDATES = [
"/usr/share/fonts/opentype/ipafont-mincho/ipam.ttf",
"/usr/share/fonts/truetype/fonts-ipafont-mincho/ipam.ttf",
"/usr/share/fonts/opentype/ipafont-gothic/ipag.ttf",
"/usr/share/fonts/truetype/vlgothic/VL-Gothic-Regular.ttf",
]
FONT_PATH = os.path.join(FONTS_DIR, "ipaexm.ttf")
# Linuxシステムフォントが存在すれば優先使用
for _lf in LINUX_FONT_CANDIDATES:
if os.path.exists(_lf):
FONT_PATH = _lf
break
# ============================================================
# メモリ内データストア
# セッションIDをキーにノートデータを保持する
# ============================================================
_memory_store = {}
def _get_session_id():
"""セッションIDを取得または生成する"""
if "sid" not in session:
import uuid
session["sid"] = str(uuid.uuid4())
return session["sid"]
def load_note_data():
"""メモリからノートデータを取得する。なければ初期データを返す。"""
sid = _get_session_id()
if sid not in _memory_store:
_memory_store[sid] = _default_note_data()
return deepcopy(_memory_store[sid])
def save_note_data(data):
"""メモリにノートデータを保存する(ファイルには書かない)"""
data.setdefault("meta", {})
data["meta"]["updated_at"] = date.today().isoformat()
if not data["meta"].get("created_at"):
data["meta"]["created_at"] = date.today().isoformat()
sid = _get_session_id()
_memory_store[sid] = deepcopy(data)
def _default_note_data():
"""デフォルトのnote_data構造を返す"""
return {
"meta": {
"created_at": date.today().isoformat(),
"updated_at": date.today().isoformat()
},
"sections": {
"basic": {
"name_kanji": "", "name_kana": "", "birthdate": "",
"birthplace": "", "honseki": "", "address": "",
"blood_type": "", "my_number_location": ""
},
"family": {"members": [], "emergency": [], "friends": []},
"assets": {
"deposits": [], "stocks": [], "real_estate": [],
"insurance": [], "pension_type": "", "pension_account": "",
"debts": [], "account_location": ""
},
"digital": {
"device_lock_location": "", "accounts": [], "subscriptions": []
},
"medical": {
"chronic_diseases": "", "allergies": "", "medications": [],
"doctors": [], "care_preference": "", "care_preference_note": "",
"care_person": "", "care_funds": "", "life_sustaining": "",
"diagnosis_disclosure": "", "organ_donation": "",
"organ_donation_note": "", "living_will": False,
"living_will_location": ""
},
"funeral": {
"style": "", "budget": "", "prepaid": False, "prepaid_detail": "",
"coffin_items": "", "photo_location": "", "attendees": [],
"grave_type": "", "grave_note": "", "religion": ""
},
"will": {"existence": "", "format": "", "location_or_contact": "", "notes": ""},
"will_template": {
"testator_name": "", "testator_birthdate": "", "testator_address": "",
"items": [], "message": "", "date": ""
},
"life_story": {
"childhood": "", "career": "", "hobbies": "",
"family_message": "", "messages": [], "pets": []
}
}
}
# ============================================================
# フォント(PDF用)
# ============================================================
def ensure_directories():
os.makedirs(FONTS_DIR, exist_ok=True)
def ensure_font():
if os.path.exists(FONT_PATH):
return
print("[INFO] IPAexMinchoフォントをダウンロード中...")
font_url = "https://moji.or.jp/wp-content/ipafont/IPAexfont/ipaexm.zip"
try:
with tempfile.TemporaryDirectory() as tmp_dir:
zip_path = os.path.join(tmp_dir, "ipaexm.zip")
urllib.request.urlretrieve(font_url, zip_path)
with zipfile.ZipFile(zip_path, "r") as zf:
for name in zf.namelist():
if name.endswith("ipaexm.ttf"):
zf.extract(name, tmp_dir)
import shutil
shutil.copy2(os.path.join(tmp_dir, name), FONT_PATH)
print(f"[INFO] フォント配置完了: {FONT_PATH}")
return
except Exception as e:
print(f"[WARNING] フォントダウンロード失敗: {e}")
# ============================================================
# 完成度計算
# ============================================================
SECTION_IMPORTANT_FIELDS = {
"basic": ["name_kanji", "birthdate", "address", "blood_type"],
"family": ["members", "emergency"],
"assets": ["deposits", "account_location"],
"digital": ["device_lock_location", "accounts"],
"medical": ["chronic_diseases", "medications", "doctors",
"life_sustaining", "organ_donation"],
"funeral": ["style", "grave_type"],
"will": ["existence", "location_or_contact"],
"will_template": ["testator_name", "items", "date"],
"life_story": ["childhood", "family_message"]
}
def calc_section_progress(section_id, section_data):
fields = SECTION_IMPORTANT_FIELDS.get(section_id, [])
if not fields:
return 0
filled = 0
for f in fields:
val = section_data.get(f)
if isinstance(val, list):
if len(val) > 0:
filled += 1
elif isinstance(val, bool):
filled += 1
elif val:
filled += 1
return int(filled / len(fields) * 100)
def calc_all_progress(note_data):
result = {}
sections = note_data.get("sections", {})
for sid, sdata in sections.items():
result[sid] = calc_section_progress(sid, sdata)
return result
# ============================================================
# セクション定義
# ============================================================
SECTIONS = [
{"id": "basic", "label": "基本情報", "icon": "👤"},
{"id": "family", "label": "家族構成・緊急連絡先", "icon": "👨‍👩‍👧"},
{"id": "assets", "label": "財産・負債", "icon": "💴"},
{"id": "digital", "label": "デジタル遺産", "icon": "💻"},
{"id": "medical", "label": "医療・介護の希望", "icon": "🏥"},
{"id": "funeral", "label": "葬儀・供養の意向", "icon": "🌸"},
{"id": "will", "label": "遺言書・法的情報", "icon": "📜"},
{"id": "will_template","label": "遺言状テンプレート", "icon": "✍️"},
{"id": "life_story", "label": "自分史・メッセージ", "icon": "📖"},
]
# ============================================================
# 認証デコレータ
# ============================================================
def login_required(f):
@wraps(f)
def decorated(*args, **kwargs):
# デモ版:認証チェックなし、常に通す
session["authenticated"] = True
return f(*args, **kwargs)
return decorated
# ============================================================
# ルート定義
# ============================================================
@app.route("/login", methods=["GET", "POST"])
def login():
"""デモ版:認証スキップ、直接ダッシュボードへ"""
session["authenticated"] = True
load_note_data()
return redirect(url_for("dashboard"))
@app.route("/logout")
def logout():
"""ログアウト(メモリデータも削除)"""
sid = session.get("sid")
if sid and sid in _memory_store:
del _memory_store[sid]
session.clear()
return redirect(url_for("login"))
@app.route("/")
@login_required
def dashboard():
"""ダッシュボード"""
note_data = load_note_data()
progress = calc_all_progress(note_data)
total_progress = int(sum(progress.values()) / len(progress)) if progress else 0
updated_at = note_data.get("meta", {}).get("updated_at", "")
show_reminder = False
if updated_at:
try:
diff = (date.today() - date.fromisoformat(updated_at)).days
show_reminder = diff >= 365
except Exception:
pass
overall_pct = total_progress
return render_template(
"dashboard.html",
sections=SECTIONS,
progress=progress,
total_progress=total_progress,
overall_pct=overall_pct,
note_data=note_data,
show_reminder=show_reminder,
is_demo=True
)
@app.route("/section/<section_id>", methods=["GET"])
@login_required
def section_view(section_id):
"""セクション編集画面"""
valid_ids = [s["id"] for s in SECTIONS]
if section_id not in valid_ids:
return redirect(url_for("dashboard"))
note_data = load_note_data()
section_data = note_data.get("sections", {}).get(section_id, {})
section_info = next(s for s in SECTIONS if s["id"] == section_id)
idx = valid_ids.index(section_id)
prev_section = SECTIONS[idx - 1] if idx > 0 else None
next_section = SECTIONS[idx + 1] if idx < len(SECTIONS) - 1 else None
return render_template(
"section.html",
section_id=section_id,
section_info=section_info,
section_data=section_data,
sections=SECTIONS,
prev_section=prev_section,
next_section=next_section,
section_data_json=json.dumps(section_data, ensure_ascii=False),
is_demo=True
)
@app.route("/api/save", methods=["POST"])
@login_required
def api_save():
"""
自動保存API(メモリのみ保存・ファイルには書かない)
"""
try:
payload = request.get_json()
if not payload:
return jsonify({"status": "error", "message": "データなし"}), 400
section_id = payload.get("section_id")
new_data = payload.get("data", {})
valid_ids = [s["id"] for s in SECTIONS]
if section_id not in valid_ids:
return jsonify({"status": "error", "message": "不正なセクションID"}), 400
note_data = load_note_data()
note_data["sections"][section_id] = new_data
save_note_data(note_data) # メモリのみ
return jsonify({"status": "ok", "updated_at": note_data["meta"]["updated_at"]})
except Exception as e:
return jsonify({"status": "error", "message": str(e)}), 500
@app.route("/export/json")
@login_required
def export_json():
"""JSONエクスポート(メモリデータから生成)"""
note_data = load_note_data()
today = date.today().strftime("%Y%m%d")
filename = f"ending_note_backup_{today}.json"
buf = io.BytesIO()
buf.write(json.dumps(note_data, ensure_ascii=False, indent=2).encode("utf-8"))
buf.seek(0)
return send_file(
buf,
mimetype="application/json",
download_name=filename,
as_attachment=True
)
@app.route("/import/json", methods=["POST"])
@login_required
def import_json():
"""JSONインポート(メモリに復元)"""
try:
f = request.files.get("file")
if not f:
flash("ファイルが選択されていません。", "error")
return redirect(url_for("dashboard"))
raw = f.read().decode("utf-8")
imported = json.loads(raw)
if "sections" not in imported:
flash("インポートファイルの形式が正しくありません。", "error")
return redirect(url_for("dashboard"))
save_note_data(imported) # メモリのみ
flash("データをインポートしました。(デモ版のため再起動でリセットされます)", "success")
return redirect(url_for("dashboard"))
except Exception as e:
flash(f"インポートに失敗しました: {e}", "error")
return redirect(url_for("dashboard"))
@app.route("/export/pdf")
@login_required
def export_pdf():
"""PDFエクスポート(メモリバッファ方式)"""
note_data = load_note_data()
today_str = date.today().strftime("%Y%m%d")
filename = f"ending_note_{today_str}.pdf"
try:
buf = io.BytesIO()
_generate_pdf_to_buffer(note_data, buf)
buf.seek(0)
return send_file(buf, mimetype="application/pdf",
download_name=filename, as_attachment=True)
except Exception as e:
flash(f"PDF出力に失敗しました: {e}", "error")
return redirect(url_for("dashboard"))
@app.route("/print-preview")
@login_required
def print_preview():
"""印刷プレビュー画面"""
note_data = load_note_data()
return render_template(
"print_preview.html",
note_data=note_data,
sections=SECTIONS,
today=date.today().isoformat(),
is_demo=True
)
@app.route("/will-preview")
@login_required
def will_preview():
"""遺言状HTMLプレビュー"""
note_data = load_note_data()
will_tpl = note_data.get("sections", {}).get("will_template", {})
return render_template("will_preview.html", data=will_tpl, today=date.today().isoformat())
# ============================================================
# PDF生成ロジック(元のapp.pyと同じ)
# ============================================================
def _generate_pdf_to_buffer(note_data, buf):
"""PDFをメモリバッファに生成する"""
_generate_pdf(note_data, buf)
def _generate_pdf(note_data, filepath):
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import mm
from reportlab.lib import colors
from reportlab.platypus import (
SimpleDocTemplate, Paragraph, Spacer, HRFlowable
)
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
if os.path.exists(FONT_PATH):
try:
pdfmetrics.registerFont(TTFont("IPAexMincho", FONT_PATH))
font_name = "IPAexMincho"
except Exception as e:
print(f"[WARNING] IPAexMincho登録失敗: {e}")
font_name = "Helvetica"
if font_name == "Helvetica":
WINDOWS_FONTS = [
("MSMincho", "C:/Windows/Fonts/msmincho.ttc"),
("MSGothic", "C:/Windows/Fonts/msgothic.ttc"),
("YuMincho", "C:/Windows/Fonts/yumin.ttf"),
("Meiryo", "C:/Windows/Fonts/meiryo.ttc"),
]
for fname, fpath in WINDOWS_FONTS:
if os.path.exists(fpath):
try:
pdfmetrics.registerFont(TTFont(fname, fpath))
font_name = fname
break
except Exception:
continue
doc = SimpleDocTemplate(
filepath, pagesize=A4,
topMargin=20*mm, bottomMargin=20*mm,
leftMargin=20*mm, rightMargin=20*mm,
)
dark_green = colors.Color(0.176, 0.314, 0.086)
light_gray = colors.Color(0.91, 0.894, 0.863)
title_style = ParagraphStyle("Title", fontName=font_name, fontSize=18, spaceAfter=4, textColor=dark_green, alignment=1)
sub_style = ParagraphStyle("Sub", fontName=font_name, fontSize=10, spaceAfter=2, textColor=colors.grey, alignment=1)
heading_style = ParagraphStyle("Heading", fontName=font_name, fontSize=13, spaceBefore=8, spaceAfter=4, textColor=dark_green)
body_style = ParagraphStyle("Body", fontName=font_name, fontSize=10, spaceAfter=3, leading=16)
label_style = ParagraphStyle("Label", fontName=font_name, fontSize=9, spaceAfter=1, textColor=colors.grey)
note_style = ParagraphStyle("Note", fontName=font_name, fontSize=8, spaceAfter=2, textColor=colors.red)
def val(text):
return text if text else "(未記入)"
def field(label_text, value_text):
return [
Paragraph(label_text, label_style),
Paragraph(val(value_text), body_style),
Spacer(1, 3),
]
def section_heading(title_text):
return [
Spacer(1, 8),
HRFlowable(width="100%", thickness=1.5, color=dark_green),
Paragraph(title_text, heading_style),
HRFlowable(width="100%", thickness=0.5, color=light_gray),
Spacer(1, 4),
]
story = []
meta = note_data.get("meta", {})
secs = note_data.get("sections", {})
basic = secs.get("basic", {})
story += [
Spacer(1, 10*mm),
Paragraph("私のエンディングノート(デモ版)", title_style),
Spacer(1, 5),
Paragraph(f"作成日:{val(meta.get('created_at'))}", sub_style),
Paragraph(f"氏 名:{val(basic.get('name_kanji'))}", sub_style),
Spacer(1, 10*mm),
HRFlowable(width="100%", thickness=2, color=dark_green),
Spacer(1, 8*mm),
]
story += section_heading("基本情報")
story += field("氏名(漢字)", basic.get("name_kanji"))
story += field("氏名(ふりがな)", basic.get("name_kana"))
story += field("生年月日", basic.get("birthdate"))
story += field("出生地", basic.get("birthplace"))
story += field("本籍地", basic.get("honseki"))
story += field("現住所", basic.get("address"))
story += field("血液型", basic.get("blood_type"))
family = secs.get("family", {})
story += section_heading("家族構成・緊急連絡先")
for m in family.get("members", []):
story.append(Paragraph(f"{val(m.get('relation'))}{val(m.get('name'))}", body_style))
for e in family.get("emergency", []):
story.append(Paragraph(f"{val(e.get('name'))} Tel: {val(e.get('phone'))}", body_style))
assets = secs.get("assets", {})
story += section_heading("財産・負債")
for d in assets.get("deposits", []):
story.append(Paragraph(f"{val(d.get('bank'))} {val(d.get('branch'))}", body_style))
for dbt in assets.get("debts", []):
story.append(Paragraph(f"{val(dbt.get('type'))}{val(dbt.get('creditor'))}", body_style))
medical = secs.get("medical", {})
story += section_heading("医療・介護の希望")
story += field("延命治療", medical.get("life_sustaining"))
story += field("臓器提供", medical.get("organ_donation"))
funeral = secs.get("funeral", {})
story += section_heading("葬儀・供養の意向")
story += field("葬儀形式", funeral.get("style"))
story += field("お墓・納骨の希望", funeral.get("grave_type"))
ls = secs.get("life_story", {})
story += section_heading("自分史・メッセージ")
story += field("家族へのメッセージ", ls.get("family_message"))
doc.build(story)
# ============================================================
# アプリ起動(HF Spaces: port 7860)
# ============================================================
if __name__ == "__main__":
ensure_directories()
ensure_font()
app.run(host="0.0.0.0", port=7860, debug=False)