Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ repos:
hooks:
- id: ty
name: ty
entry: uvx ty check
entry: bash -c "uv run --no-sync ty check services/bot/src packages/byte-common/src"
language: system
types: [python]
pass_filenames: false
Expand Down
6 changes: 3 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ refresh-container: clean-container up-container load-container ## Refresh the By
##@ Code Quality

lint: ## Runs prek hooks; includes ruff linting, codespell, black
@$(UV) run --no-sync prek run --all-files
@$(UV) run --no-sync prek run --all-files --skip ty

fmt-check: ## Runs Ruff format in check mode (no changes)
@$(UV) run --no-sync ruff format --check .
Expand All @@ -118,10 +118,10 @@ type-check: ## Run ty type checker
@$(UV) run --no-sync ty check

test: ## Run the tests
@$(UV) run --no-sync pytest tests
@$(UV) run --no-sync pytest

coverage: ## Run the tests and generate coverage report
@$(UV) run --no-sync pytest tests --cov=byte_bot
@$(UV) run --no-sync pytest --cov=byte_bot
@$(UV) run --no-sync coverage html
@$(UV) run --no-sync coverage xml

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"tailwindcss": "^3.3.3"
},
"devDependencies": {
"@biomejs/biome": "2.3.7",
"daisyui": "^3.5.0"
}
}
71 changes: 63 additions & 8 deletions packages/byte-common/src/byte_common/models/forum_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,74 @@

from __future__ import annotations

from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any

from advanced_alchemy.base import UUIDAuditBase
from sqlalchemy import BigInteger, ForeignKey
from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy
from sqlalchemy import JSON, BigInteger, ForeignKey
from sqlalchemy.dialects.postgresql import ARRAY
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.types import TypeDecorator

if TYPE_CHECKING:
from sqlalchemy.engine import Dialect

from byte_common.models.guild import Guild

__all__ = ("ForumConfig",)
__all__ = ("ForumConfig", "IntegerArray")


class IntegerArray(TypeDecorator):
"""Platform-independent integer array type.

Uses ARRAY on PostgreSQL and JSON on other databases.
"""

impl = JSON
cache_ok = True

def load_dialect_impl(self, dialect: Dialect) -> Any:
"""Load dialect-specific type implementation.

Args:
dialect: Database dialect instance

Returns:
Any: Type descriptor for the dialect (ARRAY or JSON)
"""
if dialect.name == "postgresql":
return dialect.type_descriptor(ARRAY(BigInteger))
return dialect.type_descriptor(JSON())

def process_bind_param(self, value: list[int] | None, dialect: Dialect) -> list[int] | None:
"""Process value before binding to database.

Args:
value: List of integers or None
dialect: Database dialect instance

Returns:
list[int] | None: Processed value
"""
if value is None:
return value
if dialect.name == "postgresql":
return value
# For JSON, ensure it's a list
return value if isinstance(value, list) else []

def process_result_value(self, value: list[int] | None, _dialect: Dialect) -> list[int]:
"""Process value after fetching from database.

Args:
value: List of integers or None
_dialect: Database dialect instance (unused)

Returns:
list[int]: Empty list if None, otherwise the value
"""
if value is None:
return []
return value if isinstance(value, list) else []


class ForumConfig(UUIDAuditBase):
Expand Down Expand Up @@ -48,18 +105,16 @@ class ForumConfig(UUIDAuditBase):
# Help forum settings
help_forum: Mapped[bool] = mapped_column(default=False)
help_forum_category: Mapped[str | None]
help_channel_id: AssociationProxy[int | None] = association_proxy("guild", "help_channel_id")
help_thread_auto_close: Mapped[bool] = mapped_column(default=False)
help_thread_auto_close_days: Mapped[int | None]
help_thread_notify: Mapped[bool] = mapped_column(default=False)
help_thread_notify_roles: Mapped[str | None]
help_thread_notify_roles: Mapped[list[int]] = mapped_column(IntegerArray, default=list)
help_thread_notify_days: Mapped[int | None]
help_thread_sync: AssociationProxy[bool] = association_proxy("guild", "github_config.discussion_sync")
help_thread_sync: Mapped[bool] = mapped_column(default=False)

