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
7 changes: 3 additions & 4 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from app.routers.project_routers import router as projects_router
from app.routers.auth_router import router as auth_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

settings = get_settings()
Expand Down Expand Up @@ -39,10 +40,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(profile_router, prefix="/api")
app.include_router(categories_router, prefix="/api")


@app.get("/api/health", tags=["system"])
Expand Down
65 changes: 50 additions & 15 deletions backend/app/models/project.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
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 uuid import UUID, uuid4

if TYPE_CHECKING:
from .category import Category
Expand All @@ -20,35 +20,70 @@ class ProjectCategory(Model, table=True):

__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)
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 that can be listed in the directory."""
"""A project listed in the Matrix directory."""

__tablename__ = "projects"

id: UUID = Field(default_factory=uuid4, primary_key=True)

name: str = Field(max_length=100, index=True)
description: str
short_description: str = Field(max_length=240)
name: str = Field(
max_length=100,
index=True,
)

repository_url: str | None = None
website_url: str | None = None
matrix_server_url: str | None = None
short_description: str = Field(
max_length=160,
)

user_id: UUID = Field(foreign_key="users.id", index=True)
description: str = Field(
max_length=10_000,
)

supports_e2ee: bool = False
repository_url: str | None = Field(
default=None,
max_length=255,
)
website_url: str | None = Field(
default=None,
max_length=255,
)
matrix_server_url: str | None = Field(
default=None,
max_length=255,
)

user_id: UUID = Field(
foreign_key="users.id",
index=True,
)

supports_e2ee: bool = Field(default=False)

owner: "User" = Relationship(back_populates="projects")
owner: "User" = Relationship(
back_populates="projects",
)

categories: list["Category"] = Relationship(
back_populates="projects",
link_model=ProjectCategory,
)

created_at: datetime = Field(default_factory=utc_now)
updated_at: datetime = Field(default_factory=utc_now)
created_at: datetime = Field(
default_factory=utc_now,
)
updated_at: datetime = Field(
default_factory=utc_now,
)
15 changes: 15 additions & 0 deletions backend/app/routers/category_router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
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())
5 changes: 4 additions & 1 deletion backend/app/routers/project_routers.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,10 @@ def update_project(
data,
user_id=user.id,
)
except projects_service.CategoryNotFoundError as exc:
except (
projects_service.CategoryNotFoundError,
projects_service.ProjectLinkRequiredError,
) as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
Expand Down
164 changes: 148 additions & 16 deletions backend/app/schemas/project.py
Original file line number Diff line number Diff line change
@@ -1,51 +1,178 @@
from datetime import datetime
from typing import Any
from urllib.parse import urlsplit
from uuid import UUID

from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from pydantic import (
BaseModel,
ConfigDict,
Field,
field_validator,
model_validator,
)

from .category import CategoryRead


def normalize_optional_http_url(value: Any) -> Any:
"""Normalize blank URLs and reject non-HTTP(S) or relative URLs."""
if not isinstance(value, str):
return value

value = value.strip()
if not value:
return None

parsed = urlsplit(value)

if parsed.scheme not in {"http", "https"} or parsed.hostname is None:
raise ValueError("Must be an absolute HTTP or HTTPS URL")

return value


class ProjectCreate(BaseModel):
"""Schema for creating a new project."""

name: str = Field(min_length=2, max_length=100)
description: str
short_description: str = Field(max_length=240)
name: str = Field(
min_length=2,
max_length=100,
)
short_description: str = Field(
min_length=1,
max_length=160,
)
description: str = Field(
min_length=1,
max_length=10_000,
)

repository_url: str | None = None
website_url: str | None = None
matrix_server_url: str | None = None
repository_url: str | None = Field(default=None, max_length=255)
website_url: str | None = Field(default=None, max_length=255)
matrix_server_url: str | None = Field(default=None, max_length=255)

supports_e2ee: bool = False

category_ids: list[UUID] = Field(min_length=1)

@field_validator(
"name",
"short_description",
"description",
mode="before",
)
@classmethod
def strip_required_text(cls, value: Any) -> Any:
if isinstance(value, str):
return value.strip()

return value

@field_validator(
"repository_url",
"website_url",
"matrix_server_url",
mode="before",
)
@classmethod
def validate_optional_urls(cls, value: Any) -> Any:
return normalize_optional_http_url(value)

@field_validator("category_ids")
@classmethod
def reject_duplicate_categories(
cls,
value: list[UUID],
) -> list[UUID]:
if len(value) != len(set(value)):
raise ValueError("Categories must be unique")

return value

@model_validator(mode="after")
def require_project_link(self) -> "ProjectCreate":
"""Require somewhere users can learn more about the project."""
if self.repository_url is None and self.website_url is None:
raise ValueError("Provide at least a repository URL or website URL")

return self


class ProjectUpdate(BaseModel):
name: str | None = Field(default=None, min_length=2, max_length=100)
description: str | None = None
short_description: str | None = Field(default=None, max_length=240)
"""Schema for partially updating a project."""

name: str | None = Field(
default=None,
min_length=2,
max_length=100,
)
short_description: str | None = Field(
default=None,
min_length=1,
max_length=160,
)
description: str | None = Field(
default=None,
min_length=1,
max_length=10_000,
)

repository_url: str | None = None
website_url: str | None = None
matrix_server_url: str | None = None
repository_url: str | None = Field(default=None, max_length=255)
website_url: str | None = Field(default=None, max_length=255)
matrix_server_url: str | None = Field(default=None, max_length=255)

supports_e2ee: bool | None = None

category_ids: list[UUID] | None = Field(default=None, min_length=1)
category_ids: list[UUID] | None = Field(
default=None,
min_length=1,
)

@field_validator(
"name",
"short_description",
"description",
mode="before",
)
@classmethod
def strip_updated_text(cls, value: Any) -> Any:
if isinstance(value, str):
return value.strip()

return value

@field_validator(
"repository_url",
"website_url",
"matrix_server_url",
mode="before",
)
@classmethod
def validate_optional_urls(cls, value: Any) -> Any:
return normalize_optional_http_url(value)

@field_validator("category_ids")
@classmethod
def reject_duplicate_categories(
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")

return value

@field_validator(
"name",
"short_description",
"description",
"supports_e2ee",
"category_ids",
mode="before",
)
@classmethod
def reject_explicit_null(cls, value: Any) -> Any:
"""Reject null for required fields while allowing them to be omitted."""
"""Reject null for required fields while allowing omission."""
if value is None:
raise ValueError("Field cannot be null")

Expand Down Expand Up @@ -79,13 +206,15 @@ def from_user(cls, value: Any) -> Any:


class ProjectRead(BaseModel):
"""Public representation of a directory project."""

model_config = ConfigDict(from_attributes=True)

id: UUID

name: str
description: str
short_description: str
description: str

repository_url: str | None
website_url: str | None
Expand All @@ -97,3 +226,6 @@ class ProjectRead(BaseModel):
owner: ProjectOwnerRead

categories: list[CategoryRead] = Field(default_factory=list)

created_at: datetime
updated_at: datetime
4 changes: 4 additions & 0 deletions backend/app/services/errors.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
class CategoryNotFoundError(Exception):
pass


class ProjectLinkRequiredError(Exception):
pass
9 changes: 8 additions & 1 deletion backend/app/services/projects_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from app.models.project import Project
from app.schemas.project import ProjectCreate, ProjectUpdate

from .errors import CategoryNotFoundError
from .errors import CategoryNotFoundError, ProjectLinkRequiredError


def list_projects(session: Session) -> list[Project]:
Expand Down Expand Up @@ -74,6 +74,13 @@ def update_project(
exclude={"category_ids"},
)

repository_url = update_data.get("repository_url", project.repository_url)
website_url = update_data.get("website_url", project.website_url)
if repository_url is None and website_url is None:
raise ProjectLinkRequiredError(
"Provide at least a repository URL or website URL"
)

for field, value in update_data.items():
setattr(project, field, value)

Expand Down
Loading