Skip to content

Commit 9650282

Browse files
committed
Improvement: Integrate Versions and into Projects API
Now we don't have to load any data for each project, greatly reducing the number of requests and improving responsiveness. We now also return hidden versions, but with the property "hidden" to distinguish them in the UI, which fixes a bug where you couldn't look at hidden versions because they weren't found. fixes: #367
1 parent 4d5c568 commit 9650282

15 files changed

+231
-162
lines changed

docat/docat/models.py

+9-8
Original file line numberDiff line numberDiff line change
@@ -17,19 +17,20 @@ class ClaimResponse(ApiResponse):
1717
token: str
1818

1919

20-
class ProjectWithVersionCount(BaseModel):
20+
class ProjectVersion(BaseModel):
2121
name: str
22-
logo: bool
23-
versions: int
22+
tags: list[str]
23+
hidden: bool
2424

2525

26-
class Projects(BaseModel):
27-
projects: list[ProjectWithVersionCount]
26+
class Project(BaseModel):
27+
name: str
28+
logo: bool
29+
versions: list[ProjectVersion]
2830

2931

30-
class ProjectVersion(BaseModel):
31-
name: str
32-
tags: list[str]
32+
class Projects(BaseModel):
33+
projects: list[Project]
3334

3435

3536
class ProjectDetail(BaseModel):

docat/docat/utils.py

+19-22
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, ProjectWithVersionCount
14+
from docat.models import Project, ProjectDetail, Projects, ProjectVersion
1515

1616
NGINX_CONFIG_PATH = Path("/etc/nginx/locations.d")
1717
UPLOAD_FOLDER = "doc"
@@ -113,32 +113,28 @@ def get_all_projects(upload_folder_path: Path) -> Projects:
113113
"""
114114
Returns all projects in the upload folder.
115115
"""
116+
projects: list[Project] = []
116117

117-
def count_not_hidden_versions(project) -> int:
118-
path = upload_folder_path / project
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)
118+
for project in upload_folder_path.iterdir():
119+
if not project.is_dir():
120+
continue
125121

126-
projects: list[ProjectWithVersionCount] = []
122+
details = get_project_details(upload_folder_path, project.name)
127123

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
124+
if details is None:
125+
continue
126+
127+
if len(details.versions) < 1:
128+
continue
133129

134-
project_name = str(project.relative_to(upload_folder_path))
135-
project_has_logo = (upload_folder_path / project / "logo").exists()
136-
projects.append(ProjectWithVersionCount(name=project_name, logo=project_has_logo, versions=versions))
130+
project_name = str(project.relative_to(upload_folder_path))
131+
project_has_logo = (upload_folder_path / project / "logo").exists()
132+
projects.append(Project(name=project_name, logo=project_has_logo, versions=details.versions))
137133

138134
return Projects(projects=projects)
139135

140136

