inital commit, very early alpha stage

This commit is contained in:
2026-06-30 13:38:24 +02:00
parent f5530ad336
commit 70cf1a84ca
72 changed files with 14074 additions and 2 deletions

View File

@@ -0,0 +1,2 @@
"""Application services."""

View File

@@ -0,0 +1,194 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from fastapi import Depends, HTTPException, Request, Response, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.security import hash_token, session_expiry, token_urlsafe, utc_now
from app.db.base import get_db
from app.models import (
AppSession,
AuditLog,
HomeDevice,
HomeProfile,
Member,
MemberDevice,
)
@dataclass
class CurrentContext:
session: AppSession | None
home_profile: HomeProfile | None
member: Member | None
home_device: HomeDevice | None
member_device: MemberDevice | None
@property
def authenticated(self) -> bool:
return self.session is not None
def _expired(dt: datetime) -> bool:
now = utc_now()
if dt.tzinfo is None:
return dt < now.replace(tzinfo=None)
return dt < now
def create_session(
db: Session,
*,
home_profile: HomeProfile | None = None,
member: Member | None = None,
home_device: HomeDevice | None = None,
member_device: MemberDevice | None = None,
) -> tuple[AppSession, str]:
csrf_token = token_urlsafe(24)
session = AppSession(
home_profile_id=home_profile.id if home_profile else None,
member_id=member.id if member else None,
home_device_id=home_device.id if home_device else None,
member_device_id=member_device.id if member_device else None,
csrf_token_hash=hash_token(csrf_token),
expires_at=session_expiry(),
)
db.add(session)
db.flush()
return session, csrf_token
def set_session_cookies(response: Response, session: AppSession, csrf_token: str) -> None:
settings = get_settings()
response.set_cookie(
settings.session_cookie_name,
session.id,
httponly=True,
secure=settings.cookie_secure,
samesite="lax",
max_age=60 * 60 * 24 * 30,
path="/",
)
response.set_cookie(
"grouphome_csrf",
csrf_token,
httponly=False,
secure=settings.cookie_secure,
samesite="lax",
max_age=60 * 60 * 24 * 30,
path="/",
)
def clear_session_cookies(response: Response) -> None:
settings = get_settings()
response.delete_cookie(settings.session_cookie_name, path="/")
response.delete_cookie("grouphome_csrf", path="/")
def load_context_from_request(request: Request, db: Session) -> CurrentContext:
settings = get_settings()
session_id = request.cookies.get(settings.session_cookie_name)
if not session_id:
return CurrentContext(None, None, None, None, None)
session = db.get(AppSession, session_id)
if not session or session.revoked_at or _expired(session.expires_at):
return CurrentContext(None, None, None, None, None)
home_profile = db.get(HomeProfile, session.home_profile_id) if session.home_profile_id else None
member = db.get(Member, session.member_id) if session.member_id else None
home_device = db.get(HomeDevice, session.home_device_id) if session.home_device_id else None
member_device = db.get(MemberDevice, session.member_device_id) if session.member_device_id else None
now = utc_now()
if home_device and not home_device.revoked_at:
home_device.last_seen_at = now
if member_device and not member_device.revoked_at:
member_device.last_seen_at = now
db.flush()
return CurrentContext(session, home_profile, member, home_device, member_device)
def get_optional_context(request: Request, db: Session = Depends(get_db)) -> CurrentContext:
return load_context_from_request(request, db)
def get_current_context(request: Request, db: Session = Depends(get_db)) -> CurrentContext:
ctx = load_context_from_request(request, db)
if not ctx.authenticated:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"error": {"code": "not_authenticated", "message": "Open an invite or recover access to continue.", "details": {}}},
)
return ctx
def get_members_for_context(db: Session, ctx: CurrentContext) -> list[Member]:
if ctx.home_profile:
return list(
db.scalars(
select(Member).where(
Member.home_profile_id == ctx.home_profile.id,
Member.status.in_(["joined", "verified"]),
)
).all()
)
if ctx.member:
return [ctx.member]
return []
def get_member_for_group(db: Session, ctx: CurrentContext, group_id: str) -> Member | None:
if ctx.member and ctx.member.group_id == group_id and ctx.member.status != "left":
return ctx.member
if ctx.home_profile:
return db.scalar(
select(Member).where(
Member.home_profile_id == ctx.home_profile.id,
Member.group_id == group_id,
Member.status.in_(["joined", "verified"]),
)
)
return None
def ensure_home_profile(db: Session, ctx: CurrentContext, display_name: str | None = None) -> HomeProfile:
if ctx.home_profile:
return ctx.home_profile
name = display_name or (ctx.member.display_name if ctx.member else "GroupHome member")
profile = HomeProfile(primary_display_name=name)
db.add(profile)
db.flush()
if ctx.member and ctx.member.home_profile_id is None:
ctx.member.home_profile_id = profile.id
if ctx.session:
ctx.session.home_profile_id = profile.id
ctx.home_profile = profile
db.flush()
return profile
def audit(
db: Session,
*,
ctx: CurrentContext | None = None,
action: str,
resource_type: str = "",
resource_id: str = "",
details: dict | None = None,
) -> None:
db.add(
AuditLog(
actor_member_id=ctx.member.id if ctx and ctx.member else None,
actor_home_profile_id=ctx.home_profile.id if ctx and ctx.home_profile else None,
action=action,
resource_type=resource_type,
resource_id=resource_id,
details_json=details or {},
)
)

