feat: isolate households and add explicit ownership succession
This commit is contained in:
+150
@@ -0,0 +1,150 @@
|
||||
"""Household ownership and request scope. A site is a location within one household."""
|
||||
import datetime as dt
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
|
||||
|
||||
class Households:
|
||||
TABLES = ('sites', 'assets', 'recorders', 'cameras', 'people', 'family_links', 'family_events', 'users')
|
||||
|
||||
def __init__(self, app):
|
||||
self.a = app
|
||||
|
||||
@staticmethod
|
||||
def manager(user):
|
||||
return bool(user and user.get('role') in ('admin', 'family_admin'))
|
||||
|
||||
def initialize(self):
|
||||
a = self.a
|
||||
with a.LOCK, a.DB:
|
||||
a.DB.execute('CREATE TABLE IF NOT EXISTS households (id TEXT PRIMARY KEY, body TEXT NOT NULL)')
|
||||
default = a.setting('defaultHousehold')
|
||||
if not default:
|
||||
default = secrets.token_hex(8)
|
||||
a.DB.execute('INSERT INTO settings VALUES (?,?)', ('defaultHousehold', json.dumps(default)))
|
||||
item = dict(id=default, name='赵府', note='原有家庭资料', createdAt=dt.datetime.now(dt.timezone.utc).isoformat())
|
||||
a.DB.execute('INSERT INTO households VALUES (?,?)', (default, json.dumps(item)))
|
||||
# One-time ownership backfill preserves identifiers, credentials and device paths.
|
||||
for table in self.TABLES:
|
||||
for row in a.DB.execute('SELECT id,body FROM ' + table).fetchall():
|
||||
item = json.loads(row['body'])
|
||||
if 'familyId' not in item:
|
||||
item['familyId'] = default
|
||||
a.DB.execute('UPDATE ' + table + ' SET body=? WHERE id=?', (json.dumps(item), row['id']))
|
||||
for row in a.DB.execute('SELECT revision,before_json,after_json FROM family_changes').fetchall():
|
||||
for column in ('before_json', 'after_json'):
|
||||
item = json.loads(row[column]) if row[column] else None
|
||||
if item is not None and 'familyId' not in item:
|
||||
item['familyId'] = default
|
||||
a.DB.execute('UPDATE family_changes SET ' + column + '=? WHERE revision=?',
|
||||
(json.dumps(item), row['revision']))
|
||||
|
||||
def family_of(self, item):
|
||||
# Legacy in-process import helpers remain in the migrated default household.
|
||||
return item.get('familyId', self.a.setting('defaultHousehold')) if item is not None else None
|
||||
|
||||
def scope(self, user):
|
||||
family = (user or {}).get('_familyId') or self.family_of(user or {})
|
||||
if not family or not self.a.get_object('households', family):
|
||||
raise self.a.Problem('账号尚未分配有效家庭,请联系平台管理员', 403)
|
||||
return family
|
||||
|
||||
def context(self, user, requested=None):
|
||||
if not user:
|
||||
return None
|
||||
own = self.family_of(user)
|
||||
if requested and user['role'] != 'admin' and requested != own:
|
||||
raise self.a.Problem('没有这个家庭的访问权限', 403)
|
||||
result = dict(user, _familyId=requested or own)
|
||||
self.scope(result)
|
||||
return result
|
||||
|
||||
def owns(self, user, item):
|
||||
if not item or self.family_of(item) != self.scope(user):
|
||||
raise self.a.Problem('记录不存在或不属于当前家庭', 404)
|
||||
return item
|
||||
|
||||
def objects(self, user, table):
|
||||
family = self.scope(user)
|
||||
return [x for x in self.a.objects(table) if self.family_of(x) == family]
|
||||
|
||||
def prepare(self, user, table, body, references=()):
|
||||
family = self.scope(user)
|
||||
if body.get('familyId', family) != family:
|
||||
raise self.a.Problem('不能修改记录的家庭归属', 403)
|
||||
if body.get('id'):
|
||||
if not isinstance(body['id'], str) or not re.fullmatch('[a-f0-9]{16}', body['id']):
|
||||
raise self.a.Problem('对象编号不正确')
|
||||
self.owns(user, self.a.get_object(table, body['id']))
|
||||
for target, key in references:
|
||||
if body.get(key):
|
||||
self.owns(user, self.a.get_object(target, body[key]))
|
||||
return family
|
||||
|
||||
def endpoint(self, user, host):
|
||||
family = self.scope(user)
|
||||
if any(item.get('host') == host and self.family_of(item) != family
|
||||
for table in ('cameras', 'recorders') for item in self.a.objects(table)):
|
||||
raise self.a.Problem('该设备地址已归属其他家庭,不能重复接入', 403)
|
||||
|
||||
def list(self, user):
|
||||
rows = self.a.objects('households') if user['role'] == 'admin' else [self.a.get_object('households', self.scope(user))]
|
||||
def label(uid):
|
||||
account = self.a.get_object('users', uid)
|
||||
return account.get('name', '') if account else ''
|
||||
return [dict({k: v for k, v in row.items() if k != 'leadershipHistory' or self.manager(user)}, ownerName=label(row.get('ownerId')), successorName=label(row.get('successorId')),
|
||||
canAssignOwner=user['role'] == 'admin' or row.get('ownerId') == user['id'],
|
||||
members=sum(self.family_of(u) == row['id'] for u in self.a.objects('users')),
|
||||
sites=sum(self.family_of(s) == row['id'] for s in self.a.objects('sites'))) for row in rows]
|
||||
|
||||
def leadership(self, data, user):
|
||||
"""Explicit handover; a backup already has management access during an emergency."""
|
||||
a = self.a
|
||||
with a.LOCK:
|
||||
home = a.get_object('households', data.get('id'))
|
||||
if not home or home['id'] != self.scope(user):
|
||||
raise a.Problem('请先进入要管理的家庭', 404)
|
||||
if user['role'] != 'admin' and user['id'] != home.get('ownerId'):
|
||||
raise a.Problem('只有一家之主或超级管理员可以交接管理权', 403)
|
||||
if data.get('revision') != home.get('leadershipRevision', 0):
|
||||
raise a.Problem('家庭负责人已变更,请重新载入', 409)
|
||||
owner_id = a.clean_text(data.get('ownerId', ''), 32, True)
|
||||
successor_id = a.clean_text(data.get('successorId', ''), 32)
|
||||
reason = a.clean_text(data.get('reason', ''), 300, True)
|
||||
if owner_id == successor_id:
|
||||
raise a.Problem('备用负责人必须是另一位家庭管理员')
|
||||
for uid in filter(None, (owner_id, successor_id)):
|
||||
account = self.owns(user, a.get_object('users', uid))
|
||||
if account['role'] != 'family_admin' or account['disabled']:
|
||||
raise a.Problem('负责人须为本家庭已启用的家庭管理员')
|
||||
record = dict(at=dt.datetime.now(dt.timezone.utc).isoformat(), actor=user['username'], reason=reason,
|
||||
previousOwnerId=home.get('ownerId', ''), ownerId=owner_id, successorId=successor_id)
|
||||
home.update(ownerId=owner_id, successorId=successor_id,
|
||||
leadershipRevision=home.get('leadershipRevision', 0) + 1,
|
||||
leadershipHistory=(home.get('leadershipHistory', []) + [record])[-50:])
|
||||
a.save_object('households', home)
|
||||
a.audit('家庭负责人交接', user['username'] + ' / ' + home['name'])
|
||||
return {'ok': True}
|
||||
|
||||
def save(self, data, user):
|
||||
a = self.a
|
||||
if not self.manager(user):
|
||||
raise a.Problem('需要家庭管理员权限', 403)
|
||||
with a.LOCK:
|
||||
old = a.get_object('households', data.get('id'))
|
||||
if data.get('id') and not old:
|
||||
raise a.Problem('家庭不存在', 404)
|
||||
if user['role'] != 'admin' and (not old or old['id'] != self.scope(user)):
|
||||
raise a.Problem('只能管理自己所属的家庭', 403)
|
||||
item = dict(old or {}, id=old['id'] if old else secrets.token_hex(8),
|
||||
name=a.clean_text(data.get('name', ''), 80, True),
|
||||
note=a.clean_text(data.get('note', ''), 300),
|
||||
createdAt=old['createdAt'] if old else dt.datetime.now(dt.timezone.utc).isoformat())
|
||||
a.save_object('households', item)
|
||||
a.audit('保存家庭', item['name'])
|
||||
return item
|
||||
|
||||
def revision_key(self, user):
|
||||
family = self.scope(user)
|
||||
return 'familyRevision' if family == self.a.setting('defaultHousehold') else 'familyRevision:' + family
|
||||
Reference in New Issue
Block a user