Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions backend/alembic/versions/87dd17a9cb8d_branches.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""branches

Revision ID: 87dd17a9cb8d
Revises: 4315ee2f32f5
Create Date: 2026-08-19 00:00:00.000000
"""

from collections.abc import Sequence

from alembic import op

revision: str = "87dd17a9cb8d"
down_revision: str | None = "4315ee2f32f5"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None

UPGRADE = """
CREATE TABLE branch_drafts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
description TEXT,
icon_key TEXT NOT NULL,
color TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX ix_branch_drafts_position ON branch_drafts (position);

CREATE TABLE branches (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
icon_key TEXT NOT NULL,
color TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX ix_branches_position ON branches (position);
"""

DOWNGRADE = """
DROP TABLE IF EXISTS branches;
DROP TABLE IF EXISTS branch_drafts;
"""


def upgrade() -> None:
op.execute(UPGRADE)


def downgrade() -> None:
op.execute(DOWNGRADE)
20 changes: 20 additions & 0 deletions backend/src/admin/api/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
from admin.repositories import (
AccessRequestRepository,
AuditRepository,
BranchDraftRepository,
BranchRepository,
InvitationRepository,
MediaRepository,
RoleRepository,
Expand All @@ -32,6 +34,7 @@
from admin.schemas.user import UserRead
from admin.services.access import AccessService
from admin.services.access_request import AccessRequestService
from admin.services.branch import BranchService
from admin.services.invitation import InvitationService
from admin.services.media import MediaService
from admin.services.member import MemberService
Expand Down Expand Up @@ -111,13 +114,23 @@ def get_media_repository(connection: Connection) -> MediaRepository:
return MediaRepository(connection)


def get_branch_draft_repository(connection: Connection) -> BranchDraftRepository:
return BranchDraftRepository(connection)


def get_branch_repository(connection: Connection) -> BranchRepository:
return BranchRepository(connection)


Users = Annotated[UserRepository, Depends(get_user_repository)]
Roles = Annotated[RoleRepository, Depends(get_role_repository)]
Invitations = Annotated[InvitationRepository, Depends(get_invitation_repository)]
AccessRequests = Annotated[AccessRequestRepository, Depends(get_access_request_repository)]
Audit = Annotated[AuditLog, Depends(get_audit_log)]
AuditEntries = Annotated[AuditRepository, Depends(get_audit_repository)]
Media = Annotated[MediaRepository, Depends(get_media_repository)]
BranchDrafts = Annotated[BranchDraftRepository, Depends(get_branch_draft_repository)]
Branches = Annotated[BranchRepository, Depends(get_branch_repository)]


def get_access_service(
Expand Down Expand Up @@ -171,11 +184,18 @@ def get_media_service(media: Media, storage: Storage, audit: Audit) -> MediaServ
return MediaService(media=media, storage=storage, audit=audit)


def get_branch_service(
drafts: BranchDrafts, published: Branches, storage: Storage, audit: Audit
) -> BranchService:
return BranchService(drafts=drafts, published=published, storage=storage, audit=audit)


AccessServiceDep = Annotated[AccessService, Depends(get_access_service)]
MemberServiceDep = Annotated[MemberService, Depends(get_member_service)]
InvitationServiceDep = Annotated[InvitationService, Depends(get_invitation_service)]
AccessRequestServiceDep = Annotated[AccessRequestService, Depends(get_access_request_service)]
MediaServiceDep = Annotated[MediaService, Depends(get_media_service)]
BranchServiceDep = Annotated[BranchService, Depends(get_branch_service)]


async def get_identity(
Expand Down
Empty file.
Empty file.
11 changes: 11 additions & 0 deletions backend/src/admin/api/public/v1/branches.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from fastapi import APIRouter

from admin.api.dependencies import BranchServiceDep
from admin.schemas.branch import BranchRead

router = APIRouter(prefix="/branches", tags=["public"])


@router.get("", response_model=list[BranchRead])
async def list_public_branches(service: BranchServiceDep) -> list[BranchRead]:
return await service.list_published()
6 changes: 6 additions & 0 deletions backend/src/admin/api/router.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from fastapi import APIRouter

from admin.api.public.v1 import branches as public_branches
from admin.api.v1 import (
access_requests,
audit,
branches,
health,
invitations,
media,
Expand All @@ -19,6 +21,10 @@
api_router.include_router(roles.router)
api_router.include_router(media.router)
api_router.include_router(audit.router)
api_router.include_router(branches.router)

public_router = APIRouter(prefix="/public/v1")
public_router.include_router(public_branches.router)

root_router = APIRouter()
root_router.include_router(health.router)
86 changes: 86 additions & 0 deletions backend/src/admin/api/v1/branches.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import uuid
from typing import Annotated

from fastapi import APIRouter, Depends, status

from admin.api.dependencies import AuthContext, BranchServiceDep, require
from admin.domain.permissions import Permission
from admin.schemas.branch import (
BranchDraftCreate,
BranchDraftRead,
BranchDraftReorder,
BranchDraftUpdate,
BranchPublishStatus,
BranchRead,
)

router = APIRouter(prefix="/branches", tags=["branches"])


@router.get("/drafts", response_model=list[BranchDraftRead])
async def list_drafts(
service: BranchServiceDep,
_: Annotated[AuthContext, Depends(require(Permission.BRANCHES_READ))],
) -> list[BranchDraftRead]:
return await service.list_drafts()


@router.post("/drafts", response_model=BranchDraftRead, status_code=status.HTTP_201_CREATED)
async def create_draft(
payload: BranchDraftCreate,
service: BranchServiceDep,
context: Annotated[AuthContext, Depends(require(Permission.BRANCHES_MANAGE))],
) -> BranchDraftRead:
return await service.create_draft(actor=context.user, payload=payload)


@router.patch("/drafts/{branch_id}", response_model=BranchDraftRead)
async def update_draft(
branch_id: uuid.UUID,
payload: BranchDraftUpdate,
service: BranchServiceDep,
context: Annotated[AuthContext, Depends(require(Permission.BRANCHES_MANAGE))],
) -> BranchDraftRead:
return await service.update_draft(actor=context.user, branch_id=branch_id, payload=payload)


@router.delete("/drafts/{branch_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_draft(
branch_id: uuid.UUID,
service: BranchServiceDep,
context: Annotated[AuthContext, Depends(require(Permission.BRANCHES_MANAGE))],
) -> None:
await service.delete_draft(actor=context.user, branch_id=branch_id)


@router.post("/drafts/reorder", response_model=list[BranchDraftRead])
async def reorder_drafts(
payload: BranchDraftReorder,
service: BranchServiceDep,
context: Annotated[AuthContext, Depends(require(Permission.BRANCHES_MANAGE))],
) -> list[BranchDraftRead]:
return await service.reorder_drafts(actor=context.user, ordered_ids=payload.ids)


@router.get("/status", response_model=BranchPublishStatus)
async def publish_status(
service: BranchServiceDep,
_: Annotated[AuthContext, Depends(require(Permission.BRANCHES_READ))],
) -> BranchPublishStatus:
return await service.publish_status()


@router.post("/publish", response_model=list[BranchRead])
async def publish(
service: BranchServiceDep,
context: Annotated[AuthContext, Depends(require(Permission.BRANCHES_PUBLISH))],
) -> list[BranchRead]:
return await service.publish(actor=context.user)


@router.post("/discard", response_model=list[BranchDraftRead])
async def discard(
service: BranchServiceDep,
context: Annotated[AuthContext, Depends(require(Permission.BRANCHES_PUBLISH))],
) -> list[BranchDraftRead]:
return await service.discard_drafts(actor=context.user)
7 changes: 7 additions & 0 deletions backend/src/admin/domain/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class AccessRequestStatus(StrEnum):

class MediaPurpose(StrEnum):
AVATAR = "avatar"
BRANCH_ICON = "branch_icon"


class AuditAction(StrEnum):
Expand All @@ -47,3 +48,9 @@ class AuditAction(StrEnum):
ACCESS_REQUEST_DENIED = "access_request.denied"
MEDIA_UPLOADED = "media.uploaded"
MEDIA_DELETED = "media.deleted"
BRANCH_DRAFT_CREATED = "branch_draft.created"
BRANCH_DRAFT_UPDATED = "branch_draft.updated"
BRANCH_DRAFT_DELETED = "branch_draft.deleted"
BRANCH_DRAFTS_REORDERED = "branch_draft.reordered"
BRANCHES_PUBLISHED = "branches.published"
BRANCHES_DISCARDED = "branches.discarded"
6 changes: 6 additions & 0 deletions backend/src/admin/domain/media.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ class MediaPreset:
visibility=MediaVisibility.PUBLIC,
mime_types=frozenset({"image/jpeg", "image/png", "image/webp"}),
),
MediaPurpose.BRANCH_ICON: MediaPreset(
max_edge=256,
max_bytes=262_144,
visibility=MediaVisibility.PUBLIC,
mime_types=frozenset({"image/jpeg", "image/png", "image/webp"}),
),
}


Expand Down
6 changes: 6 additions & 0 deletions backend/src/admin/domain/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ class Permission(StrEnum):
AUDIT_READ = "core.audit.read"
MEDIA_READ = "core.media.read"
MEDIA_DELETE = "core.media.delete"
BRANCHES_READ = "core.branches.read"
BRANCHES_MANAGE = "core.branches.manage"
BRANCHES_PUBLISH = "core.branches.publish"

@property
def description(self) -> str:
Expand All @@ -32,6 +35,9 @@ def description(self) -> str:
Permission.AUDIT_READ: "Read the audit log",
Permission.MEDIA_READ: "View private files uploaded by other members",
Permission.MEDIA_DELETE: "Delete files uploaded by other members",
Permission.BRANCHES_READ: "View branch drafts and publish status",
Permission.BRANCHES_MANAGE: "Create, edit, delete, and reorder branch drafts",
Permission.BRANCHES_PUBLISH: "Publish or discard branch draft changes",
}


Expand Down
3 changes: 2 additions & 1 deletion backend/src/admin/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse

from admin.api.router import api_router, root_router
from admin.api.router import api_router, public_router, root_router
from admin.core.cache import build_cache
from admin.core.config import Settings, get_settings
from admin.core.database import create_pool
Expand Down Expand Up @@ -82,6 +82,7 @@ async def handle_domain_error(_: Request, error: DomainError) -> JSONResponse:

app.include_router(root_router)
app.include_router(api_router)
app.include_router(public_router)
return app


Expand Down
3 changes: 3 additions & 0 deletions backend/src/admin/repositories/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from admin.repositories.access_request import AccessRequestRepository
from admin.repositories.audit import AuditRepository
from admin.repositories.branch import BranchDraftRepository, BranchRepository
from admin.repositories.invitation import InvitationRepository
from admin.repositories.media import MediaRepository
from admin.repositories.role import RoleRepository
Expand All @@ -8,6 +9,8 @@
__all__ = [
"AccessRequestRepository",
"AuditRepository",
"BranchDraftRepository",
"BranchRepository",
"InvitationRepository",
"MediaRepository",
"RoleRepository",
Expand Down
Loading
Loading