Skip to content

Commit 3b935d0

Browse files
committed
Adds membership as a concept
1 parent aa083f0 commit 3b935d0

File tree

16 files changed

+1095
-5
lines changed

16 files changed

+1095
-5
lines changed

soauth/api/app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from .groups import group_app
1919
from .keys import key_management_routes
2020
from .login import login_app
21+
from .members import membership_app
2122

2223
settings = SETTINGS()
2324

@@ -90,3 +91,4 @@ async def lifespan(app: FastAPI):
9091
app.include_router(app_management_routes, prefix="/apps")
9192
app.include_router(key_management_routes, prefix="/keys")
9293
app.include_router(group_app, prefix="/groups")
94+
app.include_router(membership_app, prefix="/membership")

soauth/api/members.py

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
"""
2+
Membership management.
3+
"""
4+
5+
6+
from fastapi import APIRouter, HTTPException
7+
from pydantic import BaseModel
8+
9+
from soauth.api.dependencies import DatabaseDependency, LoggerDependency
10+
from soauth.core.members import InstitutionData, InstitutionalMembershipData
11+
from soauth.toolkit.fastapi import AuthenticatedUserDependency
12+
13+
from soauth.service import membership as membership_service
14+
from soauth.service import user as user_service
15+
from soauth.core.uuid import UUID
16+
17+
membership_app = APIRouter(tags=["Membership Management"])
18+
19+
20+
@membership_app.put(
21+
"",
22+
summary="Create a new institution",
23+
description="Create a new institution, only available to either admins or users "
24+
"with the membership grant.",
25+
responses={
26+
200: {"description": "Institution created."},
27+
401: {"description": "Unauthorized."},
28+
}
29+
)
30+
async def create_institution(
31+
institution: InstitutionData,
32+
user: AuthenticatedUserDependency,
33+
conn: DatabaseDependency,
34+
log: LoggerDependency,
35+
) -> InstitutionData:
36+
"""
37+
Create a new institution.
38+
"""
39+
log = log.bind(user_id=user.user_id)
40+
41+
if "admin" not in user.grants and "membership" not in user.grants:
42+
await log.awarning("institution.create_unauthorized")
43+
raise HTTPException(status_code=401, detail="Unauthorized")
44+
45+
created_institution = await membership_service.create_institution(
46+
institution_name=institution.institution_name,
47+
unit_name=institution.unit_name,
48+
publication_text=institution.publication_text,
49+
role=institution.role,
50+
conn=conn,
51+
log=log
52+
)
53+
await log.ainfo("institution.created", institution_id=created_institution.institution_id)
54+
55+
return created_institution.to_core()
56+
57+
58+
@membership_app.get(
59+
"/list",
60+
summary="List all institutions",
61+
description="Retrieve a list of all institutions, only available to either admins "
62+
"or users with the membership grant.",
63+
responses={
64+
200: {"description": "List of institutions."},
65+
401: {"description": "Unauthorized."},
66+
}
67+
)
68+
async def list_institutions(
69+
user: AuthenticatedUserDependency,
70+
conn: DatabaseDependency,
71+
log: LoggerDependency,
72+
) -> list[InstitutionData]:
73+
"""
74+
List all institutions.
75+
"""
76+
log = log.bind(user_id=user.user_id)
77+
78+
if "admin" not in user.grants and "membership" not in user.grants:
79+
await log.awarning("institution.list_unauthorized")
80+
raise HTTPException(status_code=401, detail="Unauthorized")
81+
82+
institutions = await membership_service.get_institution_list(conn=conn, log=log)
83+
await log.adebug("institution.listed")
84+
85+
return [i.to_core() for i in institutions]
86+
87+
88+
@membership_app.get(
89+
"/{institution_id}",
90+
summary="Get institution by ID",
91+
description="Retrieve an institution by its ID, only available to either admins "
92+
"or users with the membership grant.",
93+
responses={
94+
200: {"description": "Institution details."},
95+
401: {"description": "Unauthorized."},
96+
404: {"description": "Institution not found."},
97+
}
98+
)
99+
async def get_institution(
100+
institution_id: UUID,
101+
user: AuthenticatedUserDependency,
102+
conn: DatabaseDependency,
103+
log: LoggerDependency,
104+
) -> dict[str, InstitutionData | list[InstitutionalMembershipData]]:
105+
"""
106+
Get institution by ID.
107+
"""
108+
log = log.bind(user_id=user.user_id)
109+
110+
if "admin" not in user.grants and "membership" not in user.grants:
111+
await log.awarning("institution.get_unauthorized", institution_id=institution_id)
112+
raise HTTPException(status_code=401, detail="Unauthorized")
113+
114+
institution = await membership_service.read_by_id(institution_id=institution_id, conn=conn, log=log)
115+
await log.adebug("institution.retrieved", institution_id=institution_id)
116+
117+
members = await membership_service.get_membership_list_of_institution(institution_id=institution_id, conn=conn, log=log)
118+
await log.adebug("institution.members_retrieved", institution_id=institution_id, number_of_members=len(members))
119+
120+
return {
121+
"institution": institution.to_core(),
122+
"members": [x.to_core() for x in members]
123+
}
124+
125+
126+
@membership_app.post(
127+
"/{institution_id}/add_member/{user_id}",
128+
summary="Add a member to an institution",
129+
description="Add a user as a member to an institution, only available to either "
130+
"admins or users with the membership grant.",
131+
responses={
132+
200: {"description": "User added as member."},
133+
401: {"description": "Unauthorized."},
134+
404: {"description": "Institution or user not found."},
135+
400: {"description": "User is not a member."},
136+
})
137+
async def add_member_to_institution(
138+
institution_id: UUID,
139+
user_id: UUID,
140+
user: AuthenticatedUserDependency,
141+
conn: DatabaseDependency,
142+
log: LoggerDependency,
143+
) -> None:
144+
"""
145+
Add a member to an institution.
146+
"""
147+
log = log.bind(user_id=user.user_id, institution_id=institution_id, new_member_id=user_id)
148+
149+
if "admin" not in user.grants and "membership" not in user.grants:
150+
await log.awarning("institution.add_member_unauthorized", institution_id=institution_id)
151+
raise HTTPException(status_code=401, detail="Unauthorized")
152+
153+
await membership_service.add_member_to_institution(
154+
institution_id=institution_id,
155+
user_id=user_id,
156+
conn=conn,
157+
log=log
158+
)
159+
await log.ainfo("institution.member_added")
160+
161+
return None
162+
163+
164+
@membership_app.post(
165+
"/{institution_id}/remove_member/{user_id}",
166+
)
167+
async def remove_member_from_institution():
168+
raise NotImplementedError()
169+
170+
171+
@membership_app.get(
172+
"/details/{user_id}",
173+
summary="Get member details",
174+
description="Retrieve member details by user ID, only available to either admins "
175+
"or users with the membership grant.",
176+
responses={
177+
200: {"description": "Member details."},
178+
401: {"description": "Unauthorized."},
179+
404: {"description": "User not found."},
180+
}
181+
)
182+
async def get_member_details(
183+
user_id: UUID,
184+
user: AuthenticatedUserDependency,
185+
conn: DatabaseDependency,
186+
log: LoggerDependency,
187+
) -> dict:
188+
"""
189+
Get member details by user ID.
190+
"""
191+
log = log.bind(user_id=user.user_id, queried_user_id=user_id)
192+
193+
if "admin" not in user.grants and "membership" not in user.grants:
194+
await log.awarning("membership.get_details_unauthorized", queried_user_id=user_id)
195+
raise HTTPException(status_code=401, detail="Unauthorized")
196+
197+
user = await user_service.read_by_id(user_id=user_id, conn=conn)
198+
199+
await log.adebug("membership.details_retrieved", queried_user_id=user_id)
200+
201+
if user.membership is None:
202+
raise HTTPException(status_code=404, detail="User not found")
203+
204+
return user.membership.to_core()
205+
206+
207+
class PromoteToMemberRequest(BaseModel):
208+
first_name: str
209+
last_name: str
210+
email: str
211+
status: str
212+
confluence: str | None = None
213+
website: str | None = None
214+
orcid: str | None = None
215+
216+
@membership_app.post(
217+
"/promote/{user_id}",
218+
summary="Promote a user to member",
219+
description="Promote a user to member, only available to either admins "
220+
"or users with the membership grant.",
221+
responses={
222+
200: {"description": "User promoted to member."},
223+
401: {"description": "Unauthorized."},
224+
404: {"description": "User not found."},
225+
}
226+
)
227+
async def promote_user_to_member(
228+
user_id: UUID,
229+
details: PromoteToMemberRequest,
230+
user: AuthenticatedUserDependency,
231+
conn: DatabaseDependency,
232+
log: LoggerDependency,
233+
) -> None:
234+
"""
235+
Promote a user to member.
236+
"""
237+
log = log.bind(user_id=user.user_id, promoted_user_id=user_id)
238+
239+
if "admin" not in user.grants and "membership" not in user.grants:
240+
await log.awarning("membership.promote_unauthorized", promoted_user_id=user_id)
241+
raise HTTPException(status_code=401, detail="Unauthorized")
242+
243+
await membership_service.update_user_to_be_member(
244+
user_id=user_id,
245+
first_name=details.first_name,
246+
last_name=details.last_name,
247+
email=details.email,
248+
status=details.status,
249+
confluence=details.confluence,
250+
website=details.website,
251+
orcid=details.orcid,
252+
conn=conn,
253+
log=log
254+
)
255+
await log.ainfo("membership.user_promoted")
256+
257+
return None
258+

