Replaces per-member Nextcloud credentials with a BandStorage model that supports multiple providers. Credentials are Fernet-encrypted at rest; worker receives audio via an internal streaming endpoint instead of direct storage access. - Add BandStorage DB model with partial unique index (one active per band) - Add migrations 0007 (create band_storage) and 0008 (drop old nc columns) - Add StorageFactory that builds the correct StorageClient from BandStorage - Add storage router: connect/nextcloud, OAuth2 authorize/callback, list, disconnect - Add Fernet encryption helpers in security/encryption.py - Rewrite watcher for per-band polling via internal API config endpoint - Update worker to stream audio from API instead of accessing storage directly - Update frontend: new storage API in bands.ts, rewritten StorageSection, simplified band creation modal (no storage step) - Add STORAGE_ENCRYPTION_KEY to all docker-compose files Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
127 lines
4.6 KiB
Python
Executable File
127 lines
4.6 KiB
Python
Executable File
from __future__ import annotations
|
|
|
|
import secrets
|
|
import uuid
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from rehearsalhub.db.models import Band, BandInvite, BandMember, BandStorage
|
|
from rehearsalhub.repositories.base import BaseRepository
|
|
|
|
|
|
class BandRepository(BaseRepository[Band]):
|
|
model = Band
|
|
|
|
async def get_by_slug(self, slug: str) -> Band | None:
|
|
stmt = select(Band).where(Band.slug == slug)
|
|
result = await self.session.execute(stmt)
|
|
return result.scalar_one_or_none()
|
|
|
|
async def get_with_members(self, band_id: uuid.UUID) -> Band | None:
|
|
stmt = (
|
|
select(Band)
|
|
.options(selectinload(Band.memberships).selectinload(BandMember.member))
|
|
.where(Band.id == band_id)
|
|
)
|
|
result = await self.session.execute(stmt)
|
|
return result.scalar_one_or_none()
|
|
|
|
async def get_member_role(self, band_id: uuid.UUID, member_id: uuid.UUID) -> str | None:
|
|
stmt = select(BandMember.role).where(
|
|
BandMember.band_id == band_id, BandMember.member_id == member_id
|
|
)
|
|
result = await self.session.execute(stmt)
|
|
return result.scalar_one_or_none()
|
|
|
|
async def add_member(
|
|
self,
|
|
band_id: uuid.UUID,
|
|
member_id: uuid.UUID,
|
|
role: str = "member",
|
|
instrument: str | None = None,
|
|
) -> BandMember:
|
|
bm = BandMember(band_id=band_id, member_id=member_id, role=role, instrument=instrument)
|
|
self.session.add(bm)
|
|
await self.session.flush()
|
|
return bm
|
|
|
|
async def is_member(self, band_id: uuid.UUID, member_id: uuid.UUID) -> bool:
|
|
return await self.get_member_role(band_id, member_id) is not None
|
|
|
|
async def remove_member(self, band_id: uuid.UUID, member_id: uuid.UUID) -> None:
|
|
stmt = select(BandMember).where(
|
|
BandMember.band_id == band_id, BandMember.member_id == member_id
|
|
)
|
|
result = await self.session.execute(stmt)
|
|
bm = result.scalar_one_or_none()
|
|
if bm:
|
|
await self.session.delete(bm)
|
|
await self.session.flush()
|
|
|
|
async def create_invite(
|
|
self, band_id: uuid.UUID, created_by: uuid.UUID, role: str = "member", ttl_hours: int = 72
|
|
) -> BandInvite:
|
|
invite = BandInvite(
|
|
band_id=band_id,
|
|
token=secrets.token_urlsafe(32),
|
|
role=role,
|
|
created_by=created_by,
|
|
expires_at=datetime.now(UTC) + timedelta(hours=ttl_hours),
|
|
)
|
|
self.session.add(invite)
|
|
await self.session.flush()
|
|
await self.session.refresh(invite)
|
|
return invite
|
|
|
|
async def get_invite_by_token(self, token: str) -> BandInvite | None:
|
|
stmt = select(BandInvite).where(BandInvite.token == token)
|
|
result = await self.session.execute(stmt)
|
|
return result.scalar_one_or_none()
|
|
|
|
async def get_invite_by_id(self, invite_id: uuid.UUID) -> BandInvite | None:
|
|
stmt = select(BandInvite).where(BandInvite.id == invite_id)
|
|
result = await self.session.execute(stmt)
|
|
return result.scalar_one_or_none()
|
|
|
|
async def get_invites_for_band(self, band_id: uuid.UUID) -> list[BandInvite]:
|
|
"""Get all invites for a specific band."""
|
|
stmt = select(BandInvite).where(BandInvite.band_id == band_id)
|
|
result = await self.session.execute(stmt)
|
|
return list(result.scalars().all())
|
|
|
|
async def get_by_nc_folder_prefix(self, path: str) -> Band | None:
|
|
"""Return the band whose active storage root_path is a prefix of *path*.
|
|
|
|
Longest match wins (most-specific prefix) so nested paths resolve correctly.
|
|
"""
|
|
stmt = (
|
|
select(Band, BandStorage.root_path)
|
|
.join(
|
|
BandStorage,
|
|
(BandStorage.band_id == Band.id) & BandStorage.is_active.is_(True),
|
|
)
|
|
.where(BandStorage.root_path.is_not(None))
|
|
)
|
|
result = await self.session.execute(stmt)
|
|
rows = result.all()
|
|
best: Band | None = None
|
|
best_len = 0
|
|
for band, root_path in rows:
|
|
folder = root_path.rstrip("/") + "/"
|
|
if path.startswith(folder) and len(folder) > best_len:
|
|
best = band
|
|
best_len = len(folder)
|
|
return best
|
|
|
|
async def list_for_member(self, member_id: uuid.UUID) -> list[Band]:
|
|
stmt = (
|
|
select(Band)
|
|
.join(BandMember, BandMember.band_id == Band.id)
|
|
.where(BandMember.member_id == member_id)
|
|
.order_by(Band.name)
|
|
)
|
|
result = await self.session.execute(stmt)
|
|
return list(result.scalars().all())
|