View File

@@ -0,0 +1,341 @@
from __future__ import annotations
from datetime import timedelta
from typing import Any
from sqlalchemy import desc, func, select
from sqlalchemy.orm import Session
from app.core.security import utc_now
from app.models import (
Announcement,
Event,
EventRsvp,
FileAsset,
Group,
Member,
Poll,
PollOption,
PollVote,
RemoteCachedObject,
RemoteServerConnection,
Task,
Thread,
)
from app.services.auth import CurrentContext, get_members_for_context
from app.services.serializers import (
action_dict,
announcement_dict,
event_dict,
file_dict,
poll_dict,
remote_connection_dict,
task_dict,
thread_dict,
)
PRIORITY_ORDER = {"urgent": 0, "high": 1, "normal": 2, "low": 3}
def _db_now():
return utc_now().replace(tzinfo=None)
def _member_rsvp_status(db: Session, event_id: str, member_id: str) -> str:
rsvp = db.scalar(select(EventRsvp).where(EventRsvp.event_id == event_id, EventRsvp.member_id == member_id))
return rsvp.status if rsvp else "unknown"
def _local_actions_for_member(db: Session, member: Member) -> list[dict[str, Any]]:
group = db.get(Group, member.group_id)
if not group:
return []
now = _db_now()
actions: list[dict[str, Any]] = []
events = db.scalars(select(Event).where(Event.group_id == group.id, Event.starts_at >= now - timedelta(days=1))).all()
for event in events:
status = _member_rsvp_status(db, event.id, member.id)
if event.rsvp_required and status == "unknown":
actions.append(
action_dict(
id=f"local:rsvp:{member.id}:{event.id}",
source_type="local",
source_server_origin=group.server_origin,
source_group_id=group.id,
source_group_name=group.name,
type="rsvp_required",
priority="urgent" if event.starts_at <= now + timedelta(days=2) else "high",
title=f"RSVP: {event.title}",
summary=event.location_name or "Let the group know if you can make it.",
object_type="event",
object_id=event.id,
due_at=event.starts_at,
)
)
if event.changed_at and (not member.last_seen_at or event.changed_at > member.last_seen_at):
actions.append(
action_dict(
id=f"local:event_changed:{member.id}:{event.id}",
source_type="local",
source_server_origin=group.server_origin,
source_group_id=group.id,
source_group_name=group.name,
type="event_changed",
priority="high",
title=f"Changed: {event.title}",
summary="Time or location changed since your last visit.",
object_type="event",
object_id=event.id,
due_at=event.starts_at,
)
)
tasks = db.scalars(select(Task).where(Task.assigned_to_member_id == member.id, Task.status == "open")).all()
for task in tasks:
actions.append(
action_dict(
id=f"local:task:{member.id}:{task.id}",
source_type="local",
source_server_origin=group.server_origin,
source_group_id=group.id,
source_group_name=group.name,
type="task_assigned",
priority="high" if task.due_at and task.due_at <= now + timedelta(days=3) else "normal",
title=task.title,
summary=task.description or "Task assigned to you.",
object_type="task",
object_id=task.id,
due_at=task.due_at,
)
)
polls = db.scalars(select(Poll).where(Poll.group_id == group.id, Poll.status == "open")).all()
for poll in polls:
existing_vote = db.scalar(select(PollVote).where(PollVote.poll_id == poll.id, PollVote.member_id == member.id))
if not existing_vote:
actions.append(
action_dict(
id=f"local:poll:{member.id}:{poll.id}",
source_type="local",
source_server_origin=group.server_origin,
source_group_id=group.id,
source_group_name=group.name,
type="vote_required",
priority="high" if poll.closes_at and poll.closes_at <= now + timedelta(days=2) else "normal",
title=poll.title,
summary=poll.description or "Add your vote.",
object_type="poll",
object_id=poll.id,
due_at=poll.closes_at,
)
)
files = db.scalars(select(FileAsset).where(FileAsset.group_id == group.id, FileAsset.requires_ack.is_(True))).all()
for file in files:
actions.append(
action_dict(
id=f"local:file_ack:{member.id}:{file.id}",
source_type="local",
source_server_origin=group.server_origin,
source_group_id=group.id,
source_group_name=group.name,
type="file_ack",
priority="normal",
title=f"Read: {file.filename_original}",
summary=file.description or "Please review this file.",
object_type="file",
object_id=file.id,
due_at=None,
)
)
announcements = db.scalars(
select(Announcement).where(Announcement.group_id == group.id, Announcement.requires_ack.is_(True), Announcement.official.is_(True))
).all()
for announcement in announcements:
actions.append(
action_dict(
id=f"local:announcement_ack:{member.id}:{announcement.id}",
source_type="local",
source_server_origin=group.server_origin,
source_group_id=group.id,
source_group_name=group.name,
type="admin_request",
priority="urgent" if announcement.priority == "urgent" else "normal",
title=announcement.title,
summary=announcement.body[:180],
object_type="announcement",
object_id=announcement.id,
due_at=None,
)
)
return actions
def local_actions_for_context(db: Session, ctx: CurrentContext) -> list[dict[str, Any]]:
actions: list[dict[str, Any]] = []
for member in get_members_for_context(db, ctx):
actions.extend(_local_actions_for_member(db, member))
return sort_actions(actions)
def sort_actions(actions: list[dict[str, Any]]) -> list[dict[str, Any]]:
return sorted(
actions,
key=lambda item: (
PRIORITY_ORDER.get(item.get("priority", "normal"), 9),
item.get("due_at") or "9999-12-31T00:00:00",
item.get("title") or "",
),
)
def remote_items_for_context(db: Session, ctx: CurrentContext, object_type: str | None = None) -> list[dict[str, Any]]:
if not ctx.home_profile:
return []
connections = db.scalars(select(RemoteServerConnection).where(RemoteServerConnection.home_profile_id == ctx.home_profile.id)).all()
connection_ids = [connection.id for connection in connections]
if not connection_ids:
return []
query = select(RemoteCachedObject).where(RemoteCachedObject.remote_connection_id.in_(connection_ids))
if object_type:
query = query.where(RemoteCachedObject.object_type == object_type)
rows = db.scalars(query).all()
connection_by_id = {connection.id: connection for connection in connections}
items: list[dict[str, Any]] = []
for row in rows:
payload = dict(row.payload_json or {})
connection = connection_by_id.get(row.remote_connection_id)
payload.setdefault("source_type", "remote")
payload.setdefault("source_server_origin", connection.server_origin if connection else "")
payload.setdefault("source_group_id", row.group_remote_id)
payload.setdefault("source_group_name", row.group_name)
payload.setdefault("remote_connection_id", row.remote_connection_id)
items.append(payload)
return items
def home_dashboard(db: Session, ctx: CurrentContext) -> dict[str, Any]:
members = get_members_for_context(db, ctx)
member_group_ids = [member.group_id for member in members]
now = _db_now()
local_actions = local_actions_for_context(db, ctx)
remote_actions = remote_items_for_context(db, ctx, "action")
needs_me = sort_actions(local_actions + remote_actions)
today: list[dict[str, Any]] = []
if member_group_ids:
events = db.scalars(
select(Event)
.where(Event.group_id.in_(member_group_ids), Event.starts_at >= now - timedelta(hours=6))
.order_by(Event.starts_at)
.limit(50)
).all()
member_by_group = {member.group_id: member for member in members}
for event in events:
group = db.get(Group, event.group_id)
member = member_by_group.get(event.group_id)
today.append(event_dict(event, group, _member_rsvp_status(db, event.id, member.id) if member else None))
today.extend(remote_items_for_context(db, ctx, "event"))
today = sorted(today, key=lambda item: item.get("starts_at") or "9999-12-31T00:00:00")[:50]
changed = [action for action in needs_me if action["type"] in {"event_changed", "admin_request"}][:20]
official_updates: list[dict[str, Any]] = []
if member_group_ids:
announcements = db.scalars(
select(Announcement)
.where(Announcement.group_id.in_(member_group_ids), Announcement.official.is_(True))
.order_by(desc(Announcement.created_at))
.limit(20)
).all()
for announcement in announcements:
official_updates.append(announcement_dict(announcement, db.get(Group, announcement.group_id)))
official_updates.extend(remote_items_for_context(db, ctx, "announcement"))
discussion_count = 0
if member_group_ids:
discussion_count = db.scalar(select(func.count()).select_from(Thread).where(Thread.group_id.in_(member_group_ids), Thread.kind == "discussion")) or 0
catch_up = [
{"label": "official announcements", "count": len(official_updates)},
{"label": "changed events", "count": len([item for item in changed if item["type"] == "event_changed"])},
{"label": "open actions", "count": len(needs_me)},
{"label": "discussion threads", "count": discussion_count + len(remote_items_for_context(db, ctx, "thread"))},
]
connections = []
if ctx.home_profile:
connections = [
remote_connection_dict(connection)
for connection in db.scalars(select(RemoteServerConnection).where(RemoteServerConnection.home_profile_id == ctx.home_profile.id)).all()
]
return {
"sections": {
"needs_me": needs_me,
"today": today,
"changed": changed,
"official_updates": official_updates,
"catch_up": catch_up,
},
"connections": connections,
}
def calendar_items(db: Session, ctx: CurrentContext) -> list[dict[str, Any]]:
items = home_dashboard(db, ctx)["sections"]["today"]
return sorted(items, key=lambda item: item.get("starts_at") or "9999-12-31T00:00:00")
def file_items(db: Session, ctx: CurrentContext) -> list[dict[str, Any]]:
members = get_members_for_context(db, ctx)
group_ids = [member.group_id for member in members]
files: list[dict[str, Any]] = []
if group_ids:
for file in db.scalars(select(FileAsset).where(FileAsset.group_id.in_(group_ids)).order_by(desc(FileAsset.created_at))).all():
files.append(file_dict(file, db.get(Group, file.group_id)))
files.extend(remote_items_for_context(db, ctx, "file"))
return files
def group_dashboard(db: Session, group: Group, member: Member | None = None) -> dict[str, Any]:
rsvp_member_id = member.id if member else ""
announcements = [
announcement_dict(item, group)
for item in db.scalars(
select(Announcement).where(Announcement.group_id == group.id).order_by(desc(Announcement.created_at)).limit(20)
).all()
]
events = [
event_dict(item, group, _member_rsvp_status(db, item.id, rsvp_member_id) if rsvp_member_id else None)
for item in db.scalars(select(Event).where(Event.group_id == group.id).order_by(Event.starts_at).limit(30)).all()
]
tasks = [task_dict(item, group) for item in db.scalars(select(Task).where(Task.group_id == group.id).order_by(Task.status, Task.due_at)).all()]
files = [file_dict(item, group) for item in db.scalars(select(FileAsset).where(FileAsset.group_id == group.id).order_by(desc(FileAsset.created_at))).all()]
polls = []
for poll in db.scalars(select(Poll).where(Poll.group_id == group.id).order_by(desc(Poll.created_at))).all():
options = db.scalars(select(PollOption).where(PollOption.poll_id == poll.id).order_by(PollOption.position)).all()
votes = db.scalars(select(PollVote).where(PollVote.poll_id == poll.id)).all()
polls.append(poll_dict(poll, list(options), list(votes), group))
threads = [
thread_dict(item, group=group)
for item in db.scalars(select(Thread).where(Thread.group_id == group.id).order_by(desc(Thread.updated_at)).limit(20)).all()
]
important_now = _local_actions_for_member(db, member) if member else []
return {
"important_now": sort_actions(important_now)[:8],
"upcoming": events[:8],
"open_actions": sort_actions(important_now),
"announcements": announcements,
"tasks": tasks,
"polls": polls,
"files": files,
"discussions": threads,
}