soauth/app/app.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from .groups import router as group_router
1919
from .keys import router as key_router
2020
from .users import router as user_router
21+
from .institutions import router as institutions_router
2122

2223
settings = Settings()
2324

@@ -69,6 +70,8 @@ async def lifespan(app: FastAPI):
6970
app.group_detail_url = f"{settings.hostname}/groups"
7071
app.group_list_url = f"{settings.hostname}/groups/list"
7172
app.group_grant_update_url = f"{settings.hostname}/admin/group"
73+
app.institution_detail_url = f"{settings.hostname}/membership"
74+
app.institution_list_url = f"{settings.hostname}/membership/list"
7275
yield
7376

7477

@@ -101,3 +104,4 @@ async def apple(param: str | None):
101104
app.include_router(user_router)
102105
app.include_router(key_router)
103106
app.include_router(group_router)
107+
app.include_router(institutions_router)

soauth/app/dependencies.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ def internal_urls(request: Request):
2727
logout_url=f"{settings.management_hostname}{settings.management_path}/logout",
2828
login_url=f"{settings.hostname}/login/{request.app.app_id}",
2929
group_list=f"{settings.management_hostname}{settings.management_path}/groups",
30+
institution_list=f"{settings.management_hostname}{settings.management_path}/institutions",
3031
)
3132

3233
def user_and_scope(request: Request):

0 commit comments

Comments
 (0)