From a6aa3ab0f19c320defc9ff90f56e5b046cc3e163 Mon Sep 17 00:00:00 2001 From: Chris Dedman Rollet <61106361+chrisdedman@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:32:42 -0700 Subject: [PATCH 1/4] Implement project listing with search and filter capabilities, add random project retrieval and project count endpoints (#38) --- backend/app/routers/project_routers.py | 34 ++- backend/app/services/projects_service.py | 78 ++++++- backend/tests/test_project_validation.py | 70 ++++++ frontend/src/api/client.ts | 37 +++- frontend/src/components/ProjectBrowseRow.vue | 163 ++++++++++++++ frontend/src/components/SearchBar.vue | 89 ++++++++ frontend/src/components/SearchFilter.vue | 112 ++++++++++ frontend/src/components/SiteHeader.vue | 15 +- .../src/composables/useProjectDirectory.ts | 81 +++++++ frontend/src/pages/BrowsePage.vue | 206 ++++++++++++++++++ 10 files changed, 868 insertions(+), 17 deletions(-) create mode 100644 frontend/src/components/ProjectBrowseRow.vue create mode 100644 frontend/src/components/SearchBar.vue create mode 100644 frontend/src/components/SearchFilter.vue create mode 100644 frontend/src/composables/useProjectDirectory.ts create mode 100644 frontend/src/pages/BrowsePage.vue diff --git a/backend/app/routers/project_routers.py b/backend/app/routers/project_routers.py index bd066d2..b0cd1d2 100644 --- a/backend/app/routers/project_routers.py +++ b/backend/app/routers/project_routers.py @@ -1,6 +1,6 @@ from uuid import UUID -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlmodel import Session from app.database import get_session @@ -23,9 +23,21 @@ @router.get("/", response_model=list[ProjectRead]) def list_projects( + q: str | None = Query(default=None, max_length=200), + project_type: str | None = Query(default=None, max_length=100), + label: str | None = Query(default=None, max_length=100), + limit: int = Query(default=50, ge=1, le=100), + offset: int = Query(default=0, ge=0), session: Session = Depends(get_session), ) -> list[Project]: - return projects_service.list_projects(session) + return projects_service.list_projects( + session, + query=q, + project_type=project_type, + label=label, + limit=limit, + offset=offset, + ) @router.get("/mine/", response_model=list[ProjectRead]) @@ -36,6 +48,24 @@ def list_my_projects( return projects_service.list_projects_for_user(session, user_id=user.id) +@router.get("/count/", response_model=int) +def count_projects( + session: Session = Depends(get_session), +) -> int: + return projects_service.count_projects(session) + + +@router.get("/random/", response_model=list[ProjectRead]) +def list_random_projects( + limit: int = Query(default=6, ge=1, le=24), + session: Session = Depends(get_session), +) -> list[Project]: + return projects_service.list_random_projects( + session, + limit=limit, + ) + + @router.get("/{project_id}", response_model=ProjectRead) def get_project( project_id: UUID, diff --git a/backend/app/services/projects_service.py b/backend/app/services/projects_service.py index 2935cf4..9dfb6c7 100644 --- a/backend/app/services/projects_service.py +++ b/backend/app/services/projects_service.py @@ -1,16 +1,90 @@ from uuid import UUID -from sqlmodel import Session, select +from sqlalchemy import func, or_ +from sqlmodel import Session, col, select +from app.models.label import Label +from app.models.profile import Profile from app.models.project import Project +from app.models.project_label import ProjectLabel +from app.models.project_type import ProjectType from app.schemas.project import ProjectCreate, ProjectUpdate from . import labels_service, project_types_service from .errors import ProjectLinkRequiredError -def list_projects(session: Session) -> list[Project]: +def list_projects( + session: Session, + *, + query: str | None = None, + project_type: str | None = None, + label: str | None = None, + limit: int = 50, + offset: int = 0, +) -> list[Project]: statement = select(Project) + + if query: + pattern = f"%{query.strip()}%" + matching_project_types = select(ProjectType.id).where( + col(ProjectType.name).ilike(pattern) + ) + matching_project_labels = ( + select(ProjectLabel.project_id) + .join(Label, col(ProjectLabel.label_id) == col(Label.id)) + .where(col(Label.name).ilike(pattern)) + ) + matching_owners = select(Profile.user_id).where( + or_( + col(Profile.display_name).ilike(pattern), + col(Profile.matrix_id).ilike(pattern), + ) + ) + statement = statement.where( + or_( + col(Project.name).ilike(pattern), + col(Project.short_description).ilike(pattern), + col(Project.description).ilike(pattern), + col(Project.project_type_id).in_(matching_project_types), + col(Project.id).in_(matching_project_labels), + col(Project.user_id).in_(matching_owners), + ) + ) + + if project_type: + matching_project_type = select(ProjectType.id).where( + ProjectType.name == project_type + ) + statement = statement.where( + col(Project.project_type_id).in_(matching_project_type) + ) + + if label: + matching_label = ( + select(ProjectLabel.project_id) + .join(Label, col(ProjectLabel.label_id) == col(Label.id)) + .where(col(Label.name) == label) + ) + statement = statement.where(col(Project.id).in_(matching_label)) + + statement = ( + statement.order_by(col(Project.created_at).desc()).offset(offset).limit(limit) + ) + return list(session.exec(statement).all()) + + +def count_projects(session: Session) -> int: + statement = select(func.count()).select_from(Project) + return int(session.exec(statement).one()) + + +def list_random_projects( + session: Session, + *, + limit: int = 6, +) -> list[Project]: + statement = select(Project).order_by(func.random()).limit(limit) return list(session.exec(statement).all()) diff --git a/backend/tests/test_project_validation.py b/backend/tests/test_project_validation.py index e7d8d3e..154abcf 100644 --- a/backend/tests/test_project_validation.py +++ b/backend/tests/test_project_validation.py @@ -108,6 +108,76 @@ def test_create__expect_project_associated_with_authenticated_user( assert [label["id"] for label in body["labels"]] == [str(project_client.label_id)] +def test_random_projects__expect_requested_limit_and_total_count( + project_client: ProjectClient, +) -> None: + for index in range(3): + response = project_client.client.post( + "/api/projects/", + json={ + "name": f"Test Project {index}", + "description": "A useful project.", + "short_description": "Useful project", + "repository_url": f"https://example.com/project-{index}", + "project_type_id": str(project_client.project_type_id), + "label_ids": [], + }, + ) + assert response.status_code == 201 + + random_response = project_client.client.get("/api/projects/random/?limit=2") + count_response = project_client.client.get("/api/projects/count/") + + assert random_response.status_code == 200 + assert len(random_response.json()) == 2 + assert count_response.status_code == 200 + assert count_response.json() == 3 + + +@pytest.mark.parametrize("limit", [0, 25]) +def test_random_projects_with_invalid_limit__expect_validation_error( + project_client: ProjectClient, + limit: int, +) -> None: + response = project_client.client.get(f"/api/projects/random/?limit={limit}") + + assert response.status_code == 422 + + +def test_list_projects__expect_server_side_search_and_filters( + project_client: ProjectClient, +) -> None: + for name, labels in [ + ("Alpha Bridge", [str(project_client.label_id)]), + ("Beta Bot", []), + ]: + response = project_client.client.post( + "/api/projects/", + json={ + "name": name, + "description": f"Description for {name}", + "short_description": name, + "repository_url": f"https://example.com/{name.lower().replace(' ', '-')}", + "project_type_id": str(project_client.project_type_id), + "label_ids": labels, + }, + ) + assert response.status_code == 201 + + response = project_client.client.get( + "/api/projects/", + params={ + "q": "alpha", + "project_type": "Bot", + "label": "Utility", + "limit": 24, + }, + ) + + assert response.status_code == 200 + assert [project["name"] for project in response.json()] == ["Alpha Bridge"] + + @pytest.mark.parametrize( ("project_type_id", "label_ids", "message"), [ diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index aa7bcd6..9d68b6b 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -76,8 +76,27 @@ async function request(path: string, init?: RequestInit): Promise { return response.json() as Promise } -export function listProjects() { - return request('/projects/') +type ProjectQuery = { + query?: string + projectType?: string + label?: string + limit?: number + offset?: number +} + +export function listProjects(filters: ProjectQuery = {}) { + const params = new URLSearchParams() + + if (filters.query) params.set('q', filters.query) + if (filters.projectType) params.set('project_type', filters.projectType) + if (filters.label) params.set('label', filters.label) + if (filters.limit !== undefined) params.set('limit', String(filters.limit)) + if (filters.offset !== undefined) params.set('offset', String(filters.offset)) + + const queryString = params.toString() + const path = queryString ? `/projects/?${queryString}` : '/projects/' + + return request(path) } export function getProject(id: string) { @@ -111,6 +130,20 @@ export function deleteProject(id: string) { }) } +export function listRandomProjects(limit = 6) { + const params = new URLSearchParams({ + limit: String(limit), + }) + + return request( + `/projects/random/?${params.toString()}`, + ) +} + +export function countProjects() { + return request('/projects/count/') +} + export function listProjectTypes() { return request('/project-types/') } diff --git a/frontend/src/components/ProjectBrowseRow.vue b/frontend/src/components/ProjectBrowseRow.vue new file mode 100644 index 0000000..fe2b2df --- /dev/null +++ b/frontend/src/components/ProjectBrowseRow.vue @@ -0,0 +1,163 @@ + + + diff --git a/frontend/src/components/SearchBar.vue b/frontend/src/components/SearchBar.vue new file mode 100644 index 0000000..981444e --- /dev/null +++ b/frontend/src/components/SearchBar.vue @@ -0,0 +1,89 @@ + + + diff --git a/frontend/src/components/SearchFilter.vue b/frontend/src/components/SearchFilter.vue new file mode 100644 index 0000000..eed7625 --- /dev/null +++ b/frontend/src/components/SearchFilter.vue @@ -0,0 +1,112 @@ + + + diff --git a/frontend/src/components/SiteHeader.vue b/frontend/src/components/SiteHeader.vue index 4d25117..0a9f0f2 100644 --- a/frontend/src/components/SiteHeader.vue +++ b/frontend/src/components/SiteHeader.vue @@ -17,7 +17,6 @@ import { currentUser, logout } from '../auth' import logoUrl from '../assets/matrix-directory-mark.svg' - const docsUrl = import.meta.env.VITE_DOCS_URL ?? (import.meta.env.DEV ? 'http://127.0.0.1:8001' : '/docs/') @@ -73,10 +72,6 @@ const profileLabel = computed(() => ) function isActive(path: string) { - if (path === '/') { - return route.path === '/' - } - return route.path.startsWith(path) } @@ -126,10 +121,10 @@ watch(