View File

@@ -0,0 +1,39 @@
from __future__ import annotations
from app.core.security import token_urlsafe
class PasskeyProvider:
def registration_options(self, display_name: str | None = None) -> dict:
raise NotImplementedError
def verify_registration(self, payload: dict) -> dict:
raise NotImplementedError
def login_options(self) -> dict:
raise NotImplementedError
def verify_login(self, payload: dict) -> dict:
raise NotImplementedError
class DevelopmentPasskeyProvider(PasskeyProvider):
"""Development-only passkey-shaped adapter.
It preserves the API contract and UI flow without claiming production WebAuthn.
"""
def registration_options(self, display_name: str | None = None) -> dict:
return {"challenge": token_urlsafe(24), "display_name": display_name, "development_only": True}
def verify_registration(self, payload: dict) -> dict:
return {"verified": True, "trust_level": "passkey_ready", "development_only": True}
def login_options(self) -> dict:
return {"challenge": token_urlsafe(24), "development_only": True}
def verify_login(self, payload: dict) -> dict:
return {"verified": True, "development_only": True}
passkey_provider: PasskeyProvider = DevelopmentPasskeyProvider()

View File

@@ -0,0 +1,41 @@
from fastapi import HTTPException, status
from app.models import Member
ROLE_ORDER = {
"guest": 0,
"member": 10,
"moderator": 20,
"admin": 30,
"owner": 40,
}
def has_role(member: Member | None, min_role: str) -> bool:
if member is None:
return False
return ROLE_ORDER.get(member.role, -1) >= ROLE_ORDER.get(min_role, 999)
def require_role(member: Member | None, min_role: str = "admin") -> None:
if not has_role(member, min_role):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"error": {"code": "permission_denied", "message": "You do not have permission to do that.", "details": {}}},
)
def can(member: Member | None, action: str, resource: object | None = None) -> bool:
if member is None:
return False
if action in {"rsvp", "vote", "comment", "upload_file", "view_group"}:
return ROLE_ORDER.get(member.role, -1) >= ROLE_ORDER["member"]
if action == "create_official_announcement":
return has_role(member, "moderator")
if action in {"create_invite", "view_migration", "manage_members", "create_connection_token"}:
return has_role(member, "admin")
if action in {"create_event", "create_task"}:
return has_role(member, "moderator")
return False

