diff --git a/Alembic/versions/d4e5f6a7b8c9_add_open_to_projects_to_users.py b/Alembic/versions/d4e5f6a7b8c9_add_open_to_projects_to_users.py new file mode 100644 index 0000000..d2e570f --- /dev/null +++ b/Alembic/versions/d4e5f6a7b8c9_add_open_to_projects_to_users.py @@ -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') diff --git a/api/api_models/user.py b/api/api_models/user.py index 63b96d2..bf76f9e 100644 --- a/api/api_models/user.py +++ b/api/api_models/user.py @@ -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) diff --git a/api/routes/profile_page.py b/api/routes/profile_page.py index 95ea95a..b4fbade 100644 --- a/api/routes/profile_page.py +++ b/api/routes/profile_page.py @@ -1,4 +1,5 @@ import logging +from datetime import datetime from typing import List, Optional from fastapi import APIRouter, Depends, File, Query, UploadFile, status @@ -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) diff --git a/api/routes/tags.py b/api/routes/tags.py index 025a704..8057a1d 100644 --- a/api/routes/tags.py +++ b/api/routes/tags.py @@ -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)): diff --git a/db/models/users.py b/db/models/users.py index 071deee..782c4e3 100644 --- a/db/models/users.py +++ b/db/models/users.py @@ -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') diff --git a/db/repository/tags.py b/db/repository/tags.py index a2dc7cc..560fc9f 100644 --- a/db/repository/tags.py +++ b/db/repository/tags.py @@ -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() diff --git a/db/repository/users.py b/db/repository/users.py index 445fa84..da2a4fc 100644 --- a/db/repository/users.py +++ b/db/repository/users.py @@ -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 @@ -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 diff --git a/services/tag_service.py b/services/tag_service.py index 5b665cf..5d3a37d 100644 --- a/services/tag_service.py +++ b/services/tag_service.py @@ -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: diff --git a/services/user_service.py b/services/user_service.py index d128243..644cff1 100644 --- a/services/user_service.py +++ b/services/user_service.py @@ -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)