Skip to content

Commit a0c9f9b

Browse files
authored
✨ web-api: new share_project operation (dev) and updates notifications-library (#7431)
1 parent a29c865 commit a0c9f9b

File tree

36 files changed

+847
-334
lines changed

36 files changed

+847
-334
lines changed

api/specs/web-server/_projects_groups.py renamed to api/specs/web-server/_projects_access_rights.py

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,17 @@
77
from typing import Annotated
88

99
from fastapi import APIRouter, Depends, status
10+
from models_library.api_schemas_webserver.projects_access_rights import (
11+
ProjectsGroupsBodyParams,
12+
ProjectsGroupsPathParams,
13+
ProjectShare,
14+
ProjectShareAccepted,
15+
)
1016
from models_library.generics import Envelope
1117
from simcore_service_webserver._meta import API_VTAG
1218
from simcore_service_webserver.projects._controller._rest_schemas import (
1319
ProjectPathParams,
1420
)
15-
from simcore_service_webserver.projects._controller.groups_rest import (
16-
_ProjectsGroupsBodyParams,
17-
_ProjectsGroupsPathParams,
18-
)
1921
from simcore_service_webserver.projects._groups_service import ProjectGroupGet
2022

2123
router = APIRouter(
@@ -24,14 +26,30 @@
2426
)
2527

2628

29+
@router.post(
30+
"/projects/{project_id}:share",
31+
response_model=Envelope[ProjectShareAccepted],
32+
status_code=status.HTTP_202_ACCEPTED,
33+
responses={
34+
status.HTTP_202_ACCEPTED: {
35+
"description": "The request to share the project has been accepted, but the actual sharing process has to be confirmd."
36+
}
37+
},
38+
)
39+
async def share_project(
40+
_path: Annotated[ProjectPathParams, Depends()],
41+
_body: ProjectShare,
42+
): ...
43+
44+
2745
@router.post(
2846
"/projects/{project_id}/groups/{group_id}",
2947
response_model=Envelope[ProjectGroupGet],
3048
status_code=status.HTTP_201_CREATED,
3149
)
3250
async def create_project_group(
33-
_path: Annotated[_ProjectsGroupsPathParams, Depends()],
34-
_body: _ProjectsGroupsBodyParams,
51+
_path: Annotated[ProjectsGroupsPathParams, Depends()],
52+
_body: ProjectsGroupsBodyParams,
3553
): ...
3654

3755

@@ -47,8 +65,8 @@ async def list_project_groups(_path: Annotated[ProjectPathParams, Depends()]): .
4765
response_model=Envelope[ProjectGroupGet],
4866
)
4967
async def replace_project_group(
50-
_path: Annotated[_ProjectsGroupsPathParams, Depends()],
51-
_body: _ProjectsGroupsBodyParams,
68+
_path: Annotated[ProjectsGroupsPathParams, Depends()],
69+
_body: ProjectsGroupsBodyParams,
5270
): ...
5371

5472

@@ -57,5 +75,5 @@ async def replace_project_group(
5775
status_code=status.HTTP_204_NO_CONTENT,
5876
)
5977
async def delete_project_group(
60-
_path: Annotated[_ProjectsGroupsPathParams, Depends()],
78+
_path: Annotated[ProjectsGroupsPathParams, Depends()],
6179
): ...

api/specs/web-server/openapi.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,9 @@
4242
"_nih_sparc",
4343
"_nih_sparc_redirections",
4444
"_projects",
45+
"_projects_access_rights",
4546
"_projects_comments",
4647
"_projects_folders",
47-
"_projects_groups",
4848
"_projects_metadata",
4949
"_projects_nodes",
5050
"_projects_nodes_pricing_unit", # after _projects_nodes

packages/models-library/src/models_library/access_rights.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@ class AccessRights(BaseModel):
1010

1111
model_config = ConfigDict(extra="forbid")
1212

13+
def verify_access_integrity(self):
14+
"""Helper function that checks extra constraints in access-rights flags"""
15+
if self.write and not self.read:
16+
msg = "Write access requires read access"
17+
raise ValueError(msg)
18+
if self.delete and not self.write:
19+
msg = "Delete access requires read access"
20+
raise ValueError(msg)
21+
return self
22+
1323

