Files
rehearshalhub/api/src/rehearsalhub/repositories/band.py
Mistral Vibe b2d6b4d113 Refactor storage to provider-agnostic band-scoped model
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>
2026-04-10 23:22:36 +02:00

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())