Skip to content

Commit 98c6871

Browse files
authored
Merge pull request #1022 from alliance-genome/SCRUM-3947
adding a new person_setting table to db and adding endpoints to work with the data in this table
2 parents df61e67 + ea64288 commit 98c6871

File tree

10 files changed

+741
-2
lines changed

10 files changed

+741
-2
lines changed
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import logging
2+
from typing import Any, Dict, List, Optional
3+
4+
from fastapi import HTTPException, status
5+
from fastapi.encoders import jsonable_encoder
6+
from sqlalchemy import and_, func
7+
from sqlalchemy.orm import Session, joinedload
8+
9+
from agr_literature_service.api.models.person_model import PersonModel
10+
from agr_literature_service.api.models.email_model import EmailModel
11+
from agr_literature_service.api.models.person_setting_model import PersonSettingModel
12+
from agr_literature_service.api.crud.user_utils import map_to_user_id
13+
14+
logger = logging.getLogger(__name__)
15+
16+
17+
def normalize_email(s: str) -> str:
18+
return s.strip().lower()
19+
20+
21+
def _non_empty_or_422(field: str, value: Optional[str]) -> str:
22+
v = (value or "").strip()
23+
if not v:
24+
raise HTTPException(
25+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
26+
detail=f"{field} must be a non-empty string",
27+
)
28+
return v
29+
30+
31+
def _assert_person_exists(db: Session, person_id: int) -> None:
32+
exists = db.query(PersonModel.person_id).filter(PersonModel.person_id == person_id).first()
33+
if not exists:
34+
raise HTTPException(
35+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
36+
detail=f"person_id {person_id} does not exist",
37+
)
38+
39+
40+
def _assert_default_unique(
41+
db: Session,
42+
person_id: int,
43+
component_name: str,
44+
exclude_person_setting_id: Optional[int] = None,
45+
) -> None:
46+
"""
47+
Enforce at-most-one default per (person_id, component_name).
48+
This mirrors the DB partial unique index at the application layer
49+
to give a friendlier error before hitting the constraint.
50+
"""
51+
q = (
52+
db.query(PersonSettingModel.person_setting_id)
53+
.filter(PersonSettingModel.person_id == person_id)
54+
.filter(PersonSettingModel.component_name == component_name)
55+
.filter(PersonSettingModel.default_setting.is_(True))
56+
)
57+
if exclude_person_setting_id is not None:
58+
q = q.filter(PersonSettingModel.person_setting_id != exclude_person_setting_id)
59+
60+
existing = q.first()
61+
if existing:
62+
raise HTTPException(
63+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
64+
detail=(
65+
"A default setting already exists for this (person_id, component_name). "
66+
"Unset the existing default or set this row to default_setting = false."
67+
),
68+
)
69+
70+
71+
def create(db: Session, payload) -> PersonSettingModel:
72+
"""
73+
Create a PersonSetting row.
74+
Enforces:
75+
- person exists
76+
- non-empty component_name / setting_name
77+
- only one default per (person_id, component_name)
78+
"""
79+
data: Dict[str, Any] = jsonable_encoder(payload)
80+
81+
if "created_by" in data and data["created_by"] is not None:
82+
data["created_by"] = map_to_user_id(data["created_by"], db)
83+
if "updated_by" in data and data["updated_by"] is not None:
84+
data["updated_by"] = map_to_user_id(data["updated_by"], db)
85+
86+
person_id = data.get("person_id")
87+
if person_id is None:
88+
raise HTTPException(
89+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
90+
detail="person_id is required",
91+
)
92+
_assert_person_exists(db, int(person_id))
93+
94+
component_name = _non_empty_or_422("component_name", data.get("component_name"))
95+
setting_name = _non_empty_or_422("setting_name", data.get("setting_name"))
96+
97+
is_default = bool(data.get("default_setting", False))
98+
if is_default:
99+
_assert_default_unique(db, person_id, component_name)
100+
101+
obj = PersonSettingModel(
102+
person_id=person_id,
103+
component_name=component_name,
104+
setting_name=setting_name,
105+
default_setting=is_default,
106+
json_settings=data.get("json_settings") or {},
107+
created_by=data.get("created_by"),
108+
updated_by=data.get("updated_by"),
109+
)
110+
db.add(obj)
111+
db.commit()
112+
db.refresh(obj)
113+
return obj
114+
115+
116+
def destroy(db: Session, person_setting_id: int) -> None:
117+
obj: Optional[PersonSettingModel] = (
118+
db.query(PersonSettingModel)
119+
.filter(PersonSettingModel.person_setting_id == person_setting_id)
120+
.first()
121+
)
122+
if not obj:
123+
raise HTTPException(
124+
status_code=status.HTTP_404_NOT_FOUND,
125+
detail=f"person_setting_id {person_setting_id} not found",
126+
)
127+
db.delete(obj)
128+
db.commit()
129+
130+
131+
def patch(db: Session, person_setting_id: int, patch_dict: Dict[str, Any]) -> Dict[str, Any]:
132+
obj: Optional[PersonSettingModel] = (
133+
db.query(PersonSettingModel)
134+
.filter(PersonSettingModel.person_setting_id == person_setting_id)
135+
.first()
136+
)
137+
if not obj:
138+
raise HTTPException(
139+
status_code=status.HTTP_404_NOT_FOUND,
140+
detail=f"person_setting_id {person_setting_id} not found",
141+
)
142+
143+
data = jsonable_encoder(patch_dict)
144+
145+
if "created_by" in data and data["created_by"] is not None:
146+
data["created_by"] = map_to_user_id(data["created_by"], db)
147+
if "updated_by" in data and data["updated_by"] is not None:
148+
data["updated_by"] = map_to_user_id(data["updated_by"], db)
149+
150+
# Prepare new target values for uniqueness check
151+
new_person_id = int(data.get("person_id", obj.person_id))
152+
new_component_name = data.get("component_name", obj.component_name)
153+
new_default_setting = data.get("default_setting", obj.default_setting)
154+
155+
if "person_id" in data and data["person_id"] is not None:
156+
_assert_person_exists(db, new_person_id)
157+
158+
if "component_name" in data and data["component_name"] is not None:
159+
new_component_name = _non_empty_or_422("component_name", new_component_name)
160+
161+
if "setting_name" in data and data["setting_name"] is not None:
162+
data["setting_name"] = _non_empty_or_422("setting_name", data["setting_name"])
163+
164+
# If this row is (or will become) the default, ensure no other default conflicts
165+
if bool(new_default_setting):
166+
_assert_default_unique(
167+
db,
168+
person_id=new_person_id,
169+
component_name=new_component_name,
170+
exclude_person_setting_id=person_setting_id,
171+
)
172+
173+
# Only update scalar fields defined on the table
174+
ALLOWED = {"person_id", "component_name", "setting_name", "default_setting", "json_settings"}
175+
for field, value in data.items():
176+
if field in ALLOWED:
177+
setattr(obj, field, value)
178+
179+
db.commit()
180+
return {"message": "updated"}
181+
182+
183+
def show(db: Session, person_setting_id: int) -> PersonSettingModel:
184+
obj: Optional[PersonSettingModel] = (
185+
db.query(PersonSettingModel)
186+
.options(joinedload(PersonSettingModel.person))
187+
.filter(PersonSettingModel.person_setting_id == person_setting_id)
188+
.first()
189+
)
190+
if not obj:
191+
raise HTTPException(
192+
status_code=status.HTTP_404_NOT_FOUND,
193+
detail=f"person_setting_id {person_setting_id} not found",
194+
)
195+
return obj
196+
197+
198+
# ---------- Lookup helpers used by router ----------
199+
200+
def get_by_okta_id(db: Session, okta_id: str) -> List[PersonSettingModel]:
201+
if not okta_id:
202+
return []
203+
return (
204+
db.query(PersonSettingModel)
205+
.join(PersonModel, PersonModel.person_id == PersonSettingModel.person_id)
206+
.options(joinedload(PersonSettingModel.person))
207+
.filter(PersonModel.okta_id == okta_id)
208+
.order_by(PersonSettingModel.component_name.asc(), PersonSettingModel.setting_name.asc())
209+
.all()
210+
)
211+
212+
213+
def get_by_email(db: Session, email: str) -> List[PersonSettingModel]:
214+
if not email:
215+
return []
216+
email_norm = normalize_email(email)
217+
return (
218+
db.query(PersonSettingModel)
219+
.join(PersonModel, PersonModel.person_id == PersonSettingModel.person_id)
220+
.join(EmailModel, and_(EmailModel.person_id == PersonModel.person_id))
221+
.options(joinedload(PersonSettingModel.person))
222+
.filter(func.lower(EmailModel.email_address) == email_norm)
223+
.order_by(PersonSettingModel.component_name.asc(), PersonSettingModel.setting_name.asc())
224+
.all()
225+
)
226+
227+
228+
def find_by_name(db: Session, name: str) -> List[PersonSettingModel]:
229+
"""
230+
Case-insensitive partial match on Person.display_name.
231+
"""
232+
if not name:
233+
return []
234+
pattern = f"%{name.strip()}%"
235+
return (
236+
db.query(PersonSettingModel)
237+
.join(PersonModel, PersonModel.person_id == PersonSettingModel.person_id)
238+
.options(joinedload(PersonSettingModel.person))
239+
.filter(PersonModel.display_name.ilike(pattern))
240+
.order_by(PersonModel.display_name.asc(), PersonSettingModel.component_name.asc(), PersonSettingModel.setting_name.asc())
241+
.all()
242+
)

