Files

260 lines
15 KiB
Python
Raw Permalink Normal View History

"""Household ownership and request scope. A site is a location within one household."""
import datetime as dt
import json
import re
import secrets
import hashlib
from appearance import png_bytes
import geography
class Households:
TABLES = ('sites', 'assets', 'recorders', 'cameras', 'devices', '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)')
a.DB.execute('CREATE TABLE IF NOT EXISTS devices (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 site_ids(self, user):
"""Optional branch grants, always restricted to the account's own organization."""
sites = self.objects(user, 'sites')
ids = {s['id'] for s in sites if s['id'] in user.get('siteIds', [])}
if user.get('includeSubspaces', False):
children = {}
for site in sites:
children.setdefault(site.get('parentId'), []).append(site['id'])
queue = list(ids)
for parent in queue:
for child in children.get(parent, []):
if child not in ids:
ids.add(child)
queue.append(child)
return ids
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 ''
inventory = [x for table in ('devices','assets','recorders') for x in self.a.objects(table)]
def network_summary(home):
devices = [x for x in inventory if self.family_of(x) == home['id'] and
(self.manager(user) or x.get('siteId') in self.site_ids(user))]
return dict(deviceCount=len(devices), networkAccesses=sorted({x.get('networkAccess','unknown') for x in devices}))
return [dict({k: v for k, v in row.items() if k != 'coverData' and (k != 'leadershipHistory' or self.manager(user))}, ownerName=label(row.get('ownerId')), successorName=label(row.get('successorId')),
**network_summary(row),
coverUrl=('/api/households/cover?id='+row['id']+'&v='+row.get('coverRevision','0')) if row.get('coverData') or row.get('coverRef') else '',
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 layout(self, user):
saved = self.a.setting('householdLayout:'+user['id'], {})
allowed = {h['id'] for h in self.list(user)}
return dict(revision=saved.get('revision', 0), columns=saved.get('columns', 2), rows=saved.get('rows', 2),
groupCity=saved.get('groupCity', False), order=[x for x in saved.get('order', []) if x in allowed])
def save_layout(self, data, user):
a = self.a
with a.LOCK:
old = self.layout(user)
if set(data) != {'revision','columns','rows','groupCity','order'}:
raise a.Problem('卡片布局参数不正确')
if type(data['revision']) is not int or data['revision'] != old['revision']:
raise a.Problem('布局已被更新,请刷新后重新排列', 409)
for key in ('columns','rows'):
if type(data[key]) is not int or not 1 <= data[key] <= 4:
raise a.Problem('行数和列数须为 1 到 4')
order = data['order']
allowed = {h['id'] for h in self.list(user)}
if type(data['groupCity']) is not bool or not isinstance(order, list) or len(order) > 1000 or any(not isinstance(x,str) or x not in allowed for x in order) or len(order) != len(set(order)):
raise a.Problem('请选择有权访问的家庭/场所,排列不能重复')
a.set_setting('householdLayout:'+user['id'], dict(data, revision=old['revision']+1))
return self.layout(user)
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('备用负责人必须是另一位家庭管理员')
grant = data.get('grantManagement', False)
if type(grant) is not bool:
raise a.Problem('管理员授权确认格式不正确')
promotions = []
for uid in filter(None, (owner_id, successor_id)):
account = self.owns(user, a.get_object('users', uid))
if account['disabled'] or account['role'] not in ('family_admin', 'member'):
raise a.Problem('请选择本家庭已启用的成员或家庭管理员')
if account['role'] == 'member':
if not grant:
raise a.Problem('所选成员还不是家庭管理员,请确认授予本家庭管理权限')
promotions.append(dict(account, role='family_admin'))
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,
promotedUserIds=[u['id'] for u in promotions])
home.update(ownerId=owner_id, successorId=successor_id,
leadershipRevision=home.get('leadershipRevision', 0) + 1,
leadershipHistory=(home.get('leadershipHistory', []) + [record])[-50:])
# Validate both choices first, then commit role grants and succession together.
with a.DB:
for account in promotions:
a.DB.execute('UPDATE users SET body=? WHERE id=?', (json.dumps(account), account['id']))
a.DB.execute('DELETE FROM sessions WHERE user_id=?', (account['id'],))
a.DB.execute('UPDATE households SET body=? WHERE id=?', (json.dumps(home), home['id']))
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)
if type(data.get('profileRevision', 0)) is not int or data.get('profileRevision', 0) != (old or {}).get('profileRevision', 0):
raise a.Problem('家庭档案已被更新,请重新打开后保存,避免覆盖他人的修改', 409)
kind = data.get('kind', old.get('kind', 'home') if old else 'home')
if kind not in ('home', 'office', 'factory'):
raise a.Problem('请选择家庭、办公场所或工厂')
item = dict(old or {}, id=old['id'] if old else secrets.token_hex(8),
name=a.clean_text(data.get('name', ''), 80, True),
kind=kind,
note=a.clean_text(data.get('note', ''), 300),
profileRevision=(old or {}).get('profileRevision', 0)+1,
createdAt=old['createdAt'] if old else dt.datetime.now(dt.timezone.utc).isoformat())
for field in ('province','city','district'):
item[field] = a.clean_text(data.get(field, (old or {}).get(field, '')), 60)
geography.apply(item, data, old, a.clean_text, a.Problem)
if 'coverData' in data:
cover = data['coverData']
if not isinstance(cover, str): raise a.Problem('封面图片格式不正确')
if cover:
try: png_bytes(cover, 2*1024*1024)
except ValueError as error: raise a.Problem(str(error).replace('Logo', '封面'))
# Network I/O must not hold the database lock or stall unrelated requests.
if 'coverData' in data:
ref=a.CLOUD_IMAGES.put(data['coverData'],item['id'],'封面') if data['coverData'] else None
item.pop('coverData',None);item.pop('coverRef',None)
if ref:item['coverRef']=ref
item['coverRevision']=ref['sha256'][:16] if ref else 'default'
with a.LOCK:
current=a.get_object('households',item['id'])
if (current or {}).get('profileRevision',0)!=(old or {}).get('profileRevision',0):
raise a.Problem('上传期间档案已变化,请重新打开后保存',409)
# Preserve unrelated management/grouping updates made during the upload.
committed=dict(current or {},**{k:v for k,v in item.items() if k not in ('leadershipHistory','ownerId','successorId','leadershipRevision','deviceGrouping')})
if 'coverData' in data:committed.pop('coverData',None);committed.pop('coverRef',None)
if item.get('coverRef'):committed['coverRef']=item['coverRef']
a.save_object('households', committed)
a.audit('保存家庭', item['name'])
return committed
def revision_key(self, user):
family = self.scope(user)
return 'familyRevision' if family == self.a.setting('defaultHousehold') else 'familyRevision:' + family
def save_view(self, data, user):
if not self.manager(user):
raise self.a.Problem('需要场所管理员权限', 403)
groups = data.get('groups')
if (not isinstance(groups, list) or len(groups) > 5 or
any(g not in ('type', 'space', 'manager', 'owner', 'tag') for g in groups) or len(set(groups)) != len(groups)):
raise self.a.Problem('分组维度不可重复,请选择类型、区域、负责人、所有人或标签')
with self.a.LOCK:
home = self.a.get_object('households', self.scope(user))
home['deviceGrouping'] = groups
self.a.save_object('households', home)
self.a.audit('更新设备分组方式', home['name'])
return {'ok': True}