|
|
|
|
|
from flask import Flask, request, jsonify, g as flask_g |
|
|
from flask_cors import CORS |
|
|
import json |
|
|
import os |
|
|
import graphviz |
|
|
from datetime import datetime |
|
|
import re |
|
|
import uuid |
|
|
import random |
|
|
import google.generativeai as genai |
|
|
from collections import defaultdict |
|
|
import firebase_admin |
|
|
from firebase_admin import credentials, db, auth as firebase_auth |
|
|
from copy import deepcopy |
|
|
from dictdiffer import diff |
|
|
from functools import wraps |
|
|
|
|
|
|
|
|
app = Flask(__name__) |
|
|
CORS(app) |
|
|
|
|
|
|
|
|
DEFAULT_PROFILE = { |
|
|
"profile": {"name": "", "dob": "", "gender": "Unknown", "phone": ""}, |
|
|
"family_members": [], |
|
|
"relationships": [], |
|
|
"settings": {"theme": "Default", "privacy": "Private"}, |
|
|
"metadata": {"owner_phone": "", "tree_name": "My Family Tree", "created_at": ""} |
|
|
} |
|
|
DEFAULT_MEMBER_STRUCTURE = { |
|
|
"id": "", "name": "", "dob": "", "dod": "", "gender": "Unknown", |
|
|
"phone": "", "stories": [], "totem": "", |
|
|
"created_at": "", "created_by": "", |
|
|
"last_edited_at": "", "last_edited_by": "" |
|
|
} |
|
|
DEFAULT_STORY_STRUCTURE = { |
|
|
"timestamp": "", "text": "", "added_by": "" |
|
|
} |
|
|
GEMINI_MODEL_NAME = "gemini-2.0-flash" |
|
|
|
|
|
|
|
|
Firebase_DB_URL = os.environ.get("Firebase_DB") |
|
|
Firebase_Credentials_JSON_Str = os.environ.get("FIREBASE") |
|
|
GOOGLE_API_KEY = os.environ.get("Gemini") |
|
|
|
|
|
|
|
|
firebase_app = None |
|
|
firebase_db_ref = None |
|
|
firebase_error = None |
|
|
|
|
|
try: |
|
|
if Firebase_Credentials_JSON_Str and Firebase_DB_URL: |
|
|
credentials_json_parsed = json.loads(Firebase_Credentials_JSON_Str) |
|
|
if not firebase_admin._apps: |
|
|
cred = credentials.Certificate(credentials_json_parsed) |
|
|
firebase_app = firebase_admin.initialize_app(cred, {'databaseURL': Firebase_DB_URL}) |
|
|
firebase_db_ref = db.reference('/') |
|
|
app.logger.info("Firebase Admin SDK initialized successfully.") |
|
|
else: |
|
|
firebase_app = firebase_admin.get_app() |
|
|
firebase_db_ref = db.reference('/') |
|
|
app.logger.info("Firebase Admin SDK already initialized.") |
|
|
else: |
|
|
firebase_error = "Firebase secrets (Firebase_DB, FIREBASE) missing in environment variables." |
|
|
app.logger.error(firebase_error) |
|
|
except Exception as e: |
|
|
firebase_error = f"Error initializing Firebase: {e}" |
|
|
app.logger.error(firebase_error) |
|
|
|
|
|
|
|
|
genai_client = None |
|
|
api_key_error = False |
|
|
try: |
|
|
if not GOOGLE_API_KEY: |
|
|
api_key_error = True |
|
|
app.logger.error("GOOGLE_API_KEY missing in environment variables.") |
|
|
else: |
|
|
genai.configure(api_key=GOOGLE_API_KEY) |
|
|
genai_client = genai |
|
|
app.logger.info("Gemini API Client initialized successfully.") |
|
|
except Exception as e: |
|
|
api_key_error = True |
|
|
app.logger.error(f"Error initializing Gemini API Client: {e}") |
|
|
|
|
|
|
|
|
|
|
|
def get_uid_phone_mapping_path(uid): |
|
|
"""Get the Firebase path for storing user's phone number by UID""" |
|
|
return f'users_by_uid/{uid}' |
|
|
|
|
|
def get_user_phone_by_uid(uid): |
|
|
"""Look up a user's phone number from their UID""" |
|
|
if firebase_error or not firebase_db_ref: |
|
|
return None, firebase_error or "Firebase not initialized" |
|
|
try: |
|
|
path = get_uid_phone_mapping_path(uid) |
|
|
data = firebase_db_ref.child(path).get() |
|
|
if data and isinstance(data, dict): |
|
|
return data.get('phone_number'), None |
|
|
return None, "Phone number not linked for this user" |
|
|
except Exception as e: |
|
|
return None, f"Error looking up phone: {e}" |
|
|
|
|
|
def set_user_phone_by_uid(uid, phone_number, display_name=None, email=None): |
|
|
"""Store a user's phone number mapping by UID""" |
|
|
if firebase_error or not firebase_db_ref: |
|
|
return False, firebase_error or "Firebase not initialized" |
|
|
norm_phone = normalize_phone(phone_number) |
|
|
if not norm_phone: |
|
|
return False, "Invalid phone number format" |
|
|
try: |
|
|
path = get_uid_phone_mapping_path(uid) |
|
|
user_data = { |
|
|
'phone_number': norm_phone, |
|
|
'linked_at': datetime.now().strftime("%Y-%m-%d %H:%M:%S") |
|
|
} |
|
|
if display_name: |
|
|
user_data['display_name'] = display_name |
|
|
if email: |
|
|
user_data['email'] = email |
|
|
firebase_db_ref.child(path).set(user_data) |
|
|
return True, None |
|
|
except Exception as e: |
|
|
return False, f"Error saving phone mapping: {e}" |
|
|
|
|
|
|
|
|
|
|
|
def token_required(f): |
|
|
@wraps(f) |
|
|
def decorated_function(*args, **kwargs): |
|
|
if firebase_error: |
|
|
return jsonify({"error": "Authentication service not available", "detail": "Firebase Admin SDK not initialized."}), 503 |
|
|
|
|
|
auth_header = request.headers.get('Authorization') |
|
|
if not auth_header or not auth_header.startswith('Bearer '): |
|
|
return jsonify({"error": "Authorization token is missing or invalid"}), 401 |
|
|
|
|
|
id_token = auth_header.split('Bearer ')[1] |
|
|
try: |
|
|
decoded_token = firebase_auth.verify_id_token(id_token) |
|
|
uid = decoded_token.get('uid') |
|
|
email = decoded_token.get('email') |
|
|
|
|
|
|
|
|
phone_from_token = decoded_token.get('phone_number') |
|
|
|
|
|
if phone_from_token: |
|
|
|
|
|
phone_number = normalize_phone(phone_from_token) |
|
|
if not phone_number: |
|
|
app.logger.error(f"Failed to normalize phone number from token for UID {uid}.") |
|
|
return jsonify({"error": "Invalid phone number format in token."}), 403 |
|
|
flask_g.user = { |
|
|
"uid": uid, |
|
|
"email": email, |
|
|
"phone_number": phone_number, |
|
|
"phone_linked": True |
|
|
} |
|
|
else: |
|
|
|
|
|
phone_number, lookup_error = get_user_phone_by_uid(uid) |
|
|
if not phone_number: |
|
|
|
|
|
flask_g.user = { |
|
|
"uid": uid, |
|
|
"email": email, |
|
|
"phone_number": None, |
|
|
"phone_linked": False |
|
|
} |
|
|
else: |
|
|
flask_g.user = { |
|
|
"uid": uid, |
|
|
"email": email, |
|
|
"phone_number": phone_number, |
|
|
"phone_linked": True |
|
|
} |
|
|
|
|
|
except firebase_auth.ExpiredIdTokenError: |
|
|
return jsonify({"error": "Token has expired"}), 401 |
|
|
except firebase_auth.InvalidIdTokenError: |
|
|
return jsonify({"error": "Token is invalid"}), 401 |
|
|
except Exception as e: |
|
|
app.logger.error(f"Error during token verification: {e}") |
|
|
return jsonify({"error": "Could not verify token", "detail": str(e)}), 500 |
|
|
|
|
|
return f(*args, **kwargs) |
|
|
return decorated_function |
|
|
|
|
|
|
|
|
def phone_required(f): |
|
|
"""Decorator for endpoints that require a linked phone number""" |
|
|
@wraps(f) |
|
|
def decorated_function(*args, **kwargs): |
|
|
if not flask_g.user.get("phone_linked") or not flask_g.user.get("phone_number"): |
|
|
return jsonify({ |
|
|
"error": "Phone number not linked", |
|
|
"message": "Please link your phone number to access this feature.", |
|
|
"action_required": "link_phone" |
|
|
}), 403 |
|
|
return f(*args, **kwargs) |
|
|
return decorated_function |
|
|
|
|
|
|
|
|
|
|
|
def normalize_phone(phone): |
|
|
if not phone: return None |
|
|
phone_str = str(phone).strip() |
|
|
if not re.match(r"^\+\d{5,}$", phone_str): |
|
|
|
|
|
if re.match(r"^\d{10,}$", phone_str.replace("+", "")): |
|
|
digits_only = "".join(filter(str.isdigit, phone_str)) |
|
|
return f"+{digits_only}" |
|
|
return None |
|
|
digits = "".join(filter(str.isdigit, phone_str)) |
|
|
return f"+{digits}" |
|
|
|
|
|
def get_user_db_path(owner_phone): |
|
|
normalized = normalize_phone(owner_phone) |
|
|
if not normalized: return None |
|
|
return f'users/{normalized}' |
|
|
|
|
|
def get_phone_index_path(member_phone): |
|
|
normalized = normalize_phone(member_phone) |
|
|
if not normalized: return None |
|
|
return f'phone_index/{normalized}' |
|
|
|
|
|
def get_pending_changes_path(owner_phone, proposer_phone=None): |
|
|
norm_owner = normalize_phone(owner_phone) |
|
|
if not norm_owner: return None |
|
|
base_path = f'pending_changes/{norm_owner}' |
|
|
if proposer_phone: |
|
|
norm_proposer = normalize_phone(proposer_phone) |
|
|
if not norm_proposer: return None |
|
|
return f'{base_path}/{norm_proposer}' |
|
|
return base_path |
|
|
|
|
|
|
|
|
def load_tree_data(owner_phone_to_load): |
|
|
if firebase_error or not firebase_db_ref: |
|
|
return None, firebase_error or "Firebase not initialized" |
|
|
|
|
|
norm_owner_phone_to_load = normalize_phone(owner_phone_to_load) |
|
|
if not norm_owner_phone_to_load: |
|
|
return None, "Invalid owner phone format for loading data." |
|
|
|
|
|
user_path = get_user_db_path(norm_owner_phone_to_load) |
|
|
if not user_path: |
|
|
return None, "Invalid owner phone format for loading data (path generation failed)." |
|
|
try: |
|
|
data = firebase_db_ref.child(user_path).get() |
|
|
if data and isinstance(data, dict): |
|
|
merged_data = deepcopy(DEFAULT_PROFILE) |
|
|
if "metadata" not in data or not isinstance(data["metadata"], dict): data["metadata"] = {} |
|
|
merged_data.update(data) |
|
|
merged_data["settings"] = {**DEFAULT_PROFILE["settings"], **data.get("settings", {})} |
|
|
merged_data["metadata"] = {**DEFAULT_PROFILE["metadata"], **data.get("metadata", {})} |
|
|
merged_data["metadata"]["owner_phone"] = norm_owner_phone_to_load |
|
|
|
|
|
updated_members = [] |
|
|
for member_data in merged_data.get("family_members", []): |
|
|
if isinstance(member_data, dict): |
|
|
complete_member = deepcopy(DEFAULT_MEMBER_STRUCTURE) |
|
|
member_data.pop('photo_path', None) |
|
|
complete_member.update(member_data) |
|
|
updated_members.append(complete_member) |
|
|
merged_data["family_members"] = updated_members |
|
|
|
|
|
if not isinstance(merged_data.get("relationships"), list): merged_data["relationships"] = [] |
|
|
|
|
|
for member in merged_data["family_members"]: |
|
|
if "stories" not in member or not isinstance(member["stories"], list): member["stories"] = [] |
|
|
cleaned_stories = [] |
|
|
for story in member.get("stories", []): |
|
|
if isinstance(story, dict): |
|
|
complete_story = deepcopy(DEFAULT_STORY_STRUCTURE) |
|
|
complete_story.update(story) |
|
|
cleaned_stories.append(complete_story) |
|
|
member["stories"] = cleaned_stories |
|
|
return merged_data, None |
|
|
elif data is None: |
|
|
app.logger.info(f"No existing tree data for {norm_owner_phone_to_load}. Initializing new tree.") |
|
|
new_tree = deepcopy(DEFAULT_PROFILE) |
|
|
new_tree["metadata"]["owner_phone"] = norm_owner_phone_to_load |
|
|
new_tree["metadata"]["created_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") |
|
|
|
|
|
|
|
|
|
|
|
owner_me_node = deepcopy(DEFAULT_MEMBER_STRUCTURE) |
|
|
owner_me_node.update({ |
|
|
"id": "Me", "name": "Me", |
|
|
"phone": norm_owner_phone_to_load, |
|
|
"created_at": new_tree["metadata"]["created_at"], |
|
|
"created_by": norm_owner_phone_to_load |
|
|
}) |
|
|
new_tree["family_members"].append(owner_me_node) |
|
|
new_tree["profile"]["phone"] = norm_owner_phone_to_load |
|
|
|
|
|
|
|
|
initial_save_success, initial_save_error = save_tree_data(norm_owner_phone_to_load, new_tree, DEFAULT_PROFILE) |
|
|
if not initial_save_success: |
|
|
app.logger.error(f"Failed to save initial tree for {norm_owner_phone_to_load}: {initial_save_error}") |
|
|
return None, f"Failed to initialize and save new tree: {initial_save_error}" |
|
|
app.logger.info(f"Successfully initialized and saved new tree for {norm_owner_phone_to_load}.") |
|
|
return new_tree, None |
|
|
else: |
|
|
return None, f"Invalid data format found for tree owner {norm_owner_phone_to_load}." |
|
|
except Exception as e: |
|
|
app.logger.error(f"Error loading tree data for {norm_owner_phone_to_load}: {e}") |
|
|
return None, f"Error loading tree data: {e}" |
|
|
|
|
|
def find_linked_trees(member_phone): |
|
|
if firebase_error or not firebase_db_ref: return {}, firebase_error or "Firebase not initialized" |
|
|
index_path = get_phone_index_path(member_phone) |
|
|
if not index_path: return {}, "Invalid member phone for index query." |
|
|
try: |
|
|
owner_phones_dict = firebase_db_ref.child(index_path).child("trees").get() |
|
|
return owner_phones_dict if owner_phones_dict and isinstance(owner_phones_dict, dict) else {}, None |
|
|
except Exception as e: |
|
|
app.logger.error(f"Error querying phone index for {member_phone}: {e}") |
|
|
return {}, f"Error querying phone index: {e}" |
|
|
|
|
|
|
|
|
def update_phone_index(owner_phone_of_tree, previous_members, current_members): |
|
|
if firebase_error or not firebase_db_ref: |
|
|
return False, firebase_error or "Firebase not initialized" |
|
|
|
|
|
norm_owner_phone_of_tree = normalize_phone(owner_phone_of_tree) |
|
|
if not norm_owner_phone_of_tree: return False, "Invalid owner phone for index update." |
|
|
|
|
|
prev_phones = {normalize_phone(p.get('phone')) for p in previous_members if isinstance(p, dict) and normalize_phone(p.get('phone'))} |
|
|
curr_phones = {normalize_phone(p.get('phone')) for p in current_members if isinstance(p, dict) and normalize_phone(p.get('phone'))} |
|
|
|
|
|
phones_added = curr_phones - prev_phones |
|
|
phones_removed = prev_phones - curr_phones |
|
|
|
|
|
updates = {} |
|
|
for phone in phones_added: |
|
|
if phone: |
|
|
index_entry_path = f"{get_phone_index_path(phone)}/trees/{norm_owner_phone_of_tree}" |
|
|
updates[index_entry_path] = True |
|
|
for phone in phones_removed: |
|
|
if phone: |
|
|
index_entry_path = f"{get_phone_index_path(phone)}/trees/{norm_owner_phone_of_tree}" |
|
|
updates[index_entry_path] = None |
|
|
|
|
|
if not updates: return True, None |
|
|
try: |
|
|
firebase_db_ref.update(updates) |
|
|
return True, None |
|
|
except Exception as e: |
|
|
app.logger.error(f"Error updating phone index: {e}. Updates: {json.dumps(updates)}") |
|
|
return False, f"Error updating phone index: {e}" |
|
|
|
|
|
|
|
|
def save_tree_data(owner_phone_of_tree, current_data, previous_data): |
|
|
if firebase_error or not firebase_db_ref: |
|
|
return False, firebase_error or "Firebase not initialized. Cannot save." |
|
|
|
|
|
norm_owner_phone_of_tree = normalize_phone(owner_phone_of_tree) |
|
|
if not norm_owner_phone_of_tree: |
|
|
return False, "Invalid owner phone format for saving." |
|
|
|
|
|
user_path = get_user_db_path(norm_owner_phone_of_tree) |
|
|
if not user_path: |
|
|
return False, "Failed to generate user DB path for saving." |
|
|
|
|
|
|
|
|
update_totems(current_data) |
|
|
current_data["metadata"]["owner_phone"] = norm_owner_phone_of_tree |
|
|
if "family_members" in current_data: |
|
|
for member in current_data["family_members"]: |
|
|
if isinstance(member, dict): member.pop('photo_path', None) |
|
|
|
|
|
try: |
|
|
firebase_db_ref.child(user_path).set(current_data) |
|
|
except Exception as e: |
|
|
app.logger.error(f"Error saving tree data to {user_path}: {e}") |
|
|
return False, f"Error saving tree data: {e}" |
|
|
|
|
|
prev_members = previous_data.get("family_members", []) if previous_data and isinstance(previous_data, dict) else [] |
|
|
curr_members = current_data.get("family_members", []) |
|
|
index_success, index_error = update_phone_index(norm_owner_phone_of_tree, prev_members, curr_members) |
|
|
if not index_success: |
|
|
app.logger.warning(f"Tree data saved for {norm_owner_phone_of_tree}, but failed to update phone index: {index_error}") |
|
|
return True, f"Tree data saved, but phone index update failed: {index_error}" |
|
|
return True, None |
|
|
|
|
|
|
|
|
|
|
|
def propose_changes(owner_phone_of_tree, proposer_phone_from_token, proposed_data): |
|
|
if firebase_error or not firebase_db_ref: |
|
|
return False, firebase_error or "Firebase not initialized. Cannot propose." |
|
|
|
|
|
norm_owner_phone_of_tree = normalize_phone(owner_phone_of_tree) |
|
|
norm_proposer_phone = normalize_phone(proposer_phone_from_token) |
|
|
|
|
|
if not norm_owner_phone_of_tree or not norm_proposer_phone: |
|
|
return False, "Invalid owner or proposer phone format for proposing changes." |
|
|
|
|
|
pending_path = get_pending_changes_path(norm_owner_phone_of_tree, norm_proposer_phone) |
|
|
if not pending_path: |
|
|
return False, "Failed to generate pending changes path." |
|
|
|
|
|
proposal_payload = { |
|
|
"proposed_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), |
|
|
"proposer_phone": norm_proposer_phone, |
|
|
"tree_data": proposed_data |
|
|
} |
|
|
try: |
|
|
firebase_db_ref.child(pending_path).set(proposal_payload) |
|
|
return True, None |
|
|
except Exception as e: |
|
|
app.logger.error(f"Error proposing changes to {pending_path}: {e}") |
|
|
return False, f"Error proposing changes: {e}" |
|
|
|
|
|
|
|
|
def load_pending_changes(owner_phone_from_token): |
|
|
if firebase_error or not firebase_db_ref: return {}, firebase_error or "Firebase not initialized" |
|
|
|
|
|
norm_owner_phone = normalize_phone(owner_phone_from_token) |
|
|
if not norm_owner_phone: return {}, "Invalid owner phone for loading pending changes." |
|
|
|
|
|
pending_base_path = get_pending_changes_path(norm_owner_phone) |
|
|
if not pending_base_path: return {}, "Failed to generate pending base path." |
|
|
try: |
|
|
data = firebase_db_ref.child(pending_base_path).get() |
|
|
return data if data and isinstance(data, dict) else {}, None |
|
|
except Exception as e: |
|
|
app.logger.error(f"Error loading pending changes for {norm_owner_phone}: {e}") |
|
|
return {}, f"Error loading pending changes: {e}" |
|
|
|
|
|
|
|
|
def accept_changes(owner_phone_from_token, proposer_phone_to_accept): |
|
|
if firebase_error or not firebase_db_ref: return False, firebase_error or "Firebase not initialized" |
|
|
|
|
|
norm_owner_phone = normalize_phone(owner_phone_from_token) |
|
|
norm_proposer_phone = normalize_phone(proposer_phone_to_accept) |
|
|
|
|
|
if not norm_owner_phone or not norm_proposer_phone: |
|
|
return False, "Invalid owner or proposer phone for accepting changes." |
|
|
|
|
|
pending_path = get_pending_changes_path(norm_owner_phone, norm_proposer_phone) |
|
|
if not pending_path: return False, "Failed to generate pending path for accept." |
|
|
|
|
|
try: |
|
|
proposal_payload = firebase_db_ref.child(pending_path).get() |
|
|
if not proposal_payload or "tree_data" not in proposal_payload: |
|
|
return False, f"Proposal from {norm_proposer_phone} not found or invalid." |
|
|
accepted_data = proposal_payload["tree_data"] |
|
|
|
|
|
previous_data, load_err = load_tree_data(norm_owner_phone) |
|
|
if load_err: |
|
|
return False, f"Failed to load current owner data before accepting: {load_err}" |
|
|
|
|
|
save_success, save_error = save_tree_data(norm_owner_phone, accepted_data, previous_data) |
|
|
if save_success: |
|
|
firebase_db_ref.child(pending_path).delete() |
|
|
return True, None |
|
|
else: |
|
|
return False, f"Failed to save accepted changes or update index: {save_error}. Proposal not deleted." |
|
|
except Exception as e: |
|
|
app.logger.error(f"Error accepting changes from {norm_proposer_phone}: {e}") |
|
|
return False, f"Error accepting changes: {e}" |
|
|
|
|
|
|
|
|
def reject_changes(owner_phone_from_token, proposer_phone_to_reject): |
|
|
if firebase_error or not firebase_db_ref: return False, firebase_error or "Firebase not initialized" |
|
|
|
|
|
norm_owner_phone = normalize_phone(owner_phone_from_token) |
|
|
norm_proposer_phone = normalize_phone(proposer_phone_to_reject) |
|
|
|
|
|
if not norm_owner_phone or not norm_proposer_phone: |
|
|
return False, "Invalid owner or proposer phone for rejecting." |
|
|
|
|
|
pending_path = get_pending_changes_path(norm_owner_phone, norm_proposer_phone) |
|
|
if not pending_path: return False, "Failed to generate pending path for reject." |
|
|
try: |
|
|
firebase_db_ref.child(pending_path).delete() |
|
|
return True, None |
|
|
except Exception as e: |
|
|
app.logger.error(f"Error rejecting changes from {norm_proposer_phone}: {e}") |
|
|
return False, f"Error rejecting changes: {e}" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def find_person_by_id(data, person_id): |
|
|
for person in data.get("family_members", []): |
|
|
if isinstance(person, dict) and person.get("id") == person_id: return person |
|
|
return None |
|
|
|
|
|
def find_person_by_name(data, name): |
|
|
if not name: return None |
|
|
normalized_name = name.strip().lower() |
|
|
return [ |
|
|
person for person in data.get("family_members", []) |
|
|
if isinstance(person, dict) and person.get("name", "").strip().lower() == normalized_name |
|
|
] |
|
|
|
|
|
def generate_unique_id(data=None): return uuid.uuid4().hex |
|
|
|
|
|
|
|
|
|
|
|
def format_node_label(person): |
|
|
label = f"{person.get('name', 'Unknown')}" |
|
|
details = [] |
|
|
if person.get('dob'): details.append(f"b. {person.get('dob')}") |
|
|
if person.get('dod'): details.append(f"d. {person.get('dod')}") |
|
|
if details: label += f"\n({' / '.join(details)})" |
|
|
if person.get('totem'): label += f"\nTotem: {person.get('totem')}" |
|
|
return label |
|
|
|
|
|
def get_father_id(person_id, relationships, members): |
|
|
id_to_person = {p['id']: p for p in members if isinstance(p, dict)} |
|
|
for rel in relationships: |
|
|
if isinstance(rel, dict) and rel.get('type') == 'parent' and rel.get('to_id') == person_id: |
|
|
parent_id = rel.get('from_id') |
|
|
parent = id_to_person.get(parent_id) |
|
|
if parent and parent.get('gender', '').lower() == 'male': |
|
|
return parent_id |
|
|
return None |
|
|
|
|
|
def update_totems(user_data): |
|
|
members = user_data.get("family_members", []) |
|
|
relationships = user_data.get("relationships", []) |
|
|
if not members or not relationships: return |
|
|
id_to_person = {p['id']: p for p in members if isinstance(p, dict)} |
|
|
visited = set(); processed_in_queue = set() |
|
|
all_child_ids = {r['to_id'] for r in relationships if isinstance(r, dict) and r.get('type') == 'parent'} |
|
|
queue = [p['id'] for p in members if isinstance(p, dict) and p['id'] not in all_child_ids] |
|
|
queue.extend([p['id'] for p in members if isinstance(p, dict) and p['id'] not in queue]) |
|
|
processed_count = 0 |
|
|
max_process = len(members) * 2 + 5 |
|
|
while queue and processed_count < max_process: |
|
|
person_id = queue.pop(0) |
|
|
processed_count += 1 |
|
|
if person_id in visited: continue |
|
|
person = id_to_person.get(person_id) |
|
|
if not person: continue |
|
|
totem_updated = False |
|
|
current_totem = person.get('totem') |
|
|
if current_totem is None or current_totem == "": |
|
|
father_id = get_father_id(person_id, relationships, members) |
|
|
if father_id: |
|
|
father = id_to_person.get(father_id) |
|
|
if father and father.get('totem') and (father_id in visited or father_id not in all_child_ids): |
|
|
person['totem'] = father.get('totem') |
|
|
totem_updated = True |
|
|
visited.add(person_id) |
|
|
for rel in relationships: |
|
|
if isinstance(rel, dict) and rel.get('type') == 'parent' and rel.get('from_id') == person_id: |
|
|
child_id = rel.get('to_id') |
|
|
if child_id and child_id not in visited and child_id not in queue: |
|
|
queue.append(child_id) |
|
|
if totem_updated: |
|
|
for rel in relationships: |
|
|
if isinstance(rel, dict) and rel.get('type') == 'parent' and rel.get('from_id') == person_id: |
|
|
child_id = rel.get('to_id') |
|
|
if child_id and child_id in visited: |
|
|
visited.remove(child_id) |
|
|
if child_id not in queue: queue.append(child_id) |
|
|
elif child_id and child_id not in queue: |
|
|
queue.append(child_id) |
|
|
if processed_count >= max_process: |
|
|
app.logger.warning("WARN: Totem update BFS reached max iterations, potential loop?") |
|
|
|
|
|
|
|
|
def generate_graphviz_object(data): |
|
|
dot = graphviz.Digraph(comment='Family Tree', format='svg') |
|
|
dot.attr(rankdir='TB', splines='ortho', nodesep='0.6', ranksep='0.8') |
|
|
theme = data.get("settings", {}).get("theme", "Default") |
|
|
node_style = {'shape': 'box', 'style': 'filled', 'margin': '0.1'} |
|
|
edge_color, marriage_node_color = 'gray50', 'gray50' |
|
|
if theme == "Dark": |
|
|
dot.attr(bgcolor='black', fontcolor='white'); node_style.update(fillcolor='grey30', fontcolor='white', color='white'); edge_color, marriage_node_color = 'white', 'white' |
|
|
else: dot.attr(bgcolor='transparent'); node_style.update(fillcolor='lightblue', fontcolor='black', color='black') |
|
|
members = data.get("family_members", []); relationships = data.get("relationships", []) |
|
|
if not members: return dot, "No members to graph" |
|
|
member_ids = {m['id'] for m in members if isinstance(m, dict)}; id_to_person = {p['id']: p for p in members if isinstance(p, dict)} |
|
|
marriage_nodes = {}; parents_of_child = defaultdict(list); children_by_parent_pair = defaultdict(list) |
|
|
for person in members: |
|
|
if isinstance(person, dict): dot.node(person['id'], label=format_node_label(person), **node_style) |
|
|
processed_spouses = set() |
|
|
for rel in relationships: |
|
|
if not isinstance(rel, dict): continue |
|
|
from_id, to_id, rel_type = rel.get("from_id"), rel.get("to_id"), rel.get("type") |
|
|
if from_id not in member_ids or to_id not in member_ids: continue |
|
|
if rel_type == 'parent': parents_of_child[to_id].append(from_id) |
|
|
elif rel_type == 'spouse': |
|
|
pair = frozenset([from_id, to_id]) |
|
|
if pair not in processed_spouses: |
|
|
m_id = f"m_{uuid.uuid4().hex[:8]}"; marriage_nodes[pair] = m_id |
|
|
dot.node(m_id, shape='point', width='0.1', height='0.1', label='', color=marriage_node_color) |
|
|
dot.edge(from_id, m_id, style='invis', dir='none', weight='10') |
|
|
dot.edge(to_id, m_id, style='invis', dir='none', weight='10') |
|
|
with dot.subgraph(name=f"cluster_m_{m_id}") as sub: |
|
|
sub.attr(rank='same', style='invis') |
|
|
sub.node(from_id); sub.node(to_id) |
|
|
processed_spouses.add(pair) |
|
|
for child_id, parent_ids_list in parents_of_child.items(): |
|
|
valid_p_ids = sorted([p_id for p_id in parent_ids_list if p_id in member_ids]); |
|
|
if not valid_p_ids: continue |
|
|
parent_key = frozenset(valid_p_ids); children_by_parent_pair[parent_key].append(child_id) |
|
|
processed_child_links = set() |
|
|
for parent_key, children_list in children_by_parent_pair.items(): |
|
|
p_ids = list(parent_key); source_id = None |
|
|
if len(p_ids) == 1: source_id = p_ids[0] |
|
|
elif len(p_ids) > 1: source_id = marriage_nodes.get(parent_key) |
|
|
if source_id: |
|
|
for child_id in children_list: |
|
|
if child_id in member_ids and (source_id, child_id) not in processed_child_links: |
|
|
dot.edge(source_id, child_id, arrowhead='none', color=edge_color); processed_child_links.add((source_id, child_id)) |
|
|
elif len(p_ids) > 1 and not source_id: |
|
|
app.logger.warning(f"WARN: Missing marriage node for parents {p_ids}, linking child {children_list} from first parent.") |
|
|
source_id = p_ids[0] |
|
|
for child_id in children_list: |
|
|
if child_id in member_ids and (source_id, child_id) not in processed_child_links: |
|
|
dot.edge(source_id, child_id, arrowhead='none', color=edge_color); processed_child_links.add((source_id, child_id)) |
|
|
for parent_key, siblings in children_by_parent_pair.items(): |
|
|
valid_sibs = [sid for sid in siblings if sid in member_ids] |
|
|
if len(valid_sibs) > 1: |
|
|
with dot.subgraph(name=f"cluster_s_{uuid.uuid4().hex[:8]}") as sub: |
|
|
sub.attr(rank='same', style='invis') |
|
|
for sid in sorted(valid_sibs): sub.node(sid) |
|
|
return dot, None |
|
|
|
|
|
|
|
|
def safe_json_loads(text): |
|
|
match = re.search(r"```(?:json)?\s*(\{.*\}|\[.*\])\s*```", text, re.DOTALL | re.IGNORECASE) |
|
|
if match: json_text = match.group(1) |
|
|
else: |
|
|
start_brace, start_bracket = text.find('{'), text.find('[') |
|
|
end_brace, end_bracket = text.rfind('}'), text.rfind(']') |
|
|
start = min(start_brace, start_bracket) if start_brace != -1 and start_bracket != -1 else (start_brace if start_brace != -1 else start_bracket) |
|
|
end = -1 |
|
|
if start == start_brace and end_brace != -1: end = end_brace |
|
|
elif start == start_bracket and end_bracket != -1: end = end_bracket |
|
|
elif end_brace != -1 and end_bracket != -1: end = max(end_brace, end_bracket) |
|
|
else: end = end_brace if end_brace != -1 else end_bracket |
|
|
if start != -1 and end != -1 and end > start: json_text = text[start:end+1] |
|
|
else: |
|
|
json_text = text.strip() |
|
|
if not ((json_text.startswith('{') and json_text.endswith('}')) or \ |
|
|
(json_text.startswith('[') and json_text.endswith(']'))): |
|
|
return None, f"Could not find JSON structure. Response: {text[:500]}..." |
|
|
json_text = re.sub(r",\s*(\]|\})", r"\1", json_text) |
|
|
try: |
|
|
return json.loads(json_text), None |
|
|
except json.JSONDecodeError as e: |
|
|
return None, f"Error parsing JSON: {e}. Attempted JSON: {json_text[:500]}..." |
|
|
except Exception as e: |
|
|
return None, f"Unexpected error during JSON parsing: {e}. Response: {text[:500]}..." |
|
|
|
|
|
def call_gemini(prompt_text, is_json_output_expected=True): |
|
|
if not genai_client or api_key_error: |
|
|
return None, "Gemini API not ready or API key error." |
|
|
try: |
|
|
model = genai.GenerativeModel(GEMINI_MODEL_NAME) |
|
|
safety_settings = [ |
|
|
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_MEDIUM_AND_ABOVE"}, |
|
|
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_MEDIUM_AND_ABOVE"}, |
|
|
{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_MEDIUM_AND_ABOVE"}, |
|
|
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_MEDIUM_AND_ABOVE"}, |
|
|
] |
|
|
|
|
|
|
|
|
gen_config = None |
|
|
if is_json_output_expected: |
|
|
gen_config = genai.types.GenerationConfig( |
|
|
temperature=0.2, |
|
|
top_p=0.8, |
|
|
top_k=40, |
|
|
max_output_tokens=2048, |
|
|
) |
|
|
|
|
|
response = model.generate_content( |
|
|
prompt_text, |
|
|
generation_config=gen_config, |
|
|
safety_settings=safety_settings |
|
|
) |
|
|
|
|
|
if not response.parts: |
|
|
reason = "Unknown" |
|
|
try: |
|
|
reason = response.prompt_feedback.block_reason.name if hasattr(response, 'prompt_feedback') and response.prompt_feedback.block_reason else None |
|
|
if not reason and response.candidates: |
|
|
reason = response.candidates[0].finish_reason.name |
|
|
except Exception: |
|
|
pass |
|
|
return None, f"AI response empty or blocked. Reason: {reason}" |
|
|
|
|
|
if not hasattr(response, 'text') or not response.text: |
|
|
return None, "AI response text empty." |
|
|
|
|
|
if is_json_output_expected: |
|
|
|
|
|
response_text = response.text.strip() |
|
|
|
|
|
if response_text.startswith('```json'): |
|
|
response_text = response_text[7:] |
|
|
if response_text.startswith('```'): |
|
|
response_text = response_text[3:] |
|
|
if response_text.endswith('```'): |
|
|
response_text = response_text[:-3] |
|
|
response_text = response_text.strip() |
|
|
|
|
|
parsed_json, err = safe_json_loads(response_text) |
|
|
if err: |
|
|
return None, f"AI response received, but failed to parse as JSON: {err}. Raw: {response_text[:500]}..." |
|
|
return parsed_json, None |
|
|
else: |
|
|
return response_text, None |
|
|
|
|
|
except Exception as e: |
|
|
app.logger.error(f"Gemini API Call Error: {e} (Type: {type(e).__name__})") |
|
|
return None, f"Gemini API Call Error: {e}" |
|
|
|
|
|
|
|
|
def generate_tree_from_description_gemini(description): |
|
|
prompt = f"""Please analyze the following family description. Extract individuals mentioned, including their name, and if available, their date of birth (dob), date of death (dod), gender (Male/Female/Other/Unknown), and totem. Also, identify direct relationships like parent_of, spouse_of, or sibling_of between these individuals. |
|
|
|
|
|
Format the output as a single JSON object containing two keys: |
|
|
1. "people": A list of objects, where each object represents an individual and has keys for "name", "dob", "dod", "gender", and "totem" (use null or omit if information is not present). Use the name "Me" if the description uses first-person references without providing a name. |
|
|
2. "relationships": A list of objects, where each object represents a relationship. Each relationship object should have "person1_name", "person2_name", and "type" (e.g., "parent_of", "spouse_of", "sibling_of"). Ensure the names match exactly those extracted in the "people" list. |
|
|
|
|
|
Strictly output only the JSON object, without any introductory text, explanations, or markdown formatting like ```json. |
|
|
|
|
|
Description: |
|
|
"{description}" |
|
|
|
|
|
JSON Output: |
|
|
""" |
|
|
return call_gemini(prompt, is_json_output_expected=True) |
|
|
|
|
|
|
|
|
|
|
|
def generate_quiz_questions_gemini(members, relationships, num_questions=3): |
|
|
if not members or len(members) < 2: return None, "Need at least 2 members for a quiz." |
|
|
id_to_name = {p["id"]: p.get("name", "Unknown") for p in members if isinstance(p, dict)} |
|
|
rel_strings, processed_pairs = [], set() |
|
|
for rel in relationships: |
|
|
if not isinstance(rel, dict): continue |
|
|
p1_id, p2_id, rt = rel.get("from_id"), rel.get("to_id"), rel.get("type") |
|
|
p1n, p2n = id_to_name.get(p1_id), id_to_name.get(p2_id) |
|
|
if p1n and p2n and p1n != p2n: |
|
|
pair = frozenset([p1_id, p2_id]) |
|
|
if rt == "parent": rel_strings.append(f"'{p1n}' is a Parent of '{p2n}'") |
|
|
elif rt == "spouse" and pair not in processed_pairs: rel_strings.append(f"'{p1n}' is a Spouse of '{p2n}'"); processed_pairs.add(pair) |
|
|
elif rt == "sibling" and pair not in processed_pairs: rel_strings.append(f"'{p1n}' is a Sibling of '{p2n}'"); processed_pairs.add(pair) |
|
|
if not rel_strings: return None, "No relationships found to base quiz questions on." |
|
|
unique_names = set(id_to_name.values()) |
|
|
min_required_names, actual_num_questions = 4, num_questions |
|
|
if len(unique_names) < min_required_names: |
|
|
if len(unique_names) >= 2: actual_num_questions = max(1, len(unique_names) - 1) |
|
|
else: return None, "Need at least 2 unique names in the tree to generate a quiz." |
|
|
prompt = f"""Based *only* on the provided list of family relationships and names, generate {actual_num_questions} unique multiple-choice quiz questions. |
|
|
|
|
|
Each question should test knowledge about one specific relationship. |
|
|
The output format must be a single JSON list, where each element is an object representing a question: |
|
|
`{{"text": "Question text?", "options": ["Correct Answer", "Wrong Option 1", "Wrong Option 2", "Wrong Option 3"], "correct": "Correct Answer"}}` |
|
|
|
|
|
- The "options" list must contain the correct answer and 3 incorrect answers chosen from the provided 'Names' list. |
|
|
- All options within a single question must be unique names. |
|
|
- If fewer than 4 unique names are available in the 'Names' list, use all available names as options, ensuring the correct answer is included. |
|
|
- Do not invent relationships or information not present in the 'Relationships' list. |
|
|
- Ensure the 'correct' value exactly matches one of the strings in the 'options' list. |
|
|
- Strictly output only the JSON list, without any other text or markdown formatting. |
|
|
|
|
|
Names: |
|
|
{chr(10).join(f"- {name}" for name in sorted(list(unique_names)))} |
|
|
|
|
|
Relationships: |
|
|
{chr(10).join(f"- {rel}" for rel in rel_strings)} |
|
|
|
|
|
JSON Output: |
|
|
""" |
|
|
return call_gemini(prompt, is_json_output_expected=True) |
|
|
|
|
|
def generate_diff_summary(current_data, proposed_data): |
|
|
summary = [] |
|
|
try: |
|
|
|
|
|
result = list(diff(current_data, proposed_data, ignore={'last_edited_at', 'last_edited_by', 'created_at', 'created_by'})) |
|
|
|
|
|
|
|
|
current_members = {m['id']: m for m in current_data.get('family_members', []) if isinstance(m, dict)} |
|
|
proposed_members = {m['id']: m for m in proposed_data.get('family_members', []) if isinstance(m, dict)} |
|
|
added_members = set(proposed_members.keys()) - set(current_members.keys()) |
|
|
deleted_members = set(current_members.keys()) - set(proposed_members.keys()) |
|
|
common_members = set(current_members.keys()) & set(proposed_members.keys()) |
|
|
|
|
|
if added_members: |
|
|
summary.append("**Members Added:**") |
|
|
for mid in added_members: |
|
|
summary.append(f"- {proposed_members[mid].get('name', 'Unnamed')} (ID: ...{mid[-6:]})") |
|
|
if deleted_members: |
|
|
summary.append("**Members Deleted:**") |
|
|
for mid in deleted_members: |
|
|
summary.append(f"- {current_members[mid].get('name', 'Unnamed')} (ID: ...{mid[-6:]})") |
|
|
|
|
|
|
|
|
edited_summary = [] |
|
|
for mid in common_members: |
|
|
curr_m, prop_m = current_members[mid], proposed_members[mid] |
|
|
changes = [] |
|
|
for key in ['name', 'dob', 'dod', 'gender', 'totem', 'phone']: |
|
|
if curr_m.get(key) != prop_m.get(key): |
|
|
changes.append(f"{key}: '{curr_m.get(key)}' -> '{prop_m.get(key)}'") |
|
|
|
|
|
curr_stories = set(s.get('timestamp') for s in curr_m.get('stories', []) if isinstance(s, dict)) |
|
|
prop_stories = set(s.get('timestamp') for s in prop_m.get('stories', []) if isinstance(s, dict)) |
|
|
if curr_stories != prop_stories: |
|
|
added_s = len(prop_stories - curr_stories) |
|
|
removed_s = len(curr_stories - prop_stories) |
|
|
if added_s > 0: changes.append(f"{added_s} storie(s) added") |
|
|
if removed_s > 0: changes.append(f"{removed_s} storie(s) removed") |
|
|
if changes: |
|
|
edited_summary.append(f"- {curr_m.get('name', 'Unnamed')} (ID: ...{mid[-6:]}): {', '.join(changes)}") |
|
|
if edited_summary: |
|
|
summary.append("**Members Edited:**") |
|
|
summary.extend(edited_summary) |
|
|
|
|
|
|
|
|
curr_rels = set(tuple(sorted([r['from_id'],r['to_id']])) + (r['type'],) for r in current_data.get('relationships', []) if isinstance(r, dict) and all(k in r for k in ['from_id', 'to_id', 'type'])) |
|
|
prop_rels = set(tuple(sorted([r['from_id'],r['to_id']])) + (r['type'],) for r in proposed_data.get('relationships', []) if isinstance(r, dict) and all(k in r for k in ['from_id', 'to_id', 'type'])) |
|
|
added_rels = prop_rels - curr_rels |
|
|
deleted_rels = curr_rels - prop_rels |
|
|
|
|
|
if added_rels: |
|
|
summary.append("**Relationships Added:**") |
|
|
for rel_tuple in added_rels: |
|
|
name1 = proposed_members.get(rel_tuple[0], {}).get('name', f'...{rel_tuple[0][-6:]}') |
|
|
name2 = proposed_members.get(rel_tuple[1], {}).get('name', f'...{rel_tuple[1][-6:]}') |
|
|
summary.append(f"- {name1} <--> {name2} ({rel_tuple[2]})") |
|
|
if deleted_rels: |
|
|
summary.append("**Relationships Deleted:**") |
|
|
for rel_tuple in deleted_rels: |
|
|
name1 = current_members.get(rel_tuple[0], {}).get('name', f'...{rel_tuple[0][-6:]}') |
|
|
name2 = current_members.get(rel_tuple[1], {}).get('name', f'...{rel_tuple[1][-6:]}') |
|
|
summary.append(f"- {name1} <--> {name2} ({rel_tuple[2]})") |
|
|
|
|
|
|
|
|
settings_changes = [] |
|
|
if current_data.get('settings', {}).get('theme') != proposed_data.get('settings', {}).get('theme'): |
|
|
settings_changes.append(f"Theme: '{current_data.get('settings', {}).get('theme')}' -> '{proposed_data.get('settings', {}).get('theme')}'") |
|
|
if current_data.get('metadata', {}).get('tree_name') != proposed_data.get('metadata', {}).get('tree_name'): |
|
|
settings_changes.append(f"Tree Name: '{current_data.get('metadata', {}).get('tree_name')}' -> '{proposed_data.get('metadata', {}).get('tree_name')}'") |
|
|
if settings_changes: |
|
|
summary.append("**Settings/Metadata Changed:**") |
|
|
summary.extend([f"- {c}" for c in settings_changes]) |
|
|
|
|
|
if not summary: return "No significant changes detected." |
|
|
return "\n".join(summary) |
|
|
except Exception as e: |
|
|
app.logger.error(f"Error generating diff summary: {e}") |
|
|
return f"Error generating diff summary: {e}" |
|
|
|
|
|
|
|
|
|
|
|
def check_system_health(): |
|
|
if firebase_error: |
|
|
return jsonify({"error": "Firebase not available", "detail": firebase_error}), 503 |
|
|
if not firebase_db_ref: |
|
|
return jsonify({"error": "Firebase database reference not set."}), 503 |
|
|
return None |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/health', methods=['GET']) |
|
|
def health_check(): |
|
|
status = { |
|
|
"status": "ok", |
|
|
"firebase": "ok" if not firebase_error and firebase_db_ref else f"error: {firebase_error or 'DB ref not set'}", |
|
|
"gemini_api": "ok" if not api_key_error and genai_client else "error or not configured" |
|
|
} |
|
|
overall_ok = status["firebase"] == "ok" |
|
|
return jsonify(status), 200 if overall_ok else 503 |
|
|
|
|
|
|
|
|
|
|
|
@app.route('/user/me', methods=['GET']) |
|
|
@token_required |
|
|
def get_current_user_api(): |
|
|
"""Get the authenticated user's information""" |
|
|
uid = flask_g.user["uid"] |
|
|
email = flask_g.user.get("email") |
|
|
phone_number = flask_g.user.get("phone_number") |
|
|
phone_linked = flask_g.user.get("phone_linked", False) |
|
|
|
|
|
response = { |
|
|
"uid": uid, |
|
|
"email": email, |
|
|
"phone_number": phone_number, |
|
|
"phone_linked": phone_linked |
|
|
} |
|
|
|
|
|
|
|
|
if phone_linked and phone_number: |
|
|
tree_data, _ = load_tree_data(phone_number) |
|
|
if tree_data: |
|
|
response["display_name"] = tree_data.get("profile", {}).get("name") |
|
|
response["primary_tree"] = { |
|
|
"owner_phone": phone_number, |
|
|
"tree_name": tree_data.get("metadata", {}).get("tree_name", "My Family Tree"), |
|
|
"created_at": tree_data.get("metadata", {}).get("created_at") |
|
|
} |
|
|
else: |
|
|
|
|
|
try: |
|
|
path = get_uid_phone_mapping_path(uid) |
|
|
user_data = firebase_db_ref.child(path).get() |
|
|
if user_data and isinstance(user_data, dict): |
|
|
response["display_name"] = user_data.get("display_name") |
|
|
except Exception: |
|
|
pass |
|
|
|
|
|
return jsonify(response), 200 |
|
|
|
|
|
|
|
|
@app.route('/user/link_phone', methods=['POST']) |
|
|
@token_required |
|
|
def link_phone_api(): |
|
|
"""Link a phone number to the authenticated user's account""" |
|
|
uid = flask_g.user["uid"] |
|
|
email = flask_g.user.get("email") |
|
|
|
|
|
|
|
|
if flask_g.user.get("phone_linked"): |
|
|
return jsonify({ |
|
|
"message": "Phone number already linked", |
|
|
"phone_number": flask_g.user.get("phone_number") |
|
|
}), 200 |
|
|
|
|
|
req_data = request.json |
|
|
if not req_data or "phone_number" not in req_data: |
|
|
return jsonify({"error": "Missing phone_number in request body"}), 400 |
|
|
|
|
|
phone_number = req_data["phone_number"] |
|
|
display_name = req_data.get("display_name") |
|
|
|
|
|
|
|
|
norm_phone = normalize_phone(phone_number) |
|
|
if not norm_phone: |
|
|
return jsonify({"error": "Invalid phone number format. Use +263... format."}), 400 |
|
|
|
|
|
|
|
|
try: |
|
|
all_users = firebase_db_ref.child('users_by_uid').get() |
|
|
if all_users: |
|
|
for existing_uid_key, user_data in all_users.items(): |
|
|
if isinstance(user_data, dict) and user_data.get('phone_number') == norm_phone: |
|
|
if existing_uid_key != uid: |
|
|
return jsonify({ |
|
|
"error": "Phone number already linked", |
|
|
"message": "This phone number is already linked to another account." |
|
|
}), 409 |
|
|
except Exception as e: |
|
|
app.logger.warning(f"Could not check for existing phone links: {e}") |
|
|
|
|
|
|
|
|
success, error = set_user_phone_by_uid(uid, norm_phone, display_name, email) |
|
|
if not success: |
|
|
return jsonify({"error": "Failed to link phone number", "detail": error}), 500 |
|
|
|
|
|
|
|
|
tree_data, load_error = load_tree_data(norm_phone) |
|
|
if load_error and "Failed to initialize" in load_error: |
|
|
return jsonify({"error": "Phone linked but tree initialization failed", "detail": load_error}), 500 |
|
|
|
|
|
|
|
|
if display_name and tree_data: |
|
|
tree_data["profile"]["name"] = display_name |
|
|
me_node = find_person_by_id(tree_data, "Me") |
|
|
if me_node: |
|
|
me_node["name"] = display_name |
|
|
me_node["last_edited_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") |
|
|
me_node["last_edited_by"] = norm_phone |
|
|
save_tree_data(norm_phone, tree_data, tree_data) |
|
|
|
|
|
return jsonify({ |
|
|
"message": "Phone number linked successfully", |
|
|
"phone_number": norm_phone, |
|
|
"tree_initialized": tree_data is not None |
|
|
}), 200 |
|
|
|
|
|
|
|
|
|
|
|
@app.route('/tree/<string:owner_phone>', methods=['GET']) |
|
|
def get_tree_data_api(owner_phone): |
|
|
system_error_response = check_system_health() |
|
|
if system_error_response: return system_error_response |
|
|
norm_phone = normalize_phone(owner_phone) |
|
|
if not norm_phone: return jsonify({"error": "Invalid owner_phone format"}), 400 |
|
|
|
|
|
tree_data, error = load_tree_data(norm_phone) |
|
|
if error: |
|
|
if tree_data is None and "Failed to initialize" not in error : |
|
|
return jsonify({"error": "Tree data not found or could not be loaded.", "detail": error}), 404 |
|
|
return jsonify({"error": "Could not load or initialize tree data", "detail": error}), 500 |
|
|
return jsonify(tree_data), 200 |
|
|
|
|
|
|
|
|
@app.route('/user/linked_trees', methods=['GET']) |
|
|
@token_required |
|
|
def get_linked_trees_api(): |
|
|
"""Gets trees the authenticated user is a member of (excluding their own primary tree).""" |
|
|
current_user_phone = flask_g.user.get("phone_number") |
|
|
|
|
|
|
|
|
if not current_user_phone: |
|
|
return jsonify({ |
|
|
"message": "Link your phone number to see linked trees", |
|
|
"trees": [], |
|
|
"action_required": "link_phone" |
|
|
}), 200 |
|
|
|
|
|
linked_owner_phones_map, error = find_linked_trees(current_user_phone) |
|
|
if error: |
|
|
return jsonify({"error": "Could not fetch linked trees", "detail": error}), 500 |
|
|
|
|
|
result = [] |
|
|
for owner_ph, status_val_in_index in linked_owner_phones_map.items(): |
|
|
norm_owner_ph = normalize_phone(owner_ph) |
|
|
if norm_owner_ph == current_user_phone: |
|
|
continue |
|
|
if norm_owner_ph: |
|
|
tree_d, err = load_tree_data(norm_owner_ph) |
|
|
tree_name = tree_d.get("metadata", {}).get("tree_name", f"Tree by ...{norm_owner_ph[-4:]}") if tree_d and not err else f"Unknown Tree ({norm_owner_ph})" |
|
|
result.append({"owner_phone": norm_owner_ph, "tree_name": tree_name}) |
|
|
|
|
|
return jsonify(result), 200 |
|
|
|
|
|
|
|
|
@app.route('/tree/<string:owner_phone_of_tree>/graph', methods=['GET']) |
|
|
|
|
|
|
|
|
def get_tree_graph_api(owner_phone_of_tree): |
|
|
system_error_response = check_system_health() |
|
|
if system_error_response: return system_error_response |
|
|
norm_phone = normalize_phone(owner_phone_of_tree) |
|
|
if not norm_phone: return jsonify({"error": "Invalid owner_phone format"}), 400 |
|
|
|
|
|
tree_data, error = load_tree_data(norm_phone) |
|
|
if error or not tree_data: return jsonify({"error": "Could not load tree data for graph", "detail": error or "Tree data is empty"}), 404 |
|
|
|
|
|
try: |
|
|
graph_dot, graph_err = generate_graphviz_object(tree_data) |
|
|
if graph_err: return jsonify({"error": "Could not generate graph", "detail": graph_err}), 500 |
|
|
|
|
|
svg_data = graph_dot.pipe(format='svg').decode('utf-8') |
|
|
return jsonify({"svg": svg_data, "dot_source": graph_dot.source}), 200 |
|
|
except graphviz.backend.execute.ExecutableNotFound: |
|
|
app.logger.error("Graphviz executable not found.") |
|
|
return jsonify({"error": "Graphviz executable not found on server."}), 500 |
|
|
except Exception as e: |
|
|
app.logger.error(f"Error generating graph SVG: {e}") |
|
|
return jsonify({"error": "Error generating graph SVG", "detail": str(e)}), 500 |
|
|
|
|
|
|
|
|
|
|
|
@app.route('/tree/<string:owner_phone_of_tree>/members', methods=['POST']) |
|
|
@token_required |
|
|
@phone_required |
|
|
def add_member_api(owner_phone_of_tree): |
|
|
current_user_phone = flask_g.user["phone_number"] |
|
|
norm_owner_phone_of_tree = normalize_phone(owner_phone_of_tree) |
|
|
if not norm_owner_phone_of_tree: return jsonify({"error": "Invalid owner_phone_of_tree format in URL"}), 400 |
|
|
|
|
|
|
|
|
|
|
|
if current_user_phone != norm_owner_phone_of_tree: |
|
|
return jsonify({"error": "Forbidden: You can only add members to your own tree directly. Use proposals for other trees."}), 403 |
|
|
|
|
|
req_data = request.json |
|
|
if not req_data: return jsonify({"error": "Missing request body"}), 400 |
|
|
new_member_info = req_data.get("member_data") |
|
|
confirm_conflict = req_data.get("confirm_conflict", False) |
|
|
if not new_member_info or not new_member_info.get("name"): |
|
|
return jsonify({"error": "Missing member data or name"}), 400 |
|
|
|
|
|
tree_data, load_error = load_tree_data(norm_owner_phone_of_tree) |
|
|
if load_error: return jsonify({"error": "Could not load tree data", "detail": load_error}), 500 |
|
|
|
|
|
original_tree_data_for_save = deepcopy(tree_data) |
|
|
|
|
|
conflicting_members = find_person_by_name(tree_data, new_member_info["name"]) |
|
|
if conflicting_members and not confirm_conflict: |
|
|
return jsonify({ |
|
|
"error": "Name conflict", |
|
|
"message": f"A member named '{new_member_info['name']}' already exists.", |
|
|
"conflicting_members": [{"id": m["id"], "name": m["name"]} for m in conflicting_members] |
|
|
}), 409 |
|
|
|
|
|
new_member_id = generate_unique_id() |
|
|
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") |
|
|
norm_new_phone = normalize_phone(new_member_info.get("phone")) |
|
|
if new_member_info.get("phone") and not norm_new_phone: |
|
|
return jsonify({"error": "Invalid phone format for new member"}), 400 |
|
|
|
|
|
new_member_entry = deepcopy(DEFAULT_MEMBER_STRUCTURE) |
|
|
new_member_entry.update({ |
|
|
"id": new_member_id, |
|
|
"name": new_member_info["name"].strip(), |
|
|
"dob": new_member_info.get("dob", "").strip(), |
|
|
"dod": new_member_info.get("dod", "").strip(), |
|
|
"gender": new_member_info.get("gender", "Unknown"), |
|
|
"phone": norm_new_phone or "", |
|
|
"totem": new_member_info.get("totem", "").strip(), |
|
|
"created_at": current_time, |
|
|
"created_by": current_user_phone, |
|
|
"last_edited_at": current_time, |
|
|
"last_edited_by": current_user_phone, |
|
|
"stories": [] |
|
|
}) |
|
|
tree_data.setdefault("family_members", []).append(new_member_entry) |
|
|
|
|
|
save_success, save_error = save_tree_data(norm_owner_phone_of_tree, tree_data, original_tree_data_for_save) |
|
|
if not save_success: |
|
|
return jsonify({"error": "Failed to save new member", "detail": save_error}), 500 |
|
|
|
|
|
response_message = "Member added successfully." |
|
|
if save_error: response_message += f" Warning: {save_error}" |
|
|
return jsonify({"message": response_message, "member": new_member_entry}), 201 |
|
|
|
|
|
|
|
|
@app.route('/tree/<string:owner_phone_of_tree>/members/<string:member_id>', methods=['PUT']) |
|
|
@token_required |
|
|
@phone_required |
|
|
def edit_member_api(owner_phone_of_tree, member_id): |
|
|
current_user_phone = flask_g.user["phone_number"] |
|
|
norm_owner_phone_of_tree = normalize_phone(owner_phone_of_tree) |
|
|
if not norm_owner_phone_of_tree: return jsonify({"error": "Invalid owner_phone_of_tree format"}), 400 |
|
|
|
|
|
if current_user_phone != norm_owner_phone_of_tree: |
|
|
return jsonify({"error": "Forbidden: You can only edit members in your own tree directly. Use proposals."}), 403 |
|
|
|
|
|
req_data = request.json |
|
|
if not req_data: return jsonify({"error": "Missing request body"}), 400 |
|
|
updated_info = req_data.get("member_data") |
|
|
confirm_conflict = req_data.get("confirm_conflict", False) |
|
|
if not updated_info: return jsonify({"error": "Missing member_data in request"}), 400 |
|
|
|
|
|
tree_data, load_error = load_tree_data(norm_owner_phone_of_tree) |
|
|
if load_error: return jsonify({"error": "Could not load tree data", "detail": load_error}), 500 |
|
|
|
|
|
original_tree_data_for_save = deepcopy(tree_data) |
|
|
member_to_edit = find_person_by_id(tree_data, member_id) |
|
|
if not member_to_edit: return jsonify({"error": "Member not found"}), 404 |
|
|
|
|
|
is_me_node_of_owner = (member_id == "Me" and member_to_edit.get("phone") == norm_owner_phone_of_tree) |
|
|
if is_me_node_of_owner and "phone" in updated_info and normalize_phone(updated_info["phone"]) != norm_owner_phone_of_tree: |
|
|
return jsonify({"error": "Cannot change the phone number of the owner's 'Me' node."}), 403 |
|
|
if is_me_node_of_owner and "name" in updated_info and updated_info["name"] != member_to_edit.get("name"): |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
pass |
|
|
|
|
|
|
|
|
new_name = updated_info.get("name", member_to_edit.get("name")).strip() |
|
|
if new_name != member_to_edit.get("name"): |
|
|
conflicting_members = [ |
|
|
m for m in tree_data.get("family_members", []) |
|
|
if m["id"] != member_id and m.get("name", "").strip().lower() == new_name.lower() |
|
|
] |
|
|
if conflicting_members and not confirm_conflict: |
|
|
return jsonify({ |
|
|
"error": "Name conflict on edit", |
|
|
"message": f"Changing name to '{new_name}' would conflict with an existing member.", |
|
|
"conflicting_members": [{"id": m["id"], "name": m["name"]} for m in conflicting_members] |
|
|
}), 409 |
|
|
|
|
|
something_changed = False |
|
|
editable_fields = ["name", "dob", "dod", "gender", "totem", "phone"] |
|
|
|
|
|
for field in editable_fields: |
|
|
if field in updated_info: |
|
|
new_value_raw = updated_info[field] |
|
|
new_value = new_value_raw.strip() if isinstance(new_value_raw, str) else new_value_raw |
|
|
|
|
|
if field == "phone": |
|
|
if is_me_node_of_owner and new_value != norm_owner_phone_of_tree : |
|
|
continue |
|
|
norm_new_phone = normalize_phone(new_value) |
|
|
if new_value and not norm_new_phone: return jsonify({"error": f"Invalid phone format for field {field}"}), 400 |
|
|
new_value = norm_new_phone or "" |
|
|
|
|
|
if member_to_edit.get(field) != new_value: |
|
|
member_to_edit[field] = new_value |
|
|
something_changed = True |
|
|
|
|
|
if something_changed: |
|
|
member_to_edit["last_edited_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") |
|
|
member_to_edit["last_edited_by"] = current_user_phone |
|
|
save_success, save_error = save_tree_data(norm_owner_phone_of_tree, tree_data, original_tree_data_for_save) |
|
|
if not save_success: |
|
|
return jsonify({"error": "Failed to save updated member", "detail": save_error}), 500 |
|
|
|
|
|
response_message = "Member updated successfully." |
|
|
if save_error: response_message += f" Warning: {save_error}" |
|
|
return jsonify({"message": response_message, "member": member_to_edit}), 200 |
|
|
else: |
|
|
return jsonify({"message": "No changes detected for member.", "member": member_to_edit}), 200 |
|
|
|
|
|
|
|
|
@app.route('/tree/<string:owner_phone_of_tree>/members/<string:member_id>', methods=['DELETE']) |
|
|
@token_required |
|
|
@phone_required |
|
|
def delete_member_api(owner_phone_of_tree, member_id): |
|
|
current_user_phone = flask_g.user["phone_number"] |
|
|
norm_owner_phone_of_tree = normalize_phone(owner_phone_of_tree) |
|
|
if not norm_owner_phone_of_tree: return jsonify({"error": "Invalid owner_phone_of_tree format"}), 400 |
|
|
|
|
|
if current_user_phone != norm_owner_phone_of_tree: |
|
|
return jsonify({"error": "Forbidden: You can only delete members from your own tree directly."}), 403 |
|
|
|
|
|
tree_data, load_error = load_tree_data(norm_owner_phone_of_tree) |
|
|
if load_error: return jsonify({"error": "Could not load tree data", "detail": load_error}), 500 |
|
|
|
|
|
member_to_check = find_person_by_id(tree_data, member_id) |
|
|
if not member_to_check: return jsonify({"error": "Member not found"}), 404 |
|
|
if member_id == "Me" and member_to_check.get("phone") == norm_owner_phone_of_tree: |
|
|
return jsonify({"error": "Cannot delete the owner's 'Me' user node."}), 403 |
|
|
|
|
|
original_tree_data_for_save = deepcopy(tree_data) |
|
|
member_index = next((i for i, p in enumerate(tree_data.get("family_members", [])) if p.get("id") == member_id), -1) |
|
|
|
|
|
|
|
|
deleted_member_name = tree_data["family_members"][member_index].get("name", "Unknown") |
|
|
del tree_data["family_members"][member_index] |
|
|
tree_data["relationships"] = [ |
|
|
r for r in tree_data.get("relationships", []) |
|
|
if isinstance(r,dict) and r.get("from_id") != member_id and r.get("to_id") != member_id |
|
|
] |
|
|
|
|
|
save_success, save_error = save_tree_data(norm_owner_phone_of_tree, tree_data, original_tree_data_for_save) |
|
|
if not save_success: |
|
|
return jsonify({"error": "Failed to save after deleting member", "detail": save_error}), 500 |
|
|
|
|
|
response_message = f"Member '{deleted_member_name}' deleted successfully." |
|
|
if save_error: response_message += f" Warning: {save_error}" |
|
|
return jsonify({"message": response_message}), 200 |
|
|
|
|
|
|
|
|
|
|
|
@app.route('/tree/<string:owner_phone_of_tree>/relationships', methods=['POST']) |
|
|
@token_required |
|
|
@phone_required |
|
|
def add_relationship_api(owner_phone_of_tree): |
|
|
current_user_phone = flask_g.user["phone_number"] |
|
|
norm_owner_phone_of_tree = normalize_phone(owner_phone_of_tree) |
|
|
if not norm_owner_phone_of_tree: return jsonify({"error": "Invalid owner_phone_of_tree format"}), 400 |
|
|
|
|
|
if current_user_phone != norm_owner_phone_of_tree: |
|
|
return jsonify({"error": "Forbidden: You can only add relationships to your own tree directly."}), 403 |
|
|
|
|
|
req_data = request.json |
|
|
if not req_data or not all(k in req_data for k in ["from_id", "to_id", "type"]): |
|
|
return jsonify({"error": "Missing from_id, to_id, or type in request body"}), 400 |
|
|
|
|
|
from_id, to_id, rel_type = req_data["from_id"], req_data["to_id"], req_data["type"] |
|
|
valid_rel_types = ['parent', 'spouse', 'sibling'] |
|
|
if rel_type not in valid_rel_types: |
|
|
return jsonify({"error": f"Invalid relationship type. Must be one of {valid_rel_types}"}), 400 |
|
|
if from_id == to_id: |
|
|
return jsonify({"error": "Cannot define a relationship between a person and themselves."}), 400 |
|
|
|
|
|
tree_data, load_error = load_tree_data(norm_owner_phone_of_tree) |
|
|
if load_error: return jsonify({"error": "Could not load tree data", "detail": load_error}), 500 |
|
|
|
|
|
original_tree_data_for_save = deepcopy(tree_data) |
|
|
if not find_person_by_id(tree_data, from_id) or not find_person_by_id(tree_data, to_id): |
|
|
return jsonify({"error": "One or both persons in the relationship not found."}), 404 |
|
|
|
|
|
relationships = tree_data.setdefault("relationships", []) |
|
|
exists = False |
|
|
for rel in relationships: |
|
|
if not isinstance(rel, dict): continue |
|
|
rf, rt, rtype_existing = rel.get('from_id'), rel.get('to_id'), rel.get('type') |
|
|
if rel_type == 'parent' and rtype_existing == 'parent' and rf == from_id and rt == to_id: exists = True; break |
|
|
if rel_type in ['spouse', 'sibling'] and rtype_existing == rel_type and frozenset([rf, rt]) == frozenset([from_id, to_id]): exists = True; break |
|
|
if exists: |
|
|
return jsonify({"error": "This relationship already exists."}), 409 |
|
|
|
|
|
new_rel_entry = {"from_id": from_id, "to_id": to_id, "type": rel_type} |
|
|
relationships.append(new_rel_entry) |
|
|
|
|
|
save_success, save_error = save_tree_data(norm_owner_phone_of_tree, tree_data, original_tree_data_for_save) |
|
|
if not save_success: |
|
|
return jsonify({"error": "Failed to save new relationship", "detail": save_error}), 500 |
|
|
|
|
|
response_message = "Relationship added successfully." |
|
|
if save_error: response_message += f" Warning: {save_error}" |
|
|
return jsonify({"message": response_message, "relationship": new_rel_entry}), 201 |
|
|
|
|
|
|
|
|
@app.route('/tree/<string:owner_phone_of_tree>/relationships/delete', methods=['POST']) |
|
|
@token_required |
|
|
@phone_required |
|
|
def delete_relationship_api(owner_phone_of_tree): |
|
|
current_user_phone = flask_g.user["phone_number"] |
|
|
norm_owner_phone_of_tree = normalize_phone(owner_phone_of_tree) |
|
|
if not norm_owner_phone_of_tree: return jsonify({"error": "Invalid owner_phone_of_tree format"}), 400 |
|
|
|
|
|
if current_user_phone != norm_owner_phone_of_tree: |
|
|
return jsonify({"error": "Forbidden: You can only delete relationships from your own tree directly."}), 403 |
|
|
|
|
|
req_data = request.json |
|
|
if not req_data or not all(k in req_data for k in ["from_id", "to_id", "type"]): |
|
|
return jsonify({"error": "Missing from_id, to_id, or type in request body for deletion"}), 400 |
|
|
|
|
|
del_from_id, del_to_id, del_rel_type = req_data["from_id"], req_data["to_id"], req_data["type"] |
|
|
|
|
|
tree_data, load_error = load_tree_data(norm_owner_phone_of_tree) |
|
|
if load_error: return jsonify({"error": "Could not load tree data", "detail": load_error}), 500 |
|
|
|
|
|
original_tree_data_for_save = deepcopy(tree_data) |
|
|
relationships = tree_data.get("relationships", []) |
|
|
new_relationships = [] |
|
|
deleted = False |
|
|
for rel in relationships: |
|
|
if not isinstance(rel, dict): new_relationships.append(rel); continue |
|
|
rf, rt, rtype = rel.get('from_id'), rel.get('to_id'), rel.get('type') |
|
|
is_match = False |
|
|
if del_rel_type == 'parent' and rtype == 'parent' and rf == del_from_id and rt == del_to_id: is_match = True |
|
|
elif del_rel_type in ['spouse', 'sibling'] and rtype == del_rel_type and frozenset([rf, rt]) == frozenset([del_from_id, del_to_id]): is_match = True |
|
|
|
|
|
if is_match: deleted = True |
|
|
else: new_relationships.append(rel) |
|
|
|
|
|
if not deleted: return jsonify({"error": "Relationship to delete not found."}), 404 |
|
|
|
|
|
tree_data["relationships"] = new_relationships |
|
|
save_success, save_error = save_tree_data(norm_owner_phone_of_tree, tree_data, original_tree_data_for_save) |
|
|
if not save_success: |
|
|
return jsonify({"error": "Failed to save after deleting relationship", "detail": save_error}), 500 |
|
|
|
|
|
response_message = "Relationship deleted successfully." |
|
|
if save_error: response_message += f" Warning: {save_error}" |
|
|
return jsonify({"message": response_message}), 200 |
|
|
|
|
|
|
|
|
|
|
|
@app.route('/tree/<string:owner_phone_of_tree>/members/<string:member_id>/stories', methods=['POST']) |
|
|
@token_required |
|
|
@phone_required |
|
|
def add_story_api(owner_phone_of_tree, member_id): |
|
|
current_user_phone = flask_g.user["phone_number"] |
|
|
norm_owner_phone_of_tree = normalize_phone(owner_phone_of_tree) |
|
|
if not norm_owner_phone_of_tree: return jsonify({"error": "Invalid owner_phone_of_tree format"}), 400 |
|
|
|
|
|
req_data = request.json |
|
|
if not req_data or "text" not in req_data or not req_data["text"].strip(): |
|
|
return jsonify({"error": "Missing or empty 'text' for story"}), 400 |
|
|
story_text = req_data["text"].strip() |
|
|
|
|
|
tree_data, load_error = load_tree_data(norm_owner_phone_of_tree) |
|
|
if load_error: return jsonify({"error": "Could not load tree data", "detail": load_error}), 500 |
|
|
|
|
|
original_tree_data_for_save = deepcopy(tree_data) |
|
|
member = find_person_by_id(tree_data, member_id) |
|
|
if not member: return jsonify({"error": "Member not found"}), 404 |
|
|
|
|
|
new_story = { |
|
|
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), |
|
|
"text": story_text, |
|
|
"added_by": current_user_phone |
|
|
} |
|
|
member.setdefault("stories", []).append(new_story) |
|
|
member["last_edited_at"] = new_story["timestamp"] |
|
|
member["last_edited_by"] = current_user_phone |
|
|
|
|
|
if current_user_phone == norm_owner_phone_of_tree: |
|
|
|
|
|
save_success, save_error = save_tree_data(norm_owner_phone_of_tree, tree_data, original_tree_data_for_save) |
|
|
if not save_success: |
|
|
return jsonify({"error": "Failed to save story", "detail": save_error}), 500 |
|
|
response_message = "Story added successfully." |
|
|
if save_error: response_message += f" Warning: {save_error}" |
|
|
return jsonify({"message": response_message, "story": new_story}), 201 |
|
|
else: |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
save_success, save_error = save_tree_data(norm_owner_phone_of_tree, tree_data, original_tree_data_for_save) |
|
|
if not save_success: |
|
|
return jsonify({"error": "Failed to save story (as collaborator)", "detail": save_error}), 500 |
|
|
response_message = "Story added successfully (as collaborator)." |
|
|
if save_error: response_message += f" Warning: {save_error}" |
|
|
return jsonify({"message": response_message, "story": new_story}), 201 |
|
|
|
|
|
|
|
|
@app.route('/tree/<string:owner_phone_of_tree>/members/<string:member_id>/stories/<string:story_timestamp>', methods=['DELETE']) |
|
|
@token_required |
|
|
@phone_required |
|
|
def delete_story_api(owner_phone_of_tree, member_id, story_timestamp): |
|
|
current_user_phone = flask_g.user["phone_number"] |
|
|
norm_owner_phone_of_tree = normalize_phone(owner_phone_of_tree) |
|
|
if not norm_owner_phone_of_tree: return jsonify({"error": "Invalid owner_phone_of_tree format"}), 400 |
|
|
|
|
|
tree_data, load_error = load_tree_data(norm_owner_phone_of_tree) |
|
|
if load_error: return jsonify({"error": "Could not load tree data", "detail": load_error}), 500 |
|
|
|
|
|
original_tree_data_for_save = deepcopy(tree_data) |
|
|
member = find_person_by_id(tree_data, member_id) |
|
|
if not member: return jsonify({"error": "Member not found"}), 404 |
|
|
|
|
|
stories = member.get("stories", []) |
|
|
story_to_delete_index = -1 |
|
|
for i, story in enumerate(stories): |
|
|
if story.get("timestamp") == story_timestamp: |
|
|
|
|
|
can_delete = (story.get("added_by") == current_user_phone or current_user_phone == norm_owner_phone_of_tree) |
|
|
if not can_delete: |
|
|
return jsonify({"error": "Forbidden: You can only delete stories you added or if you are the tree owner."}), 403 |
|
|
story_to_delete_index = i |
|
|
break |
|
|
|
|
|
if story_to_delete_index == -1: |
|
|
return jsonify({"error": "Story with given timestamp not found or you do not have permission to delete it."}), 404 |
|
|
|
|
|
del member["stories"][story_to_delete_index] |
|
|
member["last_edited_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") |
|
|
member["last_edited_by"] = current_user_phone |
|
|
|
|
|
if current_user_phone == norm_owner_phone_of_tree: |
|
|
save_success, save_error = save_tree_data(norm_owner_phone_of_tree, tree_data, original_tree_data_for_save) |
|
|
if not save_success: |
|
|
return jsonify({"error": "Failed to save after deleting story", "detail": save_error}), 500 |
|
|
response_message = "Story deleted successfully." |
|
|
if save_error: response_message += f" Warning: {save_error}" |
|
|
return jsonify({"message": response_message}), 200 |
|
|
else: |
|
|
|
|
|
return jsonify({"error": "Forbidden: Use proposals to delete stories from trees you do not own (unless you added the story and it's a direct delete)."}), 403 |
|
|
|
|
|
|
|
|
|
|
|
@app.route('/tree/<string:owner_phone_of_tree>/proposals', methods=['POST']) |
|
|
@token_required |
|
|
@phone_required |
|
|
def propose_changes_api(owner_phone_of_tree): |
|
|
proposer_phone_from_token = flask_g.user["phone_number"] |
|
|
norm_owner_phone_of_tree = normalize_phone(owner_phone_of_tree) |
|
|
|
|
|
if not norm_owner_phone_of_tree: return jsonify({"error": "Invalid owner_phone_of_tree format in URL"}), 400 |
|
|
if norm_owner_phone_of_tree == proposer_phone_from_token: |
|
|
return jsonify({"error": "Cannot propose changes to your own tree. Edit directly."}), 400 |
|
|
|
|
|
req_data = request.json |
|
|
|
|
|
if not req_data or "proposed_data" not in req_data: |
|
|
return jsonify({"error": "Missing proposed_data in request"}), 400 |
|
|
|
|
|
proposed_data = req_data["proposed_data"] |
|
|
|
|
|
success, error = propose_changes(norm_owner_phone_of_tree, proposer_phone_from_token, proposed_data) |
|
|
if not success: |
|
|
return jsonify({"error": "Failed to propose changes", "detail": error}), 500 |
|
|
return jsonify({"message": "Changes proposed successfully."}), 201 |
|
|
|
|
|
|
|
|
@app.route('/tree/my_proposals/pending', methods=['GET']) |
|
|
@token_required |
|
|
@phone_required |
|
|
def get_my_pending_changes_api(): |
|
|
owner_phone_from_token = flask_g.user["phone_number"] |
|
|
|
|
|
pending_data, error = load_pending_changes(owner_phone_from_token) |
|
|
if error: |
|
|
return jsonify({"error": "Could not load pending changes for your tree(s)", "detail": error}), 500 |
|
|
return jsonify(pending_data), 200 |
|
|
|
|
|
|
|
|
@app.route('/tree/my_proposals/<string:proposer_phone_to_manage>/accept', methods=['POST']) |
|
|
@token_required |
|
|
@phone_required |
|
|
def accept_changes_api(proposer_phone_to_manage): |
|
|
owner_phone_from_token = flask_g.user["phone_number"] |
|
|
norm_proposer_phone = normalize_phone(proposer_phone_to_manage) |
|
|
if not norm_proposer_phone: return jsonify({"error": "Invalid proposer_phone_to_manage format"}), 400 |
|
|
|
|
|
success, error = accept_changes(owner_phone_from_token, norm_proposer_phone) |
|
|
if not success: |
|
|
return jsonify({"error": "Failed to accept changes", "detail": error}), 500 |
|
|
return jsonify({"message": "Changes accepted and merged successfully."}), 200 |
|
|
|
|
|
|
|
|
@app.route('/tree/my_proposals/<string:proposer_phone_to_manage>/reject', methods=['POST']) |
|
|
@token_required |
|
|
@phone_required |
|
|
def reject_changes_api(proposer_phone_to_manage): |
|
|
owner_phone_from_token = flask_g.user["phone_number"] |
|
|
norm_proposer_phone = normalize_phone(proposer_phone_to_manage) |
|
|
if not norm_proposer_phone: return jsonify({"error": "Invalid proposer_phone_to_manage format"}), 400 |
|
|
|
|
|
success, error = reject_changes(owner_phone_from_token, norm_proposer_phone) |
|
|
if not success: |
|
|
return jsonify({"error": "Failed to reject changes", "detail": error}), 500 |
|
|
return jsonify({"message": "Changes rejected successfully."}), 200 |
|
|
|
|
|
@app.route('/tree/my_proposals/<string:proposer_phone_to_review>/diff', methods=['GET']) |
|
|
@token_required |
|
|
@phone_required |
|
|
def get_proposal_diff_api(proposer_phone_to_review): |
|
|
owner_phone_from_token = flask_g.user["phone_number"] |
|
|
norm_proposer_phone = normalize_phone(proposer_phone_to_review) |
|
|
if not norm_proposer_phone: return jsonify({"error": "Invalid proposer_phone_to_review format"}), 400 |
|
|
|
|
|
current_owner_data, load_err = load_tree_data(owner_phone_from_token) |
|
|
if load_err: return jsonify({"error": "Could not load current owner tree data", "detail": load_err}), 500 |
|
|
|
|
|
pending_proposals, pend_err = load_pending_changes(owner_phone_from_token) |
|
|
if pend_err: return jsonify({"error": "Could not load pending proposals", "detail": pend_err}), 500 |
|
|
|
|
|
|
|
|
proposal_payload = pending_proposals.get(norm_proposer_phone) |
|
|
if not proposal_payload or "tree_data" not in proposal_payload: |
|
|
return jsonify({"error": f"Proposal from {norm_proposer_phone} not found or invalid."}), 404 |
|
|
|
|
|
proposed_data = proposal_payload["tree_data"] |
|
|
diff_summary_text = generate_diff_summary(current_owner_data, proposed_data) |
|
|
return jsonify({"diff_summary": diff_summary_text}), 200 |
|
|
|
|
|
|
|
|
|
|
|
@app.route('/ai/build_tree_from_text', methods=['POST']) |
|
|
@token_required |
|
|
def ai_build_tree_api(): |
|
|
""" |
|
|
Endpoint to analyze family description text and extract people and relationships. |
|
|
|
|
|
Request body: |
|
|
{ |
|
|
"description": "Family description text here..." |
|
|
} |
|
|
|
|
|
Response: |
|
|
{ |
|
|
"people": [...], |
|
|
"relationships": [...] |
|
|
} |
|
|
""" |
|
|
if api_key_error: |
|
|
return jsonify({"error": "Gemini API not configured"}), 503 |
|
|
|
|
|
req_data = request.json |
|
|
if not req_data or "description" not in req_data: |
|
|
return jsonify({"error": "Missing description in request body"}), 400 |
|
|
|
|
|
description = req_data["description"] |
|
|
|
|
|
if not description.strip(): |
|
|
return jsonify({"error": "Description cannot be empty"}), 400 |
|
|
|
|
|
ai_result, error = generate_tree_from_description_gemini(description) |
|
|
if error: |
|
|
return jsonify({"error": "AI analysis failed", "detail": error}), 500 |
|
|
|
|
|
return jsonify(ai_result), 200 |
|
|
|
|
|
|
|
|
|
|
|
@app.route('/tree/<string:owner_phone_of_tree>/ai_merge_suggestions', methods=['POST']) |
|
|
@token_required |
|
|
@phone_required |
|
|
def ai_merge_tree_api(owner_phone_of_tree): |
|
|
current_user_phone = flask_g.user["phone_number"] |
|
|
norm_owner_phone_of_tree = normalize_phone(owner_phone_of_tree) |
|
|
if not norm_owner_phone_of_tree: return jsonify({"error": "Invalid owner_phone_of_tree format"}), 400 |
|
|
|
|
|
if current_user_phone != norm_owner_phone_of_tree: |
|
|
return jsonify({"error": "Forbidden: You can only merge AI suggestions into your own tree."}), 403 |
|
|
if api_key_error: return jsonify({"error": "Gemini API features disabled."}), 503 |
|
|
|
|
|
|
|
|
req_data = request.json |
|
|
if not req_data or "ai_result" not in req_data: |
|
|
return jsonify({"error": "Missing ai_result in request body"}), 400 |
|
|
|
|
|
ai_result_data = req_data["ai_result"] |
|
|
if not isinstance(ai_result_data, dict) or "people" not in ai_result_data or "relationships" not in ai_result_data: |
|
|
return jsonify({"error": "Invalid ai_result format"}), 400 |
|
|
|
|
|
tree_data, load_error = load_tree_data(norm_owner_phone_of_tree) |
|
|
if load_error: return jsonify({"error": "Could not load tree data for merge", "detail": load_error}), 500 |
|
|
|
|
|
original_tree_data_for_save = deepcopy(tree_data) |
|
|
|
|
|
|
|
|
ai_people = ai_result_data.get("people", []) |
|
|
ai_rels = ai_result_data.get("relationships", []) |
|
|
valid_people = [p for p in ai_people if isinstance(p, dict) and p.get("name")] |
|
|
valid_rel_structs = [r for r in ai_rels if isinstance(r, dict) and all(k in r for k in ["person1_name", "person2_name", "type"])] |
|
|
ai_people_names = {p.get('name') for p in valid_people} |
|
|
valid_rels_final = [r for r in valid_rel_structs if r.get('person1_name') in ai_people_names and r.get('person2_name') in ai_people_names] |
|
|
|
|
|
if not valid_people and not valid_rels_final: |
|
|
return jsonify({"message": "No valid people or relationships from AI to merge."}), 200 |
|
|
|
|
|
added_p_count, added_r_count, updated_p_names, skipped_p_names, skipped_r_desc = 0, 0, [], [], [] |
|
|
id_map = {} |
|
|
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") |
|
|
|
|
|
for person_ai in valid_people: |
|
|
ai_name = person_ai.get("name") |
|
|
if not ai_name: continue |
|
|
existing_people_with_name = find_person_by_name(tree_data, ai_name) |
|
|
existing_person = existing_people_with_name[0] if existing_people_with_name else None |
|
|
person_updated = False |
|
|
if existing_person: |
|
|
id_map[ai_name] = existing_person['id'] |
|
|
for key in ['dob', 'dod', 'gender', 'totem']: |
|
|
if not existing_person.get(key) and person_ai.get(key): |
|
|
if key == 'gender' and person_ai.get(key) == 'Unknown': continue |
|
|
existing_person[key] = person_ai.get(key); person_updated = True |
|
|
if person_updated: |
|
|
existing_person["last_edited_at"] = current_time; existing_person["last_edited_by"] = current_user_phone |
|
|
updated_p_names.append(ai_name) |
|
|
else: skipped_p_names.append(ai_name + " (exists, no new info)") |
|
|
else: |
|
|
new_id = generate_unique_id(); id_map[ai_name] = new_id |
|
|
new_data = deepcopy(DEFAULT_MEMBER_STRUCTURE) |
|
|
valid_keys = ['name', 'dob', 'dod', 'gender', 'totem'] |
|
|
new_data.update({k: v for k, v in person_ai.items() if k in valid_keys and v}) |
|
|
new_data.update({"id": new_id, "created_at": current_time, "created_by": current_user_phone, |
|
|
"last_edited_at": current_time, "last_edited_by": current_user_phone}) |
|
|
tree_data.setdefault("family_members", []).append(new_data) |
|
|
added_p_count += 1 |
|
|
|
|
|
current_rels_list = tree_data.setdefault("relationships", []) |
|
|
existing_rel_set = set() |
|
|
for r_exist in current_rels_list: |
|
|
if not isinstance(r_exist, dict): continue |
|
|
from_id_e, to_id_e, r_type_e = r_exist.get('from_id'), r_exist.get('to_id'), r_exist.get('type') |
|
|
if not from_id_e or not to_id_e or not r_type_e: continue |
|
|
if r_type_e == 'parent': existing_rel_set.add( (from_id_e, to_id_e, r_type_e) ) |
|
|
elif r_type_e in ['spouse', 'sibling']: existing_rel_set.add( (tuple(sorted((from_id_e, to_id_e))), r_type_e) ) |
|
|
|
|
|
rel_type_map = {"parent_of":'parent', "spouse_of":'spouse', "sibling_of":'sibling'} |
|
|
for rel_ai in valid_rels_final: |
|
|
p1n, p2n, rta = rel_ai.get("person1_name"), rel_ai.get("person2_name"), rel_ai.get("type") |
|
|
id1, id2 = id_map.get(p1n), id_map.get(p2n) |
|
|
rti = rel_type_map.get(rta) |
|
|
if id1 and id2 and rti: |
|
|
exists, rel_key = False, None |
|
|
if rti == 'parent': rel_key = (id1, id2, rti) |
|
|
elif rti in ['spouse', 'sibling']: rel_key = (tuple(sorted((id1, id2))), rti) |
|
|
if rel_key and rel_key in existing_rel_set: exists = True |
|
|
if exists: skipped_r_desc.append(f"{p1n} {rta.replace('_',' ')} {p2n} (exists)") |
|
|
else: |
|
|
current_rels_list.append({"from_id": id1, "to_id": id2, "type": rti}); added_r_count += 1 |
|
|
if rel_key: existing_rel_set.add(rel_key) |
|
|
else: skipped_r_desc.append(f"{p1n} {rta.replace('_',' ')} {p2n} (missing person ID or invalid type)") |
|
|
|
|
|
if added_p_count > 0 or added_r_count > 0 or updated_p_names: |
|
|
save_success, save_error = save_tree_data(norm_owner_phone_of_tree, tree_data, original_tree_data_for_save) |
|
|
if not save_success: |
|
|
return jsonify({"error": "Failed to save merged AI data", "detail": save_error}), 500 |
|
|
merge_summary_msg = f"AI Merge: Added {added_p_count} people, {added_r_count} relationships. Updated {len(updated_p_names)} people." |
|
|
if save_error: merge_summary_msg += f" Warning: {save_error}" |
|
|
return jsonify({"message": merge_summary_msg, "skipped_people": skipped_p_names, "skipped_relationships": skipped_r_desc}), 200 |
|
|
else: |
|
|
return jsonify({"message": "No new information from AI to merge.", "skipped_people": skipped_p_names, "skipped_relationships": skipped_r_desc}), 200 |
|
|
|
|
|
|
|
|
@app.route('/tree/<string:owner_phone_of_tree>/quiz/generate', methods=['POST']) |
|
|
@token_required |
|
|
def generate_quiz_api(owner_phone_of_tree): |
|
|
if api_key_error: return jsonify({"error": "Gemini API not configured"}), 503 |
|
|
norm_owner_phone_of_tree = normalize_phone(owner_phone_of_tree) |
|
|
if not norm_owner_phone_of_tree: return jsonify({"error": "Invalid owner_phone_of_tree format"}), 400 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
num_questions = request.json.get("num_questions", 5) if request.json else 5 |
|
|
|
|
|
tree_data, load_error = load_tree_data(norm_owner_phone_of_tree) |
|
|
if load_error or not tree_data: return jsonify({"error": "Could not load tree data for quiz", "detail": load_error or "Tree data is empty"}), 500 |
|
|
|
|
|
members = tree_data.get("family_members", []) |
|
|
relationships = tree_data.get("relationships", []) |
|
|
|
|
|
questions, error = generate_quiz_questions_gemini(members, relationships, num_questions=num_questions) |
|
|
if error: return jsonify({"error": "Failed to generate quiz questions", "detail": error}), 500 |
|
|
if not questions or not isinstance(questions, list) or len(questions) == 0: |
|
|
return jsonify({"error": "AI returned no valid questions or in an unexpected format."}), 500 |
|
|
|
|
|
valid_qs = [q for q in questions if isinstance(q, dict) and all(k in q for k in ['text','options','correct']) and isinstance(q['options'],list) and len(q['options']) > 1 and q['correct'] in q['options']] |
|
|
if not valid_qs: |
|
|
app.logger.error(f"AI Quiz Response Invalid Structure: {questions}") |
|
|
return jsonify({"error": "AI returned questions in an invalid structure."}), 500 |
|
|
|
|
|
return jsonify(valid_qs), 200 |
|
|
|
|
|
|
|
|
|
|
|
@app.route('/user/me/settings', methods=['PUT']) |
|
|
@token_required |
|
|
@phone_required |
|
|
def update_my_settings_api(): |
|
|
current_user_phone = flask_g.user["phone_number"] |
|
|
|
|
|
req_data = request.json |
|
|
if not req_data: return jsonify({"error": "Missing request body for settings"}), 400 |
|
|
|
|
|
tree_data, load_error = load_tree_data(current_user_phone) |
|
|
if load_error: return jsonify({"error": "Could not load tree data for settings", "detail": load_error}), 500 |
|
|
|
|
|
original_tree_data_for_save = deepcopy(tree_data) |
|
|
settings = tree_data.setdefault("settings", deepcopy(DEFAULT_PROFILE["settings"])) |
|
|
metadata = tree_data.setdefault("metadata", deepcopy(DEFAULT_PROFILE["metadata"])) |
|
|
changed = False |
|
|
|
|
|
if "tree_name" in req_data and metadata.get("tree_name") != req_data["tree_name"]: |
|
|
metadata["tree_name"] = req_data["tree_name"]; changed = True |
|
|
if "theme" in req_data and settings.get("theme") != req_data["theme"]: |
|
|
settings["theme"] = req_data["theme"]; changed = True |
|
|
if "privacy" in req_data and settings.get("privacy") != req_data["privacy"]: |
|
|
settings["privacy"] = req_data["privacy"]; changed = True |
|
|
|
|
|
if changed: |
|
|
save_success, save_error = save_tree_data(current_user_phone, tree_data, original_tree_data_for_save) |
|
|
if not save_success: |
|
|
return jsonify({"error": "Failed to save settings", "detail": save_error}), 500 |
|
|
response_message = "Settings updated successfully." |
|
|
if save_error: response_message += f" Warning: {save_error}" |
|
|
return jsonify({"message": response_message, "settings": settings, "metadata": metadata}), 200 |
|
|
else: |
|
|
return jsonify({"message": "No changes made to settings."}), 200 |
|
|
|
|
|
|
|
|
@app.route('/user/me/profile', methods=['PUT']) |
|
|
@token_required |
|
|
@phone_required |
|
|
def update_my_profile_api(): |
|
|
current_user_phone = flask_g.user["phone_number"] |
|
|
|
|
|
req_data = request.json |
|
|
if not req_data: return jsonify({"error": "Missing request body for profile"}), 400 |
|
|
|
|
|
tree_data, load_error = load_tree_data(current_user_phone) |
|
|
if load_error: return jsonify({"error": "Could not load tree data for profile", "detail": load_error}), 500 |
|
|
|
|
|
original_tree_data_for_save = deepcopy(tree_data) |
|
|
profile = tree_data.setdefault("profile", deepcopy(DEFAULT_PROFILE["profile"])) |
|
|
profile_updated, me_node_updated = False, False |
|
|
|
|
|
|
|
|
if "name" in req_data and profile.get("name") != req_data["name"]: |
|
|
profile["name"] = req_data["name"]; profile_updated = True |
|
|
if "dob" in req_data and profile.get("dob") != req_data["dob"]: |
|
|
profile["dob"] = req_data["dob"]; profile_updated = True |
|
|
if "gender" in req_data and profile.get("gender") != req_data["gender"]: |
|
|
profile["gender"] = req_data["gender"]; profile_updated = True |
|
|
|
|
|
|
|
|
me_node = find_person_by_id(tree_data, "Me") |
|
|
if me_node and me_node.get("phone") == current_user_phone: |
|
|
|
|
|
me_node_name_update = profile.get("name") if profile.get("name") else me_node.get("name", "Me") |
|
|
if me_node.get("name") != me_node_name_update: me_node["name"] = me_node_name_update; me_node_updated = True |
|
|
|
|
|
if "dob" in req_data and me_node.get("dob") != req_data["dob"]: |
|
|
me_node["dob"] = req_data["dob"]; me_node_updated = True |
|
|
if "gender" in req_data and me_node.get("gender") != req_data["gender"]: |
|
|
me_node["gender"] = req_data["gender"]; me_node_updated = True |
|
|
|
|
|
if me_node_updated: |
|
|
me_node["last_edited_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") |
|
|
me_node["last_edited_by"] = current_user_phone |
|
|
elif not me_node: |
|
|
app.logger.warning(f"Could not find 'Me' node for owner {current_user_phone} to sync profile.") |
|
|
|
|
|
if profile_updated or me_node_updated: |
|
|
save_success, save_error = save_tree_data(current_user_phone, tree_data, original_tree_data_for_save) |
|
|
if not save_success: |
|
|
return jsonify({"error": "Failed to save profile updates", "detail": save_error}), 500 |
|
|
response_message = "Profile updated successfully." |
|
|
if save_error: response_message += f" Warning: {save_error}" |
|
|
return jsonify({"message": response_message, "profile": profile, "me_node_updated": me_node_updated}), 200 |
|
|
else: |
|
|
return jsonify({"message": "No changes made to profile."}), 200 |
|
|
|
|
|
|
|
|
@app.route('/tree/<string:owner_phone_of_tree>/timeline', methods=['GET']) |
|
|
def get_timeline_api(owner_phone_of_tree): |
|
|
system_error_response = check_system_health() |
|
|
if system_error_response: return system_error_response |
|
|
norm_phone = normalize_phone(owner_phone_of_tree) |
|
|
if not norm_phone: return jsonify({"error": "Invalid owner_phone_of_tree format"}), 400 |
|
|
|
|
|
tree_data, load_error = load_tree_data(norm_phone) |
|
|
if load_error or not tree_data: return jsonify({"error": "Could not load tree data for timeline", "detail": load_error or "Tree data is empty"}), 500 |
|
|
|
|
|
members = tree_data.get("family_members", []) |
|
|
events = [] |
|
|
valid_date_format = "%Y-%m-%d" |
|
|
for person in members: |
|
|
if not isinstance(person, dict): continue |
|
|
name = person.get("name", "Unknown") |
|
|
dob_str, dod_str = person.get("dob"), person.get("dod") |
|
|
try: |
|
|
if dob_str: |
|
|
datetime.strptime(dob_str, valid_date_format) |
|
|
events.append({"date": dob_str, "type": "Birth", "desc": f"{name} born."}) |
|
|
if dod_str: |
|
|
datetime.strptime(dod_str, valid_date_format) |
|
|
events.append({"date": dod_str, "type": "Death", "desc": f"{name} passed."}) |
|
|
except (ValueError, TypeError): pass |
|
|
|
|
|
try: events.sort(key=lambda x: x.get("date", "0000-00-00")) |
|
|
except Exception as e: app.logger.warning(f"Timeline sort error: {e}.") |
|
|
|
|
|
return jsonify(events), 200 |
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__": |
|
|
app.run(host="0.0.0.0", port=7860, debug=True) |
|
|
|