agr_literature_service/api/main.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929
copyright_license_router, check_router,
3030
dataset_router, ml_model_router,
3131
manual_indexing_tag_router, person_router,
32-
person_cross_reference_router, email_router)
32+
person_cross_reference_router, email_router,
33+
person_setting_router)
3334

3435
TITLE = "Alliance Literature Service"
3536
VERSION = "0.1.0"
@@ -128,6 +129,7 @@ def custom_openapi() -> Dict[str, Any]:
128129
app.include_router(person_router.router)
129130
app.include_router(email_router.router)
130131
app.include_router(person_cross_reference_router.router)
132+
app.include_router(person_setting_router.router)
131133

132134
app.add_api_route("/health", health([is_database_online]))
133135

agr_literature_service/api/models/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
create_all_tables,
66
create_default_user,
77
create_all_triggers,
8-
drop_open_db_sessions)
8+
drop_open_db_sessions
9+
)
910
from agr_literature_service.api.models.author_model import AuthorModel
1011
from agr_literature_service.api.models.cross_reference_model import CrossReferenceModel
1112
from agr_literature_service.api.models.editor_model import EditorModel
@@ -38,6 +39,7 @@
3839
from agr_literature_service.api.models.curation_status_model import CurationStatusModel
3940
from agr_literature_service.api.models.indexing_priority_model import IndexingPriorityModel
4041
from agr_literature_service.api.models.manual_indexing_tag_model import ManualIndexingTagModel
42+
from agr_literature_service.api.models.person_setting_model import PersonSettingModel
4143

