2026-10-03 17:41:52 +08:00
|
|
|
"""A private family register, independent from login accounts and camera roles."""
|
|
|
|
|
import datetime as dt
|
|
|
|
|
import json
|
|
|
|
|
import secrets
|
2026-10-03 20:02:07 +08:00
|
|
|
from urllib.parse import urlsplit
|
2026-10-03 17:41:52 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class Family:
|
|
|
|
|
def __init__(self, app):
|
|
|
|
|
self.a = app
|
|
|
|
|
|
|
|
|
|
def initialize(self):
|
|
|
|
|
with self.a.LOCK:
|
|
|
|
|
self.a.DB.executescript('''
|
|
|
|
|
CREATE TABLE IF NOT EXISTS people (id TEXT PRIMARY KEY, body TEXT NOT NULL);
|
|
|
|
|
CREATE TABLE IF NOT EXISTS family_links (id TEXT PRIMARY KEY, body TEXT NOT NULL);
|
2026-10-03 20:02:07 +08:00
|
|
|
CREATE TABLE IF NOT EXISTS family_events (id TEXT PRIMARY KEY, body TEXT NOT NULL);
|
2026-10-03 17:41:52 +08:00
|
|
|
''')
|
|
|
|
|
self.a.DB.commit()
|
|
|
|
|
|
|
|
|
|
def authorize(self, user, edit=False):
|
|
|
|
|
if user['role'] != 'admin' and user.get('familyAccess', 'none') not in (('edit',) if edit else ('read', 'edit')):
|
|
|
|
|
raise self.a.Problem('没有家谱' + ('编辑' if edit else '查看') + '权限', 403)
|
|
|
|
|
|
|
|
|
|
def snapshot(self, user):
|
|
|
|
|
self.authorize(user)
|
|
|
|
|
with self.a.LOCK:
|
2026-10-03 20:02:07 +08:00
|
|
|
can_edit = user['role'] == 'admin' or user.get('familyAccess') == 'edit'
|
2026-10-03 17:41:52 +08:00
|
|
|
return {'revision': self.a.setting('familyRevision', 0), 'people': self.a.objects('people'),
|
|
|
|
|
'links': self.a.objects('family_links'),
|
2026-10-03 20:02:07 +08:00
|
|
|
'events': [e for e in self.a.objects('family_events')
|
|
|
|
|
if can_edit or (e.get('status') == 'published' and not e.get('archived'))],
|
|
|
|
|
'canEdit': can_edit,
|
2026-10-03 17:41:52 +08:00
|
|
|
'selfId': user.get('personId', '')}
|
|
|
|
|
|
|
|
|
|
def save(self, kind, data, user):
|
|
|
|
|
self.authorize(user, True)
|
|
|
|
|
a = self.a
|
|
|
|
|
with a.LOCK:
|
|
|
|
|
revision = a.setting('familyRevision', 0)
|
|
|
|
|
if type(data.get('revision')) is not int or data['revision'] != revision:
|
|
|
|
|
raise a.Problem('家谱已被更新,请重新载入后再编辑', 409)
|
2026-10-03 20:02:07 +08:00
|
|
|
if kind not in ('person', 'link', 'event'):
|
|
|
|
|
raise a.Problem('家谱记录类型不存在', 404)
|
|
|
|
|
table = {'person': 'people', 'link': 'family_links', 'event': 'family_events'}[kind]
|
2026-10-03 17:41:52 +08:00
|
|
|
old = a.get_object(table, data.get('id'))
|
|
|
|
|
if data.get('id') and not old:
|
|
|
|
|
raise a.Problem('家谱记录不存在', 404)
|
2026-10-03 20:02:07 +08:00
|
|
|
item = {'person': self.person, 'link': self.link, 'event': self.event}[kind](data, old)
|
2026-10-03 17:41:52 +08:00
|
|
|
item.update(updatedAt=dt.datetime.now(dt.timezone.utc).isoformat(), updatedBy=user['username'])
|
2026-10-03 20:02:07 +08:00
|
|
|
if kind == 'event':
|
|
|
|
|
item.update(createdAt=old.get('createdAt', item['updatedAt']) if old else item['updatedAt'],
|
|
|
|
|
createdBy=old.get('createdBy', user['username']) if old else user['username'])
|
2026-10-03 20:27:11 +08:00
|
|
|
# Withdrawal and restoration must retain the first publication time.
|
|
|
|
|
if old and old.get('publishedAt'):
|
|
|
|
|
item['publishedAt'] = old['publishedAt']
|
|
|
|
|
elif item['status'] == 'published':
|
|
|
|
|
item['publishedAt'] = item['updatedAt']
|
2026-10-03 17:41:52 +08:00
|
|
|
# Revision and record are committed together, so concurrent forms cannot overwrite silently.
|
|
|
|
|
a.DB.execute('INSERT OR REPLACE INTO ' + table + ' VALUES (?,?)', (item['id'], json.dumps(item)))
|
|
|
|
|
a.DB.execute('INSERT OR REPLACE INTO settings VALUES (?,?)', ('familyRevision', json.dumps(revision + 1)))
|
|
|
|
|
a.DB.commit()
|
2026-10-03 20:02:07 +08:00
|
|
|
a.audit({'person': '更新家谱人物', 'link': '更新家谱关系', 'event': '更新家族记事'}[kind], user['username'])
|
2026-10-03 17:41:52 +08:00
|
|
|
return {'item': item, 'revision': revision + 1}
|
|
|
|
|
|
2026-10-03 20:02:07 +08:00
|
|
|
def event(self, data, old):
|
|
|
|
|
a = self.a
|
|
|
|
|
item = {'id': old['id'] if old else secrets.token_hex(8)}
|
|
|
|
|
for key, maximum, required in [('title', 160, True), ('eventDate', 10, True),
|
2026-10-03 20:27:11 +08:00
|
|
|
('sourceName', 160, False),
|
2026-10-03 20:02:07 +08:00
|
|
|
('sourceUrl', 2000, False)]:
|
|
|
|
|
item[key] = a.clean_text(data.get(key, ''), maximum, required)
|
|
|
|
|
try:
|
|
|
|
|
if dt.date.fromisoformat(item['eventDate']).isoformat() != item['eventDate']:
|
|
|
|
|
raise ValueError()
|
|
|
|
|
except ValueError:
|
|
|
|
|
raise a.Problem('记事日期请填写有效日期,格式为 YYYY-MM-DD')
|
2026-10-03 20:27:11 +08:00
|
|
|
for key, maximum, label in [('summary', 400, '摘要'), ('body', 12000, '正文')]:
|
|
|
|
|
value = data.get(key, '')
|
|
|
|
|
if not isinstance(value, str) or len(value) > maximum or any(ord(c) < 32 and c not in '\r\n\t' for c in value):
|
|
|
|
|
raise a.Problem(f'记事{label}格式不正确,最多 {maximum} 字')
|
|
|
|
|
item[key] = value.strip()
|
2026-10-03 20:02:07 +08:00
|
|
|
item['personIds'] = data.get('personIds', [])
|
|
|
|
|
if (not isinstance(item['personIds'], list) or len(item['personIds']) > 200
|
|
|
|
|
or any(not isinstance(p, str) or not a.get_object('people', p) for p in item['personIds'])):
|
|
|
|
|
raise a.Problem('请选择有效的关联人物,最多 200 位')
|
|
|
|
|
item['personIds'] = list(dict.fromkeys(item['personIds']))
|
2026-10-03 20:27:11 +08:00
|
|
|
item['status'] = data.get('status', old.get('status', 'draft') if old else 'draft')
|
2026-10-03 20:02:07 +08:00
|
|
|
item['archived'] = data.get('archived', old.get('archived', False) if old else False)
|
|
|
|
|
if item['status'] not in ('draft', 'published') or not isinstance(item['archived'], bool):
|
|
|
|
|
raise a.Problem('记事状态不正确')
|
|
|
|
|
if item['sourceUrl']:
|
|
|
|
|
try:
|
|
|
|
|
parsed = urlsplit(item['sourceUrl'])
|
|
|
|
|
if parsed.scheme not in ('https', 'http') or not parsed.hostname or parsed.username or parsed.password:
|
|
|
|
|
raise ValueError()
|
|
|
|
|
parsed.port
|
|
|
|
|
except ValueError:
|
|
|
|
|
raise a.Problem('报道链接请填写完整的 http 或 https 地址,不可包含账号密码')
|
|
|
|
|
if item['status'] == 'published' and not (item['body'] or item['summary'] or item['sourceUrl']):
|
|
|
|
|
raise a.Problem('发布前请填写记事正文、摘要或报道链接')
|
|
|
|
|
return item
|
|
|
|
|
|
2026-10-03 17:41:52 +08:00
|
|
|
def person(self, data, old):
|
|
|
|
|
a = self.a
|
|
|
|
|
item = {'id': old['id'] if old else secrets.token_hex(8)}
|
|
|
|
|
for key, maximum in [('name', 80), ('alias', 120), ('birthDate', 10), ('deathDate', 10),
|
|
|
|
|
('birthplace', 160), ('biography', 2000), ('note', 500)]:
|
|
|
|
|
value = data.get(key, '')
|
|
|
|
|
if key in ('biography', 'note'):
|
|
|
|
|
if not isinstance(value, str) or len(value) > maximum or any(ord(c) < 32 and c not in '\r\n\t' for c in value):
|
|
|
|
|
raise a.Problem('生平或备注格式不正确')
|
|
|
|
|
item[key] = value.strip()
|
|
|
|
|
else:
|
|
|
|
|
item[key] = a.clean_text(value, maximum, key == 'name')
|
|
|
|
|
for key in ('birthDate', 'deathDate'):
|
|
|
|
|
if item[key]:
|
|
|
|
|
try:
|
|
|
|
|
if dt.date.fromisoformat(item[key]).isoformat() != item[key]:
|
|
|
|
|
raise ValueError()
|
|
|
|
|
except ValueError:
|
|
|
|
|
raise a.Problem('日期请使用公历 YYYY-MM-DD;不确定可留空,在备注记录')
|
|
|
|
|
if item['birthDate'] and item['deathDate'] and item['birthDate'] > item['deathDate']:
|
|
|
|
|
raise a.Problem('逝世日期不能早于出生日期')
|
|
|
|
|
item['gender'] = data.get('gender', 'unknown')
|
2026-10-03 18:29:18 +08:00
|
|
|
item['lifeStatus'] = data.get('lifeStatus', 'alive')
|
2026-10-03 17:41:52 +08:00
|
|
|
if item['gender'] not in ('male', 'female', 'unknown') or item['lifeStatus'] not in ('alive', 'deceased', 'unknown'):
|
|
|
|
|
raise a.Problem('人物属性不正确')
|
|
|
|
|
if item['deathDate'] and item['lifeStatus'] != 'deceased':
|
|
|
|
|
raise a.Problem('填写逝世日期时,请选择已故')
|
|
|
|
|
item['archived'] = data.get('archived', False)
|
|
|
|
|
if not isinstance(item['archived'], bool):
|
|
|
|
|
raise a.Problem('归档状态不正确')
|
2026-10-03 20:27:11 +08:00
|
|
|
if not old or item['birthDate'] != old.get('birthDate', ''):
|
|
|
|
|
self.check_birth_order([e for e in a.objects('family_links')
|
|
|
|
|
if item['id'] in (e['fromId'], e['toId'])], item)
|
2026-10-03 17:41:52 +08:00
|
|
|
return item
|
|
|
|
|
|
2026-10-03 20:27:11 +08:00
|
|
|
def check_birth_order(self, links, proposed_person=None):
|
|
|
|
|
people = {p['id']: p for p in self.a.objects('people')}
|
|
|
|
|
if proposed_person:
|
|
|
|
|
people[proposed_person['id']] = proposed_person
|
|
|
|
|
for edge in links:
|
|
|
|
|
if not edge.get('active') or edge['kind'] != 'parent' or edge.get('lineage') != 'biological':
|
|
|
|
|
continue
|
|
|
|
|
parent, child = people.get(edge['fromId']), people.get(edge['toId'])
|
|
|
|
|
if parent and child and parent.get('birthDate') and child.get('birthDate') and parent['birthDate'] >= child['birthDate']:
|
|
|
|
|
raise self.a.Problem('亲生父母的出生日期必须早于子女,请核对日期或亲子属性')
|
|
|
|
|
|
2026-10-03 17:41:52 +08:00
|
|
|
def link(self, data, old):
|
|
|
|
|
a = self.a
|
|
|
|
|
left, right = data.get('fromId'), data.get('toId')
|
|
|
|
|
if not isinstance(left, str) or not isinstance(right, str) or not a.get_object('people', left) or not a.get_object('people', right):
|
|
|
|
|
raise a.Problem('请先录入关系双方的人物')
|
|
|
|
|
if left == right:
|
|
|
|
|
raise a.Problem('不能把人物与自己建立亲属关系')
|
|
|
|
|
kind = data.get('kind')
|
|
|
|
|
if kind not in ('parent', 'spouse'):
|
|
|
|
|
raise a.Problem('关系类型不正确')
|
|
|
|
|
lineage = data.get('lineage', 'unspecified')
|
|
|
|
|
if lineage not in ('biological', 'adoptive', 'step', 'unspecified'):
|
|
|
|
|
raise a.Problem('亲子关系属性不正确')
|
|
|
|
|
active = data.get('active', True)
|
|
|
|
|
if not isinstance(active, bool):
|
|
|
|
|
raise a.Problem('关系状态不正确')
|
|
|
|
|
item = dict(id=old['id'] if old else secrets.token_hex(8), fromId=left, toId=right, kind=kind,
|
|
|
|
|
lineage=lineage if kind == 'parent' else 'unspecified', active=active,
|
|
|
|
|
note=a.clean_text(data.get('note', ''), 300))
|
2026-10-03 20:27:11 +08:00
|
|
|
self.check_birth_order([item])
|
2026-10-03 17:41:52 +08:00
|
|
|
links = [e for e in a.objects('family_links') if e.get('active') and e['id'] != item['id']]
|
|
|
|
|
if active:
|
|
|
|
|
for e in links:
|
|
|
|
|
same = (e['fromId'], e['toId']) == (left, right)
|
|
|
|
|
if kind == 'spouse':
|
|
|
|
|
same = {e['fromId'], e['toId']} == {left, right}
|
|
|
|
|
if e['kind'] == kind and same:
|
|
|
|
|
raise a.Problem('这条关系已经存在', 409)
|
|
|
|
|
graph = {}
|
|
|
|
|
for e in links + [item]:
|
|
|
|
|
if e['kind'] == 'parent':
|
|
|
|
|
graph.setdefault(e['fromId'], []).append(e['toId'])
|
|
|
|
|
# Iterative walk supports deep ancestry without Python recursion limits.
|
|
|
|
|
def reaches(start, target):
|
|
|
|
|
pending, seen = [start], set()
|
|
|
|
|
while pending:
|
|
|
|
|
p = pending.pop()
|
|
|
|
|
if p in seen:
|
|
|
|
|
continue
|
|
|
|
|
seen.add(p)
|
|
|
|
|
for child in graph.get(p, []):
|
|
|
|
|
if child == target:
|
|
|
|
|
return True
|
|
|
|
|
pending.append(child)
|
|
|
|
|
return False
|
|
|
|
|
if kind == 'parent' and reaches(right, left):
|
|
|
|
|
raise a.Problem('这条关系会形成祖先与后代循环,请检查方向')
|
|
|
|
|
for e in links + [item]:
|
|
|
|
|
if e['kind'] == 'spouse' and (reaches(e['fromId'], e['toId']) or reaches(e['toId'], e['fromId'])):
|
|
|
|
|
raise a.Problem('配偶不能同时是直系祖先或后代,请检查已有关系')
|
|
|
|
|
return item
|