View File

@@ -0,0 +1,175 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
import httpx
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.security import hash_token, utc_now
from app.models import ConnectionToken, RemoteCachedObject, RemoteServerConnection, RemoteSyncCursor
from app.services.dashboard import home_dashboard
def mask_store_token(raw_token: str) -> str:
return f"dev:{raw_token}"
def unmask_store_token(stored: str) -> str:
if stored.startswith("dev:"):
return stored[4:]
return stored
def validate_connection_token(db: Session, raw_token: str) -> ConnectionToken | None:
token = db.scalar(select(ConnectionToken).where(ConnectionToken.token_hash == hash_token(raw_token)))
if not token or token.revoked_at:
return None
if token.expires_at:
now = utc_now()
expires = token.expires_at if token.expires_at.tzinfo else token.expires_at.replace(tzinfo=now.tzinfo)
if expires < now:
return None
return token
def manifest() -> dict[str, Any]:
settings = get_settings()
return {
"server_name": settings.server_name,
"api_base": settings.api_base_url,
"protocol_version": "0.1",
"capabilities": {
"events": True,
"tasks": True,
"files": True,
"chat": True,
"polls": True,
"federation": False,
},
}
def sync_payload_for_token(db: Session, token: ConnectionToken | None) -> dict[str, Any]:
settings = get_settings()
fake_ctx = type("SyncContext", (), {"home_profile": None, "member": None, "session": None})()
payload = home_dashboard(db, fake_ctx) if False else None
actions: list[dict[str, Any]] = []
events: list[dict[str, Any]] = []
announcements: list[dict[str, Any]] = []
files: list[dict[str, Any]] = []
threads: list[dict[str, Any]] = []
from sqlalchemy import desc
from app.models import Announcement, Event, FileAsset, Group, Poll, Task, Thread
from app.services.dashboard import _local_actions_for_member
from app.services.serializers import announcement_dict, event_dict, file_dict, thread_dict
group_filter = [token.group_id] if token and token.group_id else [group.id for group in db.scalars(select(Group)).all()]
groups = [db.get(Group, group_id) for group_id in group_filter]
for group in [item for item in groups if item]:
for member in group.members:
if member.status in {"joined", "verified"}:
actions.extend(_local_actions_for_member(db, member))
break
events.extend([event_dict(item, group) for item in db.scalars(select(Event).where(Event.group_id == group.id)).all()])
announcements.extend(
[announcement_dict(item, group) for item in db.scalars(select(Announcement).where(Announcement.group_id == group.id, Announcement.official.is_(True))).all()]
)
files.extend([file_dict(item, group) for item in db.scalars(select(FileAsset).where(FileAsset.group_id == group.id)).all()])
threads.extend(
[thread_dict(item, group=group) for item in db.scalars(select(Thread).where(Thread.group_id == group.id).order_by(desc(Thread.updated_at))).all()]
)
for collection in (actions, events, announcements, files, threads):
for item in collection:
item["source_type"] = "remote"
item["source_server_origin"] = settings.server_origin
return {
"cursor": utc_now().isoformat(),
"server_time": utc_now().isoformat(),
"actions": actions,
"events": events,
"announcements": announcements,
"files": files,
"threads": threads,
}
def fetch_manifest(server_url: str) -> dict[str, Any]:
settings = get_settings()
with httpx.Client(timeout=settings.remote_request_timeout_seconds, follow_redirects=True) as client:
response = client.get(f"{server_url.rstrip('/')}/.well-known/group-platform.json")
response.raise_for_status()
return response.json()
def sync_connection(db: Session, connection: RemoteServerConnection) -> RemoteServerConnection:
settings = get_settings()
cursor = db.scalar(select(RemoteSyncCursor).where(RemoteSyncCursor.remote_connection_id == connection.id))
since = cursor.cursor if cursor else None
params = {"since": since} if since else {}
raw_token = unmask_store_token(connection.access_token_encrypted)
try:
with httpx.Client(timeout=settings.remote_request_timeout_seconds, follow_redirects=True) as client:
response = client.get(f"{connection.api_base.rstrip('/')}/sync", params=params, headers={"Authorization": f"Bearer {raw_token}"})
response.raise_for_status()
payload = response.json()
except Exception as exc: # noqa: BLE001
connection.status = "error"
connection.last_error = str(exc)
connection.updated_at = utc_now()
db.flush()
return connection
for object_type, collection_name in [
("action", "actions"),
("event", "events"),
("announcement", "announcements"),
("file", "files"),
("thread", "threads"),
]:
for item in payload.get(collection_name, []):
remote_id = str(item.get("id") or item.get("object_id") or f"{object_type}:{len(item)}")
group_remote_id = str(item.get("source_group_id") or item.get("group_id") or "remote")
group_name = str(item.get("source_group_name") or item.get("group_name") or connection.server_name)
existing = db.scalar(
select(RemoteCachedObject).where(
RemoteCachedObject.remote_connection_id == connection.id,
RemoteCachedObject.object_type == object_type,
RemoteCachedObject.remote_id == remote_id,
)
)
if existing:
existing.group_remote_id = group_remote_id
existing.group_name = group_name
existing.payload_json = item
existing.cached_at = utc_now()
else:
db.add(
RemoteCachedObject(
remote_connection_id=connection.id,
object_type=object_type,
remote_id=remote_id,
group_remote_id=group_remote_id,
group_name=group_name,
payload_json=item,
)
)
next_cursor = payload.get("cursor")
if cursor:
cursor.cursor = next_cursor
cursor.updated_at = utc_now()
else:
db.add(RemoteSyncCursor(remote_connection_id=connection.id, cursor=next_cursor))
connection.status = "active"
connection.last_error = None
connection.last_sync_at = utc_now()
connection.updated_at = utc_now()
db.flush()
return connection