4244
import logging
4345

agr_literature_service/api/models/person_model.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ class PersonModel(Base, AuditedModel):
2222
# Only keep these relationships
2323
emails = relationship("EmailModel", back_populates="person", cascade="all, delete-orphan")
2424
cross_references = relationship("PersonCrossReferenceModel", back_populates="person", cascade="all, delete-orphan")
25+
settings = relationship("PersonSettingModel", back_populates="person", cascade="all, delete-orphan")
2526

2627
__table_args__ = (
2728
UniqueConstraint("okta_id", name="uq_person_okta_id"),
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from sqlalchemy import (
2+
Column, Integer, String, Boolean, ForeignKey, Index
3+
)
4+
from sqlalchemy.orm import relationship
5+
from sqlalchemy.dialects.postgresql import JSONB
6+
from agr_literature_service.api.database.base import Base
7+
from agr_literature_service.api.models.audited_model import AuditedModel
8+
9+
10+
class PersonSettingModel(Base, AuditedModel):
11+
__tablename__ = "person_setting"
12+
13+
person_setting_id = Column(Integer, primary_key=True, autoincrement=True)
14+
15+
person_id = Column(
16+
Integer,
17+
ForeignKey("person.person_id", ondelete="CASCADE"),
18+
nullable=False,
19+
index=True,
20+
)
21+
22+
component_name = Column(String(), nullable=False, index=True)
23+
setting_name = Column(String(), nullable=False) # user_given_name
24+
25+
# “Only one True per (person_id, component_name)” is enforced by a partial unique index (see Alembic)
26+
default_setting = Column(Boolean, nullable=False, default=False, server_default="false")
27+
28+
json_settings = Column(JSONB, nullable=False, server_default="{}")
29+
30+
# relationships
31+
person = relationship("PersonModel", back_populates="settings")
32+
33+
__table_args__ = (
34+
Index("ix_person_setting_person_component", "person_id", "component_name"),
35+
Index(
36+
"uq_person_setting_one_default",
37+
"person_id",
38+
"component_name",
39+
unique=True,
40+
postgresql_where=(default_setting.is_(True)),
41+
),
42+
)
43+
44+
def __str__(self) -> str:
45+
return f"{self.person_id}:{self.component_name} [{self.setting_name}] (default={self.default_setting})"

0 commit comments

Comments
 (0)