1424
class ExecutableAccessRights(BaseModel):
1525
write: Annotated[bool, Field(description="can change executable settings")]
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
from typing import Annotated, Self
2+
3+
from models_library.groups import GroupID
4+
from models_library.projects import ProjectID
5+
from pydantic import (
6+
BaseModel,
7+
ConfigDict,
8+
EmailStr,
9+
Field,
10+
HttpUrl,
11+
StringConstraints,
12+
model_validator,
13+
)
14+
15+
from ..access_rights import AccessRights
16+
from ._base import InputSchema, OutputSchema
17+
18+
19+
class ProjectsGroupsPathParams(BaseModel):
20+
project_id: ProjectID
21+
group_id: GroupID
22+
23+
model_config = ConfigDict(extra="forbid")
24+
25+
26+
class ProjectsGroupsBodyParams(InputSchema):
27+
read: bool
28+
write: bool
29+
delete: bool
30+
31+
32+
class ProjectShare(InputSchema):
33+
sharee_email: EmailStr
34+
sharer_message: Annotated[
35+
str,
36+
StringConstraints(max_length=500, strip_whitespace=True),
37+
Field(description="An optional message from sharer to sharee"),
38+
] = ""
39+
40+
# Sharing access rights
41+
read: bool
42+
write: bool
43+
delete: bool
44+
45+
@model_validator(mode="after")
46+
def _validate_access_rights(self) -> Self:
47+
AccessRights.model_construct(
48+
read=self.read, write=self.write, delete=self.delete
49+
).verify_access_integrity()
50+
return self
51+
52+
53+
class ProjectShareAccepted(OutputSchema):
54+
sharee_email: EmailStr
55+
confirmation_link: HttpUrl

packages/notifications-library/src/notifications_library/_models.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,18 @@
22

33
from models_library.products import ProductName
44

5-
65
#
76
# *Data are models used for rendering
87
#
8+
9+
10+
@dataclass(frozen=True)
11+
class JinjaTemplateDbGet:
12+
product_name: ProductName
13+
name: str
14+
content: str
15+
16+
917
@dataclass(frozen=True)
1018
class UserData:
1119
first_name: str

packages/notifications-library/src/notifications_library/_payments_db.py

Lines changed: 0 additions & 44 deletions
This file was deleted.

packages/notifications-library/src/notifications_library/_render.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,15 @@
77
_logger = logging.getLogger(__name__)
88

99

10-
def create_render_env_from_package(**kwargs):
10+
def create_render_environment_from_notifications_library(**kwargs) -> Environment:
1111
return Environment(
1212
loader=PackageLoader(notifications_library.__name__, "templates"),
1313
autoescape=select_autoescape(["html", "xml"]),
1414
**kwargs
1515
)
1616

1717

