Files
ucvl-home-vision/genealogy.py
T

357 lines
22 KiB
Python

"""A private family register, independent from login accounts and camera roles."""
import geography
import datetime as dt
import json
import secrets
import family_permissions as permissions
from urllib.parse import urlsplit
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);
CREATE TABLE IF NOT EXISTS family_events (id TEXT PRIMARY KEY, body TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS family_changes (
revision INTEGER PRIMARY KEY, kind TEXT NOT NULL, target_id TEXT NOT NULL,
before_json TEXT, after_json TEXT NOT NULL, actor TEXT NOT NULL,
at TEXT NOT NULL, restored_from INTEGER);
CREATE INDEX IF NOT EXISTS family_changes_target ON family_changes(kind,target_id,revision);
''')
self.a.DB.commit()
def authorize(self, user, edit=False):
self.a.HOUSEHOLDS.scope(user)
if not self.a.HOUSEHOLDS.manager(user) 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:
can_edit = self.a.HOUSEHOLDS.manager(user) or user.get('familyAccess') == 'edit'
people = self.a.HOUSEHOLDS.objects(user, 'people')
links = self.a.HOUSEHOLDS.objects(user, 'family_links')
scope = self.edit_scope(user, people, links)
return {'revision': self.a.setting(self.a.HOUSEHOLDS.revision_key(user), 0), 'people': people,
'links': links,
'events': [e for e in self.a.HOUSEHOLDS.objects(user, 'family_events')
if can_edit or (e.get('status') == 'published' and not e.get('archived'))],
'canEdit': can_edit and bool(scope['levels']) and not scope['conflict'],
'editScope': scope,
'selfId': scope['selfId'],
'familyId': self.a.HOUSEHOLDS.scope(user)}
def edit_scope(self, user, people=None, links=None):
a = self.a
people = a.HOUSEHOLDS.objects(user, 'people') if people is None else people
links = a.HOUSEHOLDS.objects(user, 'family_links') if links is None else links
# Always use the account's binding, never a selected graph reference or submitted ID.
account = a.get_object('users', user.get('id')) if user.get('id') else None
self_id = (account or user).get('personId', '')
levels, conflict = permissions.generations(people, links, self_id)
granted = a.HOUSEHOLDS.manager(user) or user.get('familyAccess') == 'edit'
editable = [key for key in levels if permissions.within(levels, key)] if granted and not conflict else []
note = ('族谱维护限本人上下各五代,所有角色(含超级管理员)相同;远代仍可按权限查看。'
if levels else '尚未关联本家庭族谱中的本人,暂不能维护。请在成员与权限中关联本人。')
if conflict:
note = '亲属关系存在代际冲突,无法可靠确定五代范围,已暂时锁定维护。'
can_start = bool(granted and account and not account.get('personId')
and a.HOUSEHOLDS.family_of(account) == a.HOUSEHOLDS.scope(user))
return dict(limit=permissions.LIMIT, selfId=self_id if levels else '', levels=levels,
conflict=conflict, editablePersonIds=editable, canCreateSelf=can_start, note=note)
def check_write(self, user, kind, item, old=None, proposed_people=None, proposed_links=None):
scope = self.edit_scope(user)
if not scope['levels'] or scope['conflict']:
raise self.a.Problem(scope['note'], 403)
levels = scope['levels']
if old and any(not permissions.within(levels, key) for key in permissions.targets(kind, old)):
raise self.a.Problem('该资料超出本人上下五代维护范围,所有角色均只能查看', 403)
if (kind == 'person' and old is not None) or kind == 'event':
if any(not permissions.within(levels, key) for key in permissions.targets(kind, item)):
raise self.a.Problem('只能维护本人上下五代内且已连接的亲属资料', 403)
if proposed_links is not None:
next_levels, conflict = permissions.generations(proposed_people, proposed_links, scope['selfId'])
if conflict:
raise self.a.Problem('这条关系会产生代际冲突,不能据此确定维护范围', 409)
# Prevent moving/disconnecting an existing branch and reattaching it closer
# to oneself to circumvent the five-generation rule.
if any(next_levels.get(key) != value for key, value in levels.items()):
raise self.a.Problem('不能改变已确认人物的代际位置或断开其世系;请先补充同代的正确关系', 403)
if any(not permissions.within(next_levels, key) for key in permissions.targets(kind, item)):
raise self.a.Problem('新增关系或人物超出本人上下五代维护范围', 403)
if kind == 'link' and not any(permissions.within(levels, key) for key in permissions.targets(kind, item)):
raise self.a.Problem('请从本人上下五代内的已知亲属连接关系', 403)
def save(self, kind, data, user, restored_from=None):
self.authorize(user, True)
a = self.a
with a.LOCK:
revision_key = a.HOUSEHOLDS.revision_key(user)
revision = a.setting(revision_key, 0)
if type(data.get('revision')) is not int or data['revision'] != revision:
raise a.Problem('家谱已被更新,请重新载入后再编辑', 409)
if kind not in ('person', 'link', 'event'):
raise a.Problem('家谱记录类型不存在', 404)
table = {'person': 'people', 'link': 'family_links', 'event': 'family_events'}[kind]
family_id = a.HOUSEHOLDS.prepare(user, table, data,
(('people', 'fromId'), ('people', 'toId')) if kind == 'link' else ())
if kind == 'event' and isinstance(data.get('personIds', []), list):
for person in data.get('personIds', []):
a.HOUSEHOLDS.owns(user, a.get_object('people', person))
old = a.get_object(table, data.get('id'))
if data.get('id') and not old:
raise a.Problem('家谱记录不存在', 404)
item = {'person': self.person, 'link': self.link, 'event': self.event}[kind](data, old)
item.update(familyId=family_id, updatedAt=dt.datetime.now(dt.timezone.utc).isoformat(), updatedBy=user['username'])
added_link = None
bind_self = data.get('asSelf', False)
if not isinstance(bind_self, bool):
raise a.Problem('本人关联设置不正确')
if bind_self and (kind != 'person' or old or not self.edit_scope(user)['canCreateSelf']):
raise a.Problem('已有本人关联,不能用另一个人物切换维护范围', 403)
people = a.HOUSEHOLDS.objects(user, 'people')
links = a.HOUSEHOLDS.objects(user, 'family_links')
if kind == 'person' and not old and not bind_self:
relative = data.get('relativeId')
a.HOUSEHOLDS.owns(user, a.get_object('people', relative))
relation = data.get('relativeKind')
if relation not in ('parent', 'child', 'spouse'):
raise a.Problem('新增人物时请选择与已有亲属的关系')
if relative not in self.edit_scope(user)['editablePersonIds']:
raise a.Problem('请选择本人上下五代内可维护的亲属', 403)
added_link = dict(id=secrets.token_hex(8), fromId=item['id'] if relation == 'parent' else relative,
toId=relative if relation == 'parent' else item['id'],
kind='spouse' if relation == 'spouse' else 'parent', active=True,
lineage='unspecified', note='', familyId=family_id,
updatedAt=item['updatedAt'], updatedBy=user['username'])
self.check_write(user, kind, item, proposed_people=people+[item], proposed_links=links+[added_link])
elif kind == 'link':
self.check_write(user, kind, item, old, people, [e for e in links if e['id'] != item['id']]+[item])
elif not bind_self:
self.check_write(user, kind, item, old)
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'])
# 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']
# Revision and record are committed together, so concurrent forms cannot overwrite silently.
with a.DB:
change_revision = a.DB.execute('SELECT COALESCE(MAX(revision),0)+1 FROM family_changes').fetchone()[0]
a.DB.execute('INSERT OR REPLACE INTO ' + table + ' VALUES (?,?)', (item['id'], json.dumps(item)))
a.DB.execute('INSERT OR REPLACE INTO settings VALUES (?,?)', (revision_key, json.dumps(revision + 1)))
a.DB.execute('INSERT INTO family_changes VALUES (?,?,?,?,?,?,?,?)',
(change_revision, kind, item['id'], json.dumps(old) if old else None,
json.dumps(item), user['username'], item['updatedAt'], restored_from))
if added_link:
a.DB.execute('INSERT INTO family_links VALUES (?,?)', (added_link['id'], json.dumps(added_link)))
a.DB.execute('INSERT INTO family_changes VALUES (?,?,?,?,?,?,?,?)',
(change_revision+1, 'link', added_link['id'], None, json.dumps(added_link),
user['username'], item['updatedAt'], None))
if bind_self:
account = dict(a.get_object('users', user['id']), personId=item['id'])
a.DB.execute('UPDATE users SET body=? WHERE id=?', (json.dumps(account), user['id']))
a.audit({'person': '更新家谱人物', 'link': '更新家谱关系', 'event': '更新家族记事'}[kind], user['username'])
return {'item': item, 'revision': revision + 1, 'changeRevision': change_revision}
def history(self, user, kind, target_id, before=None):
# Previous drafts and withdrawn text are visible only to family editors.
self.authorize(user, True)
a = self.a
table = {'person': 'people', 'link': 'family_links', 'event': 'family_events'}.get(kind)
with a.LOCK:
if not table or not a.get_object(table, target_id):
raise a.Problem('家谱记录不存在', 404)
a.HOUSEHOLDS.owns(user, a.get_object(table, target_id))
if before is not None:
a.integer(before, 1, 2**53 - 1)
rows = a.DB.execute('SELECT * FROM family_changes WHERE kind=? AND target_id=? AND revision<? '
'ORDER BY revision DESC LIMIT 21', (kind, target_id, before or 2**53 - 1)).fetchall()
items = []
for row in rows[:20]:
old, new = json.loads(row['before_json'] or '{}'), json.loads(row['after_json'])
fields = [k for k in new if k not in ('id', 'updatedAt', 'updatedBy', 'createdAt', 'createdBy', 'publishedAt')
and old.get(k) != new[k]]
items.append(dict(revision=row['revision'], at=row['at'], actor=row['actor'], fields=fields,
created=row['before_json'] is None, restoredFrom=row['restored_from']))
return dict(items=items, nextBefore=items[-1]['revision'] if len(rows) > 20 else None,
revision=a.setting(a.HOUSEHOLDS.revision_key(user), 0))
def change(self, user, change_revision):
self.authorize(user, True)
self.a.integer(change_revision, 1, 2**53 - 1)
with self.a.LOCK:
row = self.a.DB.execute('SELECT * FROM family_changes WHERE revision=?', (change_revision,)).fetchone()
if not row:
raise self.a.Problem('修改记录不存在', 404)
self.a.HOUSEHOLDS.owns(user, json.loads(row['after_json']))
return dict(revision=row['revision'], kind=row['kind'], targetId=row['target_id'],
before=json.loads(row['before_json']) if row['before_json'] else None,
after=json.loads(row['after_json']), at=row['at'], actor=row['actor'])
def restore(self, data, user):
self.authorize(user, True)
with self.a.LOCK:
change = self.change(user, data.get('changeRevision'))
side = data.get('side')
if side not in ('before', 'after') or change[side] is None:
raise self.a.Problem('请选择有效的历史版本;创建前没有可恢复的内容')
# Reuse all current relationship/date/publication validation. Restore is a new edit,
# not an overwrite of history or a rollback of other people's records.
return self.save(change['kind'], dict(change[side], revision=data.get('revision')),
user, restored_from=change['revision'])
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),
('sourceName', 160, False),
('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')
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()
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']))
item['status'] = data.get('status', old.get('status', 'draft') if old else 'draft')
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
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')
# Keep the legacy combined field verbatim. New structured locations are optional;
# old clients updating another field must not erase them.
for prefix in ('native', 'birth'):
for field in ('Country', 'Province', 'City', 'District', 'Town', 'Community', 'Address'):
key = prefix+field
value = data.get(key, (old or {}).get(key, ''))
if not isinstance(value, str): raise a.Problem('地域信息须为文字')
item[key] = a.clean_text(value, 200 if field == 'Address' else 80)
for prefix in ('native', 'birth'):
geography.apply(item, data, old, a.clean_text, a.Problem, prefix)
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')
item['lifeStatus'] = data.get('lifeStatus', 'alive')
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('归档状态不正确')
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)
return item
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('亲生父母的出生日期必须早于子女,请核对日期或亲子属性')
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))
self.check_birth_order([item])
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