diff --git a/docat/docat/app.py b/docat/docat/app.py
index 34b430c9b..6d0dac8f3 100644
--- a/docat/docat/app.py
+++ b/docat/docat/app.py
@@ -75,7 +75,21 @@ class ProjectDetailResponse(BaseModel):
def get_projects():
if not DOCAT_UPLOAD_FOLDER.exists():
return ProjectsResponse(projects=[])
- return ProjectsResponse(projects=[str(x.relative_to(DOCAT_UPLOAD_FOLDER)) for x in DOCAT_UPLOAD_FOLDER.iterdir() if x.is_dir()])
+
+ def has_not_hidden_versions(project):
+ path = DOCAT_UPLOAD_FOLDER / project
+ return any(
+ (path / version).is_dir() and not (path / version / ".hidden").exists() for version in (DOCAT_UPLOAD_FOLDER / project).iterdir()
+ )
+
+ return ProjectsResponse(
+ projects=list(
+ filter(
+ has_not_hidden_versions,
+ [str(project.relative_to(DOCAT_UPLOAD_FOLDER)) for project in DOCAT_UPLOAD_FOLDER.iterdir() if project.is_dir()],
+ )
+ )
+ )
@app.get(
@@ -106,7 +120,7 @@ def get_project(project):
tags=[str(t.relative_to(docs_folder)) for t in tags if t.resolve() == x],
)
for x in docs_folder.iterdir()
- if x.is_dir() and not x.is_symlink()
+ if x.is_dir() and not x.is_symlink() and not (docs_folder / x.name / ".hidden").exists()
],
key=lambda k: k.name,
reverse=True,
@@ -155,6 +169,77 @@ def upload_icon(
return ApiResponse(message="Icon successfully uploaded")
+@app.post("/api/{project}/{version}/hide", response_model=ApiResponse, status_code=status.HTTP_200_OK)
+@app.post("/api/{project}/{version}/hide/", response_model=ApiResponse, status_code=status.HTTP_200_OK)
+def hide_version(
+ project: str,
+ version: str,
+ response: Response,
+ docat_api_key: Optional[str] = Header(None),
+ db: TinyDB = Depends(get_db),
+):
+ project_base_path = DOCAT_UPLOAD_FOLDER / project
+ version_path = project_base_path / version
+ hidden_file = version_path / ".hidden"
+
+ if not project_base_path.exists():
+ response.status_code = status.HTTP_404_NOT_FOUND
+ return ApiResponse(message=f"Project {project} not found")
+
+ if not version_path.exists():
+ response.status_code = status.HTTP_404_NOT_FOUND
+ return ApiResponse(message=f"Version {version} not found")
+
+ if hidden_file.exists():
+ response.status_code = status.HTTP_400_BAD_REQUEST
+ return ApiResponse(message=f"Version {version} is already hidden")
+
+ token_status = check_token_for_project(db, docat_api_key, project)
+ if not token_status.valid:
+ response.status_code = status.HTTP_401_UNAUTHORIZED
+ return ApiResponse(message=token_status.reason)
+
+ with open(hidden_file, "w") as f:
+ f.close()
+
+ return ApiResponse(message=f"Version {version} is now hidden")
+
+
+@app.post("/api/{project}/{version}/show", response_model=ApiResponse, status_code=status.HTTP_200_OK)
+@app.post("/api/{project}/{version}/show/", response_model=ApiResponse, status_code=status.HTTP_200_OK)
+def show_version(
+ project: str,
+ version: str,
+ response: Response,
+ docat_api_key: Optional[str] = Header(None),
+ db: TinyDB = Depends(get_db),
+):
+ project_base_path = DOCAT_UPLOAD_FOLDER / project
+ version_path = project_base_path / version
+ hidden_file = version_path / ".hidden"
+
+ if not project_base_path.exists():
+ response.status_code = status.HTTP_404_NOT_FOUND
+ return ApiResponse(message=f"Project {project} not found")
+
+ if not version_path.exists():
+ response.status_code = status.HTTP_404_NOT_FOUND
+ return ApiResponse(message=f"Version {version} not found")
+
+ if not hidden_file.exists():
+ response.status_code = status.HTTP_400_BAD_REQUEST
+ return ApiResponse(message=f"Version {version} is not hidden")
+
+ token_status = check_token_for_project(db, docat_api_key, project)
+ if not token_status.valid:
+ response.status_code = status.HTTP_401_UNAUTHORIZED
+ return ApiResponse(message=token_status.reason)
+
+ os.remove(hidden_file)
+
+ return ApiResponse(message=f"Version {version} is now shown")
+
+
@app.post("/api/{project}/{version}", response_model=ApiResponse, status_code=status.HTTP_201_CREATED)
@app.post("/api/{project}/{version}/", response_model=ApiResponse, status_code=status.HTTP_201_CREATED)
def upload(
diff --git a/docat/tests/conftest.py b/docat/tests/conftest.py
index 404c99835..8104dffd2 100644
--- a/docat/tests/conftest.py
+++ b/docat/tests/conftest.py
@@ -20,6 +20,11 @@ def client():
temp_dir.cleanup()
+@pytest.fixture
+def upload_folder_path():
+ return docat.DOCAT_UPLOAD_FOLDER
+
+
@pytest.fixture
def client_with_claimed_project(client):
table = docat.db.table("claims")
diff --git a/docat/tests/test_hide_show.py b/docat/tests/test_hide_show.py
new file mode 100644
index 000000000..c8fcb04ea
--- /dev/null
+++ b/docat/tests/test_hide_show.py
@@ -0,0 +1,395 @@
+import io
+from pathlib import Path
+from unittest.mock import patch
+
+
+def test_hide(client_with_claimed_project):
+ """
+ Tests that the version is no longer returned when getting the details after hiding
+ """
+ # create a version
+ create_response = client_with_claimed_project.post(
+ "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"
Hello World
"), "plain/text")}
+ )
+ assert create_response.status_code == 201
+
+ # check detected before hiding
+ project_details_response = client_with_claimed_project.get("/api/projects/some-project")
+ assert project_details_response.status_code == 200
+ assert project_details_response.json() == {
+ "name": "some-project",
+ "versions": [{"name": "1.0.0", "tags": []}],
+ }
+
+ # hide the version
+ hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "1234"})
+ assert hide_response.status_code == 200
+ assert hide_response.json() == {"message": "Version 1.0.0 is now hidden"}
+
+ # check hidden
+ project_details_response = client_with_claimed_project.get("/api/projects/some-project")
+ assert project_details_response.status_code == 200
+ assert project_details_response.json() == {
+ "name": "some-project",
+ "versions": [],
+ }
+
+
+def test_hide_only_version_not_listed_in_projects(client_with_claimed_project):
+ """
+ Test that the project is not listed in the projects endpoint when the only version is hidden
+ """
+ # create a version
+ create_response = client_with_claimed_project.post(
+ "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"Hello World
"), "plain/text")}
+ )
+ assert create_response.status_code == 201
+
+ # check detected before hiding
+ projects_response = client_with_claimed_project.get("/api/projects")
+ assert projects_response.status_code == 200
+ assert projects_response.json() == {
+ "projects": ["some-project"],
+ }
+
+ # hide the only version
+ hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "1234"})
+ assert hide_response.status_code == 200
+ assert hide_response.json() == {"message": "Version 1.0.0 is now hidden"}
+
+ # check hidden
+ projects_response = client_with_claimed_project.get("/api/projects")
+ assert projects_response.status_code == 200
+ assert projects_response.json() == {
+ "projects": [],
+ }
+
+ # check versions hidden
+ project_details_response = client_with_claimed_project.get("/api/projects/some-project")
+ assert project_details_response.status_code == 200
+ assert project_details_response.json() == {"name": "some-project", "versions": []}
+
+
+def test_hide_creates_hidden_file(client_with_claimed_project, upload_folder_path):
+ """
+ Tests that the hidden file is created when hiding a version
+ """
+ hidden_file_path = Path(upload_folder_path) / "some-project" / "1.0.0" / ".hidden"
+
+ # create a version
+ create_response = client_with_claimed_project.post(
+ "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"Hello World
"), "plain/text")}
+ )
+ assert create_response.status_code == 201
+
+ # check open was called at least once with the correct path
+ with patch("docat.app.open") as open_file_mock:
+ # hide
+ hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "1234"})
+ assert hide_response.status_code == 200
+ assert hide_response.json() == {"message": "Version 1.0.0 is now hidden"}
+
+ open_file_mock.assert_called_once_with(hidden_file_path, "w")
+
+
+def test_hide_fails_project_does_not_exist(client_with_claimed_project):
+ """
+ Tests that hiding a version fails when the project does not exist
+ """
+ with patch("docat.app.open") as open_file_mock:
+ hide_response = client_with_claimed_project.post("/api/does-not-exist/1.0.0/hide", headers={"Docat-Api-Key": "1234"})
+ assert hide_response.status_code == 404
+ assert hide_response.json() == {"message": "Project does-not-exist not found"}
+
+ open_file_mock.assert_not_called()
+
+
+def test_hide_fails_version_does_not_exist(client_with_claimed_project):
+ """
+ Tests that hiding a version fails when the version does not exist
+ """
+ with patch("docat.app.open") as open_file_mock:
+
+ # create a version
+ create_response = client_with_claimed_project.post(
+ "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"Hello World
"), "plain/text")}
+ )
+ assert create_response.status_code == 201
+
+ # hide different version
+ hide_response = client_with_claimed_project.post("/api/some-project/2.0.0/hide", headers={"Docat-Api-Key": "1234"})
+ assert hide_response.status_code == 404
+ assert hide_response.json() == {"message": "Version 2.0.0 not found"}
+
+ open_file_mock.assert_not_called()
+
+
+def test_hide_fails_already_hidden(client_with_claimed_project):
+ """
+ Tests that hiding a version fails when the version is already hidden
+ """
+ # create a version
+ create_response = client_with_claimed_project.post(
+ "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"Hello World
"), "plain/text")}
+ )
+ assert create_response.status_code == 201
+
+ # hide version
+ hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "1234"})
+ assert hide_response.status_code == 200
+ assert hide_response.json() == {"message": "Version 1.0.0 is now hidden"}
+
+ with patch("docat.app.open") as open_file_mock:
+ # hide version again
+ hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "1234"})
+ assert hide_response.status_code == 400
+ assert hide_response.json() == {"message": "Version 1.0.0 is already hidden"}
+
+ open_file_mock.assert_not_called()
+
+
+def test_hide_fails_no_token(client_with_claimed_project):
+ """
+ Tests that hiding a version fails when no token is provided
+ """
+ with patch("docat.app.open") as open_file_mock:
+ # create a version
+ create_response = client_with_claimed_project.post(
+ "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"Hello World
"), "plain/text")}
+ )
+ assert create_response.status_code == 201
+
+ # hide version
+ hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide")
+ assert hide_response.status_code == 401
+ assert hide_response.json() == {"message": "Please provide a header with a valid Docat-Api-Key token for some-project"}
+
+ open_file_mock.assert_not_called()
+
+
+def test_hide_fails_invalid_token(client_with_claimed_project):
+ """
+ Tests that hiding a version fails when an invalid token is provided
+ """
+ with patch("docat.app.open") as open_file_mock:
+ # create a version
+ create_response = client_with_claimed_project.post(
+ "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"Hello World
"), "plain/text")}
+ )
+ assert create_response.status_code == 201
+
+ # hide version
+ hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "invalid"})
+ assert hide_response.status_code == 401
+ assert hide_response.json() == {"message": "Docat-Api-Key token is not valid for some-project"}
+
+ open_file_mock.assert_not_called()
+
+
+def test_show(client_with_claimed_project):
+ """
+ Tests that the version is returned again after requesting show.
+ """
+ # create a version
+ create_response = client_with_claimed_project.post(
+ "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"Hello World
"), "plain/text")}
+ )
+ assert create_response.status_code == 201
+
+ # hide the version
+ hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "1234"})
+ assert hide_response.status_code == 200
+ assert hide_response.json() == {"message": "Version 1.0.0 is now hidden"}
+
+ # check hidden
+ project_details_response = client_with_claimed_project.get("/api/projects/some-project")
+ assert project_details_response.status_code == 200
+ assert project_details_response.json() == {
+ "name": "some-project",
+ "versions": [],
+ }
+
+ # show the version
+ show_response = client_with_claimed_project.post("/api/some-project/1.0.0/show", headers={"Docat-Api-Key": "1234"})
+ assert show_response.status_code == 200
+ assert show_response.json() == {"message": "Version 1.0.0 is now shown"}
+
+ # check detected again
+ project_details_response = client_with_claimed_project.get("/api/projects/some-project")
+ assert project_details_response.status_code == 200
+ assert project_details_response.json() == {
+ "name": "some-project",
+ "versions": [{"name": "1.0.0", "tags": []}],
+ }
+
+
+def test_show_deletes_hidden_file(client_with_claimed_project, upload_folder_path):
+ """
+ Tests that the hidden file is deleted when requesting show.
+ """
+ hidden_file_path = Path(upload_folder_path) / "some-project" / "1.0.0" / ".hidden"
+
+ # create a version
+ create_response = client_with_claimed_project.post(
+ "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"Hello World
"), "plain/text")}
+ )
+ assert create_response.status_code == 201
+
+ # hide the version
+ hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "1234"})
+ assert hide_response.status_code == 200
+ assert hide_response.json() == {"message": "Version 1.0.0 is now hidden"}
+
+ # check os.remove was called at least once with the correct path
+ with patch("os.remove") as remove_file_mock:
+ # show again
+ show_response = client_with_claimed_project.post("/api/some-project/1.0.0/show", headers={"Docat-Api-Key": "1234"})
+ assert show_response.status_code == 200
+ assert show_response.json() == {"message": "Version 1.0.0 is now shown"}
+
+ remove_file_mock.assert_called_once_with(hidden_file_path)
+
+
+def test_show_fails_project_does_not_exist(client_with_claimed_project):
+ """
+ Tests that showing a version fails when the project does not exist
+ """
+ with patch("os.remove") as delete_file_mock:
+ show_response = client_with_claimed_project.post("/api/does-not-exist/1.0.0/hide", headers={"Docat-Api-Key": "1234"})
+ assert show_response.status_code == 404
+ assert show_response.json() == {"message": "Project does-not-exist not found"}
+
+ delete_file_mock.assert_not_called()
+
+
+def test_show_fails_version_does_not_exist(client_with_claimed_project):
+ """
+ Tests that showing a version fails when the version does not exist
+ """
+ # create a version
+ create_response = client_with_claimed_project.post(
+ "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"Hello World
"), "plain/text")}
+ )
+ assert create_response.status_code == 201
+
+ # hide the version
+ hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "1234"})
+ assert hide_response.status_code == 200
+ assert hide_response.json() == {"message": "Version 1.0.0 is now hidden"}
+
+ with patch("os.remove") as delete_file_mock:
+ # show different version
+ show_response = client_with_claimed_project.post("/api/some-project/2.0.0/show", headers={"Docat-Api-Key": "1234"})
+ assert show_response.status_code == 404
+ assert show_response.json() == {"message": "Version 2.0.0 not found"}
+
+ delete_file_mock.assert_not_called()
+
+
+def test_show_fails_already_shown(client_with_claimed_project):
+ """
+ Tests that showing a version fails when the version is already shown
+ """
+ # create a version
+ create_response = client_with_claimed_project.post(
+ "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"Hello World
"), "plain/text")}
+ )
+ assert create_response.status_code == 201
+
+ with patch("os.remove") as delete_file_mock:
+ # show version
+ show_response = client_with_claimed_project.post("/api/some-project/1.0.0/show", headers={"Docat-Api-Key": "1234"})
+ assert show_response.status_code == 400
+ assert show_response.json() == {"message": "Version 1.0.0 is not hidden"}
+
+ delete_file_mock.assert_not_called()
+
+
+def test_show_fails_no_token(client_with_claimed_project):
+ """
+ Tests that showing a version fails when no token is provided
+ """
+ with patch("os.remove") as remove_file_mock:
+ # create a version
+ create_response = client_with_claimed_project.post(
+ "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"Hello World
"), "plain/text")}
+ )
+ assert create_response.status_code == 201
+
+ # hide version
+ hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "1234"})
+ assert hide_response.status_code == 200
+ assert hide_response.json() == {"message": "Version 1.0.0 is now hidden"}
+
+ # try to show without token
+ show_response = client_with_claimed_project.post("/api/some-project/1.0.0/show")
+ assert show_response.status_code == 401
+ assert show_response.json() == {"message": "Please provide a header with a valid Docat-Api-Key token for some-project"}
+
+ remove_file_mock.assert_not_called()
+
+
+def test_show_fails_invalid_token(client_with_claimed_project):
+ """
+ Tests that showing a version fails when an invalid token is provided
+ """
+ with patch("os.remove") as remove_file_mock:
+ # create a version
+ create_response = client_with_claimed_project.post(
+ "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"Hello World
"), "plain/text")}
+ )
+ assert create_response.status_code == 201
+
+ # hide version
+ hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "1234"})
+ assert hide_response.status_code == 200
+ assert hide_response.json() == {"message": "Version 1.0.0 is now hidden"}
+
+ # try to show without token
+ show_response = client_with_claimed_project.post("/api/some-project/1.0.0/show", headers={"Docat-Api-Key": "invalid"})
+ assert show_response.status_code == 401
+ assert show_response.json() == {"message": "Docat-Api-Key token is not valid for some-project"}
+
+ remove_file_mock.assert_not_called()
+
+
+def test_hide_and_show_with_tag(client_with_claimed_project):
+ """
+ Tests that the version is returned again after requesting show on a tag.
+ """
+ # create a version
+ create_response = client_with_claimed_project.post(
+ "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"Hello World
"), "plain/text")}
+ )
+ assert create_response.status_code == 201
+
+ # create a tag
+ create_tag_response = client_with_claimed_project.put("/api/some-project/1.0.0/tags/latest")
+ assert create_tag_response.status_code == 201
+ assert create_tag_response.json() == {"message": "Tag latest -> 1.0.0 successfully created"}
+
+ # hide the tag
+ hide_response = client_with_claimed_project.post("/api/some-project/latest/hide", headers={"Docat-Api-Key": "1234"})
+ assert hide_response.status_code == 200
+ assert hide_response.json() == {"message": "Version latest is now hidden"}
+
+ # check hidden
+ project_details_response = client_with_claimed_project.get("/api/projects/some-project")
+ assert project_details_response.status_code == 200
+ assert project_details_response.json() == {
+ "name": "some-project",
+ "versions": [],
+ }
+
+ # show the version
+ show_response = client_with_claimed_project.post("/api/some-project/latest/show", headers={"Docat-Api-Key": "1234"})
+ assert show_response.status_code == 200
+ assert show_response.json() == {"message": "Version latest is now shown"}
+
+ # check detected again
+ project_details_response = client_with_claimed_project.get("/api/projects/some-project")
+ assert project_details_response.status_code == 200
+ assert project_details_response.json() == {
+ "name": "some-project",
+ "versions": [{"name": "1.0.0", "tags": ["latest"]}],
+ }