18-
def create_render_env_from_folder(top_dir: Path):
18+
def create_render_environment_from_folder(top_dir: Path) -> Environment:
1919
assert top_dir.exists() # nosec
2020
assert top_dir.is_dir() # nosec
2121
return Environment(
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,52 @@
1+
from collections.abc import AsyncIterable
2+
13
import sqlalchemy as sa
24
from models_library.products import ProductName
35
from models_library.users import UserID
46
from simcore_postgres_database.models.jinja2_templates import jinja2_templates
57
from simcore_postgres_database.models.products_to_templates import products_to_templates
68
from simcore_postgres_database.models.users import users
9+
from simcore_postgres_database.utils_repos import pass_or_acquire_connection
710
from sqlalchemy.ext.asyncio import AsyncEngine
811

12+
from ._models import (
13+
JinjaTemplateDbGet,
14+
UserData,
15+
)
16+
917

1018
class _BaseRepo:
1119
def __init__(self, db_engine: AsyncEngine):
1220
assert db_engine is not None # nosec
1321
self.db_engine = db_engine
1422

15-
async def _get(self, query):
16-
async with self.db_engine.begin() as conn:
23+
24+
class UsersRepo(_BaseRepo):
25+
async def get_user_data(self, user_id: UserID) -> UserData:
26+
query = sa.select(
27+
# NOTE: careful! privacy applies here!
28+
users.c.first_name,
29+
users.c.last_name,
30+
users.c.email,
31+
).where(users.c.id == user_id)
32+
async with pass_or_acquire_connection(self.db_engine) as conn:
1733
result = await conn.execute(query)
18-
return result.first()
34+
row = result.one_or_none()
1935

36+
if row is None:
37+
msg = f"User not found {user_id=}"
38+
raise ValueError(msg)
2039

21-
class UsersRepo(_BaseRepo):
22-
async def get_user_data(self, user_id: UserID):
23-
return await self._get(
24-
sa.select(
25-
users.c.first_name,
26-
users.c.last_name,
27-
users.c.email,
28-
).where(users.c.id == user_id)
40+
return UserData(
41+
first_name=row.first_name, last_name=row.last_name, email=row.email
2942
)
3043

3144

3245
class TemplatesRepo(_BaseRepo):
33-
async def iter_email_templates(self, product_name: ProductName):
34-
async with self.db_engine.begin() as conn:
46+
async def iter_email_templates(
47+
self, product_name: ProductName
48+
) -> AsyncIterable[JinjaTemplateDbGet]:
49+
async with pass_or_acquire_connection(self.db_engine) as conn:
3550
async for row in await conn.stream(
3651
sa.select(
3752
jinja2_templates.c.name,
@@ -43,10 +58,14 @@ async def iter_email_templates(self, product_name: ProductName):
4358
& (jinja2_templates.c.name.ilike("%.email.%"))
4459
)
4560
):
46-
yield row
61+
yield JinjaTemplateDbGet(
62+
product_name=product_name, name=row.name, content=row.content
63+
)
4764

48-
async def iter_product_templates(self, product_name: ProductName):
49-
async with self.db_engine.begin() as conn:
65+
async def iter_product_templates(
66+
self, product_name: ProductName
67+
) -> AsyncIterable[JinjaTemplateDbGet]:
68+
async with pass_or_acquire_connection(self.db_engine) as conn:
5069
async for row in await conn.stream(
5170
sa.select(
5271
products_to_templates.c.product_name,
@@ -56,4 +75,6 @@ async def iter_product_templates(self, product_name: ProductName):
5675
.select_from(products_to_templates.join(jinja2_templates))
5776
.where(products_to_templates.c.product_name == product_name)
5877
):
59-
yield row
78+
yield JinjaTemplateDbGet(
79+
product_name=row.product_name, name=row.name, content=row.template
80+
)

packages/notifications-library/src/notifications_library/_templates.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from aiofiles.os import wrap as sync_to_async
1111
from models_library.products import ProductName
1212

13-
from ._db import TemplatesRepo
13+
from ._repository import TemplatesRepo
1414

1515
_logger = logging.getLogger(__name__)
1616

packages/notifications-library/tests/email/test_email_events.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@
4242
render_email_parts,
4343
)
4444
from notifications_library._models import ProductData, UserData
45-
from notifications_library._render import create_render_env_from_package
45+
from notifications_library._render import (
46+
create_render_environment_from_notifications_library,
47+
)
4648
from notifications_library.payments import PaymentData
4749
from pydantic import EmailStr
4850
from pydantic.json import pydantic_encoder
@@ -65,8 +67,8 @@ def ipinfo(faker: Faker) -> dict[str, Any]:
6567

6668
@pytest.fixture
6769
def request_form(faker: Faker) -> dict[str, Any]:
68-
return AccountRequestInfo(
69-
**AccountRequestInfo.model_config["json_schema_extra"]["example"]
70+
return AccountRequestInfo.model_validate(
71+
AccountRequestInfo.model_json_schema()["example"]
7072
).model_dump()
7173

7274

@@ -126,7 +128,6 @@ def event_extra_data( # noqa: PLR0911
126128
"host": host_url,
127129
"link": f"{host_url}?registration={code}",
128130
}
129-
130131
case "on_reset_password":
131132
return {
132133
"host": host_url,
@@ -187,7 +188,9 @@ async def test_email_event(
187188
assert product_data.product_name == product_name
188189

189190
parts = render_email_parts(
190-
env=create_render_env_from_package(undefined=StrictUndefined),
191+
env=create_render_environment_from_notifications_library(
192+
undefined=StrictUndefined
193+
),
191194
event_name=event_name,
192195
user=user_data,
193196
product=product_data,
@@ -254,7 +257,9 @@ async def test_email_with_reply_to(
254257
)
255258

256259
parts = render_email_parts(
257-
env=create_render_env_from_package(undefined=StrictUndefined),
260+
env=create_render_environment_from_notifications_library(
261+
undefined=StrictUndefined
262+
),
258263
event_name=event_name,
259264
user=user_data,
260265
product=product_data,

0 commit comments

Comments
 (0)