Skip to content

Commit 87c0f1e

Browse files
committed
Improvement: Integrate the number of versions into projects endpoint
Now the UI doesn't need to make that many requests just to load the number of versions. I also adapted the get_all_projects versions to count only real folders, not symlinks and added a test for that. fixes: #364
1 parent 31ee0f5 commit 87c0f1e

12 files changed

+93
-63
lines changed

docat/docat/models.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,13 @@ class ClaimResponse(ApiResponse):
1717
token: str
1818

1919

20+
class ProjectWithVersionCount(BaseModel):
21+
name: str
22+
versions: int
23+
24+
2025
class Projects(BaseModel):
21-
projects: list[str]
26+
projects: list[ProjectWithVersionCount]
2227

2328

2429
class ProjectVersion(BaseModel):

docat/docat/utils.py

+22-16
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from bs4.element import Comment
1212
from tinydb import Query, TinyDB
1313

14-
from docat.models import ProjectDetail, Projects, ProjectVersion
14+
from docat.models import ProjectDetail, Projects, ProjectVersion, ProjectWithVersionCount
1515

1616
NGINX_CONFIG_PATH = Path("/etc/nginx/locations.d")
1717
UPLOAD_FOLDER = "doc"
@@ -114,20 +114,26 @@ def get_all_projects(upload_folder_path: Path) -> Projects:
114114
Returns all projects in the upload folder.
115115
"""
116116

117-
def has_not_hidden_versions(project):
117+
def count_not_hidden_versions(project) -> int:
118118
path = upload_folder_path / project
119-
return any(
120-
(path / version).is_dir() and not (path / version / ".hidden").exists() for version in (upload_folder_path / project).iterdir()
121-
)
122-
123-
return Projects(
124-
projects=list(
125-
filter(
126-
has_not_hidden_versions,
127-
[str(project.relative_to(upload_folder_path)) for project in upload_folder_path.iterdir() if project.is_dir()],
128-
)
129-
)
130-
)
119+
versions = [
120+
version
121+
for version in (upload_folder_path / project).iterdir()
122+
if (path / version).is_dir() and not (path / version).is_symlink() and not (path / version / ".hidden").exists()
123+
]
124+
return len(versions)
125+
126+
projects: list[ProjectWithVersionCount] = []
127+
128+
for project in upload_folder_path.iterdir():
129+
if project.is_dir():
130+
versions = count_not_hidden_versions(project)
131+
if versions < 1:
132+
continue
133+
134+
projects.append(ProjectWithVersionCount(name=str(project.relative_to(upload_folder_path)), versions=versions))
135+
136+
return Projects(projects=projects)
131137

132138

133139
def get_project_details(upload_folder_path: Path, project_name: str) -> ProjectDetail | None:
@@ -174,8 +180,8 @@ def index_all_projects(
174180
all_projects = get_all_projects(upload_folder_path).projects
175181

176182
for project in all_projects:
177-
update_version_index_for_project(upload_folder_path, index_db, project)
178-
update_file_index_for_project(upload_folder_path, index_db, project)
183+
update_version_index_for_project(upload_folder_path, index_db, project.name)
184+
update_file_index_for_project(upload_folder_path, index_db, project.name)
179185

180186

181187
def update_file_index_for_project(upload_folder_path: Path, index_db: TinyDB, project: str):

docat/tests/test_hide_show.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ def test_hide_only_version_not_listed_in_projects(client_with_claimed_project):
5050
projects_response = client_with_claimed_project.get("/api/projects")
5151
assert projects_response.status_code == 200
5252
assert projects_response.json() == {
53-
"projects": ["some-project"],
53+
"projects": [{"name": "some-project", "versions": 1}],
5454
}
5555

5656
# hide the only version

docat/tests/test_project.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ def test_project_api(temp_project_version):
1515
response = client.get("/api/projects")
1616

1717
assert response.ok
18-
assert response.json() == {"projects": ["project"]}
18+
assert response.json() == {"projects": [{"name": "project", "versions": 1}]}
1919

2020

2121
def test_project_api_without_any_projects():

docat/tests/test_utils.py

+23
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import io
12
from pathlib import Path
23
from unittest.mock import MagicMock, patch
34

@@ -86,3 +87,25 @@ def test_remove_symlink_version(temp_project_version):
8687
remove_docs(project, "latest", docat.DOCAT_UPLOAD_FOLDER)
8788

8889
assert not symlink_to_latest.exists()
90+
91+
92+
def test_get_all_projects_counts_versions_correctly(client_with_claimed_project):
93+
"""
94+
Tests whether get_all_projects returns the correct number of versions.
95+
(Don't count symlinks)
96+
"""
97+
98+
versions = ["1.0.0", "2.0.0", "3.0.0"]
99+
for version in versions:
100+
response = client_with_claimed_project.post(
101+
f"/api/some-project/{version}", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
102+
)
103+
assert response.status_code == 201
104+
105+
# tag "3.0.0" as latest
106+
response = client_with_claimed_project.put(f"/api/some-project/{versions[-1]}/tags/latest")
107+
assert response.status_code == 201
108+
109+
response = client_with_claimed_project.get("/api/projects")
110+
assert response.status_code == 200
111+
assert response.json() == {"projects": [{"name": "some-project", "versions": len(versions)}]}

web/src/components/Project.tsx

+15-30
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,22 @@ import ReactTooltip from 'react-tooltip'
33
import { Link } from 'react-router-dom'
44
import ProjectRepository from '../repositories/ProjectRepository'
55
import styles from './../style/components/Project.module.css'
6+
import { Project as ProjectType } from '../models/ProjectsResponse'
67

7-
import ProjectDetails from '../models/ProjectDetails'
88
import FavoriteStar from './FavoriteStar'
99

1010
interface Props {
11-
projectName: string
11+
project: ProjectType
1212
onFavoriteChanged: () => void
1313
}
1414

15-
export default function Project (props: Props): JSX.Element {
16-
const [versions, setVersions] = useState<ProjectDetails[]>([])
15+
export default function Project(props: Props): JSX.Element {
1716
const [logo, setLogo] = useState<Blob | null>(null)
1817

1918
// try to load image to prevent image flashing
2019
useEffect(() => {
2120
void (async () => {
22-
const logoURL = ProjectRepository.getProjectLogoURL(props.projectName)
21+
const logoURL = ProjectRepository.getProjectLogoURL(props.project.name)
2322
try {
2423
const response = await fetch(logoURL)
2524
if (response.status === 200) {
@@ -30,62 +29,48 @@ export default function Project (props: Props): JSX.Element {
3029
setLogo(null)
3130
}
3231
})()
33-
}, [props.projectName])
34-
35-
// reload versions on project name change
36-
useEffect(() => {
37-
void (async () => {
38-
try {
39-
const versionResponse = await ProjectRepository.getVersions(
40-
props.projectName
41-
)
42-
setVersions(versionResponse)
43-
} catch (e) {
44-
setVersions([])
45-
}
46-
})()
47-
}, [props.projectName])
32+
}, [props.project])
4833

4934
return (
5035
<div className={styles['project-card']}>
5136
<ReactTooltip />
5237
<div className={styles['project-card-header']}>
53-
<Link to={`/${props.projectName}/latest`}>
38+
<Link to={`/${props.project.name}/latest`}>
5439
{logo == null
5540
? (
5641
<div
5742
className={styles['project-card-title']}
58-
data-tip={props.projectName}
43+
data-tip={props.project.name}
5944
>
60-
{props.projectName}
45+
{props.project.name}
6146
</div>
6247
)
6348
: (
6449
<>
6550
<img
6651
className={styles['project-logo']}
6752
src={URL.createObjectURL(logo)}
68-
alt={`${props.projectName} project Logo`}
53+
alt={`${props.project.name} project Logo`}
6954
/>
7055

7156
<div
7257
className={styles['project-card-title-with-logo']}
73-
data-tip={props.projectName}
58+
data-tip={props.project.name}
7459
>
75-
{props.projectName}
60+
{props.project.name}
7661
</div>
7762
</>
7863
)}
7964
</Link>
8065
<FavoriteStar
81-
projectName={props.projectName}
66+
projectName={props.project.name}
8267
onFavoriteChanged={props.onFavoriteChanged}
8368
/>
8469
</div>
8570
<div className={styles.subhead}>
86-
{versions.length === 1
87-
? `${versions.length} version`
88-
: `${versions.length} versions`}
71+
{props.project.versions === 1
72+
? `${props.project.versions} version`
73+
: `${props.project.versions} versions`}
8974
</div>
9075
</div>
9176
)

web/src/components/ProjectList.tsx

+4-3
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ import Project from './Project'
22
import React from 'react'
33

44
import styles from './../style/components/ProjectList.module.css'
5+
import { Project as ProjectType } from '../models/ProjectsResponse'
56

67
interface Props {
7-
projects: string[]
8+
projects: ProjectType[]
89
onFavoriteChanged: () => void
910
}
1011

@@ -17,8 +18,8 @@ export default function ProjectList (props: Props): JSX.Element {
1718
<div className={styles['project-list']}>
1819
{props.projects.map((project) => (
1920
<Project
20-
projectName={project}
21-
key={project}
21+
project={project}
22+
key={project.name}
2223
onFavoriteChanged={() => props.onFavoriteChanged()}
2324
/>
2425
))}

web/src/data-providers/ProjectDataProvider.tsx

+3-2
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@
55
*/
66

77
import React, { createContext, useContext, useEffect, useState } from 'react'
8+
import ProjectsResponse, { Project } from '../models/ProjectsResponse'
89
import { useMessageBanner } from './MessageBannerProvider'
910

1011
interface ProjectState {
11-
projects: string[] | null
12+
projects: Project[] | null
1213
loadingFailed: boolean
1314
reload: () => void
1415
}
@@ -42,7 +43,7 @@ export function ProjectDataProvider({ children }: any): JSX.Element {
4243
)
4344
}
4445

45-
const data: { projects: string[] } = await response.json()
46+
const data: ProjectsResponse = await response.json()
4647
setProjects({
4748
projects: data.projects,
4849
loadingFailed: false,

web/src/models/ProjectsResponse.ts

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export interface Project {
2+
name: string
3+
versions: number
4+
}
5+
6+
export default interface ProjectsResponse {
7+
projects: Project[]
8+
}

web/src/pages/Claim.tsx

+3-3
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,15 @@ export default function Claim(): JSX.Element {
3737
}
3838

3939
/**
40-
* Returns loaded Projects for DataSelect
41-
* @returns projects as string[] or an empty array
40+
* Returns loaded project names for DataSelect
41+
* @returns project names as string[] or an empty array
4242
*/
4343
const getProjects = (): string[] => {
4444
if (loadingFailed || projects == null) {
4545
return []
4646
}
4747

48-
return projects
48+
return projects.map((project) => project.name)
4949
}
5050

5151
const onProjectSelect = (p: string): void => {

web/src/pages/Delete.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -83,15 +83,15 @@ export default function Delete(): JSX.Element {
8383
}
8484

8585
/**
86-
* Returns loaded Projects for DataSelect
86+
* Returns loaded project names for DataSelect
8787
* @returns string[] or an empty array
8888
*/
8989
const getProjects = (): string[] => {
9090
if (loadingFailed || projects == null) {
9191
return []
9292
}
9393

94-
return projects
94+
return projects.map((project) => project.name)
9595
}
9696

9797
/**

web/src/pages/Home.tsx

+5-4
Original file line numberDiff line numberDiff line change
@@ -14,22 +14,23 @@ import LoadingPage from './LoadingPage'
1414

1515
import styles from './../style/pages/Home.module.css'
1616
import { ErrorOutline } from '@mui/icons-material'
17+
import { Project } from '../models/ProjectsResponse'
1718

1819
export default function Home (): JSX.Element {
1920
const { projects, loadingFailed } = useProjects()
20-
const [nonFavoriteProjects, setNonFavoriteProjects] = useState<string[]>([])
21-
const [favoriteProjects, setFavoriteProjects] = useState<string[]>([])
21+
const [nonFavoriteProjects, setNonFavoriteProjects] = useState<Project[]>([])
22+
const [favoriteProjects, setFavoriteProjects] = useState<Project[]>([])
2223

2324
document.title = 'Home | docat'
2425

2526
const updateFavorites = (): void => {
2627
if (projects == null) return
2728

2829
setFavoriteProjects(
29-
projects.filter((project) => ProjectRepository.isFavorite(project))
30+
projects.filter((project) => ProjectRepository.isFavorite(project.name))
3031
)
3132
setNonFavoriteProjects(
32-
projects.filter((project) => !ProjectRepository.isFavorite(project))
33+
projects.filter((project) => !ProjectRepository.isFavorite(project.name))
3334
)
3435
}
3536

0 commit comments

Comments
 (0)