Skip to content
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
2328150
API Endpoint: Update opportunity_routes.py
jaswanthDuddu Sep 29, 2025
474d3cf
API Endpoint: Update test_opportunity.py
jaswanthDuddu Sep 29, 2025
ce80f77
Single Opportunity: Update opportunity_routes.py
jaswanthDuddu Sep 29, 2025
0995fce
Single Opportunity: Update test_opportunity.py
jaswanthDuddu Sep 29, 2025
2a3cf8e
Update README.md
jaswanthDuddu Oct 3, 2025
071ee70
Merge branch 'LabConnect-RCOS:main' into main
jaswanthDuddu Oct 3, 2025
557cb0d
Fixes and Improvements for Single and All Opportunity Endpoints
jaswanthDuddu Oct 10, 2025
f9b2025
User Profile Management
jaswanthDuddu Oct 21, 2025
5e30938
User Profile Management Update
jaswanthDuddu Oct 21, 2025
87ff46e
User Profile Management Update Majors, Departments
jaswanthDuddu Oct 21, 2025
24b0590
Tests: User Profile Management endpoints
jaswanthDuddu Oct 21, 2025
75852a5
Merge branch 'LabConnect-RCOS:main' into main
jaswanthDuddu Oct 24, 2025
dcc07ff
Fixing Ruff Linter Errors
jaswanthDuddu Oct 24, 2025
62dab3c
Add profile management and single opportunity endpoints (#340)
WBroadwell Oct 24, 2025
80d5afc
Add profile_routes to main blueprint imports
jaswanthDuddu Oct 28, 2025
646b909
Update README.md
jaswanthDuddu Nov 3, 2025
58b4693
Refactor opportunity retrieval error handling
jaswanthDuddu Nov 3, 2025
2ddea05
Refactor user profile update responses and queries
jaswanthDuddu Nov 3, 2025
69c9dbf
Refactor SQLAlchemy queries to use select and delete
jaswanthDuddu Nov 3, 2025
6fd93ef
Merge branch 'main' into Single-Opportunity-and-User-Profile-Management
jaswanthDuddu Nov 3, 2025
c59099f
Refactor opportunity_to_dict tests for dynamic import
jaswanthDuddu Nov 3, 2025
4acbb9b
Revert "Refactor opportunity_to_dict tests for dynamic import"
jaswanthDuddu Nov 3, 2025
847487a
Refactor sqlalchemy functions
jaswanthDuddu Nov 4, 2025
2499177
Refactor test to fix Cyclic import
jaswanthDuddu Nov 4, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,9 @@ env/
*.db
.coverage
*.vscode
.env
.env

labconnect/bin
labconnect/include
labconnect/lib
labconnect/pyvenv.cfg
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ posted by professors, graduate students, or lab staff.</p>
```bash
$ python3 -m pytest --cov
```
or individual tests
```bash
$ python3 -m pytest -q tests/(file_name).py
```

## Development
* Run flask with python directly
Expand Down
2 changes: 1 addition & 1 deletion labconnect/main/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@

main_blueprint = Blueprint("main", __name__)

from . import auth_routes, opportunity_routes, routes # noqa
from . import auth_routes, opportunity_routes, routes, profile_routes # noqa
52 changes: 52 additions & 0 deletions labconnect/main/opportunity_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,58 @@
from . import main_blueprint


def opportunity_to_dict(opportunity: Opportunities) -> dict:
"""Return a plain dict representation of an Opportunities model instance."""
if opportunity is None:
return {}

return {
"id": opportunity.id,
"name": opportunity.name,
"description": opportunity.description,
"recommended_experience": opportunity.recommended_experience,
"pay": opportunity.pay,
"one_credit": bool(opportunity.one_credit),
"two_credits": bool(opportunity.two_credits),
"three_credits": bool(opportunity.three_credits),
"four_credits": bool(opportunity.four_credits),
"semester": str(opportunity.semester)
if opportunity.semester is not None
else None,
"year": opportunity.year,
"active": bool(opportunity.active),
}


# Single opportunity endpoints used by the frontend/tests
@main_blueprint.get("/opportunity/<int:opportunity_id>")
def get_single_opportunity(opportunity_id: int):
"""Return a single opportunity by id. Returns 404 if not found"""
opp = db.session.get(Opportunities, opportunity_id)
if not opp:
abort(404)

return opportunity_to_dict(opp)


@main_blueprint.get("/opportunity")
def get_opportunity_via_json():
"""GET /opportunity expects a JSON payload with {"id": <int>}."""
data = request.get_json()
opp_id_raw = data.get("id") if data else None

try:
opp_id = int(opp_id_raw)
except (ValueError, TypeError):
abort(400, description="Invalid or missing 'id' in JSON payload.")

opp = db.session.get(Opportunities, opp_id)
if not opp:
abort(404, description="Opportunity not found.")

return opportunity_to_dict(opp)


@main_blueprint.get("/searchOpportunity/<string:query>")
def searchOpportunity(query: str):
# Perform a search
Expand Down
120 changes: 120 additions & 0 deletions labconnect/main/profile_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
from flask import Response, jsonify, request
from flask_jwt_extended import get_jwt_identity, jwt_required

from sqlalchemy import select, delete

from labconnect import db
from labconnect.models import Majors, RPIDepartments, User, UserDepartments, UserMajors

from . import main_blueprint


def user_to_dict(user: User) -> dict:
"""Helper function to serialize User object data."""
user_departments = (
db.session.execute(
select(UserDepartments.department_id).where(
UserDepartments.user_id == user.id
)
)
.scalars()
.all()
)

user_majors = (
db.session.execute(
select(UserMajors.major_code).where(UserMajors.user_id == user.id)
)
.scalars()
.all()
)

return {
"id": user.id,
"email": user.email,
"first_name": user.first_name,
"last_name": user.last_name,
"preferred_name": user.preferred_name,
"class_year": user.class_year,
"profile_picture": user.profile_picture,
"website": user.website,
"description": user.description,
"departments": user_departments,
"majors": user_majors,
}


@main_blueprint.route("/profile", methods=["GET"])
@jwt_required()
def get_profile() -> Response:
"""GET /profile: current user profile"""
user_email = get_jwt_identity()
user = db.session.execute(
select(User).where(User.email == user_email)
).scalar_one_or_none()

if not user:
return {"msg": "User not found"}, 404

return jsonify(user_to_dict(user))


@main_blueprint.route("/profile", methods=["PUT"])
@jwt_required()
def update_profile() -> Response:
"""PUT /profile: Updates current user profile"""
user_email = get_jwt_identity()
user = db.session.execute(
select(User).where(User.email == user_email)
).scalar_one_or_none()

if not user:
return {"msg": "User not found"}, 404

json_data = request.get_json()
if not json_data:
return {"msg": "Missing JSON in request"}, 400

# Update basic User fields
user.first_name = json_data.get("first_name", user.first_name)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@WBroadwell not going to hold up this PR but would be good to make a ticket to validate and sanitze inputs on the frontend and backend.

Sanitization can be like this PR I have on another projects: https://github.com/alpha-phi-omega-ez/backend/pull/51/files#diff-b678c90e066bcc3eaff460004c1d8c094e9abc66acafe9226cbff14a85354882R24-R28

And validation just checkings like length limits like whats set in the database schema

user.last_name = json_data.get("last_name", user.last_name)
user.preferred_name = json_data.get("preferred_name", user.preferred_name)
user.class_year = json_data.get("class_year", user.class_year)
user.website = json_data.get("website", user.website)
user.description = json_data.get("description", user.description)

if "departments" in json_data:
db.session.execute(
delete(UserDepartments).where(UserDepartments.user_id == user.id)
)

req_dept_ids = set(json_data["departments"])
if req_dept_ids: # Only query if list is not empty
valid_dept_ids = db.session.execute(
select(RPIDepartments.id).where(
RPIDepartments.id.in_(req_dept_ids)
)
).scalars().all()

for dept_id in valid_dept_ids: # Add only the valid ones
new_user_dept = UserDepartments(user_id=user.id, department_id=dept_id)
db.session.add(new_user_dept)

if "majors" in json_data:
db.session.execute(
delete(UserMajors).where(UserMajors.user_id == user.id)
)

req_major_codes = set(json_data["majors"])
if req_major_codes: # Only query if list is not empty
valid_major_codes = db.session.execute(
select(Majors.code).where(Majors.code.in_(req_major_codes))
).scalars().all()

for major_code in valid_major_codes: # Add only the valid ones
new_user_major = UserMajors(user_id=user.id, major_code=major_code)
db.session.add(new_user_major)

db.session.commit()

return {"msg": "Profile updated successfully"}
137 changes: 137 additions & 0 deletions tests/test_profile_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import json

from flask.testing import FlaskClient

from labconnect import db
from labconnect.models import User, UserDepartments, UserMajors


def login_as_student(test_client: FlaskClient):
"""Helper function to log in a user and handle the auth flow."""
response = test_client.get("/login")
assert response.status_code == 302

redirect_url = response.headers["Location"]
code = redirect_url.split("code=")[1]

token_response = test_client.post("/token", json={"code": code})
assert token_response.status_code == 200


# === GET /profile Tests ===


def test_get_profile_success(test_client: FlaskClient):
"""
logged-in user: '/profile' endpoint is requested (GET)
-> correct data and 200 status
"""
login_as_student(test_client)

response = test_client.get("/profile")
data = json.loads(response.data)

assert response.status_code == 200
assert data["email"] == "[email protected]"
assert data["first_name"] == "Test"
assert data["last_name"] == "User"
assert "departments" in data
assert "majors" in data


def test_get_profile_unauthorized(test_client: FlaskClient):
"""
no user is logged in: '/profile' endpoint is requested (GET)
-> 401 Unauthorized status is returned.
"""
test_client.get("/logout")
response = test_client.get("/profile")
assert response.status_code == 401


# === PUT /profile Tests ===


def test_update_profile_success(test_client: FlaskClient):
"""
logged-in user: '/profile' endpoint is updated with new data (PUT)
-> 200 status and database changed.
"""
login_as_student(test_client)

update_data = {
"first_name": "UpdatedFirst",
"last_name": "UpdatedLast",
"preferred_name": "Pref",
"class_year": 2025,
"website": "https://new.example.com",
"description": "This is an updated description.",
"departments": ["CS"],
"majors": ["CSCI", "MATH"],
}

response = test_client.put("/profile", json=update_data)
assert response.status_code == 200
assert "Profile updated successfully" in json.loads(response.data)["msg"]

# Verify the changes in the database
user = db.session.execute(
db.select(User).where(User.email == "[email protected]")
).scalar_one()
assert user.first_name == "UpdatedFirst"
assert user.website == "https://new.example.com"
assert user.class_year == 2025

user_depts = (
db.session.execute(
db.select(UserDepartments.department_id).where(
UserDepartments.user_id == user.id
)
)
.scalars()
.all()
)
assert set(user_depts) == {"CS"}

user_majors = (
db.session.execute(
db.select(UserMajors.major_code).where(UserMajors.user_id == user.id)
)
.scalars()
.all()
)
assert set(user_majors) == {"CSCI", "MATH"}


def test_update_profile_partial(test_client: FlaskClient):
"""
logged-in user: '/profile' endpoint is updated with partial data (PUT)
-> check only provided fields updated.
"""
login_as_student(test_client)

update_data = {
"website": "https://partial.update.com",
"description": "Only this was updated.",
}

response = test_client.put("/profile", json=update_data)
assert response.status_code == 200

user = db.session.execute(
db.select(User).where(User.email == "[email protected]")
).scalar_one()
assert user.website == "https://partial.update.com"
assert user.description == "Only this was updated."
assert user.last_name == "User"


def test_update_profile_unauthorized(test_client: FlaskClient):
"""
no user is logged in: '/profile' endpoint is sent a PUT request
-> 401 Unauthorized status.
"""
test_client.get("/logout")
update_data = {"first_name": "ShouldFail"}
response = test_client.put("/profile", json=update_data)
assert response.status_code == 401
Loading
Loading