Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
34 changes: 34 additions & 0 deletions Alembic/versions/d4e5f6a7b8c9_add_open_to_projects_to_users.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""Add open_to_projects to users

Revision ID: d4e5f6a7b8c9
Revises: c1d2e3f4a5b6
Create Date: 2026-06-04 10:00:00.000000

"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = 'd4e5f6a7b8c9'
down_revision = 'c1d2e3f4a5b6'
branch_labels = None
depends_on = None


def upgrade() -> None:
# Distinct from is_active: "open to project work" availability.
# Default true so existing members stay visible/available on rollout.
op.add_column(
'users',
sa.Column(
'open_to_projects',
sa.Boolean(),
nullable=False,
server_default=sa.text('true'),
)
)


def downgrade() -> None:
op.drop_column('users', 'open_to_projects')
1 change: 1 addition & 0 deletions api/api_models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ class ProfileUpdate(BaseModel):
portfolio_url: Optional[str] = None
profile_pic_url: Optional[str] = None
stack_id: PositiveStackId = None # Must be positive integer or None; rejects -1, 0, etc.
open_to_projects: Optional[bool] = None # Availability for project work

model_config = ConfigDict(from_attributes=True)

Expand Down
32 changes: 31 additions & 1 deletion api/routes/profile_page.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
from datetime import datetime
from typing import List, Optional

from fastapi import APIRouter, Depends, File, Query, UploadFile, status
Expand Down Expand Up @@ -59,13 +60,42 @@ def get_all_profile(skill: str = Query(None), stack: str = Query(None),
None,
description="Filter users by status. Accepted values are defined by the UserStatus enum."
),
tags: Optional[List[str]] = Query(
None, description="Filter by tag name(s); matches users having ANY of the given tags."
),
skills: Optional[List[str]] = Query(
None, description="Filter by skill name(s); matches users having ANY of the given skills."
),
experience: Optional[List[str]] = Query(
None,
description="Experience level(s): entry (0-2), intermediate (3-5), expert (6+). Matches ANY."
),
min_experience: Optional[int] = Query(
None, ge=0, description="Minimum years of experience (inclusive)."
),
max_experience: Optional[int] = Query(
None, ge=0, description="Maximum years of experience (inclusive)."
),
open_to_projects: Optional[bool] = Query(
None, description="Filter by availability for project work."
),
created_after: Optional[datetime] = Query(
None, description="Only include users created on or after this timestamp (ISO 8601)."
),
db: Session = Depends(get_db), current_user: User = Depends(user_accepted)):
query = _service(db).build_search_query(
skill,
stack,
active,
p,
status.value if status is not None else None
status.value if status is not None else None,
tags,
min_experience,
max_experience,
open_to_projects,
skills,
experience,
created_after,
)
return paginate(db, query)

Expand Down
5 changes: 5 additions & 0 deletions api/routes/tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ def get_current_user_tags(user=Depends(user_accepted), db: Session = Depends(get
return _service(db).get_user_tags(user)


@tag_route.get("/tags/all")
def get_all_tags(user=Depends(user_accepted), db: Session = Depends(get_db)):
return _service(db).get_all_tags()


@tag_route.post("/tags", response_model=Tags, status_code=status.HTTP_201_CREATED)
def create_tag(tag: TagCreate, current_user=Depends(user_accepted),
db: Session = Depends(get_db)):
Expand Down
2 changes: 2 additions & 0 deletions db/models/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ class User(Base):
TIMESTAMP(timezone=True),
nullable=False, server_default=text('now()'), server_onupdate=text("now()"))
is_active = Column(Boolean)
# Availability for project work — distinct from is_active (account/directory status)
open_to_projects = Column(Boolean, nullable=False, server_default=text('true'))
status = Column(SQLAlchemyEnum(UserStatus), default=UserStatus.TO_CONTACT, nullable=False)

skills = relationship('Skill', secondary="users_skills", back_populates='users')
Expand Down
3 changes: 3 additions & 0 deletions db/repository/tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ def get_all_for_user(self, user_id: int) -> list[Tag]:
).first()
return user.tags if user else []

def get_all(self) -> list[Tag]:
return self.db.query(Tag).order_by(Tag.name).all()

def get_by_name(self, name: str) -> Optional[Tag]:
return self.db.query(Tag).filter(Tag.name == name).first()