141-
def get_project_details(upload_folder_path: Path, project_name: str) -> ProjectDetail | None:
137+
def get_project_details(upload_folder_path: Path, project_name: str, include_hidden: bool = True) -> ProjectDetail | None:
142138
"""
143139
Returns all versions and tags for a project.
144140
"""
@@ -156,9 +152,10 @@ def get_project_details(upload_folder_path: Path, project_name: str) -> ProjectD
156152
ProjectVersion(
157153
name=str(x.relative_to(docs_folder)),
158154
tags=[str(t.relative_to(docs_folder)) for t in tags if t.resolve() == x],
155+
hidden=(docs_folder / x.name / ".hidden").exists(),
159156
)
160157
for x in docs_folder.iterdir()
161-
if x.is_dir() and not x.is_symlink() and not (docs_folder / x.name / ".hidden").exists()
158+
if x.is_dir() and not x.is_symlink() and (include_hidden or not (docs_folder / x.name / ".hidden").exists())
162159
],
163160
key=lambda k: k.name,
164161
reverse=True,
@@ -193,7 +190,7 @@ def update_file_index_for_project(upload_folder_path: Path, index_db: TinyDB, pr
193190
files_table = index_db.table("files")
194191
files_table.remove(Query().project == project)
195192

196-
project_details = get_project_details(upload_folder_path, project)
193+
project_details = get_project_details(upload_folder_path, project, include_hidden=False)
197194

198195
if not project_details:
199196
return
@@ -233,7 +230,7 @@ def update_version_index_for_project(upload_folder_path: Path, index_db: TinyDB,
233230
Project = Query()
234231
project_table.remove(Project.name == project)
235232

236-
details = get_project_details(upload_folder_path, project)
233+
details = get_project_details(upload_folder_path, project, include_hidden=False)
237234

238235
if not details:
239236
return

docat/tests/test_hide_show.py

+9-44
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
def test_hide(client_with_claimed_project):
88
"""
9-
Tests that the version is no longer returned when getting the details after hiding
9+
Tests that the version is marked as hidden when getting the details after hiding
1010
"""
1111
# create a version
1212
create_response = client_with_claimed_project.post(
@@ -19,7 +19,7 @@ def test_hide(client_with_claimed_project):
1919
assert project_details_response.status_code == 200
2020
assert project_details_response.json() == {
2121
"name": "some-project",
22-
"versions": [{"name": "1.0.0", "tags": []}],
22+
"versions": [{"name": "1.0.0", "tags": [], "hidden": False}],
2323
}
2424

2525
# hide the version
@@ -32,45 +32,10 @@ def test_hide(client_with_claimed_project):
3232
assert project_details_response.status_code == 200
3333
assert project_details_response.json() == {
3434
"name": "some-project",
35-
"versions": [],
35+
"versions": [{"name": "1.0.0", "tags": [], "hidden": True}],
3636
}
3737

3838

39-
def test_hide_only_version_not_listed_in_projects(client_with_claimed_project):
40-
"""
41-
Test that the project is not listed in the projects endpoint when the only version is hidden
42-
"""
43-
# create a version
44-
create_response = client_with_claimed_project.post(
45-
"/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
46-
)
47-
assert create_response.status_code == 201
48-
49-
# check detected before hiding
50-
projects_response = client_with_claimed_project.get("/api/projects")
51-
assert projects_response.status_code == 200
52-
assert projects_response.json() == {
53-
"projects": [{"name": "some-project", "logo": False, "versions": 1}],
54-
}
55-
56-
# hide the only version
57-
hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "1234"})
58-
assert hide_response.status_code == 200
59-
assert hide_response.json() == {"message": "Version 1.0.0 is now hidden"}
60-
61-
# check hidden
62-
projects_response = client_with_claimed_project.get("/api/projects")
63-
assert projects_response.status_code == 200
64-
assert projects_response.json() == {
65-
"projects": [],
66-
}
67-
68-
# check versions hidden
69-
project_details_response = client_with_claimed_project.get("/api/projects/some-project")
70-
assert project_details_response.status_code == 200
71-
assert project_details_response.json() == {"name": "some-project", "versions": []}
72-
73-
7439
def test_hide_creates_hidden_file(client_with_claimed_project):
7540
"""
7641
Tests that the hidden file is created when hiding a version
@@ -189,7 +154,7 @@ def test_hide_fails_invalid_token(client_with_claimed_project):
189154

190155
def test_show(client_with_claimed_project):
191156
"""
192-
Tests that the version is returned again after requesting show.
157+
Tests that the version is no longer marked as hidden after requesting show.
193158
"""
194159
# create a version
195160
create_response = client_with_claimed_project.post(
@@ -207,7 +172,7 @@ def test_show(client_with_claimed_project):
207172
assert project_details_response.status_code == 200
208173
assert project_details_response.json() == {
209174
"name": "some-project",
210-
"versions": [],
175+
"versions": [{"name": "1.0.0", "tags": [], "hidden": True}],
211176
}
212177

213178
# show the version
@@ -220,7 +185,7 @@ def test_show(client_with_claimed_project):
220185
assert project_details_response.status_code == 200
221186
assert project_details_response.json() == {
222187
"name": "some-project",
223-
"versions": [{"name": "1.0.0", "tags": []}],
188+
"versions": [{"name": "1.0.0", "tags": [], "hidden": False}],
224189
}
225190

226191

@@ -356,7 +321,7 @@ def test_show_fails_invalid_token(client_with_claimed_project):
356321

357322
def test_hide_and_show_with_tag(client_with_claimed_project):
358323
"""
359-
Tests that the version is returned again after requesting show on a tag.
324+
Tests that the version is no longer marked as hidden after requesting show on a tag.
360325
"""
361326
# create a version
362327
create_response = client_with_claimed_project.post(
@@ -379,7 +344,7 @@ def test_hide_and_show_with_tag(client_with_claimed_project):
379344
assert project_details_response.status_code == 200
380345
assert project_details_response.json() == {
381346
"name": "some-project",
382-
"versions": [],
347+
"versions": [{"name": "1.0.0", "tags": ["latest"], "hidden": True}],
383348
}
384349

385350
# show the version
@@ -392,5 +357,5 @@ def test_hide_and_show_with_tag(client_with_claimed_project):
392357
assert project_details_response.status_code == 200
393358
assert project_details_response.json() == {
394359
"name": "some-project",
395-
"versions": [{"name": "1.0.0", "tags": ["latest"]}],
360+
"versions": [{"name": "1.0.0", "tags": ["latest"], "hidden": False}],
396361
}

docat/tests/test_project.py

+65-4
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
import io
12
from unittest.mock import patch
23

34
from fastapi.testclient import TestClient
45

5-
from docat.app import app
6+
import docat.app as docat
7+
from docat.models import ProjectDetail, ProjectVersion
8+
from docat.utils import get_project_details
69

7-
client = TestClient(app)
10+
client = TestClient(docat.app)
811

912

1013
def test_project_api(temp_project_version):
@@ -15,7 +18,17 @@ def test_project_api(temp_project_version):
1518
response = client.get("/api/projects")
1619

1720
assert response.ok
18-
assert response.json() == {"projects": [{"name": "project", "logo": False, "versions": 1}]}
21+
assert response.json() == {
22+
"projects": [
23+
{
24+
"name": "project",
25+
"logo": False,
26+
"versions": [
27+
{"name": "1.0", "tags": ["latest"], "hidden": False},
28+
],
29+
}
30+
]
31+
}
1932

2033

2134
def test_project_api_without_any_projects():
@@ -35,11 +48,59 @@ def test_project_details_api(temp_project_version):
3548
response = client.get(f"/api/projects/{project}")
3649

3750
assert response.ok
38-
assert response.json() == {"name": "project", "versions": [{"name": "1.0", "tags": ["latest"]}]}
51+
assert response.json() == {"name": "project", "versions": [{"name": "1.0", "tags": ["latest"], "hidden": False}]}
3952

4053

4154
def test_project_details_api_with_a_project_that_does_not_exist():
4255
response = client.get("/api/projects/i-do-not-exist")
4356

4457
assert not response.ok
4558
assert response.json() == {"message": "Project i-do-not-exist does not exist"}
59+
60+
61+
def test_get_project_details_with_hidden_versions(client_with_claimed_project):
62+
"""
63+
Make sure that get_project_details works when include_hidden is set to True.
64+
"""
65+
# create a version
66+
create_response = client_with_claimed_project.post(
67+
"/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
68+
)
69+
assert create_response.status_code == 201
70+
71+
# check detected before hiding
72+
details = get_project_details(docat.DOCAT_UPLOAD_FOLDER, "some-project", include_hidden=True)
73+
assert details == ProjectDetail(name="some-project", versions=[ProjectVersion(name="1.0.0", tags=[], hidden=False)])
74+
75+
# hide the version
76+
hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "1234"})
77+
assert hide_response.status_code == 200
78+
assert hide_response.json() == {"message": "Version 1.0.0 is now hidden"}
79+
80+
# check hidden
81+
details = get_project_details(docat.DOCAT_UPLOAD_FOLDER, "some-project", include_hidden=True)
82+
assert details == ProjectDetail(name="some-project", versions=[ProjectVersion(name="1.0.0", tags=[], hidden=True)])
83+
84+
85+
def test_project_details_without_hidden_versions(client_with_claimed_project):
86+
"""
87+
Make sure that project_details works when include_hidden is set to False.
88+
"""
89+
# create a version
90+
create_response = client_with_claimed_project.post(
91+
"/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
92+
)
93+
assert create_response.status_code == 201
94+
95+
# check detected before hiding
96+
details = get_project_details(docat.DOCAT_UPLOAD_FOLDER, "some-project", include_hidden=False)
97+
assert details == ProjectDetail(name="some-project", versions=[ProjectVersion(name="1.0.0", tags=[], hidden=False)])
98+
99+
# hide the version
100+
hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "1234"})
101+
assert hide_response.status_code == 200
102+
assert hide_response.json() == {"message": "Version 1.0.0 is now hidden"}
103+
104+
# check hidden
105+
details = get_project_details(docat.DOCAT_UPLOAD_FOLDER, "some-project", include_hidden=False)
106+
assert details == ProjectDetail(name="some-project", versions=[])

docat/tests/test_upload_icon.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ def test_get_project_recongizes_icon(client_with_claimed_project):
160160
{
161161
"name": "some-project",
162162
"logo": False,
163-
"versions": 1,
163+
"versions": [{"name": "1.0.0", "tags": [], "hidden": False}],
164164
}
165165
]
166166
}
@@ -178,7 +178,7 @@ def test_get_project_recongizes_icon(client_with_claimed_project):
178178
{
179179
"name": "some-project",
180180
"logo": True,
181-
"versions": 1,
181+
"versions": [{"name": "1.0.0", "tags": [], "hidden": False}],
182182
}
183183
]
184184
}

docat/tests/test_utils.py

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

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

8988
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", "logo": False, "versions": len(versions)}]}

web/src/components/Project.tsx

+3-3
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,9 @@ export default function Project(props: Props): JSX.Element {
5050
/>
5151
</div>
5252
<div className={styles.subhead}>
53-
{props.project.versions === 1
54-
? `${props.project.versions} version`
55-
: `${props.project.versions} versions`}
53+
{props.project.versions.length === 1
54+
? `${props.project.versions.length} version`
55+
: `${props.project.versions.length} versions`}
5656
</div>
5757
</div>
5858
)

0 commit comments

Comments
 (0)