feat: Groups refinement and band theming
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s

- Group model: Add vertical_id scoping, image_url
- Vertical model: Add logo_url, accent_color for branding
- Groups router: Add vertical filter, member count, leave endpoint
- Fix CI/CD deploy.yml git clone URL (runfoo-org)
This commit is contained in:
fullsizemalt 2025-12-28 16:57:41 -08:00
parent a9eb35fa75
commit c8e5a48d57
3 changed files with 72 additions and 7 deletions

View file

@ -36,7 +36,7 @@ jobs:
script: | script: |
# Clone or pull repo # Clone or pull repo
if [ ! -d "${{ steps.target.outputs.deploy_path }}" ]; then if [ ! -d "${{ steps.target.outputs.deploy_path }}" ]; then
git clone https://git.runfoo.run/runfoo/fediversion.git ${{ steps.target.outputs.deploy_path }} git clone https://git.runfoo.run/runfoo-org/fediversion.git ${{ steps.target.outputs.deploy_path }}
fi fi
cd ${{ steps.target.outputs.deploy_path }} cd ${{ steps.target.outputs.deploy_path }}
git fetch origin ${{ github.ref_name }} git fetch origin ${{ github.ref_name }}

View file

@ -88,6 +88,10 @@ class Vertical(SQLModel, table=True):
is_active: bool = Field(default=True, description="Show in band selector") is_active: bool = Field(default=True, description="Show in band selector")
is_featured: bool = Field(default=False, description="Highlight in discovery") is_featured: bool = Field(default=False, description="Highlight in discovery")
# Branding
logo_url: Optional[str] = Field(default=None, description="Band logo URL for UI")
accent_color: Optional[str] = Field(default=None, description="Hex color for accents")
# Relationships # Relationships
shows: List["Show"] = Relationship(back_populates="vertical") shows: List["Show"] = Relationship(back_populates="vertical")
songs: List["Song"] = Relationship(back_populates="vertical") songs: List["Song"] = Relationship(back_populates="vertical")
@ -421,10 +425,14 @@ class Group(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(index=True, unique=True) name: str = Field(index=True, unique=True)
description: Optional[str] = None description: Optional[str] = None
privacy: str = Field(default="public") # public, private privacy: str = Field(default="public") # public, private, invite_only
created_by: int = Field(foreign_key="user.id") created_by: int = Field(foreign_key="user.id")
created_at: datetime = Field(default_factory=datetime.utcnow) created_at: datetime = Field(default_factory=datetime.utcnow)
# Vertical scoping (optional - null means cross-band group)
vertical_id: Optional[int] = Field(default=None, foreign_key="vertical.id", index=True)
image_url: Optional[str] = Field(default=None, description="Group logo/image URL")
members: List["GroupMember"] = Relationship(back_populates="group") members: List["GroupMember"] = Relationship(back_populates="group")
posts: List["GroupPost"] = Relationship(back_populates="group") posts: List["GroupPost"] = Relationship(back_populates="group")

View file

@ -1,4 +1,4 @@
from typing import List from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import Session, select, func from sqlmodel import Session, select, func
from database import get_session from database import get_session
@ -32,12 +32,33 @@ def create_group(
@router.get("/", response_model=List[GroupRead]) @router.get("/", response_model=List[GroupRead])
def read_groups( def read_groups(
offset: int = 0, offset: int = 0,
limit: int = Query(default=100, le=100), limit: int = Query(default=100, le=100),
vertical: Optional[str] = None,
session: Session = Depends(get_session) session: Session = Depends(get_session)
): ):
# TODO: Add member count to response from models import Vertical
groups = session.exec(select(Group).offset(offset).limit(limit)).all()
return groups query = select(Group)
# Filter by vertical if specified
if vertical:
v = session.exec(select(Vertical).where(Vertical.slug == vertical)).first()
if v:
query = query.where(Group.vertical_id == v.id)
groups = session.exec(query.offset(offset).limit(limit)).all()
# Add member count to each group
result = []
for g in groups:
member_count = session.exec(
select(func.count(GroupMember.id)).where(GroupMember.group_id == g.id)
).one()
result.append({
**g.model_dump(),
"member_count": member_count or 0
})
return result
@router.get("/{group_id}", response_model=GroupRead) @router.get("/{group_id}", response_model=GroupRead)
def read_group(group_id: int, session: Session = Depends(get_session)): def read_group(group_id: int, session: Session = Depends(get_session)):
@ -83,6 +104,42 @@ def join_group(
return {"status": "joined"} return {"status": "joined"}
@router.delete("/{group_id}/leave")
def leave_group(
group_id: int,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user)
):
"""Leave a group (non-admins only, admins must transfer ownership first)"""
member = session.exec(
select(GroupMember)
.where(GroupMember.group_id == group_id)
.where(GroupMember.user_id == current_user.id)
).first()
if not member:
raise HTTPException(status_code=404, detail="Not a member")
if member.role == "admin":
# Check if only admin
other_admins = session.exec(
select(GroupMember)
.where(GroupMember.group_id == group_id)
.where(GroupMember.role == "admin")
.where(GroupMember.user_id != current_user.id)
).first()
if not other_admins:
raise HTTPException(
status_code=400,
detail="Cannot leave: you are the only admin. Transfer ownership first."
)
session.delete(member)
session.commit()
return {"status": "left"}
# --- Posts --- # --- Posts ---
@router.post("/{group_id}/posts", response_model=GroupPostRead) @router.post("/{group_id}/posts", response_model=GroupPostRead)