Expand Down
56 changes: 55 additions & 1 deletion db/repository/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,34 @@
from api.api_models.user import UserSignUp
from db.models.users import User
from db.models.skills import Skill
from db.models.tags import Tag
from db.models import users_skills
from db.repository.base import BaseRepository

# Upwork-style experience bands: level name -> (min_years, max_years|None)
EXPERIENCE_LEVELS = {
"entry": (0, 2),
"intermediate": (3, 5),
"expert": (6, None),
}


def experience_level_filter(levels: list[str]):
"""OR together the year-ranges for the selected experience levels."""
from sqlalchemy import or_, and_

conditions = []
for level in levels:
bounds = EXPERIENCE_LEVELS.get(level.lower())
if not bounds:
continue
low, high = bounds
cond = User.years_of_experience >= low
if high is not None:
cond = and_(cond, User.years_of_experience <= high)
conditions.append(cond)
return or_(*conditions) if conditions else None


class UserRepository(BaseRepository):
model = User
Expand Down Expand Up @@ -66,16 +91,45 @@ def update_avatar(self, user: User, url: str) -> User:

def build_search_query(self, skill: Optional[str], stack: Optional[str],
active: Optional[bool], p: Optional[str],
status: Optional[str] = None):
status: Optional[str] = None,
tags: Optional[list[str]] = None,
min_experience: Optional[int] = None,
max_experience: Optional[int] = None,
open_to_projects: Optional[bool] = None,
skills: Optional[list[str]] = None,
experience_levels: Optional[list[str]] = None,
created_after=None):
from sqlalchemy import or_, func

query = select(User).order_by(desc(User.created_at))
if created_after is not None:
query = query.filter(User.created_at >= created_after)
if skill:
query = query.join(users_skills.UserSkill).join(Skill).filter(
Skill.name == skill.capitalize()
)
if skills:
# Match ANY of the selected skills; .any() avoids join-row duplication
skill_names = [s.capitalize() for s in skills if s]
if skill_names:
query = query.filter(User.skills.any(Skill.name.in_(skill_names)))
if stack:
query = query.filter(User.stack.has(name=stack.capitalize()))
if tags:
# Match ANY of the selected tags (case-insensitive); tags are stored lower-case
tag_names = [t.lower() for t in tags if t]
if tag_names:
query = query.filter(User.tags.any(Tag.name.in_(tag_names)))
if min_experience is not None:
query = query.filter(User.years_of_experience >= min_experience)
if max_experience is not None:
query = query.filter(User.years_of_experience <= max_experience)
if experience_levels:
level_filter = experience_level_filter(experience_levels)
if level_filter is not None:
query = query.filter(level_filter)
if open_to_projects is not None:
query = query.filter(User.open_to_projects.is_(open_to_projects))
if active is not None:
if active: # active=True means Directory
# Only show ACCEPTED + is_active users
Expand Down
3 changes: 3 additions & 0 deletions services/tag_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ def get_user_tags(self, user: User) -> dict:
tags = self.tag_repo.get_all_for_user(user.id)
return {"tags": tags}

def get_all_tags(self) -> dict:
return {"tags": self.tag_repo.get_all()}

def create_tag(self, user: User, tag_name: str) -> Tag:
existing_tag = self.tag_repo.get_by_name(tag_name)
if existing_tag:
Expand Down
15 changes: 13 additions & 2 deletions services/user_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,19 @@ def update_profile(self, user_id: int, update_data: dict) -> User:

def build_search_query(self, skill: Optional[str], stack: Optional[str],
active: Optional[bool], p: Optional[str],
status: Optional[str] = None):
return self.user_repo.build_search_query(skill, stack, active, p, status)
status: Optional[str] = None,
tags: Optional[list[str]] = None,
min_experience: Optional[int] = None,
max_experience: Optional[int] = None,
open_to_projects: Optional[bool] = None,
skills: Optional[list[str]] = None,
experience_levels: Optional[list[str]] = None,
created_after=None):
return self.user_repo.build_search_query(
skill, stack, active, p, status,
tags, min_experience, max_experience, open_to_projects, skills,
experience_levels, created_after
)

def activate_user(self, user_id: int) -> User:
user = self.user_repo.get_by_id(user_id)
Expand Down
Loading