Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Parent/Child relationships for Collections #35

Merged
merged 5 commits into from
Jan 7, 2025
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,7 @@ env*

examples/*.fits

# Secrets
.jwt_secret
.github_client_id
.github_client_secret
11 changes: 11 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
FROM python:3.12

COPY hippometa hippo/hippometa
COPY hipposerve hippo/hipposerve
COPY hippoclient hippo/hippoclient
COPY pyproject.toml hippo/pyproject.toml

RUN pip install uv
RUN uv pip install --system --upgrade /hippo

CMD ["uvicorn", "hipposerve.api.app:app", "--host", "0.0.0.0", "--port", "44776"]
37 changes: 36 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,39 @@ You may need to copy `config.json` to `~/.hippo.conf` to correctly load the envi
or export appropriate variables.

Products can then be viewed through the web interface (served at `/web` from the
server root), or through the use of the `henry` command-line tool.
server root), or through the use of the `henry` command-line tool.

Containerized Version
---------------------

There is a containerized version of the application available in the repository. You can
build the container with `docker build .`. By default, the server runs on port 44776
(HIPPO on a T9 keyboard).

To actually run HIPPO, you will need a running instance of MongoDB and an instance
of an S3-compaitible storage server. HIPPO was built to be ran with MinIO. MinIO will
actually provide file storage for your server, with MongoDB handling the metadata.

There are a number of important configuration variables:

- `MINIO_URL`: hostname of the MINIO server.
- `MINIO_ACCESS`: the MINIO access token (username).
- `MINIO_SECRET`: the MINIO access secret (password).
- `MONGO_URI`: the full URI for the mongo instance including password.
- `TITLE`: the title of the HIPPO instance.
- `DESCRIPTION`: the description of the HIPPO instance.
- `ADD_CORS`: boolean, whether to allow unlimited CORS access. True by default (dev)
- `DEBUG`: boolean, whether to run in debug mode. True by default (dev)
- `CREATE_TEST_USER`: boolean, whether to create a test user on startup. False.
- `TEST_USER_PASSWORD`: password of said test user.
- `TEST_USER_API_KEY`: a custom API key for your test user.
- `WEB`: boolean, whether to serve the web UI.
- `WEB_JWT_SECRET`: 32 bytes of hexidecimal data, secret for JWTs.
- `WEB_ALLOW_GITHUB_LOGIN`: whether to allow GitHub Login (False).
- `WEB_GITHUB_CLIENT_ID`: client ID for GitHub integration.
- `WEB_GITHUB_CLIENT_SECRET`: client secret for GitHub integration.
- `WEB_GITHUB_REQUIRED_ORGANISATION_MEMBERSHIP`: the GitHub organisation that users must be a part of to be granted access.

Secrets can be loaded from `/run/secrets` automatically, so long as they have the same file name as their environment variable.

For GitHub integration, your callback URL needs to be $URL/web.
50 changes: 50 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
services:
core:
build: .
ports:
- "44776:44776"
environment:
MINIO_URL: "minio:9000"
MINIO_ACCESS: "minio"
MINIO_SECRET: "minio"
MONGO_URI: "mongodb://root:example@mongodb:27017"
TITLE: "Example HIPPO"
DESCRIPTION: "A containerised example of the HIPPO service"
ADD_CORS: false
DEBUG: true
WEB: true
WEB_ALLOW_GITHUB_LOGIN: true
WEB_GITHUB_REQUIRED_ORGANISATION_MEMBERSHIP: "simonsobs"
secrets:
- WEB_JWT_SECRET
- WEB_GITHUB_CLIENT_ID
- WEB_GITHUB_CLIENT_SECRET
mongodb:
image: mongo:latest
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: example
minio:
ports:
- 9000:9000
volumes:
- ./data:/data
image: minio/minio:latest
environment:
MINIO_ROOT_USER: "minio"
MINIO_ROOT_PASSWORD: "minio"
command: server /data --console-address ":9001"



secrets:
WEB_JWT_SECRET:
file: ./.jwt_secret
WEB_GITHUB_CLIENT_ID:
file: ./.github_client_id
WEB_GITHUB_CLIENT_SECRET:
file: ./.github_client_secret
MINIO_CONFIG:
file: ./.minio_config


28 changes: 28 additions & 0 deletions examples/recursive_collection/simplepopulate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""
An extremely simple example that creates two collections, one which is a child
collection of the other.
"""

from hippoclient import Client
from hippoclient.collections import create as create_collection
from hippoclient.relationships import add_child_collection

API_KEY = "TEST_API_KEY"
SERVER_LOCATION = "http://127.0.0.1:8000"

if __name__ == "__main__":
client = Client(api_key=API_KEY, host=SERVER_LOCATION, verbose=True)

child = create_collection(
client=client,
name="Child Collection",
description="This is a child collection",
)

parent = create_collection(
client=client,
name="Parent Collection",
description="This is a parent collection",
)

add_child_collection(client=client, parent=parent, child=child)
82 changes: 78 additions & 4 deletions hippoclient/relationships.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ def add_child(client: Client, parent: str, child: str) -> bool:
client : Client
The client to use for interacting with the hippo API.
parent : str
The name of the parent product.
The ID of the parent product.
child : str
The name of the child product.
The ID of the child product.

Returns
-------
Expand Down Expand Up @@ -51,9 +51,9 @@ def remove_child(client: Client, parent: str, child: str) -> bool:
client : Client
The client to use for interacting with the hippo API.
parent : str
The name of the parent product.
The ID of the parent product.
child : str
The name of the child product.
The ID of the child product.

Returns
-------
Expand All @@ -77,3 +77,77 @@ def remove_child(client: Client, parent: str, child: str) -> bool:
)

return True


def add_child_collection(client: Client, parent: str, child: str) -> bool:
"""
Add a child relationship between a collection and another collection.

Arguments
---------
client : Client
The client to use for interacting with the hippo API.
parent : str
The ID of the parent collection.
child : str
The ID of the child collection.

Returns
-------
bool
True if the relationship was added successfully.

Raises
------
httpx.HTTPStatusError
If a request to the API fails
"""

response = client.put(f"/relationships/collection/{child}/child_of/{parent}")

response.raise_for_status()

if client.verbose:
console.print(
f"Successfully added child relationship between {parent} and {child}.",
style="bold green",
)

return True


def remove_child_collection(client: Client, parent: str, child: str) -> bool:
"""
Remove a child relationship between a collection and another collection.

Arguments
---------
client : Client
The client to use for interacting with the hippo API.
parent : str
The ID of the parent collection.
child : str
The ID of the child collection.

Returns
-------
bool
True if the relationship was removed successfully.

Raises
------
httpx.HTTPStatusError
If a request to the API fails
"""

response = client.delete(f"/relationships/collection/{child}/child_of/{parent}")

response.raise_for_status()

if client.verbose:
console.print(
f"Successfully removed child relationship between {parent} and {child}.",
style="bold green",
)

return True
13 changes: 13 additions & 0 deletions hipposerve/api/models/relationships.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,17 @@ class ReadCollectionProductResponse(BaseModel):
metadata: ALL_METADATA_TYPE


class ReadCollectionCollectionResponse(BaseModel):
"""
Response model for reading a collection (i.e. a parent or child)
as part of a collection request.
"""

id: PydanticObjectId
name: str
description: str


class ReadCollectionResponse(BaseModel):
"""
Response model for reading a collection.
Expand All @@ -41,3 +52,5 @@ class ReadCollectionResponse(BaseModel):
name: str
description: str
products: list[ReadCollectionProductResponse] | None
child_collections: list[ReadCollectionCollectionResponse] | None
parent_collections: list[ReadCollectionCollectionResponse] | None
85 changes: 83 additions & 2 deletions hipposerve/api/relationships.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from hipposerve.api.auth import UserDependency, check_user_for_privilege
from hipposerve.api.models.relationships import (
CreateCollectionRequest,
ReadCollectionCollectionResponse,
ReadCollectionProductResponse,
ReadCollectionResponse,
)
Expand Down Expand Up @@ -78,6 +79,22 @@ async def read_collection(
)
for x in item.products
],
child_collections=[
ReadCollectionCollectionResponse(
id=x.id,
name=x.name,
description=x.description,
)
for x in item.child_collections
],
parent_collections=[
ReadCollectionCollectionResponse(
id=x.id,
name=x.name,
description=x.description,
)
for x in item.parent_collections
],
)


Expand All @@ -88,8 +105,9 @@ async def search_collection(
calling_user: UserDependency,
) -> list[ReadCollectionResponse]:
"""
Search for collections by name. Products are not returned; these should be
fetched separately through the read_collection endpoint.
Search for collections by name. Products and sub-collections are not
returned; these should be fetched separately through the read_collection
endpoint.
"""

logger.info("Request to search for collection: {} from {}", name, calling_user.name)
Expand Down Expand Up @@ -199,6 +217,13 @@ async def delete_collection(
await check_user_for_privilege(calling_user, Privilege.DELETE_COLLECTION)

try:
# Check if we have a parent; if we do, we need to remove its link to us.
coll = await collection.read(id=id)

if coll.parent_collections:
for parent in coll.parent_collections:
await collection.remove_child(parent_id=parent.id, child_id=id)

await collection.delete(id=id)
logger.info("Successfully deleted collection {} from {}", id, calling_user.name)
except collection.CollectionNotFound:
Expand Down Expand Up @@ -277,3 +302,59 @@ async def remove_child_product(
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Product not found."
)


@relationship_router.put("/collection/{parent_id}/child_of/{child_id}")
async def add_child_collection(
parent_id: PydanticObjectId,
child_id: PydanticObjectId,
calling_user: UserDependency,
) -> None:
"""
Add a child collection to a parent collection.
"""

logger.info(
"Request to add collection {} as child of {} from {}",
child_id,
parent_id,
calling_user.name,
)

await check_user_for_privilege(calling_user, Privilege.CREATE_RELATIONSHIP)

try:
await collection.add_child(parent_id=parent_id, child_id=child_id)
logger.info("Successfully added {} as child of {}", child_id, parent_id)
except collection.CollectionNotFound:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Collection not found."
)


@relationship_router.delete("/collection/{parent_id}/child_of/{child_id}")
async def remove_child_collection(
parent_id: PydanticObjectId,
child_id: PydanticObjectId,
calling_user: UserDependency,
) -> None:
"""
Remove a parent-child relationship between two collections.
"""

logger.info(
"Request to remove collection {} as child of {} from {}",
child_id,
parent_id,
calling_user.name,
)

await check_user_for_privilege(calling_user, Privilege.DELETE_RELATIONSHIP)

try:
await collection.remove_child(parent_id=parent_id, child_id=child_id)
logger.info("Successfully removed {} as child of {}", child_id, parent_id)
except collection.CollectionNotFound:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Collection not found."
)
Loading