Skip to content

Commit 66c9fdf

Browse files
authored
Feature Panel - Backend (#283)
1 parent 3c22c8c commit 66c9fdf

File tree

9 files changed

+599
-0
lines changed

9 files changed

+599
-0
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ dmypy.json
147147
.pyre/
148148

149149
# mac env
150+
.DS_Store
150151
bin
151152

152153
# register stuff

AUTHORS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
* PureDreamer - Developer
3434
* ShiZinDle - Developer
3535
* YairEn - Developer
36+
* LiranCaduri - Developer
3637
* IdanPelled - Developer
3738

3839
# Special thanks to

app/database/models.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,16 @@
3434
Base: DeclarativeMeta = declarative_base()
3535

3636

37+
class UserFeature(Base):
38+
__tablename__ = "user_feature"
39+
40+
id = Column(Integer, primary_key=True, index=True)
41+
feature_id = Column('feature_id', Integer, ForeignKey('features.id'))
42+
user_id = Column('user_id', Integer, ForeignKey('users.id'))
43+
44+
is_enable = Column(Boolean, default=False)
45+
46+
3747
class User(Base):
3848
__tablename__ = "users"
3949

@@ -69,6 +79,7 @@ class User(Base):
6979
)
7080
comments = relationship("Comment", back_populates="user")
7181