View File

@@ -0,0 +1,260 @@
from __future__ import annotations
from datetime import date, datetime
from typing import Any
from app.models import (
Announcement,
Event,
FileAsset,
Group,
HomeDevice,
HomeProfile,
Member,
MemberDevice,
Message,
Poll,
PollOption,
PollVote,
RemoteServerConnection,
Task,
Thread,
)
def iso(value: datetime | date | None) -> str | None:
if value is None:
return None
return value.isoformat()
def group_dict(group: Group) -> dict[str, Any]:
return {
"id": group.id,
"server_origin": group.server_origin,
"name": group.name,
"description": group.description,
"visibility": group.visibility,
"legacy_channel_status": group.legacy_channel_status,
"transition_deadline": iso(group.transition_deadline),
"created_at": iso(group.created_at),
"updated_at": iso(group.updated_at),
}
def member_dict(member: Member) -> dict[str, Any]:
return {
"id": member.id,
"group_id": member.group_id,
"home_profile_id": member.home_profile_id,
"display_name": member.display_name,
"role": member.role,
"status": member.status,
"joined_at": iso(member.joined_at),
"last_seen_at": iso(member.last_seen_at),
"notification_enabled_at": iso(member.notification_enabled_at),
}
def profile_dict(profile: HomeProfile | None, member: Member | None = None) -> dict[str, Any] | None:
if profile:
return {
"id": profile.id,
"primary_display_name": profile.primary_display_name,
"status": profile.status,
"last_seen_at": iso(profile.last_seen_at),
}
if member:
return {
"id": None,
"primary_display_name": member.display_name,
"status": "membership_only",
"last_seen_at": iso(member.last_seen_at),
}
return None
def announcement_dict(announcement: Announcement, group: Group | None = None) -> dict[str, Any]:
return {
"id": announcement.id,
"group_id": announcement.group_id,
"group_name": group.name if group else None,
"source_type": "local",
"source_server_origin": group.server_origin if group else None,
"author_member_id": announcement.author_member_id,
"title": announcement.title,
"body": announcement.body,
"priority": announcement.priority,
"official": announcement.official,
"requires_ack": announcement.requires_ack,
"created_at": iso(announcement.created_at),
"updated_at": iso(announcement.updated_at),
}
def event_dict(event: Event, group: Group | None = None, rsvp_status: str | None = None) -> dict[str, Any]:
return {
"id": event.id,
"group_id": event.group_id,
"group_name": group.name if group else None,
"source_type": "local",
"source_server_origin": group.server_origin if group else None,
"created_by_member_id": event.created_by_member_id,
"title": event.title,
"description": event.description,
"starts_at": iso(event.starts_at),
"ends_at": iso(event.ends_at),
"location_name": event.location_name,
"location_address": event.location_address,
"rsvp_required": event.rsvp_required,
"rsvp_status": rsvp_status,
"changed_at": iso(event.changed_at),
"created_at": iso(event.created_at),
"updated_at": iso(event.updated_at),
}
def task_dict(task: Task, group: Group | None = None) -> dict[str, Any]:
return {
"id": task.id,
"group_id": task.group_id,
"group_name": group.name if group else None,
"source_type": "local",
"source_server_origin": group.server_origin if group else None,
"created_by_member_id": task.created_by_member_id,
"assigned_to_member_id": task.assigned_to_member_id,
"title": task.title,
"description": task.description,
"due_at": iso(task.due_at),
"status": task.status,
"created_at": iso(task.created_at),
"updated_at": iso(task.updated_at),
}
def poll_dict(poll: Poll, options: list[PollOption], votes: list[PollVote], group: Group | None = None) -> dict[str, Any]:
counts: dict[str, int] = {option.id: 0 for option in options}
for vote in votes:
counts[vote.option_id] = counts.get(vote.option_id, 0) + 1
return {
"id": poll.id,
"group_id": poll.group_id,
"group_name": group.name if group else None,
"source_type": "local",
"source_server_origin": group.server_origin if group else None,
"title": poll.title,
"description": poll.description,
"closes_at": iso(poll.closes_at),
"status": poll.status,
"created_by_member_id": poll.created_by_member_id,
"created_at": iso(poll.created_at),
"options": [{"id": option.id, "label": option.label, "position": option.position, "vote_count": counts.get(option.id, 0)} for option in options],
}
def file_dict(file: FileAsset, group: Group | None = None) -> dict[str, Any]:
return {
"id": file.id,
"group_id": file.group_id,
"group_name": group.name if group else None,
"source_type": "local",
"source_server_origin": group.server_origin if group else None,
"uploaded_by_member_id": file.uploaded_by_member_id,
"filename_original": file.filename_original,
"content_type": file.content_type,
"size_bytes": file.size_bytes,
"visibility": file.visibility,
"description": file.description,
"requires_ack": file.requires_ack,
"created_at": iso(file.created_at),
"download_url": f"/api/files/{file.id}/download",
}
def thread_dict(thread: Thread, messages: list[Message] | None = None, group: Group | None = None) -> dict[str, Any]:
return {
"id": thread.id,
"group_id": thread.group_id,
"group_name": group.name if group else None,
"source_type": "local",
"source_server_origin": group.server_origin if group else None,
"title": thread.title,
"kind": thread.kind,
"created_by_member_id": thread.created_by_member_id,
"created_at": iso(thread.created_at),
"updated_at": iso(thread.updated_at),
"messages": [message_dict(message) for message in messages or []],
}
def message_dict(message: Message) -> dict[str, Any]:
return {
"id": message.id,
"thread_id": message.thread_id,
"author_member_id": message.author_member_id,
"body": message.body,
"created_at": iso(message.created_at),
"edited_at": iso(message.edited_at),
"deleted_at": iso(message.deleted_at),
}
def action_dict(
*,
id: str,
source_type: str,
source_server_origin: str,
source_group_id: str,
source_group_name: str,
type: str,
priority: str,
title: str,
summary: str,
object_type: str,
object_id: str,
due_at: datetime | None = None,
status: str = "open",
) -> dict[str, Any]:
return {
"id": id,
"source_type": source_type,
"source_server_origin": source_server_origin,
"source_group_id": source_group_id,
"source_group_name": source_group_name,
"type": type,
"status": status,
"priority": priority,
"title": title,
"summary": summary,
"object_type": object_type,
"object_id": object_id,
"due_at": iso(due_at),
}
def device_dict(device: HomeDevice | MemberDevice, current_id: str | None = None) -> dict[str, Any]:
return {
"id": device.id,
"label": device.label,
"created_at": iso(device.created_at),
"last_seen_at": iso(device.last_seen_at),
"revoked_at": iso(device.revoked_at),
"trust_level": device.trust_level,
"current": device.id == current_id,
}
def remote_connection_dict(connection: RemoteServerConnection) -> dict[str, Any]:
return {
"id": connection.id,
"server_origin": connection.server_origin,
"server_name": connection.server_name,
"api_base": connection.api_base,
"protocol_version": connection.protocol_version,
"capabilities": connection.capabilities_json,
"status": connection.status,
"last_sync_at": iso(connection.last_sync_at),
"last_error": connection.last_error,
"created_at": iso(connection.created_at),
}