# Showcase forum settings
showcase_forum: Mapped[bool] = mapped_column(default=False)
showcase_forum_category: Mapped[str | None]
showcase_channel_id: AssociationProxy[int | None] = association_proxy("guild", "showcase_channel_id")
showcase_thread_auto_close: Mapped[bool] = mapped_column(default=False)
showcase_thread_auto_close_days: Mapped[int | None]

Expand Down
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ dev = [
"aiosqlite>=0.21.0",
"respx>=0.22.0",
"ruff>=0.14.6",
"pytest-sugar>=1.1.1",
]

[tool.codespell]
Expand Down Expand Up @@ -118,9 +119,10 @@ extra-paths = ["tests/", "packages/byte-common/src/", "packages/byte-common/test

[tool.ty.src]
exclude = [
"services/api/**/*.py",
"docs/conf.py",
"tests/unit/api/test_orm.py",
"tests/unit/bot/**/*.py", # Mock attributes not recognized by ty
"tests/unit/bot/views/**/*.py",
]

[tool.slotscheck]
Expand Down
6 changes: 5 additions & 1 deletion services/api/src/byte_api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ def create_app() -> Litestar:
exceptions,
log,
openapi,
schema,
settings,
static_files,
template,
Expand Down Expand Up @@ -58,7 +59,10 @@ def create_app() -> Litestar:
debug=settings.project.DEBUG,
middleware=[log.controller.middleware_factory],
signature_namespace=domain.signature_namespace,
type_encoders={SecretStr: str},
type_encoders={
SecretStr: str,
schema.CamelizedBaseModel: schema.serialize_camelized_model,
},
plugins=[db.plugin],
)

Expand Down
30 changes: 27 additions & 3 deletions services/api/src/byte_api/domain/guilds/controllers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from typing import TYPE_CHECKING

from advanced_alchemy.filters import LimitOffset
from litestar import Controller, get, patch, post
from litestar.di import Provide
from litestar.params import Dependency, Parameter
Expand Down Expand Up @@ -31,6 +32,7 @@
GuildsService, # noqa: TC001
SOTagsConfigService, # noqa: TC001
)
from byte_api.lib import constants

if TYPE_CHECKING:
from advanced_alchemy.filters import FilterTypes
Expand Down Expand Up @@ -60,19 +62,39 @@ class GuildsController(Controller):
async def list_guilds(
self,
guilds_service: GuildsService,
limit: int = Parameter(
query="limit",
ge=1,
default=constants.DEFAULT_PAGINATION_SIZE,
required=False,
description="Maximum number of items to return",
),
offset: int = Parameter(
query="offset",
ge=0,
default=0,
required=False,
description="Number of items to skip",
),
filters: list[FilterTypes] = Dependency(skip_validation=True),
) -> OffsetPagination[GuildSchema]:
"""List guilds.

Args:
guilds_service (GuildsService): Guilds service
limit (int): Maximum number of items to return
offset (int): Number of items to skip
filters (list[FilterTypes]): Filters

Returns:
list[Guild]: List of guilds
OffsetPagination[GuildSchema]: Paginated list of guilds
"""
results, total = await guilds_service.list_and_count(*filters)
return guilds_service.to_schema(data=results, total=total, filters=filters, schema_type=GuildSchema)
# Create LimitOffset filter with explicit limit and offset parameters
# Filter out any existing LimitOffset from the auto-injected filters
limit_offset = LimitOffset(limit, offset)
filtered_filters = [f for f in filters if not isinstance(f, LimitOffset)]
results, total = await guilds_service.list_and_count(limit_offset, *filtered_filters)
return guilds_service.to_schema(data=results, total=total, filters=filtered_filters, schema_type=GuildSchema)

