diff --git a/README.md b/README.md index b50a80e..9a18744 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,9 @@ POST /api/projects/ PATCH /api/projects/{project_id} DELETE /api/projects/{project_id} +GET /api/project-types/ +GET /api/labels/ + GET /api/auth/matrix/login GET /api/auth/matrix/callback GET /api/auth/me diff --git a/backend/app/main.py b/backend/app/main.py index dd0f3a9..5035350 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,15 +2,16 @@ from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware -from starlette.middleware.sessions import SessionMiddleware from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles +from starlette.middleware.sessions import SessionMiddleware -from app.routers.project_routers import router as projects_router +from app.config import get_settings from app.routers.auth_router import router as auth_router +from app.routers.label_router import router as labels_router from app.routers.profile_router import router as profile_router -from app.routers.category_router import router as categories_router -from app.config import get_settings +from app.routers.project_routers import router as projects_router +from app.routers.project_type_router import router as project_types_router settings = get_settings() app = FastAPI( @@ -41,7 +42,8 @@ app.include_router(projects_router, prefix="/api") app.include_router(auth_router, prefix="/api") app.include_router(profile_router, prefix="/api") -app.include_router(categories_router, prefix="/api") +app.include_router(project_types_router, prefix="/api") +app.include_router(labels_router, prefix="/api") @app.get("/api/health", tags=["system"]) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 87df174..4955c13 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,16 +1,19 @@ """Database models loaded as a group for SQLAlchemy relationship setup.""" -from .category import Category from .auth import AuthSession -from .project import Project, ProjectCategory +from .label import Label +from .project import Project +from .project_label import ProjectLabel +from .project_type import ProjectType from .user import User from .profile import Profile __all__ = [ "AuthSession", - "Category", + "Label", "Project", - "ProjectCategory", + "ProjectLabel", + "ProjectType", "User", "Profile", ] diff --git a/backend/app/models/label.py b/backend/app/models/label.py new file mode 100644 index 0000000..3148f0a --- /dev/null +++ b/backend/app/models/label.py @@ -0,0 +1,32 @@ +from datetime import UTC, datetime +from typing import TYPE_CHECKING +from uuid import UUID, uuid4 + +from sqlmodel import Field, Relationship +from sqlmodel_toolkit import Model + +from .project_label import ProjectLabel + +if TYPE_CHECKING: + from .project import Project + + +def utc_now() -> datetime: + return datetime.now(UTC).replace(tzinfo=None) + + +class Label(Model, table=True): + """A descriptive label that may be assigned to many projects.""" + + __tablename__ = "labels" + + id: UUID = Field(default_factory=uuid4, primary_key=True) + name: str = Field(max_length=100, unique=True, index=True) + + projects: list["Project"] = Relationship( + back_populates="labels", + link_model=ProjectLabel, + ) + + created_at: datetime = Field(default_factory=utc_now) + updated_at: datetime = Field(default_factory=utc_now) diff --git a/backend/app/models/project.py b/backend/app/models/project.py index 2460f82..cbf9c6d 100644 --- a/backend/app/models/project.py +++ b/backend/app/models/project.py @@ -5,8 +5,11 @@ from sqlmodel import Field, Relationship from sqlmodel_toolkit import Model +from .label import Label +from .project_label import ProjectLabel +from .project_type import ProjectType + if TYPE_CHECKING: - from .category import Category from .user import User @@ -15,23 +18,6 @@ def utc_now() -> datetime: return datetime.now(UTC).replace(tzinfo=None) -class ProjectCategory(Model, table=True): - """Association table between projects and categories.""" - - __tablename__ = "project_categories" - - project_id: UUID = Field( - foreign_key="projects.id", - primary_key=True, - index=True, - ) - category_id: UUID = Field( - foreign_key="categories.id", - primary_key=True, - index=True, - ) - - class Project(Model, table=True): """A project listed in the Matrix directory.""" @@ -72,13 +58,16 @@ class Project(Model, table=True): supports_e2ee: bool = Field(default=False) - owner: "User" = Relationship( + project_type_id: UUID = Field(foreign_key="project_types.id", index=True) + project_type: ProjectType = Relationship(back_populates="projects") + + labels: list[Label] = Relationship( back_populates="projects", + link_model=ProjectLabel, ) - categories: list["Category"] = Relationship( + owner: "User" = Relationship( back_populates="projects", - link_model=ProjectCategory, ) created_at: datetime = Field( diff --git a/backend/app/models/project_label.py b/backend/app/models/project_label.py new file mode 100644 index 0000000..19e901e --- /dev/null +++ b/backend/app/models/project_label.py @@ -0,0 +1,21 @@ +from uuid import UUID + +from sqlmodel import Field +from sqlmodel_toolkit import Model + + +class ProjectLabel(Model, table=True): + """Association table between projects and descriptive labels.""" + + __tablename__ = "project_labels" + + project_id: UUID = Field( + foreign_key="projects.id", + primary_key=True, + index=True, + ) + label_id: UUID = Field( + foreign_key="labels.id", + primary_key=True, + index=True, + ) diff --git a/backend/app/models/category.py b/backend/app/models/project_type.py similarity index 53% rename from backend/app/models/category.py rename to backend/app/models/project_type.py index 88fdb0f..5136a50 100644 --- a/backend/app/models/category.py +++ b/backend/app/models/project_type.py @@ -1,28 +1,27 @@ from datetime import UTC, datetime +from typing import TYPE_CHECKING +from uuid import UUID, uuid4 from sqlmodel import Field, Relationship from sqlmodel_toolkit import Model -from .project import Project, ProjectCategory -from uuid import UUID, uuid4 + +if TYPE_CHECKING: + from .project import Project def utc_now() -> datetime: return datetime.now(UTC).replace(tzinfo=None) -class Category(Model, table=True): - """A category that a project can belong to.""" +class ProjectType(Model, table=True): + """The single primary kind assigned to a project.""" - __tablename__ = "categories" + __tablename__ = "project_types" id: UUID = Field(default_factory=uuid4, primary_key=True) + name: str = Field(max_length=100, unique=True, index=True) - name: str = Field(unique=True, index=True) + projects: list["Project"] = Relationship(back_populates="project_type") created_at: datetime = Field(default_factory=utc_now) updated_at: datetime = Field(default_factory=utc_now) - - projects: list[Project] = Relationship( - back_populates="categories", - link_model=ProjectCategory, - ) diff --git a/backend/app/routers/category_router.py b/backend/app/routers/category_router.py deleted file mode 100644 index 1fb0597..0000000 --- a/backend/app/routers/category_router.py +++ /dev/null @@ -1,15 +0,0 @@ -from fastapi import APIRouter, Depends -from sqlmodel import Session, select - -from app.database import get_session -from app.models.category import Category -from app.schemas.category import CategoryRead - -router = APIRouter(prefix="/categories", tags=["categories"]) - - -@router.get("/", response_model=list[CategoryRead]) -def list_categories( - session: Session = Depends(get_session), -) -> list[Category]: - return list(session.exec(select(Category).order_by(Category.name)).all()) diff --git a/backend/app/routers/label_router.py b/backend/app/routers/label_router.py new file mode 100644 index 0000000..ca41ced --- /dev/null +++ b/backend/app/routers/label_router.py @@ -0,0 +1,16 @@ +from fastapi import APIRouter, Depends +from sqlmodel import Session + +from app.database import get_session +from app.models.label import Label +from app.schemas.label import LabelRead +from app.services import labels_service + +router = APIRouter(prefix="/labels", tags=["labels"]) + + +@router.get("/", response_model=list[LabelRead]) +def list_labels( + session: Session = Depends(get_session), +) -> list[Label]: + return labels_service.list_labels(session) diff --git a/backend/app/routers/project_routers.py b/backend/app/routers/project_routers.py index 5a4b5d5..bd066d2 100644 --- a/backend/app/routers/project_routers.py +++ b/backend/app/routers/project_routers.py @@ -1,6 +1,7 @@ +from uuid import UUID + from fastapi import APIRouter, Depends, HTTPException, status from sqlmodel import Session -from uuid import UUID from app.database import get_session from app.dependencies import get_current_user @@ -8,6 +9,11 @@ from app.models.user import User from app.schemas.project import ProjectCreate, ProjectRead, ProjectUpdate from app.services import projects_service +from app.services.errors import ( + LabelNotFoundError, + ProjectLinkRequiredError, + ProjectTypeNotFoundError, +) router = APIRouter( prefix="/projects", @@ -58,7 +64,10 @@ def create_project( ) -> Project: try: return projects_service.create_project(session, data, user_id=user.id) - except projects_service.CategoryNotFoundError as exc: + except ( + LabelNotFoundError, + ProjectTypeNotFoundError, + ) as exc: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc), @@ -80,8 +89,9 @@ def update_project( user_id=user.id, ) except ( - projects_service.CategoryNotFoundError, - projects_service.ProjectLinkRequiredError, + LabelNotFoundError, + ProjectLinkRequiredError, + ProjectTypeNotFoundError, ) as exc: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, diff --git a/backend/app/routers/project_type_router.py b/backend/app/routers/project_type_router.py new file mode 100644 index 0000000..695b69b --- /dev/null +++ b/backend/app/routers/project_type_router.py @@ -0,0 +1,16 @@ +from fastapi import APIRouter, Depends +from sqlmodel import Session + +from app.database import get_session +from app.models.project_type import ProjectType +from app.schemas.project_type import ProjectTypeRead +from app.services import project_types_service + +router = APIRouter(prefix="/project-types", tags=["project types"]) + + +@router.get("/", response_model=list[ProjectTypeRead]) +def list_project_types( + session: Session = Depends(get_session), +) -> list[ProjectType]: + return project_types_service.list_project_types(session) diff --git a/backend/app/schemas/category.py b/backend/app/schemas/category.py deleted file mode 100644 index 2114da1..0000000 --- a/backend/app/schemas/category.py +++ /dev/null @@ -1,15 +0,0 @@ -from pydantic import BaseModel, Field -from uuid import UUID - - -class CategoryCreate(BaseModel): - """Schema for creating a new category.""" - - name: str = Field(min_length=2, max_length=100) - - project_ids: list[UUID] - - -class CategoryRead(BaseModel): - id: UUID - name: str diff --git a/backend/app/schemas/label.py b/backend/app/schemas/label.py new file mode 100644 index 0000000..384e112 --- /dev/null +++ b/backend/app/schemas/label.py @@ -0,0 +1,12 @@ +from uuid import UUID + +from pydantic import BaseModel, ConfigDict + + +class LabelRead(BaseModel): + """Public representation of a descriptive project label.""" + + model_config = ConfigDict(from_attributes=True) + + id: UUID + name: str diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py index c140994..f44a964 100644 --- a/backend/app/schemas/project.py +++ b/backend/app/schemas/project.py @@ -11,7 +11,8 @@ model_validator, ) -from .category import CategoryRead +from .label import LabelRead +from .project_type import ProjectTypeRead def normalize_optional_http_url(value: Any) -> Any: @@ -53,7 +54,8 @@ class ProjectCreate(BaseModel): supports_e2ee: bool = False - category_ids: list[UUID] = Field(min_length=1) + project_type_id: UUID + label_ids: list[UUID] = Field(default_factory=list) @field_validator( "name", @@ -78,14 +80,14 @@ def strip_required_text(cls, value: Any) -> Any: def validate_optional_urls(cls, value: Any) -> Any: return normalize_optional_http_url(value) - @field_validator("category_ids") + @field_validator("label_ids") @classmethod - def reject_duplicate_categories( + def reject_duplicate_labels( cls, value: list[UUID], ) -> list[UUID]: if len(value) != len(set(value)): - raise ValueError("Categories must be unique") + raise ValueError("Labels must be unique") return value @@ -123,10 +125,8 @@ class ProjectUpdate(BaseModel): supports_e2ee: bool | None = None - category_ids: list[UUID] | None = Field( - default=None, - min_length=1, - ) + project_type_id: UUID | None = None + label_ids: list[UUID] | None = None @field_validator( "name", @@ -151,14 +151,14 @@ def strip_updated_text(cls, value: Any) -> Any: def validate_optional_urls(cls, value: Any) -> Any: return normalize_optional_http_url(value) - @field_validator("category_ids") + @field_validator("label_ids") @classmethod - def reject_duplicate_categories( + def reject_duplicate_labels( cls, value: list[UUID] | None, ) -> list[UUID] | None: if value is not None and len(value) != len(set(value)): - raise ValueError("Categories must be unique") + raise ValueError("Labels must be unique") return value @@ -167,7 +167,8 @@ def reject_duplicate_categories( "short_description", "description", "supports_e2ee", - "category_ids", + "project_type_id", + "label_ids", mode="before", ) @classmethod @@ -222,10 +223,11 @@ class ProjectRead(BaseModel): supports_e2ee: bool + project_type: ProjectTypeRead + labels: list[LabelRead] = Field(default_factory=list) + user_id: UUID owner: ProjectOwnerRead - categories: list[CategoryRead] = Field(default_factory=list) - created_at: datetime updated_at: datetime diff --git a/backend/app/schemas/project_type.py b/backend/app/schemas/project_type.py new file mode 100644 index 0000000..8c9c692 --- /dev/null +++ b/backend/app/schemas/project_type.py @@ -0,0 +1,12 @@ +from uuid import UUID + +from pydantic import BaseModel, ConfigDict + + +class ProjectTypeRead(BaseModel): + """Public representation of a project's primary type.""" + + model_config = ConfigDict(from_attributes=True) + + id: UUID + name: str diff --git a/backend/app/seed.py b/backend/app/seed.py index d0749ed..ab8ce12 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -1,13 +1,24 @@ from sqlmodel import Session, select from app.database import engine -from app.models.category import Category +from app.models.label import Label from app.models.profile import Profile from app.models.project import Project +from app.models.project_type import ProjectType from app.models.user import User ADA_REPOSITORY_URL = "https://github.com/Code-Society-Lab/ada" +PROJECT_TYPE_NAMES = ( + "Bot", + "SDK", + "Framework", + "Bridges", + "Clients", + "Server", + "Integrations", +) + SEED_OIDC_ISSUER = "seed://matrix-directory" SEED_OIDC_SUBJECT = "penguinboi" SEED_MATRIX_ID = "@penguinboi:matrix.org" @@ -81,29 +92,50 @@ def get_or_create_profile( return profile -def get_or_create_category( +def get_or_create_project_type( session: Session, *, name: str, -) -> Category: - category = session.exec(select(Category).where(Category.name == name)).first() +) -> ProjectType: + project_type = session.exec( + select(ProjectType).where(ProjectType.name == name) + ).first() - if category is not None: - return category + if project_type is not None: + return project_type - category = Category(name=name) + project_type = ProjectType(name=name) - session.add(category) + session.add(project_type) session.flush() - return category + return project_type + + +def get_or_create_label( + session: Session, + *, + name: str, +) -> Label: + label = session.exec(select(Label).where(Label.name == name)).first() + + if label is not None: + return label + + label = Label(name=name) + + session.add(label) + session.flush() + + return label def get_or_create_ada( session: Session, *, owner: User, - categories: list[Category], + project_type: ProjectType, + labels: list[Label], ) -> Project: project = session.exec( select(Project).where(Project.repository_url == ADA_REPOSITORY_URL) @@ -116,6 +148,7 @@ def get_or_create_ada( description="", repository_url=ADA_REPOSITORY_URL, user_id=owner.id, + project_type_id=project_type.id, ) session.add(project) @@ -133,7 +166,8 @@ def get_or_create_ada( project.website_url = "https://codesociety.xyz/" project.matrix_server_url = "https://matrix.to/#/#codesociety:matrix.org" project.supports_e2ee = False - project.categories = categories + project.project_type = project_type + project.labels = labels return project @@ -154,12 +188,17 @@ def seed() -> None: display_name="PenguinBoi", ) - development_category = get_or_create_category( + project_types = { + name: get_or_create_project_type(session, name=name) + for name in PROJECT_TYPE_NAMES + } + + development_label = get_or_create_label( session, name="Dev tools", ) - utilities_category = get_or_create_category( + utilities_label = get_or_create_label( session, name="Utility", ) @@ -167,9 +206,10 @@ def seed() -> None: get_or_create_ada( session, owner=owner, - categories=[ - development_category, - utilities_category, + project_type=project_types["Bot"], + labels=[ + development_label, + utilities_label, ], ) diff --git a/backend/app/services/errors.py b/backend/app/services/errors.py index 3b439fd..c88b5d4 100644 --- a/backend/app/services/errors.py +++ b/backend/app/services/errors.py @@ -1,4 +1,8 @@ -class CategoryNotFoundError(Exception): +class LabelNotFoundError(Exception): + pass + + +class ProjectTypeNotFoundError(Exception): pass diff --git a/backend/app/services/labels_service.py b/backend/app/services/labels_service.py new file mode 100644 index 0000000..7952d4b --- /dev/null +++ b/backend/app/services/labels_service.py @@ -0,0 +1,29 @@ +from typing import Any, cast +from uuid import UUID + +from sqlmodel import Session, select + +from app.models.label import Label + +from .errors import LabelNotFoundError + + +def list_labels(session: Session) -> list[Label]: + statement = select(Label).order_by(Label.name) + return list(session.exec(statement).all()) + + +def get_labels(session: Session, label_ids: list[UUID]) -> list[Label]: + if not label_ids: + return [] + + label_id_column = cast(Any, Label.id) + statement = select(Label).where(label_id_column.in_(label_ids)) + labels = list(session.exec(statement).all()) + + found_ids = {label.id for label in labels} + missing_ids = set(label_ids) - found_ids + if missing_ids: + raise LabelNotFoundError(f"Labels not found: {missing_ids}") + + return labels diff --git a/backend/app/services/project_types_service.py b/backend/app/services/project_types_service.py new file mode 100644 index 0000000..e52c5d0 --- /dev/null +++ b/backend/app/services/project_types_service.py @@ -0,0 +1,20 @@ +from uuid import UUID + +from sqlmodel import Session, select + +from app.models.project_type import ProjectType + +from .errors import ProjectTypeNotFoundError + + +def list_project_types(session: Session) -> list[ProjectType]: + statement = select(ProjectType).order_by(ProjectType.name) + return list(session.exec(statement).all()) + + +def get_project_type(session: Session, project_type_id: UUID) -> ProjectType: + project_type = session.get(ProjectType, project_type_id) + if project_type is None: + raise ProjectTypeNotFoundError(f"Project type not found: {project_type_id}") + + return project_type diff --git a/backend/app/services/projects_service.py b/backend/app/services/projects_service.py index 222e336..2935cf4 100644 --- a/backend/app/services/projects_service.py +++ b/backend/app/services/projects_service.py @@ -1,13 +1,12 @@ -from typing import Any, cast +from uuid import UUID from sqlmodel import Session, select -from uuid import UUID -from app.models.category import Category from app.models.project import Project from app.schemas.project import ProjectCreate, ProjectUpdate -from .errors import CategoryNotFoundError, ProjectLinkRequiredError +from . import labels_service, project_types_service +from .errors import ProjectLinkRequiredError def list_projects(session: Session) -> list[Project]: @@ -33,10 +32,8 @@ def create_project( *, user_id: UUID, ) -> Project: - if not data.category_ids: - raise ValueError("A project must have at least one category") - - categories = _get_categories(session, data.category_ids) + project_type = project_types_service.get_project_type(session, data.project_type_id) + labels = labels_service.get_labels(session, data.label_ids) project = Project( name=data.name, @@ -47,7 +44,8 @@ def create_project( matrix_server_url=data.matrix_server_url, supports_e2ee=data.supports_e2ee, user_id=user_id, - categories=categories, + project_type=project_type, + labels=labels, ) session.add(project) @@ -71,7 +69,7 @@ def update_project( update_data = data.model_dump( exclude_unset=True, - exclude={"category_ids"}, + exclude={"project_type_id", "label_ids"}, ) repository_url = update_data.get("repository_url", project.repository_url) @@ -81,17 +79,24 @@ def update_project( "Provide at least a repository URL or website URL" ) + project_type = None + if data.project_type_id is not None: + project_type = project_types_service.get_project_type( + session, data.project_type_id + ) + + labels = None + if data.label_ids is not None: + labels = labels_service.get_labels(session, data.label_ids) + for field, value in update_data.items(): setattr(project, field, value) - if data.category_ids is not None: - if not data.category_ids: - raise ValueError("A project must have at least one category") + if project_type is not None: + project.project_type = project_type - project.categories = _get_categories( - session, - data.category_ids, - ) + if labels is not None: + project.labels = labels session.add(project) session.commit() @@ -123,21 +128,3 @@ def _get_owned_project( return session.exec( select(Project).where(Project.id == project_id, Project.user_id == user_id) ).first() - - -def _get_categories( - session: Session, - category_ids: list[UUID], -) -> list[Category]: - category_id_column = cast(Any, Category.id) - statement = select(Category).where(category_id_column.in_(category_ids)) - - categories = list(session.exec(statement).all()) - - if len(categories) != len(set(category_ids)): - found_ids = {category.id for category in categories} - missing_ids = set(category_ids) - found_ids - - raise CategoryNotFoundError(f"Categories not found: {missing_ids}") - - return categories diff --git a/backend/db/migrations/2026081801_add_project_types_and_labels.py b/backend/db/migrations/2026081801_add_project_types_and_labels.py new file mode 100644 index 0000000..28ac3fb --- /dev/null +++ b/backend/db/migrations/2026081801_add_project_types_and_labels.py @@ -0,0 +1,162 @@ +"""Split project categories into one project type and optional labels.""" + +from pelican import change_table, create_table, drop_table, get_runner, migration +from sqlalchemy import text +from sqlalchemy.dialects.postgresql import UUID as PostgreSQLUUID + + +def _create_named_lookup(table_name: str) -> None: + with create_table(table_name) as table: + table.column( + "id", + PostgreSQLUUID(as_uuid=True), + primary_key=True, + server_default=text("gen_random_uuid()"), + ) + table.string("name", length=100, nullable=False) + table.timestamps() + table.index(["name"], unique=True) + + +def _create_project_labels() -> None: + with create_table("project_labels", primary_key=False) as table: + table.column( + "project_id", + PostgreSQLUUID(as_uuid=True), + nullable=False, + primary_key=True, + ) + table.column( + "label_id", + PostgreSQLUUID(as_uuid=True), + nullable=False, + primary_key=True, + ) + table.add_foreign_key( + ["project_id"], + "projects", + ["id"], + name="project_labels_project_id_fkey", + on_delete="CASCADE", + ) + table.add_foreign_key( + ["label_id"], + "labels", + ["id"], + name="project_labels_label_id_fkey", + on_delete="CASCADE", + ) + table.index(["project_id", "label_id"], unique=True) + table.index(["label_id"]) + + +@migration.up +def upgrade() -> None: + _create_named_lookup("project_types") + _create_named_lookup("labels") + _create_project_labels() + + with change_table("projects") as table: + table.column("project_type_id", PostgreSQLUUID(as_uuid=True), nullable=True) + + with change_table("projects") as table: + table.add_foreign_key( + ["project_type_id"], + "project_types", + ["id"], + name="projects_project_type_id_fkey", + ) + table.index(["project_type_id"]) + + with get_runner().engine.begin() as connection: + connection.execute(text(""" + INSERT INTO labels (id, name, created_at, updated_at) + SELECT id, name, created_at, updated_at + FROM categories + """)) + connection.execute(text(""" + INSERT INTO project_labels (project_id, label_id) + SELECT project_id, category_id + FROM project_categories + """)) + connection.execute(text(""" + INSERT INTO project_types (name, created_at, updated_at) + VALUES + ('Bot', NOW(), NOW()), + ('SDK', NOW(), NOW()), + ('Framework', NOW(), NOW()), + ('Bridges', NOW(), NOW()), + ('Clients', NOW(), NOW()), + ('Server', NOW(), NOW()), + ('Integrations', NOW(), NOW()) + """)) + connection.execute(text(""" + UPDATE projects + SET project_type_id = ( + SELECT id FROM project_types WHERE name = 'Bot' + ) + """)) + + with change_table("projects") as table: + table.alter("project_type_id", nullable=False) + + drop_table("project_categories") + drop_table("categories") + + +@migration.down +def downgrade() -> None: + _create_named_lookup("categories") + + with create_table("project_categories", primary_key=False) as table: + table.column( + "project_id", + PostgreSQLUUID(as_uuid=True), + nullable=False, + primary_key=True, + ) + table.column( + "category_id", + PostgreSQLUUID(as_uuid=True), + nullable=False, + primary_key=True, + ) + table.add_foreign_key( + ["project_id"], + "projects", + ["id"], + name="project_categories_project_id_fkey", + on_delete="CASCADE", + ) + table.add_foreign_key( + ["category_id"], + "categories", + ["id"], + name="project_categories_category_id_fkey", + on_delete="CASCADE", + ) + table.index(["project_id", "category_id"], unique=True) + table.index(["category_id"]) + + with get_runner().engine.begin() as connection: + connection.execute(text(""" + INSERT INTO categories (id, name, created_at, updated_at) + SELECT id, name, created_at, updated_at + FROM labels + """)) + connection.execute(text(""" + INSERT INTO project_categories (project_id, category_id) + SELECT project_id, label_id + FROM project_labels + """)) + + with change_table("projects") as table: + table.remove_index(["project_type_id"]) + table.remove_foreign_key(name="projects_project_type_id_fkey") + + with change_table("projects") as table: + table.drop("project_type_id") + + drop_table("project_labels") + drop_table("labels") + drop_table("project_types") diff --git a/backend/tests/test_categories.py b/backend/tests/test_categories.py deleted file mode 100644 index 7803144..0000000 --- a/backend/tests/test_categories.py +++ /dev/null @@ -1,29 +0,0 @@ -from fastapi import FastAPI -from fastapi.testclient import TestClient -from sqlmodel import Session - -from app.database import get_session -from app.models.category import Category -from app.routers.category_router import router - - -def test_list_categories__expect_alphabetical_results(session: Session) -> None: - session.add_all( - [ - Category(name="Utility"), - Category(name="Dev tools"), - ] - ) - session.commit() - - app = FastAPI() - app.include_router(router, prefix="/api") - app.dependency_overrides[get_session] = lambda: session - - response = TestClient(app).get("/api/categories/") - - assert response.status_code == 200 - assert [category["name"] for category in response.json()] == [ - "Dev tools", - "Utility", - ] diff --git a/backend/tests/test_labels.py b/backend/tests/test_labels.py new file mode 100644 index 0000000..69f6bc9 --- /dev/null +++ b/backend/tests/test_labels.py @@ -0,0 +1,27 @@ +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlmodel import Session + +from app.database import get_session +from app.models.label import Label +from app.routers.label_router import router + + +def test_list_labels__expect_alphabetical_results(session: Session) -> None: + session.add_all( + [ + Label(name="Utility"), + Label(name="Dev tools"), + ] + ) + session.commit() + + app = FastAPI() + app.include_router(router, prefix="/api") + app.dependency_overrides[get_session] = lambda: session + client = TestClient(app) + + response = client.get("/api/labels/") + + assert response.status_code == 200 + assert [item["name"] for item in response.json()] == ["Dev tools", "Utility"] diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index 945c244..8c92053 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -5,6 +5,7 @@ def test_project_model_creation__expect_uuid_identifiers_assigned() -> None: user_id = uuid4() + project_type_id = uuid4() project = Project( name="Test", description="Test project", @@ -13,8 +14,10 @@ def test_project_model_creation__expect_uuid_identifiers_assigned() -> None: website_url="", matrix_server_url="", user_id=user_id, + project_type_id=project_type_id, supports_e2ee=False, ) assert isinstance(project.id, UUID) assert project.user_id == user_id + assert project.project_type_id == project_type_id diff --git a/backend/tests/test_project_ownership.py b/backend/tests/test_project_ownership.py index b101d1b..3e1c00b 100644 --- a/backend/tests/test_project_ownership.py +++ b/backend/tests/test_project_ownership.py @@ -1,26 +1,31 @@ +from uuid import uuid4 + import pytest from sqlmodel import Session -from app.models.category import Category +from app.models.label import Label from app.models.project import Project +from app.models.project_type import ProjectType from app.models.user import User from app.schemas.project import ProjectUpdate -from app.services.errors import ProjectLinkRequiredError +from app.services.errors import ProjectLinkRequiredError, ProjectTypeNotFoundError from app.services.projects_service import delete_project, update_project def test_project_writes__expect_only_owner_allowed(session: Session) -> None: owner = User(oidc_issuer="issuer", oidc_subject="owner") stranger = User(oidc_issuer="issuer", oidc_subject="stranger") - category = Category(name="Bots") - session.add_all([owner, stranger, category]) + project_type = ProjectType(name="Bot") + label = Label(name="Utility") + session.add_all([owner, stranger, project_type, label]) session.flush() project = Project( name="Test Bot", description="Test bot", short_description="Test bot", user_id=owner.id, - categories=[category], + project_type_id=project_type.id, + labels=[label], ) session.add(project) session.commit() @@ -43,8 +48,8 @@ def test_project_writes__expect_only_owner_allowed(session: Session) -> None: def test_project_update__expect_at_least_one_project_link(session: Session) -> None: owner = User(oidc_issuer="issuer", oidc_subject="owner") - category = Category(name="Bots") - session.add_all([owner, category]) + project_type = ProjectType(name="Bot") + session.add_all([owner, project_type]) session.flush() project = Project( name="Test Bot", @@ -52,7 +57,7 @@ def test_project_update__expect_at_least_one_project_link(session: Session) -> N short_description="Test bot", repository_url="https://example.com/repository", user_id=owner.id, - categories=[category], + project_type_id=project_type.id, ) session.add(project) session.commit() @@ -67,3 +72,65 @@ def test_project_update__expect_at_least_one_project_link(session: Session) -> N session.refresh(project) assert project.repository_url == "https://example.com/repository" + + +def test_project_update__expect_type_changed_and_labels_cleared( + session: Session, +) -> None: + owner = User(oidc_issuer="issuer", oidc_subject="owner") + bot_type = ProjectType(name="Bot") + sdk_type = ProjectType(name="SDK") + label = Label(name="Dev tools") + session.add_all([owner, bot_type, sdk_type, label]) + session.flush() + project = Project( + name="Test project", + description="Test project", + short_description="Test project", + repository_url="https://example.com/repository", + user_id=owner.id, + project_type_id=bot_type.id, + labels=[label], + ) + session.add(project) + session.commit() + + updated = update_project( + session, + project.id, + ProjectUpdate(project_type_id=sdk_type.id, label_ids=[]), + user_id=owner.id, + ) + + assert updated is not None + assert updated.project_type.id == sdk_type.id + assert updated.labels == [] + + +def test_project_update_with_unknown_type__expect_no_partial_changes( + session: Session, +) -> None: + owner = User(oidc_issuer="issuer", oidc_subject="owner") + project_type = ProjectType(name="Bot") + session.add_all([owner, project_type]) + session.flush() + project = Project( + name="Original name", + description="Test project", + short_description="Test project", + repository_url="https://example.com/repository", + user_id=owner.id, + project_type_id=project_type.id, + ) + session.add(project) + session.commit() + + with pytest.raises(ProjectTypeNotFoundError): + update_project( + session, + project.id, + ProjectUpdate(name="Changed name", project_type_id=uuid4()), + user_id=owner.id, + ) + + assert project.name == "Original name" diff --git a/backend/tests/test_project_types.py b/backend/tests/test_project_types.py new file mode 100644 index 0000000..b34e651 --- /dev/null +++ b/backend/tests/test_project_types.py @@ -0,0 +1,27 @@ +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlmodel import Session + +from app.database import get_session +from app.models.project_type import ProjectType +from app.routers.project_type_router import router + + +def test_list_project_types__expect_alphabetical_results(session: Session) -> None: + session.add_all( + [ + ProjectType(name="SDK"), + ProjectType(name="Bot"), + ] + ) + session.commit() + + app = FastAPI() + app.include_router(router, prefix="/api") + app.dependency_overrides[get_session] = lambda: session + client = TestClient(app) + + response = client.get("/api/project-types/") + + assert response.status_code == 200 + assert [item["name"] for item in response.json()] == ["Bot", "SDK"] diff --git a/backend/tests/test_project_validation.py b/backend/tests/test_project_validation.py index 5f7f132..e7d8d3e 100644 --- a/backend/tests/test_project_validation.py +++ b/backend/tests/test_project_validation.py @@ -12,7 +12,8 @@ from app.database import get_session from app.dependencies import get_current_user -from app.models.category import Category +from app.models.label import Label +from app.models.project_type import ProjectType from app.models.user import User from app.routers.project_routers import router from app.schemas.project import ProjectCreate, ProjectUpdate @@ -22,7 +23,8 @@ class ProjectClient: client: TestClient user_id: UUID - category_id: UUID + project_type_id: UUID + label_id: UUID @pytest.fixture @@ -37,15 +39,18 @@ def project_client() -> Generator[ProjectClient, None, None]: app = FastAPI() app.include_router(router, prefix="/api") user = User(oidc_issuer="issuer", oidc_subject="owner") - category = Category(name="Bots") + project_type = ProjectType(name="Bot") + label = Label(name="Utility") with Session(engine) as setup_session: - setup_session.add_all([user, category]) + setup_session.add_all([user, project_type, label]) setup_session.commit() setup_session.refresh(user) - setup_session.refresh(category) + setup_session.refresh(project_type) + setup_session.refresh(label) user_id = user.id - category_id = category.id + project_type_id = project_type.id + label_id = label.id def override_session() -> Generator[Session, None, None]: with Session(engine) as session: @@ -59,12 +64,12 @@ def override_current_user() -> User: try: with TestClient(app) as test_client: - yield ProjectClient(test_client, user_id, category_id) + yield ProjectClient(test_client, user_id, project_type_id, label_id) finally: engine.dispose() -def test_create_with_no_categories__expect_validation_error( +def test_create_without_project_type__expect_validation_error( project_client: ProjectClient, ) -> None: response = project_client.client.post( @@ -73,12 +78,12 @@ def test_create_with_no_categories__expect_validation_error( "name": "Test Bot", "description": "A test bot", "short_description": "Test bot", - "category_ids": [], + "label_ids": [], }, ) assert response.status_code == 422 - assert response.json()["detail"][0]["loc"] == ["body", "category_ids"] + assert response.json()["detail"][0]["loc"] == ["body", "project_type_id"] def test_create__expect_project_associated_with_authenticated_user( @@ -91,28 +96,45 @@ def test_create__expect_project_associated_with_authenticated_user( "description": "A useful bot.", "short_description": "Useful bot", "repository_url": "https://example.com/test-bot", - "category_ids": [str(project_client.category_id)], + "project_type_id": str(project_client.project_type_id), + "label_ids": [str(project_client.label_id)], }, ) assert response.status_code == 201 body = response.json() assert body["user_id"] == str(project_client.user_id) - assert [category["id"] for category in body["categories"]] == [ - str(project_client.category_id) - ] + assert body["project_type"]["id"] == str(project_client.project_type_id) + assert [label["id"] for label in body["labels"]] == [str(project_client.label_id)] -def test_update_with_no_categories__expect_validation_error( +@pytest.mark.parametrize( + ("project_type_id", "label_ids", "message"), + [ + (uuid4(), [], "Project type not found"), + (None, [uuid4()], "Labels not found"), + ], +) +def test_create_with_unknown_classification__expect_bad_request( project_client: ProjectClient, + project_type_id: UUID | None, + label_ids: list[UUID], + message: str, ) -> None: - response = project_client.client.patch( - f"/api/projects/{uuid4()}", - json={"category_ids": []}, + response = project_client.client.post( + "/api/projects/", + json={ + "name": "Test Bot", + "description": "A useful bot.", + "short_description": "Useful bot", + "repository_url": "https://example.com/test-bot", + "project_type_id": str(project_type_id or project_client.project_type_id), + "label_ids": [str(label_id) for label_id in label_ids], + }, ) - assert response.status_code == 422 - assert response.json()["detail"][0]["loc"] == ["body", "category_ids"] + assert response.status_code == 400 + assert message in response.json()["detail"] @pytest.mark.parametrize( @@ -137,7 +159,8 @@ def test_update_with_invalid_text__expect_validation_error( "description", "short_description", "supports_e2ee", - "category_ids", + "project_type_id", + "label_ids", ], ) def test_update_with_null_required_field__expect_validation_error(field: str) -> None: @@ -159,7 +182,7 @@ def test_create__expect_required_text_trimmed() -> None: description=" A useful bot. ", short_description=" Useful bot ", repository_url="https://example.com/test-bot", - category_ids=[uuid4()], + project_type_id=uuid4(), ) assert project.name == "Test Bot" @@ -178,7 +201,7 @@ def test_create_with_whitespace_only_text__expect_validation_error( "name": "Test Bot", "description": "A useful bot.", "short_description": "Useful bot", - "category_ids": [uuid4()], + "project_type_id": uuid4(), } data[field] = " " @@ -197,7 +220,7 @@ def test_create_with_invalid_url__expect_validation_error(url: str) -> None: description="A useful bot.", short_description="Useful bot", repository_url=url, - category_ids=[uuid4()], + project_type_id=uuid4(), ) @@ -210,7 +233,7 @@ def test_create_with_overlong_url__expect_validation_error() -> None: description="A useful bot.", short_description="Useful bot", repository_url=url, - category_ids=[uuid4()], + project_type_id=uuid4(), ) @@ -221,19 +244,20 @@ def test_create_with_blank_optional_url__expect_null() -> None: short_description="Useful bot", repository_url="https://example.com/test-bot", website_url=" ", - category_ids=[uuid4()], + project_type_id=uuid4(), ) assert project.website_url is None -def test_create_with_duplicate_categories__expect_validation_error() -> None: - category_id = uuid4() +def test_create_with_duplicate_labels__expect_validation_error() -> None: + label_id = uuid4() - with pytest.raises(ValidationError, match="Categories must be unique"): + with pytest.raises(ValidationError, match="Labels must be unique"): ProjectCreate( name="Test Bot", description="A useful bot.", short_description="Useful bot", - category_ids=[category_id, category_id], + project_type_id=uuid4(), + label_ids=[label_id, label_id], ) diff --git a/backend/tests/test_seed.py b/backend/tests/test_seed.py index 63a5c2e..2484189 100644 --- a/backend/tests/test_seed.py +++ b/backend/tests/test_seed.py @@ -1,8 +1,28 @@ from sqlmodel import Session, select from app.models.profile import Profile +from app.models.label import Label +from app.models.project_type import ProjectType from app.models.user import User -from app.seed import get_or_create_profile, get_or_create_user +from app.seed import ( + PROJECT_TYPE_NAMES, + get_or_create_label, + get_or_create_profile, + get_or_create_project_type, + get_or_create_user, +) + + +def test_project_type_names__expect_supported_taxonomy() -> None: + assert PROJECT_TYPE_NAMES == ( + "Bot", + "SDK", + "Framework", + "Bridges", + "Clients", + "Server", + "Integrations", + ) def test_seed_with_migrated_profile__expect_existing_user_reused( @@ -52,3 +72,13 @@ def test_seed_without_profile__expect_user_created(session: Session) -> None: assert owner.oidc_issuer == "seed-issuer" assert owner.oidc_subject == "seed-subject" + + +def test_seed_classifications__expect_existing_values_reused(session: Session) -> None: + project_type = get_or_create_project_type(session, name="Bot") + label = get_or_create_label(session, name="Utility") + + assert get_or_create_project_type(session, name="Bot").id == project_type.id + assert get_or_create_label(session, name="Utility").id == label.id + assert len(session.exec(select(ProjectType)).all()) == 1 + assert len(session.exec(select(Label)).all()) == 1 diff --git a/docs/docs/architecture/authentication.md b/docs/docs/architecture/authentication.md index 91de438..c519396 100644 --- a/docs/docs/architecture/authentication.md +++ b/docs/docs/architecture/authentication.md @@ -78,8 +78,9 @@ erDiagram USER ||--o| PROFILE : has USER ||--o{ AUTH_SESSION : authenticates_with USER ||--o{ PROJECT : owns - PROJECT ||--o{ PROJECT_CATEGORY : classified_by - CATEGORY ||--o{ PROJECT_CATEGORY : groups + PROJECT_TYPE ||--o{ PROJECT : classifies + PROJECT ||--o{ PROJECT_LABEL : tagged_with + LABEL ||--o{ PROJECT_LABEL : groups USER { UUID id PK @@ -120,28 +121,35 @@ erDiagram string website_url "nullable" string matrix_server_url "nullable" UUID user_id FK + UUID project_type_id FK bool supports_e2ee datetime created_at datetime updated_at } - CATEGORY { + PROJECT_TYPE { UUID id PK string name UK datetime created_at datetime updated_at } - PROJECT_CATEGORY { + LABEL { + UUID id PK + string name UK + datetime created_at + datetime updated_at + } + + PROJECT_LABEL { UUID project_id PK, FK - UUID category_id PK, FK + UUID label_id PK, FK } ``` -!!! note "Category invariant" - The database relationship permits a project to have zero or more category - rows. The API and project service enforce the stronger domain rule that a - project must be created and updated with at least one category. +!!! note "Project classification" + Every project has exactly one required project type and may have zero or + more labels. Types describe what a project is; labels describe what it does. ## Login flow diff --git a/docs/docs/development.md b/docs/docs/development.md index 89ca35e..7a8e47c 100644 --- a/docs/docs/development.md +++ b/docs/docs/development.md @@ -87,7 +87,7 @@ pytest -q During development, run an individual test by its node ID: ```bash -pytest tests/test_project_validation.py::test_create_with_no_categories__expect_validation_error -q +pytest tests/test_project_validation.py::test_create_without_project_type__expect_validation_error -q ``` ## Work on the frontend diff --git a/docs/docs/guides/submitting-a-project.md b/docs/docs/guides/submitting-a-project.md index 00b8d37..4b24b36 100644 --- a/docs/docs/guides/submitting-a-project.md +++ b/docs/docs/guides/submitting-a-project.md @@ -34,6 +34,18 @@ The listing is associated with your authenticated account and published immediately. After a successful submission, the application redirects you to the new public listing. +## Types and labels + +A project type describes what the project **is**. Every listing has exactly +one of the initial directory types: **Bot**, **SDK**, **Framework**, +**Bridges**, **Clients**, **Server**, or **Integrations**. + +Labels describe what the project **does**. A listing may have any number of +labels, including none. Examples include **Dev tools** and **Utility**. + +This separation lets visitors filter by both the project format and its +purpose. + ## Field requirements | Field | Requirement | @@ -41,19 +53,23 @@ the new public listing. | Name | Required; 2–100 characters | | Short description | Required; 1–160 characters | | About | Required; 1–10,000 characters; Markdown supported | -| Repository | Optional individually; required if no website is supplied | -| Website | Optional individually; required if no repository is supplied | +| Repository | Required when no website is supplied | +| Website | Required when no repository is supplied | | Matrix room | Optional | -| Categories | At least one required; duplicate categories are not accepted | -| E2EE support | Select only when the project can operate in encrypted Matrix rooms | +| Project type | Exactly one required | +| Labels | Optional; duplicate labels are rejected | +| E2EE support | Select when the project operates in encrypted Matrix rooms | -Repository, website, and Matrix room values must be absolute `http://` or -`https://` URLs no longer than 255 characters. Blank optional URLs are treated -as omitted. +URLs must be absolute `http://` or `https://` URLs no longer than 255 +characters. At least one repository or website must remain on the listing when it is updated later. +!!! note "Listings are public" + Do not include secrets, access tokens, private room links, or other + information that should not appear in the public directory. + ## Markdown descriptions The **About** editor supports CommonMark formatting, including: diff --git a/docs/docs/reference/api.md b/docs/docs/reference/api.md index 313b8f5..4d8e0e3 100644 --- a/docs/docs/reference/api.md +++ b/docs/docs/reference/api.md @@ -9,7 +9,7 @@ provides the complete generated schemas in its - Public reads do not require authentication. - Profile and project-management endpoints use a browser session cookie. -- Project IDs, category IDs, and user IDs are UUIDs. +- Project, project-type, label, and user IDs are UUIDs. - Request validation failures use FastAPI's standard `422` response. - Project ownership is derived from the session and cannot be supplied by a client. @@ -57,7 +57,7 @@ Application errors use a JSON `detail` field: | Status | Typical cause | | --- | --- | -| `400 Bad Request` | A category does not exist or an update would remove every project link | +| `400 Bad Request` | A project type or label does not exist, or an update removes every project link | | `401 Unauthorized` | The session cookie is missing, invalid, or expired | | `404 Not Found` | The resource is missing or unavailable to its current user | | `422 Unprocessable Content` | The request body or path parameters are invalid | @@ -99,9 +99,8 @@ GET /api/health ### Create a project -A project requires at least one valid category and at least one repository or -website. The authenticated user becomes the owner; there is no writable -`user_id` field. +A project requires one valid project type. Labels are optional. The +authenticated user becomes the owner; there is no writable `user_id` field. ```json { @@ -112,8 +111,9 @@ website. The authenticated user becomes the owner; there is no writable "website_url": null, "matrix_server_url": null, "supports_e2ee": true, - "category_ids": [ - "11111111-1111-1111-1111-111111111111" + "project_type_id": "11111111-1111-1111-1111-111111111111", + "label_ids": [ + "22222222-2222-2222-2222-222222222222" ] } ``` @@ -121,13 +121,14 @@ website. The authenticated user becomes the owner; there is no writable | Field | Required | Constraints | | --- | --- | --- | | `name` | Yes | 2–100 characters | -| `description` | Yes | 1–10,000 characters; surrounding whitespace is removed | -| `short_description` | Yes | 1–160 characters; surrounding whitespace is removed | -| `repository_url` | Conditional | Absolute HTTP(S) URL up to 255 characters; required when `website_url` is absent | -| `website_url` | Conditional | Absolute HTTP(S) URL up to 255 characters; required when `repository_url` is absent | +| `description` | Yes | 1–10,000 characters | +| `short_description` | Yes | 1–160 characters | +| `repository_url` | Conditional | Absolute HTTP(S) URL up to 255 characters; required without a website | +| `website_url` | Conditional | Absolute HTTP(S) URL up to 255 characters; required without a repository | | `matrix_server_url` | No | Absolute HTTP(S) URL up to 255 characters or `null` | | `supports_e2ee` | No | Boolean; defaults to `false` | -| `category_ids` | Yes | Non-empty list of unique, existing category UUIDs | +| `project_type_id` | Yes | Existing project-type UUID | +| `label_ids` | No | Unique existing label UUIDs; defaults to an empty list | ### Update a project @@ -141,17 +142,19 @@ website. The authenticated user becomes the owner; there is no writable ``` The required project fields—`name`, `description`, `short_description`, -`supports_e2ee`, and `category_ids`—may be omitted from a patch but cannot -be explicitly set to `null`. URL fields may be cleared with `null`, but an -update cannot leave both `repository_url` and `website_url` empty. +`supports_e2ee`, `project_type_id`, and `label_ids`—may be omitted from a patch +but cannot be explicitly set to `null`. Pass an empty `label_ids` list to clear +all labels. URL fields may be cleared with `null`, but a project must retain a +repository or website. ### Project response -Project responses include public owner details and expanded categories: +Project responses include public owner details, one expanded project type, and +expanded labels: ```json { - "id": "22222222-2222-2222-2222-222222222222", + "id": "33333333-3333-3333-3333-333333333333", "name": "Example Bot", "description": "A longer description of the project.", "short_description": "A concise project summary.", @@ -159,40 +162,37 @@ Project responses include public owner details and expanded categories: "website_url": null, "matrix_server_url": null, "supports_e2ee": true, - "user_id": "33333333-3333-3333-3333-333333333333", + "project_type": { + "id": "11111111-1111-1111-1111-111111111111", + "name": "Bot" + }, + "labels": [ + { + "id": "22222222-2222-2222-2222-222222222222", + "name": "Utility" + } + ], + "user_id": "44444444-4444-4444-4444-444444444444", "owner": { - "id": "33333333-3333-3333-3333-333333333333", + "id": "44444444-4444-4444-4444-444444444444", "display_name": "Example Maintainer", "matrix_id": "@maintainer:example.org", "avatar_url": null }, - "categories": [ - { - "id": "11111111-1111-1111-1111-111111111111", - "name": "Bots" - } - ], "created_at": "2026-08-18T12:00:00", "updated_at": "2026-08-18T12:00:00" } ``` -## Categories +## Classifications -Categories are public reference data used by the submission form. +The submission form reads the available project types and labels from public +lookup endpoints. | Method | Path | Access | Success | Description | | --- | --- | --- | --- | --- | -| `GET` | `/api/categories/` | Public | `200 OK` | List categories alphabetically | - -```json -[ - { - "id": "11111111-1111-1111-1111-111111111111", - "name": "Bots" - } -] -``` +| `GET` | `/api/project-types/` | Public | `200 OK` | List project types alphabetically | +| `GET` | `/api/labels/` | Public | `200 OK` | List labels alphabetically | ## Authentication