Skip to content

Commit 303fbe8

Browse files
authored
Merge branch 'LabConnect-RCOS:main' into labconnect-backend-aniket
2 parents e88935a + 8ed288b commit 303fbe8

File tree

9 files changed

+424
-9
lines changed

9 files changed

+424
-9
lines changed

.gitignore

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,9 @@ env/
66
*.db
77
.coverage
88
*.vscode
9-
.env
9+
.env
10+
11+
labconnect/bin
12+
labconnect/include
13+
labconnect/lib
14+
labconnect/pyvenv.cfg

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,10 @@ posted by professors, graduate students, or lab staff.</p>
112112
```bash
113113
$ python3 -m pytest --cov
114114
```
115+
or individual tests
116+
```bash
117+
$ python3 -m pytest -q tests/(file_name).py
118+
```
115119

116120
## Development
117121
* Run flask with python directly

docs/CONTRIBUTING.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ If this is something you think you can fix, then fork `main` and create a branch
1616
A good branch name would be (where issue #123 is the ticket you're working on):
1717

1818
```sh
19-
git checkout -b 123-add-japanese-translations
19+
git switch -c 123-add-japanese-translations
2020
```
2121

2222
### Working with open source projects
@@ -50,4 +50,4 @@ Follow normal guidelines for git commits, limiting the length and adding extra d
5050

5151
For Pull Requests please include an issue number or details on what is changed in the pull request.
5252

53-
Please comment code at least every 10 lines to explain what code does. Use more for more complicated sections of code.
53+
Please comment code at least every 10 lines to explain what code does. Use more for more complicated sections of code.

labconnect/main/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,4 @@
66

77
main_blueprint = Blueprint("main", __name__)
88

9-
from . import auth_routes, opportunity_routes, routes # noqa
9+
from . import auth_routes, opportunity_routes, routes, profile_routes # noqa

labconnect/main/opportunity_routes.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,58 @@
2727
from . import main_blueprint
2828

2929

30+
def opportunity_to_dict(opportunity: Opportunities) -> dict:
31+
"""Return a plain dict representation of an Opportunities model instance."""
32+
if opportunity is None:
33+
return {}
34+
35+
return {
36+
"id": opportunity.id,
37+
"name": opportunity.name,
38+
"description": opportunity.description,
39+
"recommended_experience": opportunity.recommended_experience,
40+
"pay": opportunity.pay,
41+
"one_credit": bool(opportunity.one_credit),
42+
"two_credits": bool(opportunity.two_credits),
43+
"three_credits": bool(opportunity.three_credits),
44+
"four_credits": bool(opportunity.four_credits),
45+
"semester": str(opportunity.semester)
46+
if opportunity.semester is not None
47+
else None,
48+
"year": opportunity.year,
49+
"active": bool(opportunity.active),
50+
}
51+
52+
53+
# Single opportunity endpoints used by the frontend/tests
54+
@main_blueprint.get("/opportunity/<int:opportunity_id>")
55+
def get_single_opportunity(opportunity_id: int):
56+
"""Return a single opportunity by id. Returns 404 if not found"""
57+
opp = db.session.get(Opportunities, opportunity_id)
58+
if not opp:
59+
abort(404)
60+
61+
return opportunity_to_dict(opp)
62+
63+
64+
@main_blueprint.get("/opportunity")
65+
def get_opportunity_via_json():
66+
"""GET /opportunity expects a JSON payload with {"id": <int>}."""
67+
data = request.get_json()
68+
opp_id_raw = data.get("id") if data else None
69+
70+
try:
71+
opp_id = int(opp_id_raw)
72+
except (ValueError, TypeError):
73+
abort(400, description="Invalid or missing 'id' in JSON payload.")
74+
75+
opp = db.session.get(Opportunities, opp_id)
76+
if not opp:
77+
abort(404, description="Opportunity not found.")
78+
79+
return opportunity_to_dict(opp)
80+
81+
3082
@main_blueprint.get("/searchOpportunity/<string:query>")
3183
def searchOpportunity(query: str):
3284
# Perform a search

labconnect/main/profile_routes.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
from flask import Response, jsonify, request
2+
from flask_jwt_extended import get_jwt_identity, jwt_required
3+
4+
from labconnect import db
5+
from labconnect.models import Majors, RPIDepartments, User, UserDepartments, UserMajors
6+
7+
from . import main_blueprint
8+
9+
10+
def user_to_dict(user: User) -> dict:
11+
"""Helper function to serialize User object data."""
12+
user_departments = (
13+
db.session.execute(
14+
db.select(UserDepartments.department_id).where(
15+
UserDepartments.user_id == user.id
16+
)
17+
)
18+
.scalars()
19+
.all()
20+
)
21+
22+
user_majors = (
23+
db.session.execute(
24+
db.select(UserMajors.major_code).where(UserMajors.user_id == user.id)
25+
)
26+
.scalars()
27+
.all()
28+
)
29+
30+
return {
31+
"id": user.id,
32+
"email": user.email,
33+
"first_name": user.first_name,
34+
"last_name": user.last_name,
35+
"preferred_name": user.preferred_name,
36+
"class_year": user.class_year,
37+
"profile_picture": user.profile_picture,
38+
"website": user.website,
39+
"description": user.description,
40+
"departments": user_departments,
41+
"majors": user_majors,
42+
}
43+
44+
45+
@main_blueprint.route("/profile", methods=["GET"])
46+
@jwt_required()
47+
def get_profile() -> Response:
48+
"""GET /profile: current user profile"""
49+
user_email = get_jwt_identity()
50+
user = db.session.execute(
51+
db.select(User).where(User.email == user_email)
52+
).scalar_one_or_none()
53+
54+
if not user:
55+
return {"msg": "User not found"}, 404
56+
57+
return jsonify(user_to_dict(user))
58+
59+
60+
@main_blueprint.route("/profile", methods=["PUT"])
61+
@jwt_required()
62+
def update_profile() -> Response:
63+
"""PUT /profile: Updates current user profile"""
64+
user_email = get_jwt_identity()
65+
user = db.session.execute(
66+
db.select(User).where(User.email == user_email)
67+
).scalar_one_or_none()
68+
69+
if not user:
70+
return {"msg": "User not found"}, 404
71+
72+
json_data = request.get_json()
73+
if not json_data:
74+
return {"msg": "Missing JSON in request"}, 400
75+
76+
# Update basic User fields
77+
user.first_name = json_data.get("first_name", user.first_name)
78+
user.last_name = json_data.get("last_name", user.last_name)
79+
user.preferred_name = json_data.get("preferred_name", user.preferred_name)
80+
user.class_year = json_data.get("class_year", user.class_year)
81+
user.website = json_data.get("website", user.website)
82+
user.description = json_data.get("description", user.description)
83+
84+
if "departments" in json_data:
85+
db.session.execute(
86+
db.delete(UserDepartments).where(UserDepartments.user_id == user.id)
87+
)
88+
89+
req_dept_ids = set(json_data["departments"])
90+
if req_dept_ids: # Only query if list is not empty
91+
valid_dept_ids = db.session.execute(
92+
db.select(RPIDepartments.id).where(
93+
RPIDepartments.id.in_(req_dept_ids)
94+
)
95+
).scalars().all()
96+
97+
for dept_id in valid_dept_ids: # Add only the valid ones
98+
new_user_dept = UserDepartments(user_id=user.id, department_id=dept_id)
99+
db.session.add(new_user_dept)
100+
101+
if "majors" in json_data:
102+
db.session.execute(
103+
db.delete(UserMajors).where(UserMajors.user_id == user.id)
104+
)
105+
106+
req_major_codes = set(json_data["majors"])
107+
if req_major_codes: # Only query if list is not empty
108+
valid_major_codes = db.session.execute(
109+
db.select(Majors.code).where(Majors.code.in_(req_major_codes))
110+
).scalars().all()
111+
112+
for major_code in valid_major_codes: # Add only the valid ones
113+
new_user_major = UserMajors(user_id=user.id, major_code=major_code)
114+
db.session.add(new_user_major)
115+
116+
db.session.commit()
117+
118+
return {"msg": "Profile updated successfully"}

requirements.txt

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
alembic==1.16.5
1+
alembic==1.17.1
22
blinker==1.9.0
33
certifi==2025.8.3
44
charset-normalizer==3.4.3
5-
click==8.1.8
5+
click==8.3.0
66
coverage==7.10.6
77
Flask==3.1.2
88
flask-cors==6.0.1
@@ -18,7 +18,7 @@ Jinja2==3.1.6
1818
lxml==6.0.2
1919
Mako==1.3.10
2020
MarkupSafe==3.0.2
21-
orjson==3.11.3
21+
orjson==3.11.4
2222
packaging==25.0
2323
pluggy==1.6.0
2424
psycopg2-binary==2.9.10
@@ -29,10 +29,10 @@ python-dotenv==1.1.1
2929
python3-saml==1.16.0
3030
pytz==2025.2
3131
requests==2.32.5
32-
ruff==0.13.2
32+
ruff==0.14.3
3333
sentry-sdk==2.39.0
3434
six==1.17.0
35-
SQLAlchemy==2.0.41
35+
SQLAlchemy==2.0.44
3636
typing_extensions==4.15.0
3737
urllib3==2.5.0
3838
Werkzeug==3.1.3

tests/test_profile_routes.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import json
2+
3+
from flask.testing import FlaskClient
4+
5+
from labconnect import db
6+
from labconnect.models import User, UserDepartments, UserMajors
7+
8+
9+
def login_as_student(test_client: FlaskClient):
10+
"""Helper function to log in a user and handle the auth flow."""
11+
response = test_client.get("/login")
12+
assert response.status_code == 302
13+
14+
redirect_url = response.headers["Location"]
15+
code = redirect_url.split("code=")[1]
16+
17+
token_response = test_client.post("/token", json={"code": code})
18+
assert token_response.status_code == 200
19+
20+
21+
# === GET /profile Tests ===
22+
23+
24+
def test_get_profile_success(test_client: FlaskClient):
25+
"""
26+
logged-in user: '/profile' endpoint is requested (GET)
27+
-> correct data and 200 status
28+
"""
29+
login_as_student(test_client)
30+
31+
response = test_client.get("/profile")
32+
data = json.loads(response.data)
33+
34+
assert response.status_code == 200
35+
assert data["email"] == "[email protected]"
36+
assert data["first_name"] == "Test"
37+
assert data["last_name"] == "User"
38+
assert "departments" in data
39+
assert "majors" in data
40+
41+
42+
def test_get_profile_unauthorized(test_client: FlaskClient):
43+
"""
44+
no user is logged in: '/profile' endpoint is requested (GET)
45+
-> 401 Unauthorized status is returned.
46+
"""
47+
test_client.get("/logout")
48+
response = test_client.get("/profile")
49+
assert response.status_code == 401
50+
51+
52+
# === PUT /profile Tests ===
53+
54+
55+
def test_update_profile_success(test_client: FlaskClient):
56+
"""
57+
logged-in user: '/profile' endpoint is updated with new data (PUT)
58+
-> 200 status and database changed.
59+
"""
60+
login_as_student(test_client)
61+
62+
update_data = {
63+
"first_name": "UpdatedFirst",
64+
"last_name": "UpdatedLast",
65+
"preferred_name": "Pref",
66+
"class_year": 2025,
67+
"website": "https://new.example.com",
68+
"description": "This is an updated description.",
69+
"departments": ["CS"],
70+
"majors": ["CSCI", "MATH"],
71+
}
72+
73+
response = test_client.put("/profile", json=update_data)
74+
assert response.status_code == 200
75+
assert "Profile updated successfully" in json.loads(response.data)["msg"]
76+
77+
# Verify the changes in the database
78+
user = db.session.execute(
79+
db.select(User).where(User.email == "[email protected]")
80+
).scalar_one()
81+
assert user.first_name == "UpdatedFirst"
82+
assert user.website == "https://new.example.com"
83+
assert user.class_year == 2025
84+
85+
user_depts = (
86+
db.session.execute(
87+
db.select(UserDepartments.department_id).where(
88+
UserDepartments.user_id == user.id
89+
)
90+
)
91+
.scalars()
92+
.all()
93+
)
94+
assert set(user_depts) == {"CS"}
95+
96+
user_majors = (
97+
db.session.execute(
98+
db.select(UserMajors.major_code).where(UserMajors.user_id == user.id)
99+
)
100+
.scalars()
101+
.all()
102+
)
103+
assert set(user_majors) == {"CSCI", "MATH"}
104+
105+
106+
def test_update_profile_partial(test_client: FlaskClient):
107+
"""
108+
logged-in user: '/profile' endpoint is updated with partial data (PUT)
109+
-> check only provided fields updated.
110+
"""
111+
login_as_student(test_client)
112+
113+
update_data = {
114+
"website": "https://partial.update.com",
115+
"description": "Only this was updated.",
116+
}
117+
118+
response = test_client.put("/profile", json=update_data)
119+
assert response.status_code == 200
120+
121+
user = db.session.execute(
122+
db.select(User).where(User.email == "[email protected]")
123+
).scalar_one()
124+
assert user.website == "https://partial.update.com"
125+
assert user.description == "Only this was updated."
126+
assert user.last_name == "User"
127+
128+
129+
def test_update_profile_unauthorized(test_client: FlaskClient):
130+
"""
131+
no user is logged in: '/profile' endpoint is sent a PUT request
132+
-> 401 Unauthorized status.
133+
"""
134+
test_client.get("/logout")
135+
update_data = {"first_name": "ShouldFail"}
136+
response = test_client.put("/profile", json=update_data)
137+
assert response.status_code == 401

0 commit comments

Comments
 (0)