82+
features = relationship("Feature", secondary=UserFeature.__tablename__)
7283
oauth_credentials = relationship(
7384
"OAuthCredentials",
7485
cascade="all, delete",
@@ -85,6 +96,18 @@ async def get_by_username(db: Session, username: str) -> User:
8596
return db.query(User).filter(User.username == username).first()
8697

8798

99+
class Feature(Base):
100+
__tablename__ = "features"
101+
102+
id = Column(Integer, primary_key=True, index=True)
103+
name = Column(String, nullable=False)
104+
route = Column(String, nullable=False)
105+
creator = Column(String, nullable=True)
106+
description = Column(String, nullable=False)
107+
108+
users = relationship("User", secondary=UserFeature.__tablename__)
109+
110+
88111
class Event(Base):
89112
__tablename__ = "events"
90113

app/internal/features.py

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
from functools import wraps
2+
from typing import Dict, List
3+
4+
from fastapi import Depends, Request
5+
from sqlalchemy.orm import Session
6+
from sqlalchemy.sql import exists
7+
from starlette.responses import RedirectResponse
8+
9+
from app.database.models import Feature, UserFeature
10+
from app.dependencies import SessionLocal, get_db
11+
from app.internal.features_index import features
12+
from app.internal.security.dependencies import current_user
13+
from app.internal.security.ouath2 import get_authorization_cookie
14+
from app.internal.utils import create_model
15+
16+
17+
def feature_access_filter(call_next):
18+
@wraps(call_next)
19+
async def wrapper(*args, **kwargs):
20+
request = kwargs["request"]
21+
22+
if request.headers["user-agent"] == "testclient":
23+
# in case it's a unit test.
24+
return await call_next(*args, **kwargs)
25+
26+
# getting the url route path for matching with the database.
27+
route = "/" + str(request.url).replace(str(request.base_url), "")
28+
29+
# getting access status.
30+
access = await is_access_allowd(route=route, request=request)
31+
32+
if access:
33+
# in case the feature is enabled or access is allowed.
34+
return await call_next(*args, **kwargs)
35+
36+
elif "referer" not in request.headers:
37+
# in case request come straight from address bar in browser.
38+
return RedirectResponse(url="/")
39+
40+
# in case the feature is disabled or access isn't allowed.
41+
return RedirectResponse(url=request.headers["referer"])
42+
43+
return wrapper
44+
45+
46+
def create_features_at_startup(session: Session) -> bool:
47+
for feat in features:
48+
if not is_feature_exists(feature=feat, session=session):
49+
create_feature(**feat, db=session)
50+
return True
51+
52+
53+
def is_user_has_feature(
54+
session: Session,
55+
feature_id: int,
56+
user_id: int,
57+
) -> bool:
58+
return session.query(
59+
exists()
60+
.where(UserFeature.user_id == user_id)
61+
.where(UserFeature.feature_id == feature_id),
62+
).scalar()
63+
64+
65+
def delete_feature(
66+
feature: Feature,
67+
session: Session = Depends(get_db),
68+
) -> None:
69+
session.query(UserFeature).filter_by(feature_id=feature.id).delete()
70+
session.query(Feature).filter_by(id=feature.id).delete()
71+
session.commit()
72+
73+
74+
def is_feature_exists(feature: Dict[str, str], session: Session) -> bool:
75+
is_exists = session.query(
76+
exists()
77+
.where(Feature.name == feature["name"])
78+
.where(Feature.route == feature["route"]),
79+
).scalar()
80+
81+
return is_exists
82+
83+
84+
def update_feature(
85+
feature: Feature,
86+
feature_dict: Dict[str, str],
87+
session: Session = Depends(get_db),
88+
) -> Feature:
89+
feature.name = feature_dict["name"]
90+
feature.route = feature_dict["route"]
91+
feature.description = feature_dict["description"]
92+
feature.creator = feature_dict["creator"]
93+
session.commit()
94+
return feature
95+
96+
97+
async def is_access_allowd(request: Request, route: str) -> bool:
98+
session = SessionLocal()
99+
100+
# Get current user.
101+
# Note: can't use dependency beacause its designed for routes only.
102+
# current_user return schema not an db model.
103+
jwt = await get_authorization_cookie(request=request)
104+
user = await current_user(request=request, jwt=jwt, db=session)
105+
106+
feature = session.query(Feature).filter_by(route=route).first()
107+
108+
if feature is None:
109+
# in case there is no feature exists in the database that match the
110+
# route that gived by to the request.
111+
return True
112+
113+
user_feature = session.query(
114+
exists().where(
115+
(UserFeature.feature_id == feature.id)
116+
& (UserFeature.user_id == user.user_id),
117+
),
118+
).scalar()
119+
120+
return user_feature
121+
122+
123+
def create_feature(
124+
db: Session,
125+
name: str,
126+
route: str,
127+
description: str,
128+
creator: str = None,
129+
) -> Feature:
130+
"""Creates a feature."""
131+
return create_model(
132+
db,
133+
Feature,
134+
name=name,
135+
route=route,
136+
creator=creator,
137+
description=description,
138+
)
139+
140+
141+
def create_user_feature_association(
142+
db: Session,
143+
feature_id: int,
144+
user_id: int,
145+
is_enable: bool,
146+
) -> UserFeature:
147+
"""Creates an association."""
148+
return create_model(
149+
db,
150+
UserFeature,
151+
user_id=user_id,
152+
feature_id=feature_id,
153+
is_enable=is_enable,
154+
)
155+
156+
157+
def get_user_installed_features(
158+
user_id: int,
159+
session: Session = Depends(get_db),
160+
) -> List[Feature]:
161+
return (
162+
session.query(Feature)
163+
.join(UserFeature)
164+
.filter(UserFeature.user_id == user_id)
165+
.all()
166+
)
167+
168+
169+
def get_user_uninstalled_features(
170+
user_id: int,
171+
session: Session = Depends(get_db),
172+
) -> List[Feature]:
173+
return (
174+
session.query(Feature)
175+
.filter(
176+
Feature.id.notin_(
177+
session.query(UserFeature.feature_id).filter(
178+
UserFeature.user_id == user_id,
179+
),
180+
),
181+
)
182+
.all()
183+
)

app/internal/features_index.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
'''
2+
This file purpose is for developers to add their features to the database
3+
in one convenient place, every time the system loads up it's adding and
4+
updating the features in the features table in the database.
5+
6+
To update a feature, The developer needs to change the name or the route
7+
and let the system load, but not change both at the same time otherwise
8+
it will create junk and unnecessary duplicates.
9+
10+
* IMPORTANT - To enable features panel functionlity the developer must *
11+
* add the feature_access_filter decorator to ALL the feature routes *
12+
* Please see the example below. *
13+
14+
Enjoy and good luck :)
15+
'''
16+
17+
'''
18+
Example to feature stracture:
19+
20+
{
21+
"name": "<feature name - str>",
22+
"route": "/<the route like: /features - str>",
23+
"description": "<description - str>",
24+
"creator": "<creator name or nickname - str>"
25+
}
26+
'''
27+
28+
'''
29+
* IMPORTANT *
30+
31+
Example to decorator placement:
32+
33+
@router.get("/<my-route>")
34+
@feature_access_filter <---- just above def keyword!
35+
def my_cool_feature_route():
36+
....
37+
...
38+
some code.
39+
..
40+
.
41+
42+
'''
43+
44+
features = [
45+
{
46+
"name": "Google Sync",
47+
"route": "/google/sync",
48+
"description": "Sync Google Calendar events with Pylender",
49+
"creator": "Liran Caduri"
50+
},
51+
]

app/main.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@
1616
get_db,
1717
logger,
1818
templates,
19+
SessionLocal,
1920
)
2021
from app.internal import daily_quotes, json_data_loader
22+
import app.internal.features as internal_features
2123
from app.internal.languages import set_ui_language
2224
from app.internal.security.ouath2 import auth_exception_handler
2325
from app.routers.salary import routes as salary
@@ -66,6 +68,7 @@ def create_tables(engine, psql_environment):
6668
email,
6769
event,
6870
export,
71+
features,
6972
four_o_four,
7073
friendview,
7174
google_connect,
@@ -118,6 +121,7 @@ async def swagger_ui_redirect():
118121
email.router,
119122
event.router,
120123
export.router,
124+
features.router,
121125
four_o_four.router,
122126
friendview.router,
123127
google_connect.router,
@@ -143,6 +147,13 @@ async def swagger_ui_redirect():
143147
app.include_router(router)
144148

145149

150+
@app.on_event("startup")
151+
async def startup_event():
152+
session = SessionLocal()
153+
internal_features.create_features_at_startup(session=session)
154+
session.close()
155+
156+
146157
# TODO: I add the quote day to the home page
147158
# until the relevant calendar view will be developed.
148159
@app.get("/", include_in_schema=False)

0 commit comments

Comments
 (0)