@post(
operation_id="CreateGuild",
Expand All @@ -86,6 +108,8 @@ async def create_guild(
guild_id: int = Parameter(
title="Guild ID",
description="The guild ID.",
gt=0, # Must be positive
le=9223372036854775807, # Max 64-bit signed integer (Discord snowflake)
),
guild_name: str = Parameter(
title="Guild Name",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# type: ignore
"""Revision ID: cd34267d1ffb
Revises: f32ee278015d
Create Date: 2025-11-23 16:53:17.780348+00:00

Fix ForumConfig.help_thread_notify_roles field type from String to ARRAY(BigInteger).
"""

from __future__ import annotations

import warnings

import sqlalchemy as sa
from advanced_alchemy.types import GUID, ORA_JSONB, DateTimeUTC
from alembic import op
from sqlalchemy import Text # noqa: F401

from byte_common.models.forum_config import IntegerArray

__all__ = ["data_downgrades", "data_upgrades", "downgrade", "schema_downgrades", "schema_upgrades", "upgrade"]

sa.GUID = GUID
sa.DateTimeUTC = DateTimeUTC
sa.ORA_JSONB = ORA_JSONB

# revision identifiers, used by Alembic.
revision = "cd34267d1ffb"
down_revision = "f32ee278015d"
branch_labels = None
depends_on = None


def upgrade() -> None:
with warnings.catch_warnings():
warnings.filterwarnings("ignore", category=UserWarning)
with op.get_context().autocommit_block():
schema_upgrades()
data_upgrades()


def downgrade() -> None:
with warnings.catch_warnings():
warnings.filterwarnings("ignore", category=UserWarning)
with op.get_context().autocommit_block():
data_downgrades()
schema_downgrades()


def schema_upgrades() -> None:
"""Schema upgrade migrations go here."""
# Change help_thread_notify_roles from String to IntegerArray (ARRAY on PostgreSQL, JSON elsewhere)
# Add help_thread_sync as a regular boolean field (was broken association proxy)
with op.batch_alter_table("forum_config", schema=None) as batch_op:
batch_op.alter_column(
"help_thread_notify_roles",
existing_type=sa.String(),
type_=IntegerArray(),
existing_nullable=True,
postgresql_using="string_to_array(help_thread_notify_roles, ',')::bigint[]",
)
batch_op.add_column(sa.Column("help_thread_sync", sa.Boolean(), nullable=False, server_default="false"))


def schema_downgrades() -> None:
"""Schema downgrade migrations go here."""
# Revert help_thread_notify_roles from IntegerArray back to String
# Remove help_thread_sync field
with op.batch_alter_table("forum_config", schema=None) as batch_op:
batch_op.drop_column("help_thread_sync")
batch_op.alter_column(
"help_thread_notify_roles",
existing_type=IntegerArray(),
type_=sa.String(),
existing_nullable=True,
postgresql_using="array_to_string(help_thread_notify_roles, ',')",
)


def data_upgrades() -> None:
"""Add any optional data upgrade migrations here!"""


def data_downgrades() -> None:
"""Add any optional data downgrade migrations here!"""
32 changes: 29 additions & 3 deletions services/api/src/byte_api/lib/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@

from __future__ import annotations

from typing import Any

from pydantic import BaseModel as _BaseModel
from pydantic import ConfigDict

from byte_common.utils.strings import camel_case

__all__ = ["BaseModel", "CamelizedBaseModel"]
__all__ = ["BaseModel", "CamelizedBaseModel", "serialize_camelized_model"]


class BaseModel(_BaseModel):
Expand All @@ -22,6 +24,30 @@ class BaseModel(_BaseModel):


class CamelizedBaseModel(BaseModel):
"""Camelized Base pydantic schema."""
"""Camelized Base pydantic schema.

This model uses camelCase for field names in serialization by default.
When serialized, snake_case fields will be converted to camelCase.
"""

model_config = ConfigDict(
populate_by_name=True,
alias_generator=camel_case,
)


def serialize_camelized_model(value: Any) -> dict[str, Any]:
"""Serialize CamelizedBaseModel instances with camelCase field names.

This encoder is used by Litestar to ensure that all Pydantic models
extending CamelizedBaseModel are serialized with their aliases (camelCase).

Args:
value: The CamelizedBaseModel instance to serialize

model_config = ConfigDict(populate_by_name=True, alias_generator=camel_case)
Returns:
dict: The serialized model with camelCase field names
"""
if isinstance(value, CamelizedBaseModel):
return value.model_dump(by_alias=True, mode="json")
return value
Loading