Skip to content

Commit b9d6a7c

Browse files
ddey2longshuicy
andauthored
Implementation of feeds page (#1056)
* Implementation of feeds page * Implemented Create, edit and delete feed * Added feed page, fixed several issues. All working now * Imlemented FeedAuthorization, addressed backend CSS issue and feedbacks * missed file in previous commit * Removed index_name from backend and UI * missed change in prev commit * addressed comments * Adding restriction for min of 1 item for criteria and navigating to feeds page on deletion * adding restrictions for arrays to have atleast 1 item - rsjf/mui specific changes --------- Co-authored-by: Chen Wang <[email protected]>
1 parent e6724e8 commit b9d6a7c

32 files changed

+1480
-156
lines changed

backend/app/deps/authorization_deps.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from app.keycloak_auth import get_current_username, get_read_only_user
22
from app.models.authorization import AuthorizationDB, RoleType
33
from app.models.datasets import DatasetDB, DatasetStatus
4+
from app.models.feeds import FeedDB
45
from app.models.files import FileDB, FileStatus
56
from app.models.groups import GroupDB
67
from app.models.listeners import EventListenerDB
@@ -426,6 +427,37 @@ async def __call__(
426427
raise HTTPException(status_code=404, detail=f"Listener {listener_id} not found")
427428

428429

430+
class FeedAuthorization:
431+
"""We use class dependency so that we can provide the `permission` parameter to the dependency.
432+
For more info see https://fastapi.tiangolo.com/advanced/advanced-dependencies/.
433+
Regular users can only see their own feeds"""
434+
435+
# def __init__(self, optional_arg: str = None):
436+
# self.optional_arg = optional_arg
437+
438+
async def __call__(
439+
self,
440+
feed_id: str,
441+
current_user: str = Depends(get_current_username),
442+
admin_mode: bool = Depends(get_admin_mode),
443+
admin: bool = Depends(get_admin),
444+
):
445+
# If the current user is admin and has turned on admin_mode, user has access irrespective of any role assigned
446+
if admin and admin_mode:
447+
return True
448+
449+
# Else check if current user is the creator of the feed
450+
if (feed := await FeedDB.get(PydanticObjectId(feed_id))) is not None:
451+
if feed.creator and feed.creator == current_user:
452+
return True
453+
else:
454+
raise HTTPException(
455+
status_code=403,
456+
detail=f"User `{current_user} does not have permission on feed `{feed_id}`",
457+
)
458+
raise HTTPException(status_code=404, detail=f"Feed {feed_id} not found")
459+
460+
429461
class CheckStatus:
430462
"""We use class dependency so that we can provide the `permission` parameter to the dependency.
431463
For more info see https://fastapi.tiangolo.com/advanced/advanced-dependencies/."""

backend/app/heartbeat_listener_sync.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,17 @@ def callback(ch, method, properties, body):
2323

2424
extractor_info = msg["extractor_info"]
2525
extractor_name = extractor_info["name"]
26+
extractor_description = extractor_name
27+
if "description" in extractor_info:
28+
extractor_description = extractor_info["description"]
2629
extractor_db = EventListenerDB(
2730
**extractor_info, properties=ExtractorInfo(**extractor_info)
2831
)
2932

3033
mongo_client = MongoClient(settings.MONGODB_URL)
3134
db = mongo_client[settings.MONGO_DATABASE]
3235

33-
# check to see if extractor alredy exists
36+
# check to see if extractor already exists
3437
existing_extractor = db["listeners"].find_one({"name": msg["queue"]})
3538
if existing_extractor is not None:
3639
# Update existing listener
@@ -85,8 +88,8 @@ def callback(ch, method, properties, body):
8588
# TODO: Who should the author be for an auto-generated feed? Currently None.
8689
new_feed = FeedDB(
8790
name=extractor_name,
91+
description=extractor_description,
8892
search={
89-
"index_name": "file",
9093
"criteria": criteria_list,
9194
"mode": "or",
9295
},

backend/app/models/feeds.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,11 @@ class JobFeed(BaseModel):
1313
resources match the saved search criteria for the Feed."""
1414

1515
name: str
16+
description: str = ""
1617
search: SearchObject
1718
listeners: List[FeedListener] = []
1819

1920

20-
class FeedBase(JobFeed):
21-
description: str = ""
22-
23-
2421
class FeedIn(JobFeed):
2522
pass
2623

backend/app/models/search.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,11 @@ class SearchObject(BaseModel):
1414
"""This is a way to save a search (i.e. as a Feed).
1515
1616
Parameters:
17-
index_name -- which ES index to search
1817
criteria -- some number of field/operator/value tuples describing the search requirements
1918
mode -- and/or determines whether all of the criteria must match, or any of them
2019
original -- if the user originally performed a string search, their original text entry is preserved here
2120
"""
2221

23-
index_name: str
2422
criteria: List[SearchCriteria] = []
2523
mode: str = "and" # and / or
2624
original: Optional[str] # original un-parsed search string

backend/app/routers/feeds.py

Lines changed: 81 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,27 @@
1-
from typing import List, Optional
1+
from typing import Optional
22

3-
from app.deps.authorization_deps import ListenerAuthorization
3+
from app.deps.authorization_deps import FeedAuthorization, ListenerAuthorization
44
from app.keycloak_auth import get_current_user, get_current_username
55
from app.models.feeds import FeedDB, FeedIn, FeedOut
66
from app.models.files import FileOut
77
from app.models.listeners import EventListenerDB, FeedListener
8+
from app.models.pages import Paged, _construct_page_metadata, _get_page_query
89
from app.models.users import UserOut
910
from app.rabbitmq.listeners import submit_file_job
1011
from app.routers.authentication import get_admin, get_admin_mode
1112
from app.search.connect import check_search_result
1213
from beanie import PydanticObjectId
14+
from beanie.operators import Or, RegEx
1315
from fastapi import APIRouter, Depends, HTTPException
1416
from pika.adapters.blocking_connection import BlockingChannel
1517

1618
router = APIRouter()
1719

1820

1921
# TODO: Move this to MongoDB middle layer
20-
async def disassociate_listener_db(feed_id: str, listener_id: str):
22+
async def disassociate_listener_db(
23+
feed_id: str, listener_id: str, allows: bool = Depends(FeedAuthorization())
24+
):
2125
"""Remove a specific Event Listener from a feed. Does not delete either resource, just removes relationship.
2226
2327
This actually performs the database operations, and can be used by any endpoints that need this functionality.
@@ -71,34 +75,90 @@ async def save_feed(
7175
return feed.dict()
7276

7377

74-
@router.get("", response_model=List[FeedOut])
78+
@router.put("/{feed_id}", response_model=FeedOut)
79+
async def edit_feed(
80+
feed_id: str,
81+
feed_in: FeedIn,
82+
user=Depends(get_current_username),
83+
allow: bool = Depends(FeedAuthorization()),
84+
):
85+
"""Update the information about an existing Feed..
86+
87+
Arguments:
88+
feed_id -- UUID of the feed to be udpated
89+
feed_in -- JSON object including updated information
90+
"""
91+
feed = await FeedDB.get(PydanticObjectId(feed_id))
92+
if feed:
93+
# TODO: Refactor this with permissions checks etc.
94+
feed_update = feed_in.dict()
95+
if (
96+
not feed_update["name"]
97+
or not feed_update["search"]
98+
or len(feed_update["listeners"]) == 0
99+
):
100+
raise HTTPException(
101+
status_code=400,
102+
detail="Feed name/search/listeners can't be null or empty",
103+
)
104+
return
105+
feed.description = feed_update["description"]
106+
feed.name = feed_update["name"]
107+
feed.search = feed_update["search"]
108+
feed.listeners = feed_update["listeners"]
109+
try:
110+
await feed.save()
111+
return feed.dict()
112+
except Exception as e:
113+
raise HTTPException(status_code=500, detail=e.args[0])
114+
raise HTTPException(status_code=404, detail=f"listener {feed_id} not found")
115+
116+
117+
@router.get("", response_model=Paged)
75118
async def get_feeds(
76-
name: Optional[str] = None,
119+
searchTerm: Optional[str] = None,
77120
user=Depends(get_current_user),
78121
skip: int = 0,
79122
limit: int = 10,
123+
admin=Depends(get_admin),
124+
admin_mode=Depends(get_admin_mode),
80125
):
81126
"""Fetch all existing Feeds."""
82-
if name is not None:
83-
feeds = (
84-
await FeedDB.find(FeedDB.name == name)
85-
.sort(-FeedDB.created)
86-
.skip(skip)
87-
.limit(limit)
88-
.to_list()
89-
)
90-
else:
91-
feeds = (
92-
await FeedDB.find().sort(-FeedDB.created).skip(skip).limit(limit).to_list()
127+
criteria_list = []
128+
if not admin or not admin_mode:
129+
criteria_list.append(FeedDB.creator == user.email)
130+
if searchTerm is not None:
131+
criteria_list.append(
132+
Or(
133+
RegEx(field=FeedDB.name, pattern=searchTerm, options="i"),
134+
RegEx(field=FeedDB.description, pattern=searchTerm, options="i"),
135+
)
93136
)
94137

95-
return [feed.dict() for feed in feeds]
138+
feeds_and_count = (
139+
await FeedDB.find(
140+
*criteria_list,
141+
)
142+
.aggregate(
143+
[_get_page_query(skip, limit, sort_field="created", ascending=False)],
144+
)
145+
.to_list()
146+
)
147+
page_metadata = _construct_page_metadata(feeds_and_count, skip, limit)
148+
page = Paged(
149+
metadata=page_metadata,
150+
data=[
151+
FeedOut(id=item.pop("_id"), **item) for item in feeds_and_count[0]["data"]
152+
],
153+
)
154+
return page.dict()
96155

97156

98157
@router.get("/{feed_id}", response_model=FeedOut)
99158
async def get_feed(
100159
feed_id: str,
101160
user=Depends(get_current_user),
161+
allow: bool = Depends(FeedAuthorization()),
102162
):
103163
"""Fetch an existing saved search Feed."""
104164
if (feed := await FeedDB.get(PydanticObjectId(feed_id))) is not None:
@@ -107,15 +167,16 @@ async def get_feed(
107167
raise HTTPException(status_code=404, detail=f"Feed {feed_id} not found")
108168

109169

110-
@router.delete("/{feed_id}")
170+
@router.delete("/{feed_id}", response_model=FeedOut)
111171
async def delete_feed(
112172
feed_id: str,
113173
user=Depends(get_current_user),
174+
allow: bool = Depends(FeedAuthorization()),
114175
):
115176
"""Delete an existing saved search Feed."""
116177
if (feed := await FeedDB.get(PydanticObjectId(feed_id))) is not None:
117178
await feed.delete()
118-
return {"deleted": feed_id}
179+
return feed.dict()
119180
raise HTTPException(status_code=404, detail=f"Feed {feed_id} not found")
120181

121182

@@ -126,6 +187,7 @@ async def associate_listener(
126187
user=Depends(get_current_user),
127188
admin=Depends(get_admin),
128189
admin_mode=Depends(get_admin_mode),
190+
allow: bool = Depends(FeedAuthorization()),
129191
):
130192
"""Associate an existing Event Listener with a Feed, e.g. so it will be triggered on new Feed results.
131193

backend/app/search/connect.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,9 +159,9 @@ def check_search_result(es_client, file_out: FileOut, search_obj: SearchObject):
159159
match_list.append({"match": crit})
160160

161161
# TODO: This will need to be more complex to support other operators
162-
if search_obj.mode == "and":
162+
if search_obj.mode.lower() == "and":
163163
subquery = {"bool": {"must": match_list}}
164-
if search_obj.mode == "or":
164+
if search_obj.mode.lower() == "or":
165165
subquery = {"bool": {"should": match_list}}
166166

167167
# Wrap the normal criteria with restriction of file ID also

0 commit comments

Comments
 (0)